mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
test: pin the suite's timezone and make the zone-sensitive fixtures say which zone they mean
The suite was not timezone-pinned. CI runs UTC, so a whole class of day-bucketing defect was invisible to the gate while a contributor outside UTC saw a red suite on unmodified main. Verified: a UTC+14 host fails five tests on an otherwise green tree. Pin TZ=UTC in both vitest configs; tests that need a different HOST zone now set and restore it explicitly. The resting-pulse fixtures passed offset-less ISO literals, which parse as host-local, so they could not distinguish the Berlin day-key the implementation uses from a UTC one — swapping the day-key's zone left the file green. Build each instant explicitly in Berlin and add cases whose local day and UTC day genuinely differ; that swap now fails two of them. Delete the 'DST boundary (spring-forward)' compliance case. It passed no timezone, so the slot builder took the legacy UTC path where the date carries no transition, and it asserted totalExpected === 7 — which holds in every zone and therefore could never see the double-emit its own comment described. Threading a real zone does not rescue it: the assertion stays true in Berlin, UTC, UTC+14 and UTC-11 alike. The property it claimed is already pinned properly in the scheduling suite, which walks buildCadenceTimeline with an explicit zone across both the 23 h spring-forward and the 25 h fall-back day.
This commit is contained in:
1 parent
4ba600d908
commit
4791d6f543
4 files changed
+114
-38
No files matched your search
@@ -12,11 +12,45 @@ import {
|
||||
resolveRestingPulseSeries,
|
||||
type PulseSample,
|
||||
} from "../resting-pulse";
|
||||
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
|
||||
|
||||
const day = (iso: string, value: number): PulseSample => ({
|
||||
measuredAt: new Date(iso),
|
||||
value,
|
||||
});
|
||||
/**
|
||||
* The zone `deriveRestingProxyFromPulse` buckets in by default
|
||||
* (`toBerlinDayKey`). Naming it here is the point: the fixtures used to
|
||||
* pass offset-less ISO literals, which `new Date(...)` reads as HOST-local.
|
||||
* That made every expectation mean something different on every developer
|
||||
* machine, and — because a Berlin day and a UTC day coincide for samples in
|
||||
* the middle of the day — swapping the implementation's day-key zone left
|
||||
* the whole file green. Building each instant explicitly in Berlin lets the
|
||||
* assertions actually pin the bucketing.
|
||||
*/
|
||||
const PROXY_DAY_ZONE = "Europe/Berlin";
|
||||
|
||||
/**
|
||||
* A pulse sample at the given `YYYY-MM-DDTHH:MM:SS` wall clock **in
|
||||
* Berlin**, expressed as the UTC instant it denotes.
|
||||
*/
|
||||
const day = (wallClock: string, value: number): PulseSample => {
|
||||
const m = wallClock.match(
|
||||
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/,
|
||||
);
|
||||
if (!m) throw new Error(`fixture wall clock must be local, got ${wallClock}`);
|
||||
const [, y, mo, d, h, mi, sec] = m;
|
||||
return {
|
||||
measuredAt: zonedWallClockToUtc(
|
||||
{
|
||||
year: Number(y),
|
||||
month: Number(mo),
|
||||
day: Number(d),
|
||||
hour: Number(h),
|
||||
minute: Number(mi),
|
||||
second: Number(sec),
|
||||
},
|
||||
PROXY_DAY_ZONE,
|
||||
),
|
||||
value,
|
||||
};
|
||||
};
|
||||
|
||||
describe("deriveRestingProxyFromPulse", () => {
|
||||
it("excludes a workout burst — the daily proxy tracks the resting floor", () => {
|
||||
@@ -154,3 +188,55 @@ describe("resolveRestingPulseSeries", () => {
|
||||
).toEqual({ series: [], which: "none" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveRestingProxyFromPulse — the day bucket is the PROFILE day", () => {
|
||||
it("keeps after-midnight local samples on their own local day, not the UTC one", () => {
|
||||
// Berlin is UTC+2 in June, so 00:20–01:00 local on 2026-06-02 is
|
||||
// 22:20–23:00 UTC on 2026-06-01 — a DIFFERENT UTC calendar day. All
|
||||
// six samples belong to one Berlin day and must produce ONE bucket.
|
||||
// Under a UTC day-key they split 3/3 and produce two.
|
||||
const samples = [
|
||||
day("2026-06-02T00:20:00", 60),
|
||||
day("2026-06-02T00:40:00", 62),
|
||||
day("2026-06-02T01:00:00", 64),
|
||||
day("2026-06-02T09:00:00", 66),
|
||||
day("2026-06-02T15:00:00", 68),
|
||||
day("2026-06-02T21:00:00", 70),
|
||||
];
|
||||
const proxy = deriveRestingProxyFromPulse(samples);
|
||||
expect(proxy).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("splits two local days that a UTC key would merge", () => {
|
||||
// 23:10–23:50 Berlin on 2026-06-01 is 21:10–21:50 UTC the same day,
|
||||
// while 00:10–00:50 Berlin on 2026-06-02 is 22:10–22:50 UTC on
|
||||
// 2026-06-01. A UTC key merges all six into one bucket; the Berlin key
|
||||
// must see two days.
|
||||
const samples = [
|
||||
day("2026-06-01T23:10:00", 60),
|
||||
day("2026-06-01T23:30:00", 62),
|
||||
day("2026-06-01T23:50:00", 64),
|
||||
day("2026-06-02T00:10:00", 80),
|
||||
day("2026-06-02T00:30:00", 82),
|
||||
day("2026-06-02T00:50:00", 84),
|
||||
];
|
||||
const proxy = deriveRestingProxyFromPulse(samples);
|
||||
expect(proxy).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("honours an injected day-key function over the Berlin default", () => {
|
||||
// The targets route threads `userDayKey(d, userTz)`. Same samples,
|
||||
// different zone → a different bucketing. This is the seam the default
|
||||
// hides, so it gets its own pin.
|
||||
const samples = [
|
||||
day("2026-06-02T00:20:00", 60),
|
||||
day("2026-06-02T00:40:00", 62),
|
||||
day("2026-06-02T01:00:00", 64),
|
||||
];
|
||||
const utcKey = (d: Date) => d.toISOString().slice(0, 10);
|
||||
// In UTC these three are 2026-06-01 22:20/22:40/23:00 → one UTC day.
|
||||
expect(deriveRestingProxyFromPulse(samples, utcKey)).toHaveLength(1);
|
||||
// And that UTC day is 06-01, whereas the Berlin bucket is 06-02.
|
||||
expect(utcKey(samples[0].measuredAt)).toBe("2026-06-01");
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,18 @@
|
||||
* the numbers now honour `daysOfWeek` and `intervalWeeks`. These
|
||||
* tests pin the contract for every cadence the production app
|
||||
* exercises: daily 1×/day, daily multi-dose, weekly Mondays-only,
|
||||
* bi-weekly, weekday-only multi-dose, and the DST-boundary day.
|
||||
* Closes #214.
|
||||
* bi-weekly, and weekday-only multi-dose. Closes #214.
|
||||
*
|
||||
* The DST local-day walk is NOT pinned here. A "DST boundary" case used
|
||||
* to sit in this file passing no timezone, so the slot builder took the
|
||||
* legacy UTC path where the date carries no transition — it asserted
|
||||
* `totalExpected === 7`, which holds in every zone and therefore could
|
||||
* never see a double-emit. Threading a real zone through it does not
|
||||
* help: the assertion stays true in Berlin, UTC, UTC+14 and UTC−11
|
||||
* alike. The property it claimed lives in
|
||||
* `src/lib/medications/scheduling/__tests__/compliance.test.ts`, which
|
||||
* walks `buildCadenceTimeline` with an explicit zone across BOTH the 23 h
|
||||
* spring-forward and the 25 h fall-back day.
|
||||
*
|
||||
* Each test uses `vi.useFakeTimers()` to pin `now` so the rolling
|
||||
* window is deterministic. `classifyIntakeTiming` tests below the
|
||||
@@ -343,38 +353,6 @@ describe("calculateCompliance — cadence-aware adapter", () => {
|
||||
expect(result.missed).toBe(0);
|
||||
});
|
||||
|
||||
it("DST boundary (Europe/Berlin spring-forward) does not inflate the expected count", () => {
|
||||
// Spring-forward in Berlin: 2025-03-30 02:00 → 03:00. A daily
|
||||
// schedule across this boundary must still emit one slot per
|
||||
// local day — not two on the short day and zero on the next.
|
||||
// Pin `now` to Apr-02 noon UTC and run a 7-day window so the
|
||||
// boundary day is inside it. Generate one event per day
|
||||
// (working backwards from NOW − 1 day so each event lands inside
|
||||
// the window) and assert the totalExpected lands at 7 (not 8).
|
||||
vi.setSystemTime(new Date("2025-04-02T12:00:00Z"));
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{ windowStart: "10:00", windowEnd: "11:00", daysOfWeek: null },
|
||||
];
|
||||
// Slots emitted by the timeline: Mar-27..Apr-02 (Mar-26's window
|
||||
// sits below the rolling-window start of Mar-26 12:00). Generate
|
||||
// one taken event per slot — today's 10:30 slot is also in the
|
||||
// past relative to NOW = 12:00 so it counts as taken too.
|
||||
const events = [];
|
||||
for (let d = 0; d <= 6; d++) {
|
||||
const at = new Date("2025-04-02T10:30:00Z");
|
||||
at.setUTCDate(at.getUTCDate() - d);
|
||||
events.push(eventAt(at, true));
|
||||
}
|
||||
const result = calculateCompliance(events, schedules, 7);
|
||||
// The hard contract: totalExpected stays at 7 — not 8 (which
|
||||
// would mean the DST short day double-emitted) and not 6 (which
|
||||
// would mean the short day silently dropped a slot).
|
||||
expect(result.totalExpected).toBe(7);
|
||||
expect(result.taken).toBe(7);
|
||||
expect(result.missed).toBe(0);
|
||||
expect(result.rate).toBe(100);
|
||||
});
|
||||
|
||||
it("filters events outside the rolling window", () => {
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
|
||||
|
||||
@@ -9,6 +9,15 @@ export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
// Pin the host timezone. CI runs UTC; without this a contributor in any
|
||||
// other zone sees a different suite than the gate does — verified: a
|
||||
// UTC+14 host fails five tests on an otherwise green tree. Pinning makes
|
||||
// local and CI agree, and makes "which zone does this test mean?" an
|
||||
// explicit choice the fixture has to state rather than a property of the
|
||||
// machine it runs on. Tests that need a different HOST zone set
|
||||
// `process.env.TZ` themselves and restore it afterwards (see
|
||||
// `next-due-day-label.test.ts`).
|
||||
env: { TZ: "UTC" },
|
||||
// Primes the i18n locale cache so provider mounts resolve every
|
||||
// locale synchronously (production gets the active bundle as an RSC
|
||||
// prop instead — see src/lib/i18n/load-locale.ts).
|
||||
|
||||
@@ -13,6 +13,9 @@ export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
// Same host-timezone pin as the unit config — CI runs UTC, so the
|
||||
// integration contracts must be read against the same clock.
|
||||
env: { TZ: "UTC" },
|
||||
include: ["tests/integration/**/*.test.ts"],
|
||||
// Container boot + migration apply is slow; give each test a generous
|
||||
// budget and beforeAll/afterAll twice that.
|
||||
|
||||
Reference in new issue
Block a user