A whole lot of stuff

This commit is contained in:
Courtney Arnold 2026-08-18 11:04:58 -05:00
parent faf319322a
commit ad850d2be6
50 changed files with 3013 additions and 213 deletions

View file

@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:fuel_tax_tracker/services/app_state.dart';
import 'package:fuel_tax_tracker/widgets/ad_free_upsell_dialog.dart';
/// Covers the "remove ads for a year" upsell shown right after a user
/// watches the app's one ad placement — see `AppState.syncNow`'s
/// `onAdWatched` hook, wired up in main.dart. Tested here as a standalone
/// dialog function, matching backup_reminder_test.dart's convention.
void main() {
Future<void> openDialog(WidgetTester tester, AppState appState) async {
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: MaterialApp(
home: Builder(
builder: (context) => ElevatedButton(
onPressed: () => showAdFreeUpsellDialog(context),
child: const Text('open'),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
}
testWidgets('shows the pitch with both actions', (tester) async {
await openDialog(tester, AppState());
expect(find.text('Tired of Ads?'), findsOneWidget);
expect(find.text('Not Now'), findsOneWidget);
expect(find.text('Remove Ads'), findsOneWidget);
});
testWidgets('falls back to generic copy when no price is loaded yet', (tester) async {
await openDialog(tester, AppState());
expect(find.textContaining('a single one-time purchase'), findsOneWidget);
expect(find.textContaining('\$'), findsNothing);
});
testWidgets('Not Now closes the dialog without navigating anywhere', (tester) async {
await openDialog(tester, AppState());
await tester.tap(find.text('Not Now'));
await tester.pumpAndSettle();
expect(find.text('Tired of Ads?'), findsNothing);
});
testWidgets('Remove Ads closes the dialog and starts the purchase flow', (tester) async {
final appState = AppState();
await openDialog(tester, appState);
await tester.tap(find.text('Remove Ads'));
await tester.pumpAndSettle();
expect(find.text('Tired of Ads?'), findsNothing, reason: 'dialog closed');
// buyAdFreeYear() delegates to PurchaseService, which — with no real
// store connection in tests — surfaces its failure through
// AppState.purchaseError rather than throwing, so tapping through is
// enough to confirm the button actually invoked it without crashing.
expect(appState.purchaseError, isNotNull);
});
}

View file

@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:fuel_tax_tracker/screens/data_settings_screen.dart';
import 'package:fuel_tax_tracker/screens/faq_screen.dart';
import 'package:fuel_tax_tracker/services/app_state.dart';
import 'package:fuel_tax_tracker/services/cloud/cloud_storage_provider.dart';
import 'package:fuel_tax_tracker/services/cloud_sync_service.dart';
@ -44,10 +45,40 @@ void main() {
await openDialog(tester);
expect(find.text("Your Data Isn't Backed Up"), findsOneWidget);
expect(find.text('Learn More'), findsOneWidget);
expect(find.text('Not Now'), findsOneWidget);
expect(find.text('Set Up Backup'), findsOneWidget);
});
testWidgets('Learn More resolves false, closes the dialog, and opens the FAQ to the '
'backup question', (tester) async {
bool? result;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) => ElevatedButton(
onPressed: () async => result = await showBackupReminderDialog(context),
child: const Text('open'),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
await tester.tap(find.text('Learn More'));
await tester.pumpAndSettle();
expect(result, isFalse);
expect(find.text("Your Data Isn't Backed Up"), findsNothing, reason: 'dialog closed');
expect(find.byType(FaqScreen), findsOneWidget);
await tester.scrollUntilVisible(find.text('Is my data backed up?'), 300,
scrollable: find.byType(Scrollable));
expect(find.textContaining('Not by default'), findsOneWidget,
reason: 'backup question starts expanded');
});
testWidgets('Not Now resolves false and closes the dialog', (tester) async {
bool? result;
await tester.pumpWidget(

View file

@ -180,10 +180,14 @@ void main() {
syncService.configure('app-folder-id');
final result = await syncService.syncNow(
beforeUpload: () async => calls.add('beforeUpload'),
beforeUpload: () async {
calls.add('beforeUpload');
return true;
},
);
expect(result.ranSync, isTrue);
expect(result.adGateBlocked, isFalse);
expect(calls.first, 'download', reason: 'pull/merge happens first');
expect(calls[1], 'beforeUpload');
// Everything after beforeUpload is an upload (the data-file push,
@ -200,12 +204,39 @@ void main() {
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
syncService.configure('app-folder-id');
await syncService.syncNow(beforeUpload: () async => calls.add('beforeUpload'));
await syncService.syncNow(
beforeUpload: () async {
calls.add('beforeUpload');
return true;
},
);
expect(calls.first, 'beforeUpload');
expect(calls, contains('upload:$dataFileName'));
});
test('a false return blocks the upload but keeps whatever was already pulled/merged',
() async {
final calls = <String>[];
final remoteDbBytes = await File(databaseService.databasePath).readAsBytes();
final session = _FakeSession();
session.existingDataFile = CloudFileInfo(id: 'remote-db-id', versionTag: 'remote-v1');
session.remoteDbBytes = remoteDbBytes;
session.onDownload = () => calls.add('download');
session.onUpload = (name) => calls.add('upload:$name');
final provider = _FakeProvider(session);
final syncService = CloudSyncService(provider: provider, databaseService: databaseService);
syncService.configure('app-folder-id');
final result = await syncService.syncNow(beforeUpload: () async => false);
expect(result.ranSync, isFalse);
expect(result.adGateBlocked, isTrue);
expect(calls, ['download'], reason: 'pull/merge still ran; nothing was uploaded');
expect(session.deletedFileIds, ['lock-id'],
reason: 'the lock must still be released when the gate blocks the upload');
});
test('omitting it changes nothing — every other syncNow call site keeps working', () async {
final session = _FakeSession();
final provider = _FakeProvider(session);

View file

@ -23,6 +23,8 @@ import 'package:fuel_tax_tracker/services/database_service.dart';
/// other upsert in this app (see [DatabaseService.saveVehicle]).
void main() {
final vehicle = Vehicle(id: 'v1', vin: '1FMPU18L1TLB51349', updatedAt: DateTime.utc(2026, 1, 1));
final otherVehicle =
Vehicle(id: 'v2', vin: '1HGCM82633A004352', nickname: 'Second Car', updatedAt: DateTime.utc(2026, 1, 1));
final entry = FuelEntry(
id: 'e1',
vehicleId: 'v1',
@ -83,6 +85,25 @@ void main() {
final rawRow = (await database.rawDb.query('fuel_entries', where: 'id = ?', whereArgs: ['e1'])).single;
expect(rawRow['dirty'], 1);
});
test('saving again with a different vehicleId moves the entry to that vehicle', () async {
await database.saveVehicle(vehicle);
await database.saveVehicle(otherVehicle);
await database.saveFuelEntry(entry);
await database.saveFuelEntry(FuelEntry(
id: entry.id,
vehicleId: otherVehicle.id,
date: entry.date,
gallons: entry.gallons,
pricePerGallon: entry.pricePerGallon,
totalCost: entry.totalCost,
updatedAt: DateTime.utc(2026, 3, 6),
));
final saved = (await database.getFuelEntries()).single;
expect(saved.vehicleId, otherVehicle.id);
});
});
group('EditFuelEntryScreen', () {
@ -142,6 +163,50 @@ void main() {
expect(find.text('Required'), findsOneWidget);
expect(find.byType(EditFuelEntryScreen), findsOneWidget, reason: 'did not navigate away');
});
testWidgets('shows a vehicle picker pre-selected to the entry\'s current vehicle',
(tester) async {
final appState = AppState()
..isLoading = false
..vehicles = [vehicle, otherVehicle]
..fuelEntries = [entry];
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: MaterialApp(home: EditFuelEntryScreen(entry: entry)),
),
);
expect(find.widgetWithText(DropdownButtonFormField<String>, vehicle.displayLabel),
findsOneWidget);
});
testWidgets('picking a different vehicle updates the selection shown', (tester) async {
final appState = AppState()
..isLoading = false
..vehicles = [vehicle, otherVehicle]
..fuelEntries = [entry];
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: MaterialApp(home: EditFuelEntryScreen(entry: entry)),
),
);
await tester.tap(find.byType(DropdownButtonFormField<String>));
await tester.pumpAndSettle();
// Two matches once the menu is open: the closed field's current
// selection, and the same label repeated as a menu item.
await tester.tap(find.text(otherVehicle.displayLabel).last);
await tester.pumpAndSettle();
expect(find.widgetWithText(DropdownButtonFormField<String>, otherVehicle.displayLabel),
findsOneWidget);
expect(find.widgetWithText(DropdownButtonFormField<String>, vehicle.displayLabel),
findsNothing);
});
});
}

View file

@ -0,0 +1,29 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:fuel_tax_tracker/services/estimated_refund.dart';
void main() {
test('the rate itself is 12.5 cents per gallon', () {
expect(moHighwayFuelTaxRefundRatePerGallon, 0.125);
});
test('multiplies whole gallons by the rate', () {
expect(estimatedFuelRefund(10), 1.25);
expect(estimatedFuelRefund(20), 2.50);
expect(estimatedFuelRefund(0), 0);
});
test('rounds fractional gallons to the nearest whole gallon before multiplying', () {
// 10.523 rounds up to 11 gallons: 11 * $0.125 = $1.375, floored to $1.37.
expect(estimatedFuelRefund(10.523), 1.37);
// 10.4 rounds down to 10 gallons: 10 * $0.125 = $1.25 exactly.
expect(estimatedFuelRefund(10.4), 1.25);
});
test('rounds the resulting cents down, never up', () {
// 1 gallon * $0.125 = $0.125 — rounding to the nearest cent would give
// $0.13, but this must floor to $0.12 instead.
expect(estimatedFuelRefund(1), 0.12);
// 3 gallons * $0.125 = $0.375 — floors to $0.37, not $0.38.
expect(estimatedFuelRefund(3), 0.37);
});
}

104
test/faq_screen_test.dart Normal file
View file

