From 586def916c7b0f0fc9ca35018f20e16d1c3330c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 18 May 2026 12:18:00 +0200 Subject: [PATCH] test(rollup-read-wmy): granularity-routing and parity coverage 18 unit pins covering each reader's granularity filter, the coverage-miss `null` return shape, the auto-router's window-to-tier selection (90 d / 365 d / 1095 d), the fall-through chain on partial coverage, and the linear-composition contract for count / min / max / mean / sumValue across coarser buckets. --- .../__tests__/rollup-read-wmy.test.ts | 292 ++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 src/lib/measurements/__tests__/rollup-read-wmy.test.ts diff --git a/src/lib/measurements/__tests__/rollup-read-wmy.test.ts b/src/lib/measurements/__tests__/rollup-read-wmy.test.ts new file mode 100644 index 000000000..7f5ec9be2 --- /dev/null +++ b/src/lib/measurements/__tests__/rollup-read-wmy.test.ts @@ -0,0 +1,292 @@ +/** + * v1.4.39 W-WMY — unit tests for the WEEK / MONTH / YEAR rollup + * readers and the auto-router that picks the largest granularity + * that still resolves a requested window. + * + * `prisma.measurementRollup.findMany` is mocked at the module level + * so the test pins: + * - each per-granularity reader filters by the correct + * `granularity` literal, + * - empty rollups return `null` so the caller can branch on + * coverage miss, + * - the auto-router walks YEAR → MONTH → WEEK → DAY and stops at + * the first granularity whose floor the window clears AND which + * has coverage, + * - `aggregateWmyBuckets` composes `count / min / max / mean / sum` + * linearly across coarser buckets (the same compositional + * contract `rollup-read.ts:aggregateBuckets` carries for DAY). + * + * Integration coverage against a real Postgres lives outside this + * file — the writer's `STDDEV_POP / REGR_SLOPE` semantics are pinned + * in `tests/integration/measurement-rollups.test.ts` already. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findMany: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + measurementRollup: { + findMany: mocks.findMany, + }, + }, +})); + +import { + aggregateWmyBuckets, + readBestGranularityRollups, + readMonthRollups, + readWeekRollups, + readYearRollups, + type RollupBucketRow, +} from "../rollup-read-wmy"; + +const { findMany } = mocks; + +function bucket( + bucketStart: string, + partial: Partial = {}, +): RollupBucketRow { + return { + bucketStart: new Date(bucketStart), + count: 10, + mean: 82, + sd: 1, + slope: -0.01, + r2: 0.3, + sumValue: null, + minValue: 80, + maxValue: 84, + ...partial, + }; +} + +beforeEach(() => { + findMany.mockReset(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("readWeekRollups", () => { + it("returns the WEEK buckets for (userId, type, since)", async () => { + const rows = [ + bucket("2026-04-27T00:00:00.000Z"), + bucket("2026-05-04T00:00:00.000Z", { mean: 81 }), + ]; + findMany.mockResolvedValueOnce(rows); + const since = new Date("2026-04-20T00:00:00.000Z"); + + const result = await readWeekRollups("user-1", "WEIGHT", since); + + expect(result).not.toBeNull(); + expect(result).toHaveLength(2); + expect(result?.[0].mean).toBe(82); + expect(result?.[1].mean).toBe(81); + const args = findMany.mock.calls[0][0]; + expect(args.where.granularity).toBe("WEEK"); + expect(args.where.userId).toBe("user-1"); + expect(args.where.type).toBe("WEIGHT"); + expect(args.where.bucketStart.gte).toBe(since); + expect(args.orderBy).toEqual({ bucketStart: "asc" }); + }); + + it("returns null when the WEEK window has no coverage", async () => { + findMany.mockResolvedValueOnce([]); + const result = await readWeekRollups( + "user-1", + "WEIGHT", + new Date("2026-04-20T00:00:00.000Z"), + ); + expect(result).toBeNull(); + }); +}); + +describe("readMonthRollups", () => { + it("returns the MONTH buckets and pins the granularity filter", async () => { + findMany.mockResolvedValueOnce([bucket("2026-04-01T00:00:00.000Z")]); + const result = await readMonthRollups( + "user-2", + "PULSE", + new Date("2026-01-01T00:00:00.000Z"), + ); + expect(result).toHaveLength(1); + expect(findMany.mock.calls[0][0].where.granularity).toBe("MONTH"); + }); + + it("returns null on coverage miss", async () => { + findMany.mockResolvedValueOnce([]); + const result = await readMonthRollups( + "user-2", + "PULSE", + new Date("2026-01-01T00:00:00.000Z"), + ); + expect(result).toBeNull(); + }); +}); + +describe("readYearRollups", () => { + it("returns the YEAR buckets and pins the granularity filter", async () => { + findMany.mockResolvedValueOnce([bucket("2025-01-01T00:00:00.000Z")]); + const result = await readYearRollups( + "user-3", + "WEIGHT", + new Date("2023-01-01T00:00:00.000Z"), + ); + expect(result).toHaveLength(1); + expect(findMany.mock.calls[0][0].where.granularity).toBe("YEAR"); + }); + + it("returns null on coverage miss", async () => { + findMany.mockResolvedValueOnce([]); + const result = await readYearRollups( + "user-3", + "WEIGHT", + new Date("2023-01-01T00:00:00.000Z"), + ); + expect(result).toBeNull(); + }); +}); + +describe("readBestGranularityRollups", () => { + it("returns null on a non-positive window", async () => { + expect(await readBestGranularityRollups("user", "WEIGHT", 0)).toBeNull(); + expect(await readBestGranularityRollups("user", "WEIGHT", -10)).toBeNull(); + expect(await readBestGranularityRollups("user", "WEIGHT", NaN)).toBeNull(); + expect(findMany).not.toHaveBeenCalled(); + }); + + it("routes a 90-day window to DAY (every coarser floor is too low)", async () => { + findMany.mockResolvedValueOnce([bucket("2026-04-01T00:00:00.000Z")]); + const result = await readBestGranularityRollups("user", "WEIGHT", 90); + expect(result).not.toBeNull(); + expect(result?.granularity).toBe("DAY"); + expect(findMany).toHaveBeenCalledTimes(1); + expect(findMany.mock.calls[0][0].where.granularity).toBe("DAY"); + }); + + it("routes a 365-day window to MONTH (clears the 62-day MONTH floor)", async () => { + findMany.mockResolvedValueOnce([bucket("2026-01-01T00:00:00.000Z")]); + const result = await readBestGranularityRollups("user", "WEIGHT", 365); + expect(result).not.toBeNull(); + expect(result?.granularity).toBe("MONTH"); + expect(findMany).toHaveBeenCalledTimes(1); + expect(findMany.mock.calls[0][0].where.granularity).toBe("MONTH"); + }); + + it("routes a 1095-day window to YEAR (clears the 730-day YEAR floor)", async () => { + findMany.mockResolvedValueOnce([bucket("2024-01-01T00:00:00.000Z")]); + const result = await readBestGranularityRollups("user", "WEIGHT", 1095); + expect(result).not.toBeNull(); + expect(result?.granularity).toBe("YEAR"); + expect(findMany).toHaveBeenCalledTimes(1); + expect(findMany.mock.calls[0][0].where.granularity).toBe("YEAR"); + }); + + it("falls back to MONTH when YEAR has no coverage", async () => { + // 1095-day window asks for YEAR first; coverage miss → MONTH. + findMany + .mockResolvedValueOnce([]) // YEAR miss + .mockResolvedValueOnce([bucket("2024-01-01T00:00:00.000Z")]); // MONTH hit + + const result = await readBestGranularityRollups("user", "WEIGHT", 1095); + + expect(result?.granularity).toBe("MONTH"); + expect(findMany.mock.calls[0][0].where.granularity).toBe("YEAR"); + expect(findMany.mock.calls[1][0].where.granularity).toBe("MONTH"); + }); + + it("falls all the way through to DAY when every coarser tier misses", async () => { + findMany + .mockResolvedValueOnce([]) // YEAR miss + .mockResolvedValueOnce([]) // MONTH miss + .mockResolvedValueOnce([]) // WEEK miss + .mockResolvedValueOnce([bucket("2024-01-01T00:00:00.000Z")]); // DAY hit + + const result = await readBestGranularityRollups("user", "WEIGHT", 1095); + + expect(result?.granularity).toBe("DAY"); + expect(findMany).toHaveBeenCalledTimes(4); + }); + + it("returns null when no granularity carries any coverage", async () => { + findMany.mockResolvedValue([]); + const result = await readBestGranularityRollups("user", "WEIGHT", 1095); + expect(result).toBeNull(); + // YEAR, MONTH, WEEK, DAY all probed. + expect(findMany).toHaveBeenCalledTimes(4); + }); +}); + +describe("aggregateWmyBuckets", () => { + it("returns the empty-window shape on no rows", () => { + expect(aggregateWmyBuckets([])).toEqual({ + count: 0, + min: null, + max: null, + mean: null, + sum: null, + }); + }); + + it("sums count + folds min/max + weights mean across MONTH buckets", () => { + // Two MONTH buckets — same compositional contract as DAY because + // `count / min / max / mean` are linearly composable across any + // bucket granularity. + // April: count=10, mean=82, min=79, max=84 + // May: count=20, mean=80, min=77, max=83 + // ⇒ totalCount=30, min=77, max=84, + // mean = (10×82 + 20×80) / 30 = 80.6666… + const rows: RollupBucketRow[] = [ + bucket("2026-04-01T00:00:00.000Z", { + count: 10, + mean: 82, + minValue: 79, + maxValue: 84, + }), + bucket("2026-05-01T00:00:00.000Z", { + count: 20, + mean: 80, + minValue: 77, + maxValue: 83, + }), + ]; + const result = aggregateWmyBuckets(rows); + expect(result.count).toBe(30); + expect(result.min).toBe(77); + expect(result.max).toBe(84); + expect(result.mean).toBeCloseTo((10 * 82 + 20 * 80) / 30, 5); + }); + + it("sums cumulative sumValue when every bucket carries one", () => { + const rows: RollupBucketRow[] = [ + bucket("2026-04-01T00:00:00.000Z", { sumValue: 12_500 }), + bucket("2026-05-01T00:00:00.000Z", { sumValue: 8_200 }), + ]; + const result = aggregateWmyBuckets(rows); + expect(result.sum).toBe(20_700); + }); + + it("returns null sum when no bucket carries sumValue (pre-W-SUM data)", () => { + const rows: RollupBucketRow[] = [ + bucket("2026-04-01T00:00:00.000Z", { sumValue: null }), + bucket("2026-05-01T00:00:00.000Z", { sumValue: null }), + ]; + expect(aggregateWmyBuckets(rows).sum).toBeNull(); + }); + + it("treats sumValue NaN/Infinity as missing", () => { + const rows: RollupBucketRow[] = [ + bucket("2026-04-01T00:00:00.000Z", { sumValue: 100 }), + bucket("2026-05-01T00:00:00.000Z", { + // simulate a Postgres NaN slipping through serialisation. + sumValue: Number.NaN, + }), + ]; + // Only the finite sumValue contributes. + expect(aggregateWmyBuckets(rows).sum).toBe(100); + }); +});