diff --git a/src/__tests__/unit-preference-display-guard.test.ts b/src/__tests__/unit-preference-display-guard.test.ts index 0a7c638ec..10e0598d7 100644 --- a/src/__tests__/unit-preference-display-guard.test.ts +++ b/src/__tests__/unit-preference-display-guard.test.ts @@ -46,6 +46,27 @@ const TARGET_SURFACES = [ join(SRC, "components", "settings", "thresholds-editor-section.tsx"), ]; +/** + * v1.32.30 — the profile height surfaces. Height is not a + * `MeasurementType`, so `display-transform.ts` never reached it and it + * stayed in centimetres for two releases after everything else moved. + * These three are the coupled entry unit: the two forms that write + * `User.heightCm` and the control they share. A partial wire here is + * the same failure the target block guards against — a converted field + * over a canonical save silently rewrites the stored height. + */ +const HEIGHT_SURFACES = [ + join(SRC, "components", "onboarding", "baseline-form.tsx"), + join(SRC, "components", "settings", "account-section", "index.tsx"), +]; + +const HEIGHT_CONTROL = join( + SRC, + "components", + "profile", + "height-field-control.tsx", +); + /** * Swept surfaces: the dashboard client, every insights sub-page, and the * target/threshold surfaces. @@ -124,6 +145,40 @@ describe("unit-preference display guard", () => { expect(offenders).toEqual([]); }); + it("every height entry surface resolves through the profile adapter", () => { + // Same tripwire as the target block: drop the hook or the adapter + // 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"); + if (!src.includes("useUnitDisplay()")) { + offenders.push(`${file}: no preference hook`); + } + if (!src.includes("resolveHeightUnitAdapter")) { + offenders.push(`${file}: no height adapter`); + } + if (!src.includes("HeightFieldControl")) { + offenders.push(`${file}: 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`); + } + } + expect(offenders).toEqual([]); + }); + + it("the height control announces its units from the bundle", () => { + const src = readFileSync(HEIGHT_CONTROL, "utf8"); + // A quoted "cm" / "ft" / "in" here is a hardcoded unit label; every + // unit string on this control resolves through `t(...)`. + expect(src).not.toMatch(/["'](cm|ft|in)["']/); + expect(src).toContain('t("common.feet")'); + expect(src).toContain('t("common.inches")'); + }); + it("every transformed type carries both a metric and an imperial branch", () => { for (const type of TRANSFORMED_TYPES) { const metric = getDisplayTransform(type, "metric"); diff --git a/src/components/profile/__tests__/height-entry-surfaces.test.tsx b/src/components/profile/__tests__/height-entry-surfaces.test.tsx new file mode 100644 index 000000000..97af6e93a --- /dev/null +++ b/src/components/profile/__tests__/height-entry-surfaces.test.tsx @@ -0,0 +1,133 @@ +/** + * v1.32.30 — the two surfaces that ENTER a height: the onboarding + * baseline step and the Settings profile card. + * + * Vitest runs in the Node environment here (no jsdom), so this asserts + * the SSR markup — which is exactly where the failure this release + * closes lived: an imperial account being handed a box labelled "cm". + * + * Mutation check: swap either surface's label back to the unconditional + * `t("settings.height")` / `t("onboarding.baseline.heightLabel")` and + * the "(ft, in)" assertions go red; drop the adapter and the feet + + * inches inputs disappear with them. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +let unitPreference: "metric" | "imperial" = "metric"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/settings/account", + useSearchParams: () => new URLSearchParams(), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: null, isLoading: false, refetch: vi.fn() }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), +})); + +vi.mock("sonner", () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})); + +vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { + id: "u1", + username: "testuser", + email: "user@example.com", + heightCm: 180.34, + dateOfBirth: "1990-01-01", + gender: "MALE", + role: "USER", + unitPreference, + }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { BaselineForm } from "@/components/onboarding/baseline-form"; +import { AccountSection } from "@/components/settings/account-section"; + +function renderUnder( + preference: "metric" | "imperial", + node: React.ReactElement, +): string { + unitPreference = preference; + try { + return renderToStaticMarkup( + {node}, + ); + } finally { + unitPreference = "metric"; + } +} + +const SURFACES = [ + { + name: "onboarding baseline step", + node: , + firstInputId: "ob-baseline-height", + metricLabel: "Height (cm)", + }, + { + name: "Settings profile card", + node: , + firstInputId: "height", + metricLabel: "Height (cm)", + }, +] as const; + +describe.each(SURFACES)("$name — height entry", (surface) => { + it("keeps the single centimetres box for a metric account", () => { + const html = renderUnder("metric", surface.node); + expect(html).toContain(surface.metricLabel); + expect(html).not.toContain("Height (ft, in)"); + expect(html).toContain(`id="${surface.firstInputId}"`); + expect(html).not.toContain(`id="${surface.firstInputId}-inches"`); + // The canonical guardrails, unconverted, on the height box itself. + expect(html).toMatch( + new RegExp( + `id="${surface.firstInputId}"[^>]*min="50"[^>]*max="300"`, + "u", + ), + ); + }); + + it("shows feet + inches for an imperial account", () => { + const html = renderUnder("imperial", surface.node); + expect(html).toContain("Height (ft, in)"); + expect(html).not.toContain(surface.metricLabel); + expect(html).toContain(`id="${surface.firstInputId}"`); + expect(html).toContain(`id="${surface.firstInputId}-inches"`); + expect(html).toContain('aria-label="Height in feet"'); + expect(html).toContain('aria-label="Height in inches"'); + // The unit suffixes come from the bundle, not from a hardcoded + // literal in the surface. + expect(html).toContain(">ft"); + expect(html).toContain(">in"); + // The canonical 50/300 centimetre guardrails must not survive onto + // a feet field. + expect(html).not.toContain('max="300"'); + }); + + it("seeds the imperial fields from the stored centimetres", () => { + const html = renderUnder("imperial", surface.node); + // 180.34 cm is exactly 71 in. + if (surface.firstInputId === "height") { + expect(html).toMatch(/id="height"[^>]*value="5"/u); + expect(html).toMatch(/id="height-inches"[^>]*value="11"/u); + } else { + // The onboarding step starts blank by design — it is a fresh + // profile, not an edit of an existing one. + expect(html).toMatch(/id="ob-baseline-height"[^>]*value=""/u); + } + }); +}); diff --git a/src/lib/profile/__tests__/height-unit-display.test.ts b/src/lib/profile/__tests__/height-unit-display.test.ts new file mode 100644 index 000000000..53d3cbeab --- /dev/null +++ b/src/lib/profile/__tests__/height-unit-display.test.ts @@ -0,0 +1,219 @@ +/** + * v1.32.30 — the height entry adapter. + * + * Two properties carry the whole feature and both are swept, not + * sampled: + * + * 1. Round-trip stability. Every feet + inches pair the imperial + * fields can offer must survive save-then-reopen unchanged, or a + * user's height creeps by a fraction on every visit to Settings. + * 2. Inward-rounded bounds. Every value inside the client's own + * limits must invert to a centimetre value the server's 50-300 + * Zod bound accepts, or an imperial user is handed a field that + * 422s. + * + * The metric branch is asserted with `toBe` (identity, no rounding + * drift), because "we quietly re-derived every metric account's stored + * height" is the expensive way to get this wrong. + */ +import { describe, expect, it } from "vitest"; + +import { + EMPTY_HEIGHT_DRAFT, + resolveHeightUnitAdapter, +} from "@/lib/profile/height-unit-display"; + +/** The canonical window `src/lib/validations/auth.ts` enforces. */ +const MIN_CM = 50; +const MAX_CM = 300; + +const metric = resolveHeightUnitAdapter("metric"); +const imperial = resolveHeightUnitAdapter("imperial"); + +describe("height unit adapter — metric branch", () => { + it("renders a single centimetres field, not the feet + inches pair", () => { + expect(metric.usesFeetInches).toBe(false); + expect(metric.preference).toBe("metric"); + }); + + it("seeds the draft as the exact stored number, unrounded", () => { + for (const cm of [50, 160, 175.5, 180, 180.34, 199.99, 300]) { + expect(metric.toDraft(cm)).toEqual({ + cm: String(cm), + feet: "", + inches: "", + }); + } + }); + + it("returns the typed centimetres untouched (identity, no drift)", () => { + for (const typed of ["50", "160", "175.5", "180", "180.34", "300"]) { + expect(metric.toCanonicalCm({ ...EMPTY_HEIGHT_DRAFT, cm: typed })).toBe( + Number.parseFloat(typed), + ); + } + }); + + it("round-trips every stored centimetre value byte-for-byte", () => { + for (let cm = MIN_CM; cm <= MAX_CM; cm += 0.5) { + expect(metric.toCanonicalCm(metric.toDraft(cm))).toBe(cm); + } + }); + + it("reads a blank or unparseable field as no height", () => { + expect(metric.toCanonicalCm(EMPTY_HEIGHT_DRAFT)).toBeNull(); + expect( + metric.toCanonicalCm({ cm: " ", feet: "", inches: "" }), + ).toBeNull(); + expect( + metric.toCanonicalCm({ cm: "abc", feet: "", inches: "" }), + ).toBeNull(); + expect(metric.toDraft(null)).toEqual(EMPTY_HEIGHT_DRAFT); + expect(metric.toDraft(undefined)).toEqual(EMPTY_HEIGHT_DRAFT); + }); + + it("offers the canonical guardrails unconverted", () => { + expect(metric.bounds.cm).toEqual({ min: MIN_CM, max: MAX_CM }); + }); +}); + +describe("height unit adapter — imperial branch", () => { + it("renders the two-part feet + inches row", () => { + expect(imperial.usesFeetInches).toBe(true); + expect(imperial.preference).toBe("imperial"); + }); + + it("converts the worked example both ways", () => { + const cm = imperial.toCanonicalCm({ cm: "", feet: "5", inches: "11" }); + expect(cm).toBe(180.34); + expect(imperial.toDraft(cm)).toEqual({ cm: "", feet: "5", inches: "11" }); + }); + + it("round-trips EVERY feet + inches pair the fields can offer", () => { + const { feet, inches } = imperial.bounds; + let checked = 0; + for (let ft = feet.min; ft <= feet.max; ft += 1) { + for (let inch = inches.min; inch <= inches.max; inch += 1) { + const draft = { cm: "", feet: String(ft), inches: String(inch) }; + const cm = imperial.toCanonicalCm(draft); + expect(cm).not.toBeNull(); + expect(imperial.toDraft(cm)).toEqual(draft); + checked += 1; + } + } + // A silently empty sweep is a green void. + expect(checked).toBeGreaterThan(60); + }); + + it("round-trips every whole-inch height across the plausible range", () => { + // Independent of the offered rectangle: the full canonical window + // expressed in whole inches, which is what a height synced from a + // wearable can land on. + for (let totalInches = 20; totalInches <= 118; totalInches += 1) { + const draft = { + cm: "", + feet: String(Math.floor(totalInches / 12)), + inches: String(totalInches % 12), + }; + const cm = imperial.toCanonicalCm(draft); + expect(cm).not.toBeNull(); + expect(cm).toBeGreaterThanOrEqual(MIN_CM); + expect(cm).toBeLessThanOrEqual(MAX_CM); + expect(imperial.toDraft(cm)).toEqual(draft); + } + }); + + it("keeps every value inside the offered limits acceptable to the server", () => { + const { feet, inches } = imperial.bounds; + const corners = [ + { feet: feet.min, inches: inches.min }, + { feet: feet.min, inches: inches.max }, + { feet: feet.max, inches: inches.min }, + { feet: feet.max, inches: inches.max }, + ]; + for (const corner of corners) { + const cm = imperial.toCanonicalCm({ + cm: "", + feet: String(corner.feet), + inches: String(corner.inches), + }); + expect(cm).not.toBeNull(); + expect(cm).toBeGreaterThanOrEqual(MIN_CM); + expect(cm).toBeLessThanOrEqual(MAX_CM); + } + }); + + it("rounds the offered window INWARD, never outward", () => { + // The naive rounding: 300 cm is 118.11 in, so a feet ceiling of 9 + // would let 9 ft 11 in (119 in = 302.26 cm) through the client and + // straight into a server 422. + const { feet, inches } = imperial.bounds; + expect(inches).toEqual({ min: 0, max: 11 }); + const justOverTheCeiling = imperial.toCanonicalCm({ + cm: "", + feet: String(feet.max + 1), + inches: "11", + }); + expect(justOverTheCeiling).toBeGreaterThan(MAX_CM); + }); + + it("rejects a genuinely out-of-range height as out of range", () => { + const tooTall = imperial.toCanonicalCm({ + cm: "", + feet: "12", + inches: "0", + }); + expect(tooTall).toBe(365.76); + expect(tooTall).toBeGreaterThan(MAX_CM); + const tooShort = imperial.toCanonicalCm({ cm: "", feet: "1", inches: "0" }); + expect(tooShort).toBeLessThan(MIN_CM); + }); + + it("treats an untouched inches box as zero, not as no height", () => { + expect(imperial.toCanonicalCm({ cm: "", feet: "6", inches: "" })).toBe( + 182.88, + ); + expect(imperial.toCanonicalCm({ cm: "", feet: "", inches: "8" })).toBe( + 20.32, + ); + }); + + it("reads a blank or unparseable pair as no height", () => { + expect(imperial.toCanonicalCm(EMPTY_HEIGHT_DRAFT)).toBeNull(); + expect( + imperial.toCanonicalCm({ cm: "", feet: " ", inches: " " }), + ).toBeNull(); + expect( + imperial.toCanonicalCm({ cm: "", feet: "abc", inches: "3" }), + ).toBeNull(); + expect(imperial.toDraft(null)).toEqual(EMPTY_HEIGHT_DRAFT); + }); + + it("quantises a fractional inch so the round-trip still holds", () => { + const cm = imperial.toCanonicalCm({ cm: "", feet: "5", inches: "11.5" }); + expect(cm).toBe(182.88); // 72 in + expect(imperial.toDraft(cm)).toEqual({ cm: "", feet: "6", inches: "0" }); + expect(imperial.toCanonicalCm(imperial.toDraft(cm))).toBe(cm); + }); + + it("snaps a centimetre-entered height to the nearest whole inch", () => { + // A metric account that flips to imperial: 180 cm reads as 5 ft 11 + // in, and the next save re-canonicalises to 180.34. One snap, then + // stable forever. + expect(imperial.toDraft(180)).toEqual({ cm: "", feet: "5", inches: "11" }); + const resaved = imperial.toCanonicalCm(imperial.toDraft(180)); + expect(resaved).toBe(180.34); + expect(imperial.toDraft(resaved)).toEqual({ + cm: "", + feet: "5", + inches: "11", + }); + }); +}); + +describe("height unit adapter — resolution", () => { + it("falls back to the metric branch for anything but imperial", () => { + expect(resolveHeightUnitAdapter("metric")).toBe(metric); + expect(resolveHeightUnitAdapter("imperial")).toBe(imperial); + }); +});