Files
DriverVault/API Server/internal/api/cartabs_test.go
T
tajniak81andClaude Opus 5 c5d431c560 Changed parts: one column in the service table, not one each
Oil & Oil filter, Engine air filter and Cabin air filter had a column of Yes/No
each in the Service history table, 375px of the 1022px table between them for
three bits of information. The form has always kept them together in one Changed
parts section, which is the honest shape: they are one answer to one question
about a service, not three unrelated readings. The table said otherwise, and the
list is going to grow - every part added would have taken another column and
pushed the table into a sideways scroll.

They are one column now, 234px with the widest summary on screen, and its width
no longer depends on how many parts exist. The cell names what was changed
rather than counting it, because a history is read down the page and "2" tells
you nothing about which two; past two names it becomes the first part and a
tally, which is what keeps one line one line as the list grows. Nothing changed
reads as an em dash.

The detail is a dropdown, not a dialog. This is read-only detail about one row
of a table you are reading down: a modal would black out the rows being compared
against and charge an open-and-close for each one. It is pinned under the button
it was opened from, closes on an outside click, Escape or a scroll - it is fixed
to a point on the screen, so a table that moves underneath would leave it
pointing at the wrong row - and there is one panel rather than one per row. It
lists every part with a Yes or a No, the unchanged ones included, so the em-dash
row still answers the question instead of being a dead cell.

One list in lib/serviceParts.js now drives the form's checkboxes, the cell's
summary and the panel. That is the point of the change as much as the width is:
adding a part was three edits that had to agree, and is now one entry plus its
boolean on the API's service_records collection. The form builds its state and
its payload from the list rather than naming the three fields twice - the save
payload is unchanged in shape, which was checked against the wire rather than by
reading it.

This walks back part of the previous commit, which had just made all three
hideable separately: the server's column set drops oil/engineFilter/cabinFilter
for a single "parts" key, and a test now asserts those three are not columns of
their own, so the table cannot drift back. A car with ["oil"] stored as hidden
would quietly get the combined column - nothing has that stored, the deployed
stack predating the feature, and stale keys are dropped on read rather than
erroring.

Verified in a browser against a stub API: all four summary cases (one part
named, two named, three as "Oil & Oil filter +2", none as an em dash); the panel
opens anchored under its button with the right Yes/No for the row, stays inside
the window, and closes on outside click, Escape, scroll and a second click,
switching rows without leaving a second panel behind; the picker offers "Changed
parts" as one entry and hiding it sends {"hiddenServiceColumns":["parts"]};
dragging sends "parts" in the order with the hidden column holding its slot; the
Edit dialog renders from the shared list and its PATCH still carries all three
booleans with the unticked one false. go vet, go test ./... and npm run build
are clean.

Not verified: no automated test covers any of it - the web app still has no test
runner, so the cases above were driven by hand. The drag and the panel were
exercised through dispatched events rather than a pointer, the browser pane not
compositing, so the native drag image and the panel's behaviour under a real
click-and-hold are unchecked. The dropdown overlaps the rows beneath it, which
is what a dropdown does but was not weighed against a taller table. The deployed
Web App still shows three columns until it is redeployed.

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

242 lines
10 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 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)
}
}
}