mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
test(score): pin the weight yardstick, the mood gate and rollup/live parity
Covers what the required parameters now make impossible to forget: the scorer takes the band whole and never widens it, a missing target scores the bare trend, the resolved yardstick rides the weight component, the shared builder refuses the reference band that sits under an override, the snapshot gates its mood pillar on the module map, an explicit target reaches both the score and the dashboard bands, and weight/BMI record direction follows the user's own goal. F1 adds the golden the score has never had: the rollup read branch and the live read branch scored against one series encoded both ways. On the canonical one-weigh-in-a-day cadence they agree exactly. On an uneven cadence they part company, and the test pins that the whole difference is the per-day mean collapse - the rollup branch equals the live branch fed those same daily means - with a companion assertion proving the fixture is not a no-op.
This commit is contained in:
1 parent
b0862c0902
commit
be348f8820
12 files changed
+886
-63
No files matched your search
@@ -359,3 +359,75 @@ describe("<HealthScoreCard>", () => {
|
||||
expect(progressMatch?.[0]).toContain('aria-valuemax="100"');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.34 — the weight row names its yardstick. Before this the score graded
|
||||
* weight against a band the user never set and the card said nothing at all,
|
||||
* which is why the defect survived for months.
|
||||
*/
|
||||
describe("<HealthScoreCard> — weight yardstick disclosure", () => {
|
||||
it("names the user's own target band", () => {
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={80}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
weightYardstick={{ source: "user", targetLabel: "64-70 kg" }}
|
||||
/>,
|
||||
);
|
||||
const line = html.match(
|
||||
/<p[^>]*data-slot="health-score-card-weight-yardstick"[^>]*>([\s\S]*?)<\/p>/,
|
||||
);
|
||||
expect(line).not.toBeNull();
|
||||
expect(line?.[0]).toContain('data-target-source="user"');
|
||||
expect(line?.[1]).toContain("64-70 kg");
|
||||
});
|
||||
|
||||
it("says plainly when no target graded the row", () => {
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={80}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
weightYardstick={{ source: "none", targetLabel: null }}
|
||||
/>,
|
||||
);
|
||||
const line = html.match(
|
||||
/<p[^>]*data-slot="health-score-card-weight-yardstick"[^>]*>([\s\S]*?)<\/p>/,
|
||||
);
|
||||
expect(line).not.toBeNull();
|
||||
expect(line?.[0]).toContain('data-target-source="none"');
|
||||
expect(line?.[1]).toMatch(/trend/i);
|
||||
});
|
||||
|
||||
it("discloses nothing when the payload carries no yardstick", () => {
|
||||
// An older cached body has no `targetSource`; silence beats a guess.
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={80}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain("health-score-card-weight-yardstick");
|
||||
});
|
||||
|
||||
it("stays quiet when the weight pillar itself has no value", () => {
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={80}
|
||||
band="green"
|
||||
components={{
|
||||
...baseComponents,
|
||||
weight: { value: null, weight: 0 },
|
||||
}}
|
||||
delta={null}
|
||||
weightYardstick={{ source: "none", targetLabel: null }}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain("health-score-card-weight-yardstick");
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
getEffectiveRange,
|
||||
getAllEffectiveRanges,
|
||||
METRIC_BOUNDS,
|
||||
resolveWeightTargetOverride,
|
||||
type UserProfileForRange,
|
||||
type ThresholdOverridesJson,
|
||||
} from "../effective-range";
|
||||
@@ -224,3 +225,48 @@ describe("OXYGEN_SATURATION thresholds", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.34 — `User.thresholdsJson` is untyped JSON and v1.34 gave the weight
|
||||
* target three new consumers at once (the Health Score, the dashboard bands,
|
||||
* the PR direction). One narrowing beats three hand-rolled casts, and a
|
||||
* malformed blob must read as "no target", never as a target of NaN.
|
||||
*/
|
||||
describe("resolveWeightTargetOverride", () => {
|
||||
it("reads a well-formed weight override", () => {
|
||||
expect(
|
||||
resolveWeightTargetOverride({ WEIGHT: { min: 74, max: 78 } }),
|
||||
).toEqual({ min: 74, max: 78 });
|
||||
});
|
||||
|
||||
it("returns null for a blob with no weight entry", () => {
|
||||
expect(resolveWeightTargetOverride(null)).toBeNull();
|
||||
expect(resolveWeightTargetOverride({})).toBeNull();
|
||||
expect(
|
||||
resolveWeightTargetOverride({ BLOOD_PRESSURE_SYS: { min: 1, max: 2 } }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a malformed pair instead of passing it through", () => {
|
||||
expect(resolveWeightTargetOverride({ WEIGHT: { min: 74 } })).toBeNull();
|
||||
expect(
|
||||
resolveWeightTargetOverride({ WEIGHT: { min: "74", max: "78" } }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveWeightTargetOverride({ WEIGHT: { min: NaN, max: 78 } }),
|
||||
).toBeNull();
|
||||
// An inverted or collapsed band is not a band.
|
||||
expect(
|
||||
resolveWeightTargetOverride({ WEIGHT: { min: 80, max: 70 } }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveWeightTargetOverride({ WEIGHT: { min: 75, max: 75 } }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("survives a non-object blob", () => {
|
||||
expect(resolveWeightTargetOverride("nope")).toBeNull();
|
||||
expect(resolveWeightTargetOverride(42)).toBeNull();
|
||||
expect(resolveWeightTargetOverride(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -132,9 +132,11 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
const result = await computeUserHealthScoreFastPath({
|
||||
userId: "user-rollup",
|
||||
bpInTargetPct: 85,
|
||||
heightCm: 178,
|
||||
weightTarget: null,
|
||||
weightTargetSource: "none",
|
||||
now,
|
||||
coverage,
|
||||
moodEnabled: true,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
@@ -225,9 +227,11 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
await computeUserHealthScoreFastPath({
|
||||
userId: "user-longwindow",
|
||||
bpInTargetPct: 85,
|
||||
heightCm: 178,
|
||||
weightTarget: null,
|
||||
weightTargetSource: "none",
|
||||
now,
|
||||
coverage,
|
||||
moodEnabled: true,
|
||||
});
|
||||
|
||||
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
|
||||
@@ -276,9 +280,11 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
await computeUserHealthScoreFastPath({
|
||||
userId: "user-no-longwindow",
|
||||
bpInTargetPct: 85,
|
||||
heightCm: 178,
|
||||
weightTarget: null,
|
||||
weightTargetSource: "none",
|
||||
now,
|
||||
coverage,
|
||||
moodEnabled: true,
|
||||
});
|
||||
|
||||
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
|
||||
@@ -306,9 +312,11 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
await computeUserHealthScoreFastPath({
|
||||
userId: "user-live-nowmy",
|
||||
bpInTargetPct: 80,
|
||||
heightCm: 178,
|
||||
weightTarget: null,
|
||||
weightTargetSource: "none",
|
||||
now,
|
||||
coverage,
|
||||
moodEnabled: true,
|
||||
});
|
||||
|
||||
// The live branch never issues the long-window probe — guards
|
||||
@@ -348,9 +356,11 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
const result = await computeUserHealthScoreFastPath({
|
||||
userId: "user-live",
|
||||
bpInTargetPct: 75,
|
||||
heightCm: 178,
|
||||
weightTarget: null,
|
||||
weightTargetSource: "none",
|
||||
now,
|
||||
coverage,
|
||||
moodEnabled: true,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
@@ -402,9 +412,11 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
const result = await computeUserHealthScoreFastPath({
|
||||
userId: "user-no-height",
|
||||
bpInTargetPct: null,
|
||||
heightCm: null,
|
||||
weightTarget: null,
|
||||
weightTargetSource: "none",
|
||||
now,
|
||||
coverage,
|
||||
moodEnabled: true,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
@@ -430,9 +442,11 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
const result = await computeUserHealthScoreFastPath({
|
||||
userId: "user-empty",
|
||||
bpInTargetPct: null,
|
||||
heightCm: null,
|
||||
weightTarget: null,
|
||||
weightTargetSource: "none",
|
||||
now: new Date("2026-05-17T12:00:00.000Z"),
|
||||
coverage,
|
||||
moodEnabled: true,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
@@ -468,9 +482,11 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
const result = await computeUserHealthScoreFastPath({
|
||||
userId: "user-one-mood-entry",
|
||||
bpInTargetPct: null,
|
||||
heightCm: null,
|
||||
weightTarget: null,
|
||||
weightTargetSource: "none",
|
||||
now,
|
||||
coverage,
|
||||
moodEnabled: true,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
@@ -500,9 +516,11 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
const result = await computeUserHealthScoreFastPath({
|
||||
userId: "user-one-weight-reading",
|
||||
bpInTargetPct: null,
|
||||
heightCm: null,
|
||||
weightTarget: null,
|
||||
weightTargetSource: "none",
|
||||
now,
|
||||
coverage,
|
||||
moodEnabled: true,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
@@ -534,9 +552,11 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
const result = await computeUserHealthScoreFastPath({
|
||||
userId: "user-multi-pillar-ineligible",
|
||||
bpInTargetPct: null,
|
||||
heightCm: null,
|
||||
weightTarget: null,
|
||||
weightTargetSource: "none",
|
||||
now,
|
||||
coverage,
|
||||
moodEnabled: true,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
@@ -559,9 +579,11 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
userId: "user-legit-zero",
|
||||
bpInTargetPct: 0,
|
||||
bpGradedScore: 0,
|
||||
heightCm: null,
|
||||
weightTarget: null,
|
||||
weightTargetSource: "none",
|
||||
now,
|
||||
coverage,
|
||||
moodEnabled: true,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
@@ -580,8 +602,10 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
await computeUserHealthScoreFastPath({
|
||||
userId: "user-fresh",
|
||||
bpInTargetPct: null,
|
||||
heightCm: null,
|
||||
weightTarget: null,
|
||||
weightTargetSource: "none",
|
||||
now: new Date("2026-05-17T12:00:00.000Z"),
|
||||
moodEnabled: true,
|
||||
});
|
||||
|
||||
expect(PROBE).toHaveBeenCalledWith("user-fresh");
|
||||
@@ -603,9 +627,11 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
userId: "user-bpdelta",
|
||||
bpInTargetPct: 90,
|
||||
bpInTargetPctPriorWeek: 70,
|
||||
heightCm: null,
|
||||
weightTarget: null,
|
||||
weightTargetSource: "none",
|
||||
now: new Date("2026-05-17T12:00:00.000Z"),
|
||||
coverage,
|
||||
moodEnabled: true,
|
||||
});
|
||||
|
||||
// With only BP data the helper still returns a score. The delta
|
||||
@@ -634,9 +660,11 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
userId: "user-bplegacy",
|
||||
bpInTargetPct: 80,
|
||||
// bpInTargetPctPriorWeek omitted — should fall back to 80.
|
||||
heightCm: null,
|
||||
weightTarget: null,
|
||||
weightTargetSource: "none",
|
||||
now: new Date("2026-05-17T12:00:00.000Z"),
|
||||
coverage,
|
||||
moodEnabled: true,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
@@ -715,9 +743,11 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
const result = await computeUserHealthScoreFastPath({
|
||||
userId: "user-weekly-med",
|
||||
bpInTargetPct: 80,
|
||||
heightCm: 178,
|
||||
weightTarget: null,
|
||||
weightTargetSource: "none",
|
||||
now,
|
||||
coverage,
|
||||
moodEnabled: true,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
@@ -778,9 +808,11 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
const result = await computeUserHealthScoreFastPath({
|
||||
userId: "user-daily-med",
|
||||
bpInTargetPct: 80,
|
||||
heightCm: 178,
|
||||
weightTarget: null,
|
||||
weightTargetSource: "none",
|
||||
now,
|
||||
coverage,
|
||||
moodEnabled: true,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
@@ -799,6 +831,10 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
* (or inflated) by a pillar the user can no longer see. Golden fixture:
|
||||
* an identical profile with strong, stable mood scored mood-on vs
|
||||
* mood-off must produce a different, correctly re-weighted score.
|
||||
*
|
||||
* v1.34 — the flag is required. It used to default to on, and the
|
||||
* dashboard snapshot never passed it, so the hero ring scored a pillar
|
||||
* the account had switched off while the Insights card dropped it.
|
||||
*/
|
||||
describe("module-aware mood pillar (R4)", () => {
|
||||
// A profile with BP + steady high mood (low variance → high
|
||||
@@ -811,7 +847,7 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
}));
|
||||
}
|
||||
|
||||
it("weights the mood pillar when moodEnabled is omitted (pre-v1.18.0 default)", async () => {
|
||||
it("weights the mood pillar when moodEnabled is true", async () => {
|
||||
const coverage = new Map<string, boolean>();
|
||||
FULLY_COVERED.mockReturnValue(false);
|
||||
PROBE.mockResolvedValue(coverage);
|
||||
@@ -823,9 +859,11 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
userId: "user-mood-on",
|
||||
bpInTargetPct: 60,
|
||||
bpGradedScore: 60,
|
||||
heightCm: null,
|
||||
weightTarget: null,
|
||||
weightTargetSource: "none",
|
||||
now,
|
||||
coverage,
|
||||
moodEnabled: true,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
@@ -849,7 +887,8 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
userId: "user-mood-off",
|
||||
bpInTargetPct: 60,
|
||||
bpGradedScore: 60,
|
||||
heightCm: null,
|
||||
weightTarget: null,
|
||||
weightTargetSource: "none",
|
||||
now,
|
||||
coverage,
|
||||
moodEnabled: false,
|
||||
@@ -888,9 +927,11 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
userId: "user-golden-on",
|
||||
bpInTargetPct: 60,
|
||||
bpGradedScore: 60,
|
||||
heightCm: null,
|
||||
weightTarget: null,
|
||||
weightTargetSource: "none",
|
||||
now,
|
||||
coverage,
|
||||
moodEnabled: true,
|
||||
});
|
||||
|
||||
// Mood-OFF: only BP=60 survives → score 60.
|
||||
@@ -899,7 +940,8 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
userId: "user-golden-off",
|
||||
bpInTargetPct: 60,
|
||||
bpGradedScore: 60,
|
||||
heightCm: null,
|
||||
weightTarget: null,
|
||||
weightTargetSource: "none",
|
||||
now,
|
||||
coverage,
|
||||
moodEnabled: false,
|
||||
@@ -936,9 +978,11 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
bpInTargetPct: 16,
|
||||
// …but the graded clinical-proximity score says borderline.
|
||||
bpGradedScore: 55,
|
||||
heightCm: null,
|
||||
weightTarget: null,
|
||||
weightTargetSource: "none",
|
||||
now,
|
||||
coverage,
|
||||
moodEnabled: true,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
@@ -968,9 +1012,11 @@ describe("computeUserHealthScoreFastPath", () => {
|
||||
// stray graded score were supplied.
|
||||
bpInTargetPct: null,
|
||||
bpGradedScore: 80,
|
||||
heightCm: null,
|
||||
weightTarget: null,
|
||||
weightTargetSource: "none",
|
||||
now,
|
||||
coverage,
|
||||
moodEnabled: true,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildHealthScoreBpInputs } from "../health-score-inputs";
|
||||
import {
|
||||
buildHealthScoreBpInputs,
|
||||
buildHealthScoreWeightTarget,
|
||||
} from "../health-score-inputs";
|
||||
import type { BpInTargetEnvelope } from "../bp-in-target-fast-path";
|
||||
|
||||
function env(partial: Partial<BpInTargetEnvelope>): BpInTargetEnvelope {
|
||||
@@ -134,3 +137,60 @@ describe("buildHealthScoreBpInputs — single Health-Score BP input builder", ()
|
||||
expect(a.bpInTargetPct).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.34 — the weight yardstick builder. The score used to grade weight against
|
||||
* `22 x height^2` +/- 2 kg, a band no user set and no surface named. The
|
||||
* builder is the one place the two Health-Score callers resolve it, and its
|
||||
* whole job is to refuse to hand over anything but a real user target.
|
||||
*/
|
||||
describe("buildHealthScoreWeightTarget — the one weight-yardstick resolver", () => {
|
||||
const profile = {
|
||||
heightCm: 178,
|
||||
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
|
||||
gender: "MALE",
|
||||
};
|
||||
|
||||
it("hands over the user's own band when they set one", () => {
|
||||
const result = buildHealthScoreWeightTarget(profile, {
|
||||
WEIGHT: { min: 74, max: 78 },
|
||||
});
|
||||
expect(result.weightTargetSource).toBe("user");
|
||||
expect(result.weightTarget).toEqual({ min: 74, max: 78 });
|
||||
});
|
||||
|
||||
it("refuses the evidence-based default that sits under the override", () => {
|
||||
// `getEffectiveRange` resolves a height-derived WHO band for this profile.
|
||||
// It is a reference band, not a goal the person chose, so it must never
|
||||
// reach the score — that substitution IS the defect.
|
||||
const result = buildHealthScoreWeightTarget(profile, null);
|
||||
expect(result.weightTargetSource).toBe("none");
|
||||
expect(result.weightTarget).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores overrides on other metrics", () => {
|
||||
const result = buildHealthScoreWeightTarget(profile, {
|
||||
BLOOD_PRESSURE_SYS: { min: 110, max: 130 },
|
||||
});
|
||||
expect(result.weightTargetSource).toBe("none");
|
||||
expect(result.weightTarget).toBeNull();
|
||||
});
|
||||
|
||||
it("returns no target for a profile with no height and no override", () => {
|
||||
const result = buildHealthScoreWeightTarget(
|
||||
{ heightCm: null, dateOfBirth: null, gender: null },
|
||||
null,
|
||||
);
|
||||
expect(result.weightTargetSource).toBe("none");
|
||||
expect(result.weightTarget).toBeNull();
|
||||
});
|
||||
|
||||
it("honours an override even when the profile carries no height", () => {
|
||||
const result = buildHealthScoreWeightTarget(
|
||||
{ heightCm: null, dateOfBirth: null, gender: null },
|
||||
{ WEIGHT: { min: 60, max: 65 } },
|
||||
);
|
||||
expect(result.weightTargetSource).toBe("user");
|
||||
expect(result.weightTarget).toEqual({ min: 60, max: 65 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* F1 — rollup-vs-live golden for the Health Score.
|
||||
*
|
||||
* `computeUserHealthScoreFastPath` reads the weight series two ways: per-day
|
||||
* MEAN buckets off `measurement_rollups` when the WEIGHT type is covered, raw
|
||||
* `measurements` rows otherwise. The scoring math cannot diverge — there is one
|
||||
* scorer and both branches call it — but the INPUT SERIES can, and nothing in
|
||||
* the suite asserted the two branches land on the same number for the same
|
||||
* underlying readings. The existing fast-path tests exercise each branch alone.
|
||||
*
|
||||
* This file encodes one series both ways and pins the equivalence, plus the one
|
||||
* documented case where the two legitimately differ: with several readings on a
|
||||
* day, the rollup branch regresses over the day's mean while the live branch
|
||||
* regresses over every reading. That is a collapse, not a bug — so the second
|
||||
* suite pins that the rollup branch equals the live branch fed those same daily
|
||||
* means, which is the exact claim the fast-path's own comment makes.
|
||||
*/
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
measurementRollup: { findMany: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rollups/measurement-coverage", () => ({
|
||||
isFullyCovered: vi.fn(),
|
||||
probeRollupCoverage: vi.fn(),
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
|
||||
import { computeUserHealthScoreFastPath } from "../health-score-fast-path";
|
||||
|
||||
const MEASUREMENT_FIND_MANY = prisma.measurement
|
||||
.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
const ROLLUP_FIND_MANY = prisma.measurementRollup
|
||||
.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
const MOOD_FIND_MANY = prisma.moodEntry.findMany as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
const MEDICATION_FIND_MANY = prisma.medication
|
||||
.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
const PROBE = probeRollupCoverage as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
const NOW = new Date("2026-05-20T12:00:00.000Z");
|
||||
|
||||
/** A weight reading in the shared fixture vocabulary. */
|
||||
interface Reading {
|
||||
/** Days before `NOW` the reading was taken. */
|
||||
daysAgo: number;
|
||||
/** Hour of that day, so several readings can share a day. */
|
||||
hour: number;
|
||||
kg: number;
|
||||
}
|
||||
|
||||
function measuredAt(r: Reading): Date {
|
||||
const d = new Date(NOW.getTime() - r.daysAgo * 24 * 60 * 60 * 1000);
|
||||
d.setUTCHours(r.hour, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** UTC midnight of a reading's day — the DAY bucket key. */
|
||||
function bucketStart(r: Reading): Date {
|
||||
const d = measuredAt(r);
|
||||
return new Date(
|
||||
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()),
|
||||
);
|
||||
}
|
||||
|
||||
/** Collapse readings into the per-day MEAN buckets the rollup tier stores. */
|
||||
function toDayBuckets(readings: Reading[]) {
|
||||
const byDay = new Map<number, { sum: number; count: number }>();
|
||||
for (const r of readings) {
|
||||
const key = bucketStart(r).getTime();
|
||||
const slot = byDay.get(key) ?? { sum: 0, count: 0 };
|
||||
slot.sum += r.kg;
|
||||
slot.count += 1;
|
||||
byDay.set(key, slot);
|
||||
}
|
||||
return [...byDay.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([ms, slot]) => ({
|
||||
bucketStart: new Date(ms),
|
||||
count: slot.count,
|
||||
mean: slot.sum / slot.count,
|
||||
minValue: slot.sum / slot.count,
|
||||
maxValue: slot.sum / slot.count,
|
||||
sd: null,
|
||||
slope: null,
|
||||
r2: null,
|
||||
computedAt: NOW,
|
||||
}));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
MEASUREMENT_FIND_MANY.mockReset();
|
||||
ROLLUP_FIND_MANY.mockReset();
|
||||
MOOD_FIND_MANY.mockReset();
|
||||
MEDICATION_FIND_MANY.mockReset();
|
||||
PROBE.mockReset();
|
||||
|
||||
MOOD_FIND_MANY.mockResolvedValue([]);
|
||||
MEDICATION_FIND_MANY.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/** Score the readings through the ROLLUP branch (WEIGHT covered). */
|
||||
async function scoreViaRollup(readings: Reading[]) {
|
||||
const coverage = new Map<string, boolean>([["WEIGHT", true]]);
|
||||
PROBE.mockResolvedValue(coverage);
|
||||
const buckets = toDayBuckets(readings);
|
||||
// The DAY read and the long-window (WEEK/MONTH/YEAR) read fire in parallel,
|
||||
// so key the mock on the requested granularity rather than call order.
|
||||
ROLLUP_FIND_MANY.mockImplementation(
|
||||
async ({ where }: { where: { granularity: string } }) =>
|
||||
where.granularity === "DAY" ? buckets : [],
|
||||
);
|
||||
// Source attribution still comes off the raw rows (2-column projection),
|
||||
// then the BP-SYS attribution read.
|
||||
MEASUREMENT_FIND_MANY.mockResolvedValueOnce(
|
||||
readings.map((r) => ({ measuredAt: measuredAt(r), source: "MANUAL" })),
|
||||
).mockResolvedValueOnce([]);
|
||||
|
||||
return computeUserHealthScoreFastPath({
|
||||
userId: "user-rollup",
|
||||
bpInTargetPct: null,
|
||||
weightTarget: null,
|
||||
weightTargetSource: "none",
|
||||
now: NOW,
|
||||
coverage,
|
||||
moodEnabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Score the readings through the LIVE branch (WEIGHT uncovered). */
|
||||
async function scoreViaLive(readings: Reading[]) {
|
||||
const coverage = new Map<string, boolean>([["WEIGHT", false]]);
|
||||
PROBE.mockResolvedValue(coverage);
|
||||
MEASUREMENT_FIND_MANY.mockResolvedValueOnce(
|
||||
readings.map((r) => ({
|
||||
measuredAt: measuredAt(r),
|
||||
value: r.kg,
|
||||
source: "MANUAL",
|
||||
})),
|
||||
).mockResolvedValueOnce([]);
|
||||
|
||||
return computeUserHealthScoreFastPath({
|
||||
userId: "user-live",
|
||||
bpInTargetPct: null,
|
||||
weightTarget: null,
|
||||
weightTargetSource: "none",
|
||||
now: NOW,
|
||||
coverage,
|
||||
moodEnabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
describe("Health Score — rollup / live parity on one weigh-in per day", () => {
|
||||
// The canonical cadence: one weigh-in a day, the pattern the fast path's own
|
||||
// comment says the equivalence rests on.
|
||||
const dailyDecline: Reading[] = [
|
||||
{ daysAgo: 24, hour: 7, kg: 84.0 },
|
||||
{ daysAgo: 20, hour: 7, kg: 83.6 },
|
||||
{ daysAgo: 16, hour: 7, kg: 83.1 },
|
||||
{ daysAgo: 12, hour: 7, kg: 82.7 },
|
||||
{ daysAgo: 8, hour: 7, kg: 82.2 },
|
||||
{ daysAgo: 4, hour: 7, kg: 81.8 },
|
||||
{ daysAgo: 1, hour: 7, kg: 81.5 },
|
||||
];
|
||||
|
||||
it("produces the same score, band and weight pillar on both branches", async () => {
|
||||
const rollup = await scoreViaRollup(dailyDecline);
|
||||
const live = await scoreViaLive(dailyDecline);
|
||||
|
||||
expect(rollup).not.toBeNull();
|
||||
expect(live).not.toBeNull();
|
||||
expect(rollup?.score).toBe(live?.score);
|
||||
expect(rollup?.band).toBe(live?.band);
|
||||
expect(rollup?.components.weight.value).toBe(live?.components.weight.value);
|
||||
// The weight pillar is the whole score here (no BP, mood or medications),
|
||||
// so an equal score is only meaningful if the pillar actually resolved.
|
||||
expect(rollup?.components.weight.value).not.toBeNull();
|
||||
});
|
||||
|
||||
it("agrees on the week-over-week delta too", async () => {
|
||||
// The delta re-runs the scorer over a 7-day-shifted window, so it exercises
|
||||
// the branch's series construction a second time on a different slice.
|
||||
const rollup = await scoreViaRollup(dailyDecline);
|
||||
const live = await scoreViaLive(dailyDecline);
|
||||
expect(rollup?.delta).toBe(live?.delta);
|
||||
});
|
||||
|
||||
it("agrees on a rising series as well as a falling one", async () => {
|
||||
const rise = dailyDecline.map((r) => ({ ...r, kg: 160 - r.kg }));
|
||||
const rollup = await scoreViaRollup(rise);
|
||||
const live = await scoreViaLive(rise);
|
||||
expect(rollup?.score).toBe(live?.score);
|
||||
expect(rollup?.components.weight.value).toBe(live?.components.weight.value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Health Score — rollup / live on an uneven weigh-in cadence", () => {
|
||||
// Three weigh-ins on the older day, one on the recent day. Per-reading
|
||||
// regression weights that older day three times over; per-day-mean regression
|
||||
// gives it one point. This is the shape where the two branches genuinely part
|
||||
// company, so it is the shape worth pinning.
|
||||
const uneven: Reading[] = [
|
||||
{ daysAgo: 20, hour: 6, kg: 85.0 },
|
||||
{ daysAgo: 20, hour: 12, kg: 84.0 },
|
||||
{ daysAgo: 20, hour: 20, kg: 83.0 },
|
||||
{ daysAgo: 2, hour: 7, kg: 83.6 },
|
||||
];
|
||||
|
||||
/** The same readings collapsed to one per day at their daily mean. */
|
||||
const collapsed: Reading[] = toDayBuckets(uneven).map((b) => ({
|
||||
daysAgo: Math.round(
|
||||
(NOW.getTime() - b.bucketStart.getTime()) / (24 * 60 * 60 * 1000),
|
||||
),
|
||||
hour: 0,
|
||||
kg: b.mean,
|
||||
}));
|
||||
|
||||
it("the two branches do differ here — the fixture is not a no-op", async () => {
|
||||
// Guard the guard: if raw and collapsed ever scored the same for this
|
||||
// series, the equality below would prove nothing.
|
||||
const liveOnRaw = await scoreViaLive(uneven);
|
||||
const liveOnMeans = await scoreViaLive(collapsed);
|
||||
expect(liveOnRaw?.components.weight.value).not.toBe(
|
||||
liveOnMeans?.components.weight.value,
|
||||
);
|
||||
});
|
||||
|
||||
it("the rollup branch equals the live branch fed the same daily means", async () => {
|
||||
// The whole difference between the branches is the mean-collapse and
|
||||
// nothing else: replay the collapsed series through the LIVE branch and the
|
||||
// two agree again. That is the claim the fast path's own comment makes,
|
||||
// stated as a test rather than left as prose.
|
||||
const rollup = await scoreViaRollup(uneven);
|
||||
const liveOnMeans = await scoreViaLive(collapsed);
|
||||
|
||||
expect(rollup?.score).toBe(liveOnMeans?.score);
|
||||
expect(rollup?.components.weight.value).toBe(
|
||||
liveOnMeans?.components.weight.value,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
coefficientOfVariation,
|
||||
complianceRate,
|
||||
computeHealthScore,
|
||||
defaultWeightTargetFromHeight,
|
||||
linearRegressionSlope,
|
||||
moodStability,
|
||||
weightTrendAlignment,
|
||||
@@ -170,16 +169,6 @@ describe("complianceRate", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultWeightTargetFromHeight", () => {
|
||||
it("returns null when height is null", () => {
|
||||
expect(defaultWeightTargetFromHeight(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("approximates BMI-22 midpoint for 178 cm", () => {
|
||||
expect(defaultWeightTargetFromHeight(178)).toBeCloseTo(69.7, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Composite scenarios ──────────────────────────────────────────────
|
||||
|
||||
describe("computeHealthScore — strong positive case", () => {
|
||||
@@ -187,7 +176,8 @@ describe("computeHealthScore — strong positive case", () => {
|
||||
const input: HealthScoreInput = {
|
||||
bpInTargetRate: 90,
|
||||
weightSeriesLast30d: weightSeries([80, 79.5, 79, 78.5, 78]),
|
||||
weightTargetKg: 78,
|
||||
weightTarget: { min: 76, max: 80 },
|
||||
weightTargetSource: "user",
|
||||
moodEntriesLast30d: moodEntries([4, 4, 5, 4, 5, 4, 5, 4, 4]),
|
||||
medicationCompliance30: [95, 100, 90],
|
||||
};
|
||||
@@ -205,7 +195,8 @@ describe("computeHealthScore — mixed case", () => {
|
||||
bpInTargetRate: 60,
|
||||
// Above the band, drifting up — alignment <50.
|
||||
weightSeriesLast30d: weightSeries([85, 86, 87, 88]),
|
||||
weightTargetKg: 80,
|
||||
weightTarget: { min: 78, max: 82 },
|
||||
weightTargetSource: "user",
|
||||
moodEntriesLast30d: moodEntries([2, 3, 5, 1, 4, 3]),
|
||||
medicationCompliance30: [100, 100],
|
||||
};
|
||||
@@ -221,7 +212,8 @@ describe("computeHealthScore — poor case", () => {
|
||||
const input: HealthScoreInput = {
|
||||
bpInTargetRate: 20,
|
||||
weightSeriesLast30d: weightSeries([95, 96, 97, 98, 99]),
|
||||
weightTargetKg: 80,
|
||||
weightTarget: { min: 78, max: 82 },
|
||||
weightTargetSource: "user",
|
||||
moodEntriesLast30d: moodEntries([1, 5, 1, 5, 1, 5]),
|
||||
medicationCompliance30: [40, 30, 20],
|
||||
};
|
||||
@@ -236,7 +228,8 @@ describe("computeHealthScore — null component redistribution", () => {
|
||||
const input: HealthScoreInput = {
|
||||
bpInTargetRate: null,
|
||||
weightSeriesLast30d: weightSeries([80, 80, 80, 80]),
|
||||
weightTargetKg: 80,
|
||||
weightTarget: { min: 78, max: 82 },
|
||||
weightTargetSource: "user",
|
||||
moodEntriesLast30d: moodEntries([5, 5, 5, 5, 5]),
|
||||
medicationCompliance30: [100, 100],
|
||||
};
|
||||
@@ -254,7 +247,8 @@ describe("computeHealthScore — null component redistribution", () => {
|
||||
const input: HealthScoreInput = {
|
||||
bpInTargetRate: null,
|
||||
weightSeriesLast30d: weightSeries([80, 80, 80]),
|
||||
weightTargetKg: 80,
|
||||
weightTarget: { min: 78, max: 82 },
|
||||
weightTargetSource: "user",
|
||||
// <5 mood entries → moodStability returns null.
|
||||
moodEntriesLast30d: moodEntries([4, 4]),
|
||||
medicationCompliance30: [80],
|
||||
@@ -273,7 +267,8 @@ describe("computeHealthScore — null component redistribution", () => {
|
||||
const input: HealthScoreInput = {
|
||||
bpInTargetRate: null,
|
||||
weightSeriesLast30d: [],
|
||||
weightTargetKg: null,
|
||||
weightTarget: null,
|
||||
weightTargetSource: "none",
|
||||
moodEntriesLast30d: [],
|
||||
medicationCompliance30: [73],
|
||||
};
|
||||
@@ -287,7 +282,8 @@ describe("computeHealthScore — null component redistribution", () => {
|
||||
const input: HealthScoreInput = {
|
||||
bpInTargetRate: null,
|
||||
weightSeriesLast30d: [],
|
||||
weightTargetKg: null,
|
||||
weightTarget: null,
|
||||
weightTargetSource: "none",
|
||||
moodEntriesLast30d: [],
|
||||
medicationCompliance30: [],
|
||||
};
|
||||
@@ -305,7 +301,8 @@ describe("computeHealthScore — determinism", () => {
|
||||
const input: HealthScoreInput = {
|
||||
bpInTargetRate: 75,
|
||||
weightSeriesLast30d: weightSeries([82, 81, 80, 79]),
|
||||
weightTargetKg: 78,
|
||||
weightTarget: { min: 76, max: 80 },
|
||||
weightTargetSource: "user",
|
||||
moodEntriesLast30d: moodEntries([4, 5, 3, 4, 5, 4]),
|
||||
medicationCompliance30: [85, 90, 80],
|
||||
};
|
||||
@@ -335,7 +332,8 @@ describe("computeHealthScore — delta vs previous week", () => {
|
||||
const baseInput: HealthScoreInput = {
|
||||
bpInTargetRate: 80,
|
||||
weightSeriesLast30d: weightSeries([82, 81, 80]),
|
||||
weightTargetKg: 80,
|
||||
weightTarget: { min: 78, max: 82 },
|
||||
weightTargetSource: "user",
|
||||
moodEntriesLast30d: moodEntries([5, 4, 5, 4, 5]),
|
||||
medicationCompliance30: [100, 100],
|
||||
};
|
||||
@@ -377,7 +375,8 @@ describe("computeHealthScore — source attribution", () => {
|
||||
return {
|
||||
bpInTargetRate: 90,
|
||||
weightSeriesLast30d: weightSeries([80, 80, 80]),
|
||||
weightTargetKg: 80,
|
||||
weightTarget: { min: 78, max: 82 },
|
||||
weightTargetSource: "user",
|
||||
moodEntriesLast30d: moodEntries([5, 4, 5, 4, 5]),
|
||||
medicationCompliance30: [95, 100],
|
||||
attribution,
|
||||
@@ -433,7 +432,8 @@ describe("computeHealthScore — source attribution", () => {
|
||||
const input: HealthScoreInput = {
|
||||
bpInTargetRate: null,
|
||||
weightSeriesLast30d: [],
|
||||
weightTargetKg: null,
|
||||
weightTarget: null,
|
||||
weightTargetSource: "none",
|
||||
// <5 mood entries → moodStability returns null.
|
||||
moodEntriesLast30d: moodEntries([4, 4]),
|
||||
medicationCompliance30: [],
|
||||
@@ -461,7 +461,8 @@ describe("computeHealthScore — source attribution", () => {
|
||||
const result = computeHealthScore({
|
||||
bpInTargetRate: 80,
|
||||
weightSeriesLast30d: weightSeries([80, 80, 80]),
|
||||
weightTargetKg: 80,
|
||||
weightTarget: { min: 78, max: 82 },
|
||||
weightTargetSource: "user",
|
||||
moodEntriesLast30d: moodEntries([5, 5, 5, 5, 5]),
|
||||
medicationCompliance30: [100],
|
||||
});
|
||||
@@ -480,7 +481,8 @@ describe("computeHealthScore — source attribution", () => {
|
||||
const result = computeHealthScore({
|
||||
bpInTargetRate: 90,
|
||||
weightSeriesLast30d: weightSeries([80, 80, 80]),
|
||||
weightTargetKg: 80,
|
||||
weightTarget: { min: 78, max: 82 },
|
||||
weightTargetSource: "user",
|
||||
moodEntriesLast30d: moodEntries([5, 5, 5, 5, 5]),
|
||||
medicationCompliance30: [100],
|
||||
attribution: {
|
||||
@@ -494,3 +496,75 @@ describe("computeHealthScore — source attribution", () => {
|
||||
expect(result.components.bp.asOf).toBe(WINDOW_END);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.34 — the weight yardstick.
|
||||
*
|
||||
* Until this release the pillar graded every account with a height on file
|
||||
* against `22 x height^2` +/- 2 kg: a band nobody set and no surface named.
|
||||
* These pin the replacement contract — the band arrives whole from the caller,
|
||||
* a missing band scores the bare trend, and the result says which of the two
|
||||
* happened.
|
||||
*/
|
||||
describe("computeHealthScore — weight yardstick", () => {
|
||||
const stable = weightSeries([80, 80, 80, 80]);
|
||||
|
||||
function scoreWeightOnly(
|
||||
series: Array<{ date: string; kg: number }>,
|
||||
target: { min: number; max: number } | null,
|
||||
) {
|
||||
return computeHealthScore({
|
||||
bpInTargetRate: null,
|
||||
weightSeriesLast30d: series,
|
||||
weightTarget: target,
|
||||
weightTargetSource: target ? "user" : "none",
|
||||
moodEntriesLast30d: [],
|
||||
medicationCompliance30: [],
|
||||
});
|
||||
}
|
||||
|
||||
it("takes the band whole — no expansion of the caller's bounds", () => {
|
||||
// 80 kg sits OUTSIDE 76-79. A +/- 2 kg widening would pull it in and
|
||||
// score 100; the band must be used exactly as handed over.
|
||||
expect(
|
||||
scoreWeightOnly(stable, { min: 76, max: 79 }).components.weight.value,
|
||||
).toBe(50);
|
||||
// The same reading INSIDE the band scores the in-band 100.
|
||||
expect(
|
||||
scoreWeightOnly(stable, { min: 78, max: 82 }).components.weight.value,
|
||||
).toBe(100);
|
||||
});
|
||||
|
||||
it("scores the bare trend when no target is set", () => {
|
||||
// Stable weight, no target: the trend-only anchor, not the target-aware
|
||||
// "neither" midpoint of 50, and never a fabricated band.
|
||||
const result = scoreWeightOnly(stable, null);
|
||||
expect(result.components.weight.value).toBe(75);
|
||||
expect(result.components.weight.weight).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("carries the resolved yardstick on the weight component", () => {
|
||||
const withTarget = scoreWeightOnly(stable, { min: 78, max: 82 });
|
||||
expect(withTarget.components.weight.targetSource).toBe("user");
|
||||
expect(withTarget.components.weight.target).toEqual({ min: 78, max: 82 });
|
||||
|
||||
const without = scoreWeightOnly(stable, null);
|
||||
expect(without.components.weight.targetSource).toBe("none");
|
||||
expect(without.components.weight.target).toBeNull();
|
||||
});
|
||||
|
||||
it("never reports a band when the source is `none`", () => {
|
||||
// Defensive: a caller that passes a band while declaring no source must
|
||||
// not have that band surface as though the user had set it.
|
||||
const result = computeHealthScore({
|
||||
bpInTargetRate: null,
|
||||
weightSeriesLast30d: stable,
|
||||
weightTarget: { min: 78, max: 82 },
|
||||
weightTargetSource: "none",
|
||||
moodEntriesLast30d: [],
|
||||
medicationCompliance30: [],
|
||||
});
|
||||
expect(result.components.weight.target).toBeNull();
|
||||
expect(result.components.weight.targetSource).toBe("none");
|
||||
});
|
||||
});
|
||||
@@ -146,6 +146,7 @@ function baseUser(
|
||||
insightsCachedText: null,
|
||||
insightsCachedAt: null,
|
||||
dashboardWidgetsJson: null,
|
||||
thresholdsJson: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -471,7 +472,9 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => {
|
||||
userId: string;
|
||||
bpInTargetPct: number | null;
|
||||
bpGradedScore: number | null;
|
||||
heightCm: number | null;
|
||||
weightTarget: { min: number; max: number } | null;
|
||||
weightTargetSource: string;
|
||||
moodEnabled: boolean;
|
||||
coverage: unknown;
|
||||
};
|
||||
expect(arg.userId).toBe("user-1");
|
||||
@@ -480,7 +483,10 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => {
|
||||
// number. Previously this was last30Days (80).
|
||||
expect(arg.bpInTargetPct).toBe(77);
|
||||
expect(arg.bpGradedScore).toBe(88);
|
||||
expect(arg.heightCm).toBe(180);
|
||||
// v1.34 — no weight target on this fixture, so the pillar scores the bare
|
||||
// trend. The BMI-22 band that used to be derived from `heightCm` is gone.
|
||||
expect(arg.weightTarget).toBeNull();
|
||||
expect(arg.weightTargetSource).toBe("none");
|
||||
expect(arg.coverage).toBe(coverageMap);
|
||||
});
|
||||
|
||||
@@ -998,6 +1004,40 @@ describe("buildDashboardSnapshot — module gating (v1.18.0)", () => {
|
||||
expect(snap.tiles.mood.summary).toBeNull();
|
||||
// Core tile unaffected.
|
||||
expect(widget(snap, "weight")!.visible).toBe(true);
|
||||
// v1.34 — the hero ring's health score drops the mood pillar too. Before
|
||||
// this the snapshot never resolved the module, so a mood-off account was
|
||||
// scored on a pillar the Insights card had already dropped.
|
||||
const scoreCall = computeUserHealthScoreFastPath.mock.calls.at(-1)?.[0] as
|
||||
{ moodEnabled: boolean } | undefined;
|
||||
expect(scoreCall?.moodEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("mood enabled: the health score keeps its mood pillar", async () => {
|
||||
await buildDashboardSnapshot(fakePrisma, baseUser(), {
|
||||
modules: () => Promise.resolve(moduleMap()),
|
||||
});
|
||||
const scoreCall = computeUserHealthScoreFastPath.mock.calls.at(-1)?.[0] as
|
||||
{ moodEnabled: boolean } | undefined;
|
||||
expect(scoreCall?.moodEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("an explicit weight target reaches the score and the bands", async () => {
|
||||
const snap = await buildDashboardSnapshot(
|
||||
fakePrisma,
|
||||
baseUser({ thresholdsJson: { WEIGHT: { min: 74, max: 78 } } }),
|
||||
{ modules: () => Promise.resolve(moduleMap()) },
|
||||
);
|
||||
const scoreCall = computeUserHealthScoreFastPath.mock.calls.at(-1)?.[0] as
|
||||
| {
|
||||
weightTarget: { min: number; max: number } | null;
|
||||
weightTargetSource: string;
|
||||
}
|
||||
| undefined;
|
||||
expect(scoreCall?.weightTarget).toEqual({ min: 74, max: 78 });
|
||||
expect(scoreCall?.weightTargetSource).toBe("user");
|
||||
// The tile / chart band follows the same target.
|
||||
expect(snap.targetBands.weightRange?.greenMin).toBe(74);
|
||||
expect(snap.targetBands.weightRange?.greenMax).toBe(78);
|
||||
});
|
||||
|
||||
it("sleep disabled: tile hidden + SLEEP_DURATION stripped from summaries", async () => {
|
||||
|
||||
@@ -105,24 +105,28 @@ describe("buildTargetBands — server/client parity", () => {
|
||||
dateOfBirth: Date | null;
|
||||
gender: "MALE" | "FEMALE" | null;
|
||||
heightCm: number | null;
|
||||
weightTargetOverride: { min: number; max: number } | null;
|
||||
}> = [
|
||||
{
|
||||
name: "male 180cm with DOB",
|
||||
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
|
||||
gender: "MALE",
|
||||
heightCm: 180,
|
||||
weightTargetOverride: null,
|
||||
},
|
||||
{
|
||||
name: "female 165cm with DOB",
|
||||
dateOfBirth: new Date("1970-01-01T00:00:00.000Z"),
|
||||
gender: "FEMALE",
|
||||
heightCm: 165,
|
||||
weightTargetOverride: null,
|
||||
},
|
||||
{
|
||||
name: "no-profile user (null DOB / gender / height)",
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
weightTargetOverride: null,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -142,17 +146,20 @@ describe("buildTargetBands — server/client parity", () => {
|
||||
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
|
||||
gender: "OTHER",
|
||||
heightCm: 180,
|
||||
weightTargetOverride: null,
|
||||
});
|
||||
const unanswered = buildTargetBands({
|
||||
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
|
||||
gender: null,
|
||||
heightCm: 180,
|
||||
weightTargetOverride: null,
|
||||
});
|
||||
expect(other).toEqual(unanswered);
|
||||
const male = buildTargetBands({
|
||||
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
|
||||
gender: "MALE",
|
||||
heightCm: 180,
|
||||
weightTargetOverride: null,
|
||||
});
|
||||
expect(other.bodyFatBands).not.toEqual(male.bodyFatBands);
|
||||
});
|
||||
@@ -162,6 +169,7 @@ describe("buildTargetBands — server/client parity", () => {
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
weightTargetOverride: null,
|
||||
});
|
||||
expect(bands.bpTargets).toBeNull();
|
||||
expect(bands.bpSysRange).toBeNull();
|
||||
@@ -173,3 +181,63 @@ describe("buildTargetBands — server/client parity", () => {
|
||||
expect(bands.bodyFatBands.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.34 — an explicit weight target outranks the height-derived WHO band.
|
||||
* The dashboard used to shade the BMI band for someone who had already
|
||||
* answered the question on `/targets`, so the answer stayed invisible on
|
||||
* every surface that mattered.
|
||||
*/
|
||||
describe("buildTargetBands — user weight target", () => {
|
||||
const profile = {
|
||||
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
|
||||
gender: "MALE" as const,
|
||||
heightCm: 180,
|
||||
};
|
||||
|
||||
it("replaces the height-derived band with the user's own", () => {
|
||||
const withTarget = buildTargetBands({
|
||||
...profile,
|
||||
weightTargetOverride: { min: 74, max: 78 },
|
||||
});
|
||||
expect(withTarget.weightRange?.greenMin).toBe(74);
|
||||
expect(withTarget.weightRange?.greenMax).toBe(78);
|
||||
|
||||
const heightDerived = buildTargetBands({
|
||||
...profile,
|
||||
weightTargetOverride: null,
|
||||
});
|
||||
expect(heightDerived.weightRange).not.toEqual(withTarget.weightRange);
|
||||
// The chart bands follow the same range, so tile and chart agree.
|
||||
expect(withTarget.weightBands).not.toEqual(heightDerived.weightBands);
|
||||
const green = withTarget.weightBands?.find(
|
||||
(b) => b.color === "var(--success)",
|
||||
);
|
||||
expect(green).toEqual(expect.objectContaining({ min: 74, max: 78 }));
|
||||
});
|
||||
|
||||
it("honours a target even for a profile with no height on file", () => {
|
||||
const bands = buildTargetBands({
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
weightTargetOverride: { min: 60, max: 65 },
|
||||
});
|
||||
expect(bands.weightRange?.greenMin).toBe(60);
|
||||
expect(bands.weightBands?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("leaves every non-weight band untouched", () => {
|
||||
const withTarget = buildTargetBands({
|
||||
...profile,
|
||||
weightTargetOverride: { min: 74, max: 78 },
|
||||
});
|
||||
const without = buildTargetBands({
|
||||
...profile,
|
||||
weightTargetOverride: null,
|
||||
});
|
||||
expect(withTarget.bpTargets).toEqual(without.bpTargets);
|
||||
expect(withTarget.pulseBands).toEqual(without.pulseBands);
|
||||
expect(withTarget.bodyFatBands).toEqual(without.bodyFatBands);
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,7 @@ function baseSnapshot(
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
weightTargetOverride: null,
|
||||
}),
|
||||
tiles: {
|
||||
summaries: {},
|
||||
@@ -395,6 +396,7 @@ describe("rung 4 — weightDrift", () => {
|
||||
avg7: number | null,
|
||||
avg30: number | null,
|
||||
heightCm: number | null = 180,
|
||||
weightTargetOverride: { min: number; max: number } | null = null,
|
||||
): DashboardSnapshot {
|
||||
const snap = baseSnapshot({
|
||||
tiles: {
|
||||
@@ -408,6 +410,14 @@ describe("rung 4 — weightDrift", () => {
|
||||
},
|
||||
});
|
||||
snap.user.heightCm = heightCm;
|
||||
// v1.34 — the rung reads the snapshot's RESOLVED band, so the fixture
|
||||
// resolves it the same way the builder does.
|
||||
snap.targetBands = buildTargetBands({
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm,
|
||||
weightTargetOverride,
|
||||
});
|
||||
return snap;
|
||||
}
|
||||
|
||||
@@ -429,7 +439,7 @@ describe("rung 4 — weightDrift", () => {
|
||||
expect(verdict.variant).toBe("allQuiet");
|
||||
});
|
||||
|
||||
it("skips entirely when no height is stored (no derivable range)", () => {
|
||||
it("skips entirely when no height and no target (no band at all)", () => {
|
||||
const verdict = resolveDashboardVerdict(
|
||||
weightSnapshot(GREEN_MAX_180 + 5, GREEN_MAX_180 - 1, null),
|
||||
NOW,
|
||||
@@ -729,3 +739,55 @@ describe("rung 9 — allQuiet + full-ladder precedence", () => {
|
||||
expect(verdict.values).toEqual({ points: 15 });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.34 — the weight-drift rung measures against the band the user actually
|
||||
* reads. It used to recompute the height-derived BMI band inline, so it kept
|
||||
* nudging about drift away from a band an explicit target had replaced.
|
||||
*/
|
||||
describe("rung 4 — weightDrift honours an explicit target", () => {
|
||||
function driftSnapshot(
|
||||
avg7: number,
|
||||
avg30: number,
|
||||
weightTargetOverride: { min: number; max: number } | null,
|
||||
): DashboardSnapshot {
|
||||
const snap = baseSnapshot({
|
||||
tiles: {
|
||||
summaries: {
|
||||
WEIGHT: summary({ latest: avg7, avg7, avg30, count: 20 }),
|
||||
},
|
||||
lastSeenByType: {
|
||||
WEIGHT: { lastSeenAt: isoHoursAgo(2), daysAgo: 0 },
|
||||
},
|
||||
mood: { summary: null, entries: [] },
|
||||
},
|
||||
});
|
||||
snap.user.heightCm = 180;
|
||||
snap.targetBands = buildTargetBands({
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: 180,
|
||||
weightTargetOverride,
|
||||
});
|
||||
return snap;
|
||||
}
|
||||
|
||||
it("stays quiet inside the user's own band even when the BMI band would fire", () => {
|
||||
// 82 / 81 kg is above the height-derived green max (80.68) and drifting
|
||||
// away from it, but sits inside a self-set 78-85 kg band.
|
||||
expect(
|
||||
resolveDashboardVerdict(driftSnapshot(82, 81, null), NOW).variant,
|
||||
).toBe("weightDrift");
|
||||
expect(
|
||||
resolveDashboardVerdict(driftSnapshot(82, 81, { min: 78, max: 85 }), NOW)
|
||||
.variant,
|
||||
).toBe("allQuiet");
|
||||
});
|
||||
|
||||
it("fires on drift away from the user's own band", () => {
|
||||
expect(
|
||||
resolveDashboardVerdict(driftSnapshot(72, 74, { min: 78, max: 85 }), NOW)
|
||||
.variant,
|
||||
).toBe("weightDrift");
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,17 @@ import {
|
||||
__test__,
|
||||
detectPersonalRecordsForUser,
|
||||
} from "../pr-detection-worker";
|
||||
import { getPRDirection, isPRTrackable } from "../pr-direction";
|
||||
import {
|
||||
getPRDirection,
|
||||
isPRTrackable,
|
||||
type PRDirectionContext,
|
||||
} from "../pr-direction";
|
||||
|
||||
/**
|
||||
* v1.34 — the fixtures below carry no weight target, so the resolver answers
|
||||
* for an account that has set no goal (the historical behaviour).
|
||||
*/
|
||||
const NO_GOAL: PRDirectionContext = { weightGoal: null };
|
||||
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import {
|
||||
@@ -147,6 +157,9 @@ function makeFakePrisma(state: {
|
||||
// the existing day-sum assertions stay valid.
|
||||
timezone: "UTC",
|
||||
sourcePriorityJson: null,
|
||||
// v1.34 — no weight target on file, so weight / BMI keep their
|
||||
// historical null PR direction throughout this file's fixtures.
|
||||
thresholdsJson: null,
|
||||
})),
|
||||
},
|
||||
measurement: {
|
||||
@@ -688,7 +701,7 @@ describe("detectPersonalRecordsForUser — flags + drift guard", () => {
|
||||
const result = await detectPersonalRecordsForUser(USER, { prisma });
|
||||
|
||||
const trackable = measurementTypeEnum.options.filter((t) =>
|
||||
isPRTrackable(t as MeasurementType),
|
||||
isPRTrackable(t as MeasurementType, NO_GOAL),
|
||||
);
|
||||
expect(result.scanned).toBeGreaterThanOrEqual(trackable.length);
|
||||
});
|
||||
@@ -783,7 +796,7 @@ describe("detectPersonalRecordsForUser — REG-9 cumulative day-sum (v1.4.46)",
|
||||
// direction lookup short-circuits null-direction types upstream;
|
||||
// we filter to the PR-trackable subset for the loop.
|
||||
const trackableCumulative = Array.from(CUMULATIVE_HK_TYPES).filter((t) =>
|
||||
isPRTrackable(t),
|
||||
isPRTrackable(t, NO_GOAL),
|
||||
);
|
||||
expect(trackableCumulative.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -843,7 +856,9 @@ describe("detectPersonalRecordsForUser — REG-9 cumulative day-sum (v1.4.46)",
|
||||
// falls is the record) the winning day is the smallest day-sum, so
|
||||
// the seed days at 100 hold the record over the 500 day.
|
||||
const expectedValue =
|
||||
getPRDirection(type) === PersonalRecordDirection.MIN ? 100 : 500;
|
||||
getPRDirection(type, NO_GOAL) === PersonalRecordDirection.MIN
|
||||
? 100
|
||||
: 500;
|
||||
expect(pr?.value, `${type} day-sum`).toBe(expectedValue);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getPRDirection, isPRTrackable } from "../pr-direction";
|
||||
import {
|
||||
getPRDirection,
|
||||
isPRTrackable,
|
||||
resolveWeightGoalDirection,
|
||||
type PRDirectionContext,
|
||||
} from "../pr-direction";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import {
|
||||
PersonalRecordDirection,
|
||||
type MeasurementType,
|
||||
} from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* v1.34 — the resolver takes the user-goal context. `NO_GOAL` is the account
|
||||
* with no weight target on file, which is the historical behaviour every
|
||||
* assertion below was written against.
|
||||
*/
|
||||
const NO_GOAL: PRDirectionContext = { weightGoal: null };
|
||||
|
||||
describe("getPRDirection", () => {
|
||||
// Drift guard — every canonical MeasurementType MUST land in exactly
|
||||
// one branch of the switch so the future detection worker never
|
||||
// encounters an "I have a sample but no direction" race.
|
||||
it("returns either MAX, MIN, or null for every MeasurementType", () => {
|
||||
for (const type of measurementTypeEnum.options) {
|
||||
const result = getPRDirection(type as MeasurementType);
|
||||
const result = getPRDirection(type as MeasurementType, NO_GOAL);
|
||||
expect(
|
||||
result === null ||
|
||||
result === PersonalRecordDirection.MAX ||
|
||||
@@ -36,7 +48,7 @@ describe("getPRDirection", () => {
|
||||
"TIME_IN_DAYLIGHT",
|
||||
];
|
||||
for (const t of maxMetrics) {
|
||||
expect(getPRDirection(t)).toBe(PersonalRecordDirection.MAX);
|
||||
expect(getPRDirection(t, NO_GOAL)).toBe(PersonalRecordDirection.MAX);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -52,11 +64,11 @@ describe("getPRDirection", () => {
|
||||
"AUDIO_EXPOSURE_HEADPHONE",
|
||||
];
|
||||
for (const t of minMetrics) {
|
||||
expect(getPRDirection(t)).toBe(PersonalRecordDirection.MIN);
|
||||
expect(getPRDirection(t, NO_GOAL)).toBe(PersonalRecordDirection.MIN);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null for BP, glucose, weight, sleep, and homeostatic vitals", () => {
|
||||
it("returns null for BP, glucose, sleep, and homeostatic vitals", () => {
|
||||
const noPRMetrics: MeasurementType[] = [
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
@@ -70,7 +82,7 @@ describe("getPRDirection", () => {
|
||||
"FAT_FREE_MASS",
|
||||
];
|
||||
for (const t of noPRMetrics) {
|
||||
expect(getPRDirection(t)).toBeNull();
|
||||
expect(getPRDirection(t, NO_GOAL)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -79,7 +91,74 @@ describe("isPRTrackable", () => {
|
||||
it("returns true exactly when getPRDirection is non-null", () => {
|
||||
for (const type of measurementTypeEnum.options) {
|
||||
const t = type as MeasurementType;
|
||||
expect(isPRTrackable(t)).toBe(getPRDirection(t) !== null);
|
||||
expect(isPRTrackable(t, NO_GOAL)).toBe(
|
||||
getPRDirection(t, NO_GOAL) !== null,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.34 — weight + BMI stopped deferring on `User.thresholdsJson`. With no
|
||||
* target they answer `null` exactly as they have since v1.5.5; with one they
|
||||
* answer the direction the user's own goal points in.
|
||||
*/
|
||||
describe("getPRDirection — user weight goal", () => {
|
||||
const goalMetrics: MeasurementType[] = ["WEIGHT", "BODY_MASS_INDEX"];
|
||||
|
||||
it("stays null when no weight target is set", () => {
|
||||
for (const t of goalMetrics) {
|
||||
expect(getPRDirection(t, { weightGoal: null })).toBeNull();
|
||||
expect(isPRTrackable(t, { weightGoal: null })).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("reads MIN when the user is working down toward their target", () => {
|
||||
for (const t of goalMetrics) {
|
||||
expect(getPRDirection(t, { weightGoal: "lower" })).toBe(
|
||||
PersonalRecordDirection.MIN,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("reads MAX when the user is working up toward their target", () => {
|
||||
for (const t of goalMetrics) {
|
||||
expect(getPRDirection(t, { weightGoal: "higher" })).toBe(
|
||||
PersonalRecordDirection.MAX,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves every other metric untouched by the goal context", () => {
|
||||
for (const type of measurementTypeEnum.options) {
|
||||
const t = type as MeasurementType;
|
||||
if (goalMetrics.includes(t)) continue;
|
||||
expect(getPRDirection(t, { weightGoal: "lower" })).toBe(
|
||||
getPRDirection(t, { weightGoal: null }),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWeightGoalDirection", () => {
|
||||
const band = { min: 70, max: 75 };
|
||||
|
||||
it("returns null without a target — a goal is never inferred", () => {
|
||||
expect(resolveWeightGoalDirection(null, 90)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null without a reading to place against the band", () => {
|
||||
expect(resolveWeightGoalDirection(band, null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null inside the band — the goal is already met", () => {
|
||||
expect(resolveWeightGoalDirection(band, 70)).toBeNull();
|
||||
expect(resolveWeightGoalDirection(band, 72.5)).toBeNull();
|
||||
expect(resolveWeightGoalDirection(band, 75)).toBeNull();
|
||||
});
|
||||
|
||||
it("points down above the band and up below it", () => {
|
||||
expect(resolveWeightGoalDirection(band, 82)).toBe("lower");
|
||||
expect(resolveWeightGoalDirection(band, 64)).toBe("higher");
|
||||
});
|
||||
});
|
||||
@@ -180,8 +180,10 @@ describe("PR detection — drift guard (real DB)", () => {
|
||||
// either the worker or the suppress branch via `getPRDirection`).
|
||||
const result = await detectPersonalRecordsForUser(TEST_USER_ID);
|
||||
|
||||
// v1.34 — the seeded user carries no weight target, so weight / BMI keep
|
||||
// their null direction and stay out of the trackable set.
|
||||
const trackable = measurementTypeEnum.options.filter((t) =>
|
||||
isPRTrackable(t as MeasurementType),
|
||||
isPRTrackable(t as MeasurementType, { weightGoal: null }),
|
||||
);
|
||||
expect(result.scanned).toBeGreaterThanOrEqual(trackable.length);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user