Files
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

285 lines
12 KiB
Go

package api
import "testing"
// What a car's page shows — which tabs, which rows of the Information tab, which
// columns of the Service history table, and the order each of those is laid out
// in — is stored on the car as key lists, so the validation has to keep them to
// keys the page actually renders. Two keys stay out of their hideable set:
// Information, because a car with no tabs left would be a dead end, and the
// service Date, because a history with the day taken out is not one.
func TestNormalizeHiddenTabs(t *testing.T) {
got, err := normalizeKeys([]string{" fuel ", "parts", "fuel", ""}, hideableCarTabs, "tab")
if err != nil {
t.Fatalf("normalizeKeys: %v", err)
}
want := []string{"fuel", "parts"} // trimmed, blanks dropped, deduped
assertKeys(t, got, want)
// Clearing the list is how a car goes back to showing everything.
if empty, err := normalizeKeys(nil, hideableCarTabs, "tab"); err != nil || len(empty) != 0 {
t.Errorf("normalizeKeys(nil) = %v, %v; want empty and no error", empty, err)
}
// Information is the car itself; hiding it would leave a page with no tabs.
if _, err := normalizeKeys([]string{"info"}, hideableCarTabs, "tab"); err == nil {
t.Error("normalizeKeys allowed hiding the info tab, want an error")
}
// A key from a stale or wrong client is an error, not something to drop
// quietly while the tab stays visible.
if _, err := normalizeKeys([]string{"fuel", "nonsense"}, hideableCarTabs, "tab"); err == nil {
t.Error("normalizeKeys accepted an unknown tab, want an error")
}
// The hideable set is the contract the web app's HIDEABLE_TABS mirrors:
// every tab the car page renders beside Information.
for _, key := range []string{
"provider", "services", "technical", "maintenance", "fuel", "charging",
"documents", "parts", "reminders",
} {
if !hideableCarTabs[key] {
t.Errorf("tab %q should be hideable", key)
}
}
if len(hideableCarTabs) != 9 {
t.Errorf("hideableCarTabs has %d entries, want the 9 tabs beside Information", len(hideableCarTabs))
}
}
func TestNormalizeHiddenFields(t *testing.T) {
got, err := normalizeKeys([]string{"vin", " differentialOil ", "vin"}, hideableCarFields, "field")
if err != nil {
t.Fatalf("normalizeKeys: %v", err)
}
assertKeys(t, got, []string{"vin", "differentialOil"})
if _, err := normalizeKeys([]string{"oilSpec", "nonsense"}, hideableCarFields, "field"); err == nil {
t.Error("normalizeKeys accepted an unknown field, want an error")
}
// A tab key is not a field key — the two sets are validated separately.
if _, err := normalizeKeys([]string{"fuel"}, hideableCarFields, "field"); err == nil {
t.Error("normalizeKeys accepted a tab key as a field, want an error")
}
// Every Information row the web app renders must be hideable; unlike the
// tabs there is no row the page has to keep.
for _, key := range []string{
"oilSpec", "transmissionOil", "differentialOil", "brakeFluid", "coolant",
"odometer", "serviceInterval", "nextDue", "registrationPlate",
"registrationCountry", "vin", "fuelType", "buildDate", "firstRegistration",
} {
if !hideableCarFields[key] {
t.Errorf("field %q should be hideable", key)
}
}
if len(hideableCarFields) != 14 {
t.Errorf("hideableCarFields has %d entries, want the 14 Information rows", len(hideableCarFields))
}
}
// The tabs arrange against a wider set than they hide against: Information
// cannot be switched off, but it can be moved off the front of the bar.
func TestNormalizeTabOrder(t *testing.T) {
got, err := normalizeKeys([]string{"reminders", "info", "fuel"}, arrangeableCarTabs, "tab")
if err != nil {
t.Fatalf("normalizeKeys: %v", err)
}
assertKeys(t, got, []string{"reminders", "info", "fuel"})
// Everything the bar renders has to be arrangeable — the hideable tabs plus
// Information, and nothing else.
for key := range hideableCarTabs {
if !arrangeableCarTabs[key] {
t.Errorf("tab %q should be arrangeable", key)
}
}
if !arrangeableCarTabs["info"] {
t.Error("the info tab should be arrangeable even though it cannot be hidden")
}
if len(arrangeableCarTabs) != len(hideableCarTabs)+1 {
t.Errorf("arrangeableCarTabs has %d entries, want the hideable tabs plus Information", len(arrangeableCarTabs))
}
// A field key is not a tab key, and an invented tab is still an error.
if _, err := normalizeKeys([]string{"vin"}, arrangeableCarTabs, "tab"); err == nil {
t.Error("normalizeKeys accepted a field key as a tab, want an error")
}
if _, err := normalizeKeys([]string{"info", "nonsense"}, arrangeableCarTabs, "tab"); err == nil {
t.Error("normalizeKeys accepted an unknown tab in an arrangement, want an error")
}
}
// The arrangement of the Information rows shares the field key set — every row
// can be moved — but not the meaning: here the order of the list is the point,
// so it has to survive validation exactly as it was sent.
func TestNormalizeFieldOrder(t *testing.T) {
got, err := normalizeKeys([]string{"vin", "odometer", "oilSpec"}, hideableCarFields, "field")
if err != nil {
t.Fatalf("normalizeKeys: %v", err)
}
assertKeys(t, got, []string{"vin", "odometer", "oilSpec"})
// A key repeated by a client that lost track keeps its first position; a
// second entry for the same row would put it in two places at once.
got, err = normalizeKeys([]string{"vin", "odometer", "vin"}, hideableCarFields, "field")
if err != nil {
t.Fatalf("normalizeKeys: %v", err)
}
assertKeys(t, got, []string{"vin", "odometer"})
// A partial arrangement is fine — the rows it leaves out follow the arranged
// ones — but an invented row is still an error.
if _, err := normalizeKeys([]string{"vin", "nonsense"}, hideableCarFields, "field"); err == nil {
t.Error("normalizeKeys accepted an unknown field in an arrangement, want an error")
}
}
// The connected service's headline readings arrange the same way, against the
// key set derived from what that panel renders.
func TestNormalizeMetricOrder(t *testing.T) {
got, err := normalizeKeys([]string{"evRangeWithAc", "odometer"}, arrangeableCarMetrics, "reading")
if err != nil {
t.Fatalf("normalizeKeys: %v", err)
}
assertKeys(t, got, []string{"evRangeWithAc", "odometer"})
// Every reading the panel can show has to be arrangeable, the two that are
// not measures included.
for _, key := range []string{
"odometer", "fuelLevel", "fuelRange", "batteryLevel", "evRange",
"evRangeWithAc", "chargingStatus", "location",
} {
if !arrangeableCarMetrics[key] {
t.Errorf("reading %q should be arrangeable", key)
}
}
// A field key is not a reading key — a car's two arrangements are separate.
if _, err := normalizeKeys([]string{"vin"}, arrangeableCarMetrics, "reading"); err == nil {
t.Error("normalizeKeys accepted a field key as a reading, want an error")
}
}
func TestNormalizeHiddenServiceColumns(t *testing.T) {
got, err := normalizeKeys([]string{" parts ", "notes", "parts", ""}, hideableServiceColumns, "service column")
if err != nil {
t.Fatalf("normalizeKeys: %v", err)
}
assertKeys(t, got, []string{"parts", "notes"}) // trimmed, blanks dropped, deduped
// The date is what a service record is; a table of them without it would be
// a list of unattributed work.
if _, err := normalizeKeys([]string{"date"}, hideableServiceColumns, "service column"); err == nil {
t.Error("normalizeKeys allowed hiding the date column, want an error")
}
// Neither a field key nor an invented one passes: each set is its own.
if _, err := normalizeKeys([]string{"vin"}, hideableServiceColumns, "service column"); err == nil {
t.Error("normalizeKeys accepted a field key as a service column, want an error")
}
if _, err := normalizeKeys([]string{"parts", "nonsense"}, hideableServiceColumns, "service column"); err == nil {
t.Error("normalizeKeys accepted an unknown service column, want an error")
}
// The parts are one column, not one each: a key per part would put the table
// back where it started, and these three were columns of their own once.
for _, key := range []string{"oil", "engineFilter", "cabinFilter"} {
if hideableServiceColumns[key] {
t.Errorf("%q should not be a column of its own — the parts share one", key)
}
}
// The hideable set is the contract the web app's HIDEABLE_SERVICE_COLUMNS
// mirrors: every column that table renders beside the date.
for _, key := range []string{"km", "nextDate", "nextKm", "parts", "notes", "file"} {
if !hideableServiceColumns[key] {
t.Errorf("service column %q should be hideable", key)
}
}
if len(hideableServiceColumns) != 6 {
t.Errorf("hideableServiceColumns has %d entries, want the 6 columns beside the date", len(hideableServiceColumns))
}
}
// The parts a service can change hide against their own set — one key per
// boolean on the service_records collection, and no order beside it, because
// they are a checkbox list inside a single column.
func TestNormalizeHiddenServiceParts(t *testing.T) {
got, err := normalizeKeys([]string{" oil ", "cabinFilter", "oil", ""}, hideableServiceParts, "service part")
if err != nil {
t.Fatalf("normalizeKeys: %v", err)
}
assertKeys(t, got, []string{"oil", "cabinFilter"}) // trimmed, blanks dropped, deduped
// Every part is hideable, unlike the tabs and the columns: a service that
// changed nothing is a real service, so there is no part the form must keep.
for _, key := range []string{"oil", "engineFilter", "cabinFilter"} {
if !hideableServiceParts[key] {
t.Errorf("service part %q should be hideable", key)
}
}
if len(hideableServiceParts) != 3 {
t.Errorf("hideableServiceParts has %d entries, want the 3 booleans on a service record", len(hideableServiceParts))
}
// "parts" is the column those three share; it is not one of them. Sending
// the column key here would hide nothing and quietly succeed if the two sets
// were not kept apart.
if _, err := normalizeKeys([]string{"parts"}, hideableServiceParts, "service part"); err == nil {
t.Error("normalizeKeys accepted the parts column as a part, want an error")
}
if _, err := normalizeKeys([]string{"notes"}, hideableServiceParts, "service part"); err == nil {
t.Error("normalizeKeys accepted a column key as a service part, want an error")
}
if _, err := normalizeKeys([]string{"oil", "nonsense"}, hideableServiceParts, "service part"); err == nil {
t.Error("normalizeKeys accepted an unknown service part, want an error")
}
// And no part is a column, which is what keeps the table from widening as
// parts are added.
for key := range hideableServiceParts {
if arrangeableServiceColumns[key] {
t.Errorf("service part %q must not also be a column", key)
}
}
}
// The columns arrange against a wider set than they hide against, the way the
// tabs do: the date cannot be switched off, but it can be moved off the left.
func TestNormalizeServiceColumnOrder(t *testing.T) {
got, err := normalizeKeys([]string{"notes", "date", "parts"}, arrangeableServiceColumns, "service column")
if err != nil {
t.Fatalf("normalizeKeys: %v", err)
}
assertKeys(t, got, []string{"notes", "date", "parts"})
for key := range hideableServiceColumns {
if !arrangeableServiceColumns[key] {
t.Errorf("service column %q should be arrangeable", key)
}
}
if !arrangeableServiceColumns["date"] {
t.Error("the date column should be arrangeable even though it cannot be hidden")
}
if len(arrangeableServiceColumns) != len(hideableServiceColumns)+1 {
t.Errorf("arrangeableServiceColumns has %d entries, want the hideable columns plus the date", len(arrangeableServiceColumns))
}
// A partial arrangement is fine — the columns it leaves out follow the
// arranged ones — but an invented column is still an error.
if _, err := normalizeKeys([]string{"date", "nonsense"}, arrangeableServiceColumns, "service column"); err == nil {
t.Error("normalizeKeys accepted an unknown column in an arrangement, want an error")
}
}
func assertKeys(t *testing.T, got, want []string) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("keys = %v, want %v", got, want)
}
for i := range got {
if got[i] != want[i] {
t.Fatalf("keys = %v, want %v", got, want)
}
}
}