Files
DriverVault/Phone App/test/models_format_test.dart
T
tajniak81andClaude Opus 5 dc6febf815 Phone App: finish the admin screen; translate the last English strings
Three loose ends from the last two commits, each of which was named as
deliberately-not-done and none of which is worth carrying further.

The phone's create-user sheet had no organization picker. The endpoint has
taken an `organization` since orgs existed and the Web App has offered the
choice all along, so a superadmin on the phone could only ever create
accounts in their own org - a silent restriction rather than a stated one.
The sheet now loads the orgs and offers them to a superadmin, with the same
blank "no organization" option and the same hint as the web. An admin still
gets no picker, because the server forces its own org on their members and a
picker that cannot change the outcome is a lie. The listing is manager-only
and can fail, in which case the picker offers only "no organization" rather
than blocking the form.

A locked role picker or delete action was greyed out with no reason given.
The web has explained itself in a title attribute since those guards existed,
and the sentences - admin.cantChangeOwnRole and the rest - have been sitting
translated in the phone's own language files since the screen was translated.
Hover has no touch equivalent, so the two controls take different routes: a
long-press on the role picker shows the reason as a tooltip, and the overflow
menu carries it under the action, because a disabled menu item cannot be
long-pressed and silently greying it out is the thing being fixed.

settings.integrations.* and charging.control.* were English-only in *both*
apps - 70 keys, identical text, identical key sets - so they are translated
once and land in all four language files. OCPP and CSMS are protocol names
and stay; product names (Toyota Connected, MyToyota, Anker Solix, Lexus) stay;
everything else follows the wording already in each language's file.

The Web App's files are edited as text rather than round-tripped through a
JSON dump, because they keep a blank line before every nested block and a
dump flattens it - a 900-line translation file is hard enough to read
without losing its paragraphs. Both diffs are purely additive as a result.

Both apps now have every key in all three languages: 738 in the web, and the
phone reports zero fallbacks. A new test locks that in - every key en.json
carries must exist in pl.json and da.json - and it was checked by deleting a
key and watching it fail, because a guard that cannot fire is not a guard.

Verified by flutter analyze (clean), flutter test - 21 pass, 1 of them new -
flutter build apk --debug, and npm run build for the Web App. The key checker
reports 575 static t() keys in the phone and 738 in the web resolving with no
fallbacks in either language.

Not verified: still nothing run against a live API Server or on a device. In
particular the organization picker's happy path - a superadmin creating an
account into a chosen org - has not been exercised end to end; it is the one
piece here that touches the API rather than only the language files.

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

431 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("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);
});
}