mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
aadc3a8e89
commit
61110b635c
12 files changed
+621
-130
No files matched your search
+15
-15
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user