/** * earth-now.co * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: apps/web/test/smoothing.test.ts * Purpose: Tests for the 60 s model-swap smoothing — exact old value at swap start, exact new value at swap end, linear blend in between */ import { describe, expect, it } from "vitest"; import { counterValue, type CounterModel } from "@earth-now/counter"; import { blendValues, makeSmoothedEvaluator, SMOOTHING_DURATION_MS, smoothingAlpha, } from "../lib/smoothing"; const ANCHOR = "2026-01-01T00:00:00.000Z"; function linearModel(anchorValue: number, perSecond: number): CounterModel { return { metricId: "test_metric", anchorValue, anchorTime: ANCHOR, rateFn: { kind: "linear", perSecond }, observedAt: "2025-12-31T00:00:00.000Z", sourceId: "test_source", modelVersion: "linear-test-v1", displayHints: { decimals: 0, unit: "u" }, }; } describe("makeSmoothedEvaluator", () => { const oldModel = linearModel(1_000, 1); const newModel = linearModel(1_600, 2); const swapStart = Date.UTC(2026, 0, 2, 12, 0, 0); const duration = SMOOTHING_DURATION_MS; const evaluate = makeSmoothedEvaluator(oldModel, newModel, swapStart, duration); it("returns exactly the old model value at swap start (and before)", () => { expect(evaluate(swapStart)).toBe(counterValue(oldModel, swapStart)); expect(evaluate(swapStart - 5_000)).toBe(counterValue(oldModel, swapStart - 5_000)); }); it("returns exactly the new model value at swap start + duration (and after)", () => { const end = swapStart + duration; expect(evaluate(end)).toBe(counterValue(newModel, end)); expect(evaluate(end + 120_000)).toBe(counterValue(newModel, end + 120_000)); }); it("is the arithmetic mean of both models at the midpoint", () => { const mid = swapStart + duration / 2; const expected = (counterValue(oldModel, mid) + counterValue(newModel, mid)) / 2; expect(evaluate(mid)).toBeCloseTo(expected, 9); }); it("blends monotonically from old towards new across the window", () => { const quarter = evaluate(swapStart + duration / 4); const mid = evaluate(swapStart + duration / 2); const threeQuarters = evaluate(swapStart + (3 * duration) / 4); const oldMid = counterValue(oldModel, swapStart + duration / 2); const newMid = counterValue(newModel, swapStart + duration / 2); const lo = Math.min(oldMid, newMid); const hi = Math.max(oldMid, newMid); expect(mid).toBeGreaterThanOrEqual(lo); expect(mid).toBeLessThanOrEqual(hi); // With new > old everywhere here, the blend weight grows with t. expect(quarter).toBeLessThan(mid); expect(mid).toBeLessThan(threeQuarters); }); it("treats a non-positive duration as an instant swap to the new model", () => { const instant = makeSmoothedEvaluator(oldModel, newModel, swapStart, 0); expect(instant(swapStart)).toBe(counterValue(newModel, swapStart)); expect(instant(swapStart - 1)).toBe(counterValue(newModel, swapStart - 1)); }); }); describe("smoothingAlpha / blendValues", () => { const swapStart = 1_000_000; it("clamps alpha to [0, 1]", () => { expect(smoothingAlpha(swapStart - 1, swapStart, 60_000)).toBe(0); expect(smoothingAlpha(swapStart, swapStart, 60_000)).toBe(0); expect(smoothingAlpha(swapStart + 30_000, swapStart, 60_000)).toBe(0.5); expect(smoothingAlpha(swapStart + 60_000, swapStart, 60_000)).toBe(1); expect(smoothingAlpha(swapStart + 90_000, swapStart, 60_000)).toBe(1); }); it("blends already-evaluated values linearly", () => { expect(blendValues(10, 20, swapStart, swapStart, 60_000)).toBe(10); expect(blendValues(10, 20, swapStart + 30_000, swapStart, 60_000)).toBeCloseTo(15, 12); expect(blendValues(10, 20, swapStart + 60_000, swapStart, 60_000)).toBe(20); }); });