MO-Fuel-Tax-Back/test/fuel_report_test.dart

312 lines
10 KiB
Dart

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';
void main() {
final truck = Vehicle(
id: 'v1',
vin: '1FMPU18L1TLB51349',
nickname: 'Truck',
updatedAt: DateTime.utc(2026, 1, 1),
);
final car = Vehicle(
id: 'v2',
vin: '1GNWK5EG4DR302326',
updatedAt: DateTime.utc(2026, 1, 1),
);
FuelEntry entry({
required String id,
required String vehicleId,
required DateTime date,
double gallons = 10,
double totalCost = 35,
String? receiptImagePath,
String? receiptDriveFileId,
}) =>
FuelEntry(
id: id,
vehicleId: vehicleId,
date: date,
gallons: gallons,
pricePerGallon: totalCost / gallons,
totalCost: totalCost,
updatedAt: date,
receiptImagePath: receiptImagePath,
receiptDriveFileId: receiptDriveFileId,
);
test('sums gallons and cost per vehicle, only for entries with a receipt', () {
final report = buildFuelReport(
vehicles: [truck, car],
fuelEntries: [
entry(
id: 'e1',
vehicleId: 'v1',
date: DateTime(2026, 3, 5),
gallons: 10,
totalCost: 35,
receiptImagePath: '/local/a.jpg'),
entry(
id: 'e2',
vehicleId: 'v1',
date: DateTime(2026, 3, 20),
gallons: 12,
totalCost: 42,
receiptDriveFileId: 'remote-1'),
// No receipt at all — should be excluded even though it's in range.
entry(id: 'e3', vehicleId: 'v1', date: DateTime(2026, 3, 10), gallons: 99, totalCost: 999),
],
startDate: DateTime(2026, 3, 1),
endDate: DateTime(2026, 3, 31),
);
expect(report.rows, hasLength(1));
expect(report.rows.single.vehicle.id, 'v1');
expect(report.rows.single.entryCount, 2);
expect(report.rows.single.totalGallons, 22);
expect(report.rows.single.totalCost, 77);
});
test('date range is inclusive of both endpoints', () {
final report = buildFuelReport(
vehicles: [truck],
fuelEntries: [
entry(
id: 'first-day',
vehicleId: 'v1',
date: DateTime(2026, 3, 1),
receiptImagePath: '/a.jpg'),
entry(
id: 'last-day',
vehicleId: 'v1',
date: DateTime(2026, 3, 31),
receiptImagePath: '/b.jpg'),
entry(
id: 'day-after',
vehicleId: 'v1',
date: DateTime(2026, 4, 1),
receiptImagePath: '/c.jpg'),
],
startDate: DateTime(2026, 3, 1),
endDate: DateTime(2026, 3, 31),
);
expect(report.rows.single.entryCount, 2);
});
test('a receipt-photo time-of-day does not push it out of range', () {
final report = buildFuelReport(
vehicles: [truck],
fuelEntries: [
entry(
id: 'late-in-day',
vehicleId: 'v1',
date: DateTime(2026, 3, 31, 23, 59),
receiptImagePath: '/a.jpg',
),
],
startDate: DateTime(2026, 3, 1),
endDate: DateTime(2026, 3, 31),
);
expect(report.rows.single.entryCount, 1);
});
test('omits vehicles with no qualifying entries rather than a zero row', () {
final report = buildFuelReport(
vehicles: [truck, car],
fuelEntries: [
entry(id: 'e1', vehicleId: 'v1', date: DateTime(2026, 3, 5), receiptImagePath: '/a.jpg'),
],
startDate: DateTime(2026, 3, 1),
endDate: DateTime(2026, 3, 31),
);
expect(report.rows, hasLength(1));
expect(report.rows.single.vehicle.id, 'v1');
});
test('rows are sorted by vehicle display label', () {
final zVehicle = Vehicle(
id: 'v3',
vin: 'ZZZ00000000000000',
nickname: 'Zeta',
updatedAt: DateTime.utc(2026, 1, 1),
);
final aVehicle = Vehicle(
id: 'v4',
vin: 'AAA00000000000000',
nickname: 'Alpha',
updatedAt: DateTime.utc(2026, 1, 1),
);
final report = buildFuelReport(
vehicles: [zVehicle, aVehicle],
fuelEntries: [
entry(id: 'e1', vehicleId: 'v3', date: DateTime(2026, 3, 5), receiptImagePath: '/a.jpg'),
entry(id: 'e2', vehicleId: 'v4', date: DateTime(2026, 3, 5), receiptImagePath: '/b.jpg'),
],
startDate: DateTime(2026, 3, 1),
endDate: DateTime(2026, 3, 31),
);
expect(report.rows.map((r) => r.vehicle.id), ['v4', 'v3']);
});
test('report totals sum across all vehicle rows', () {
final report = buildFuelReport(
vehicles: [truck, car],
fuelEntries: [
entry(
id: 'e1',
vehicleId: 'v1',
date: DateTime(2026, 3, 5),
gallons: 10,
totalCost: 35,
receiptImagePath: '/a.jpg'),
entry(
id: 'e2',
vehicleId: 'v2',
date: DateTime(2026, 3, 6),
gallons: 8,
totalCost: 28,
receiptImagePath: '/b.jpg'),
],
startDate: DateTime(2026, 3, 1),
endDate: DateTime(2026, 3, 31),
);
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']);
});
group('defaultReportDateRange', () {
test('from August through year-end, defaults to July of that year through the following June',
() {
final (start, end) = defaultReportDateRange(DateTime(2026, 8, 17));
expect(start, DateTime(2026, 7, 1));
expect(end, DateTime(2027, 6, 30));
});
test('from January through July, still defaults to the July before through this June', () {
final (start, end) = defaultReportDateRange(DateTime(2027, 7, 15));
expect(start, DateTime(2026, 7, 1));
expect(end, DateTime(2027, 6, 30));
});
test('the next cycle (August the following year) rolls the whole range forward a year', () {
final (start, end) = defaultReportDateRange(DateTime(2027, 8, 1));
expect(start, DateTime(2027, 7, 1));
expect(end, DateTime(2028, 6, 30));
});
test('July 31 is still the old cycle; August 1 is the new one', () {
final stillOld = defaultReportDateRange(DateTime(2026, 7, 31));
expect(stillOld.$1, DateTime(2025, 7, 1));
expect(stillOld.$2, DateTime(2026, 6, 30));
final nowNew = defaultReportDateRange(DateTime(2026, 8, 1));
expect(nowNew.$1, DateTime(2026, 7, 1));
expect(nowNew.$2, DateTime(2027, 6, 30));
});
});
}