@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fuel_tax_tracker/screens/faq_screen.dart';
/// Covers [FaqScreen] itself (question list, collapsed-by-default,
/// deep-linking a specific entry open) and [FaqActionButton], the shared
/// "?" AppBar action reused across every tab — see settings_screen_test.dart
/// for the Settings landing page's own FAQ entry, and
/// backup_reminder_test.dart for the "Learn More" link into a specific
/// entry.
void main() {
testWidgets('every question starts collapsed with no initiallyExpandedQuestion given',
(tester) async {
await tester.pumpWidget(const MaterialApp(home: FaqScreen()));
expect(find.text('What is the Missouri Motor Fuel Tax Refund?'), findsOneWidget);
expect(find.textContaining('12.5¢ per gallon'), findsNothing);
});
testWidgets('groups questions under section headers', (tester) async {
await tester.pumpWidget(const MaterialApp(home: FaqScreen()));
// The first section header is visible without scrolling; the last
// one only reachable by scrolling all the way down — checking both
// confirms headers span the whole list, not just its start.
expect(find.text('Missouri Motor Fuel Tax Refund'), findsOneWidget);
await tester.scrollUntilVisible(find.text('Advertisements'), 300,
scrollable: find.byType(Scrollable));
expect(find.text('Advertisements'), findsOneWidget);
});
testWidgets('tapping a question expands its answer', (tester) async {
await tester.pumpWidget(const MaterialApp(home: FaqScreen()));
await tester.scrollUntilVisible(find.text('How do I remove ads?'), 300,
scrollable: find.byType(Scrollable));
// scrollUntilVisible stops as soon as any part is on-screen, which can
// leave it right at the bottom edge — nudge further so its center
// (what tap() targets) is comfortably inside the viewport.
await tester.drag(find.byType(Scrollable), const Offset(0, -100));
await tester.pumpAndSettle();
await tester.tap(find.text('How do I remove ads?'));
await tester.pumpAndSettle();
expect(find.textContaining("doesn't auto-renew"), findsOneWidget);
});
testWidgets('includes a contact email for problem receipts/VINs/errors', (tester) async {
await tester.pumpWidget(const MaterialApp(home: FaqScreen()));
await tester.scrollUntilVisible(
find.text("Found a receipt, VIN, or error the app couldn't handle?"), 300,
scrollable: find.byType(Scrollable));
await tester.tap(find.text("Found a receipt, VIN, or error the app couldn't handle?"));
await tester.pumpAndSettle();
expect(find.textContaining('ohbrer+ShowMeTheFuelRefund@gmail.com'), findsOneWidget);
});
testWidgets('the refund program question covers both highway and non-highway refunds',
(tester) async {
await tester.pumpWidget(const MaterialApp(home: FaqScreen()));
await tester.tap(find.text('What is the Missouri Motor Fuel Tax Refund?'));
await tester.pumpAndSettle();
expect(find.textContaining('Highway use — up to 12.5¢ per gallon'), findsOneWidget);
expect(find.textContaining('Non-highway use — up to 29.5¢ per gallon'), findsOneWidget);
expect(find.textContaining("isn't tax advice"), findsOneWidget);
expect(find.textContaining('consult a tax professional'), findsOneWidget);
});
testWidgets('initiallyExpandedQuestion starts that one entry open, others collapsed',
(tester) async {
await tester.pumpWidget(
const MaterialApp(home: FaqScreen(initiallyExpandedQuestion: 'Is my data backed up?')),
);
await tester.pumpAndSettle();
// Its section (Data Safety) is further down the page now that
// questions are grouped — scroll to it before checking its content.
await tester.scrollUntilVisible(find.text('Is my data backed up?'), 300,
scrollable: find.byType(Scrollable));
expect(find.textContaining('Not by default'), findsOneWidget);
expect(find.textContaining('12.5¢ per gallon'), findsNothing,
reason: 'only the targeted question starts expanded');
});
testWidgets('FaqActionButton pushes FaqScreen', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(appBar: AppBar(actions: const [FaqActionButton()])),
),
);
await tester.tap(find.byIcon(Icons.help_outline));
await tester.pumpAndSettle();
expect(find.byType(FaqScreen), findsOneWidget);
});
}

