Temporary setup of alternative to advertisements
This commit is contained in:
parent
ad2d932842
commit
57a9793a40
14 changed files with 653 additions and 56 deletions
85
test/ad_placeholder_screen_test.dart
Normal file
85
test/ad_placeholder_screen_test.dart
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:fuel_tax_tracker/widgets/ad_placeholder_screen.dart';
|
||||
|
||||
void main() {
|
||||
/// Pushes the placeholder onto a real navigator so its self-dismissal
|
||||
/// (which pops its own route) has something to pop.
|
||||
///
|
||||
/// Deliberately avoids [WidgetTester.pumpAndSettle] while the
|
||||
/// auto-dismiss ticker is alive — that timer reschedules a frame every
|
||||
/// second, so settling would spin until it timed out rather than
|
||||
/// returning.
|
||||
Future<void> pushPlaceholder(WidgetTester tester, {required bool mustConfirm}) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => AdPlaceholderScreen(mustConfirm: mustConfirm),
|
||||
),
|
||||
),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
}
|
||||
|
||||
group('auto-dismissing placeholder (report + fuel-save interstitials)', () {
|
||||
testWidgets('shows the logo and the future-ad message, with no Continue button',
|
||||
(tester) async {
|
||||
await pushPlaceholder(tester, mustConfirm: false);
|
||||
|
||||
expect(find.text('ADVERTISEMENT'), findsOneWidget);
|
||||
expect(find.byType(Image), findsOneWidget, reason: 'the app logo');
|
||||
expect(find.text('An advertisement will appear here'), findsOneWidget);
|
||||
expect(find.textContaining('Ads are not running yet'), findsOneWidget);
|
||||
expect(find.text('Continue'), findsNothing,
|
||||
reason: 'only the sync gate waits on the user');
|
||||
|
||||
// Let it close so the ticker is cancelled before the test ends.
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('counts down and closes itself after four seconds', (tester) async {
|
||||
await pushPlaceholder(tester, mustConfirm: false);
|
||||
|
||||
expect(find.textContaining('Closing in'), findsOneWidget);
|
||||
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
expect(find.text('ADVERTISEMENT'), findsOneWidget,
|
||||
reason: 'still up three seconds in');
|
||||
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('ADVERTISEMENT'), findsNothing, reason: 'closed itself');
|
||||
expect(find.text('open'), findsOneWidget, reason: 'back on the page underneath');
|
||||
});
|
||||
});
|
||||
|
||||
group('confirming placeholder (the cloud-sync gate)', () {
|
||||
testWidgets('waits for Continue instead of closing on its own', (tester) async {
|
||||
await pushPlaceholder(tester, mustConfirm: true);
|
||||
|
||||
expect(find.text('Continue'), findsOneWidget);
|
||||
expect(find.textContaining('Closing in'), findsNothing,
|
||||
reason: 'nothing counts down — the upload waits on the user');
|
||||
|
||||
await tester.pump(const Duration(seconds: 10));
|
||||
expect(find.text('ADVERTISEMENT'), findsOneWidget,
|
||||
reason: 'still up well past the auto-dismiss window');
|
||||
|
||||
await tester.tap(find.text('Continue'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('ADVERTISEMENT'), findsNothing);
|
||||
expect(find.text('open'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
173
test/fuel_save_ad_gate_test.dart
Normal file
173
test/fuel_save_ad_gate_test.dart
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:fuel_tax_tracker/app_navigator.dart';
|
||||
import 'package:fuel_tax_tracker/models/fuel_entry.dart';
|
||||
import 'package:fuel_tax_tracker/services/app_state.dart';
|
||||
|
||||
/// Covers [AppState.maybeShowFuelSaveAd]'s "when" decision — the grace
|
||||
/// periods and watch-credits that sit in front of the fuel-save ad — by
|
||||
/// calling it directly against a hand-built [AppState] rather than through
|
||||
/// a real save, which would need an initialized database.
|
||||
///
|
||||
/// With `adsEnabled` false (lib/services/ad_config.dart), the underlying
|
||||
/// [AdService.maybeShowFuelSaveAd] always resolves to showing the
|
||||
/// placeholder, so what's under test here is purely the gating above it:
|
||||
/// if a test sees no placeholder, a gate stopped it.
|
||||
///
|
||||
/// These push against the real [navigatorKey], since that's what
|
||||
/// `showAdPlaceholder` pushes onto — an app that doesn't wire it up would
|
||||
/// make the placeholder silently do nothing, which is itself one of the
|
||||
/// failure modes worth pinning down.
|
||||
void main() {
|
||||
/// Mirrors a well-used install: past the new-user window and well past
|
||||
/// the first few saves, with no ad-free purchase.
|
||||
AppState buildEstablishedUser({int entries = 9}) {
|
||||
return AppState()
|
||||
..firstUsedAt = DateTime.now().subtract(const Duration(days: 3))
|
||||
..adFreeUntil = null
|
||||
..fuelEntries = List.generate(
|
||||
entries,
|
||||
(i) => FuelEntry(
|
||||
id: 'e$i',
|
||||
vehicleId: 'v1',
|
||||
date: DateTime.now(),
|
||||
gallons: 13.405,
|
||||
pricePerGallon: 3.899,
|
||||
totalCost: 52.27,
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> pumpHost(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
navigatorKey: navigatorKey,
|
||||
home: const Scaffold(body: Text('receipts')),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Runs one save's worth of the gate to completion and reports what it
|
||||
/// decided, running [whileShowing] at the point a placeholder would be
|
||||
/// on screen.
|
||||
///
|
||||
/// The gate's future is deliberately *not* awaited up front: when a
|
||||
/// placeholder does show, that future only completes once the
|
||||
/// placeholder pops itself, and it's [WidgetTester.pump] that advances
|
||||
/// the timer doing the popping — awaiting first deadlocks the two
|
||||
/// against each other.
|
||||
Future<bool> runGate(
|
||||
WidgetTester tester,
|
||||
AppState appState, {
|
||||
void Function()? whileShowing,
|
||||
}) async {
|
||||
bool? result;
|
||||
unawaited(appState.maybeShowFuelSaveAd().then((value) => result = value));
|
||||
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
|
||||
whileShowing?.call();
|
||||
|
||||
// Enough for the placeholder's four-second auto-dismiss, harmless
|
||||
// when nothing came up.
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(result, isNotNull, reason: 'the gate should have settled by now');
|
||||
return result!;
|
||||
}
|
||||
|
||||
testWidgets('an established user gets the ad on save', (tester) async {
|
||||
await pumpHost(tester);
|
||||
final appState = buildEstablishedUser();
|
||||
|
||||
final shown = await runGate(
|
||||
tester,
|
||||
appState,
|
||||
whileShowing: () => expect(find.text('ADVERTISEMENT'), findsOneWidget),
|
||||
);
|
||||
|
||||
expect(shown, isTrue, reason: 'the caller needs this to hold back the backup reminder');
|
||||
expect(find.text('receipts'), findsOneWidget, reason: 'placeholder closed itself');
|
||||
});
|
||||
|
||||
testWidgets('cloud backup being unconfigured does not suppress it', (tester) async {
|
||||
// The regression this is really here for: the gate used to bail out
|
||||
// whenever backup wasn't set up, which meant a user who never
|
||||
// configured backup never saw this placement at all.
|
||||
await pumpHost(tester);
|
||||
final appState = buildEstablishedUser();
|
||||
expect(appState.hasCloudBackupConfigured, isFalse, reason: 'no cloudSync wired up');
|
||||
|
||||
final shown = await runGate(
|
||||
tester,
|
||||
appState,
|
||||
whileShowing: () => expect(find.text('ADVERTISEMENT'), findsOneWidget),
|
||||
);
|
||||
|
||||
expect(shown, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('no ad during the first five minutes of app usage', (tester) async {
|
||||
await pumpHost(tester);
|
||||
final appState = buildEstablishedUser()
|
||||
..firstUsedAt = DateTime.now().subtract(const Duration(minutes: 2));
|
||||
|
||||
final shown = await runGate(
|
||||
tester,
|
||||
appState,
|
||||
whileShowing: () => expect(find.text('ADVERTISEMENT'), findsNothing),
|
||||
);
|
||||
|
||||
expect(shown, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('no ad for the first four saves', (tester) async {
|
||||
await pumpHost(tester);
|
||||
final appState = buildEstablishedUser(entries: 4);
|
||||
|
||||
final shown = await runGate(
|
||||
tester,
|
||||
appState,
|
||||
whileShowing: () => expect(find.text('ADVERTISEMENT'), findsNothing),
|
||||
);
|
||||
|
||||
expect(shown, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('an active ad-free purchase suppresses it', (tester) async {
|
||||
await pumpHost(tester);
|
||||
final appState = buildEstablishedUser()
|
||||
..adFreeUntil = DateTime.now().toUtc().add(const Duration(days: 300));
|
||||
|
||||
final shown = await runGate(
|
||||
tester,
|
||||
appState,
|
||||
whileShowing: () => expect(find.text('ADVERTISEMENT'), findsNothing),
|
||||
);
|
||||
|
||||
expect(shown, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('the credit bought by one ad covers the next two saves', (tester) async {
|
||||
await pumpHost(tester);
|
||||
final appState = buildEstablishedUser();
|
||||
|
||||
expect(await runGate(tester, appState), isTrue, reason: 'due');
|
||||
expect(await runGate(tester, appState), isFalse, reason: 'credited save 1');
|
||||
expect(await runGate(tester, appState), isFalse, reason: 'credited save 2');
|
||||
|
||||
final fourth = await runGate(
|
||||
tester,
|
||||
appState,
|
||||
whileShowing: () => expect(find.text('ADVERTISEMENT'), findsOneWidget),
|
||||
);
|
||||
|
||||
expect(fourth, isTrue, reason: 'credit spent — due again');
|
||||
});
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:fuel_tax_tracker/models/fuel_entry.dart';
|
||||
|
|
@ -215,37 +216,59 @@ void main() {
|
|||
|
||||
testWidgets('date range selector filters totals and the list, and recolors the active label',
|
||||
(tester) async {
|
||||
// Aug 5 2026: within Current Month, Last 3 Months, and Year to Date.
|
||||
// These dates are derived from "now" rather than written out, because
|
||||
// the screen filters against DateTime.now() (see _DateRange.startDate).
|
||||
// Fixed dates quietly change which bucket they land in as the calendar
|
||||
// moves — an earlier version of this test hardcoded August dates and
|
||||
// started failing the moment September arrived.
|
||||
final now = DateTime.now();
|
||||
final format = DateFormat.yMMMd();
|
||||
|
||||
// The 1st of this month: always inside all three ranges.
|
||||
final recentDate = DateTime(now.year, now.month, 1);
|
||||
// The 1st of last month: always inside Last 3 Months, never inside
|
||||
// Current Month. Also inside Year to Date except in January, when
|
||||
// "last month" lands in the previous year.
|
||||
final midDate = DateTime(now.year, now.month - 1, 1);
|
||||
final midInYearToDate = midDate.year == now.year;
|
||||
// Jan 1 this year: always inside Year to Date. It only falls *outside*
|
||||
// Last 3 Months from April onward — before that, Last 3 Months already
|
||||
// reaches back to Jan 1 or earlier, so no date at all can be "Year to
|
||||
// Date but not Last 3 Months" and that distinction isn't testable.
|
||||
final oldDate = DateTime(now.year, 1, 1);
|
||||
final oldOutsideLast3Months = oldDate.isBefore(DateTime(now.year, now.month - 2, 1));
|
||||
// In January the 1st of this month *is* Jan 1, so these two would
|
||||
// render the same date text and findsOneWidget would double-count.
|
||||
final oldIsDistinctFromRecent = oldDate != recentDate;
|
||||
|
||||
final recentEntry = FuelEntry(
|
||||
id: 'recent',
|
||||
vehicleId: 'v1',
|
||||
date: DateTime(2026, 8, 5),
|
||||
date: recentDate,
|
||||
gallons: 10,
|
||||
pricePerGallon: 3,
|
||||
totalCost: 30,
|
||||
updatedAt: DateTime.utc(2026, 8, 5),
|
||||
updatedAt: recentDate.toUtc(),
|
||||
receiptDriveFileId: 'r1',
|
||||
);
|
||||
// Jul 1 2026: within Last 3 Months and Year to Date, not Current Month.
|
||||
final midEntry = FuelEntry(
|
||||
id: 'mid',
|
||||
vehicleId: 'v1',
|
||||
date: DateTime(2026, 7, 1),
|
||||
date: midDate,
|
||||
gallons: 20,
|
||||
pricePerGallon: 3,
|
||||
totalCost: 60,
|
||||
updatedAt: DateTime.utc(2026, 7, 1),
|
||||
updatedAt: midDate.toUtc(),
|
||||
receiptDriveFileId: 'r2',
|
||||
);
|
||||
// Mar 1 2026: within Year to Date only.
|
||||
final oldEntry = FuelEntry(
|
||||
id: 'old',
|
||||
vehicleId: 'v1',
|
||||
date: DateTime(2026, 3, 1),
|
||||
date: oldDate,
|
||||
gallons: 40,
|
||||
pricePerGallon: 3,
|
||||
totalCost: 120,
|
||||
updatedAt: DateTime.utc(2026, 3, 1),
|
||||
updatedAt: oldDate.toUtc(),
|
||||
receiptDriveFileId: 'r3',
|
||||
);
|
||||
final appState = AppState()
|
||||
|
|
@ -264,27 +287,38 @@ void main() {
|
|||
.widgetList<Text>(find.text(label))
|
||||
.any((t) => t.style?.fontWeight == FontWeight.w800);
|
||||
|
||||
// Defaults to Year to Date: all three visible.
|
||||
expect(find.text('Aug 5, 2026'), findsOneWidget);
|
||||
expect(find.text('Jul 1, 2026'), findsOneWidget);
|
||||
expect(find.text('Mar 1, 2026'), findsOneWidget);
|
||||
// Defaults to Year to Date.
|
||||
expect(find.text(format.format(recentDate)), findsOneWidget);
|
||||
if (midInYearToDate) {
|
||||
expect(find.text(format.format(midDate)), findsOneWidget);
|
||||
}
|
||||
if (oldIsDistinctFromRecent) {
|
||||
expect(find.text(format.format(oldDate)), findsOneWidget);
|
||||
}
|
||||
expect(isBold('Year to Date'), isTrue, reason: 'selected by default');
|
||||
|
||||
await tester.tap(find.text('Last 3 Months'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Aug 5, 2026'), findsOneWidget);
|
||||
expect(find.text('Jul 1, 2026'), findsOneWidget);
|
||||
expect(find.text('Mar 1, 2026'), findsNothing, reason: 'older than 3 months back');
|
||||
expect(find.text(format.format(recentDate)), findsOneWidget);
|
||||
expect(find.text(format.format(midDate)), findsOneWidget,
|
||||
reason: 'last month is always within the last three');
|
||||
if (oldOutsideLast3Months) {
|
||||
expect(find.text(format.format(oldDate)), findsNothing,
|
||||
reason: 'older than three months back');
|
||||
}
|
||||
expect(isBold('Last 3 Months'), isTrue);
|
||||
expect(isBold('Year to Date'), isFalse, reason: 'no longer selected');
|
||||
|
||||
await tester.tap(find.text('Current Month'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Aug 5, 2026'), findsOneWidget);
|
||||
expect(find.text('Jul 1, 2026'), findsNothing, reason: 'not in the current month');
|
||||
expect(find.text('Mar 1, 2026'), findsNothing);
|
||||
expect(find.text(format.format(recentDate)), findsOneWidget);
|
||||
expect(find.text(format.format(midDate)), findsNothing,
|
||||
reason: 'not in the current month');
|
||||
if (oldIsDistinctFromRecent) {
|
||||
expect(find.text(format.format(oldDate)), findsNothing);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue