From 232217ce683b67c627a3c3122923ed9d72e64e91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Jul 2026 23:11:37 +0200 Subject: [PATCH] test(insights): pin the three step-page display rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One guard per defect, each verified by breaking it on purpose first. A non-negative metric's baseline `low` never goes below zero — swept over every type in the catalogue with a series whose spread exceeds its median, plus the reported step shape rendering an edge of exactly 0. The axis assertion sits in the same file, because the point of the fix is that the two read one fact. No insights page passes an English word as a unit — the page sources are scanned for `unit=` / `yAxisUnit=` literals and held against the set of language-neutral symbols, and every `unitKey` is resolved in all six locales. That second half matters: a key passed as a prop is invisible to the `t()` call-site coverage sweep, so nothing else would notice a missing translation. An integer-valued metric never renders a fractional part — the step page is rendered in German with no precision prop at all and must produce no comma-decimal, on the Min/Max card and on the band sentence below it. Two types were missing from the plausibility map, so the first guard also asserts the map covers the enum. --- .../insights-unit-literal-guard.test.ts | 142 ++++++++++++++ .../integer-metric-precision-guard.test.tsx | 174 ++++++++++++++++++ .../non-negative-metric-guard.test.ts | 116 ++++++++++++ 3 files changed, 432 insertions(+) create mode 100644 src/__tests__/insights-unit-literal-guard.test.ts create mode 100644 src/components/insights/__tests__/integer-metric-precision-guard.test.tsx create mode 100644 src/lib/measurements/__tests__/non-negative-metric-guard.test.ts diff --git a/src/__tests__/insights-unit-literal-guard.test.ts b/src/__tests__/insights-unit-literal-guard.test.ts new file mode 100644 index 000000000..d65de1b44 --- /dev/null +++ b/src/__tests__/insights-unit-literal-guard.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +/** + * GUARD — no insights page passes an English word as a unit. + * + * A German reader was shown "13,387.6 steps" because `/insights/steps` + * handed the scaffold the literal string `steps`. Five of the eighteen unit + * literals under `src/app/insights` were English words: steps, years, + * flights, falls, and a "count" that was a poor label in any language. The + * other thirteen are language-neutral symbols — bpm, %, mmHg, m/s and their + * kin read the same in every locale, and "translating" one would be the + * opposite mistake. + * + * So the rule is not "route every unit through i18n", it is: a unit literal + * on an insights page must be a SYMBOL. A word travels as an i18n key + * (`unitKey`) and is resolved by the scaffold — and that key must resolve in + * every locale, which the `t()`-call-site coverage sweep cannot see because + * the key is a prop, not a call argument. Both halves are asserted here. + */ + +const LOCALES = ["en", "de", "es", "fr", "it", "pl"] as const; +const INSIGHTS_PAGES_DIR = join(process.cwd(), "src/app/insights"); + +/** + * Unit strings that are language-neutral and therefore legitimately appear + * as literals. Every entry is a symbol, an SI abbreviation, or a composed + * one — nothing here changes when the reader's locale does. The empty string + * is the "this metric has no unit" case (pain score, waist-to-height ratio). + */ +const LANGUAGE_NEUTRAL_UNITS = new Set([ + "", + "%", + "/10", + "bpm", + "breaths/min", + "dBA", + "kcal", + "kg/m²", + "m", + "m/s", + "mL/(kg·min)", + "min", + "mmHg", + "ms", +]); + +/** Every `page.tsx` beneath `src/app/insights`, recursively. */ +function insightsPageFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + out.push(...insightsPageFiles(full)); + } else if (entry === "page.tsx") { + out.push(full); + } + } + return out; +} + +/** + * Every `unit="…"` / `yAxisUnit="…"` STRING literal in a source file. + * Expression props (`unit={glucoseUnit}`) resolve at runtime from a unit + * preference or an i18n call and are out of scope by construction. + */ +function unitLiterals(source: string): string[] { + return [...source.matchAll(/\b(?:yAxisUnit|unit)="([^"]*)"/g)].map( + (match) => match[1], + ); +} + +/** Every `unitKey="…"` literal in a source file. */ +function unitKeys(source: string): string[] { + return [...source.matchAll(/\bunitKey="([^"]*)"/g)].map((match) => match[1]); +} + +/** Resolve a dotted key against a loaded message bundle. */ +function resolve(bundle: unknown, key: string): unknown { + let node = bundle; + for (const segment of key.split(".")) { + if (typeof node !== "object" || node === null) return undefined; + node = (node as Record)[segment]; + } + return node; +} + +describe("insights unit-literal guard", () => { + const files = insightsPageFiles(INSIGHTS_PAGES_DIR); + const sources = files.map((file) => ({ + path: file.slice(file.indexOf("src/")), + source: readFileSync(file, "utf8"), + })); + + it("finds the insights pages to scan", () => { + // A broken walk would make every assertion below vacuously true. + expect(files.length).toBeGreaterThan(30); + }); + + it("scans real content — the known symbols are actually present", () => { + const seen = new Set(sources.flatMap((f) => unitLiterals(f.source))); + // If the matcher silently stopped matching, this fails rather than the + // offender list below going quietly empty. + for (const symbol of ["bpm", "mmHg", "%", "m/s"]) { + expect(seen.has(symbol), `expected to see unit="${symbol}"`).toBe(true); + } + }); + + it("passes only language-neutral unit symbols as literals", () => { + const offenders: string[] = []; + for (const { path, source } of sources) { + for (const literal of unitLiterals(source)) { + if (!LANGUAGE_NEUTRAL_UNITS.has(literal)) { + // A word-shaped unit belongs in `messages/*.json` behind `unitKey`. + offenders.push(`${path}: unit="${literal}"`); + } + } + } + expect(offenders).toEqual([]); + }); + + it("resolves every unitKey in all six locales", () => { + const keys = [...new Set(sources.flatMap((f) => unitKeys(f.source)))]; + // The five words the pages actually carry. + expect(keys.length).toBeGreaterThanOrEqual(5); + + const missing: string[] = []; + for (const locale of LOCALES) { + const bundle = JSON.parse( + readFileSync(join(process.cwd(), `messages/${locale}.json`), "utf8"), + ); + for (const key of keys) { + const value = resolve(bundle, key); + if (typeof value !== "string" || value.trim() === "") { + missing.push(`${locale}: ${key}`); + } + } + } + expect(missing).toEqual([]); + }); +}); diff --git a/src/components/insights/__tests__/integer-metric-precision-guard.test.tsx b/src/components/insights/__tests__/integer-metric-precision-guard.test.tsx new file mode 100644 index 000000000..a6da4062a --- /dev/null +++ b/src/components/insights/__tests__/integer-metric-precision-guard.test.tsx @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + INTEGER_VALUED_MEASUREMENT_TYPES, + metricFractionDigits, + DEFAULT_METRIC_FRACTION_DIGITS, +} from "@/lib/measurements/value-domain"; +import type { CoachReadStripData } from "@/lib/insights/derived/coach-read-shape"; + +/** + * GUARD — an integer-valued metric never renders a fractional part. + * + * The step sub-page read "MIN 104.0 steps" and "your usual range is + * …–13,387.6 steps". Precision was a per-page prop defaulting to one + * decimal: the pulse page remembered to pass 0, the step page did not, and + * a tenth of a step was rendered. Every page remembering this individually + * IS the defect — so the assertions below pin that the METRIC answers, and + * that a page which passes nothing still renders a count as a count. + * + * Both surfaces on the reported card are covered: the Min/Max stat strip and + * the coach-read strip underneath it. + */ + +vi.mock("next/dynamic", () => ({ + default: () => { + const Stub = () =>
; + Stub.displayName = "HealthChartStub"; + return Stub; + }, +})); + +vi.mock("next/navigation", () => ({ + usePathname: () => "/insights/steps", +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { timezone: "UTC", dateOfBirth: null, gender: null }, + isAuthenticated: true, + }), +})); + +vi.mock("@/hooks/use-insights-layout-prefs", () => ({ + useInsightsLayoutPrefs: () => ({ layout: null, compareBaseline: false }), +})); + +// The coach-read strip paints nothing until it is client-mounted (React +// #418). Force the mounted branch so the band sentence reaches the markup. +vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); + +const analyticsMock = vi.fn(); +vi.mock("@/hooks/use-insights-analytics", () => ({ + useInsightsAnalytics: () => analyticsMock(), +})); + +const coachReadMock = vi.fn(); +vi.mock("@tanstack/react-query", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useQuery: () => coachReadMock() }; +}); + +import { CoachReadStrip } from "../derived/coach-read-strip"; +import InsightsStepsPage from "@/app/insights/steps/page"; + +function render(node: React.ReactNode, locale: "en" | "de" = "de") { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + return renderToStaticMarkup( + + {node} + , + ); +} + +/** The band and the reading the reporter saw, to the digit. */ +const REPORTED_BAND: CoachReadStripData = { + learning: false, + baseline: { + low: 104, + high: 13387.6, + latest: 8213.4, + placement: "within", + }, + driver: null, +} as unknown as CoachReadStripData; + +/** + * A German-locale decimal on a count: "104,0", "13.387,6". The Latin-1 + * comma-decimal shape is what the reporter photographed. + */ +const FRACTIONAL_GERMAN = /\d,\d/; + +beforeEach(() => { + analyticsMock.mockReset(); + coachReadMock.mockReset(); +}); + +describe("integer-metric precision guard", () => { + it("resolves zero decimals for every discrete metric", () => { + expect(INTEGER_VALUED_MEASUREMENT_TYPES.size).toBeGreaterThan(10); + for (const type of INTEGER_VALUED_MEASUREMENT_TYPES) { + expect(metricFractionDigits(type), type).toBe(0); + } + // A continuous metric keeps the shared default; an unknown one too. + expect(metricFractionDigits("PULSE")).toBe(DEFAULT_METRIC_FRACTION_DIGITS); + expect(metricFractionDigits("WEIGHT")).toBe(DEFAULT_METRIC_FRACTION_DIGITS); + expect(metricFractionDigits(null)).toBe(DEFAULT_METRIC_FRACTION_DIGITS); + }); + + it("renders the coach-read band without a fractional step", () => { + coachReadMock.mockReturnValue({ data: REPORTED_BAND }); + + const html = render( + , + ); + + expect(html).toContain('data-slot="coach-read-baseline"'); + expect(html).not.toMatch(FRACTIONAL_GERMAN); + // The band edges are still there, rounded to whole steps. + expect(html).toContain("13.388"); + expect(html).toContain("104"); + }); + + it("keeps the decimal for a metric that is not a count", () => { + coachReadMock.mockReturnValue({ + data: { + learning: false, + baseline: { low: 71.4, high: 84.2, latest: 78.6, placement: "within" }, + driver: null, + } as unknown as CoachReadStripData, + }); + + const html = render(); + expect(html).toMatch(FRACTIONAL_GERMAN); + }); + + it("renders the step page stat strip without a fractional step", () => { + // The page passes no precision prop at all — that is the point. + analyticsMock.mockReturnValue({ + data: { + summaries: { + ACTIVITY_STEPS: { + count: 214, + latest: 8213, + min: 104, + max: 11762, + mean: 6431.72, + median: 6802.5, + }, + }, + }, + isEmpty: false, + isLoading: false, + }); + coachReadMock.mockReturnValue({ data: undefined }); + + const html = render(); + + expect(html).toContain('data-slot="metric-stat-strip"'); + expect(html).not.toMatch(FRACTIONAL_GERMAN); + // The numbers themselves survive, thousands-grouped and whole. + expect(html).toContain("11.762"); + expect(html).toContain("104"); + // …and the unit reads in German, not as the English word. (The route + // slug `/insights/steps` legitimately appears in the header links, so + // match the unit in its rendered position: right after a number.) + expect(html).toContain("104 Schritte"); + expect(html).not.toMatch(/\d\s*steps\b/); + }); +}); diff --git a/src/lib/measurements/__tests__/non-negative-metric-guard.test.ts b/src/lib/measurements/__tests__/non-negative-metric-guard.test.ts new file mode 100644 index 000000000..98a6e1e4a --- /dev/null +++ b/src/lib/measurements/__tests__/non-negative-metric-guard.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from "vitest"; + +import { + VALUE_RANGES, + measurementTypeEnum, +} from "@/lib/validations/measurement"; +import { + areAllNonNegativeMetrics, + isNonNegativeMetric, +} from "@/lib/measurements/value-domain"; +import { buildBaselineBand } from "@/lib/insights/derived/baseline"; +import { computePaddedYDomain } from "@/lib/insights/chart-y-domain"; + +/** + * GUARD — a non-negative metric's baseline `low` is never below zero. + * + * The step sub-page printed "your usual range is −5,595.6–13,387.6 steps" + * because the band's lower edge is `median − k·MAD·scale` and nothing said a + * step cannot be owed. The chart above it already refused a negative axis, so + * the app held both answers at once. + * + * These assertions pin the fixed shape end to end: the fact is declared once + * (the metric's plausibility floor), the band reads it, and the axis reads the + * same one. A series engineered so the spread exceeds the median is the exact + * condition that produced the reported sentence. + */ + +/** + * Median 10, MAD 10 → spread = 3 · 10 · 1.4826 ≈ 44.5, i.e. well past the + * centre. The raw arithmetic edge is ≈ −34.5. + */ +const SPREAD_EXCEEDS_CENTRE = [0, 0, 0, 10, 20, 30, 40]; + +/** The two types whose declared floor is genuinely signed. */ +const SIGNED_TYPES = ["ANS_CHARGE", "BODY_TEMPERATURE_DEVIATION"] as const; + +describe("non-negative metric guard", () => { + it("every MeasurementType declares a plausibility range", () => { + // The range map is the home of the "can this be negative" fact. A type + // missing from it answers "unknown" and silently loses the floor — and, + // separately, passes any number at all through ingest validation. + const missing = measurementTypeEnum.options.filter( + (type) => VALUE_RANGES[type] === undefined, + ); + expect(missing).toEqual([]); + }); + + it("classifies every type from its own declared floor", () => { + for (const type of measurementTypeEnum.options) { + expect(isNonNegativeMetric(type)).toBe(VALUE_RANGES[type].min >= 0); + } + for (const type of SIGNED_TYPES) { + expect(isNonNegativeMetric(type)).toBe(false); + } + // An unnamed or unknown metric is "unknown", never "non-negative". + expect(isNonNegativeMetric(null)).toBe(false); + expect(isNonNegativeMetric("NOT_A_METRIC")).toBe(false); + expect(areAllNonNegativeMetrics([])).toBe(false); + expect(areAllNonNegativeMetrics(["ACTIVITY_STEPS", "ANS_CHARGE"])).toBe( + false, + ); + }); + + it("never floors the baseline band below zero for a non-negative metric", () => { + const nonNegative = measurementTypeEnum.options.filter((type) => + isNonNegativeMetric(type), + ); + // Sanity: the sweep must actually cover the bulk of the catalogue. + expect(nonNegative.length).toBeGreaterThan(60); + + for (const type of nonNegative) { + const band = buildBaselineBand(SPREAD_EXCEEDS_CENTRE, type); + expect(band, type).not.toBeNull(); + expect(band!.low, `${type} band low`).toBeGreaterThanOrEqual(0); + // The dispersion itself stays unclamped — a deviation-in-σ calculation + // reads `spread`, and flooring it would understate every excursion. + expect(band!.spread, `${type} spread`).toBeGreaterThan(band!.center); + } + }); + + it("reproduces the reported step sentence with a non-negative lower edge", () => { + // Roughly the reported shape: a median around 4,000 steps with days near + // zero, which is what pushed the MAD band past the origin. + const dayMeans = [0, 120, 900, 4000, 6200, 9800, 11762]; + const band = buildBaselineBand(dayMeans, "ACTIVITY_STEPS"); + expect(band!.low).toBe(0); + expect(band!.high).toBeGreaterThan(band!.center); + }); + + it("keeps the real lower edge for a metric that can genuinely be negative", () => { + for (const type of SIGNED_TYPES) { + const band = buildBaselineBand(SPREAD_EXCEEDS_CENTRE, type); + expect(band!.low, type).toBeLessThan(0); + } + // No metric identity → no invented floor either. + expect(buildBaselineBand(SPREAD_EXCEEDS_CENTRE, null)!.low).toBeLessThan(0); + }); + + it("gives the chart axis the same answer as the band", () => { + // A derived overlay (trend line, moving average) can extrapolate a step + // count below zero even though no observation ever was. The axis floors + // it because the METRIC cannot be negative, not because the data was. + const domain = computePaddedYDomain([-320, 1200, 8400], ["ACTIVITY_STEPS"]); + expect(domain![0]).toBe(0); + + // A signed metric keeps its downward padding. + const signed = computePaddedYDomain([-40, 5, 60], ["ANS_CHARGE"]); + expect(signed![0]).toBeLessThan(0); + + // An anonymous series keeps the older data-derived behaviour: padding + // that would dip under an all-positive series stops at 0, a series with + // real negative values keeps the full downward padding. + expect(computePaddedYDomain([0.2, 0.5])![0]).toBe(0); + expect(computePaddedYDomain([-5, 3, 10])![0]).toBeLessThan(0); + }); +});