A whole bunch of changes
This commit is contained in:
parent
901ec678a3
commit
956231048e
122 changed files with 5442 additions and 914 deletions
|
|
@ -101,4 +101,28 @@ void main() {
|
|||
|
||||
expect(find.text('Vehicle already exists'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'tapping the VIN scan icon shows "VIN From" with an info button explaining where to find one',
|
||||
(tester) async {
|
||||
await pumpAddVehicleScreen(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('Scan VIN from a photo'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('VIN From'), findsOneWidget);
|
||||
expect(find.text('Take Photo'), findsOneWidget);
|
||||
expect(find.text('Choose from Gallery'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.info_outline));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Where to find the VIN'), findsOneWidget);
|
||||
expect(find.byType(Image), findsNWidgets(2), reason: 'door jamb + windshield examples');
|
||||
|
||||
await tester.tap(find.text('Close'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Where to find the VIN'), findsNothing);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
157
test/edit_fuel_entry_test.dart
Normal file
157
test/edit_fuel_entry_test.dart
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
import 'package:fuel_tax_tracker/models/fuel_entry.dart';
|
||||
import 'package:fuel_tax_tracker/models/vehicle.dart';
|
||||
import 'package:fuel_tax_tracker/screens/edit_fuel_entry_screen.dart';
|
||||
import 'package:fuel_tax_tracker/screens/receipt_detail_screen.dart';
|
||||
import 'package:fuel_tax_tracker/screens/vehicle_detail_screen.dart';
|
||||
import 'package:fuel_tax_tracker/services/app_state.dart';
|
||||
import 'package:fuel_tax_tracker/services/database_service.dart';
|
||||
|
||||
/// Covers editing a fuel entry's logged data (date/gallons/price/total) —
|
||||
/// not its receipt photo, which [EditFuelEntryScreen] deliberately shows
|
||||
/// read-only. The database-level overwrite semantics ([DatabaseService]
|
||||
/// group) are what [AppState.updateFuelEntry] relies on: it just calls
|
||||
/// [DatabaseService.saveFuelEntry] again with the same id, same as any
|
||||
/// other upsert in this app (see [DatabaseService.saveVehicle]).
|
||||
void main() {
|
||||
final vehicle = Vehicle(id: 'v1', vin: '1FMPU18L1TLB51349', updatedAt: DateTime.utc(2026, 1, 1));
|
||||
final entry = FuelEntry(
|
||||
id: 'e1',
|
||||
vehicleId: 'v1',
|
||||
date: DateTime(2026, 3, 5, 14, 30),
|
||||
gallons: 10.5,
|
||||
pricePerGallon: 3.499,
|
||||
totalCost: 36.74,
|
||||
updatedAt: DateTime.utc(2026, 3, 5),
|
||||
);
|
||||
|
||||
group('DatabaseService.saveFuelEntry overwrite semantics', () {
|
||||
late Directory tempDir;
|
||||
late DatabaseService database;
|
||||
|
||||
setUpAll(() {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
tempDir = await Directory.systemTemp.createTemp('edit_fuel_entry_test_');
|
||||
PathProviderPlatform.instance = _FakePathProviderPlatform(tempDir.path);
|
||||
database = DatabaseService();
|
||||
await database.init();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await database.rawDb.close();
|
||||
await tempDir.delete(recursive: true);
|
||||
});
|
||||
|
||||
test('saving again with the same id overwrites gallons/price/total/date, marking it dirty',
|
||||
() async {
|
||||
await database.saveVehicle(vehicle);
|
||||
await database.saveFuelEntry(entry);
|
||||
// Clear the dirty flag the way a completed sync would, so the
|
||||
// overwrite below can prove it gets re-marked dirty.
|
||||
await database.rawDb.update('fuel_entries', {'dirty': 0}, where: 'id = ?', whereArgs: ['e1']);
|
||||
|
||||
await database.saveFuelEntry(FuelEntry(
|
||||
id: 'e1',
|
||||
vehicleId: 'v1',
|
||||
date: DateTime(2026, 3, 6, 9, 0),
|
||||
gallons: 11.2,
|
||||
pricePerGallon: 3.599,
|
||||
totalCost: 40.31,
|
||||
updatedAt: DateTime.utc(2026, 3, 6),
|
||||
));
|
||||
|
||||
final rows = await database.getFuelEntries();
|
||||
expect(rows, hasLength(1), reason: 'overwritten, not duplicated');
|
||||
final saved = rows.single;
|
||||
expect(saved.gallons, 11.2);
|
||||
expect(saved.pricePerGallon, 3.599);
|
||||
expect(saved.totalCost, 40.31);
|
||||
expect(saved.date, DateTime(2026, 3, 6, 9, 0));
|
||||
|
||||
final rawRow = (await database.rawDb.query('fuel_entries', where: 'id = ?', whereArgs: ['e1'])).single;
|
||||
expect(rawRow['dirty'], 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('EditFuelEntryScreen', () {
|
||||
Future<void> pumpVehicleDetail(WidgetTester tester, AppState appState) async {
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider.value(
|
||||
value: appState,
|
||||
child: MaterialApp(home: VehicleDetailScreen(vehicleId: vehicle.id)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'tapping an entry row opens its receipt detail page, and its edit icon opens '
|
||||
'EditFuelEntryScreen pre-filled', (tester) async {
|
||||
final appState = AppState()
|
||||
..isLoading = false
|
||||
..vehicles = [vehicle]
|
||||
..fuelEntries = [entry];
|
||||
|
||||
await pumpVehicleDetail(tester, appState);
|
||||
|
||||
await tester.tap(find.text('10.500 gal • \$36.74'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(ReceiptDetailScreen), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Edit fuel entry'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(EditFuelEntryScreen), findsOneWidget);
|
||||
expect(find.widgetWithText(TextFormField, '10.500'), findsOneWidget);
|
||||
expect(find.widgetWithText(TextFormField, '3.499'), findsOneWidget);
|
||||
expect(find.widgetWithText(TextFormField, '36.74'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('clearing a required field shows a validation error instead of saving',
|
||||
(tester) async {
|
||||
final appState = AppState()
|
||||
..isLoading = false
|
||||
..vehicles = [vehicle]
|
||||
..fuelEntries = [entry];
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider.value(
|
||||
value: appState,
|
||||
child: MaterialApp(home: EditFuelEntryScreen(entry: entry)),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.enterText(find.widgetWithText(TextFormField, '10.500'), '');
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -300));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Save Changes'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Required'), findsOneWidget);
|
||||
expect(find.byType(EditFuelEntryScreen), findsOneWidget, reason: 'did not navigate away');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _FakePathProviderPlatform extends PathProviderPlatform with MockPlatformInterfaceMixin {
|
||||
final String tempPath;
|
||||
_FakePathProviderPlatform(this.tempPath);
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationDocumentsPath() async => tempPath;
|
||||
|
||||
@override
|
||||
Future<String?> getTemporaryPath() async => tempPath;
|
||||
}
|
||||
201
test/fuel_report_images_test.dart
Normal file
201
test/fuel_report_images_test.dart
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fuel_tax_tracker/models/fuel_entry.dart';
|
||||
import 'package:fuel_tax_tracker/models/vehicle.dart';
|
||||
import 'package:fuel_tax_tracker/services/cloud/cloud_storage_provider.dart';
|
||||
import 'package:fuel_tax_tracker/services/fuel_report.dart';
|
||||
import 'package:fuel_tax_tracker/services/fuel_report_images.dart';
|
||||
|
||||
void main() {
|
||||
final vehicle = Vehicle(
|
||||
id: 'v1',
|
||||
vin: '1FMPU18L1TLB51349',
|
||||
updatedAt: DateTime.utc(2026, 1, 1),
|
||||
);
|
||||
|
||||
late Directory tempDir;
|
||||
|
||||
setUp(() async {
|
||||
tempDir = await Directory.systemTemp.createTemp('fuel_report_images_test_');
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await tempDir.delete(recursive: true);
|
||||
});
|
||||
|
||||
FuelReport reportWith(List<FuelEntry> entries) => FuelReport(
|
||||
startDate: DateTime(2026, 1, 1),
|
||||
endDate: DateTime(2026, 12, 31),
|
||||
rows: [
|
||||
VehicleReportRow(
|
||||
vehicle: vehicle,
|
||||
entries: entries,
|
||||
totalGallons: entries.fold(0.0, (sum, e) => sum + e.gallons),
|
||||
totalCost: entries.fold(0.0, (sum, e) => sum + e.totalCost),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
FuelEntry entry({
|
||||
required String id,
|
||||
String? receiptImagePath,
|
||||
String? receiptDriveFileId,
|
||||
}) =>
|
||||
FuelEntry(
|
||||
id: id,
|
||||
vehicleId: 'v1',
|
||||
date: DateTime(2026, 3, 1),
|
||||
gallons: 10,
|
||||
pricePerGallon: 3.5,
|
||||
totalCost: 35,
|
||||
updatedAt: DateTime.utc(2026, 3, 1),
|
||||
receiptImagePath: receiptImagePath,
|
||||
receiptDriveFileId: receiptDriveFileId,
|
||||
);
|
||||
|
||||
test('reads bytes from a local file when one exists', () async {
|
||||
final file = File('${tempDir.path}/a.jpg')..writeAsBytesSync([1, 2, 3]);
|
||||
final report = reportWith([entry(id: 'e1', receiptImagePath: file.path)]);
|
||||
|
||||
final result = await loadReceiptImageBytes(report: report, activeProvider: null);
|
||||
|
||||
expect(result.bytesByEntryId['e1'], [1, 2, 3]);
|
||||
expect(result.failedEntries, isEmpty);
|
||||
});
|
||||
|
||||
test('falls back to downloading via the cloud provider when there is no local file', () async {
|
||||
final report = reportWith([entry(id: 'e1', receiptDriveFileId: 'remote-1')]);
|
||||
final provider = _FakeProvider(_FakeSession(bytesByFileId: {'remote-1': [4, 5, 6]}));
|
||||
|
||||
final result = await loadReceiptImageBytes(report: report, activeProvider: provider);
|
||||
|
||||
expect(result.bytesByEntryId['e1'], [4, 5, 6]);
|
||||
expect(result.failedEntries, isEmpty);
|
||||
});
|
||||
|
||||
test('reports (rather than silently drops) an entry with neither a local file nor a cloud reference',
|
||||
() async {
|
||||
final report = reportWith([entry(id: 'e1')]);
|
||||
|
||||
final result = await loadReceiptImageBytes(report: report, activeProvider: null);
|
||||
|
||||
expect(result.bytesByEntryId.containsKey('e1'), isFalse);
|
||||
expect(result.failedEntries.map((e) => e.id), ['e1']);
|
||||
});
|
||||
|
||||
test('reports (rather than throws) when the download fails', () async {
|
||||
final report = reportWith([entry(id: 'e1', receiptDriveFileId: 'missing')]);
|
||||
final provider = _FakeProvider(_FakeSession(bytesByFileId: {}));
|
||||
|
||||
final result = await loadReceiptImageBytes(report: report, activeProvider: provider);
|
||||
|
||||
expect(result.bytesByEntryId.containsKey('e1'), isFalse);
|
||||
expect(result.failedEntries.map((e) => e.id), ['e1']);
|
||||
});
|
||||
|
||||
test('reports a cloud-only entry when there is no signed-in provider', () async {
|
||||
final report = reportWith([entry(id: 'e1', receiptDriveFileId: 'remote-1')]);
|
||||
|
||||
final result = await loadReceiptImageBytes(report: report, activeProvider: null);
|
||||
|
||||
expect(result.bytesByEntryId.containsKey('e1'), isFalse);
|
||||
expect(result.failedEntries.map((e) => e.id), ['e1']);
|
||||
});
|
||||
|
||||
test('a receipt uploaded via a different provider than the one currently connected fails cleanly',
|
||||
() async {
|
||||
// Mirrors the real scenario this was built for: a WebDAV-style path
|
||||
// stored as receiptDriveFileId, but the currently active provider is
|
||||
// something else entirely (here, the fake) — the ID is meaningless to
|
||||
// it and the download call throws, same as any other failed download.
|
||||
final report = reportWith([
|
||||
entry(
|
||||
id: 'e1',
|
||||
receiptDriveFileId:
|
||||
'/remote.php/dav/files/user/Show Me The Fuel Refund/receipts/VIN/e1.jpg',
|
||||
),
|
||||
]);
|
||||
final provider = _FakeProvider(_FakeSession(bytesByFileId: {}));
|
||||
|
||||
final result = await loadReceiptImageBytes(report: report, activeProvider: provider);
|
||||
|
||||
expect(result.bytesByEntryId.containsKey('e1'), isFalse);
|
||||
expect(result.failedEntries.map((e) => e.id), ['e1']);
|
||||
});
|
||||
|
||||
test('mixed success and failure across multiple entries', () async {
|
||||
final file = File('${tempDir.path}/a.jpg')..writeAsBytesSync([9]);
|
||||
final report = reportWith([
|
||||
entry(id: 'local', receiptImagePath: file.path),
|
||||
entry(id: 'cloud-ok', receiptDriveFileId: 'remote-1'),
|
||||
entry(id: 'cloud-missing', receiptDriveFileId: 'nope'),
|
||||
]);
|
||||
final provider = _FakeProvider(_FakeSession(bytesByFileId: {'remote-1': [1]}));
|
||||
|
||||
final result = await loadReceiptImageBytes(report: report, activeProvider: provider);
|
||||
|
||||
expect(result.bytesByEntryId.keys, {'local', 'cloud-ok'});
|
||||
expect(result.failedEntries.map((e) => e.id), ['cloud-missing']);
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeSession implements CloudStorageSession {
|
||||
final Map<String, List<int>> bytesByFileId;
|
||||
_FakeSession({required this.bytesByFileId});
|
||||
|
||||
@override
|
||||
Future<List<int>> downloadFileBytes(String fileId) async {
|
||||
final bytes = bytesByFileId[fileId];
|
||||
if (bytes == null) throw StateError('not found: $fileId');
|
||||
return bytes;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get supportsSharedWithMe => false;
|
||||
@override
|
||||
Future<List<CloudFolder>> listFolders({String? parentId, bool sharedWithMe = false}) async => [];
|
||||
@override
|
||||
Future<String> findOrCreateFolder({required String parentId, required String name}) async => '';
|
||||
@override
|
||||
Future<CloudFileInfo?> findFile({required String folderId, required String name}) async => null;
|
||||
@override
|
||||
Future<CloudFileInfo> uploadFile({
|
||||
required String folderId,
|
||||
required String name,
|
||||
String? existingFileId,
|
||||
required File localFile,
|
||||
required String contentType,
|
||||
}) async =>
|
||||
CloudFileInfo(id: 'x', versionTag: null);
|
||||
@override
|
||||
Future<void> deleteFile(String fileId) async {}
|
||||
@override
|
||||
Future<String> createLockFile({required String folderId, required String name}) async => 'lock';
|
||||
@override
|
||||
Future<List<CloudLockFile>> listLockFiles(String folderId) async => [];
|
||||
@override
|
||||
void close() {}
|
||||
}
|
||||
|
||||
class _FakeProvider implements CloudStorageProvider {
|
||||
final _FakeSession session;
|
||||
_FakeProvider(this.session);
|
||||
|
||||
@override
|
||||
CloudProviderId get id => CloudProviderId.googleDrive;
|
||||
@override
|
||||
String get displayName => 'Fake Provider';
|
||||
@override
|
||||
bool get isSignedIn => true;
|
||||
@override
|
||||
String? get accountLabel => 'tester@example.com';
|
||||
@override
|
||||
Future<bool> attemptSilentSignIn() async => true;
|
||||
@override
|
||||
Future<String> signIn() async => accountLabel!;
|
||||
@override
|
||||
Future<void> signOut() async {}
|
||||
@override
|
||||
CloudStorageSession beginSession() => session;
|
||||
}
|
||||
158
test/fuel_report_pdf_test.dart
Normal file
158
test/fuel_report_pdf_test.dart
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fuel_tax_tracker/models/fuel_entry.dart';
|
||||
import 'package:fuel_tax_tracker/models/vehicle.dart';
|
||||
import 'package:fuel_tax_tracker/services/fuel_report.dart';
|
||||
import 'package:fuel_tax_tracker/services/fuel_report_pdf.dart';
|
||||
|
||||
/// PDF content isn't practically assertable byte-for-byte, so this is a
|
||||
/// smoke test: every include-images/include-line-items combination should
|
||||
/// render without throwing and produce a well-formed (non-trivial, valid
|
||||
/// header) document — the real coverage of what each section *contains*
|
||||
/// lives in fuel_report_test.dart (data) and is exercised by hand.
|
||||
void main() {
|
||||
final vehicle = Vehicle(
|
||||
id: 'v1',
|
||||
vin: '1FMPU18L1TLB51349',
|
||||
nickname: 'Truck',
|
||||
updatedAt: DateTime.utc(2026, 1, 1),
|
||||
);
|
||||
|
||||
final entryA = FuelEntry(
|
||||
id: 'e1',
|
||||
vehicleId: 'v1',
|
||||
date: DateTime(2026, 3, 5),
|
||||
gallons: 10,
|
||||
pricePerGallon: 3.5,
|
||||
totalCost: 35,
|
||||
updatedAt: DateTime.utc(2026, 3, 5),
|
||||
receiptImagePath: '/local/a.jpg',
|
||||
);
|
||||
final entryB = FuelEntry(
|
||||
id: 'e2',
|
||||
vehicleId: 'v1',
|
||||
date: DateTime(2026, 3, 12),
|
||||
gallons: 12,
|
||||
pricePerGallon: 3.6,
|
||||
totalCost: 43.2,
|
||||
updatedAt: DateTime.utc(2026, 3, 12),
|
||||
receiptDriveFileId: 'remote-1',
|
||||
);
|
||||
|
||||
final report = FuelReport(
|
||||
startDate: DateTime(2026, 3, 1),
|
||||
endDate: DateTime(2026, 3, 31),
|
||||
rows: [
|
||||
VehicleReportRow(
|
||||
vehicle: vehicle,
|
||||
entries: [entryA, entryB],
|
||||
totalGallons: 22,
|
||||
totalCost: 78.2,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// A 1x1 red pixel PNG — enough for the pdf package to embed as an image.
|
||||
final onePixelPng = Uint8List.fromList([
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D,
|
||||
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00,
|
||||
0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00,
|
||||
0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xDD, 0x8D, 0xB0, 0x00, 0x00, 0x00,
|
||||
0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
|
||||
]);
|
||||
|
||||
Future<void> expectValidPdf(Uint8List bytes) async {
|
||||
expect(bytes.length, greaterThan(100));
|
||||
expect(String.fromCharCodes(bytes.take(5)), '%PDF-');
|
||||
}
|
||||
|
||||
test('summary only (both toggles off)', () async {
|
||||
final bytes = await buildFuelReportPdf(report);
|
||||
await expectValidPdf(bytes);
|
||||
});
|
||||
|
||||
test('with line items, no images', () async {
|
||||
final bytes = await buildFuelReportPdf(report, includeLineItems: true);
|
||||
await expectValidPdf(bytes);
|
||||
});
|
||||
|
||||
test('with images, no line items', () async {
|
||||
final bytes = await buildFuelReportPdf(
|
||||
report,
|
||||
includeReceiptImages: true,
|
||||
receiptImages: {'e1': onePixelPng, 'e2': onePixelPng},
|
||||
);
|
||||
await expectValidPdf(bytes);
|
||||
});
|
||||
|
||||
test('with both — line items and images each get their own section, cross-referenced by number',
|
||||
() async {
|
||||
final bytes = await buildFuelReportPdf(
|
||||
report,
|
||||
includeLineItems: true,
|
||||
includeReceiptImages: true,
|
||||
receiptImages: {'e1': onePixelPng, 'e2': onePixelPng},
|
||||
);
|
||||
await expectValidPdf(bytes);
|
||||
});
|
||||
|
||||
test('a report with enough entries to span multiple pages does not throw', () async {
|
||||
// Regression test: the page banner/watermark used to be drawn by
|
||||
// wrapping the *entire*, unbounded-height body in a Stack, which
|
||||
// doesn't paginate — MultiPage threw "Widget won't fit into the page"
|
||||
// for anything long enough to need a page break. Enough months/entries
|
||||
// here to force at least one break.
|
||||
final vehicle2 = Vehicle(id: 'v2', vin: '1GNWK5EG4DR302326', updatedAt: DateTime.utc(2026, 1, 1));
|
||||
final manyEntries = <FuelEntry>[];
|
||||
for (var month = 1; month <= 12; month++) {
|
||||
for (var day = 2; day <= 26; day += 6) {
|
||||
final id = 'm$month-d$day';
|
||||
manyEntries.add(FuelEntry(
|
||||
id: id,
|
||||
vehicleId: month.isEven ? 'v1' : 'v2',
|
||||
date: DateTime(2026, month, day),
|
||||
gallons: 20,
|
||||
pricePerGallon: 3.5,
|
||||
totalCost: 70,
|
||||
updatedAt: DateTime.utc(2026, month, day),
|
||||
receiptDriveFileId: 'remote-$id',
|
||||
));
|
||||
}
|
||||
}
|
||||
final bigReport = buildFuelReport(
|
||||
vehicles: [vehicle, vehicle2],
|
||||
fuelEntries: manyEntries,
|
||||
startDate: DateTime(2026, 1, 1),
|
||||
endDate: DateTime(2026, 12, 31),
|
||||
);
|
||||
|
||||
final bytes = await buildFuelReportPdf(
|
||||
bigReport,
|
||||
includeLineItems: true,
|
||||
includeReceiptImages: true,
|
||||
receiptImages: {for (final e in manyEntries) e.id: onePixelPng},
|
||||
);
|
||||
await expectValidPdf(bytes);
|
||||
});
|
||||
|
||||
test('receiptImages missing an entry does not crash — that photo is just skipped', () async {
|
||||
final bytes = await buildFuelReportPdf(
|
||||
report,
|
||||
includeReceiptImages: true,
|
||||
receiptImages: {'e1': onePixelPng}, // e2 deliberately missing
|
||||
);
|
||||
await expectValidPdf(bytes);
|
||||
});
|
||||
|
||||
test('empty report (no rows) with all sections on', () async {
|
||||
final empty = FuelReport(startDate: DateTime(2026, 1, 1), endDate: DateTime(2026, 1, 31), rows: []);
|
||||
final bytes = await buildFuelReportPdf(empty, includeLineItems: true, includeReceiptImages: true);
|
||||
await expectValidPdf(bytes);
|
||||
});
|
||||
|
||||
test('fuelReportFileName formats the date range', () {
|
||||
expect(fuelReportFileName(report), 'fuel-report-20260301-20260331.pdf');
|
||||
});
|
||||
}
|
||||
|
|
@ -181,4 +181,101 @@ void main() {
|
|||
expect(report.totalGallons, 18);
|
||||
expect(report.totalCost, 63);
|
||||
});
|
||||
|
||||
test('a row\'s entries are sorted oldest-first, regardless of insertion order', () {
|
||||
final report = buildFuelReport(
|
||||
vehicles: [truck],
|
||||
fuelEntries: [
|
||||
entry(id: 'newest', vehicleId: 'v1', date: DateTime(2026, 3, 20), receiptImagePath: '/c.jpg'),
|
||||
entry(id: 'oldest', vehicleId: 'v1', date: DateTime(2026, 3, 5), receiptImagePath: '/a.jpg'),
|
||||
entry(id: 'middle', vehicleId: 'v1', date: DateTime(2026, 3, 12), receiptImagePath: '/b.jpg'),
|
||||
],
|
||||
startDate: DateTime(2026, 3, 1),
|
||||
endDate: DateTime(2026, 3, 31),
|
||||
);
|
||||
|
||||
expect(report.rows.single.entries.map((e) => e.id), ['oldest', 'middle', 'newest']);
|
||||
});
|
||||
|
||||
test('fillCount and avgPricePerGallon are derived from the totals across all rows', () {
|
||||
final report = buildFuelReport(
|
||||
vehicles: [truck, car],
|
||||
fuelEntries: [
|
||||
entry(id: 'e1', vehicleId: 'v1', date: DateTime(2026, 3, 5), gallons: 10, totalCost: 40, receiptImagePath: '/a.jpg'),
|
||||
entry(id: 'e2', vehicleId: 'v2', date: DateTime(2026, 3, 6), gallons: 10, totalCost: 30, receiptImagePath: '/b.jpg'),
|
||||
],
|
||||
startDate: DateTime(2026, 3, 1),
|
||||
endDate: DateTime(2026, 3, 31),
|
||||
);
|
||||
|
||||
expect(report.fillCount, 2);
|
||||
expect(report.avgPricePerGallon, 3.5); // $70 / 20 gal
|
||||
});
|
||||
|
||||
test('avgPricePerGallon is 0, not NaN/infinity, for an empty report', () {
|
||||
final report = buildFuelReport(
|
||||
vehicles: [truck],
|
||||
fuelEntries: const [],
|
||||
startDate: DateTime(2026, 3, 1),
|
||||
endDate: DateTime(2026, 3, 31),
|
||||
);
|
||||
|
||||
expect(report.avgPricePerGallon, 0);
|
||||
});
|
||||
|
||||
test('allEntries pairs each entry with its vehicle and sorts oldest-first across vehicles', () {
|
||||
final report = buildFuelReport(
|
||||
vehicles: [truck, car],
|
||||
fuelEntries: [
|
||||
entry(id: 'truck-later', vehicleId: 'v1', date: DateTime(2026, 3, 20), receiptImagePath: '/a.jpg'),
|
||||
entry(id: 'car-earlier', vehicleId: 'v2', date: DateTime(2026, 3, 5), receiptImagePath: '/b.jpg'),
|
||||
],
|
||||
startDate: DateTime(2026, 3, 1),
|
||||
endDate: DateTime(2026, 3, 31),
|
||||
);
|
||||
|
||||
final all = report.allEntries;
|
||||
expect(all.map((re) => re.entry.id), ['car-earlier', 'truck-later']);
|
||||
expect(all[0].vehicle.id, 'v2');
|
||||
expect(all[1].vehicle.id, 'v1');
|
||||
});
|
||||
|
||||
test('monthlyTotals buckets entries by calendar month across vehicles, oldest month first', () {
|
||||
final report = buildFuelReport(
|
||||
vehicles: [truck, car],
|
||||
fuelEntries: [
|
||||
entry(id: 'mar-1', vehicleId: 'v1', date: DateTime(2026, 3, 5), gallons: 10, totalCost: 35, receiptImagePath: '/a.jpg'),
|
||||
entry(id: 'mar-2', vehicleId: 'v2', date: DateTime(2026, 3, 20), gallons: 10, totalCost: 35, receiptImagePath: '/b.jpg'),
|
||||
entry(id: 'apr-1', vehicleId: 'v1', date: DateTime(2026, 4, 2), gallons: 10, totalCost: 40, receiptImagePath: '/c.jpg'),
|
||||
],
|
||||
startDate: DateTime(2026, 3, 1),
|
||||
endDate: DateTime(2026, 4, 30),
|
||||
);
|
||||
|
||||
final months = report.monthlyTotals;
|
||||
expect(months, hasLength(2));
|
||||
expect(months[0].month, DateTime(2026, 3));
|
||||
expect(months[0].fillCount, 2);
|
||||
expect(months[0].totalGallons, 20);
|
||||
expect(months[0].totalCost, 70);
|
||||
expect(months[0].avgPricePerGallon, 3.5);
|
||||
expect(months[1].month, DateTime(2026, 4));
|
||||
expect(months[1].fillCount, 1);
|
||||
});
|
||||
|
||||
test('entriesByMonth is the grouping monthlyTotals is built from', () {
|
||||
final report = buildFuelReport(
|
||||
vehicles: [truck],
|
||||
fuelEntries: [
|
||||
entry(id: 'mar-1', vehicleId: 'v1', date: DateTime(2026, 3, 5), receiptImagePath: '/a.jpg'),
|
||||
entry(id: 'apr-1', vehicleId: 'v1', date: DateTime(2026, 4, 2), receiptImagePath: '/b.jpg'),
|
||||
],
|
||||
startDate: DateTime(2026, 3, 1),
|
||||
endDate: DateTime(2026, 4, 30),
|
||||
);
|
||||
|
||||
final byMonth = report.entriesByMonth;
|
||||
expect(byMonth[DateTime(2026, 3)]!.map((re) => re.entry.id), ['mar-1']);
|
||||
expect(byMonth[DateTime(2026, 4)]!.map((re) => re.entry.id), ['apr-1']);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
68
test/image_source_sheet_test.dart
Normal file
68
test/image_source_sheet_test.dart
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:fuel_tax_tracker/widgets/image_source_sheet.dart';
|
||||
|
||||
/// Covers the optional [heading] and [onInfoTap] on [chooseImageSource]'s
|
||||
/// bottom sheet — receipt capture passes "Receipt From" with no info
|
||||
/// button (see receipt_capture.dart); VIN scanning passes "VIN From" plus
|
||||
/// an info button that explains where to find one (see
|
||||
/// add_edit_vehicle_screen.dart).
|
||||
void main() {
|
||||
Future<void> pumpSheet(
|
||||
WidgetTester tester, {
|
||||
String? heading,
|
||||
VoidCallback? onInfoTap,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) => ElevatedButton(
|
||||
onPressed: () => chooseImageSource(context, heading: heading, onInfoTap: onInfoTap),
|
||||
child: const Text('Open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('shows the heading above Take Photo / Choose from Gallery when provided',
|
||||
(tester) async {
|
||||
await pumpSheet(tester, heading: 'Receipt From');
|
||||
|
||||
expect(find.text('Receipt From'), findsOneWidget);
|
||||
expect(find.text('Take Photo'), findsOneWidget);
|
||||
expect(find.text('Choose from Gallery'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows no heading when none is provided', (tester) async {
|
||||
await pumpSheet(tester);
|
||||
|
||||
expect(find.text('Receipt From'), findsNothing);
|
||||
expect(find.text('Take Photo'), findsOneWidget);
|
||||
expect(find.text('Choose from Gallery'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows no info button when onInfoTap is omitted, even with a heading',
|
||||
(tester) async {
|
||||
await pumpSheet(tester, heading: 'Receipt From');
|
||||
|
||||
expect(find.byIcon(Icons.info_outline), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('shows an info button next to the heading when onInfoTap is provided, and invokes it',
|
||||
(tester) async {
|
||||
var tapped = false;
|
||||
await pumpSheet(tester, heading: 'VIN From', onInfoTap: () => tapped = true);
|
||||
|
||||
expect(find.text('VIN From'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.info_outline), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.info_outline));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tapped, isTrue);
|
||||
});
|
||||
}
|
||||
105
test/main_shell_test.dart
Normal file
105
test/main_shell_test.dart
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:fuel_tax_tracker/screens/main_shell.dart';
|
||||
import 'package:fuel_tax_tracker/services/app_state.dart';
|
||||
|
||||
/// Confirms the bottom nav actually switches between the four tabs and
|
||||
/// that each one builds without crashing against a freshly-constructed,
|
||||
/// not-yet-synced [AppState] — the same "no cloud connected, no vehicles
|
||||
/// yet" state a real fresh install starts in.
|
||||
void main() {
|
||||
testWidgets('bottom nav switches between all four tabs', (tester) async {
|
||||
final appState = AppState()..isLoading = false;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider.value(
|
||||
value: appState,
|
||||
child: const MaterialApp(home: MainShell()),
|
||||
),
|
||||
);
|
||||
|
||||
// Receipts is the default tab.
|
||||
expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.widgetWithText(NavigationDestination, 'Vehicles'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.widgetWithText(AppBar, 'Vehicles'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.widgetWithText(NavigationDestination, 'Reports'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.widgetWithText(AppBar, 'Fuel Report'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.widgetWithText(NavigationDestination, 'Settings'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.widgetWithText(AppBar, 'Settings'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.widgetWithText(NavigationDestination, 'Receipts'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Receipts tab shows an empty state with no fuel entries yet', (tester) async {
|
||||
final appState = AppState()..isLoading = false;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider.value(
|
||||
value: appState,
|
||||
child: const MaterialApp(home: MainShell()),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.textContaining('No fuel entries'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a right-to-left swipe on the body advances to the next tab', (tester) async {
|
||||
final appState = AppState()..isLoading = false;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider.value(
|
||||
value: appState,
|
||||
child: const MaterialApp(home: MainShell()),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget);
|
||||
|
||||
await tester.fling(find.byType(PageView), const Offset(-400, 0), 1000);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.widgetWithText(AppBar, 'Vehicles'), findsOneWidget);
|
||||
|
||||
// And a left-to-right swipe goes back.
|
||||
await tester.fling(find.byType(PageView), const Offset(400, 0), 1000);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.widgetWithText(AppBar, 'Receipts'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
"swiping away from Receipts and back preserves its selected date range "
|
||||
'(AutomaticKeepAliveClientMixin keeping the tab alive across the PageView, '
|
||||
'not just the old IndexedStack)', (tester) async {
|
||||
final appState = AppState()..isLoading = false;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider.value(
|
||||
value: appState,
|
||||
child: const MaterialApp(home: MainShell()),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Last 3 Months'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Swipe all the way to Settings and back.
|
||||
await tester.tap(find.widgetWithText(NavigationDestination, 'Settings'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(NavigationDestination, 'Receipts'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final selectedLabel = tester.widget<Text>(find.text('Last 3 Months'));
|
||||
expect((selectedLabel.style?.fontWeight), FontWeight.w800, reason: 'still the active range');
|
||||
});
|
||||
}
|
||||
174
test/purge_data_test.dart
Normal file
174
test/purge_data_test.dart
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fuel_tax_tracker/models/fuel_entry.dart';
|
||||
import 'package:fuel_tax_tracker/models/vehicle.dart';
|
||||
import 'package:fuel_tax_tracker/services/database_service.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
/// Both purge paths reuse the same soft-delete tombstones as a normal
|
||||
/// per-row delete (see [DatabaseService.softDeleteVehicle]) rather than a
|
||||
/// real SQL `DELETE`, specifically so the deletion is something a cloud
|
||||
/// sync can push to other devices instead of the rows just getting
|
||||
/// silently re-imported from the remote copy on the next sync.
|
||||
void main() {
|
||||
late Directory tempDir;
|
||||
late DatabaseService database;
|
||||
|
||||
setUpAll(() {
|
||||
sqfliteFfiInit();
|
||||
databaseFactory = databaseFactoryFfi;
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
tempDir = await Directory.systemTemp.createTemp('purge_data_test_');
|
||||
PathProviderPlatform.instance = _FakePathProviderPlatform(tempDir.path);
|
||||
database = DatabaseService();
|
||||
await database.init();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await database.rawDb.close();
|
||||
await tempDir.delete(recursive: true);
|
||||
});
|
||||
|
||||
Vehicle vehicle({required String id, required String vin}) => Vehicle(
|
||||
id: id,
|
||||
vin: vin,
|
||||
updatedAt: DateTime.utc(2026, 1, 1),
|
||||
);
|
||||
|
||||
FuelEntry entry({
|
||||
required String id,
|
||||
required String vehicleId,
|
||||
required DateTime date,
|
||||
String? receiptImagePath,
|
||||
}) =>
|
||||
FuelEntry(
|
||||
id: id,
|
||||
vehicleId: vehicleId,
|
||||
date: date,
|
||||
gallons: 10,
|
||||
pricePerGallon: 3.5,
|
||||
totalCost: 35,
|
||||
updatedAt: DateTime.utc(2026, 1, 1),
|
||||
receiptImagePath: receiptImagePath,
|
||||
);
|
||||
|
||||
group('purgeAllData', () {
|
||||
test('soft-deletes every active vehicle and fuel entry, marking them dirty', () async {
|
||||
await database.saveVehicle(vehicle(id: 'v1', vin: 'VIN1'));
|
||||
await database.saveVehicle(vehicle(id: 'v2', vin: 'VIN2'));
|
||||
await database.saveFuelEntry(entry(id: 'e1', vehicleId: 'v1', date: DateTime(2026, 3, 1)));
|
||||
await database.saveFuelEntry(entry(id: 'e2', vehicleId: 'v2', date: DateTime(2026, 4, 1)));
|
||||
|
||||
await database.purgeAllData(DateTime.utc(2026, 8, 13));
|
||||
|
||||
expect(await database.getVehicles(), isEmpty);
|
||||
expect(await database.getFuelEntries(), isEmpty);
|
||||
|
||||
final vehicleRows = await database.rawDb.query('vehicles');
|
||||
final entryRows = await database.rawDb.query('fuel_entries');
|
||||
expect(vehicleRows, hasLength(2), reason: 'tombstoned, not hard-deleted');
|
||||
expect(entryRows, hasLength(2));
|
||||
for (final row in [...vehicleRows, ...entryRows]) {
|
||||
expect(row['deleted_at'], isNotNull);
|
||||
expect(row['dirty'], 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('empties the local receipts folder, including untracked files', () async {
|
||||
final source = File(p.join(tempDir.path, 'source.jpg'))..writeAsBytesSync([1, 2, 3]);
|
||||
final storedPath =
|
||||
await database.storeReceiptImage(source, 'e1', 'VIN1', DateTime(2026, 3, 1));
|
||||
// Simulate an orphaned file the DB never referenced.
|
||||
final strayFile = File(p.join(database.receiptsDirectory.path, 'stray.jpg'))
|
||||
..writeAsBytesSync([9]);
|
||||
|
||||
await database.purgeAllData(DateTime.utc(2026, 8, 13));
|
||||
|
||||
expect(await File(storedPath).exists(), isFalse);
|
||||
expect(await strayFile.exists(), isFalse);
|
||||
expect(await database.receiptsDirectory.exists(), isTrue,
|
||||
reason: 'recreated empty, ready for future use');
|
||||
});
|
||||
|
||||
test('leaves already-deleted rows alone', () async {
|
||||
await database.saveVehicle(vehicle(id: 'v1', vin: 'VIN1'));
|
||||
await database.softDeleteVehicle('v1', DateTime.utc(2026, 1, 2));
|
||||
|
||||
await database.purgeAllData(DateTime.utc(2026, 8, 13));
|
||||
|
||||
final rows = await database.rawDb.query('vehicles', where: 'id = ?', whereArgs: ['v1']);
|
||||
expect(rows.single['deleted_at'], DateTime.utc(2026, 1, 2).millisecondsSinceEpoch);
|
||||
});
|
||||
});
|
||||
|
||||
group('purgeFuelEntriesInRange', () {
|
||||
test('soft-deletes only entries dated within the inclusive range', () async {
|
||||
await database.saveVehicle(vehicle(id: 'v1', vin: 'VIN1'));
|
||||
await database.saveFuelEntry(
|
||||
entry(id: 'before', vehicleId: 'v1', date: DateTime(2026, 2, 28)),
|
||||
);
|
||||
await database.saveFuelEntry(
|
||||
entry(id: 'start-boundary', vehicleId: 'v1', date: DateTime(2026, 3, 1)),
|
||||
);
|
||||
await database.saveFuelEntry(
|
||||
entry(id: 'inside', vehicleId: 'v1', date: DateTime(2026, 3, 15)),
|
||||
);
|
||||
await database.saveFuelEntry(
|
||||
entry(id: 'end-boundary', vehicleId: 'v1', date: DateTime(2026, 3, 31)),
|
||||
);
|
||||
await database.saveFuelEntry(
|
||||
entry(id: 'after', vehicleId: 'v1', date: DateTime(2026, 4, 1)),
|
||||
);
|
||||
|
||||
await database.purgeFuelEntriesInRange(
|
||||
DateTime(2026, 3, 1),
|
||||
DateTime(2026, 3, 31),
|
||||
DateTime.utc(2026, 8, 13),
|
||||
);
|
||||
|
||||
final remaining = await database.getFuelEntries();
|
||||
expect(remaining.map((e) => e.id).toSet(), {'before', 'after'});
|
||||
expect(await database.getVehicles(), hasLength(1), reason: 'vehicles are untouched');
|
||||
});
|
||||
|
||||
test('returns local receipt paths for the caller to delete, without touching untracked files',
|
||||
() async {
|
||||
await database.saveVehicle(vehicle(id: 'v1', vin: 'VIN1'));
|
||||
final source = File(p.join(tempDir.path, 'source.jpg'))..writeAsBytesSync([1, 2, 3]);
|
||||
final storedPath =
|
||||
await database.storeReceiptImage(source, 'e1', 'VIN1', DateTime(2026, 3, 15));
|
||||
await database.saveFuelEntry(
|
||||
entry(id: 'e1', vehicleId: 'v1', date: DateTime(2026, 3, 15), receiptImagePath: storedPath),
|
||||
);
|
||||
|
||||
final paths = await database.purgeFuelEntriesInRange(
|
||||
DateTime(2026, 3, 1),
|
||||
DateTime(2026, 3, 31),
|
||||
DateTime.utc(2026, 8, 13),
|
||||
);
|
||||
|
||||
expect(paths, [storedPath]);
|
||||
// purgeFuelEntriesInRange only reports paths — deleting the files
|
||||
// themselves is the caller's job (AppState), mirroring
|
||||
// softDeleteFuelEntriesForVehicle.
|
||||
expect(await File(storedPath).exists(), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _FakePathProviderPlatform extends PathProviderPlatform with MockPlatformInterfaceMixin {
|
||||
final String tempPath;
|
||||
_FakePathProviderPlatform(this.tempPath);
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationDocumentsPath() async => tempPath;
|
||||
|
||||
@override
|
||||
Future<String?> getTemporaryPath() async => tempPath;
|
||||
}
|
||||
115
test/receipt_detail_screen_test.dart
Normal file
115
test/receipt_detail_screen_test.dart
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
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/edit_fuel_entry_screen.dart';
|
||||
import 'package:fuel_tax_tracker/screens/receipt_detail_screen.dart';
|
||||
import 'package:fuel_tax_tracker/screens/receipt_image_screen.dart';
|
||||
import 'package:fuel_tax_tracker/services/app_state.dart';
|
||||
|
||||
/// Covers [ReceiptDetailScreen] itself — the single place to view a
|
||||
/// receipt's photo and logged data together, with edit/delete for the
|
||||
/// entry. Deliberately never taps through to an actual delete: that would
|
||||
/// call [AppState.deleteFuelEntry], which needs an initialized database not
|
||||
/// set up here (see settings_purge_test.dart for the same convention).
|
||||
void main() {
|
||||
final vehicle = Vehicle(id: 'v1', vin: '1FMPU18L1TLB51349', nickname: 'Red Truck', updatedAt: DateTime.utc(2026, 1, 1));
|
||||
final entry = FuelEntry(
|
||||
id: 'e1',
|
||||
vehicleId: 'v1',
|
||||
date: DateTime(2026, 5, 14, 9, 30),
|
||||
gallons: 10.523,
|
||||
pricePerGallon: 3.499,
|
||||
totalCost: 36.81,
|
||||
updatedAt: DateTime.utc(2026, 5, 14),
|
||||
receiptDriveFileId: 'remote-1',
|
||||
);
|
||||
|
||||
Future<void> pumpDetail(WidgetTester tester, AppState appState) async {
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider.value(
|
||||
value: appState,
|
||||
child: MaterialApp(home: ReceiptDetailScreen(entryId: 'e1')),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('shows the vehicle, date, gallons, price, and total', (tester) async {
|
||||
final appState = AppState()
|
||||
..isLoading = false
|
||||
..vehicles = [vehicle]
|
||||
..fuelEntries = [entry];
|
||||
|
||||
await pumpDetail(tester, appState);
|
||||
|
||||
expect(find.text('Red Truck'), findsOneWidget);
|
||||
expect(find.text('VIN: 1FMPU18L1TLB51349'), findsOneWidget);
|
||||
expect(find.text('10.523 gal'), findsOneWidget);
|
||||
expect(find.text('\$3.50'), findsOneWidget, reason: 'price per gallon');
|
||||
expect(find.text('\$36.81'), findsOneWidget, reason: 'total cost');
|
||||
});
|
||||
|
||||
testWidgets('shows a fallback instead of crashing when the entry no longer exists',
|
||||
(tester) async {
|
||||
final appState = AppState()
|
||||
..isLoading = false
|
||||
..vehicles = [vehicle]
|
||||
..fuelEntries = [];
|
||||
|
||||
await pumpDetail(tester, appState);
|
||||
|
||||
expect(find.text('Receipt not found'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the edit icon opens EditFuelEntryScreen pre-filled', (tester) async {
|
||||
final appState = AppState()
|
||||
..isLoading = false
|
||||
..vehicles = [vehicle]
|
||||
..fuelEntries = [entry];
|
||||
|
||||
await pumpDetail(tester, appState);
|
||||
|
||||
await tester.tap(find.byTooltip('Edit fuel entry'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(EditFuelEntryScreen), findsOneWidget);
|
||||
expect(find.widgetWithText(TextFormField, '10.523'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the delete icon shows a warning dialog; Cancel dismisses it without deleting',
|
||||
(tester) async {
|
||||
final appState = AppState()
|
||||
..isLoading = false
|
||||
..vehicles = [vehicle]
|
||||
..fuelEntries = [entry];
|
||||
|
||||
await pumpDetail(tester, appState);
|
||||
|
||||
await tester.tap(find.byTooltip('Delete fuel entry'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Delete fuel entry?'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Delete fuel entry?'), findsNothing);
|
||||
expect(appState.fuelEntries, hasLength(1), reason: 'not deleted');
|
||||
});
|
||||
|
||||
testWidgets('tapping the photo opens the full-screen viewer', (tester) async {
|
||||
final appState = AppState()
|
||||
..isLoading = false
|
||||
..vehicles = [vehicle]
|
||||
..fuelEntries = [entry];
|
||||
|
||||
await pumpDetail(tester, appState);
|
||||
|
||||
await tester.tap(find.byType(GestureDetector).first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(ReceiptImageScreen), findsOneWidget);
|
||||
});
|
||||
}
|
||||
260
test/receipts_screen_test.dart
Normal file
260
test/receipts_screen_test.dart
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
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/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('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 {
|
||||
// Aug 5 2026: within Current Month, Last 3 Months, and Year to Date.
|
||||
final recentEntry = FuelEntry(
|
||||
id: 'recent',
|
||||
vehicleId: 'v1',
|
||||
date: DateTime(2026, 8, 5),
|
||||
gallons: 10,
|
||||
pricePerGallon: 3,
|
||||
totalCost: 30,
|
||||
updatedAt: DateTime.utc(2026, 8, 5),
|
||||
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),
|
||||
gallons: 20,
|
||||
pricePerGallon: 3,
|
||||
totalCost: 60,
|
||||
updatedAt: DateTime.utc(2026, 7, 1),
|
||||
receiptDriveFileId: 'r2',
|
||||
);
|
||||
// Mar 1 2026: within Year to Date only.
|
||||
final oldEntry = FuelEntry(
|
||||
id: 'old',
|
||||
vehicleId: 'v1',
|
||||
date: DateTime(2026, 3, 1),
|
||||
gallons: 40,
|
||||
pricePerGallon: 3,
|
||||
totalCost: 120,
|
||||
updatedAt: DateTime.utc(2026, 3, 1),
|
||||
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: all three visible.
|
||||
expect(find.text('Aug 5, 2026'), findsOneWidget);
|
||||
expect(find.text('Jul 1, 2026'), findsOneWidget);
|
||||
expect(find.text('Mar 1, 2026'), 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(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);
|
||||
});
|
||||
|
||||
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.
|
||||
}
|
||||
57
test/report_screen_test.dart
Normal file
57
test/report_screen_test.dart
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:fuel_tax_tracker/screens/report_screen.dart';
|
||||
import 'package:fuel_tax_tracker/services/app_state.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('shows both toggles, off by default, with the cross-reference hint only when both are on',
|
||||
(tester) async {
|
||||
final appState = AppState()..isLoading = false;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider.value(
|
||||
value: appState,
|
||||
child: const MaterialApp(home: ReportScreen()),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Include Receipt Images'), findsOneWidget);
|
||||
expect(find.text('Include Receipt Line Items'), findsOneWidget);
|
||||
expect(
|
||||
tester.widgetList<SwitchListTile>(find.byType(SwitchListTile)).map((s) => s.value),
|
||||
[false, false],
|
||||
);
|
||||
expect(find.textContaining('matching number'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Include Receipt Images'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('matching number'), findsNothing, reason: 'only one toggle is on');
|
||||
|
||||
await tester.tap(find.text('Include Receipt Line Items'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('matching number'), findsOneWidget, reason: 'both toggles now on');
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'shows a link to the state refund claim form, regardless of whether a range is picked',
|
||||
(tester) async {
|
||||
final appState = AppState()..isLoading = false;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider.value(
|
||||
value: appState,
|
||||
child: const MaterialApp(home: ReportScreen()),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Need the refund claim form?'), findsOneWidget);
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Get the Motor Fuel Refund Claim form'),
|
||||
200,
|
||||
scrollable: find.byType(Scrollable),
|
||||
);
|
||||
expect(find.text('Get the Motor Fuel Refund Claim form'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
136
test/report_vehicle_selection_test.dart
Normal file
136
test/report_vehicle_selection_test.dart
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
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/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
|
||||
/// 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
|
||||
/// entries in range". There's deliberately no checkbox — selection state is
|
||||
/// conveyed by color alone.
|
||||
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.
|
||||
final now = DateTime.now();
|
||||
|
||||
FuelEntry entryFor(String vehicleId, String id, double gallons, double cost) => FuelEntry(
|
||||
id: id,
|
||||
vehicleId: vehicleId,
|
||||
date: now,
|
||||
gallons: gallons,
|
||||
pricePerGallon: cost / gallons,
|
||||
totalCost: cost,
|
||||
updatedAt: now.toUtc(),
|
||||
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 {
|
||||
final appState = AppState()
|
||||
..isLoading = false
|
||||
..vehicles = [vehicleA, vehicleB]
|
||||
..fuelEntries = [
|
||||
entryFor('a', 'e1', 10, 35),
|
||||
entryFor('b', 'e2', 20, 70),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider.value(
|
||||
value: appState,
|
||||
child: const MaterialApp(home: ReportScreen()),
|
||||
),
|
||||
);
|
||||
return appState;
|
||||
}
|
||||
|
||||
Card cardFor(WidgetTester tester, String vehicleLabel) => tester.widget<Card>(
|
||||
find.ancestor(of: find.text(vehicleLabel), matching: find.byType(Card)),
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
expect(cardFor(tester, 'Truck').color, colorScheme.primary);
|
||||
expect(cardFor(tester, 'Van').color, colorScheme.primary);
|
||||
expect(find.text('30.000 gal · \$105.00'), findsOneWidget, reason: 'combined total');
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
await tester.tap(find.text('Truck'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('20.000 gal · \$70.00'), findsOneWidget, reason: "only Van's total remains");
|
||||
expect(cardFor(tester, 'Truck').color, isNot(colorScheme.primary), reason: 'deselected');
|
||||
expect(cardFor(tester, 'Van').color, colorScheme.primary, reason: 'still selected');
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
await tester.tap(find.text('Truck'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Truck'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(cardFor(tester, 'Truck').color, colorScheme.primary);
|
||||
expect(find.text('30.000 gal · \$105.00'), findsOneWidget, reason: 'both counted again');
|
||||
});
|
||||
|
||||
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();
|
||||
await tester.tap(find.text('Van'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('No vehicles selected — tap at least one above to build a report.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Share'), findsNothing);
|
||||
expect(find.text('Print'), findsNothing);
|
||||
// The cards themselves stay visible so the user can tap one again.
|
||||
expect(find.text('Truck'), findsOneWidget);
|
||||
expect(find.text('Van'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
117
test/settings_purge_test.dart
Normal file
117
test/settings_purge_test.dart
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
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/services/app_state.dart';
|
||||
|
||||
/// Covers the "Danger Zone" purge UI's warning-dialog gating, now folded
|
||||
/// into the Settings > Data > Advanced section — the actual delete behavior
|
||||
/// (soft-delete tombstones, dirty flags, receipt file cleanup) is covered
|
||||
/// against a real database in purge_data_test.dart. This deliberately never
|
||||
/// taps a dialog's destructive "Delete" button: doing so would call through
|
||||
/// to [AppState.purgeAllData]/[AppState.purgeFuelEntriesInRange], which need
|
||||
/// an initialized database — not set up here, matching every other Settings
|
||||
/// widget test (see theme_toggle_test.dart) that stops short of a full
|
||||
/// [AppState.init].
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
Future<void> pumpSettings(WidgetTester tester, AppState appState) async {
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider.value(
|
||||
value: appState,
|
||||
child: const MaterialApp(home: DataSettingsScreen()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// The purge controls sit inside the collapsed "Advanced" ExpansionTile,
|
||||
// below the fold — every test needs to expand it and scroll it into view
|
||||
// before tapping anything inside it.
|
||||
Future<void> scrollToDangerZone(WidgetTester tester) async {
|
||||
await tester.scrollUntilVisible(find.text('Advanced'), 200, scrollable: find.byType(Scrollable));
|
||||
await tester.tap(find.text('Advanced'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.scrollUntilVisible(find.text('Danger Zone'), 200, scrollable: find.byType(Scrollable));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('Purge All Data shows a warning dialog; Cancel dismisses it without purging',
|
||||
(tester) async {
|
||||
final appState = AppState()..isLoading = false;
|
||||
await pumpSettings(tester, appState);
|
||||
await scrollToDangerZone(tester);
|
||||
|
||||
await tester.tap(find.widgetWithText(OutlinedButton, 'Purge All Data'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Purge all data?'), findsOneWidget);
|
||||
expect(find.textContaining("can't be undone"), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Purge all data?'), findsNothing);
|
||||
expect(appState.vehicles, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('mentions the connected cloud provider in the warning when one is connected',
|
||||
(tester) async {
|
||||
final appState = AppState()..isLoading = false;
|
||||
await pumpSettings(tester, appState);
|
||||
await scrollToDangerZone(tester);
|
||||
|
||||
await tester.tap(find.widgetWithText(OutlinedButton, 'Purge All Data'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('connected to'), findsNothing,
|
||||
reason: 'no cloud provider connected in this test');
|
||||
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('Purge Range is disabled until both a start and end date are picked',
|
||||
(tester) async {
|
||||
final appState = AppState()..isLoading = false;
|
||||
await pumpSettings(tester, appState);
|
||||
await scrollToDangerZone(tester);
|
||||
|
||||
final purgeRangeButton =
|
||||
tester.widget<OutlinedButton>(find.widgetWithText(OutlinedButton, 'Purge Range'));
|
||||
expect(purgeRangeButton.onPressed, isNull);
|
||||
});
|
||||
|
||||
testWidgets('picking a start and end date enables Purge Range, which shows a warning dialog',
|
||||
(tester) async {
|
||||
final appState = AppState()..isLoading = false;
|
||||
await pumpSettings(tester, appState);
|
||||
await scrollToDangerZone(tester);
|
||||
|
||||
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();
|
||||
|
||||
final purgeRangeButton =
|
||||
tester.widget<OutlinedButton>(find.widgetWithText(OutlinedButton, 'Purge Range'));
|
||||
expect(purgeRangeButton.onPressed, isNotNull);
|
||||
|
||||
await tester.tap(find.widgetWithText(OutlinedButton, 'Purge Range'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Purge data in range?'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
}
|
||||
59
test/settings_screen_test.dart
Normal file
59
test/settings_screen_test.dart
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import 'package:flutter/material.dart';
|
||||
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/settings_screen.dart';
|
||||
import 'package:fuel_tax_tracker/screens/ui_settings_screen.dart';
|
||||
import 'package:fuel_tax_tracker/services/app_state.dart';
|
||||
|
||||
/// Covers the Settings landing page's menu wiring: it groups settings into
|
||||
/// two submenus — [UiSettingsScreen] (appearance) and [DataSettingsScreen]
|
||||
/// (cloud storage, photo syncing/quality, advanced/purge) — plus keeps a
|
||||
/// read-only summary on the landing page itself. The submenu screens'
|
||||
/// actual contents are covered by theme_toggle_test.dart and
|
||||
/// settings_purge_test.dart respectively.
|
||||
void main() {
|
||||
Future<void> pumpSettings(WidgetTester tester) async {
|
||||
final appState = AppState()..isLoading = false;
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider.value(
|
||||
value: appState,
|
||||
child: const MaterialApp(home: SettingsScreen()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('shows UI and Data menu entries, plus the summary', (tester) async {
|
||||
await pumpSettings(tester);
|
||||
|
||||
expect(find.text('UI'), findsOneWidget);
|
||||
expect(find.text('Data'), findsOneWidget);
|
||||
expect(find.text('Summary'), findsOneWidget);
|
||||
// None of Data's contents leaked onto the landing page.
|
||||
expect(find.text('Cloud Storage'), findsNothing);
|
||||
expect(find.text('Advanced'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('tapping UI opens UiSettingsScreen', (tester) async {
|
||||
await pumpSettings(tester);
|
||||
|
||||
await tester.tap(find.text('UI'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(UiSettingsScreen), findsOneWidget);
|
||||
expect(find.text('Appearance'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tapping Data opens DataSettingsScreen', (tester) async {
|
||||
await pumpSettings(tester);
|
||||
|
||||
await tester.tap(find.text('Data'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(DataSettingsScreen), findsOneWidget);
|
||||
expect(find.text('Cloud Storage'), findsOneWidget);
|
||||
await tester.scrollUntilVisible(find.text('Advanced'), 200, scrollable: find.byType(Scrollable));
|
||||
expect(find.text('Advanced'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
64
test/theme_toggle_test.dart
Normal file
64
test/theme_toggle_test.dart
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:fuel_tax_tracker/screens/ui_settings_screen.dart';
|
||||
import 'package:fuel_tax_tracker/services/app_state.dart';
|
||||
|
||||
/// Covers the light/dark/system theme toggle on the Settings > UI screen —
|
||||
/// defaults to System, selecting a segment updates [AppState.themeMode]
|
||||
/// and persists it to SharedPreferences (so it survives a cold start).
|
||||
///
|
||||
/// Deliberately doesn't round-trip through a second [AppState.init] to
|
||||
/// verify persistence — that pulls in unrelated subsystems (connectivity
|
||||
/// listening, cloud-restore) with no test-environment platform mocks,
|
||||
/// which hangs rather than failing fast. Reading the pref back directly
|
||||
/// verifies the same thing without that baggage.
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
testWidgets('defaults to System and switches to Dark/Light on tap', (tester) async {
|
||||
final appState = AppState()..isLoading = false;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider.value(
|
||||
value: appState,
|
||||
child: const MaterialApp(home: UiSettingsScreen()),
|
||||
),
|
||||
);
|
||||
|
||||
expect(appState.themeMode, ThemeMode.system);
|
||||
|
||||
await tester.tap(find.text('Dark'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(appState.themeMode, ThemeMode.dark);
|
||||
|
||||
await tester.tap(find.text('Light'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(appState.themeMode, ThemeMode.light);
|
||||
});
|
||||
|
||||
testWidgets('persists the choice to SharedPreferences', (tester) async {
|
||||
final appState = AppState()..isLoading = false;
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider.value(
|
||||
value: appState,
|
||||
child: const MaterialApp(home: UiSettingsScreen()),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Dark'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
// Mirrors the private `_prefsKeyThemeMode` key in app_state.dart —
|
||||
// a black-box persistence check necessarily has to know the storage
|
||||
// key, and this doubles as a guardrail against silently breaking
|
||||
// already-persisted user settings by renaming it there.
|
||||
expect(prefs.getString('theme_mode'), 'dark');
|
||||
});
|
||||
}
|
||||
88
test/vehicle_detail_screen_test.dart
Normal file
88
test/vehicle_detail_screen_test.dart
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.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';
|
||||
|
||||
/// Covers the "modern navigation" swipe added to the single-vehicle
|
||||
/// receipt page: it's a PageView over every vehicle, starting on whichever
|
||||
/// one was tapped — sliding left/right moves to the next/previous vehicle,
|
||||
/// same as swiping between MainShell's bottom-nav tabs.
|
||||
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));
|
||||
final vehicleC = Vehicle(id: 'c', vin: 'VINCCCCCCCCCCCCCC', nickname: 'Sedan', updatedAt: DateTime.utc(2026, 1, 1));
|
||||
|
||||
Future<void> pumpDetail(WidgetTester tester, AppState appState, String vehicleId) async {
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider.value(
|
||||
value: appState,
|
||||
child: MaterialApp(home: VehicleDetailScreen(vehicleId: vehicleId)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('starts on the requested vehicle, not necessarily the first in the list',
|
||||
(tester) async {
|
||||
final appState = AppState()
|
||||
..isLoading = false
|
||||
..vehicles = [vehicleA, vehicleB, vehicleC];
|
||||
|
||||
await pumpDetail(tester, appState, 'b');
|
||||
|
||||
expect(find.widgetWithText(AppBar, 'Van'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a right-to-left swipe advances to the next vehicle', (tester) async {
|
||||
final appState = AppState()
|
||||
..isLoading = false
|
||||
..vehicles = [vehicleA, vehicleB, vehicleC];
|
||||
|
||||
await pumpDetail(tester, appState, 'a');
|
||||
expect(find.widgetWithText(AppBar, 'Truck'), findsOneWidget);
|
||||
|
||||
await tester.fling(find.byType(PageView), const Offset(-400, 0), 1000);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.widgetWithText(AppBar, 'Van'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a left-to-right swipe goes back to the previous vehicle', (tester) async {
|
||||
final appState = AppState()
|
||||
..isLoading = false
|
||||
..vehicles = [vehicleA, vehicleB, vehicleC];
|
||||
|
||||
await pumpDetail(tester, appState, 'c');
|
||||
expect(find.widgetWithText(AppBar, 'Sedan'), findsOneWidget);
|
||||
|
||||
await tester.fling(find.byType(PageView), const Offset(400, 0), 1000);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.widgetWithText(AppBar, 'Van'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('swiping does not go past the last vehicle', (tester) async {
|
||||
final appState = AppState()
|
||||
..isLoading = false
|
||||
..vehicles = [vehicleA, vehicleB];
|
||||
|
||||
await pumpDetail(tester, appState, 'b');
|
||||
|
||||
await tester.fling(find.byType(PageView), const Offset(-400, 0), 1000);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.widgetWithText(AppBar, 'Van'), findsOneWidget, reason: 'stayed on the last page');
|
||||
});
|
||||
|
||||
testWidgets('a single vehicle has nothing to swipe to and just shows itself', (tester) async {
|
||||
final appState = AppState()
|
||||
..isLoading = false
|
||||
..vehicles = [vehicleA];
|
||||
|
||||
await pumpDetail(tester, appState, 'a');
|
||||
|
||||
expect(find.widgetWithText(AppBar, 'Truck'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
|
@ -26,10 +26,40 @@ void main() {
|
|||
expect(VinParser.parse('vin: 1hgcm82633a004352'), '1HGCM82633A004352');
|
||||
});
|
||||
|
||||
test('does not match a run containing I, O, or Q (never valid in a real VIN)', () {
|
||||
// Same length (17) but contains an "O" partway through, so no
|
||||
// 17-character eligible-charset run exists anywhere in it.
|
||||
expect(VinParser.parse('1HGCM82633AOO4352'), isNull);
|
||||
test('normalizes an O to 0 — real VINs never contain one, so it reads as a misread zero', () {
|
||||
// Real OCR output from a Kia/Hyundai data plate photo: "0" (zero)
|
||||
// came back as "O" (letter) — this is exactly why I/O/Q are excluded
|
||||
// from real VINs in the first place (too easily confused with
|
||||
// 1/0/9), which cuts both ways for OCR reading one back off a plate.
|
||||
expect(VinParser.parse('V.I.N 5XYZKDAG2BGO50982 TYPE:MPY'), '5XYZKDAG2BG050982');
|
||||
});
|
||||
|
||||
test('normalizes an I to 1 the same way', () {
|
||||
expect(VinParser.parse('VIN: 1HGCM82633AI04352'), '1HGCM82633A104352');
|
||||
});
|
||||
|
||||
test('does not match a run containing Q — unlike I/O, not a plausible misread digit', () {
|
||||
expect(VinParser.parse('1HGCM82633AQ04352'), isNull);
|
||||
});
|
||||
|
||||
test('retries with whitespace stripped when OCR splits the VIN across a stray space', () {
|
||||
// Real OCR output from an etched-glass VIN photo: a space landed
|
||||
// mid-VIN (likely two separate detected text regions on the curved
|
||||
// surface), so no single unbroken 17-character run exists until the
|
||||
// whitespace is collapsed.
|
||||
expect(VinParser.parse('5XYZKD AG2BG050982'), '5XYZKDAG2BG050982');
|
||||
});
|
||||
|
||||
test('a whitespace-stripped retry is only a last resort, after a normal match is preferred',
|
||||
() {
|
||||
const text = '''
|
||||
PART NO 4F2CZ 58899BA12345
|
||||
VIN: 1HGCM82633A004352
|
||||
''';
|
||||
// The "VIN:"-labeled match (unaffected by the space in the unrelated
|
||||
// part number above it) should win over anything the fallback might
|
||||
// have found in the stripped text.
|
||||
expect(VinParser.parse(text), '1HGCM82633A004352');
|
||||
});
|
||||
|
||||
test('does not match a 16-character (too short) run', () {
|
||||
|
|
@ -43,5 +73,34 @@ void main() {
|
|||
test('returns null when nothing matches', () {
|
||||
expect(VinParser.parse('THANK YOU FOR YOUR PURCHASE'), isNull);
|
||||
});
|
||||
|
||||
test('matches a "V.I.N" label (period between each letter) — common on compliance/data plates',
|
||||
() {
|
||||
// Real text from a Kia/Hyundai-built data plate: "V.I.N" rather than
|
||||
// "VIN:" — a manufacturer plate styling the labeled pattern didn't
|
||||
// originally account for, so this fell through to relying on OCR
|
||||
// reading the bare 17-char run cleanly.
|
||||
const text = '''
|
||||
THIS VEHICLE CONFORMS TO ALL APPLICABLE U.S.A. FEDERAL
|
||||
MOTOR VEHICLE SAFETY AND THEFT PREVENTION STANDARDS
|
||||
IN EFFECT ON THE DATE OF MANUFACTURE SHOWN ABOVE
|
||||
V.I.N 5XYZKDAG2BG050982 TYPE:MPV
|
||||
''';
|
||||
|
||||
expect(VinParser.parse(text), '5XYZKDAG2BG050982');
|
||||
});
|
||||
|
||||
test('matches "V.I.N." with a trailing period too', () {
|
||||
expect(VinParser.parse('V.I.N. 5XYZKDAG2BG050982'), '5XYZKDAG2BG050982');
|
||||
});
|
||||
|
||||
test('a "V.I.N" labeled match is still preferred over another candidate elsewhere', () {
|
||||
const text = '''
|
||||
PART NO 4F2CZ58899BA12345
|
||||
V.I.N 5XYZKDAG2BG050982
|
||||
''';
|
||||
|
||||
expect(VinParser.parse(text), '5XYZKDAG2BG050982');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ void main() {
|
|||
),
|
||||
);
|
||||
|
||||
expect(find.text('My Vehicles'), findsOneWidget);
|
||||
expect(find.text('Add Vehicle'), findsOneWidget);
|
||||
expect(find.text('Vehicles'), findsOneWidget);
|
||||
expect(find.byTooltip('Add Vehicle'), findsOneWidget);
|
||||
expect(find.textContaining('No vehicles yet'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue