import { describe, expect, it } from "vitest"; import type { ZodType } from "zod"; import { DIVE_FORM_ALWAYS_ON_FIELDS, DIVE_FORM_FIELDS, DIVE_FORM_FIELD_GROUPS, DIVE_FORM_FIELD_REGISTRY, DIVE_FORM_SECTION_FIELDS, DIVE_FORM_VALUE_FIELDS, EMPTY_DIVE_FORM_VALUES, MIXTURE_FIELD_PREFIX, NON_BLANK_EMPTY_FIELD_VALUES, NON_HIDEABLE_MIXTURE_SCHEMA_KEYS, canonicalHiddenFields, diveFormFieldGroup, diveFormFieldsWithErrors, hiddenFieldsEqual, isMixtureField, isNonEmptyFieldValue, nonEmptyDiveFormFields, } from "./dive-form-fields"; import { diveCreateSchema, diveMixtureSchema } from "./validations/dive"; // Optionality is measured, not read off `.optional()`: `start_time` and `submerged_time` // are built by helpers, and a `.default()` accepts `undefined` too. `safeParse` is // the only question that gets all of those right. const acceptsUndefined = (schema: ZodType) => schema.safeParse(undefined).success; const optionalKeysOf = (shape: Record) => Object.keys(shape).filter((key) => acceptsUndefined(shape[key])); describe("the vocabulary is the form's own optional fields", () => { // The web half of the two-sided guard the API's `DiveFormField` docstring names. // Its half is a *subset* check - API-optional is wider than form-optional - and // this one is an equality check, so between them the mirror is pinned from both // ends with no cross-repo test. // // What this fails on is the case worth failing on: a new optional input added to // the dive form and not registered here. It cannot be hidden, it has no row in the // Fields dialog, and nothing else in the suite would notice. it("holds every optional top-level field of the create schema, and no others", () => { const optional = optionalKeysOf( diveCreateSchema.shape as unknown as Record, ); const registered = DIVE_FORM_VALUE_FIELDS.filter( (key) => !key.startsWith(MIXTURE_FIELD_PREFIX), ); expect([...registered].sort()).toEqual([...optional].sort()); }); it("exempts only keys that name no field of the dive", () => { // The section keys skip the check above, so one that came to name a schema field // would escape it with a value nothing on the form then governs. const shape = diveCreateSchema.shape as unknown as Record; for (const key of DIVE_FORM_SECTION_FIELDS) { expect(shape[key], `${key} is a dive field now`).toBeUndefined(); } }); it("holds every optional cylinder field except the ones exempt by name", () => { const optional = optionalKeysOf( diveMixtureSchema.shape as unknown as Record, ).filter( (key) => !(NON_HIDEABLE_MIXTURE_SCHEMA_KEYS as readonly string[]).includes(key), ); const registered = DIVE_FORM_FIELDS.filter((key) => key.startsWith(MIXTURE_FIELD_PREFIX), ).map((key) => key.slice(MIXTURE_FIELD_PREFIX.length)); expect([...registered].sort()).toEqual([...optional].sort()); }); it("exempts only cylinder fields that are actually optional", () => { // The exemption list is a claim about the schema as well as a decision about the // product: a name that stopped being an optional key of `diveMixtureSchema` - // renamed, removed, or made required again - would sit here excluding nothing // while the equality check above went on passing. const shape = diveMixtureSchema.shape as unknown as Record; for (const key of NON_HIDEABLE_MIXTURE_SCHEMA_KEYS) { expect(shape[key], `${key} is no longer a mixture field`).toBeDefined(); expect(acceptsUndefined(shape[key]), `${key} is required now`).toBe(true); } }); it("requires exactly the three top-level fields the panel calls always shown", () => { const required = Object.keys( diveCreateSchema.shape as unknown as Record, ).filter( (key) => !acceptsUndefined( (diveCreateSchema.shape as unknown as Record)[key], ), ); expect(required.sort()).toEqual([ "dive_number", "start_time", "submerged_time", ]); }); }); describe("the vocabulary's order", () => { it("runs through the groups in the order the form renders them", () => { // Declaration order is the order the Fields dialog lists its rows, so each // section's keys are one run of it, in section order - the per-cylinder keys // trailing the list. const groups = DIVE_FORM_FIELDS.filter((key) => !isMixtureField(key)).map( (key) => diveFormFieldGroup(key), ); const runs = groups.filter((group, i) => group !== groups[i - 1]); expect(runs).toEqual( DIVE_FORM_FIELD_GROUPS.filter((group) => runs.includes(group)), ); }); }); describe("diveFormFieldGroup", () => { it("names the section of an always-on field, a hideable one and a list", () => { expect(diveFormFieldGroup("start_time")).toBe("Dive info"); expect(diveFormFieldGroup("dive_number")).toBe("Dive info"); expect(diveFormFieldGroup("submerged_time")).toBe("Dive info"); expect(diveFormFieldGroup("entry_type")).toBe("Location"); expect(diveFormFieldGroup("mixtures")).toBe("Tanks"); expect(diveFormFieldGroup("sightings")).toBe("Marine life"); }); it("names none for a field the form does not render", () => { expect(diveFormFieldGroup("id")).toBeUndefined(); }); }); describe("the panel's registry", () => { it("carries one entry per key, in the vocabulary's own order", () => { expect(DIVE_FORM_FIELD_REGISTRY.map((entry) => entry.key)).toEqual([ ...DIVE_FORM_FIELDS, ]); }); it("labels every key, and gives each a group the panel renders", () => { for (const entry of DIVE_FORM_FIELD_REGISTRY) { expect(entry.label.length, entry.key).toBeGreaterThan(0); expect(DIVE_FORM_FIELD_GROUPS).toContain(entry.group); } for (const entry of DIVE_FORM_ALWAYS_ON_FIELDS) { expect(DIVE_FORM_FIELD_GROUPS).toContain(entry.group); } }); it("gives every group at least one row", () => { // A group with nothing in it would render an empty heading, and a group can be // carried entirely by its always-on rows, which is why both lists count. for (const group of DIVE_FORM_FIELD_GROUPS) { const rows = DIVE_FORM_FIELD_REGISTRY.filter((entry) => entry.group === group) .length + DIVE_FORM_ALWAYS_ON_FIELDS.filter((entry) => entry.group === group) .length; expect(rows, group).toBeGreaterThan(0); } }); it("gives every key a blank empty value, or a named exception", () => { // Deliberately *not* fed back through `nonEmptyDiveFormFields`: `revealsField` // compares the value against `EMPTY_DIVE_FORM_VALUES[key]`, so handing it that // same value makes the comparison false by identity and the assertion true // whatever the table holds. This asserts the property directly instead - an // empty value the reveal rule would call non-empty is a key that puts itself // back on screen the moment a stored dive holds one. for (const key of DIVE_FORM_VALUE_FIELDS) { expect(EMPTY_DIVE_FORM_VALUES).toHaveProperty(key); if (key in NON_BLANK_EMPTY_FIELD_VALUES) { expect(EMPTY_DIVE_FORM_VALUES[key], key).toBe( NON_BLANK_EMPTY_FIELD_VALUES[key], ); continue; } expect(isNonEmptyFieldValue(EMPTY_DIVE_FORM_VALUES[key]), key).toBe( false, ); } }); it("still reveals an end pressure of 0, which is a drained cylinder", () => { // The other side of the same rule: `mixture.end_pressure` clears to `""`, so a // stored `0` differs from its empty value and is a reading worth showing. expect( nonEmptyDiveFormFields({ mixtures: [{ end_pressure: 0 }] }), ).toContain("mixture.end_pressure"); expect(nonEmptyDiveFormFields({ mixtures: [{ helium: 0 }] })).not.toContain( "mixture.helium", ); expect(nonEmptyDiveFormFields({ mixtures: [{ helium: 30 }] })).toContain( "mixture.helium", ); }); }); describe("canonicalHiddenFields", () => { it("collapses duplicates and imposes form order", () => { expect( canonicalHiddenFields([ "notes", "altitude", "notes", "mixture.role", "trip_uuid", ]), ).toEqual(["trip_uuid", "altitude", "notes", "mixture.role"]); }); it("makes set equality a list comparison", () => { expect( hiddenFieldsEqual(["notes", "altitude"], ["altitude", "notes"]), ).toBe(true); expect(hiddenFieldsEqual(["notes"], ["notes", "altitude"])).toBe(false); expect(hiddenFieldsEqual([], [])).toBe(true); }); }); describe("what counts as a value having arrived", () => { it("treats undefined, null, empty string and empty array as nothing", () => { expect( nonEmptyDiveFormFields({ trip_uuid: null, course_uuid: undefined, notes: "", dive_site_uuids: [], mixtures: [], }), ).toEqual([]); }); it("treats 0 as a value", () => { // An end pressure of 0 is a drained cylinder, not a missing reading - the // asymmetry `diveMixtureSchema` spells out at length. expect(nonEmptyDiveFormFields({ mixtures: [{ end_pressure: 0 }] })).toEqual( ["mixtures", "mixture.end_pressure"], ); }); it("reveals a cylinder column any tank holds a value in", () => { expect( nonEmptyDiveFormFields({ mixtures: [ { start_pressure: "", role: "" }, { start_pressure: 210, role: "" }, ], }), ).toEqual(["mixtures", "mixture.start_pressure"]); }); it("returns the keys in form order", () => { expect( nonEmptyDiveFormFields({ notes: "wreck", trip_uuid: "trip-1" }), ).toEqual(["trip_uuid", "notes"]); }); }); describe("the keys a failed submit puts back on screen", () => { it("names the top-level fields carrying an error", () => { expect( diveFormFieldsWithErrors({ altitude: { message: "out of range" }, submerged_time: { message: "required" }, }), ).toEqual(["altitude"]); }); it("reveals the gas section as well as the column, for a per-cylinder error", () => { // The message has nowhere to render while the section is off screen, so // revealing the column alone would leave the save button doing nothing. expect( diveFormFieldsWithErrors({ mixtures: [undefined, { end_pressure: { message: "too high" } }], }), ).toEqual(["mixtures", "mixture.end_pressure"]); }); it("says nothing when the errors are all on always-on fields", () => { expect(diveFormFieldsWithErrors({ dive_number: { message: "x" } })).toEqual( [], ); }); });