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:
Marc-André Bombeck committed 2026-07-19 02:27:03 +02:00
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
View File
@@ -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).
+3
View File
@@ -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.