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); + }); +});