Files
DriverVault/Phone App/test/car_view_sheet_test.dart
tajniak81andClaude Opus 5 35e6c511b7 Changed parts: the list is the car's, not the app's
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>
2026-08-22 23:30:08 +02:00

181 lines
6.7 KiB
Dart

// The view picker and the layout it drives.
//
// What the picker saves is a property of the car, read by the web app as well,
// so the two things worth guarding are that it offers exactly the keys the
// server accepts — with the date shown but locked on, since a hidden date is a
// 400 — and that a service card lays the columns out in the order it was given
// rather than the catalogue's.
import "package:flutter/material.dart";
import "package:flutter_test/flutter_test.dart";
import "package:intl/date_symbol_data_local.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_view_sheet.dart";
import "package:drivervault_phone/service_parts.dart";
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() async {
await initializeDateFormatting();
await loadTranslations();
appSettings.locale = "en-GB";
appSettings.dateFormat = "DMY_NUM";
});
group("serviceColumnRuns", () {
test("the short columns share a line, the other three stand alone", () {
expect(
serviceColumnRuns(kServiceColumnKeys),
[
["date", "km", "nextDate", "nextKm"],
["parts"],
["notes"],
["file"],
],
);
});
test("a column moved between two short ones splits the line", () {
// The grouping follows the car's arrangement, not the catalogue's: this is
// the part a hand-written card would get wrong by grouping the short
// columns first and laying the blocks out after them, which would quietly
// undo the arrangement.
expect(
serviceColumnRuns(["date", "km", "notes", "nextDate", "nextKm"]),
[
["date", "km"],
["notes"],
["nextDate", "nextKm"],
],
);
});
test("hidden columns simply are not there", () {
expect(
serviceColumnRuns(["date", "notes"]),
[
["date"],
["notes"],
],
);
// Date cannot be hidden, so a card always has at least one run — the tile
// reads pieces.first without checking.
expect(serviceColumnRuns(["date"]), [
["date"]
]);
});
});
group("CarViewSheet", () {
// A surface tall enough for the whole sheet. Its list is lazy, so on a
// phone-sized one the columns — the last of the three sections — are simply
// not built and nothing below can be found.
Future<void> pump(WidgetTester tester, Car car) async {
tester.view.physicalSize = const Size(800, 5000);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: CarViewSheet(car: car, showProvider: false),
),
));
await tester.pumpAndSettle();
}
Car car({
List<String> hiddenColumns = const [],
List<String> columnOrder = const [],
List<String> hiddenParts = const [],
}) =>
Car.fromJson({
"id": "car1",
"name": "bZ4X",
"hiddenServiceColumns": hiddenColumns,
"serviceColumnOrder": columnOrder,
"hiddenServiceParts": hiddenParts,
});
// The sheet stacks its sections in one list: tabs, Information rows, the
// Service history columns, then the parts. Counted from the end rather than
// the start so the tab rows — whose number depends on showProvider — don't
// have to be worked out here.
List<bool> ticks(WidgetTester tester) => tester
.widgetList<Checkbox>(find.byType(Checkbox))
.map((b) => b.value ?? false)
.toList();
List<bool> columnTicks(WidgetTester tester) {
final all = ticks(tester);
final end = all.length - kServiceParts.length;
return all.sublist(end - kServiceColumnKeys.length, end);
}
List<bool> partTicks(WidgetTester tester) {
final all = ticks(tester);
return all.sublist(all.length - kServiceParts.length);
}
testWidgets("offers every column, with the date locked on", (tester) async {
await pump(tester, car());
// The section headings are rendered upper-cased.
expect(find.text(t("car.viewPicker.serviceColumnsHeading").toUpperCase()),
findsOneWidget);
for (final key in kServiceColumnKeys) {
expect(find.text(serviceColumnLabel(key)), findsWidgets,
reason: "the $key column is not on offer");
}
// The date's checkbox is on and cannot be turned off; every other column's
// can. A picker that let the date be unticked would save a hidden set the
// server refuses.
final boxes = tester.widgetList<Checkbox>(find.byType(Checkbox)).toList();
final locked = boxes.where((b) => b.onChanged == null).toList();
expect(locked, hasLength(1));
expect(locked.single.value, isTrue);
});
testWidgets("a switched-off column comes back unticked", (tester) async {
await pump(tester, car(hiddenColumns: ["parts", "file"]));
expect(columnTicks(tester), [true, true, true, true, false, true, false]);
});
testWidgets("offers every part, none of them locked", (tester) async {
await pump(tester, car());
expect(find.text(t("car.viewPicker.servicePartsHeading").toUpperCase()),
findsOneWidget);
for (final part in kServiceParts) {
expect(find.text(t(part.label)), findsWidgets,
reason: "the ${part.key} part is not on offer");
}
expect(partTicks(tester), List.filled(kServiceParts.length, true));
// Only the date is locked. Every part can be switched off: a service that
// changed nothing is a real service, so the form needs none of them.
final locked = tester
.widgetList<Checkbox>(find.byType(Checkbox))
.where((b) => b.onChanged == null);
expect(locked, hasLength(1));
});
testWidgets("a switched-off part comes back unticked", (tester) async {
await pump(tester, car(hiddenParts: ["oil", "cabinFilter"]));
expect(partTicks(tester), [false, true, false]);
// And it changes nothing about the columns, which are their own set.
expect(columnTicks(tester), List.filled(kServiceColumnKeys.length, true));
});
testWidgets("the rows follow the car's arrangement", (tester) async {
await pump(tester, car(columnOrder: ["notes", "date"]));
final notes = tester.getTopLeft(find.text(serviceColumnLabel("notes")).last).dy;
final date = tester.getTopLeft(find.text(serviceColumnLabel("date")).last).dy;
final km = tester.getTopLeft(find.text(serviceColumnLabel("km")).last).dy;
expect(notes, lessThan(date));
expect(date, lessThan(km));
});
});
}