The Changed parts section offered all three parts to every car. An EV changes no
oil, and a checkbox nobody will ever tick is one more thing to read past on every
service — so which parts a car records now belongs to the car, the same way its
tabs, its Information rows and its Service history columns already do.
It works the way those three do because a fourth mechanism for the same idea
would be a fourth to keep in step: hidden_service_parts on the car, validated by
the endpoint that already does this, stored as the hidden set so a part added in
a later release is on by default, and needing write access because the choice
belongs to the car and everyone it is shared with sees it.
There is no order beside it, which is the one place this departs from the other
three. Those arrange things whose position means something — a tab bar reads left
to right, a table's columns are read across. The parts are a checkbox list inside
a single column, and moving Cabin air filter above Oil says nothing. Adding one
later is the same shape as the others if that turns out to be wrong.
A part switched off leaves the form and the history together — the chips on the
phone's cards, the web column's summary and the panel it opens. "I don't record
this" means it stops taking up room, not that it takes up room saying nothing,
which is the rule a hidden column already follows. That is the judgment call
here: a car with five years of oil changes hides them all by switching the part
off. Nothing is written to the records, so switching it back on brings every one
of those chips back, which is what makes the call safe to reverse.
The part that would have been a silent data bug: the API rewrites all three
booleans from the body of a service update, so a form that simply stopped
sending a hidden part would set it false on the next edit of any old record.
Both forms therefore keep every part in their state and submit every one — only
the checkboxes are filtered. The mirror of that is a *new* record, where a hidden
part starts false rather than at its `initial`, since ticking a box nobody was
shown is not a default, it's a guess. Oil is the only part with initial: true, so
that case is live the moment anyone hides it.
Verified: go vet and go test ./... pass, with a new test covering that every part
is hideable (unlike the tabs and the columns — a service that changed nothing is
a real service), that the "parts" column key is refused as a part key and a part
key as a column key, and that no part is also a column. flutter analyze is clean
and flutter test passes 32 to 35, the new ones covering visibleParts, that a
hidden part's chips go while its stored boolean stays, and the picker's fourth
section. npm run build is clean.
Both apps were driven against throwaway stub APIs. Web: the picker saved
{"hiddenServiceParts":["oil"]}, the table's parts cell went from "Oil & Oil
filter +2" to "Engine air filter, Cabin air filter", the record whose only part
was oil went to an empty cell, the panel dropped to two rows, the add form
offered two unticked boxes where oil's initial: true would have ticked one, and
editing the three-part record sent changedOil:true back with a box that was never
on screen. Phone: the same car rendered chips "Engine air, Cabin air", "Changed
parts —" for the oil-only record, and an add sheet with exactly two unticked
boxes.
Not verified: no automated test guards the web behaviour — the web app still has
no test runner, so the above was read out of the live DOM and the outgoing
request bodies by hand. The phone's picker was checked by widget test and by
rendering, but its Save was not driven end to end. Neither app was run against
the real API Server: bootstrap appends the new field on the next start, and until
that start a client sending hiddenServiceParts takes a 400 — they deploy together
from this repo, but the server must go first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
556 lines
22 KiB
Dart
556 lines
22 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,
|
|
});
|
|
|
|
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("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);
|
|
});
|
|
}
|