diff --git a/e2e/settings-score.spec.ts b/e2e/settings-score.spec.ts index f8e57e90a..5980f258c 100644 --- a/e2e/settings-score.spec.ts +++ b/e2e/settings-score.spec.ts @@ -13,12 +13,12 @@ import { healthScoreAlgorithmItemKey } from "@/lib/daily/priority-item-key"; * at any width and in any locale (only the copy assertions read English, and * the suite pins the English locale through the storage state). * - * The refusal case is driven through the REAL write. Deselecting down to - * activity and wellbeing genuinely fails the server's breadth rule, and a - * refused write persists nothing, so the case leaves the shared account - * exactly as it found it. Mocking the 422 would have proved that the page - * can render a message, not that the rule is enforced or that the page reads - * the reason the server actually sends. + * The refusal case is driven through the REAL write. Deselecting every + * pillar genuinely fails the server's breadth rule, and a refused write + * persists nothing, so the case leaves the shared account exactly as it + * found it. Mocking the 422 would have proved that the page can render a + * message, not that the rule is enforced or that the page reads the reason + * the server actually sends. * * The analytics read IS mocked, because it is the page's secondary read and * the states it drives — waiting for data, safety signposting — depend on @@ -47,7 +47,7 @@ function scoreReport() { provenance, }); return { - composite: insufficient("three_domains_required"), + composite: insufficient("no_usable_data"), pillars: [ { id: "LIPIDS", @@ -237,16 +237,16 @@ test.describe("Settings → Health Score", () => { const card = page.locator('[data-slot="score-config-card"]'); await settleBeforeMeasure(page, card); - // Start from everything, then take out every pillar except activity and - // wellbeing: two areas of health and no physical measurement, which the - // server refuses. + // v1.38 — this case used to deselect down to activity and wellbeing, + // which the server refused as two areas with no physical + // measurement. That selection saves and scores now, so it no longer + // exercises a refusal at all. Take every pillar out instead: the + // empty selection is the one the scorer genuinely cannot answer, and + // it is what this spec exists to prove is enforced server-side. await page.locator('[data-slot="score-config-preset-all"]').click(); - const keep = new Set(["ACTIVITY", "WELLBEING"]); const rows = page.locator('[data-slot="score-pillar-row"]'); for (let index = 0; index < (await rows.count()); index += 1) { const row = rows.nth(index); - const pillar = await row.getAttribute("data-pillar"); - if (!pillar || keep.has(pillar)) continue; if ((await row.getAttribute("data-counts")) === "false") continue; await row.locator('[data-slot="score-pillar-switch"]').click(); } @@ -258,8 +258,8 @@ test.describe("Settings → Health Score", () => { await expect(refusal).toHaveAttribute("role", "alert"); // The server's machine reason, rendered as a sentence a person can act // on, not as an error code and not as the server's English fallback. - await expect(refusal).toContainText("physical measurement"); - await expect(refusal).not.toContainText("_required"); + await expect(refusal).toContainText("at least one area of health"); + await expect(refusal).not.toContainText("_selected"); }); test("keeps the content column the same width as its neighbours", async ({ diff --git a/src/__tests__/health-score-basis-additive-contract.test.ts b/src/__tests__/health-score-basis-additive-contract.test.ts new file mode 100644 index 000000000..fd128173c --- /dev/null +++ b/src/__tests__/health-score-basis-additive-contract.test.ts @@ -0,0 +1,190 @@ +/** + * v1.38 — `scoreBasis` is additive on every wire it rides, and stays so. + * + * The block is new on three shapes that already ship: the full score + * report's composite, the dashboard snapshot's flattened hero block, and + * the daily digest's score. All three are `additionalProperties: false`, + * and two of the three are served out of a CACHE — a snapshot or digest + * written before this release has no way to grow a field. Declare + * `scoreBasis` as required on any of them and every cached payload in + * existence becomes invalid against the document that describes it on + * the day the document is published, with nothing failing anywhere: the + * generator is happy, `openapi:check` is happy, and the only symptom is + * a strict client-side decoder rejecting real responses. + * + * That is the class this file pins. Every carrier must declare the + * property (so a registry edit that drops it is not a green pass), must + * NOT list it in `required`, and must point at the one shared component + * rather than a fourth hand-rolled copy of the same object. The fields + * that shipped before it stay required, because "additive" is a claim + * about both directions. + * + * The document is BUILT rather than read off disk, the way the sharing + * components guard does it: `scripts/check-openapi.ts` already pins + * `docs/api/openapi.yaml` to exactly this document and CI fails on + * drift, so building it here checks the same artifact without parsing + * two megabytes of YAML. + * + * ## Mutation check + * + * Drop `.optional()` from any of the three registry declarations and the + * matching `required` assertion goes red. Delete a declaration entirely + * and the presence assertion goes red first. + */ +import { describe, expect, it } from "vitest"; + +import { buildOpenApiDocument } from "@/lib/openapi/registry"; + +type Schema = { + type?: string; + properties?: Record; + required?: string[]; + items?: Schema; + anyOf?: Schema[]; + allOf?: Schema[]; + oneOf?: Schema[]; + $ref?: string; + description?: string; + enum?: string[]; + nullable?: boolean; +}; + +const doc = buildOpenApiDocument() as unknown as { + components?: { schemas?: Record }; +}; +const schemas = doc.components?.schemas ?? {}; + +const BASIS_REF = "#/components/schemas/HealthScoreBasis"; + +/** + * Walk a schema tree and collect every object that declares a + * `scoreBasis` property, wherever it is nested. + * + * Written as a walk rather than a path lookup on purpose: the three + * shapes sit at three very different depths (a component's union arm, a + * deeply nested snapshot object, a nullable digest member), and a + * hand-written path per surface is a fourth restatement of the contract + * that can rot without anybody noticing. + */ +function objectsCarryingScoreBasis(root: unknown): Schema[] { + const found: Schema[] = []; + const seen = new Set(); + const visit = (node: unknown): void => { + if (node === null || typeof node !== "object") return; + if (seen.has(node)) return; + seen.add(node); + if (Array.isArray(node)) { + for (const child of node) visit(child); + return; + } + const schema = node as Schema; + if (schema.properties && "scoreBasis" in schema.properties) { + found.push(schema); + } + for (const value of Object.values(node as Record)) { + visit(value); + } + }; + visit(root); + return found; +} + +describe("the score basis is published, and published as optional", () => { + it("declares the shared component once", () => { + const basis = schemas.HealthScoreBasis; + expect( + basis, + "HealthScoreBasis is not a published component", + ).toBeDefined(); + expect(Object.keys(basis?.properties ?? {}).sort()).toEqual([ + "domains", + "physiological", + "recommended", + "tier", + ]); + // The tier vocabulary is the whole label. A client reads it verbatim, + // so the members are pinned rather than left to the enum's shape. + expect(basis?.properties?.tier?.enum).toEqual([ + "full", + "partial", + "minimal", + ]); + // Inside the component the four fields ARE required — the block is + // all-or-nothing. Optionality lives on the shapes that carry it, not + // on its own members, and confusing the two is how a "yes it is + // optional" answer ends up being about the wrong object. + expect([...(basis?.required ?? [])].sort()).toEqual([ + "domains", + "physiological", + "recommended", + "tier", + ]); + }); + + it("is carried by every score-bearing shape, and required by none", () => { + const carriers = objectsCarryingScoreBasis(schemas); + const composites = carriers.filter( + (carrier) => carrier.properties?.scoreBasis?.$ref === BASIS_REF, + ); + + // The non-zero control, and it does double duty. Three surfaces carry + // a composite score — the report's composite, the dashboard + // snapshot's hero block, the daily digest's score — and each is + // counted only if it points at the shared component, so an inlined + // fourth copy of the object drops the count rather than passing as a + // carrier. A walk that stopped matching reports zero and fails, + // instead of reporting a green run over nothing. + expect(composites.length).toBeGreaterThanOrEqual(3); + + for (const carrier of carriers) { + expect(carrier.required ?? []).not.toContain("scoreBasis"); + } + }); + + it("does not confuse the composite's basis with a pillar's", () => { + // `scoreBasis` is an older field name on `PillarValue`, where blood + // pressure uses it to say which of its two axes produced the number. + // Same key, different object, on a shape that also rides the score + // report — so the walk above finds it, and the assertion that every + // carrier `$ref`s the composite component would have been wrong. + // Pinned here so a future reader does not "fix" that by widening the + // composite component to swallow the pillar's field. + const carriers = objectsCarryingScoreBasis(schemas); + const pillarBasis = carriers.filter( + (carrier) => carrier.properties?.scoreBasis?.$ref !== BASIS_REF, + ); + expect(pillarBasis.length).toBeGreaterThan(0); + for (const carrier of pillarBasis) { + expect(carrier.properties?.scoreBasis?.type).toBe("object"); + expect( + Object.keys(carrier.properties?.scoreBasis?.properties ?? {}), + ).toContain("axis"); + } + }); + + it("keeps the fields that shipped before it required", () => { + // The other half of "additive": adding a field must not have + // loosened anything that was already load-bearing. `composition` and + // `configured` are read by iOS on the composite, and a silently + // optional `composition` would be a contract break wearing the + // additive change's clothes. + const composite = schemas.DerivedHealthScoreComposite; + expect(composite, "DerivedHealthScoreComposite is missing").toBeDefined(); + const okArm = (composite?.anyOf ?? composite?.oneOf ?? []).find((arm) => + arm.properties?.value?.properties + ? "scoreBasis" in arm.properties.value.properties + : false, + ); + expect(okArm, "the ok arm of the composite is missing").toBeDefined(); + const value = okArm?.properties?.value; + expect([...(value?.required ?? [])].sort()).toEqual([ + "band", + "bandSetter", + "composition", + "configured", + "noiseFloor", + "score", + "scoreVersion", + ]); + }); +}); diff --git a/src/app/api/auth/me/health-score-config/__tests__/route.test.ts b/src/app/api/auth/me/health-score-config/__tests__/route.test.ts index ae1f5db38..75d4ff20f 100644 --- a/src/app/api/auth/me/health-score-config/__tests__/route.test.ts +++ b/src/app/api/auth/me/health-score-config/__tests__/route.test.ts @@ -258,45 +258,64 @@ describe("PATCH /api/auth/me/health-score-config", () => { }); }); +/** + * v1.38 — what the write still refuses, after the breadth floor became a + * recommendation. + * + * Two of the three refusals this block used to pin are gone. The route + * turned away `["ACTIVITY", "WELLBEING"]` with + * `measured_physiological_domain_required` and the cardiometabolic + * triple with `three_domains_required`, both on the ground that the + * selection could not produce a score. It can now — the scorer computes + * from whatever is readable and labels how broad the set was — and a + * write that kept refusing what the read is willing to serve would be + * the two layers disagreeing, which is the drift the shared rule exists + * to prevent. So those two assertions are inverted to acceptances. + * + * The empty selection stays refused, and that case is why this is a + * change of rule rather than a removal of one. + */ describe("PATCH — the breadth rule", () => { - it("refuses activity and wellbeing alone, in words a person can act on", async () => { + it("accepts activity and wellbeing alone, and stores them", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); primeUser(null); const res = await (PATCH as (r: Request) => Promise)( mkPatch({ pillars: ["ACTIVITY", "WELLBEING"] }), ); - expect(res.status).toBe(422); - const env = (await res.json()) as { - error: string; - meta?: { errorCode?: string; reason?: string }; - }; - expect(env.meta?.errorCode).toBe("health_score_config.too_narrow"); - expect(env.meta?.reason).toBe("measured_physiological_domain_required"); - expect(env.error).toContain("at least one physical measurement"); - expect(prisma.user.update).not.toHaveBeenCalled(); - expect(prisma.user.updateMany).not.toHaveBeenCalled(); - expect(invalidateUserHealthScore).not.toHaveBeenCalled(); + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalledTimes(1); + expect(invalidateUserHealthScore).toHaveBeenCalledWith("user-1"); }); - it("refuses the cardiometabolic triple, which is three pillars in one area", async () => { + it("accepts the cardiometabolic triple, which is three pillars in one area", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); primeUser(null); const res = await (PATCH as (r: Request) => Promise)( mkPatch({ pillars: ["BLOOD_PRESSURE", "GLYCAEMIA", "LIPIDS"] }), ); - expect(res.status).toBe(422); - const env = (await res.json()) as { - error: string; - meta?: { reason?: string }; - }; - expect(env.meta?.reason).toBe("three_domains_required"); - expect(env.error).toContain("three different areas"); - expect(prisma.user.update).not.toHaveBeenCalled(); + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalledTimes(1); + }); + + it("accepts a single pillar", async () => { + // The narrowest selection that can still be scored, and the boundary + // the one surviving refusal sits against. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(null); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ pillars: ["ACTIVITY"] }), + ); + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalledTimes(1); }); it("refuses an empty selection rather than storing a score that cannot exist", async () => { + // The counter-case. A write that accepted everything would have + // stopped enforcing anything, and this is the one selection the + // scorer genuinely cannot answer. vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); primeUser(null); @@ -304,10 +323,19 @@ describe("PATCH — the breadth rule", () => { mkPatch({ pillars: [] }), ); expect(res.status).toBe(422); + const env = (await res.json()) as { + error: string; + meta?: { errorCode?: string; reason?: string }; + }; + expect(env.meta?.errorCode).toBe("health_score_config.too_narrow"); + expect(env.meta?.reason).toBe("no_pillars_selected"); + expect(env.error).toContain("at least one area of health"); expect(prisma.user.update).not.toHaveBeenCalled(); + expect(prisma.user.updateMany).not.toHaveBeenCalled(); + expect(invalidateUserHealthScore).not.toHaveBeenCalled(); }); - it("accepts the smallest selection that clears the rule", async () => { + it("accepts a three-area selection", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); primeUser(null); @@ -449,9 +477,9 @@ describe("the published contract matches what the route serves", () => { } }; - // Too narrow, both reasons. - await record({ pillars: ["ACTIVITY"] }); - await record({ pillars: ["BLOOD_PRESSURE", "GLYCAEMIA", "LIPIDS"] }); + // Too narrow. One reason since v1.38, because one selection is: the + // empty one. + await record({ pillars: [] }); // Body that fails validation. await record({ pillars: ["NOT_A_PILLAR"] }); // Present-but-unparseable base token. diff --git a/src/components/insights/__tests__/health-score-card.test.tsx b/src/components/insights/__tests__/health-score-card.test.tsx index 41283bd96..e97471b1c 100644 --- a/src/components/insights/__tests__/health-score-card.test.tsx +++ b/src/components/insights/__tests__/health-score-card.test.tsx @@ -207,7 +207,7 @@ function report(overrides: Partial = {}): HealthScoreReport { status: "insufficient", coverage, provenance, - reason: "three_domains_required", + reason: "no_usable_data", }, pillars: [BLOOD_PRESSURE, ACTIVITY_BELOW_FLOOR], delta: null, diff --git a/src/components/settings/__tests__/score-refusal-consumer-guard.test.ts b/src/components/settings/__tests__/score-refusal-consumer-guard.test.ts index 2a864de77..d20a4466e 100644 --- a/src/components/settings/__tests__/score-refusal-consumer-guard.test.ts +++ b/src/components/settings/__tests__/score-refusal-consumer-guard.test.ts @@ -107,9 +107,13 @@ const routeReasons = objectKeys(parse(ROUTE_FILE), "BREADTH_REFUSAL"); describe("every refusal the rule can give reaches the reader", () => { it("reads the rule's own list rather than a copy of it", () => { - // A matcher that finds nothing agrees with everything. + // A matcher that finds nothing agrees with everything. The anchor + // moved from `three_domains_required` to `no_pillars_selected` in + // v1.38: three areas of health stopped being the price of a score, + // so the write stopped refusing narrow selections and the only + // refusal left is the empty one. expect(ruleReasons.length).toBeGreaterThan(0); - expect(ruleReasons).toContain("three_domains_required"); + expect(ruleReasons).toContain("no_pillars_selected"); }); it("gives the route a sentence for each of them", () => { diff --git a/src/lib/analytics/score/__tests__/breadth.test.ts b/src/lib/analytics/score/__tests__/breadth.test.ts index 864f07476..8c35cefbe 100644 --- a/src/lib/analytics/score/__tests__/breadth.test.ts +++ b/src/lib/analytics/score/__tests__/breadth.test.ts @@ -1,20 +1,37 @@ /** - * v1.35.0 — the breadth rule, and the proof that both layers apply the - * same one. + * v1.38 — the breadth rule, now a grading rather than a gate, and the + * proof that both layers still read the same one. * - * The settings write refuses a selection that cannot produce a score; - * the scorer refuses to produce one from a set that fails the same rule. - * Two layers, on purpose. The danger in two layers is that they drift, - * so the last block below drives the real `computeComposite` and asserts - * it refuses exactly the sets `evaluateScoreBreadth` refuses, with the - * same reason. + * This file used to pin the two refusals (`three_domains_required`, + * `measured_physiological_domain_required`) as the rule's whole output. + * They are gone: three areas of health is what the score RECOMMENDS, and + * a set below it is scored and labelled instead of refused. So the cases + * that asserted a refusal now assert a TIER, which is the same question + * asked of a rule that answers it differently. + * + * What survives unchanged is the reason the file exists. Two layers + * apply the rule — the settings write and the scorer — and two layers + * drift. The middle block below drives the real `computeComposite` and + * asserts it produces a score for exactly the sets the rule admits, and + * refuses exactly the one it does not. + * + * The last block is the counter-case, and it is the one that would catch + * this change going too far. A rule that has stopped refusing anything + * is not a graded rule, it is a deleted one, so the empty set must still + * come back refused — with a reason, from a verdict that carries no + * tier — and the composite must still say `no_usable_data` rather than + * inventing a score out of nothing. */ import { describe, expect, it } from "vitest"; import { buildOk, deriveCoverage } from "@/lib/insights/derived/coverage"; import type { Derived } from "@/lib/insights/derived/types"; -import { evaluateScoreBreadth } from "../breadth"; +import { + evaluateScoreBreadth, + SCORE_RECOMMENDED_DOMAINS, + type ScoreBreadthTier, +} from "../breadth"; import { computeComposite } from "../composite"; import { SCORE_PILLAR_DOMAINS, @@ -68,77 +85,119 @@ function pillar(id: ScorePillarId, score = 80): ScorePillarResult { return { id, domain: SCORE_PILLAR_DOMAINS[id], result }; } -/** The sets the rule has an opinion about, each with the expected verdict. */ +/** + * The sets the rule has an opinion about, each with the tier it should + * reach and whether the set carries a physiological measurement. + * + * `tier: null` means refused. Exactly one case is refused, on purpose: + * every other row in this table used to be a refusal too, and each is + * now a number somebody gets to see. + */ const CASES: Array<{ what: string; ids: ScorePillarId[]; - reason: string | null; + tier: ScoreBreadthTier | null; + physiological: boolean; }> = [ - { - what: "the two non-physiological pillars alone", - ids: ["ACTIVITY", "WELLBEING"], - reason: "measured_physiological_domain_required", - }, - { - what: "activity and wellbeing plus nothing measured", - ids: ["ACTIVITY"], - reason: "measured_physiological_domain_required", - }, { what: "an empty selection", ids: [], - reason: "measured_physiological_domain_required", + tier: null, + physiological: false, }, { - what: "the whole cardiometabolic triple, which is one domain", + what: "activity alone, which measures nothing physiological", + ids: ["ACTIVITY"], + tier: "minimal", + physiological: false, + }, + { + what: "the two non-physiological pillars together", + ids: ["ACTIVITY", "WELLBEING"], + tier: "partial", + physiological: false, + }, + { + what: "the whole cardiometabolic triple, which is one area", ids: ["BLOOD_PRESSURE", "GLYCAEMIA", "LIPIDS"], - reason: "three_domains_required", + tier: "minimal", + physiological: true, }, { - what: "two domains with a physiological pillar", + what: "one physiological pillar alone", + ids: ["BLOOD_PRESSURE"], + tier: "minimal", + physiological: true, + }, + { + what: "two areas with a physiological pillar", ids: ["BLOOD_PRESSURE", "ACTIVITY"], - reason: "three_domains_required", + tier: "partial", + physiological: true, }, { - what: "three domains including a physiological pillar", + what: "three areas including a physiological pillar", ids: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], - reason: null, + tier: "full", + physiological: true, }, { - what: "three domains where two pillars share one", + what: "three areas where two pillars share one", ids: ["BLOOD_PRESSURE", "LIPIDS", "ACTIVITY", "SLEEP"], - reason: null, + tier: "full", + physiological: true, + }, + { + what: "every pillar", + ids: [...SCORE_PILLAR_IDS], + tier: "full", + physiological: true, }, - { what: "every pillar", ids: [...SCORE_PILLAR_IDS], reason: null }, ]; describe("evaluateScoreBreadth", () => { - for (const { what, ids, reason } of CASES) { - it(`${reason ? "refuses" : "accepts"} ${what}`, () => { + for (const { what, ids, tier, physiological } of CASES) { + it(`${tier === null ? "refuses" : `grades ${tier} for`} ${what}`, () => { const verdict = evaluateScoreBreadth(ids); - expect(verdict.ok).toBe(reason === null); - expect(verdict.reason).toBe(reason); + expect(verdict.ok).toBe(tier !== null); + expect(verdict.tier).toBe(tier); + expect(verdict.physiological).toBe(physiological); + expect(verdict.reason).toBe(tier === null ? "no_pillars_selected" : null); }); } - it("names the missing physiological measure before the missing domain", () => { - // Three domains, none of them a physiological measure is impossible - // with the current registry, so the precedence shows on the set that - // fails both: one non-physiological pillar, one domain. - expect(evaluateScoreBreadth(["ACTIVITY"]).reason).toBe( - "measured_physiological_domain_required", - ); + it("grades on distinct areas, not on how many pillars were named", () => { + // The whole point of the domain map. Three pillars, one area: this is + // the set the old rule refused outright and the set most likely to be + // graded `full` by a client that counts `composition` instead. + const triple = evaluateScoreBreadth([ + "BLOOD_PRESSURE", + "GLYCAEMIA", + "LIPIDS", + ]); + expect(triple.tier).toBe("minimal"); + expect(triple.domains).toEqual(["cardiometabolic"]); }); - it("reports the distinct domains the set spans", () => { + it("puts the recommendation at three areas and reaches full there", () => { + expect(SCORE_RECOMMENDED_DOMAINS).toBe(3); expect( - evaluateScoreBreadth(["BLOOD_PRESSURE", "GLYCAEMIA", "LIPIDS"]).domains, - ).toEqual(["cardiometabolic"]); + evaluateScoreBreadth(["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"]).domains, + ).toHaveLength(SCORE_RECOMMENDED_DOMAINS); + }); + + it("reports a missing physiological measure without withholding the score", () => { + // The old rule's sharpest edge, inverted on purpose: activity alone + // used to be told "no score for you" and is now told what its score + // rests on. `physiological: false` is the whole of that caveat. + const verdict = evaluateScoreBreadth(["ACTIVITY"]); + expect(verdict.ok).toBe(true); + expect(verdict.physiological).toBe(false); }); }); -describe("the scorer refuses exactly what the write refuses", () => { - for (const { what, ids } of CASES) { +describe("the scorer grades exactly what the rule grades", () => { + for (const { what, ids, tier, physiological } of CASES) { it(`agrees on ${what}`, () => { const composite = computeComposite({ pillars: ids.map((id) => pillar(id)), @@ -148,11 +207,101 @@ describe("the scorer refuses exactly what the write refuses", () => { }); const verdict = evaluateScoreBreadth(ids); expect(composite.status).toBe(verdict.ok ? "ok" : "insufficient"); - if (composite.status === "insufficient") { - expect(composite.reason).toBe(verdict.reason); + if (composite.status === "ok") { + expect(composite.value.scoreBasis).toEqual({ + domains: verdict.domains.length, + recommended: SCORE_RECOMMENDED_DOMAINS, + tier, + physiological, + }); } }); } + + it("computes the same number at every tier", () => { + // No discount for the pillars somebody does not track. A mean of 80 + // and 60 is 70 whether it is drawn from two areas or five, because + // any other answer would be a target nobody set. + const narrow = computeComposite({ + pillars: [pillar("BLOOD_PRESSURE", 80), pillar("ACTIVITY", 60)], + availablePillars: ["BLOOD_PRESSURE", "ACTIVITY"], + asOf: NOW, + configured: false, + }); + const broad = computeComposite({ + pillars: [ + pillar("BLOOD_PRESSURE", 80), + pillar("ACTIVITY", 60), + pillar("SLEEP", 70), + ], + availablePillars: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], + asOf: NOW, + configured: false, + }); + expect(narrow.status).toBe("ok"); + expect(broad.status).toBe("ok"); + if (narrow.status !== "ok" || broad.status !== "ok") return; + expect(narrow.value.score).toBe(70); + expect(broad.value.score).toBe(70); + expect(narrow.value.scoreBasis?.tier).toBe("partial"); + expect(broad.value.scoreBasis?.tier).toBe("full"); + }); +}); + +describe("the refusal that is left still refuses", () => { + // A rule that grades everything has stopped being a rule. These are the + // assertions that fail if the grading is widened until nothing can be + // turned away. + it("gives the empty set a reason and no tier", () => { + const verdict = evaluateScoreBreadth([]); + expect(verdict.ok).toBe(false); + if (verdict.ok) return; + expect(verdict.reason).toBe("no_pillars_selected"); + expect(verdict.tier).toBeNull(); + expect(verdict.domains).toEqual([]); + }); + + it("refuses a composite whose pillars are all insufficient", () => { + // The realistic shape of nothing: pillars are SELECTED and graded, + // every one of them came back short. An empty `availablePillars` + // array would prove less, because a set nobody chose is trivially + // empty; this is an account that chose four and has data for none. + const selection: ScorePillarId[] = [ + "BLOOD_PRESSURE", + "ACTIVITY", + "SLEEP", + "WELLBEING", + ]; + const composite = computeComposite({ + pillars: selection.map((id) => ({ + id, + domain: SCORE_PILLAR_DOMAINS[id], + result: { + status: "insufficient", + coverage: deriveCoverage({ + requiredInputs: 1, + presentInputs: 0, + historyDays: 0, + missing: [id], + fullHistoryDays: 28, + }).coverage, + provenance: { + inputs: [], + source: "none", + windowDays: 28, + computedAt: NOW.toISOString(), + }, + reason: "below_day_floor_or_stale", + }, + })), + availablePillars: selection, + asOf: NOW, + configured: false, + }); + expect(composite.status).toBe("insufficient"); + if (composite.status !== "insufficient") return; + expect(composite.reason).toBe("no_usable_data"); + }); }); describe("the pillar-to-domain map", () => { @@ -163,8 +312,9 @@ describe("the pillar-to-domain map", () => { }); it("keeps the three cardiometabolic pillars in one domain", () => { - // The triple-count is the reason a five-pillar selection can still - // fail the rule, so it is pinned rather than left to the map's shape. + // The triple-count is the reason a four-pillar selection can still be + // a one-area score, so it is pinned rather than left to the map's + // shape. expect(SCORE_PILLAR_DOMAINS.BLOOD_PRESSURE).toBe("cardiometabolic"); expect(SCORE_PILLAR_DOMAINS.GLYCAEMIA).toBe("cardiometabolic"); expect(SCORE_PILLAR_DOMAINS.LIPIDS).toBe("cardiometabolic"); diff --git a/src/lib/analytics/score/__tests__/composite.test.ts b/src/lib/analytics/score/__tests__/composite.test.ts index 2a7063fd7..e87f00930 100644 --- a/src/lib/analytics/score/__tests__/composite.test.ts +++ b/src/lib/analytics/score/__tests__/composite.test.ts @@ -135,8 +135,10 @@ describe("the band the mean alone gives", () => { it("moved the method version with it", () => { // A stored day keeps the band it was shown under, and the notice key // carries this number, so each account hears once that the rules - // moved. A threshold edit without a version bump is a silent one. - expect(SCORE_VERSION).toBeGreaterThan(2); + // moved. A threshold edit without a version bump is a silent one, and + // so is an eligibility edit: v1.38 widened who gets a score at all, + // which is why this floor moved from 2 to 3. + expect(SCORE_VERSION).toBeGreaterThan(3); }); }); @@ -198,21 +200,43 @@ describe("the worst pillar still holds the band down", () => { }); describe("reference-score composite", () => { - it("requires three domains and one measured physiological domain", () => { - const tooNarrow = evaluation( + it("scores two areas of health, and says it was two", () => { + // Inverted from the v1.35 pin, which asserted this exact pair came + // back `insufficient`. Three areas is the recommendation now, so a + // person with two gets their number and a basis block that does not + // overstate it. Note `physiological: false` — neither activity nor + // wellbeing is a measurement, and the old rule refused the set on + // that ground alone. + const partial = evaluation( [pillar("ACTIVITY", 90), pillar("WELLBEING", 80)], NOW, ); - expect(tooNarrow.status).toBe("insufficient"); + expect(partial.status).toBe("ok"); + if (partial.status !== "ok") return; + expect(partial.value.score).toBe(85); + expect(partial.value.scoreBasis).toEqual({ + domains: 2, + recommended: 3, + tier: "partial", + physiological: false, + }); - const eligible = evaluation( + const full = evaluation( [pillar("ACTIVITY", 90), pillar("WELLBEING", 80), pillar("SLEEP", 70)], NOW, ); - expect(eligible.status).toBe("ok"); + expect(full.status).toBe("ok"); + if (full.status !== "ok") return; + expect(full.value.scoreBasis?.tier).toBe("full"); + expect(full.value.scoreBasis?.physiological).toBe(true); }); it("reports breadth coverage as distinct eligible domains", () => { + // Same fixture as the v1.35 version — four pillars, two areas — and + // the same coverage arithmetic. What changed is the outcome. The + // fraction used to be a floor every scored account had already + // cleared; it is a real moving number on a scored account now, which + // is what makes the coverage meter say something. const result = evaluation( [ pillar("BLOOD_PRESSURE", 90), @@ -223,12 +247,52 @@ describe("reference-score composite", () => { NOW, ); - expect(result.status).toBe("insufficient"); + expect(result.status).toBe("ok"); expect(result.coverage.requiredInputs).toBe(3); expect(result.coverage.presentInputs).toBe(2); - if (result.status === "insufficient") { - expect(result.reason).toBe("three_domains_required"); - } + if (result.status !== "ok") return; + expect(result.value.scoreBasis).toEqual({ + domains: 2, + recommended: 3, + tier: "partial", + physiological: true, + }); + // The narrower set does not read as fully confident, and nothing new + // was built for that: the existing coverage blend already does it. + expect(result.confidence.score).toBeLessThan(100); + }); + + it("scores one area of health, at the tier that says so", () => { + // The bottom tier boundary, on the shape most likely to fool a + // client that counts `composition` instead of areas: three pillars, + // one area. + const result = evaluation( + [ + pillar("BLOOD_PRESSURE", 90), + pillar("GLYCAEMIA", 84), + pillar("LIPIDS", 81), + ], + NOW, + ); + expect(result.status).toBe("ok"); + if (result.status !== "ok") return; + expect(result.value.score).toBe(85); + expect(result.value.composition).toHaveLength(3); + expect(result.value.scoreBasis).toEqual({ + domains: 1, + recommended: 3, + tier: "minimal", + physiological: true, + }); + }); + + it("refuses when not one pillar is usable, and says why", () => { + // The counter-case. Grading instead of gating is only honest while + // something is still turned away; this is the something. + const result = evaluation([], NOW); + expect(result.status).toBe("insufficient"); + if (result.status !== "insufficient") return; + expect(result.reason).toBe("no_usable_data"); }); it("uses equal weights in registry order", () => { diff --git a/src/lib/analytics/score/__tests__/composition-source.test.ts b/src/lib/analytics/score/__tests__/composition-source.test.ts index 288662db5..8d6535509 100644 --- a/src/lib/analytics/score/__tests__/composition-source.test.ts +++ b/src/lib/analytics/score/__tests__/composition-source.test.ts @@ -21,9 +21,9 @@ * they switched off is not waiting for anything. * * 3. **One breadth rule, applied twice.** The settings write refuses a - * selection that cannot produce a score; the reader refuses to - * produce one from a set that fails the same rule. The block at the - * bottom drives the REAL reader and asserts its refusal reason is + * selection that cannot produce a score; the reader grades the set it + * resolved through the same rule. The block at the bottom drives the + * REAL reader and asserts the basis it published is * `evaluateScoreBreadth`'s verdict on the reader's own resolved set, * so a second copy of the rule that disagreed would show up here. * @@ -480,10 +480,16 @@ describe("selected and waiting for data versus switched off", () => { }); }); -describe("the breadth rule still refuses at read time", () => { +describe("the breadth rule still grades at read time", () => { + // Inverted from the v1.35 pins, which asserted all three of these + // recipes came back `insufficient`. Two of them produce a score now. + // What the block proves has not moved — the reader publishes the shared + // rule's verdict on the reader's own resolved set, so a second copy of + // the rule that disagreed would still show up here — but the agreement + // is over a tier rather than over a refusal. const NARROW: Array<{ what: string; selection: ScorePillarId[] }> = [ { - what: "a recipe that leaves two scoring domains", + what: "a recipe that leaves two scoring areas", selection: ["ACTIVITY", "ADIPOSITY", "BLOOD_PRESSURE"], }, { @@ -497,7 +503,7 @@ describe("the breadth rule still refuses at read time", () => { ]; for (const { what, selection } of NARROW) { - it(`refuses ${what}, with the reason the shared rule gives`, async () => { + it(`agrees with the shared rule on ${what}`, async () => { const report = await score({ modules: ALL_MODULES_ON, healthScoreConfigJson: recipe(selection), @@ -508,10 +514,23 @@ describe("the breadth rule still refuses at read time", () => { .map((pillar) => pillar.id); const verdict = evaluateScoreBreadth(scoring); - expect(verdict.ok).toBe(false); - expect(report.composite.status).toBe("insufficient"); - if (report.composite.status !== "insufficient") return; - expect(report.composite.reason).toBe(verdict.reason); + expect(report.composite.status).toBe(verdict.ok ? "ok" : "insufficient"); + if (report.composite.status === "ok") { + // The published basis IS the shared rule's verdict on the + // reader's set, not a second count taken on the way out. + expect(report.composite.value.scoreBasis?.tier).toBe(verdict.tier); + expect(report.composite.value.scoreBasis?.domains).toBe( + verdict.domains.length, + ); + expect(report.composite.value.scoreBasis?.physiological).toBe( + verdict.physiological, + ); + } else { + // The one refusal that survives, reached the honest way: a recipe + // whose three pillars all came back without data. + expect(verdict.ok).toBe(false); + expect(report.composite.reason).toBe("no_usable_data"); + } }); } diff --git a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts index df1894fcb..c9316c109 100644 --- a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts +++ b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts @@ -246,7 +246,7 @@ import { MODULE_KEYS } from "@/lib/modules/registry"; import { healthScoreConfigFromSelection } from "../config"; import { computeUserHealthScore } from "../reader"; -import { SCORE_PILLAR_IDS } from "../types"; +import { SCORE_PILLAR_IDS, SCORE_VERSION } from "../types"; /** The recipe as the settings write would have stored it. */ function recipe(changedAt: Date): unknown { @@ -523,16 +523,16 @@ describe("a settings change is never narrated as a health event", () => { const unconfigured = await computeUserHealthScore(scoreInput(null)); expect(configured.algorithmNotice!.itemKey).toBe( - "health_score_algorithm:3:config:3", + `health_score_algorithm:${SCORE_VERSION}:config:3`, ); expect(configured.algorithmNotice!.dismissed).toBe(false); // An account that never chose keeps the bare method key, with no // recipe suffix on it. expect(unconfigured.algorithmNotice!.itemKey).toBe( - "health_score_algorithm:3", + `health_score_algorithm:${SCORE_VERSION}`, ); - dismissedKeys.add("health_score_algorithm:3:config:3"); + dismissedKeys.add(`health_score_algorithm:${SCORE_VERSION}:config:3`); expect( (await computeUserHealthScore(scoreInput(JUST_CHANGED))).algorithmNotice! .dismissed, @@ -550,7 +550,7 @@ describe("a settings change is never narrated as a health event", () => { ), ); expect(next.algorithmNotice!.itemKey).toBe( - "health_score_algorithm:3:config:4", + `health_score_algorithm:${SCORE_VERSION}:config:4`, ); expect(next.algorithmNotice!.dismissed).toBe(false); }); diff --git a/src/lib/analytics/score/__tests__/record.test.ts b/src/lib/analytics/score/__tests__/record.test.ts index b0b05c480..997ba2e2f 100644 --- a/src/lib/analytics/score/__tests__/record.test.ts +++ b/src/lib/analytics/score/__tests__/record.test.ts @@ -301,14 +301,35 @@ describe("building the row", () => { expect(draft.composition).not.toContain("ACTIVITY"); }); + it("records a day built from a single pillar", () => { + // Inverted from the v1.35 pin, which used a two-pillar fixture to + // assert that a below-the-floor day stored nothing. The floor is + // gone, so the day HAS a number and the table has to keep it: an + // account that only tracks steps now has a history, and one that + // begins on one pillar and widens later has an unbroken series + // rather than a hole where its narrow days were. + const single = report([pillar("ACTIVITY", 90)]); + expect(single.composite.status).toBe("ok"); + const draft = buildHealthScoreRecord(single, { + timezone: "UTC", + now: NOW, + config: UNCONFIGURED_SCORE_BOUNDARY, + }); + expect(draft).not.toBeNull(); + if (!draft) return; + expect(draft.composite).toBe(90); + expect(draft.composition).toEqual(["ACTIVITY"]); + expect(draft.pillarScores).toEqual({ ACTIVITY: 90 }); + }); + it("records nothing when the composite did not resolve", () => { - // Two domains: below the breadth floor, so there is no number. Absence has - // to reach the table as absence — a stored 0 would be a day the person is - // told they scored zero. - const thin = report([pillar("ACTIVITY", 90), pillar("WELLBEING", 80)]); - expect(thin.composite.status).toBe("insufficient"); + // Absence still has to reach the table as absence — a stored 0 would + // be a day the person is told they scored zero. What produces that + // absence has narrowed to one case: not one usable pillar. + const empty = report([]); + expect(empty.composite.status).toBe("insufficient"); expect( - buildHealthScoreRecord(thin, { + buildHealthScoreRecord(empty, { timezone: "UTC", now: NOW, config: UNCONFIGURED_SCORE_BOUNDARY, @@ -383,9 +404,24 @@ describe("writing the row", () => { ).toBe("already_recorded"); }); + it("writes the row for a day that rests on one pillar", async () => { + // The other half of the inverted pin: the narrow day does not just + // build a draft, it reaches the table. + const { db, createMany } = delegate(1); + const single = report([pillar("ACTIVITY", 90)]); + expect( + await recordHealthScore(db, "user-1", single, { + timezone: "UTC", + now: NOW, + config: UNCONFIGURED_SCORE_BOUNDARY, + }), + ).toBe("written"); + expect(createMany).toHaveBeenCalledTimes(1); + }); + it("never touches the database when there is no score to record", async () => { const { db, createMany } = delegate(1); - const thin = report([pillar("ACTIVITY", 90), pillar("WELLBEING", 80)]); + const thin = report([]); expect( await recordHealthScore(db, "user-1", thin, { timezone: "UTC", diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 56b551b16..ce4d4f8a3 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -87,7 +87,7 @@ vi.mock("@/lib/analytics/score/reader", () => ({ status: "insufficient" as const, coverage, provenance, - reason: "three_domains_required", + reason: "no_usable_data", }; return { composite, diff --git a/src/lib/insights/derived/__tests__/health-score-coverage-contract.test.ts b/src/lib/insights/derived/__tests__/health-score-coverage-contract.test.ts index 477ece211..6e37121b2 100644 --- a/src/lib/insights/derived/__tests__/health-score-coverage-contract.test.ts +++ b/src/lib/insights/derived/__tests__/health-score-coverage-contract.test.ts @@ -25,7 +25,7 @@ import { describe, expect, it } from "vitest"; import { computeComposite } from "@/lib/analytics/score/composite"; -import { SCORE_MIN_ELIGIBLE_DOMAINS } from "@/lib/analytics/score/breadth"; +import { SCORE_RECOMMENDED_DOMAINS } from "@/lib/analytics/score/breadth"; import { healthScoreConfigFromSelection, resolveHealthScoreConfig, @@ -154,7 +154,7 @@ describe("Health Score coverage contract", () => { expect(composite.coverage.requiredInputs).not.toBe(SCORE_PILLAR_IDS.length); // The denominator is the breadth rule applied to the person's own set, // so it can never exceed what they chose to count. - expect(composite.coverage.requiredInputs).toBe(SCORE_MIN_ELIGIBLE_DOMAINS); + expect(composite.coverage.requiredInputs).toBe(SCORE_RECOMMENDED_DOMAINS); expect(composite.coverage.requiredInputs).toBeLessThanOrEqual( selection.length, );