View file

@ -278,4 +278,35 @@ void main() {
expect(byMonth[DateTime(2026, 3)]!.map((re) => re.entry.id), ['mar-1']);
expect(byMonth[DateTime(2026, 4)]!.map((re) => re.entry.id), ['apr-1']);
});
group('defaultReportDateRange', () {
test('from August through year-end, defaults to July of that year through the following June',
() {
final (start, end) = defaultReportDateRange(DateTime(2026, 8, 17));
expect(start, DateTime(2026, 7, 1));
expect(end, DateTime(2027, 6, 30));
});
test('from January through July, still defaults to the July before through this June', () {
final (start, end) = defaultReportDateRange(DateTime(2027, 7, 15));
expect(start, DateTime(2026, 7, 1));
expect(end, DateTime(2027, 6, 30));
});
test('the next cycle (August the following year) rolls the whole range forward a year', () {
final (start, end) = defaultReportDateRange(DateTime(2027, 8, 1));
expect(start, DateTime(2027, 7, 1));
expect(end, DateTime(2028, 6, 30));
});
test('July 31 is still the old cycle; August 1 is the new one', () {
final stillOld = defaultReportDateRange(DateTime(2026, 7, 31));
expect(stillOld.$1, DateTime(2025, 7, 1));
expect(stillOld.$2, DateTime(2026, 6, 30));
final nowNew = defaultReportDateRange(DateTime(2026, 8, 1));
expect(nowNew.$1, DateTime(2026, 7, 1));
expect(nowNew.$2, DateTime(2027, 6, 30));
});
});
}

View file

