Files
DriverVault/Phone App/test/models_format_test.dart
T
tajniak81andClaude Opus 5 6b7abb4b84 Phone App: a service card laid out by the car's own columns
The Service history columns became a property of the car two commits ago, and
the phone was left out of it on the grounds that it has no table to arrange.
But the arrangement is not the table's — it belongs to the car, and everyone it
is shared with sees it. A reader who switched Oil off on the web still had it
on every card here, which makes the setting look broken rather than absent.

A card is not a table, so the columns cannot be cells. Consecutive short ones
share a wrapping line, which flows left to right and then down and so keeps the
arrangement intact; the parts, the notes and the file each take a line of their
own. That means the grouping follows the car's order rather than the
catalogue's — move Notes between Km and Next date and the short columns split
around it — which is the part a hand-written card gets wrong by collecting the
short columns first and appending the blocks after them, quietly undoing the
arrangement it was asked to honour. serviceColumnRuns is a function for exactly
that reason: it is the piece worth a test.

The date carries no heading where every other column does. A card list is read
down its dates, and "Date" in front of one says nothing the date doesn't — the
same judgment the server makes by refusing to hide it. It is offered in the
picker anyway, ticked and locked, because a row missing from that list is a row
nothing on this screen can drag; the web drags the column headings themselves,
which on a touch screen is the scroll's gesture. A column that is on but empty
says so ("Notes —") rather than vanishing: it was switched on deliberately, and
a card that silently drops it reads as a record that failed to load.

Changed parts arrives with it. Every part shares the one column — they are a
growing list and a column apiece would widen the web's table without end — and
lib/service_parts.dart is the twin of the web's lib/serviceParts.js, so the
form's checkboxes and the card's chips come from one list and adding a part is
one entry plus its boolean on service_records. The chips keep their own shorter
wording; the form keeps the web's, which is what stops the two apps naming the
same part differently.

Verified: flutter analyze is clean and flutter test passes, 22 tests to 32 —
the new ones cover that the arrangeable set is the hideable one plus the date,
that a field key is not a column key, that adding a part adds no column, the
run grouping, and a widget test of the picker showing the date's is the only
locked checkbox. The screen itself was driven against a throwaway stub API: a
default car renders date, Km, Next date, Next km, chips, notes and file in that
order, and a car hiding km and file with the order [notes, date, nextKm, parts,
km, nextDate] rendered exactly that — notes first, both hidden columns gone, the
short columns split around the chips. updateCarView round-trips both new fields
under the names records.go decodes.

Not verified: the picker's own Save button — the tap landed in the harness but
the request never reached the stub, which reads as the fire-and-forget future
being cut off at teardown, since the same call made directly worked. Drag was
exercised through the reorder callback, not by a finger. Nothing here needs the
API Server to change: both fields already ship, and a phone running against an
older one simply reads empty lists and shows every column.

Two commits needed nothing: the web's masked date box answers <input
type="date"> rendering in the browser's locale, which a picker-only field
cannot have, and the garage card's width answers a badge that wrapped, which
this badge cannot. car.services.next goes, its prose replaced by the columns
that now say it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 21:32:45 +02:00

534 lines
21 KiB
Dart

