From 4c858e76004a65f7d763105b4c4329fba3bb4b09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 23:29:00 +0200 Subject: [PATCH] Read the height-surface guard across a split form The onboarding step keeps the preference and the adapter where the save happens and renders the control from its inputs file, so the three conditions belong to the surface, not to one file. The control check is word-bounded now: a wrapper named after it passed a substring check while rendering something else. --- .../unit-preference-display-guard.test.ts | 32 +++++++++++++------ src/lib/profile/rejected-fields.ts | 9 +++--- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/__tests__/unit-preference-display-guard.test.ts b/src/__tests__/unit-preference-display-guard.test.ts index b42229280..a90ff2dbb 100644 --- a/src/__tests__/unit-preference-display-guard.test.ts +++ b/src/__tests__/unit-preference-display-guard.test.ts @@ -56,9 +56,19 @@ const TARGET_SURFACES = [ * the same failure the target block guards against — a converted field * over a canonical save silently rewrites the stored height. */ +/** + * One entry per surface. A surface is a group of files because the + * onboarding step keeps the preference and the adapter where the save + * happens and renders the control from its own inputs file — the three + * conditions below are asserted across the group, not per file, so + * splitting a form does not read as an un-wire. + */ const HEIGHT_SURFACES = [ - join(SRC, "components", "onboarding", "baseline-form.tsx"), - join(SRC, "components", "settings", "account-section", "index.tsx"), + [ + join(SRC, "components", "onboarding", "baseline-form.tsx"), + join(SRC, "components", "onboarding", "baseline-fields.tsx"), + ], + [join(SRC, "components", "settings", "account-section", "index.tsx")], ]; const HEIGHT_CONTROL = join( @@ -153,21 +163,25 @@ describe("unit-preference display guard", () => { // from one form and it falls back to centimetres while its sibling // shows feet and inches. const offenders: string[] = []; - for (const file of HEIGHT_SURFACES) { - const src = readFileSync(file, "utf8"); + for (const files of HEIGHT_SURFACES) { + const surface = files.join(" + "); + const src = files.map((file) => readFileSync(file, "utf8")).join("\n"); if (!src.includes("useUnitDisplay()")) { - offenders.push(`${file}: no preference hook`); + offenders.push(`${surface}: no preference hook`); } if (!src.includes("resolveHeightUnitAdapter")) { - offenders.push(`${file}: no height adapter`); + offenders.push(`${surface}: no height adapter`); } - if (!src.includes("HeightFieldControl")) { - offenders.push(`${file}: hand-rolled height input`); + // Word-bounded: a renamed local wrapper whose name merely starts + // with the control's satisfies a substring check while rendering + // something else entirely. + if (!/\bHeightFieldControl\b/.test(src)) { + offenders.push(`${surface}: hand-rolled height input`); } // The canonical centimetre guardrails must come from the // adapter's inward-rounded bounds, never from a literal. if (/min=\{50\}|max=\{300\}/.test(src)) { - offenders.push(`${file}: hardcoded centimetre guardrail`); + offenders.push(`${surface}: hardcoded centimetre guardrail`); } } expect(offenders).toEqual([]); diff --git a/src/lib/profile/rejected-fields.ts b/src/lib/profile/rejected-fields.ts index d4a2deef0..da0a3e604 100644 --- a/src/lib/profile/rejected-fields.ts +++ b/src/lib/profile/rejected-fields.ts @@ -92,10 +92,11 @@ const REJECTION_FALLBACK_KEY = "settings.profileRejection.other"; * twice. Where a field is refused for several reasons at once the first * one wins — the others are restatements of the same wrong value. * - * `labelKeys` is accepted for symmetry with - * `describeRejectedProfileField` and for the caller that wants to know - * which fields it can actually render; a path the calling screen has no - * input for still gets an entry, so nothing is dropped on the floor. + * No label map is needed: the sentence sits under the input, which + * already carries the label, and the key is the schema path so the + * calling screen can place it without a rename. A path the screen has + * no input for still gets an entry rather than being dropped, so the + * caller can decide what to do with it. */ export function describeRejectedProfileFields( fields: RejectedProfileField[] | undefined,