test(score): turn the eligibility pins from a floor into a tier

Six suites pinned the old rule as the rule. Each inversion, and why it is
one:

`breadth.test.ts` — the case table asserted a refusal reason per set; it
asserts a tier and a `physiological` flag now. Every row in it except the
empty set used to be a refusal, and each is a number somebody gets to see.
Adds the tier boundaries (one pillar, two areas, physiological-absent), a
case proving the same pillars produce the same number at `partial` and at
`full`, and a block whose only job is the counter-case: the empty verdict
still carries a reason and no tier, and a composite whose selected pillars
all came back short still refuses with `no_usable_data`. A rule that has
stopped refusing anything is a deleted rule, not a graded one.

`composite.test.ts` — "requires three domains and one measured
physiological domain" asserted the refusal that no longer happens; it now
asserts the score and the basis block on the same fixture. The coverage
case keeps its fixture and its arithmetic and flips its outcome: two of
three used to be a refusal and is a scored account with a real coverage
fraction. Adds the one-area case and the zero-pillar counter-case, and the
`SCORE_VERSION` floor moves 2 -> 3 because widening who gets a score is
exactly the kind of change that must not ship without a version bump.

`record.test.ts` — "records nothing when the composite did not resolve"
used a two-pillar fixture to prove a below-the-floor day stored nothing.
That day has a number now and the table has to keep it, or an account that
starts narrow and widens later has a hole where its early days were. The
assertion is now that a one-pillar day builds a draft AND reaches the
database; the absence case survives on the fixture that genuinely produces
no score.

`composition-source.test.ts` — three recipes asserted as refused at read
time. Two of them score. What the block proves is unchanged — the reader
publishes the shared rule's verdict on the reader's own resolved set, so a
second copy of the rule would still surface here — but the agreement is
over a tier rather than over a refusal.

`route.test.ts` (score config) — the two too-narrow refusals become
acceptances, because a write that refuses what the read is willing to serve
is the two layers disagreeing. The empty selection keeps its 422 and gains
the assertions the old cases carried: errorCode, reason, and the sentence.
The errorCode driver loses the two selections that no longer 422.

`score-refusal-consumer-guard.test.ts` — its non-zero control anchored on
`three_domains_required`; it anchors on `no_pillars_selected`.

New: `health-score-basis-additive-contract.test.ts` pins that `scoreBasis`
is published, is optional on all three carriers, and points at the one
shared component — a required field on a cached surface would invalidate
every cached payload with nothing failing anywhere. It also pins that the
walk does not confuse the composite's basis with the older per-pillar
`scoreBasis` that blood pressure uses for its axis, since they share a key.

Fixtures: the notice-key assertions derive the method half from
`SCORE_VERSION` rather than restating 3. Three fixtures that named
`three_domains_required` now name `no_usable_data`, a reason the server can
still emit. The settings e2e drove its refusal through a two-pillar
selection that now succeeds; it deselects every pillar instead.
This commit is contained in:
Marc-André Bombeck committed 2026-08-30 14:16:33 +02:00
1 parent aadc3a8e89
commit 61110b635c
12 files changed
+621 -130

No files matched your search

+15 -15
View File
@@ -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 ({
@@ -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<string, Schema>;
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<string, Schema> };
};
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<unknown>();
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<string, unknown>)) {
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",
]);
});
});
@@ -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<Response>)(
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<Response>)(
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<Response>)(
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.
@@ -207,7 +207,7 @@ function report(overrides: Partial<HealthScoreReport> = {}): HealthScoreReport {
status: "insufficient",
coverage,
provenance,
reason: "three_domains_required",
reason: "no_usable_data",
},
pillars: [BLOOD_PRESSURE, ACTIVITY_BELOW_FLOOR],
delta: null,
@@ -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", () => {
+201 -51
View File
@@ -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");
@@ -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", () => {
@@ -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");
}
});
}
@@ -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);
});
@@ -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",
+1 -1
View File
@@ -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,
@@ -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,
);