A whole lot of stuff
This commit is contained in:
parent
faf319322a
commit
ad850d2be6
50 changed files with 3013 additions and 213 deletions
69
test/ad_free_upsell_dialog_test.dart
Normal file
69
test/ad_free_upsell_dialog_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
29
test/estimated_refund_test.dart
Normal file
29
test/estimated_refund_test.dart
Normal 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
104
test/faq_screen_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue