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/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