/** * earth-now.co * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: apps/web/test/derived.test.ts * Purpose: Tests for the pure derived-metric transforms — primary window, depletion countdown, model resolution, display hints, raw values */ import { describe, expect, it } from "vitest"; import { counterValue, rateAt, startOfUtcYear, type CounterModel, } from "@earth-now/counter"; import type { MetricSummary } from "../lib/api"; import { depletionYears, displayRawValue, hintsFor, isEstimate, resolveModel, resolvePrimaryWindow, YEAR_SECONDS, } from "../lib/derived"; const ANCHOR = "2026-01-01T00:00:00.000Z"; function makeMetric(overrides: Partial): MetricSummary { return { id: "test_metric", name: { fr: "Métrique test", en: "Test metric" }, domain: "climate", priority: "mvp", kind: "stock", level: 0, model: "linear-stock-v1", unit: "u", sources: [ { id: "src", name: "Test source", url: "https://example.org", license: "CC BY 4.0", cadence: "yearly", }, ], display: { decimals: 0, unit: { fr: "unités", en: "units" } }, windows: ["total"], stale: false, ...overrides, }; } function linearModel(anchorValue: number, perSecond: number, metricId = "test_metric"): CounterModel { return { metricId, anchorValue, anchorTime: ANCHOR, rateFn: { kind: "linear", perSecond }, observedAt: "2025-12-31T00:00:00.000Z", sourceId: "src", modelVersion: "linear-test-v1", displayHints: { decimals: 0, unit: "u" }, }; } describe("resolvePrimaryWindow", () => { it("uses the derived window when op=window", () => { const metric = makeMetric({ kind: "derived", derived: { op: "window", inputs: [{ id: "births_ytd", weight: 1 }], window: "today" }, }); expect(resolvePrimaryWindow(metric)).toBe("today"); }); it("honors the registry's declared window priority (windows[0])", () => { expect( resolvePrimaryWindow(makeMetric({ kind: "cumulative", windows: ["today", "ytd"] })), ).toBe("today"); expect( resolvePrimaryWindow(makeMetric({ kind: "cumulative", windows: ["ytd", "today"] })), ).toBe("ytd"); }); it("falls back to ytd for cumulatives and total otherwise when nothing is declared", () => { expect(resolvePrimaryWindow(makeMetric({ kind: "cumulative", windows: [] }))).toBe("ytd"); expect(resolvePrimaryWindow(makeMetric({ kind: "stock" }))).toBe("total"); expect(resolvePrimaryWindow(makeMetric({ kind: "event" }))).toBe("total"); }); }); describe("depletionYears", () => { it("computes remaining / (−rate × year) exactly for a linear model", () => { // Remaining stock worth exactly 2 years at 1 unit/s. const model = linearModel(2 * YEAR_SECONDS, -1); expect(depletionYears(model, Date.parse(ANCHOR))).toBe(2); }); it("shrinks as the stock depletes", () => { const model = linearModel(2 * YEAR_SECONDS, -1); const later = Date.parse(ANCHOR) + 1_000 * YEAR_SECONDS; // one year later expect(depletionYears(model, later)).toBeCloseTo(1, 9); }); it("returns NaN when the stock is not depleting (guardrail: no absurd countdown)", () => { expect(depletionYears(linearModel(1_000, 1), Date.parse(ANCHOR))).toBeNaN(); expect(depletionYears(linearModel(1_000, 0), Date.parse(ANCHOR))).toBeNaN(); }); }); describe("resolveModel", () => { const own = linearModel(1, 1, "own_metric"); const input = linearModel(2, 2, "input_metric"); const models = { own_metric: own, input_metric: input }; it("prefers the metric's own model", () => { const metric = makeMetric({ id: "own_metric", derived: { op: "rate-of", inputs: [{ id: "input_metric", weight: 1 }] }, }); expect(resolveModel(metric, models)).toBe(own); }); it("falls back to the first derived input's model", () => { const metric = makeMetric({ id: "not_materialized", derived: { op: "depletion-countdown", inputs: [{ id: "input_metric", weight: 1 }] }, }); expect(resolveModel(metric, models)).toBe(input); }); it("returns undefined when nothing resolves", () => { expect(resolveModel(makeMetric({ id: "missing" }), models)).toBeUndefined(); }); }); describe("displayRawValue", () => { const t = Date.parse("2026-08-09T12:00:00.000Z"); it("returns the instantaneous rate for rate-of metrics", () => { const metric = makeMetric({ derived: { op: "rate-of", inputs: [{ id: "x", weight: 1 }] }, }); const model = linearModel(0, 42); expect(displayRawValue(metric, model, t, "total")).toBe(rateAt(model, t)); expect(displayRawValue(metric, model, t, "total")).toBe(42); }); it("returns depletion years for depletion-countdown metrics", () => { const metric = makeMetric({ derived: { op: "depletion-countdown", inputs: [{ id: "x", weight: 1 }] }, }); const model = linearModel(2 * YEAR_SECONDS, -1); expect(displayRawValue(metric, model, Date.parse(ANCHOR), "total")).toBe(2); }); it("computes window values from the same model (ytd = v(t) − v(Jan 1 UTC))", () => { const metric = makeMetric({ kind: "cumulative" }); const model = linearModel(1_000_000, 3); const expected = counterValue(model, t) - counterValue(model, startOfUtcYear(t)); expect(displayRawValue(metric, model, t, "ytd")).toBe(expected); }); it("returns NaN (not a throw) for a session window without a session start", () => { const metric = makeMetric({ kind: "cumulative" }); expect(displayRawValue(metric, linearModel(0, 1), t, "session")).toBeNaN(); }); it("computes session values from the arrival instant", () => { const metric = makeMetric({ kind: "cumulative" }); const model = linearModel(0, 2); const sessionStart = t - 30_000; // arrived 30 s ago at 2/s → 60 expect(displayRawValue(metric, model, t, "session", sessionStart)).toBe(60); }); }); describe("hintsFor / isEstimate", () => { it("localizes the unit and forwards optional sigFigs/scale", () => { const metric = makeMetric({ display: { decimals: 1, sigFigs: 3, scale: 1e-9, unit: { fr: "Gt CO₂", en: "Gt CO2" } }, }); expect(hintsFor(metric, "fr")).toEqual({ decimals: 1, sigFigs: 3, scale: 1e-9, unit: "Gt CO₂", }); expect(hintsFor(metric, "en").unit).toBe("Gt CO2"); }); it("omits sigFigs/scale keys entirely when the registry does not set them", () => { const hints = hintsFor(makeMetric({}), "fr"); expect("sigFigs" in hints).toBe(false); expect("scale" in hints).toBe(false); }); it("flags the mandatory estimate label if and only if uncertaintyFraction is set", () => { expect(isEstimate(makeMetric({}))).toBe(false); expect(isEstimate(makeMetric({ uncertaintyFraction: 0.5 }))).toBe(true); }); });