Files
DriverVault/Phone App/test/models_format_test.dart
tajniak81andClaude Opus 5 641f427db4 The chargers you own, on the phone as well
The web's Charging page grew a real home half while the phone kept a demo one.
There, a charger is a record imported from a connected service; here it was a
hardcoded row called "Home charger", and the only real thing on the tab was a
single OCPP control card. Modbus had been a working transport for a while, and
the phone had no way to give it the address it needs.

The home tab is now the four cards the web shows, about whichever charger is
picked, and the list of the ones you have imported. Control acts on the charger
and offers what the transport actually has — boost on Modbus, clear-limit and
reset on OCPP. Connection asks for a serial or an address depending on which,
and falls back to a text box for a serial the account does not list. Readings
render the Modbus snapshot the way it gets asked about: the per-phase matrix,
what the charger is doing, what it is set to, what it is, and any alarm word.
Information stands without a control mode at all, because what a charger is is
known either way; beside it the service's own view of whether it is reachable,
asked for when the tab is opened rather than on every build.

Rearranging is the one place the two apps differ, for the reason the car's view
picker already differs: the web drags the tab bar and the card headings, and on
a touch screen the bar owns that gesture and a heading is the fold toggle. Both
arrangements are made in a sheet with handles instead, and still saved to
chargerTabOrder / chargerCardOrder on the profile — so an arrangement made in
either app shows up in the other. Which cards are folded stays on the device.

Settings groups its integrations into the same categories the API Server panel
does, says Online as well as Offline, and shows firmware in a charger's line.
Shared strings are copied out of the web's i18n files rather than retyped, per
TRANSLATIONS.md; only the arrange sheet's own three are written here.

The readings and information cards look up some sixty keys by name at render
time, so models_format_test now guards those the way it guards the car's — an
enum value is deliberately left out, since a charger may report a number this
release has never heard of and falling back to it is the point.

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

631 lines
26 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/screens/charging_screen.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,
});
final all = kServiceParts;
expect(changedParts(record(), all).isEmpty, isTrue);
expect(changedParts(record(oil: true), all).map((p) => p.key), ["oil"]);
// Catalogue order, not the order the fields happen to be read in.
expect(
changedParts(record(cabin: true, oil: true), all).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, all).isEmpty, isTrue);
});
test("a part a car has switched off leaves its records alone", () {
final car = Car.fromJson({"id": "c", "name": "bZ4X", "hiddenServiceParts": ["oil"]});
expect(car.hiddenServiceParts, ["oil"]);
expect(visibleParts(car).map((p) => p.key), ["engineFilter", "cabinFilter"]);
// A car nobody has configured offers every part — the hidden set, so one
// added in a later release is on by default.
expect(visibleParts(Car.fromJson({"id": "d", "name": "Yaris"})).length, kServiceParts.length);
// The chips follow the car, so an oil change recorded before the part was
// switched off stops being shown...
final oiled = ServiceRecord.fromJson({
"id": "s", "car": "c", "km": 90000,
"changedOil": true, "changedCabinAirFilter": true,
});
expect(changedParts(oiled, visibleParts(car)).map((p) => p.key), ["cabinFilter"]);
// ...but the record still says so, which is what brings the chip back if the
// part is switched on again. Nothing here writes to the record.
expect(oiled.changedOil, 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("the charging page's tabs and cards", () {
for (final key in kChargerTabKeys) {
expectLabelled("charging.tabs.$key");
}
// The cards are named by their own headings rather than by their keys, so
// the arrange sheet reads like the page. Those headings are the labels.
for (final key in ["charging.control.title", "charging.control.connectionTitle",
"charging.modbus.title", "charging.info.title"]) {
expectLabelled(key);
}
});
test("everything the charger information card names", () {
// Every row is drawn every time, so a missing label is a raw key path in
// the middle of the card rather than a row that quietly stays away.
for (final key in ["vendor", "model", "firmware", "serial", "site", "siteId", "sources",
"power", "connector", "state", "chargePower", "ocpp", "providerId",
"added", "online", "offline", "refresh", "title", "empty"]) {
expectLabelled("charging.info.$key");
}
// The service's own word for how a charger is registered. Mirrors the
// sources a chargerSource reports in the API's chargerproviders.go; an
// unlisted one falls back to the slug, which is why these are named.
for (final key in ["standalone", "site", "bound"]) {
expectLabelled("charging.info.sourceNames.$key");
}
});
test("every Modbus reading the card can show", () {
// Mirrors the fields of ModbusStatus in the API's ankersolix/modbus.go
// that the readings card asks for by name. The enum values below it are
// deliberately not here: an unlisted number falls back to itself, because
// the charger may report one this release has never heard of.
for (final key in ["title", "phases", "live", "settings", "device", "alarms", "phase",
"voltage", "current", "activePower", "reactivePower", "apparentPower",
"lineToLine", "power", "sessionDuration", "cpSignal", "cpVoltage",
"phaseMode", "relayTemps", "pwm", "maxCurrentSet", "timeout",
"phaseSetting", "boostSet", "lastCommand", "chargingMode",
"loadBalancing", "solarBalancing", "ledBrightness", "model", "serial",
"firmware", "hardware", "productNumber", "ratedPower", "currentRange",
"ocppLink", "mqttLink", "alarmsHint"]) {
expectLabelled("charging.modbus.$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("the car screens ask for keys the language files carry", () {
// The Android Auto screens are Kotlin (android/.../phoneapp/car/), where the
// analyzer and every test above stop. They read these very files by these
// very dot paths — CarStrings.kt is i18n.dart's t() over the copies bundled
// into the APK — so a key mistyped there resolves to nothing, and a head
// unit is where you would find out. Read the sources and ask them here.
final dir = Directory("android/app/src/main/kotlin/com/drivervault/phoneapp/car");
final sources = dir.listSync().whereType<File>().where((f) => f.path.endsWith(".kt"));
expect(sources, isNotEmpty, reason: "the car screens moved — point this test at them");
final lookups = RegExp(r'strings\.t\(\s*"([^"]+)"');
final keys = <String>{
for (final file in sources)
...lookups.allMatches(file.readAsStringSync()).map((m) => m.group(1)!),
};
// The car's own strings are in there, so the pattern still matches calls.
expect(keys, contains("carApp.signIn"));
for (final key in keys) {
for (final lang in translatedLanguages) {
appSettings.locale = "$lang-${lang.toUpperCase()}";
// A plural fails this too, deliberately: CarStrings picks no CLDR
// category, so a car screen must not ask for a key that needs one.
expect(t(key), isNot(key), reason: "$key is missing from $lang.json");
}
}
appSettings.locale = "pl-PL";
});
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);
});
}