357 lines
12 KiB
Dart
357 lines
12 KiB
Dart
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';
|
|
import 'package:fuel_tax_tracker/models/vehicle.dart';
|
|
import 'package:fuel_tax_tracker/screens/receipt_detail_screen.dart';
|
|
import 'package:fuel_tax_tracker/screens/receipts_screen.dart';
|
|
import 'package:fuel_tax_tracker/services/app_state.dart';
|
|
|
|
void main() {
|
|
final vehicle = Vehicle(
|
|
id: 'v1',
|
|
vin: '1FMPU18L1TLB51349',
|
|
nickname: 'Red Truck',
|
|
updatedAt: DateTime.utc(2026, 1, 1),
|
|
);
|
|
|
|
testWidgets('shows an empty state with no fuel entries', (tester) async {
|
|
final appState = AppState()
|
|
..isLoading = false
|
|
..vehicles = [vehicle];
|
|
|
|
await tester.pumpWidget(
|
|
ChangeNotifierProvider.value(
|
|
value: appState,
|
|
child: const MaterialApp(home: ReceiptsScreen()),
|
|
),
|
|
);
|
|
|
|
expect(find.textContaining('No fuel entries'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('shows the hero total and a receipt row with date/amount/vehicle chip',
|
|
(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()),
|
|
),
|
|
);
|
|
|
|
// Hero total sums totalCost across all entries — just the one here.
|
|
expect(find.text('\$32.48'), findsNWidgets(2)); // hero total + row amount
|
|
expect(find.text('Total Fuel Cost'), findsOneWidget, reason: 'no year prefix');
|
|
expect(find.text('Total Gallons'), findsOneWidget);
|
|
expect(find.text('11'), findsOneWidget, reason: 'hero total gallons, rounded to whole gallons');
|
|
expect(find.text('May 14, 2026'), findsOneWidget);
|
|
expect(find.text('10.523 gal'), findsOneWidget, reason: 'row subtitle');
|
|
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',
|
|
vehicleId: 'v1',
|
|
date: DateTime(2026, 5, 14),
|
|
gallons: 10,
|
|
pricePerGallon: 3.5,
|
|
totalCost: 35,
|
|
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()),
|
|
),
|
|
);
|
|
|
|
await tester.tap(find.text('May 14, 2026'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byType(ReceiptDetailScreen), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('tapping a row with no receipt photo still opens the detail page', (tester) async {
|
|
final entry = FuelEntry(
|
|
id: 'e1',
|
|
vehicleId: 'v1',
|
|
date: DateTime(2026, 5, 14),
|
|
gallons: 10,
|
|
pricePerGallon: 3.5,
|
|
totalCost: 35,
|
|
updatedAt: DateTime.utc(2026, 5, 14),
|
|
);
|
|
final appState = AppState()
|
|
..isLoading = false
|
|
..vehicles = [vehicle]
|
|
..fuelEntries = [entry];
|
|
|
|
await tester.pumpWidget(
|
|
ChangeNotifierProvider.value(
|
|
value: appState,
|
|
child: const MaterialApp(home: ReceiptsScreen()),
|
|
),
|
|
);
|
|
|
|
await tester.tap(find.text('May 14, 2026'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byType(ReceiptDetailScreen), findsOneWidget,
|
|
reason: 'the data is still viewable/editable even without a photo');
|
|
});
|
|
|
|
testWidgets('formats large gallon totals with thousands separators', (tester) async {
|
|
final entry = FuelEntry(
|
|
id: 'e1',
|
|
vehicleId: 'v1',
|
|
date: DateTime(2026, 8, 5),
|
|
gallons: 1234.5678,
|
|
pricePerGallon: 3.5,
|
|
totalCost: 4320.99,
|
|
updatedAt: DateTime.utc(2026, 8, 5),
|
|
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(find.text('1,235'), findsOneWidget, reason: 'hero total gallons, rounded to whole gallons');
|
|
expect(find.text('1,234.568 gal'), findsOneWidget, reason: 'row subtitle keeps full precision');
|
|
});
|
|
|
|
testWidgets('date range selector filters totals and the list, and recolors the active label',
|
|
(tester) async {
|
|
// 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: recentDate,
|
|
gallons: 10,
|
|
pricePerGallon: 3,
|
|
totalCost: 30,
|
|
updatedAt: recentDate.toUtc(),
|
|
receiptDriveFileId: 'r1',
|
|
);
|
|
final midEntry = FuelEntry(
|
|
id: 'mid',
|
|
vehicleId: 'v1',
|
|
date: midDate,
|
|
gallons: 20,
|
|
pricePerGallon: 3,
|
|
totalCost: 60,
|
|
updatedAt: midDate.toUtc(),
|
|
receiptDriveFileId: 'r2',
|
|
);
|
|
final oldEntry = FuelEntry(
|
|
id: 'old',
|
|
vehicleId: 'v1',
|
|
date: oldDate,
|
|
gallons: 40,
|
|
pricePerGallon: 3,
|
|
totalCost: 120,
|
|
updatedAt: oldDate.toUtc(),
|
|
receiptDriveFileId: 'r3',
|
|
);
|
|
final appState = AppState()
|
|
..isLoading = false
|
|
..vehicles = [vehicle]
|
|
..fuelEntries = [recentEntry, midEntry, oldEntry];
|
|
|
|
await tester.pumpWidget(
|
|
ChangeNotifierProvider.value(
|
|
value: appState,
|
|
child: const MaterialApp(home: ReceiptsScreen()),
|
|
),
|
|
);
|
|
|
|
bool isBold(String label) => tester
|
|
.widgetList<Text>(find.text(label))
|
|
.any((t) => t.style?.fontWeight == FontWeight.w800);
|
|
|
|
// 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(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(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(
|
|
'tapping + with no vehicles opens vehicle creation and prompts to create one for the receipt',
|
|
(tester) async {
|
|
final appState = AppState()..isLoading = false;
|
|
|
|
await tester.pumpWidget(
|
|
ChangeNotifierProvider.value(
|
|
value: appState,
|
|
child: const MaterialApp(home: ReceiptsScreen()),
|
|
),
|
|
);
|
|
|
|
await tester.tap(find.byIcon(Icons.add));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.widgetWithText(AppBar, 'Add Vehicle'), findsOneWidget);
|
|
expect(find.text('Create a vehicle to attach receipt'), findsOneWidget);
|
|
|
|
await tester.tap(find.text('OK'));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Create a vehicle to attach receipt'), findsNothing);
|
|
expect(find.widgetWithText(AppBar, 'Add Vehicle'), findsOneWidget, reason: 'still on the form');
|
|
});
|
|
|
|
// Not covered here: that saving the new vehicle continues into receipt
|
|
// capture (rather than back to an empty Receipts tab). Exercising that
|
|
// needs AddEditVehicleScreen's real _save() to run, which touches a real
|
|
// database — and combining a real sqflite_common_ffi database with
|
|
// testWidgets (as opposed to a plain test()) reliably deadlocks in this
|
|
// project's test environment (confirmed directly: the identical
|
|
// `DatabaseService().init()` call returns immediately under test(), but
|
|
// times out under testWidgets()). Verified manually on-device instead.
|
|
}
|