From 5d13f0e69c2d2aefb0b93ac5edef057be3bf9287 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 16:48:40 +0200 Subject: [PATCH] Prove the false drop cannot come back The named failure now has a test that drives the whole chain a request takes: fixture rows through the real reader, the real score record write, the real dashboard snapshot, the real verdict rung, and the real digest that gives the morning push its sentence. Only the IO around that chain is faked. A version built on a hand-written snapshot literal would have stayed green through a release where the assembly between the two ends dropped the field, which has happened here before. Every case runs twice on the same data. Once with the recipe changed three days ago, once with it changed a month ago. The second run is what proves the fixture really does produce an eleven-point fall, so the first run's silence is the guard working rather than an account with nothing to say. The decline is gradual on purpose: a cliff would raise each pillar's own noise floor and the delta would be suppressed for a reason that has nothing to do with the recipe. Beside it, the boundary's edges, the notice key's two dimensions and the seam's shape, each with a case proving the guard stays quiet on innocent data. Against real Postgres, a row now has to come back carrying the recipe version and change date the account's stored configuration supplied. --- ...onfig-change-is-not-a-health-event.test.ts | 556 ++++++++++++++++++ .../__tests__/config-delta-guard.test.ts | 387 ++++++++++++ tests/integration/health-score-record.test.ts | 42 +- 3 files changed, 982 insertions(+), 3 deletions(-) create mode 100644 src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts create mode 100644 src/lib/analytics/score/__tests__/config-delta-guard.test.ts diff --git a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts new file mode 100644 index 000000000..f13137cff --- /dev/null +++ b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts @@ -0,0 +1,556 @@ +/** + * The named failure, driven end to end: someone opens the score + * settings, changes which pillars count, and the app tells them their + * health dropped. + * + * The bug is subtle enough that it survives any test which stops at one + * end. All three comparison windows are computed in ONE request under + * whatever recipe is in force at that moment, so both sides carry the + * same composition, `sameComposition` agrees, and the subtraction looks + * legitimate. What actually moved is the construct: the week-over-week + * delta after a reconfiguration averages a different set of pillars + * than the one before it, so a week that read as a couple of points can + * read as fourteen the moment the person leaves the settings page. + * + * So this file refuses to hand a payload from one end to the other. It + * drives the real chain, in the order a request does: + * + * fixture rows + * → `computeUserHealthScore` (the real reader, real pillars) + * → `computeAndRecordUserHealthScore` (the real write path) + * → `buildDashboardSnapshot` (the real snapshot mapping) + * → `resolveDashboardVerdict` (the real `scoreDrop` rung) + * → `loadDailyDigest` → `buildDailyDigest` (the real morning line) + * + * Only the IO around that chain is faked. A version of this test that + * built a `DashboardSnapshot` literal by hand would have been green + * through a release where the assembly between the two ends dropped the + * field, which is a thing that has happened here. + * + * Every case runs twice with the same data: once with the recipe + * changed inside the comparison window, once with it changed a month + * before. The second run is not decoration. It is what proves the + * fixture really does produce a ten-point-plus drop and that the first + * run's silence is the guard doing work rather than an account with + * nothing to say. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { User } from "@/generated/prisma/client"; + +const DAY_MS = 86_400_000; +const NOW = new Date("2026-08-20T12:00:00.000Z"); + +/** + * A steady, month-long decline in steps and sleep. + * + * Gradual on purpose. A cliff would blow up each pillar's own standard + * deviation, the noise floor would rise with it, and `below_noise_floor` + * would suppress the delta for a reason that has nothing to do with the + * recipe — a test that passes because the number never moved proves + * nothing about a guard that stops numbers from moving. + */ +const FIXTURE = vi.hoisted(() => { + const DAY = 86_400_000; + const AT = new Date("2026-08-20T12:00:00.000Z").getTime(); + const rows: Array<{ + type: string; + value: number; + unit: string; + source: string; + measuredAt: Date; + deviceType: string | null; + glucoseContext: string | null; + sleepStage: string | null; + }> = []; + for (let back = 44; back >= 0; back -= 1) { + const measuredAt = new Date(AT - back * DAY); + rows.push({ + type: "ACTIVITY_STEPS", + value: 4_200 + back * 260, + unit: "steps", + source: "MANUAL", + measuredAt, + deviceType: null, + glucoseContext: null, + sleepStage: null, + }); + rows.push({ + type: "SLEEP_DURATION", + value: 300 + back * 5, + unit: "min", + source: "MANUAL", + measuredAt: new Date(new Date(measuredAt).setUTCHours(6, 0, 0, 0)), + deviceType: null, + glucoseContext: null, + sleepStage: "ASLEEP", + }); + } + rows.push({ + type: "WAIST_TO_HEIGHT", + value: 0.45, + unit: "ratio", + source: "MANUAL", + measuredAt: new Date(AT - 20 * DAY), + deviceType: null, + glucoseContext: null, + sleepStage: null, + }); + return rows; +}); + +/** + * One fake client behind BOTH `@/lib/db` and the snapshot builder's + * injected client, so the reader's measurement reads, the score + * record's insert and the digest's own light reads all see the same + * account. + */ +const writtenRecords = vi.hoisted(() => [] as Array>); + +/** The dismiss ledger, as a set of item keys the account has acknowledged. */ +const dismissedKeys = vi.hoisted(() => new Set()); + +const fakeDb = vi.hoisted(() => { + const rows = FIXTURE; + const written = writtenRecords; + const none = async () => []; + return { + measurement: { + // Both callers' `where` shapes: the score reader asks for a set of + // types, the snapshot's glucose read asks for one. + findMany: async (query: { + where: { + type: string | { in: string[] }; + measuredAt?: { gte: Date }; + glucoseContext?: string; + }; + }) => { + const wanted = + typeof query.where.type === "string" + ? [query.where.type] + : query.where.type.in; + const since = query.where.measuredAt?.gte ?? new Date(0); + return rows.filter( + (row) => + wanted.includes(row.type) && + row.measuredAt >= since && + (query.where.glucoseContext === undefined || + row.glucoseContext === query.where.glucoseContext), + ); + }, + }, + mentalHealthAssessment: { findMany: none }, + labResult: { findMany: none }, + dismissedPriorityItem: { + // Honours the composite key. A stub that ignored `where` would + // make "dismissed once per recipe version" untestable: every key + // would read as dismissed, or none would. + findUnique: async (query: { + where: { userId_itemKey: { userId: string; itemKey: string } }; + }) => + dismissedKeys.has(query.where.userId_itemKey.itemKey) + ? { itemKey: query.where.userId_itemKey.itemKey } + : null, + findMany: none, + }, + healthScoreRecord: { + createMany: async (args: { data: Array> }) => { + written.push(...args.data); + return { count: args.data.length }; + }, + }, + illnessEpisode: { findMany: none }, + moodEntry: { findMany: none }, + nutrientIntakeDay: { findMany: none }, + integrationStatus: { findMany: none }, + measurementReminder: { findMany: none }, + coachPlan: { findMany: none }, + ecgRecording: { findFirst: async () => null }, + personalRecord: { findMany: none }, + arrivalReaction: { findMany: none }, + }; +}); + +vi.mock("@/lib/db", () => ({ prisma: fakeDb })); + +// ── Everything around the score, faked at its own seam ────────────── +const computeSummariesSlice = vi.fn(); +const probeRollupCoverage = vi.fn(); +const isFullyCovered = vi.fn(); +const readMoodDayRollups = vi.fn(); +const ensureUserMoodRollupsFresh = vi.fn(); +const computeBpInTargetFastPath = vi.fn(); +const getAssistantFlags = vi.fn(); +const hasAnyConfiguredProvider = vi.fn(); +const buildMedsTodayBlock = vi.fn(); +const resolveModuleMap = vi.fn(); +const buildScoreRingsBlock = vi.fn(); +const readDashboardSnapshotCached = vi.fn(); +const loadIntradayPulse = vi.fn(); +const readDayMeanSeries = vi.fn(); + +vi.mock("@/lib/analytics/summaries-slice", () => ({ + computeSummariesSlice: (...a: unknown[]) => computeSummariesSlice(...a), +})); +vi.mock("@/lib/rollups/measurement-coverage", () => ({ + probeRollupCoverage: (...a: unknown[]) => probeRollupCoverage(...a), + isFullyCovered: (...a: unknown[]) => isFullyCovered(...a), +})); +vi.mock("@/lib/rollups/mood-rollups", () => ({ + readMoodDayRollups: (...a: unknown[]) => readMoodDayRollups(...a), + ensureUserMoodRollupsFresh: (...a: unknown[]) => + ensureUserMoodRollupsFresh(...a), +})); +vi.mock("@/lib/analytics/bp-in-target-fast-path", () => ({ + computeBpInTargetFastPath: (...a: unknown[]) => + computeBpInTargetFastPath(...a), +})); +vi.mock("@/lib/feature-flags", () => ({ + getAssistantFlags: (...a: unknown[]) => getAssistantFlags(...a), +})); +vi.mock("@/lib/ai/provider", () => ({ + hasAnyConfiguredProvider: (...a: unknown[]) => hasAnyConfiguredProvider(...a), +})); +vi.mock("@/lib/dashboard/meds-today", () => ({ + buildMedsTodayBlock: (...a: unknown[]) => buildMedsTodayBlock(...a), +})); +vi.mock("@/lib/modules/gate", () => ({ + resolveModuleMap: (...a: unknown[]) => resolveModuleMap(...a), +})); +vi.mock("@/lib/dashboard/score-rings", () => ({ + buildScoreRingsBlock: (...a: unknown[]) => buildScoreRingsBlock(...a), +})); +vi.mock("@/lib/dashboard/snapshot-read", () => ({ + readDashboardSnapshotCached: (...a: unknown[]) => + readDashboardSnapshotCached(...a), +})); +vi.mock("@/lib/analytics/intraday-pulse-io", () => ({ + loadIntradayPulse: (...a: unknown[]) => loadIntradayPulse(...a), +})); +vi.mock("@/lib/insights/derived/baseline", () => ({ + readDayMeanSeries: (...a: unknown[]) => readDayMeanSeries(...a), +})); + +import { buildDashboardSnapshot } from "@/lib/dashboard/snapshot"; +import type { SnapshotUserInput } from "@/lib/dashboard/snapshot"; +import { resolveDashboardVerdict } from "@/lib/dashboard/verdict"; +import { loadDailyDigest } from "@/lib/daily/load-digest"; +import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item"; +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import type { ModuleKey } from "@/lib/modules/gate"; +import { MODULE_KEYS } from "@/lib/modules/registry"; + +import { healthScoreConfigFromSelection } from "../config"; +import { computeUserHealthScore } from "../reader"; +import { SCORE_PILLAR_IDS } from "../types"; + +/** The recipe as the settings write would have stored it. */ +function recipe(changedAt: Date): unknown { + return healthScoreConfigFromSelection({ + // The person keeps the four pillars they have data for and drops the + // rest. The composition this resolves to is the same either way in + // this fixture, which is exactly the case a composition comparison + // cannot see. + selection: SCORE_PILLAR_IDS.filter((id) => id !== "FITNESS"), + version: 3, + changedAt, + }); +} + +function moduleMap(): Record { + return Object.fromEntries(MODULE_KEYS.map((key) => [key, true])) as Record< + ModuleKey, + boolean + >; +} + +function user(healthScoreConfigJson: unknown): SnapshotUserInput { + return { + id: "user-1", + username: "tester", + displayName: null, + timezone: "UTC", + heightCm: 180, + dateOfBirth: new Date("1990-01-01T00:00:00.000Z"), + gender: "MALE", + glucoseUnit: "mg/dL", + onboardingTourCompleted: true, + disableCoach: false, + insightsCachedText: null, + insightsCachedAt: null, + dashboardWidgetsJson: null, + thresholdsJson: null, + healthScoreConfigJson, + } as SnapshotUserInput; +} + +/** The reader's own input, as the snapshot builder assembles it. */ +function scoreInput(healthScoreConfigJson: unknown) { + return { + userId: "user-1", + now: NOW, + profile: { + dateOfBirth: new Date("1990-01-01T00:00:00.000Z"), + gender: "MALE", + heightCm: 180, + timezone: "UTC", + sourcePriorityJson: null, + thresholdsJson: null, + }, + modules: { + glucose: true, + labs: true, + sleep: true, + mentalHealth: true, + }, + healthScoreConfigJson, + bpTargets: null, + bpEnvelope: null, + bpEnvelopePriorWeek: null, + bpEnvelopePriorTwoWeeks: null, + }; +} + +/** + * The whole chain for one account, from fixture rows to the sentence + * that would ride a morning push. + */ +async function runChain(healthScoreConfigJson: unknown) { + __resetAllCachesForTests(); + writtenRecords.length = 0; + const snapshot = await buildDashboardSnapshot( + fakeDb as never, + user(healthScoreConfigJson), + ); + const verdict = resolveDashboardVerdict(snapshot, NOW); + readDashboardSnapshotCached.mockResolvedValue({ + body: { + ...snapshot, + layout: { + ...snapshot.layout, + enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS], + }, + }, + locale: "en", + }); + const digest = await loadDailyDigest( + { + id: "user-1", + timezone: "UTC", + morningDigestRefreshedOn: null, + } as unknown as User, + NOW, + ); + return { snapshot, verdict, digest, record: writtenRecords[0] ?? null }; +} + +beforeEach(() => { + // `buildDashboardSnapshot` reads its own clock — it takes no `now`. + // Without a pinned system clock the fixture's dates would sit in the + // future relative to the real one, every window would miss, and the + // suite would go green on an account that has no comparable window + // rather than on a suppressed one. Only `Date` is faked; the SWR cells + // inside the digest loader still need working timers. + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW); + vi.clearAllMocks(); + __resetAllCachesForTests(); + dismissedKeys.clear(); + computeSummariesSlice.mockResolvedValue({ + summaries: {}, + lastSeenByType: {}, + bmi: null, + }); + probeRollupCoverage.mockResolvedValue( + new Map([ + ["WEIGHT", true], + ["BLOOD_PRESSURE_SYS", true], + ["BLOOD_PRESSURE_DIA", true], + ]), + ); + isFullyCovered.mockReturnValue(true); + readMoodDayRollups.mockResolvedValue([]); + // No blood pressure in the fixture: the envelope resolves with empty + // windows rather than absent, which is what the builder expects and + // what keeps BLOOD_PRESSURE honestly unscored. + computeBpInTargetFastPath.mockResolvedValue({ + path: "live", + last7Days: null, + last30Days: null, + last90Days: null, + last90EarliestAt: null, + last90LatestAt: null, + allTime: null, + priorMonth: null, + priorYear: null, + graded: null, + representative: null, + }); + getAssistantFlags.mockResolvedValue({ + enabled: false, + coach: false, + briefing: false, + insightStatus: false, + correlations: false, + }); + hasAnyConfiguredProvider.mockResolvedValue(false); + buildMedsTodayBlock.mockResolvedValue({ + activeCount: 0, + scheduledToday: 0, + takenToday: 0, + skippedToday: 0, + nextDueAt: null, + nextDueOverdue: false, + nextDueMedicationName: null, + }); + resolveModuleMap.mockResolvedValue(moduleMap()); + buildScoreRingsBlock.mockResolvedValue([]); + loadIntradayPulse.mockResolvedValue({ tension: null }); + readDayMeanSeries.mockResolvedValue({ points: [], source: "none" }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +/** + * The control. Same rows, same recipe, changed a month ago — outside + * every comparison window — so nothing suppresses and the account's + * genuine decline is narrated in full. + */ +const SETTLED = recipe(new Date(NOW.getTime() - 30 * DAY_MS)); +/** The act: the recipe changed three days ago. */ +const JUST_CHANGED = recipe(new Date(NOW.getTime() - 3 * DAY_MS)); + +describe("a settings change is never narrated as a health event", () => { + it("shows a real ten-point-plus drop when no recipe changed in the window", async () => { + const { snapshot, verdict, digest } = await runChain(SETTLED); + + // If this ever stops holding, every assertion in the next test is + // vacuous: an account with no drop to suppress would pass it while + // the guard did nothing at all. + expect(snapshot.healthScore).not.toBeNull(); + expect(snapshot.healthScore!.deltaReason).toBeNull(); + expect(snapshot.healthScore!.delta).not.toBeNull(); + expect(snapshot.healthScore!.delta!).toBeLessThanOrEqual(-10); + expect(verdict.variant).toBe("scoreDrop"); + expect(digest.score?.delta).toBe(snapshot.healthScore!.delta); + expect(digest.score?.deltaReason).toBeNull(); + }); + + it("fires no scoreDrop verdict when the recipe changed inside the window", async () => { + // The verdict first and on its own line: this is the sentence the + // person would read, and it is what has to go red when the guard + // goes missing. Asserting the reason before it would fail one step + // earlier and never exercise the rung at all. + const { verdict } = await runChain(JUST_CHANGED); + + expect(verdict.variant).not.toBe("scoreDrop"); + }); + + it("suppresses the delta with the reason that names the act", async () => { + const { snapshot } = await runChain(JUST_CHANGED); + + expect(snapshot.healthScore).not.toBeNull(); + expect(snapshot.healthScore!.deltaReason).toBe("config_changed"); + expect(snapshot.healthScore!.delta).toBeNull(); + }); + + it("carries no unexplained drop on the morning line", async () => { + // The digest is the push's only source of words. A delta that + // reached it without its reason is a drop nothing can explain: the + // hero renders the chip on `deltaReason === null` alone. + const { snapshot, digest } = await runChain(JUST_CHANGED); + + expect(digest.score).not.toBeNull(); + expect(digest.score!.delta).toBeNull(); + expect(digest.score!.deltaReason).toBe("config_changed"); + // The push body is `digest.line` verbatim. It states today's number + // and nothing about its movement, which is the only honest sentence + // available across a recipe change. + expect(digest.line).toBe( + `Your health score today is ${snapshot.healthScore!.score}.`, + ); + expect(digest.line).not.toMatch(/[-−]\s?\d/); + // The rail is the other place a drop could reach a person. Nothing + // in it may be narrating the score either. + expect(digest.worthALook.map((item) => item.kind)).not.toContain( + "score_drop", + ); + }); + + it("still shows the number itself, only never its movement", async () => { + // Suppressing the delta must not suppress the score. A person who + // changed what counts should see what the new recipe says today; + // what they must not see is a subtraction across the change. + const { snapshot, digest } = await runChain(JUST_CHANGED); + + expect(snapshot.healthScore!.score).toBeGreaterThan(0); + expect(digest.score!.value).toBe(snapshot.healthScore!.score); + }); + + it("writes the recipe that produced the day onto the stored record", async () => { + // The supply half of the same change. The row is written by the real + // path inside the same request the assertions above read from, so a + // version that never reached the writer would show up here rather + // than a release later when the seam turns out to be underivable. + const { record } = await runChain(JUST_CHANGED); + + expect(record).not.toBeNull(); + expect(record!.configVersion).toBe(3); + expect(record!.configChangedAt).toEqual( + new Date(NOW.getTime() - 3 * DAY_MS), + ); + }); + + it("raises the recipe notice once, under a key the person can dismiss once", async () => { + // The reader is driven directly here because the notice never + // reaches the dashboard snapshot — it rides the analytics report. + // Same account, same fixture, same request path the chain above + // used, so the key asserted is the key the ledger will be asked for. + const configured = await computeUserHealthScore(scoreInput(JUST_CHANGED)); + const unconfigured = await computeUserHealthScore(scoreInput(null)); + + expect(configured.algorithmNotice!.itemKey).toBe( + "health_score_algorithm:2:config:3", + ); + expect(configured.algorithmNotice!.dismissed).toBe(false); + // An account that never chose keeps the key its acknowledgement is + // already filed under. + expect(unconfigured.algorithmNotice!.itemKey).toBe( + "health_score_algorithm:2", + ); + + dismissedKeys.add("health_score_algorithm:2:config:3"); + expect( + (await computeUserHealthScore(scoreInput(JUST_CHANGED))).algorithmNotice! + .dismissed, + ).toBe(true); + + // The next recipe change raises it again, on its own key, and the + // earlier dismissal does not carry over. + const next = await computeUserHealthScore( + scoreInput( + healthScoreConfigFromSelection({ + selection: SCORE_PILLAR_IDS.filter((id) => id !== "FITNESS"), + version: 4, + changedAt: new Date(NOW.getTime() - DAY_MS), + }), + ), + ); + expect(next.algorithmNotice!.itemKey).toBe( + "health_score_algorithm:2:config:4", + ); + expect(next.algorithmNotice!.dismissed).toBe(false); + }); + + it("stamps an honest zero when the account never chose", async () => { + // Not an absence. The first authored recipe has to be recognisable + // as a move from something, and 0 is what it moves from. + const { record } = await runChain(null); + + expect(record).not.toBeNull(); + expect(record!.configVersion).toBe(0); + expect(record!.configChangedAt).toBeNull(); + }); +}); diff --git a/src/lib/analytics/score/__tests__/config-delta-guard.test.ts b/src/lib/analytics/score/__tests__/config-delta-guard.test.ts new file mode 100644 index 000000000..b0794076b --- /dev/null +++ b/src/lib/analytics/score/__tests__/config-delta-guard.test.ts @@ -0,0 +1,387 @@ +/** + * The per-user recipe boundary, at the three places it exists. + * + * The end-to-end proof that a settings change is never narrated as a + * health event lives in `config-change-is-not-a-health-event.test.ts`, + * over the real snapshot and digest. This file covers what that one + * cannot reach without a fixture per case: the boundary arithmetic's + * edges, the notice key's two dimensions, and the seam's shape. + * + * Each guard here has a counter-case beside it. A boundary that + * suppressed everything would pass every "no false drop" assertion ever + * written and silence every honest week as well, which is the same + * defect wearing the opposite sign. + */ +import { describe, expect, it } from "vitest"; + +import { buildOk, deriveCoverage } from "@/lib/insights/derived/coverage"; +import type { Derived } from "@/lib/insights/derived/types"; +import { healthScoreNoticeItemKey } from "@/lib/daily/priority-item-key"; +import { isDismissibleItemKey } from "@/lib/daily/priority-item"; +import { dismissPriorityItemSchema } from "@/lib/validations/daily"; + +import { attachScoreDelta, crossesConfigBoundary } from "../composite"; +import { + resolveHealthScoreConfig, + healthScoreConfigFromSelection, + scoreConfigBoundary, + UNCONFIGURED_SCORE_BOUNDARY, +} from "../config"; +import { + scoreCompositeSegments, + scorePillarSeries, + scoreSeriesSeams, + type ScoreSeriesRecord, +} from "../series"; +import type { + CompositeValue, + PillarValue, + ScorePillarId, + ScorePillarResult, +} from "../types"; + +const DAY_MS = 86_400_000; +const NOW = new Date("2026-08-20T12:00:00.000Z"); + +const DOMAIN_BY_PILLAR: Record = { + BLOOD_PRESSURE: "cardiometabolic", + GLYCAEMIA: "cardiometabolic", + ACTIVITY: "activity", + SLEEP: "sleep", + ADIPOSITY: "adiposity", + WELLBEING: "wellbeing", + FITNESS: "fitness", + LIPIDS: "cardiometabolic", +}; + +function pillar(id: ScorePillarId, score: number): ScorePillarResult { + const { coverage, confidence } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 1, + historyDays: 28, + missing: [], + fullHistoryDays: 28, + }); + const value: PillarValue = { + score, + observed: { + value: score, + unit: "score", + label: `${score}`, + asOf: NOW.toISOString(), + sources: ["MANUAL"], + }, + reference: { + kind: "guideline-band", + low: 0, + high: 100, + label: "band", + source: "Test 2026", + }, + noiseFloor: 1, + deltaEligible: true, + deltaIdentity: id, + }; + return { + id, + domain: DOMAIN_BY_PILLAR[id], + result: buildOk({ + value, + coverage, + confidence, + provenance: { + inputs: [id], + source: "live", + windowDays: 28, + computedAt: NOW.toISOString(), + }, + }), + }; +} + +/** A composite standing at `score`, computed as of `asOf`. */ +function composite(score: number, asOf: Date): Derived { + const { coverage, confidence } = deriveCoverage({ + requiredInputs: 3, + presentInputs: 3, + historyDays: 28, + missing: [], + fullHistoryDays: 28, + }); + return buildOk({ + value: { + score, + band: "green", + bandSetter: null, + composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], + noiseFloor: 1, + scoreVersion: 2, + }, + coverage, + confidence, + provenance: { + inputs: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], + source: "live", + windowDays: 28, + computedAt: asOf.toISOString(), + }, + }); +} + +const CURRENT_PILLARS = [ + pillar("BLOOD_PRESSURE", 60), + pillar("ACTIVITY", 60), + pillar("SLEEP", 60), +]; +const EARLIER_PILLARS = [ + pillar("BLOOD_PRESSURE", 80), + pillar("ACTIVITY", 80), + pillar("SLEEP", 80), +]; + +/** A twenty-point fall, well clear of the noise floor. */ +function fallingDelta(changedAt: Date | null) { + return attachScoreDelta( + composite(60, NOW), + composite(80, new Date(NOW.getTime() - 7 * DAY_MS)), + composite(80, new Date(NOW.getTime() - 14 * DAY_MS)), + NOW, + { + version: changedAt === null ? 0 : 2, + changedAt: changedAt?.toISOString() ?? null, + }, + CURRENT_PILLARS, + EARLIER_PILLARS, + ); +} + +describe("the recipe boundary", () => { + it("suppresses a fall the person's own settings change straddles", () => { + const report = fallingDelta(new Date(NOW.getTime() - 3 * DAY_MS)); + expect(report.delta).toBeNull(); + expect(report.deltaReason).toBe("config_changed"); + }); + + it("stays out of the way when the recipe has not moved in the window", () => { + // The counter-case. A guard that suppressed here would hide every + // genuine decline an account with a recipe ever has. + const report = fallingDelta(new Date(NOW.getTime() - 30 * DAY_MS)); + expect(report.deltaReason).toBeNull(); + expect(report.delta).toBe(-20); + }); + + it("stays out of the way for an account that never chose", () => { + const report = fallingDelta(null); + expect(report.deltaReason).toBeNull(); + expect(report.delta).toBe(-20); + }); + + it("suppresses at the boundary instant and not one moment past it", () => { + const previousAt = new Date(NOW.getTime() - 7 * DAY_MS); + const boundary = (changedAt: Date) => + crossesConfigBoundary( + { version: 1, changedAt: changedAt.toISOString() }, + previousAt, + NOW, + ); + + // A change on the previous window's own instant is not inside the + // window: that window was scored under it. + expect(boundary(previousAt)).toBe(false); + expect(boundary(new Date(previousAt.getTime() + 1))).toBe(true); + expect(boundary(NOW)).toBe(true); + // A change stamped after the current window is a clock that ran + // ahead, not a comparison to break. + expect(boundary(new Date(NOW.getTime() + 1))).toBe(false); + }); + + it("refuses to read a boundary it cannot parse, rather than suppressing forever", () => { + expect( + crossesConfigBoundary( + { version: 4, changedAt: "not a date" }, + new Date(NOW.getTime() - 7 * DAY_MS), + NOW, + ), + ).toBe(false); + expect( + crossesConfigBoundary( + { version: 4, changedAt: null }, + new Date(NOW.getTime() - 7 * DAY_MS), + NOW, + ), + ).toBe(false); + }); + + it("carries the resolver's own answer, not a second reading of the blob", () => { + const changedAt = new Date(NOW.getTime() - 2 * DAY_MS); + const stored = healthScoreConfigFromSelection({ + selection: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], + version: 7, + changedAt, + }); + + expect(scoreConfigBoundary(resolveHealthScoreConfig(stored))).toEqual({ + version: 7, + changedAt: changedAt.toISOString(), + }); + expect(scoreConfigBoundary(resolveHealthScoreConfig(null))).toEqual( + UNCONFIGURED_SCORE_BOUNDARY, + ); + }); +}); + +describe("the notice key", () => { + it("moves when the method moves and when the recipe moves", () => { + const base = healthScoreNoticeItemKey(2, 3); + expect(healthScoreNoticeItemKey(3, 3)).not.toBe(base); + expect(healthScoreNoticeItemKey(2, 4)).not.toBe(base); + // …and only then. The same pair is the same acknowledgement, so the + // notice is raised once and dismissed once per recipe version. + expect(healthScoreNoticeItemKey(2, 3)).toBe(base); + }); + + it("leaves a never-configured account's existing key exactly as it was", () => { + // Lengthening this key for everybody would orphan every dismissal + // already in the ledger and re-raise the method notice for the whole + // install base on upgrade. + expect(healthScoreNoticeItemKey(2, 0)).toBe("health_score_algorithm:2"); + }); + + it("stays a key the dismiss route already accepts", () => { + // The whole reason for folding the recipe version into this key + // rather than minting a second notice system: dismissal, the ledger + // prefix and the score-cache eviction all key on the prefix and need + // no change to carry the new notice. + const key = healthScoreNoticeItemKey(2, 5); + expect(key.startsWith("health_score_algorithm:")).toBe(true); + expect(isDismissibleItemKey(key)).toBe(true); + expect(dismissPriorityItemSchema.safeParse({ itemKey: key }).success).toBe( + true, + ); + }); +}); + +describe("the seam", () => { + function day( + dayKey: string, + configVersion: number | null, + ): ScoreSeriesRecord { + return { + dayKey, + composite: 70, + band: "yellow", + composition: ["ACTIVITY", "SLEEP", "ADIPOSITY"], + pillarScores: { ACTIVITY: 70, SLEEP: 70, ADIPOSITY: 70 }, + configVersion, + }; + } + + it("marks the first day after a change and nothing else", () => { + const points = scoreSeriesSeams([ + day("2026-08-17", 1), + day("2026-08-18", 1), + day("2026-08-19", 2), + day("2026-08-20", 2), + ]); + + expect(points.map((point) => point.seamBreak)).toEqual([ + false, + false, + true, + false, + ]); + }); + + it("never marks the first day of the series", () => { + // A beginning is not a break. Flagging it would put a "you changed + // what counts here" marker on the day someone started. + expect(scoreSeriesSeams([day("2026-08-20", 3)])[0].seamBreak).toBe(false); + expect(scoreSeriesSeams([])).toEqual([]); + }); + + it("stays quiet across days that share a recipe", () => { + const points = scoreSeriesSeams([ + day("2026-08-18", 0), + day("2026-08-19", 0), + day("2026-08-20", 0), + ]); + expect(points.some((point) => point.seamBreak)).toBe(false); + expect(scoreCompositeSegments(points)).toHaveLength(1); + }); + + it("orders by day before comparing, so an unordered read cannot invent a seam", () => { + const points = scoreSeriesSeams([ + day("2026-08-20", 2), + day("2026-08-18", 1), + day("2026-08-19", 2), + ]); + expect(points.map((point) => point.dayKey)).toEqual([ + "2026-08-18", + "2026-08-19", + "2026-08-20", + ]); + expect(points.map((point) => point.seamBreak)).toEqual([ + false, + true, + false, + ]); + }); + + it("treats a day that names no recipe as a break rather than a link", () => { + const points = scoreSeriesSeams([ + day("2026-08-19", null), + day("2026-08-20", 1), + ]); + expect(points[1].seamBreak).toBe(true); + }); + + it("breaks the composite line and never rejoins it", () => { + const points = scoreSeriesSeams([ + day("2026-08-17", 1), + day("2026-08-18", 1), + day("2026-08-19", 2), + day("2026-08-20", 2), + ]); + const segments = scoreCompositeSegments(points); + + expect(segments).toHaveLength(2); + expect(segments[0].map((point) => point.dayKey)).toEqual([ + "2026-08-17", + "2026-08-18", + ]); + expect(segments[1].map((point) => point.dayKey)).toEqual([ + "2026-08-19", + "2026-08-20", + ]); + // Every day is in exactly one segment: no day is repeated to make + // the ends meet, and none is dropped to hide the gap. + expect(segments.flat().map((point) => point.dayKey)).toEqual( + points.map((point) => point.dayKey), + ); + }); + + it("keeps a pillar's own row running straight through the seam", () => { + const points = scoreSeriesSeams([ + { ...day("2026-08-18", 1), pillarScores: { SLEEP: 61 } }, + { ...day("2026-08-19", 2), pillarScores: { SLEEP: 62 } }, + { ...day("2026-08-20", 2), pillarScores: { SLEEP: 63 } }, + ]); + + expect(points[1].seamBreak).toBe(true); + expect(scorePillarSeries(points, "SLEEP")).toEqual([ + { dayKey: "2026-08-18", score: 61 }, + { dayKey: "2026-08-19", score: 62 }, + { dayKey: "2026-08-20", score: 63 }, + ]); + }); + + it("leaves out a day the pillar did not count on, rather than scoring it zero", () => { + const points = scoreSeriesSeams([ + { ...day("2026-08-19", 1), pillarScores: { SLEEP: 61 } }, + { ...day("2026-08-20", 1), pillarScores: { ACTIVITY: 70 } }, + ]); + expect(scorePillarSeries(points, "SLEEP")).toEqual([ + { dayKey: "2026-08-19", score: 61 }, + ]); + }); +}); diff --git a/tests/integration/health-score-record.test.ts b/tests/integration/health-score-record.test.ts index b17ecbe6f..3d5f8d5a5 100644 --- a/tests/integration/health-score-record.test.ts +++ b/tests/integration/health-score-record.test.ts @@ -207,12 +207,48 @@ describe("the score a surface shows is the score that gets written down", () => expect(row.inputFingerprint).toMatch(/^[0-9a-f]{64}$/); expect(row.timezone).toBe("UTC"); expect(row.dayKey).toBe(new Date(now).toISOString().slice(0, 10)); - // Nothing supplies these yet, and an invented default would be worse than - // an honest null the day the delta guard starts reading them. - expect(row.configVersion).toBeNull(); + // The recipe the day was scored under, resolved on the write path from + // the account's own stored configuration. This account never opened the + // settings surface, so the honest answer is version 0 with no change + // date — the thing the first authored recipe will later be a move FROM. + expect(row.configVersion).toBe(0); expect(row.configChangedAt).toBeNull(); }); + it("stamps the account's own recipe on the row, straight from the stored configuration", async () => { + // The supply half of the delta guard, over real Postgres. Nothing in + // the request carries the recipe: the write path resolves it from the + // same column the composition came from, so a version that never + // reached the writer shows up as a row that cannot say which recipe + // produced it — and the seam becomes underivable a release later. + const user = await seedSession("hsr-recipe"); + const now = Date.now(); + await seedBp(user.id, now, 20, 122); + await seedSleep(user.id, now, 14); + await seedWaist(user.id, now); + const changedAt = new Date(now - 3 * DAY); + await getPrismaClient().user.update({ + where: { id: user.id }, + data: { + healthScoreConfigJson: { + excludedPillars: ["FITNESS"], + version: 6, + changedAt: changedAt.toISOString(), + }, + }, + }); + + expect((await readAnalytics()).data!.healthScore!.composite.status).toBe( + "ok", + ); + + const row = await getPrismaClient().healthScoreRecord.findFirstOrThrow({ + where: { userId: user.id }, + }); + expect(row.configVersion).toBe(6); + expect(row.configChangedAt?.toISOString()).toBe(changedAt.toISOString()); + }); + it("leaves the recorded day untouched when the same day is computed again under a changed standing", async () => { const user = await seedSession("hsr-never-restate"); const now = Date.now();