SPB Git

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%
6.8 KB · 203 lines typescript
Raw Blame History
1/**2 * earth-now.co3 * Author:  Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File:    apps/web/test/derived.test.ts6 * Purpose: Tests for the pure derived-metric transforms — primary window, depletion countdown, model resolution, display hints, raw values7 */89import { describe, expect, it } from "vitest";10import {11  counterValue,12  rateAt,13  startOfUtcYear,14  type CounterModel,15} from "@earth-now/counter";16import type { MetricSummary } from "../lib/api";17import {18  depletionYears,19  displayRawValue,20  hintsFor,21  isEstimate,22  resolveModel,23  resolvePrimaryWindow,24  YEAR_SECONDS,25} from "../lib/derived";2627const ANCHOR = "2026-01-01T00:00:00.000Z";2829function makeMetric(overrides: Partial<MetricSummary>): MetricSummary {30  return {31    id: "test_metric",32    name: { fr: "Métrique test", en: "Test metric" },33    domain: "climate",34    priority: "mvp",35    kind: "stock",36    level: 0,37    model: "linear-stock-v1",38    unit: "u",39    sources: [40      {41        id: "src",42        name: "Test source",43        url: "https://example.org",44        license: "CC BY 4.0",45        cadence: "yearly",46      },47    ],48    display: { decimals: 0, unit: { fr: "unités", en: "units" } },49    windows: ["total"],50    stale: false,51    ...overrides,52  };53}5455function linearModel(anchorValue: number, perSecond: number, metricId = "test_metric"): CounterModel {56  return {57    metricId,58    anchorValue,59    anchorTime: ANCHOR,60    rateFn: { kind: "linear", perSecond },61    observedAt: "2025-12-31T00:00:00.000Z",62    sourceId: "src",63    modelVersion: "linear-test-v1",64    displayHints: { decimals: 0, unit: "u" },65  };66}6768describe("resolvePrimaryWindow", () => {69  it("uses the derived window when op=window", () => {70    const metric = makeMetric({71      kind: "derived",72      derived: { op: "window", inputs: [{ id: "births_ytd", weight: 1 }], window: "today" },73    });74    expect(resolvePrimaryWindow(metric)).toBe("today");75  });7677  it("honors the registry's declared window priority (windows[0])", () => {78    expect(79      resolvePrimaryWindow(makeMetric({ kind: "cumulative", windows: ["today", "ytd"] })),80    ).toBe("today");81    expect(82      resolvePrimaryWindow(makeMetric({ kind: "cumulative", windows: ["ytd", "today"] })),83    ).toBe("ytd");84  });8586  it("falls back to ytd for cumulatives and total otherwise when nothing is declared", () => {87    expect(resolvePrimaryWindow(makeMetric({ kind: "cumulative", windows: [] }))).toBe("ytd");88    expect(resolvePrimaryWindow(makeMetric({ kind: "stock" }))).toBe("total");89    expect(resolvePrimaryWindow(makeMetric({ kind: "event" }))).toBe("total");90  });91});9293describe("depletionYears", () => {94  it("computes remaining / (−rate × year) exactly for a linear model", () => {95    // Remaining stock worth exactly 2 years at 1 unit/s.96    const model = linearModel(2 * YEAR_SECONDS, -1);97    expect(depletionYears(model, Date.parse(ANCHOR))).toBe(2);98  });99100  it("shrinks as the stock depletes", () => {101    const model = linearModel(2 * YEAR_SECONDS, -1);102    const later = Date.parse(ANCHOR) + 1_000 * YEAR_SECONDS; // one year later103    expect(depletionYears(model, later)).toBeCloseTo(1, 9);104  });105106  it("returns NaN when the stock is not depleting (guardrail: no absurd countdown)", () => {107    expect(depletionYears(linearModel(1_000, 1), Date.parse(ANCHOR))).toBeNaN();108    expect(depletionYears(linearModel(1_000, 0), Date.parse(ANCHOR))).toBeNaN();109  });110});111112describe("resolveModel", () => {113  const own = linearModel(1, 1, "own_metric");114  const input = linearModel(2, 2, "input_metric");115  const models = { own_metric: own, input_metric: input };116117  it("prefers the metric's own model", () => {118    const metric = makeMetric({119      id: "own_metric",120      derived: { op: "rate-of", inputs: [{ id: "input_metric", weight: 1 }] },121    });122    expect(resolveModel(metric, models)).toBe(own);123  });124125  it("falls back to the first derived input's model", () => {126    const metric = makeMetric({127      id: "not_materialized",128      derived: { op: "depletion-countdown", inputs: [{ id: "input_metric", weight: 1 }] },129    });130    expect(resolveModel(metric, models)).toBe(input);131  });132133  it("returns undefined when nothing resolves", () => {134    expect(resolveModel(makeMetric({ id: "missing" }), models)).toBeUndefined();135  });136});137138describe("displayRawValue", () => {139  const t = Date.parse("2026-08-09T12:00:00.000Z");140141  it("returns the instantaneous rate for rate-of metrics", () => {142    const metric = makeMetric({143      derived: { op: "rate-of", inputs: [{ id: "x", weight: 1 }] },144    });145    const model = linearModel(0, 42);146    expect(displayRawValue(metric, model, t, "total")).toBe(rateAt(model, t));147    expect(displayRawValue(metric, model, t, "total")).toBe(42);148  });149150  it("returns depletion years for depletion-countdown metrics", () => {151    const metric = makeMetric({152      derived: { op: "depletion-countdown", inputs: [{ id: "x", weight: 1 }] },153    });154    const model = linearModel(2 * YEAR_SECONDS, -1);155    expect(displayRawValue(metric, model, Date.parse(ANCHOR), "total")).toBe(2);156  });157158  it("computes window values from the same model (ytd = v(t) − v(Jan 1 UTC))", () => {159    const metric = makeMetric({ kind: "cumulative" });160    const model = linearModel(1_000_000, 3);161    const expected = counterValue(model, t) - counterValue(model, startOfUtcYear(t));162    expect(displayRawValue(metric, model, t, "ytd")).toBe(expected);163  });164165  it("returns NaN (not a throw) for a session window without a session start", () => {166    const metric = makeMetric({ kind: "cumulative" });167    expect(displayRawValue(metric, linearModel(0, 1), t, "session")).toBeNaN();168  });169170  it("computes session values from the arrival instant", () => {171    const metric = makeMetric({ kind: "cumulative" });172    const model = linearModel(0, 2);173    const sessionStart = t - 30_000; // arrived 30 s ago at 2/s → 60174    expect(displayRawValue(metric, model, t, "session", sessionStart)).toBe(60);175  });176});177178describe("hintsFor / isEstimate", () => {179  it("localizes the unit and forwards optional sigFigs/scale", () => {180    const metric = makeMetric({181      display: { decimals: 1, sigFigs: 3, scale: 1e-9, unit: { fr: "Gt CO₂", en: "Gt CO2" } },182    });183    expect(hintsFor(metric, "fr")).toEqual({184      decimals: 1,185      sigFigs: 3,186      scale: 1e-9,187      unit: "Gt CO₂",188    });189    expect(hintsFor(metric, "en").unit).toBe("Gt CO2");190  });191192  it("omits sigFigs/scale keys entirely when the registry does not set them", () => {193    const hints = hintsFor(makeMetric({}), "fr");194    expect("sigFigs" in hints).toBe(false);195    expect("scale" in hints).toBe(false);196  });197198  it("flags the mandatory estimate label if and only if uncertaintyFraction is set", () => {199    expect(isEstimate(makeMetric({}))).toBe(false);200    expect(isEstimate(makeMetric({ uncertaintyFraction: 0.5 }))).toBe(true);201  });202});203