diff --git a/src/lib/analytics/__tests__/source-priority.test.ts b/src/lib/analytics/__tests__/source-priority.test.ts index 400153096..f886505e2 100644 --- a/src/lib/analytics/__tests__/source-priority.test.ts +++ b/src/lib/analytics/__tests__/source-priority.test.ts @@ -515,3 +515,112 @@ describe("pickCanonicalSourceRows — v1.4.25 W8c two-axis device-type picker", expect(out.canonicalRows[0].source).toBe("WITHINGS"); }); }); + +/** + * v1.38.x — what the EXTERNAL source does to a contested day. + * + * `EXTERNAL` is on no priority ladder, deliberately: the `steps` and `spo2` + * defaults already sit at `metricSourceLadder`'s `.max(8)`, so a ninth entry + * would come back from `GET /api/auth/me/source-priority`, be PUT back whole + * by the settings page, and 422 — breaking ladder saves for everyone. + * + * That omission is not inert. It decides a real behaviour, and one that + * CHANGED when ingested rows stopped being labelled `MANUAL`: before, a typed + * reading and a bridged one were the same source and both survived a day; now + * `MANUAL` ranks, `EXTERNAL` does not, and the bridged row leaves the + * canonical set for that day. Seven call sites consume this picker (daily + * totals, the health score, the doctor report, correlations, mood crosstabs, + * PR detection), so the effect is user-visible and is written up in the + * changelog. + * + * Pinned here because it is emergent from a rank ladder rather than stated + * anywhere in code: put `EXTERNAL` on a ladder and the first case below goes + * red, which is the review anybody adding it deserves. + */ +describe("pickCanonicalSourceRows — the unranked EXTERNAL source", () => { + const day = "2026-09-02"; + + function weightRow( + source: "MANUAL" | "EXTERNAL", + value: number, + hour: string, + ) { + return { + measuredAt: new Date(`${day}T${hour}:00Z`), + source, + deviceType: null, + type: "WEIGHT" as const, + value, + }; + } + + it("keeps the typed reading and drops the bridged one on a contested day", () => { + // The behaviour change itself. `MANUAL` is on the weight ladder and + // `EXTERNAL` is not, so the day resolves to the reading the person + // entered — a hand correction still wins where one number is needed. + const out = pickCanonicalSourceRows( + [ + weightRow("EXTERNAL", 82.4, "06:30"), + weightRow("MANUAL", 81.9, "08:00"), + ], + "weight", + null, + isoDayKey, + ); + expect(out.canonicalRows).toHaveLength(1); + expect(out.canonicalRows[0].source).toBe("MANUAL"); + expect(out.canonicalRows[0].value).toBe(81.9); + expect(out.pickedByDay.get(day)).toBe("MANUAL"); + }); + + it("keeps a bridged-only day, since nothing on the ladder is present", () => { + // The `!picked` pass-through. Without this arm an account that ONLY ever + // pushes readings would go dark on every surface listed above, which + // would be a far worse regression than the one above is a change. + const out = pickCanonicalSourceRows( + [ + weightRow("EXTERNAL", 82.4, "06:30"), + weightRow("EXTERNAL", 82.1, "20:00"), + ], + "weight", + null, + isoDayKey, + ); + expect(out.canonicalRows).toHaveLength(2); + expect(out.canonicalRows.every((r) => r.source === "EXTERNAL")).toBe(true); + // No ladder source won, so the day names no pick. + expect(out.pickedByDay.has(day)).toBe(false); + }); + + it("kept both when the bridged row was still labelled MANUAL", () => { + // The pre-change shape, asserted so the diff between old and new + // behaviour lives in the suite rather than only in the changelog. Two + // `MANUAL` rows are indistinguishable to the picker and both survive — + // which for a cumulative metric is the double-count the new labelling + // removes. + const out = pickCanonicalSourceRows( + [weightRow("MANUAL", 82.4, "06:30"), weightRow("MANUAL", 81.9, "08:00")], + "weight", + null, + isoDayKey, + ); + expect(out.canonicalRows).toHaveLength(2); + }); + + it("lets a user who ranks EXTERNAL themselves have it win", () => { + // Not reachable from the settings UI today (the ladder cap keeps it off + // the page), but the picker resolves whatever ladder it is handed, and a + // stored ladder naming it must be honoured rather than ignored. + const out = pickCanonicalSourceRows( + [ + weightRow("EXTERNAL", 82.4, "06:30"), + weightRow("MANUAL", 81.9, "08:00"), + ], + "weight", + { metricPriority: { weight: ["EXTERNAL", "MANUAL"] } }, + isoDayKey, + ); + expect(out.canonicalRows).toHaveLength(1); + expect(out.canonicalRows[0].source).toBe("EXTERNAL"); + }); +});