spb/earth-now Public License
earth-now.co — real-time planetary dashboard: live world metrics modeled, not streamed.
TypeScript 93%
Shell 2.3%
SQL 1.4%
JavaScript 1.3%
Dockerfile 1.2%
CSS 0.8%
1/**2 * earth-now.co3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File: apps/web/test/smoothing.test.ts6 * Purpose: Tests for the 60 s model-swap smoothing — exact old value at swap start, exact new value at swap end, linear blend in between7 */89import { describe, expect, it } from "vitest";10import { counterValue, type CounterModel } from "@earth-now/counter";11import {12 blendValues,13 makeSmoothedEvaluator,14 SMOOTHING_DURATION_MS,15 smoothingAlpha,16} from "../lib/smoothing";1718const ANCHOR = "2026-01-01T00:00:00.000Z";1920function linearModel(anchorValue: number, perSecond: number): CounterModel {21 return {22 metricId: "test_metric",23 anchorValue,24 anchorTime: ANCHOR,25 rateFn: { kind: "linear", perSecond },26 observedAt: "2025-12-31T00:00:00.000Z",27 sourceId: "test_source",28 modelVersion: "linear-test-v1",29 displayHints: { decimals: 0, unit: "u" },30 };31}3233describe("makeSmoothedEvaluator", () => {34 const oldModel = linearModel(1_000, 1);35 const newModel = linearModel(1_600, 2);36 const swapStart = Date.UTC(2026, 0, 2, 12, 0, 0);37 const duration = SMOOTHING_DURATION_MS;38 const evaluate = makeSmoothedEvaluator(oldModel, newModel, swapStart, duration);3940 it("returns exactly the old model value at swap start (and before)", () => {41 expect(evaluate(swapStart)).toBe(counterValue(oldModel, swapStart));42 expect(evaluate(swapStart - 5_000)).toBe(counterValue(oldModel, swapStart - 5_000));43 });4445 it("returns exactly the new model value at swap start + duration (and after)", () => {46 const end = swapStart + duration;47 expect(evaluate(end)).toBe(counterValue(newModel, end));48 expect(evaluate(end + 120_000)).toBe(counterValue(newModel, end + 120_000));49 });5051 it("is the arithmetic mean of both models at the midpoint", () => {52 const mid = swapStart + duration / 2;53 const expected = (counterValue(oldModel, mid) + counterValue(newModel, mid)) / 2;54 expect(evaluate(mid)).toBeCloseTo(expected, 9);55 });5657 it("blends monotonically from old towards new across the window", () => {58 const quarter = evaluate(swapStart + duration / 4);59 const mid = evaluate(swapStart + duration / 2);60 const threeQuarters = evaluate(swapStart + (3 * duration) / 4);61 const oldMid = counterValue(oldModel, swapStart + duration / 2);62 const newMid = counterValue(newModel, swapStart + duration / 2);63 const lo = Math.min(oldMid, newMid);64 const hi = Math.max(oldMid, newMid);65 expect(mid).toBeGreaterThanOrEqual(lo);66 expect(mid).toBeLessThanOrEqual(hi);67 // With new > old everywhere here, the blend weight grows with t.68 expect(quarter).toBeLessThan(mid);69 expect(mid).toBeLessThan(threeQuarters);70 });7172 it("treats a non-positive duration as an instant swap to the new model", () => {73 const instant = makeSmoothedEvaluator(oldModel, newModel, swapStart, 0);74 expect(instant(swapStart)).toBe(counterValue(newModel, swapStart));75 expect(instant(swapStart - 1)).toBe(counterValue(newModel, swapStart - 1));76 });77});7879describe("smoothingAlpha / blendValues", () => {80 const swapStart = 1_000_000;8182 it("clamps alpha to [0, 1]", () => {83 expect(smoothingAlpha(swapStart - 1, swapStart, 60_000)).toBe(0);84 expect(smoothingAlpha(swapStart, swapStart, 60_000)).toBe(0);85 expect(smoothingAlpha(swapStart + 30_000, swapStart, 60_000)).toBe(0.5);86 expect(smoothingAlpha(swapStart + 60_000, swapStart, 60_000)).toBe(1);87 expect(smoothingAlpha(swapStart + 90_000, swapStart, 60_000)).toBe(1);88 });8990 it("blends already-evaluated values linearly", () => {91 expect(blendValues(10, 20, swapStart, swapStart, 60_000)).toBe(10);92 expect(blendValues(10, 20, swapStart + 30_000, swapStart, 60_000)).toBeCloseTo(15, 12);93 expect(blendValues(10, 20, swapStart + 60_000, swapStart, 60_000)).toBe(20);94 });95});96