test(profile): cover the height round-trip, the converted bounds, and both entry surfaces

Sweep every feet + inches pair the fields can offer and every whole-inch
height across the canonical range, asserting the value comes back
unchanged and lands inside the server's window. Assert the metric branch
with toBe so a re-derivation of a stored height cannot slip through.

Render both entry surfaces under each preference: the metric case keeps
the single centimetres box with the canonical guardrails, the imperial
case shows the feet + inches pair seeded from the stored value. Extend
the structural guard so a surface that drops the adapter, hardcodes a
centimetre guardrail, or hardcodes a unit label trips a tripwire.
This commit is contained in:
Marc-André Bombeck committed 2026-07-25 08:21:51 +02:00
1 parent 1b41126fb3
commit 7a0e2cb5b2
3 files changed
+407

No files matched your search

@@ -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");
@@ -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(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
} finally {
unitPreference = "metric";
}
}
const SURFACES = [
{
name: "onboarding baseline step",
node: <BaselineForm />,
firstInputId: "ob-baseline-height",
metricLabel: "Height (cm)",
},
{
name: "Settings profile card",
node: <AccountSection />,
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</span>");
expect(html).toContain(">in</span>");
// 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);
}
});
});
@@ -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);
});
});