src/lib/dive-form-fields.test.ts
Rama main · réplica de lectura del repositorio original.
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<string, ZodType>) =>
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<string, ZodType>,
);
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<string, ZodType>;
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<string, ZodType>,
).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<string, ZodType>;
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<string, ZodType>,
).filter(
(key) =>
!acceptsUndefined(
(diveCreateSchema.shape as unknown as Record<string, ZodType>)[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(
[],
);
});
});