Files
HealthLog/src/lib/analytics/__tests__/window-trend.test.ts
T
Marc-André BombeckandClaude Opus 4.7 363e73cbf2 chore: voice + filename hygiene + wave-4 state wrap
Maintainer-voice sweep across `src/`. ~190 references to the
maintainer by first name in code comments and JSDoc neutralised to
"the maintainer", "the live tenant", "users", or "the user" depending
on context. Three sed passes covering ~50 distinct patterns
(`Marc reported`, `Marc wants`, `Marc kept`, `Marc rolled back`, etc.).
Test fixtures (`displayName: "Marc B."`, `username: "marc"`,
`<HeroStrip userName="Marc">`) kept as opaque test data per the brief.

CHANGELOG normalisation. The v1.4.14 + v1.4.15 entries paired a
German bullet with its English translation by historical design — the
in-app copy is English-default and the user docs are English, so the
mixed-language style was a holdover from the early-marathon notes.
A one-off node script extracted the `_..._` English paragraph from
each bilingual bullet and rewrote the entry as that text. ~44 bullets
reduced; one Docs bullet that lost a leading word in the regex
fixed by hand.

CLAUDE.md → CONTRIBUTING-AI.md rename so the filename is no longer
AI-vendor-specific. `CONTRIBUTING.md` reference updated. `AGENTS.md`
stays in place for multi-agent compatibility (the conventions doc is
shared with Codex / Cursor / etc.).

Wave-4 state wrap. STATE.md now records C1–C5 + D + D2 as complete;
v1421-backlog.md grew a "Closed in v1.4.22" header listing the 20
items the marathon landed; `.planning/phase-W4-report.md` documents
the per-commit decisions, test deltas, and the 23 items deferred to
v1.4.23 with reasoning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:46:32 +02:00

119 lines
4.2 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { computeWindowTrend, SPLIT_HALF_THRESHOLD_DAYS } from "../window-trend";
/**
* v1.4.16 Fix A8b — chart trend overlay on the "All" filter.
*
* Maintainer report: clicking the All range button on a multi-year metric
* displays "+0.0 kg/Woche" because the per-week delta rounds to zero
* once the window is years wide. The split-half delta keeps the trend
* legible by reporting the change between the first-half mean and the
* second-half mean of the visible series — a number that cannot round
* to zero unless the metric truly didn't move.
*/
describe("computeWindowTrend()", () => {
it("returns null for an empty / single-point series", () => {
expect(
computeWindowTrend({ rawValues: [], trendValues: [], windowDays: 0 }),
).toBeNull();
expect(
computeWindowTrend({
rawValues: [80],
trendValues: [80],
windowDays: 0,
}),
).toBeNull();
});
it("returns null when raw / trend lengths disagree (programmer error)", () => {
expect(
computeWindowTrend({
rawValues: [80, 81, 82],
trendValues: [80, 81],
windowDays: 30,
}),
).toBeNull();
});
it("computes a per-week delta for short windows (< threshold)", () => {
// 7 days, slope of +1 per week → weeklyDelta = +1
const result = computeWindowTrend({
rawValues: [80, 80.5, 81],
trendValues: [80, 80.5, 81],
windowDays: 7,
});
expect(result).not.toBeNull();
expect(result?.weeklyDelta).toBeCloseTo(1, 3);
// Below the split-half threshold — no second-half computation.
expect(result?.splitHalfDelta).toBeNull();
});
it("on the threshold boundary: split-half kicks in at exactly 90 days", () => {
const result = computeWindowTrend({
rawValues: [80, 81, 82, 83],
trendValues: [80, 81, 82, 83],
windowDays: SPLIT_HALF_THRESHOLD_DAYS,
});
expect(result?.splitHalfDelta).not.toBeNull();
});
it("computes a meaningful split-half delta for a long ('All') window", () => {
// ~3 years of synthetic data, +0.3 kg per month average rise. The
// per-week rate is small (~0.07 kg/week) which rounds to 0.1 in
// the 1-decimal formatter — and the test in the chart wrapper is
// about whether ANY signal survives. The split-half delta ≈ +5 kg
// is the kind of number the maintainer actually wants to see.
const months = 36;
const rawValues = Array.from({ length: months }, (_, i) => 80 + 0.3 * i);
const trendValues = rawValues; // perfectly linear, trend === raw
const windowDays = months * 30;
const result = computeWindowTrend({ rawValues, trendValues, windowDays });
expect(result).not.toBeNull();
expect(result?.splitHalfDelta).not.toBeNull();
// First half mean ≈ 80 + 0.3*8.5 = 82.55; second half ≈ 80 + 0.3*26.5 = 87.95
// → delta ≈ 5.4 kg, definitively non-zero.
expect(result?.splitHalfDelta ?? 0).toBeGreaterThan(3);
});
it("split-half delta is exactly zero for a flat series even on a long window", () => {
// Confirms we don't fabricate movement where there is none — the
// split-half is the *mean* delta, so identical halves yield 0.
const flat = Array(50).fill(80);
const result = computeWindowTrend({
rawValues: flat,
trendValues: flat,
windowDays: 365 * 2,
});
expect(result?.splitHalfDelta).toBe(0);
expect(result?.weeklyDelta).toBe(0);
});
it("respects an overridden split-half threshold (param injection)", () => {
// Caller wants split-half from day 30.
const result = computeWindowTrend(
{
rawValues: [80, 81, 82, 83, 84, 85],
trendValues: [80, 81, 82, 83, 84, 85],
windowDays: 30,
},
30,
);
expect(result?.splitHalfDelta).not.toBeNull();
});
it("first-half/second-half split halves of a 6-point series", () => {
// Values: [70, 71, 72, 80, 81, 82]
// First half mean = (70+71+72)/3 = 71
// Second half mean = (80+81+82)/3 = 81
// Δ = 10
const result = computeWindowTrend({
rawValues: [70, 71, 72, 80, 81, 82],
trendValues: [70, 71, 72, 80, 81, 82],
windowDays: 365,
});
expect(result?.splitHalfDelta).toBeCloseTo(10, 3);
});
});