Files
DriverVault/Phone App/test/models_format_test.dart
T
tajniak81andClaude Opus 5 4372b870ac Phone App: take the admin users screen off hardcoded English
The last screen the phone rendered in English regardless of the language
picker. Its strings are the web AdminUsers.vue's, which have been translated
since b6bb6b1, so the admin.* subtree is copied across the same way the rest
were and Polish and Danish arrive complete.

Two strings are the phone's own, and only because the two UIs confirm
differently: the web deletes behind a browser confirm(), which supplies its
own title, and reports a password reset by closing the modal. The phone has a
dialog title and a snackbar to fill, so admin.deleteTitle and
admin.passwordUpdated are new.

The role picker printed its values raw - Text(r) over "user"/"admin"/
"superadmin" - which happened to read as English because the API's enum is
English. It goes through admin.roles.* now, like the web's, and the new
lookup is covered by the catalogue test alongside the other dynamic ones.

The Web App needed nothing: AdminUsers.vue was already fully translated,
including the role options and the tooltips explaining why a locked row
cannot be edited. Checked rather than assumed - it has 31 t() calls and no
bare text nodes in the template.

Two differences from the web turned up while reading them side by side, both
feature gaps rather than translation ones, and both left alone here: the
phone's create-user sheet has no organization picker for a superadmin (the
server puts the account in the creator's org), and the phone disables a
locked role dropdown or delete action without saying why, where the web
explains it in a title attribute. The strings for that explanation are now
sitting in the phone's language files, so it is a small change if wanted.

Verified by flutter analyze (clean), flutter test - 20 pass, 1 of them new -
and flutter build apk --debug. The key checker reports all 568 static t()
keys resolving in en.json.

Not verified: still nothing run against a live API Server or on a device.

TRANSLATIONS.md and the Phone App README both listed this screen as
outstanding; the only gap either records now is settings.integrations.* /
charging.control.*, which is the web app's too.

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

394 lines
15 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 "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("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);
});
}