Two changes, both about showing what is actually known rather than a tidier version of it. The build date asked for a day. A car's build date is often only a year, or a month and a year - the VIN plate is stamped with a month, the papers carry a day, a grey import neither - so a field insisting on all three is answered either with an invented day or with nothing, and both throw away what the owner did know. The field now picks its own precision: a full date, a month and year, or a year, each with the control that suits it. A year is typed rather than picked, because a date picker that makes you walk back to 1998 is worse than four keystrokes. Stored as the ISO prefix - "2015", "2015-03", "2015-03-10" - which is ISO 8601 reduced precision, and printed back at exactly that precision. The three shapes sort and compare as strings in date order, which is why the prefix is stored rather than a date with a precision field beside it. The formatter takes the string apart rather than parsing it: "2015-03" read as a UTC instant and printed in local time hands back February west of Greenwich. Narrowing the precision keeps what is still true, so a day dropped from "2015-03-10" leaves "2015-03". Widening clears the field. That is the awkward half of the control and it is deliberate: there is nothing to widen a year with, and leaving "2015" behind an empty month box would store a date the screen is not showing. The column was free text with no validation at all, which was tolerable while only a date picker could write it and is not now that three shapes are legal. normalizeBuildDate parses rather than pattern-matches, so "2015-13" and "2015-02-31" are refused instead of stored as something no reader can print. The phone needed changing to avoid destroying this. It parsed buildDate with DateTime.tryParse, which returns null for "2015" - so a half-known date would have shown as a dash, and saving the car from the phone would have written "" back over it. It holds both date fields as the string they arrived as now, prints them at their own precision, and hands back anything it cannot set. Its picker still only makes full dates; a precision control there is a separate job. Separately: an empty cell of the service table had three different looks in one row. The dash under Notes was body-coloured, as though it were content; the one under File was 12px, having borrowed the size of the Download button that would otherwise be there; the one under Changed parts was muted at 14px. They are one constant now, muted at the row's own size, which is what Next date and Next km already did for a missing value. The Download link keeps its own styling - it is an action, not a value. Verified in a browser: a stored "2015-03" loads as month precision in a month picker, month to year narrows to "2015", year to day clears, "19x98abc" typed into the year box sanitises to "1998", saving sends buildDate:"1998" and the Information tab then reads "1998" - while a full first-registration date beside it still reads 06-08-2026. All five empty cells across the three columns now compute to the same size, colour and weight, with the filled ones unchanged. go vet and go test ./... pass with a new test over the three valid shapes and six rejects; flutter analyze is clean and 22 tests pass, one new, covering a half-known date in two date formats and the time zone that could shift it; npm run build is clean. Not verified: First registration still demands a full date. The same argument applies to it and the field is now a reusable component, but it was not asked for and is one line away. The web formatter's month-name paths - the DMY and MDY formats, which spell the month out - are covered only by the phone's mirror of the logic, the web app still having no test runner. A car created through the Toyota import bypasses the new validation; it only ever produces full dates, so nothing invalid gets in that way, but it is not guarded. Both apps need redeploying before any of this is visible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
449 lines
17 KiB
Dart
449 lines
17 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";
|
|
|
|
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"],
|
|
});
|
|
expect(car.provider, "toyota");
|
|
expect(car.providerVehicleId, "VIN123");
|
|
expect(car.hiddenTabs, ["fuel"]);
|
|
expect(car.tabOrder, ["info", "charging"]);
|
|
expect(car.metricOrder, ["evRange", "odometer"]);
|
|
|
|
// 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);
|
|
});
|
|
|
|
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("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);
|
|
});
|
|
}
|