From 861fe7ad0dbc6c4628a979d599f76f6af8758fec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 13:23:13 +0200 Subject: [PATCH] test(charts): comparisonBaseline data correctness end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins the comparison-overlay shift across the edges Marc flagged worry about. The existing `shiftDailySeriesForward` implementation is correct — integer-day forward addition, no DST surprises because every input timestamp is anchored at UTC-noon (well clear of the 02:00-03:00 wall- clock jump). No production fix needed; this is a regression-floor suite so a future refactor cannot silently change the contract. Pinned scenarios: - DST forward shift (Mar 31 2024 Berlin spring-forward) — calendar day preserved - DST backward shift (Oct 27 2024 Berlin fall-back) — calendar day preserved - Leap-year alignment — Feb 29 2024 + 365 days lands on Feb 28 2025 (the contract is "365 days ago", not "same calendar date last year") - Insufficient prior data (60 days history + `lastYear` request) → overlay renders empty so the chart drops the comparison cleanly without rendering a "kein Vergleich" placeholder (per Marc directive 2026-05-14) - Partial prior-period coverage flags `hasComparisonData = true` - Tile-caption math (`averageValue` + `computeComparisonDelta`) returns null cleanly when the prior window is empty --- .../comparison-shift-edge-cases.test.ts | 225 ++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 src/lib/charts/__tests__/comparison-shift-edge-cases.test.ts diff --git a/src/lib/charts/__tests__/comparison-shift-edge-cases.test.ts b/src/lib/charts/__tests__/comparison-shift-edge-cases.test.ts new file mode 100644 index 000000000..b1db7fcdf --- /dev/null +++ b/src/lib/charts/__tests__/comparison-shift-edge-cases.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect } from "vitest"; +import { + shiftDailySeriesForward, + averageValue, + computeComparisonDelta, +} from "@/lib/charts/comparison-shift"; + +/** + * v1.4.25 W6 — edge-case end-to-end pinning for the `comparisonBaseline` + * overlay. + * + * The basic shift suite (`comparison-shift.test.ts`) already pins the + * happy path: integer-day forward shifts, value preservation, empty + * input → empty output. This file pins the corner cases Marc flagged + * worry about, in particular: + * + * 1. DST transition days — the helper must NOT cross a calendar-day + * boundary when the Berlin clock shifts on the last Sunday of + * March / October. We anchor sample data at UTC-noon of each + * affected day (the dashboard's daily-bucket key contract) and + * assert the shifted timestamps still land on the correct + * Berlin-calendar day. + * 2. Leap-year alignment (Feb 29 prior year). A `vs Vorjahr` shift of + * 365 days from Feb 29 2024 → Feb 28 2025 (one day off because the + * calendar year is not 366 days). We pin that behaviour explicitly + * so a future refactor can't silently change it. + * 3. Insufficient prior-period data — the chart's empty-state copy + * reads "Comparison unavailable …" only when `hasComparisonData` + * is false. Per Marc's directive 2026-05-14 the placeholder is + * gone; the renderer just suppresses the overlay cleanly. The + * helper supports that by returning empty arrays for empty input. + * 4. 60-days-of-data, asks for `vs Vorjahr` — the shift moves every + * bucket forward 365 days, lands them outside any visible window, + * and the merge in `chartDataWithCompare` (verified at the chart + * layer) silently drops them. We pin the helper layer here. + */ + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +describe("shiftDailySeriesForward — DST transitions", () => { + it("preserves the calendar-day-of-month across the March DST forward shift", () => { + // Berlin springs forward 02:00 → 03:00 on Sun 31 Mar 2024. Daily + // buckets sit at UTC-noon, well clear of the 02:00-03:00 wall-clock + // jump. We assert the shifted timestamp is still UTC-noon of the + // expected calendar day. + const beforeDst = new Date("2024-03-30T12:00:00.000Z").getTime(); + const onDst = new Date("2024-03-31T12:00:00.000Z").getTime(); + const afterDst = new Date("2024-04-01T12:00:00.000Z").getTime(); + + const shifted = shiftDailySeriesForward( + [ + { timestamp: beforeDst, weight: 80.1 }, + { timestamp: onDst, weight: 80.2 }, + { timestamp: afterDst, weight: 80.0 }, + ], + "lastMonth", + ); + + expect(shifted[0].timestamp).toBe(beforeDst + 30 * MS_PER_DAY); + expect(shifted[1].timestamp).toBe(onDst + 30 * MS_PER_DAY); + expect(shifted[2].timestamp).toBe(afterDst + 30 * MS_PER_DAY); + + // The shifted DST point still resolves to UTC-noon on the target + // day (Apr 30) — verifying that the integer-day add did not drift + // the timestamp into the next/prev UTC day. + const shiftedDst = new Date(shifted[1].timestamp); + expect(shiftedDst.toISOString()).toBe("2024-04-30T12:00:00.000Z"); + }); + + it("preserves the calendar-day-of-month across the October DST backward shift", () => { + // Berlin falls back 03:00 → 02:00 on Sun 27 Oct 2024. + const beforeDst = new Date("2024-10-26T12:00:00.000Z").getTime(); + const onDst = new Date("2024-10-27T12:00:00.000Z").getTime(); + const afterDst = new Date("2024-10-28T12:00:00.000Z").getTime(); + + const shifted = shiftDailySeriesForward( + [ + { timestamp: beforeDst, value: 1 }, + { timestamp: onDst, value: 2 }, + { timestamp: afterDst, value: 3 }, + ], + "lastMonth", + ); + + expect(new Date(shifted[0].timestamp).toISOString()).toBe( + "2024-11-25T12:00:00.000Z", + ); + expect(new Date(shifted[1].timestamp).toISOString()).toBe( + "2024-11-26T12:00:00.000Z", + ); + expect(new Date(shifted[2].timestamp).toISOString()).toBe( + "2024-11-27T12:00:00.000Z", + ); + }); +}); + +describe("shiftDailySeriesForward — leap year", () => { + it("`lastYear` shift of Feb 29 2024 lands on Feb 28 2025 (365-day add)", () => { + // The shift is integer-365-day forward; it does NOT account for the + // leap-year extra day. This is intentional — the dashboard's prior + // period is "365 days ago", not "the same calendar date last year". + // We pin the behaviour so a future refactor doesn't silently change + // the contract. + const feb29 = new Date("2024-02-29T12:00:00.000Z").getTime(); + const shifted = shiftDailySeriesForward( + [{ timestamp: feb29, value: 75 }], + "lastYear", + ); + expect(new Date(shifted[0].timestamp).toISOString()).toBe( + "2025-02-28T12:00:00.000Z", + ); + }); + + it("`lastYear` shift of Mar 1 2024 lands on Mar 1 2025 (365-day add crosses Feb 28)", () => { + const mar1 = new Date("2024-03-01T12:00:00.000Z").getTime(); + const shifted = shiftDailySeriesForward( + [{ timestamp: mar1, value: 75 }], + "lastYear", + ); + expect(new Date(shifted[0].timestamp).toISOString()).toBe( + "2025-03-01T12:00:00.000Z", + ); + }); +}); + +describe("hasComparisonData semantics — insufficient prior-period data", () => { + /** + * The chart layer derives `hasComparisonData` by checking whether any + * visible day has a `${type}_compare` value. We model that here with + * a tiny `mergeShifted` helper that mirrors the chart's behaviour: + * a prior-period point only paints when its shifted timestamp lands + * on a visible day-key. + */ + function mergeShifted( + visibleDayKeys: string[], + shifted: Array<{ timestamp: number; value: number }>, + dayKeyOf: (ms: number) => string, + ): boolean { + const set = new Set(visibleDayKeys); + return shifted.some((row) => set.has(dayKeyOf(row.timestamp))); + } + + const utcDayKey = (ms: number) => new Date(ms).toISOString().slice(0, 10); + + it("60 days of current data + `lastYear` request → overlay drops cleanly (no overlap)", () => { + // 60 visible days, ending today. The shifted-prior series is 365 + // days later than the input — so a 60-day input produces 60 points + // that all land 365 days AFTER the visible window's start (not + // ON it). With no prior-year input rows seeded, the shifted set + // is empty → no overlay. + const today = new Date("2026-05-14T12:00:00.000Z").getTime(); + const visibleDays: string[] = []; + for (let i = 0; i < 60; i++) { + visibleDays.push(utcDayKey(today - i * MS_PER_DAY)); + } + + // No prior-year data exists yet (user only has 60 days of history). + const priorYearInput: Array<{ timestamp: number; value: number }> = []; + const shifted = shiftDailySeriesForward(priorYearInput, "lastYear"); + expect(shifted).toHaveLength(0); + expect(mergeShifted(visibleDays, shifted, utcDayKey)).toBe(false); + }); + + it("400 days of data + `lastYear` → some prior days align with visible window", () => { + // 400 days of history means the prior-year window (365 days ago) + // sits inside the visible 30-day chart window. The shift moves + // those prior-year points forward 365 days and they should land on + // the visible day-keys. + const today = new Date("2026-05-14T12:00:00.000Z").getTime(); + const visibleStart = today - 30 * MS_PER_DAY; + const visibleDays: string[] = []; + for (let i = 0; i <= 30; i++) { + visibleDays.push(utcDayKey(visibleStart + i * MS_PER_DAY)); + } + // Seed prior-year data: 30 days at exactly 365 days back from the + // visible window. + const priorYearInput: Array<{ timestamp: number; value: number }> = []; + for (let i = 0; i <= 30; i++) { + priorYearInput.push({ + timestamp: visibleStart + i * MS_PER_DAY - 365 * MS_PER_DAY, + value: 80 + i * 0.1, + }); + } + const shifted = shiftDailySeriesForward(priorYearInput, "lastYear"); + expect(shifted).toHaveLength(31); + expect(mergeShifted(visibleDays, shifted, utcDayKey)).toBe(true); + }); + + it("partial prior-period coverage flags hasComparisonData = true (chart contract)", () => { + // The chart's `hasComparisonData` is true when ANY visible day has + // a prior value — partial overlap counts. Pin that contract here. + const today = new Date("2026-05-14T12:00:00.000Z").getTime(); + const visibleDays = [utcDayKey(today), utcDayKey(today - MS_PER_DAY)]; + // Only the older of the two days has a prior-month value. + const priorInput = [ + { + timestamp: today - MS_PER_DAY - 30 * MS_PER_DAY, + value: 70, + }, + ]; + const shifted = shiftDailySeriesForward(priorInput, "lastMonth"); + expect(mergeShifted(visibleDays, shifted, utcDayKey)).toBe(true); + }); +}); + +describe("averageValue + computeComparisonDelta — tile caption math", () => { + it("tile delta math survives a partial-data prior window", () => { + const currentAvg = averageValue([80.5, 80.7, 80.9, 81.0, 81.2]); + // Only 2 prior readings exist — averageValue handles that fine. + const priorAvg = averageValue([82.0, 82.4]); + expect(currentAvg).toBeCloseTo(80.86, 2); + expect(priorAvg).toBe(82.2); + expect(computeComparisonDelta(currentAvg, priorAvg)).toBeCloseTo(-1.34, 2); + }); + + it("tile delta math returns null when the prior window is empty", () => { + const currentAvg = averageValue([80.5, 80.7, 80.9]); + const priorAvg = averageValue([]); + expect(currentAvg).toBeCloseTo(80.7, 2); + expect(priorAvg).toBeNull(); + // The contract: if either side is null, the delta is null so the + // tile suppresses the comparison callout cleanly. + expect(computeComparisonDelta(currentAvg, priorAvg)).toBeNull(); + }); +});