mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
test(rollups): stop the summary parity tests going red after midnight UTC
The two summary tests seeded readings counting back from the current instant, a few more minutes earlier each, and compared the composed mean with a plain AVG. Between midnight and about 01:15 UTC the earliest minutes slid into the day before, a day gained a reading in another hour, and for pulse, whose day is a mean of hourly means, the composed mean left the plain AVG. The fixtures now count back from noon UTC yesterday, and the expected mean is the one each type documents: AVG, or the mean of hourly-mean days.
This commit is contained in:
1 parent
2dc313980b
commit
118494b91d
1 file changed
+87
-48
@@ -35,6 +35,7 @@ import {
|
||||
} from "@/lib/rollups/measurement-rollups";
|
||||
import { aggregateBuckets } from "@/lib/rollups/measurement-read";
|
||||
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
|
||||
import { windowWeighting } from "@/lib/measurements/day-statistic";
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -65,6 +66,81 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* The instant the summary fixtures count back from: noon UTC yesterday.
|
||||
*
|
||||
* They used to count back from `Date.now()`, with each reading a few more
|
||||
* minutes earlier than the last. Run between midnight and about 01:15 UTC,
|
||||
* the earliest of those minutes slid across midnight into the day before,
|
||||
* so a day gained a reading in another hour, and for a type whose day is a
|
||||
* mean of hourly means (pulse) the composed mean moved off the plain AVG the
|
||||
* tests compared it with: the suite went red for an hour every night. Noon
|
||||
* leaves every offset inside its own day, so the layout is the same at any
|
||||
* time the suite runs.
|
||||
*/
|
||||
function fixtureAnchor(): number {
|
||||
const now = new Date();
|
||||
return (
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) -
|
||||
DAY_MS +
|
||||
12 * 60 * 60 * 1000
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live SQL over the raw rows for the summary's count / min / max / mean,
|
||||
* with the mean each type documents: a plain AVG over readings, or, for a
|
||||
* type whose day is a mean of hourly means (`windowWeighting(type) ===
|
||||
* "day"`), the mean of those per-day values.
|
||||
*/
|
||||
async function liveSummaryRows(userId: string, since: Date) {
|
||||
const prisma = getPrismaClient();
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{
|
||||
type: string;
|
||||
count: bigint;
|
||||
min_value: number;
|
||||
max_value: number;
|
||||
mean_value: number;
|
||||
day_mean_value: number;
|
||||
}>
|
||||
>`
|
||||
WITH hourly AS (
|
||||
SELECT m."type", date_trunc('day', m."measured_at") AS day,
|
||||
AVG(m."value") AS v
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId} AND m."measured_at" >= ${since}
|
||||
GROUP BY m."type", date_trunc('day', m."measured_at"),
|
||||
date_trunc('hour', m."measured_at")
|
||||
),
|
||||
daily AS (
|
||||
SELECT "type", day, AVG(v) AS v FROM hourly GROUP BY "type", day
|
||||
),
|
||||
by_day AS (
|
||||
SELECT "type", AVG(v)::double precision AS day_mean_value
|
||||
FROM daily GROUP BY "type"
|
||||
)
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
COUNT(*) AS count,
|
||||
MIN(m."value")::double precision AS min_value,
|
||||
MAX(m."value")::double precision AS max_value,
|
||||
AVG(m."value")::double precision AS mean_value,
|
||||
d.day_mean_value AS day_mean_value
|
||||
FROM measurements m
|
||||
JOIN by_day d ON d."type" = m."type"
|
||||
WHERE m."user_id" = ${userId} AND m."measured_at" >= ${since}
|
||||
GROUP BY m."type", d.day_mean_value
|
||||
`;
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
expectedMean:
|
||||
windowWeighting(row.type) === "day" ? row.day_mean_value : row.mean_value,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("measurement rollups — integration", () => {
|
||||
it("writes a DAY rollup row that matches the inserted measurements", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
@@ -298,8 +374,8 @@ describe("measurement rollups — integration", () => {
|
||||
// type families exercise the per-type composition path. The
|
||||
// values are deliberately fractional so the round2 contract gets
|
||||
// a workout.
|
||||
const now = Date.now();
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
const now = fixtureAnchor();
|
||||
const dayMs = DAY_MS;
|
||||
const seedRows: Array<{
|
||||
userId: string;
|
||||
type: "WEIGHT" | "PULSE";
|
||||
@@ -336,26 +412,7 @@ describe("measurement rollups — integration", () => {
|
||||
// the source of truth — the read-swap must produce the same
|
||||
// count/min/max/mean byte-for-byte.
|
||||
const ninetyDaysAgo = new Date(Date.now() - 90 * dayMs);
|
||||
const live = await prisma.$queryRaw<
|
||||
Array<{
|
||||
type: string;
|
||||
count: bigint;
|
||||
min_value: number;
|
||||
max_value: number;
|
||||
mean_value: number;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
COUNT(*) AS count,
|
||||
MIN(m."value")::double precision AS min_value,
|
||||
MAX(m."value")::double precision AS max_value,
|
||||
AVG(m."value")::double precision AS mean_value
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${user.id}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
GROUP BY m."type"
|
||||
`;
|
||||
const live = await liveSummaryRows(user.id, ninetyDaysAgo);
|
||||
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
@@ -366,9 +423,10 @@ describe("measurement rollups — integration", () => {
|
||||
expect(summary.count).toBe(Number(liveRow.count));
|
||||
expect(summary.min).toBe(round2(liveRow.min_value));
|
||||
expect(summary.max).toBe(round2(liveRow.max_value));
|
||||
// The composed weighted mean equals AVG(value) over the same
|
||||
// rows; both run through `round2`.
|
||||
expect(summary.mean).toBe(round2(liveRow.mean_value));
|
||||
// The composed mean equals the type's documented mean over the same
|
||||
// rows (AVG, or the mean of hourly-mean days); both run through
|
||||
// `round2`.
|
||||
expect(summary.mean).toBe(round2(liveRow.expectedMean));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -741,8 +799,8 @@ describe("measurement rollups — integration", () => {
|
||||
|
||||
// 60 readings over 30 days, two types, deliberately spread so
|
||||
// anomalies + slopes + windowed avgs all have signal.
|
||||
const now = Date.now();
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
const now = fixtureAnchor();
|
||||
const dayMs = DAY_MS;
|
||||
const seedRows: Array<{
|
||||
userId: string;
|
||||
type: "WEIGHT" | "PULSE";
|
||||
@@ -777,26 +835,7 @@ describe("measurement rollups — integration", () => {
|
||||
// Parallel live aggregation over the same 90-day window using the
|
||||
// exact heavy-aggregate shape the cold-mount fallback runs.
|
||||
const ninetyDaysAgo = new Date(now - 90 * dayMs);
|
||||
const live = await prisma.$queryRaw<
|
||||
Array<{
|
||||
type: string;
|
||||
count: bigint;
|
||||
min_value: number;
|
||||
max_value: number;
|
||||
mean_value: number;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
COUNT(*) AS count,
|
||||
MIN(m."value")::double precision AS min_value,
|
||||
MAX(m."value")::double precision AS max_value,
|
||||
AVG(m."value")::double precision AS mean_value
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${user.id}
|
||||
AND m."measured_at" >= ${ninetyDaysAgo}
|
||||
GROUP BY m."type"
|
||||
`;
|
||||
const live = await liveSummaryRows(user.id, ninetyDaysAgo);
|
||||
|
||||
const aggregate = await buildComprehensiveAggregate(user.id);
|
||||
|
||||
@@ -811,7 +850,7 @@ describe("measurement rollups — integration", () => {
|
||||
expect(summary.count).toBe(Number(liveRow.count));
|
||||
expect(summary.min).toBe(round2(liveRow.min_value));
|
||||
expect(summary.max).toBe(round2(liveRow.max_value));
|
||||
expect(summary.mean).toBe(round2(liveRow.mean_value));
|
||||
expect(summary.mean).toBe(round2(liveRow.expectedMean));
|
||||
// Narrow aggregate fills the non-composable windowed columns.
|
||||
// Slope tuples and avg7/avg30 are sourced from live SQL even on
|
||||
// the rollup-fresh path so the values are present (not null) as
|
||||
|
||||
Reference in new issue
Block a user