// Parses server-shaped JSON through the models and checks the status/format
// helpers.
//
// These cover the parts where a mistake is invisible until it reaches a user: a
// derived field the server left out must stay null rather than becoming a
// plausible-looking zero, the badge wording has to match the web app's, and an
// unsupported locale (settable from the web, whose lists are wider than the
// phone's) must not throw out of every date on screen.
import "dart:convert";
import "dart:io";
import "package:flutter_test/flutter_test.dart";
import "package:intl/date_symbol_data_local.dart";
import "package:drivervault_phone/format.dart";
import "package:drivervault_phone/i18n.dart";
import "package:drivervault_phone/main.dart";
import "package:drivervault_phone/models.dart";
import "package:drivervault_phone/screens/car_form_sheet.dart";
import "package:drivervault_phone/screens/car_view_sheet.dart";
import "package:drivervault_phone/service_parts.dart";
void main() {
// rootBundle (used by loadTranslations) needs the binding initialised.
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() async {
await initializeDateFormatting();
await loadTranslations();
// Polish exercises both the region-aware number grouping and the localized
// status wording (one/few/many plurals), so the badge labels below are the
// Polish strings — the same text the web app renders under pl.
appSettings.locale = "pl-PL";
appSettings.currency = "PLN";
appSettings.dateFormat = "DMY";
});
test("FuelEntry keeps uncomputed derived fields null, not zero", () {
final partial = FuelEntry.fromJson({
"id": "a",
"car": "c",
"date": "2026-07-01T00:00:00Z",
"km": 1000,
"liters": 20.0,
"cost": 120.0,
"fullTank": false,
"hasFile": false,
});
expect(partial.consumptionL100, isNull);
expect(partial.distanceKm, isNull);
expect(formatConsumption(partial.consumptionL100), "—");
final full = FuelEntry.fromJson({
"id": "b",
"car": "c",
"date": "2026-07-10T00:00:00Z",
"km": 1500,
"liters": 35.0,
"cost": 210.0,
"fullTank": true,
"consumptionL100": 6.85,
"distanceKm": 500,
"kmPerLiter": 14.6,
"pricePerLiter": 6.0,
"fileName": "receipt.pdf",
"hasFile": true,
});
expect(full.consumptionL100, 6.85);
// 6.85 as a float64 is really 6.8499…, so one-decimal rounding yields 6.8 —
// the same answer JS toFixed(1) gives the web app.
expect(formatConsumption(full.consumptionL100), "6.8 L/100km");
expect(full.hasFile, isTrue);
expect(full.fileName, "receipt.pdf");
});
test("expiryStatus wording follows the server assessment", () {
CarDocument doc(String state, int? days) => CarDocument.fromJson({
"id": "d",
"car": "c",
"type": "insurance",
"title": "OC",
"expiry": {"state": state, "daysUntilExpiry": days},
"hasFile": false,
});
expect(expiryStatus(doc("expired", -5).expiry).label, "Wygasło 5 dni temu");
expect(expiryStatus(doc("expiring_soon", 0).expiry).label, "Wygasa dzisiaj");
expect(expiryStatus(doc("expiring_soon", 12).expiry).label, "Odnowienie za 12 dni");
expect(expiryStatus(doc("valid", 200).expiry).label, "Ważne · 200 dni");
expect(expiryStatus(doc("no_expiry", null).expiry).label, "Bezterminowe");
expect(expiryStatus(doc("expired", -5).expiry).key, StatusKey.overdue);
});
test("reminderStatus leads with the driving trigger", () {
Reminder rem(Map<String, dynamic> extra) =>
Reminder.fromJson({"id": "r", "car": "c", "title": "t", "type": "service", ...extra});
expect(reminderStatus(rem({"status": "done", "done": true})).label, "Gotowe");
expect(reminderStatus(rem({"status": "no_trigger"})).label, "Brak wyzwalacza");
expect(
reminderStatus(rem({"status": "overdue", "daysLeft": -3, "kmLeft": -200})).label,
"Zaległe 3 dni · 200 km");
expect(reminderStatus(rem({"status": "due_soon", "daysLeft": 0})).label, "Termin za dzisiaj");
// The km count is grouped per the chosen locale (pl-PL groups thousands with
// a space), which is the whole point of routing every number through the one
// helper. Asserted as start + end so the exact space glyph (ICU uses a
// non-breaking space) does not make the test brittle.
final due = reminderStatus(rem({"status": "upcoming", "daysLeft": 40, "kmLeft": 5000})).label;
expect(due, startsWith("Termin za 40 dni · 5"));
expect(due, endsWith("000 km"));
});
test("TechnicalCheck: a failed check derives no next date", () {
final failed = TechnicalCheck.fromJson({
"id": "t",
"car": "c",
"date": "2026-07-01T00:00:00Z",
"result": "failed",
"cost": 99.0,
"expiry": {"state": "no_expiry", "daysUntilExpiry": null},
"hasFile": false,
});
expect(failed.nextCheckDate, isNull);
expect(failed.passed, isFalse);
});
test("Maintenance derived warranty + total", () {
final m = MaintenanceEntry.fromJson({
"id": "m",
"car": "c",
"date": "2026-07-01T00:00:00Z",
"km": 1000,
"type": "repair",
"status": "completed",
"laborCost": 100.0,
"partsCost": 50.0,
"totalCost": 150.0,
"warrantyActive": true,
"warrantyDaysLeft": 10,
"hasFile": false,
});
expect(m.totalCost, 150.0);
expect(warrantyStatus(m)!.label, "Gwarancja kończy się za 10 dni");
expect(warrantyStatus(m)!.key, StatusKey.soon);
final noWarranty = MaintenanceEntry.fromJson(
{"id": "m", "car": "c", "date": "2026-07-01T00:00:00Z", "hasFile": false});
expect(warrantyStatus(noWarranty), isNull);
});
test("money and numbers follow the chosen locale/currency", () {
expect(formatMoney(1234.5).contains("zł"), isTrue);
expect(formatMoney(null), "—");
// An unsupported language must not throw — it can be set from the web.
appSettings.locale = "rm-CH";
expect(() => formatDate(DateTime(2026, 7, 17)), returnsNormally);
expect(() => formatMoney(10), returnsNormally);
expect(() => formatKm(15000), returnsNormally);
appSettings.locale = "pl-PL";
});
test("a half-known date prints at its own precision, not padded to a day", () {
// What the web app can now enter for a build date. The phone's picker only
// makes full dates, but it has to read and keep these.
appSettings.dateFormat = "DMY_NUM";
expect(formatPartialDate("2015"), "2015");
expect(formatPartialDate("2015-03"), "03-2015");
expect(formatPartialDate("2015-03-10"), "10-03-2015");
expect(formatPartialDate(""), "—");
// The year and the month must not be shuffled by the local time zone, which
// is what parsing "2015-03" as a UTC instant would risk.
appSettings.dateFormat = "YMD";
expect(formatPartialDate("2015-01"), "2015-01");
expect(formatPartialDate("2015-12"), "2015-12");
appSettings.dateFormat = "DMY_NUM";
});
test("Car carries the technical check interval", () {
final car = Car.fromJson({"id": "c", "name": "Yaris", "technicalCheckIntervalDays": 730});
expect(car.technicalCheckIntervalDays, 730);
});
test("ChargingSession keeps uncomputed derived fields null, not zero", () {
final partial = ChargingSession.fromJson({
"id": "a",
"car": "c",
"date": "2026-07-01T00:00:00Z",
"km": 1000,
"kwh": 22.5,
"cost": 30.0,
"fullCharge": false,
"hasFile": false,
});
expect(partial.consumptionKwh100, isNull);
expect(partial.distanceKm, isNull);
expect(formatKwhConsumption(partial.consumptionKwh100), "—");
expect(formatKmPerKwh(partial.kmPerKwh), "—");
expect(formatKwh(partial.kwh), "22.50 kWh");
final closed = ChargingSession.fromJson({
"id": "b",
"car": "c",
"date": "2026-07-20T00:00:00Z",
"km": 1400,
"kwh": 60.0,
"cost": 90.0,
"fullCharge": true,
"pricePerKwh": 1.5,
"distanceKm": 400,
"consumptionKwh100": 15.0,
"kmPerKwh": 6.67,
});
expect(closed.consumptionKwh100, 15.0);
expect(formatKwhConsumption(closed.consumptionKwh100), "15.0 kWh/100km");
});
test("ChargingStats leaves averages null when nothing is computable", () {
final stats = ChargingStats.fromJson({
"entries": 1,
"totalKwh": 22.5,
"totalCost": 30.0,
"trackedDistanceKm": 0,
});
expect(stats.entries, 1);
expect(stats.avgConsumptionKwh100, isNull);
expect(formatKwhConsumption(stats.avgConsumptionKwh100), "—");
});
test("Car carries the provider link and the view arrangement", () {
final car = Car.fromJson({
"id": "c",
"name": "bZ4X",
"provider": "toyota",
"providerVehicleId": "VIN123",
"hiddenTabs": ["fuel"],
"hiddenFields": ["differentialOil"],
"tabOrder": ["info", "charging"],
"fieldOrder": ["vin"],
"metricOrder": ["evRange", "odometer"],
"hiddenServiceColumns": ["parts"],
"serviceColumnOrder": ["notes", "date"],
});
expect(car.provider, "toyota");
expect(car.providerVehicleId, "VIN123");
expect(car.hiddenTabs, ["fuel"]);
expect(car.tabOrder, ["info", "charging"]);
expect(car.metricOrder, ["evRange", "odometer"]);
expect(car.hiddenServiceColumns, ["parts"]);
expect(car.serviceColumnOrder, ["notes", "date"]);
// A car nobody has arranged carries empty lists, not nulls — the callers
// read them directly.
final plain = Car.fromJson({"id": "d", "name": "Yaris"});
expect(plain.provider, "");
expect(plain.hiddenTabs, isEmpty);
expect(plain.tabOrder, isEmpty);
expect(plain.hiddenServiceColumns, isEmpty);
expect(plain.serviceColumnOrder, isEmpty);
});
test("the Service history columns: date arranges but never hides", () {
// Mirrors hideableServiceColumns/arrangeableServiceColumns in the API's
// cars.go: the arrangeable set is the hideable one plus the date, and the
// server rejects a hidden set that names anything else — so the picker must
// never offer the date as something to switch off.
expect(kHideableServiceColumnKeys, isNot(contains("date")));
expect(kServiceColumnKeys, contains("date"));
expect({...kHideableServiceColumnKeys, "date"}, kServiceColumnKeys.toSet());
// A field key is not a column key. The two catalogues are separate lists of
// similar-looking strings, and sending one where the other belongs is a 400
// from the server with nothing on screen to explain it.
for (final key in kCarInfoFieldKeys) {
expect(kServiceColumnKeys, isNot(contains(key)));
}
// Every part shares the one "parts" column, so adding a part must not add a
// column — that is what keeps the key set from growing without end.
for (final part in kServiceParts) {
expect(kServiceColumnKeys, isNot(contains(part.key)));
}
// A column switched back on returns to where it was rather than to the end,
// because the stored order covers the hidden columns too.
final arranged = arrangeKeys(kServiceColumnKeys, ["notes", "km", "date"]);
expect(arranged.take(3).toList(), ["notes", "km", "date"]);
expect(arranged.toSet(), kServiceColumnKeys.toSet());
});
test("changedParts reads a record through the shared catalogue", () {
ServiceRecord record({bool oil = false, bool engine = false, bool cabin = false}) =>
ServiceRecord.fromJson({
"id": "s",
"car": "c",
"date": "2026-03-01T00:00:00Z",
"km": 90000,
"changedOil": oil,
"changedEngineAirFilter": engine,
"changedCabinAirFilter": cabin,
});
expect(changedParts(record()).isEmpty, isTrue);
expect(changedParts(record(oil: true)).map((p) => p.key), ["oil"]);
// Catalogue order, not the order the fields happen to be read in.
expect(
changedParts(record(cabin: true, oil: true)).map((p) => p.key),
["oil", "cabinFilter"],
);
// A record written before a part existed carries no field for it, which
// reads as "not changed" rather than as a missing value.
final old = ServiceRecord.fromJson({"id": "s", "car": "c", "km": 0});
expect(changedParts(old).isEmpty, isTrue);
});
test("arrangeKeys: partial orders keep every key, unknown ones are dropped", () {
// The keys the stored order names lead; the rest follow in catalogue order,
// which is what puts a tab added in a later release at the end of somebody's
// page rather than in the middle of it.
expect(
arrangeKeys(kCarTabKeys, ["charging", "info"]).take(2).toList(),
["charging", "info"],
);
expect(arrangeKeys(kCarTabKeys, ["charging", "info"]).length, kCarTabKeys.length);
expect(arrangeKeys(kCarTabKeys, ["charging", "info"]).toSet(), kCarTabKeys.toSet());
// A key from a newer release, and a duplicate, are both ignored.
final arranged = arrangeKeys(kCarTabKeys, ["tyres", "vin", "fuel", "fuel"]);
expect(arranged.first, "fuel");
expect(arranged.length, kCarTabKeys.length);
// No arrangement at all means the catalogue's own order.
expect(arrangeKeys(kCarInfoFieldKeys, const []), kCarInfoFieldKeys);
});
test("ProviderSnapshot: an unreachable provider parses as an answer, not a failure", () {
final closed = ProviderSnapshot.fromJson({
"provider": "toyota",
"label": "MyToyota",
"unavailable": true,
"detail": "this vehicle is not on your MyToyota account",
});
expect(closed.unavailable, isTrue);
expect(closed.vehicle, isNull);
expect(closed.metrics, isEmpty);
expect(closed.detail, contains("MyToyota"));
final open = ProviderSnapshot.fromJson({
"provider": "toyota",
"label": "MyToyota",
"fetchedAt": "2026-08-21T09:30:00Z",
"vehicle": {"id": "VIN123", "vin": "VIN123", "name": "bZ4X", "make": "Toyota", "year": 2024},
"metrics": [
{"key": "odometer", "value": "16138", "unit": "km"},
{"key": "batteryLevel", "value": "72", "unit": "%"},
],
"sections": [
{"id": "telemetry", "status": "ok", "fields": [{"key": "a.b", "value": "1"}]},
{"id": "notifications", "status": "error", "error": "upstream said no"},
],
"suggestedCurrentKm": 16138,
});
expect(open.vehicle!.subtitle, "Toyota 2024");
expect(open.metrics.first.key, "odometer");
expect(open.sections.last.status, "error");
expect(open.sections.first.fields.single.key, "a.b");
expect(open.suggestedCurrentKm, 16138);
expect(open.fetchedAt, isNotNull);
});
// The car screen looks these up by key at render time — t("car.tabs.$key"),
// t("enums.fuelType.$v") and the rest — so a missing entry is invisible to the
// analyzer and shows up as a raw key path on the screen. Every catalogue the
// UI iterates is checked here, in every language the app ships.
group("every catalogue key has a label", () {
void expectLabelled(String key) {
for (final lang in translatedLanguages) {
appSettings.locale = "$lang-${lang.toUpperCase()}";
expect(t(key), isNot(key), reason: "$key is missing from $lang.json");
expect(t(key).trim(), isNotEmpty, reason: "$key is blank in $lang.json");
}
}
tearDown(() => appSettings.locale = "pl-PL");
test("car tabs", () {
for (final key in kCarTabKeys) {
// "provider" has no key of its own by design: a linked car's tab is
// named after the service ("MyToyota"), and an unlinked one falls back
// to car.tabs.connected. Both apps label it that way.
if (key == "provider") continue;
expectLabelled("car.tabs.$key");
}
expectLabelled("car.tabs.connected");
});
test("Service history columns", () {
// serviceColumnLabel maps rather than derives, so a column added to the
// catalogue without an entry in that map would render as its own key.
for (final key in kServiceColumnKeys) {
for (final lang in translatedLanguages) {
appSettings.locale = "$lang-${lang.toUpperCase()}";
expect(serviceColumnLabel(key), isNot(key),
reason: "the $key column has no label in $lang.json");
expect(serviceColumnLabel(key).trim(), isNotEmpty,
reason: "the $key column is blank in $lang.json");
}
}
});
test("the parts a service can change", () {
// Both wordings: the form's and the card's chip. A part with only one of
// them reaches a screen as a raw key path.
for (final part in kServiceParts) {
expectLabelled(part.label);
expectLabelled(part.chipLabel);
}
});
test("Information rows", () {
for (final key in kCarInfoFieldKeys) {
expectLabelled("car.info.$key");
}
expectLabelled("car.info.technicalCheckInterval");
});
test("the delete dialog's per-collection counts", () {
// Plural objects rather than plain strings, so they are checked through
// the plural path instead of expectLabelled.
for (final key in ["services", "maintenance", "fuel", "charging", "documents", "parts"]) {
for (final lang in translatedLanguages) {
appSettings.locale = "$lang-${lang.toUpperCase()}";
final one = t("car.delete.$key", n: 1);
final many = t("car.delete.$key", n: 5);
expect(one, isNot("car.delete.$key"), reason: "car.delete.$key missing from $lang.json");
expect(one, contains("1"));
expect(many, contains("5"));
}
}
});
test("the enums the pickers and tiles share", () {
for (final key in kFuelTypes) {
expectLabelled("enums.fuelType.$key");
}
for (final key in ["repair", "inspection", "bodywork", "tyres", "diagnostics", "recall",
"warranty", "other"]) {
expectLabelled("enums.maintenanceType.$key");
}
for (final key in ["scheduled", "in_progress", "completed"]) {
expectLabelled("enums.maintenanceStatus.$key");
}
for (final key in ["insurance", "pollution", "registration", "inspection", "roadTax",
"warranty", "other"]) {
expectLabelled("enums.documentType.$key");
}
for (final key in ["maintenance", "document", "service", "inspection", "other"]) {
expectLabelled("enums.reminderType.$key");
}
});
test("the roles the admin screen assigns", () {
// Mirrors the roles in the API's users.go; the picker labels every one it
// offers, including a superadmin's own role shown to an admin who cannot
// assign it.
for (final key in ["user", "admin", "superadmin"]) {
expectLabelled("admin.roles.$key");
}
});
test("the connected service's headline readings", () {
// Mirrors headlineMetricSpecs + unmeasuredMetricKeys in the API's
// vehicleproviders.go: every reading it can report needs a label here.
for (final key in ["odometer", "fuelLevel", "fuelRange", "batteryLevel", "evRange",
"evRangeWithAc", "chargingStatus", "location"]) {
expectLabelled("car.provider.metrics.$key");
}
});
});
test("no key is left behind in a translation", () {
// Every key English has, the other languages have too. t() falls back to
// English for a missing one, so a gap is invisible until someone reads a
// half-translated screen — this is what makes it a test failure instead.
final en = json.decode(
File("assets/i18n/en.json").readAsStringSync()) as Map<String, dynamic>;
List<String> flatten(Map<String, dynamic> tree, [String prefix = ""]) => [
for (final e in tree.entries)
if (e.value is Map<String, dynamic>)
...flatten(e.value as Map<String, dynamic>, "$prefix${e.key}.")
else
"$prefix${e.key}",
];
dynamic lookup(Map<String, dynamic> tree, String key) {
dynamic node = tree;
for (final part in key.split(".")) {
if (node is! Map || !node.containsKey(part)) return null;
node = node[part];
}
return node;
}
final keys = flatten(en);
expect(keys, isNotEmpty);
for (final lang in translatedLanguages.where((l) => l != "en")) {
final other = json.decode(
File("assets/i18n/$lang.json").readAsStringSync()) as Map<String, dynamic>;
final missing = keys.where((k) => lookup(other, k) == null).toList();
expect(missing, isEmpty, reason: "$lang.json is missing ${missing.length} key(s)");
}
});
test("a day count reads as prose in each language's own plural forms", () {
appSettings.locale = "en-GB";
expect(t("car.info.daysValue", n: 1), "1 day");
expect(t("car.info.daysValue", n: 365), "365 days");
// Polish splits one/few/many, which an English-style n==1 test would miss.
appSettings.locale = "pl-PL";
expect(t("car.info.daysValue", n: 1), "1 dzień");
expect(t("car.info.daysValue", n: 365), "365 dni");
appSettings.locale = "pl-PL";
});
test("ImportResult reads the server's per-collection counts", () {
final res = ImportResult.fromJson(
{"carsImported": 2, "servicesImported": 11, "partsImported": 4});
expect(res.cars, 2);
expect(res.services, 11);
expect(res.parts, 4);
});
}