Files
HealthLog/src/lib/analytics/__tests__/trends.test.ts
T
Marc-André Bombeck 9df23c3c87 chore(analytics): drop unreferenced movingAverage and weeklyAverages exports
Neither function had any production caller. The chart components in
src/components/charts/* implement a local movingAverageByPoints rather
than reaching for the lib helper, and weeklyAverages was never imported
outside its own test file. detectAnomalies stays — it is reached
transitively through summarize() which the analytics, insights, and
measurements-series routes all consume.
2026-05-21 12:04:07 +02:00

145 lines
5.2 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
trendSlope,
detectAnomalies,
summarize,
type DataPoint,
} from "../trends";
function makePoints(values: number[], startDaysAgo = 30): DataPoint[] {
const now = Date.now();
return values.map((value, i) => ({
date: new Date(now - (startDaysAgo - i) * 24 * 60 * 60 * 1000),
value,
}));
}
describe("trendSlope", () => {
it("returns null for insufficient data", () => {
expect(trendSlope([], 7)).toBeNull();
expect(trendSlope(makePoints([70]), 7)).toBeNull();
});
it("detects upward trend", () => {
const data = makePoints([70, 71, 72, 73, 74, 75, 76], 7);
const result = trendSlope(data, 7);
expect(result).not.toBeNull();
expect(result!.direction).toBe("up");
expect(result!.slope).toBeGreaterThan(0);
expect(result!.confidence).toBeGreaterThan(0.9);
});
it("detects downward trend", () => {
const data = makePoints([80, 79, 78, 77, 76, 75, 74], 7);
const result = trendSlope(data, 7);
expect(result).not.toBeNull();
expect(result!.direction).toBe("down");
expect(result!.slope).toBeLessThan(0);
});
it("detects stable trend", () => {
const data = makePoints([75, 75, 75, 75, 75, 75, 75], 7);
const result = trendSlope(data, 7);
expect(result).not.toBeNull();
expect(result!.direction).toBe("stable");
expect(Math.abs(result!.slope)).toBeLessThan(0.01);
});
});
describe("detectAnomalies", () => {
it("returns empty for insufficient data", () => {
expect(detectAnomalies(makePoints([70, 71]))).toEqual([]);
});
it("detects outliers", () => {
const values = [75, 75, 75, 75, 75, 75, 75, 75, 75, 100];
const data = makePoints(values, 10);
const anomalies = detectAnomalies(data, 2);
expect(anomalies.length).toBeGreaterThan(0);
expect(anomalies[0].value).toBe(100);
});
it("returns empty for uniform data", () => {
const data = makePoints([75, 75, 75, 75, 75], 5);
const anomalies = detectAnomalies(data);
expect(anomalies).toEqual([]);
});
});
describe("summarize", () => {
it("returns null stats (not zero) for empty input", () => {
const summary = summarize([]);
expect(summary.count).toBe(0);
expect(summary.latest).toBeNull();
// v3 audit fix: previously returned 0/0/0 sentinels which leaked into
// chart axes and tile renders as "real" readings.
expect(summary.min).toBeNull();
expect(summary.max).toBeNull();
expect(summary.mean).toBeNull();
expect(summary.avg7).toBeNull();
expect(summary.avg30).toBeNull();
});
it("calculates correct summary", () => {
const data = makePoints([70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80], 11);
const summary = summarize(data);
expect(summary.count).toBe(11);
expect(summary.latest).toBe(80);
expect(summary.min).toBe(70);
expect(summary.max).toBe(80);
expect(summary.mean).toBe(75);
expect(summary.avg7).not.toBeNull();
expect(summary.slope7).not.toBeNull();
});
// v3 audit caught a divergence: summarize().avg7 used `now` as the
// window anchor, while trendSlope() used the last point. A stale
// series (no readings for weeks) reported a slope but no average.
// Both are now `now`-anchored, so a stale series returns null
// consistently across stat fields.
it("aligns trendSlope and avg windows on `now`, not on the last point", () => {
const stale = [70, 71, 72, 73, 74, 75, 76].map((value, i) => ({
// 100 days ago, all consecutive — last point still 100 days old.
date: new Date(Date.now() - (100 - i) * 24 * 60 * 60 * 1000),
value,
}));
const summary = summarize(stale);
expect(summary.avg7).toBeNull(); // no readings in last 7 days
expect(summary.avg30).toBeNull(); // no readings in last 30 days
expect(summary.slope7).toBeNull(); // window snaps to `now`, no points
expect(summary.slope30).toBeNull(); // same
});
// v1.4.33 P0 regression — production stacktrace
// `RangeError: Maximum call stack size exceeded` at index 3 of the
// analytics route's per-type `Promise.all` (PULSE).
//
// Root cause: `Math.min(...values)` / `Math.max(...values)` spread the
// whole series as function arguments. V8 caps function arity at
// ~125 000-130 000; an Apple-Health-synced PULSE series easily
// exceeds that for a multi-year power user. The fix folds min/max via
// a single pass instead, keeping the working set bounded.
it("survives a multi-hundred-thousand-row series without blowing the stack", () => {
// 250 000 points — well above V8's spread-arg ceiling. The
// unrolled values give us deterministic min/max so we can assert
// both the safety contract AND the correctness contract in one go.
const N = 250_000;
const now = Date.now();
const data: DataPoint[] = new Array(N);
for (let i = 0; i < N; i++) {
data[i] = {
date: new Date(now - (N - i) * 1000),
// Wave between 40 and 199; min=40, max=199.
value: 40 + (i % 160),
};
}
// Must not throw — before the fix this raised
// `RangeError: Maximum call stack size exceeded`.
const summary = summarize(data);
expect(summary.count).toBe(N);
expect(summary.min).toBe(40);
expect(summary.max).toBe(199);
expect(summary.latest).toBe(data[N - 1].value);
});
});