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 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 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'); }); }