@ -65,6 +65,69 @@ void main() {
expect(find.text('Red Truck'), findsOneWidget);
});
testWidgets(
'shows an estimated fuel refund in the hero banner and next to each row\'s gallons '
'when the setting is on', (tester) async {
final entry = FuelEntry(
id: 'e1',
vehicleId: 'v1',
date: DateTime(2026, 5, 14),
gallons: 10.523,
pricePerGallon: 3.5,
totalCost: 32.48,
updatedAt: DateTime.utc(2026, 5, 14),
receiptDriveFileId: 'remote-1',
);
final appState = AppState()
..isLoading = false
..vehicles = [vehicle]
..fuelEntries = [entry]
..showEstimatedFuelRefund = true;
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: const MaterialApp(home: ReceiptsScreen()),
),
);
expect(find.text('Est. Refund'), findsOneWidget, reason: 'replaces the hero icon');
// 10.523 gal rounds up to 11, * $0.125/gal = $1.375, floored to $1.37 —
// the hero's own standalone stat value...
expect(find.text('\$1.37'), findsOneWidget);
// ...and the same figure again, folded into the row's combined text.
expect(find.textContaining('10.523 gal · Est. \$1.37'), findsOneWidget);
});
testWidgets("doesn't show an estimated fuel refund anywhere when the setting is off (default)",
(tester) async {
final entry = FuelEntry(
id: 'e1',
vehicleId: 'v1',
date: DateTime(2026, 5, 14),
gallons: 10.523,
pricePerGallon: 3.5,
totalCost: 32.48,
updatedAt: DateTime.utc(2026, 5, 14),
receiptDriveFileId: 'remote-1',
);
final appState = AppState()
..isLoading = false
..vehicles = [vehicle]
..fuelEntries = [entry];
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: const MaterialApp(home: ReceiptsScreen()),
),
);
expect(appState.showEstimatedFuelRefund, isFalse, reason: 'off by default');
expect(find.text('Est. Refund'), findsNothing);
expect(find.textContaining('Est. \$'), findsNothing);
});
testWidgets('tapping a receipted row opens the receipt detail page', (tester) async {
final entry = FuelEntry(
id: 'e1',

View file

@ -7,9 +7,11 @@ import 'package:fuel_tax_tracker/models/vehicle.dart';
import 'package:fuel_tax_tracker/screens/report_screen.dart';
import 'package:fuel_tax_tracker/services/app_state.dart';
/// Covers the per-vehicle selection cards on the Reports tab: once a date
/// range is picked, every vehicle with a receipted entry in range starts
/// selected (colored like the "Share" button), tapping one to deselect it
/// Covers the per-vehicle selection cards on the Reports tab: with a date
/// range in place (now defaulted automatically — see
/// defaultReportDateRange/ReportScreen.initState), every vehicle with a
/// receipted entry in range starts selected (colored like the "Share"
/// button), tapping one to deselect it
/// drops it from the total (and, by extension, the generated report) and
/// reverts its coloring to a plain unselected card, and deselecting every
/// vehicle shows a distinct "nothing selected" message rather than "no
@ -19,10 +21,10 @@ void main() {
final vehicleA = Vehicle(id: 'a', vin: 'VINAAAAAAAAAAAAAA', nickname: 'Truck', updatedAt: DateTime.utc(2026, 1, 1));
final vehicleB = Vehicle(id: 'b', vin: 'VINBBBBBBBBBBBBBB', nickname: 'Van', updatedAt: DateTime.utc(2026, 1, 1));
// showDatePicker's initial date (when none is set yet) defaults to
// "now" — dating these entries to "now" too, rather than a fixed date,
// means just accepting that default on both pickers (below) already
// covers them, without needing to drive the calendar UI to a specific day.
// ReportScreen's date range now defaults to the current fuel-tax-refund
// period (see defaultReportDateRange) as soon as it opens — dating these
// entries to "now" means they always fall inside that default range,
// without these tests needing to drive the date pickers themselves.
final now = DateTime.now();
FuelEntry entryFor(String vehicleId, String id, double gallons, double cost) => FuelEntry(
@ -36,26 +38,15 @@ void main() {
receiptDriveFileId: 'remote-$id',
);
Future<void> pickRange(WidgetTester tester) async {
await tester.tap(find.text('Start date'));
await tester.pumpAndSettle();
await tester.tap(find.text('OK'));
await tester.pumpAndSettle();
await tester.tap(find.text('End date'));
await tester.pumpAndSettle();
await tester.tap(find.text('OK'));
await tester.pumpAndSettle();
}
Future<AppState> pumpReport(WidgetTester tester) async {
Future<AppState> pumpReport(WidgetTester tester, {bool showEstimatedFuelRefund = false}) async {
final appState = AppState()
..isLoading = false
..vehicles = [vehicleA, vehicleB]
..fuelEntries = [
entryFor('a', 'e1', 10, 35),
entryFor('b', 'e2', 20, 70),
];
]
..showEstimatedFuelRefund = showEstimatedFuelRefund;
await tester.pumpWidget(
ChangeNotifierProvider.value(
@ -73,7 +64,6 @@ void main() {
testWidgets('both vehicles start colored as selected once a range with entries for both is picked',
(tester) async {
await pumpReport(tester);
await pickRange(tester);
final colorScheme = Theme.of(tester.element(find.text('Truck'))).colorScheme;
@ -82,10 +72,28 @@ void main() {
expect(find.text('30.000 gal · \$105.00'), findsOneWidget, reason: 'combined total');
});
testWidgets(
'shows an estimated fuel refund below each vehicle\'s total cost and next to the '
'combined total, when the setting is on', (tester) async {
await pumpReport(tester, showEstimatedFuelRefund: true);
// Truck: 10 gal * $0.125/gal = $1.25. Van: 20 gal * $0.125/gal = $2.50.
expect(find.text('Est. \$1.25'), findsOneWidget);
expect(find.text('Est. \$2.50'), findsOneWidget);
// Combined: 30 gal * $0.125/gal = $3.75, folded into the Total row.
expect(find.text('30.000 gal · \$105.00 · Est. \$3.75'), findsOneWidget);
});
testWidgets('shows no estimated fuel refund anywhere when the setting is off (default)',
(tester) async {
await pumpReport(tester);
expect(find.textContaining('Est. \$'), findsNothing);
});
testWidgets('tapping a selected vehicle deselects it (reverts its coloring) and drops it from the total',
(tester) async {
await pumpReport(tester);
await pickRange(tester);
final colorScheme = Theme.of(tester.element(find.text('Truck'))).colorScheme;
@ -100,7 +108,6 @@ void main() {
testWidgets('tapping a deselected vehicle again reselects it (restores its coloring)',
(tester) async {
await pumpReport(tester);
await pickRange(tester);
final colorScheme = Theme.of(tester.element(find.text('Truck'))).colorScheme;
@ -113,10 +120,24 @@ void main() {
expect(find.text('30.000 gal · \$105.00'), findsOneWidget, reason: 'both counted again');
});
testWidgets('tapping Preview opens the report preview screen', (tester) async {
await pumpReport(tester);
await tester.scrollUntilVisible(find.text('Preview'), 300, scrollable: find.byType(Scrollable));
await tester.tap(find.text('Preview'));
// Not pumpAndSettle: PdfPreview's own internal rendering isn't under
// test here (no platform channel mocked for it) — just that Preview
// actually navigates. A couple of bounded pumps are enough for that
// navigation (a synchronous PDF build, no plugin calls) to land.
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('Report Preview'), findsOneWidget);
});
testWidgets('deselecting every vehicle shows a distinct "nothing selected" message',
(tester) async {
await pumpReport(tester);
await pickRange(tester);
await tester.tap(find.text('Truck'));
await tester.pumpAndSettle();
@ -127,6 +148,7 @@ void main() {
find.text('No vehicles selected — tap at least one above to build a report.'),
findsOneWidget,
);
expect(find.text('Preview'), findsNothing);
expect(find.text('Share'), findsNothing);
expect(find.text('Print'), findsNothing);
// The cards themselves stay visible so the user can tap one again.

View file

@ -3,8 +3,10 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:fuel_tax_tracker/screens/data_settings_screen.dart';
import 'package:fuel_tax_tracker/screens/faq_screen.dart';
import 'package:fuel_tax_tracker/screens/settings_screen.dart';
import 'package:fuel_tax_tracker/screens/ui_settings_screen.dart';
import 'package:fuel_tax_tracker/screens/user_agreement_screen.dart';
import 'package:fuel_tax_tracker/services/app_state.dart';
/// Covers the Settings landing page's menu wiring: it groups settings into
@ -24,11 +26,15 @@ void main() {
);
}
testWidgets('shows UI and Data menu entries, plus the summary', (tester) async {
testWidgets('shows UI, Data, FAQ, and User Agreement menu entries, plus the summary',
(tester) async {
await pumpSettings(tester);
expect(find.text('UI'), findsOneWidget);
expect(find.text('Data'), findsOneWidget);
expect(find.text('FAQ'), findsOneWidget);
expect(find.text('User Agreement'), findsOneWidget);
await tester.scrollUntilVisible(find.text('Summary'), 300, scrollable: find.byType(Scrollable));
expect(find.text('Summary'), findsOneWidget);
// None of Data's contents leaked onto the landing page.
expect(find.text('Cloud Storage'), findsNothing);
@ -56,4 +62,45 @@ void main() {
await tester.scrollUntilVisible(find.text('Advanced'), 200, scrollable: find.byType(Scrollable));
expect(find.text('Advanced'), findsOneWidget);
});
testWidgets('tapping FAQ opens FaqScreen with its questions collapsed by default',
(tester) async {
await pumpSettings(tester);
await tester.tap(find.text('FAQ'));
await tester.pumpAndSettle();
expect(find.byType(FaqScreen), findsOneWidget);
expect(find.text('What is the Missouri Motor Fuel Tax Refund?'), findsOneWidget);
// Answers are inside collapsed ExpansionTiles, not visible until tapped.
expect(find.textContaining('12.5¢ per gallon'), findsNothing);
await tester.tap(find.text('What is the Missouri Motor Fuel Tax Refund?'));
await tester.pumpAndSettle();
expect(find.textContaining('12.5¢ per gallon'), findsOneWidget);
});
testWidgets('tapping User Agreement opens it in review mode (no Decline/I Agree)',
(tester) async {
await pumpSettings(tester);
await tester.tap(find.text('User Agreement'));
await tester.pumpAndSettle();
expect(find.byType(UserAgreementScreen), findsOneWidget);
expect(find.widgetWithText(FilledButton, 'I Agree'), findsNothing);
expect(find.widgetWithText(OutlinedButton, 'Decline'), findsNothing);
});
testWidgets('Ads card offers both buying and restoring when ads are not disabled',
(tester) async {
await pumpSettings(tester);
// Not tapped — buyAdFreeYear/restoreAdFreeYear reach a real platform
// channel via PurchaseService, which isn't set up in this test; only
// confirming both entry points render.
expect(find.widgetWithText(FilledButton, 'Remove Ads for a Year'), findsOneWidget);
expect(find.widgetWithText(TextButton, 'Restore Purchase'), findsOneWidget);
});
}

View file

@ -61,4 +61,43 @@ void main() {
// already-persisted user settings by renaming it there.
expect(prefs.getString('theme_mode'), 'dark');
});
group('Estimated Fuel Refund toggle', () {
testWidgets('off by default, flips on and persists when tapped', (tester) async {
final appState = AppState()..isLoading = false;
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: const MaterialApp(home: UiSettingsScreen()),
),
);
expect(appState.showEstimatedFuelRefund, isFalse);
expect(tester.widget<SwitchListTile>(find.byType(SwitchListTile)).value, isFalse);
await tester.tap(find.text('Estimated Fuel Refund'));
await tester.pumpAndSettle();
expect(appState.showEstimatedFuelRefund, isTrue);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getBool('show_estimated_fuel_refund'), isTrue);
});
testWidgets('explanation mentions the Missouri Highway Fuel Tax Refund and the current rate',
(tester) async {
final appState = AppState()..isLoading = false;
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: const MaterialApp(home: UiSettingsScreen()),
),
);
expect(find.textContaining('Missouri Highway Fuel Tax Refund'), findsOneWidget);
expect(find.textContaining('current rate'), findsOneWidget);
});
});
}

View file

@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:fuel_tax_tracker/main.dart';
import 'package:fuel_tax_tracker/screens/faq_screen.dart';
import 'package:fuel_tax_tracker/screens/main_shell.dart';
import 'package:fuel_tax_tracker/screens/user_agreement_screen.dart';
import 'package:fuel_tax_tracker/services/app_state.dart';
@ -38,12 +39,49 @@ void main() {
testWidgets('shows the data-loss liability section and both actions', (tester) async {
await pumpScreen(tester);
await tester.scrollUntilVisible(
find.text('We Are Not Responsible for Your Data or Any Data Loss'), 300,
scrollable: find.byType(Scrollable));
expect(find.text('We Are Not Responsible for Your Data or Any Data Loss'), findsOneWidget);
expect(find.textContaining('developer is not responsible for your data'), findsOneWidget);
expect(find.widgetWithText(FilledButton, 'I Agree'), findsOneWidget);
expect(find.widgetWithText(OutlinedButton, 'Decline'), findsOneWidget);
});
testWidgets('shows the privacy section disclosing no tracking and AdMob-only data collection',
(tester) async {
await pumpScreen(tester);
expect(find.text('Privacy'), findsOneWidget);
expect(find.textContaining("don't track or log your data"), findsOneWidget);
expect(find.textContaining('Google AdMob'), findsOneWidget);
});
testWidgets(
'shows both refund types and a not-tax-advice / consult-a-professional disclaimer',
(tester) async {
await pumpScreen(tester);
await tester.scrollUntilVisible(find.text('Not Tax or Legal Advice'), 300,
scrollable: find.byType(Scrollable));
expect(find.text('Not Tax or Legal Advice'), findsOneWidget);
expect(find.textContaining('12.5¢ per gallon for highway use'), findsOneWidget);
expect(find.textContaining('29.5¢ per gallon for non-highway use'), findsOneWidget);
expect(find.textContaining('Nothing here is tax or legal advice'), findsOneWidget);
expect(find.textContaining('consult a tax professional'), findsOneWidget);
});
testWidgets('See the FAQ for more opens FaqScreen', (tester) async {
await pumpScreen(tester);
await tester.scrollUntilVisible(find.text('See the FAQ for more'), 200,
scrollable: find.byType(Scrollable));
await tester.tap(find.text('See the FAQ for more'));
await tester.pumpAndSettle();
expect(find.byType(FaqScreen), findsOneWidget);
});
testWidgets('I Agree marks the agreement accepted and persists it', (tester) async {
final appState = await pumpScreen(tester);
@ -78,6 +116,56 @@ void main() {
expect(appState.hasAcceptedUserAgreement, isFalse);
});
group('isReview mode (reached from Settings, not the first-launch gate)', () {
// Pushed on top of a placeholder route (rather than set as `home`
// directly) so there's actually something to pop back to — matching
// how it's really reached (pushed from Settings) and letting the
// automaticallyImplyLeading back button behave realistically.
Future<void> pumpReview(WidgetTester tester) async {
final appState = AppState()..isLoading = false;
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: appState,
child: MaterialApp(
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const UserAgreementScreen(isReview: true)),
),
child: const Text('open'),
),
),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
}
testWidgets('hides Decline/I Agree and shows the content with a back button instead',
(tester) async {
await pumpReview(tester);
expect(find.widgetWithText(FilledButton, 'I Agree'), findsNothing);
expect(find.widgetWithText(OutlinedButton, 'Decline'), findsNothing);
expect(find.byType(BackButton), findsOneWidget);
expect(
find.text('This is the agreement you accepted when you first opened Show Me '
'The Fuel Refund.'),
findsOneWidget,
);
// Same legal content still shown, just not gating anything.
await tester.scrollUntilVisible(
find.text('We Are Not Responsible for Your Data or Any Data Loss'), 300,
scrollable: find.byType(Scrollable));
expect(find.text('We Are Not Responsible for Your Data or Any Data Loss'), findsOneWidget);
});
});
group('AppRoot gating', () {
Future<AppState> pumpRoot(WidgetTester tester, {required bool hasAcceptedUserAgreement}) async {
final appState = AppState()

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:fuel_tax_tracker/models/fuel_entry.dart';
import 'package:fuel_tax_tracker/models/vehicle.dart';
import 'package:fuel_tax_tracker/screens/vehicle_detail_screen.dart';
import 'package:fuel_tax_tracker/services/app_state.dart';
@ -85,4 +86,47 @@ void main() {
expect(find.widgetWithText(AppBar, 'Truck'), findsOneWidget);
});
testWidgets('shows an estimated fuel refund next to price/gal when the setting is on',
(tester) async {
final entry = FuelEntry(
id: 'e1',
vehicleId: 'a',
date: DateTime(2026, 5, 14, 9, 30),
gallons: 10,
pricePerGallon: 3.5,
totalCost: 35,
updatedAt: DateTime.utc(2026, 5, 14),
);
final appState = AppState()
..isLoading = false
..vehicles = [vehicleA]
..fuelEntries = [entry]
..showEstimatedFuelRefund = true;
await pumpDetail(tester, appState, 'a');
// 10 gal * $0.125/gal = $1.25.
expect(find.textContaining('\$3.50/gal · Est. \$1.25'), findsOneWidget);
});
testWidgets('shows no estimated fuel refund when the setting is off (default)', (tester) async {
final entry = FuelEntry(
id: 'e1',
vehicleId: 'a',
date: DateTime(2026, 5, 14, 9, 30),
gallons: 10,
pricePerGallon: 3.5,
totalCost: 35,
updatedAt: DateTime.utc(2026, 5, 14),
);
final appState = AppState()
..isLoading = false
..vehicles = [vehicleA]
..fuelEntries = [entry];
await pumpDetail(tester, appState, 'a');
expect(find.textContaining('Est. \$'), findsNothing);
});
}