mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user