| 1 | // Tests for the funding waterfall (docs/funding.md). node test/waterfall.test.mjs |
| 2 | // The pinned vectors in test/waterfall-vectors.json are the spec: a failure |
| 3 | // means the implementation drifted from docs/funding.md, and the fix is the |
| 4 | // code (or a deliberate new algorithm version), never the vector. |
| 5 | import assert from "node:assert/strict"; |
| 6 | import { readFileSync } from "node:fs"; |
| 7 | import { waterfall, applyQuarter, splitSurplus, monthsBetween, SPLIT } from "../tools/waterfall.mjs"; |
| 8 | |
| 9 | let failures = 0; |
| 10 | async function test(name, fn) { |
| 11 | try { |
| 12 | await fn(); |
| 13 | console.log("PASS " + name); |
| 14 | } catch (e) { |
| 15 | console.log("FAIL " + name + " — " + (e && e.message)); |
| 16 | failures++; |
| 17 | } |
| 18 | } |
| 19 | |
| 20 | const { vectors } = JSON.parse(readFileSync(new URL("./waterfall-vectors.json", import.meta.url), "utf8")); |
| 21 | |
| 22 | for (const v of vectors) { |
| 23 | await test("vector: " + v.name, () => { |
| 24 | const result = waterfall(v.ledger, v.quarter); |
| 25 | for (const [key, want] of Object.entries(v.expected)) { |
| 26 | assert.deepEqual(result[key], want, key); |
| 27 | } |
| 28 | }); |
| 29 | } |
| 30 | |
| 31 | // Every distributed cent must be accounted for — nothing leaks, nothing is |
| 32 | // minted. This holds for every vector, not just the hand-picked ones. |
| 33 | await test("conservation: pools sum to D; investor pool = payments + retained + residue", () => { |
| 34 | for (const v of vectors) { |
| 35 | const r = waterfall(v.ledger, v.quarter); |
| 36 | if (!r.distributed) { |
| 37 | assert.equal(r.payments.length, 0, v.name); |
| 38 | continue; |
| 39 | } |
| 40 | assert.equal(r.investorPool + r.teamPool + r.commonsPool, r.distributable, v.name); |
| 41 | const paid = r.payments.reduce((s, p) => s + p.amount, 0); |
| 42 | assert.equal(paid + r.retained + r.residue, r.investorPool, v.name); |
| 43 | } |
| 44 | }); |
| 45 | |
| 46 | await test("determinism: identical inputs produce identical outputs", () => { |
| 47 | for (const v of vectors) { |
| 48 | assert.deepEqual(waterfall(v.ledger, v.quarter), waterfall(v.ledger, v.quarter), v.name); |
| 49 | } |
| 50 | }); |
| 51 | |
| 52 | await test("waterfall never mutates the ledger it is given", () => { |
| 53 | const v = vectors[0]; |
| 54 | const before = JSON.stringify(v.ledger); |
| 55 | waterfall(v.ledger, v.quarter); |
| 56 | assert.equal(JSON.stringify(v.ledger), before); |
| 57 | }); |
| 58 | |
| 59 | await test("applyQuarter folds the result back into a publishable ledger", () => { |
| 60 | const v = vectors[0]; // the doc's worked example |
| 61 | const { result, ledger } = applyQuarter(v.ledger, v.quarter); |
| 62 | assert.deepEqual( |
| 63 | ledger.notes.map((n) => ({ id: n.id, remainingCap: n.remainingCap })), |
| 64 | result.notes, |
| 65 | ); |
| 66 | const q = ledger.quarters.at(-1); |
| 67 | assert.equal(q.id, v.quarter.id); |
| 68 | assert.equal(q.floorMet, true); |
| 69 | assert.deepEqual(q.payments, result.payments); |
| 70 | // The fold keeps everything an auditor needs to re-run the quarter. |
| 71 | assert.equal(q.revenue, v.quarter.revenue); |
| 72 | assert.equal(q.investorPool, result.investorPool); |
| 73 | }); |
| 74 | |
| 75 | await test("splitSurplus: shares match SPLIT and rounding favours the commons", () => { |
| 76 | assert.deepEqual(SPLIT, { investors: 60, team: 25, commons: 15 }); |
| 77 | for (const D of [1, 2, 3, 99, 100, 10001, 800000]) { |
| 78 | const { investors, team, commons } = splitSurplus(D); |
| 79 | assert.equal(investors + team + commons, D, "D=" + D); |
| 80 | assert.ok(commons >= Math.floor((D * SPLIT.commons) / 100), "D=" + D); |
| 81 | } |
| 82 | }); |
| 83 | |
| 84 | await test("monthsBetween counts whole calendar months", () => { |
| 85 | assert.equal(monthsBetween("2026-09-30", "2028-09-29"), 23); |
| 86 | assert.equal(monthsBetween("2026-09-30", "2028-09-30"), 24); |
| 87 | assert.equal(monthsBetween("2026-09-30", "2028-12-31"), 27); |
| 88 | assert.equal(monthsBetween("2026-01-31", "2026-02-28"), 0); |
| 89 | }); |
| 90 | |
| 91 | // The implementation lives in web/status/waterfall.js (served by the public |
| 92 | // Investing page, which imports and runs it); tools/waterfall.mjs is the |
| 93 | // documented Node-side path. The shim must expose the full surface — this |
| 94 | // file imports through it, and here we pin that nothing is lost. |
| 95 | await test("tools/waterfall.mjs re-exports the served module's whole surface", async () => { |
| 96 | const served = await import("../web/status/waterfall.js"); |
| 97 | const shim = await import("../tools/waterfall.mjs"); |
| 98 | assert.deepEqual(Object.keys(shim).sort(), Object.keys(served).sort()); |
| 99 | }); |
| 100 | |
| 101 | // The committed public ledger (docs/funding.md § The ledger): the Investing |
| 102 | // page renders whatever this holds, so its shape is a contract. |
| 103 | await test("web/status/funding.json holds the ledger shape the page reads", () => { |
| 104 | const d = JSON.parse(readFileSync(new URL("../web/status/funding.json", import.meta.url), "utf8")); |
| 105 | assert.equal(typeof d.as_of, "string"); |
| 106 | assert.equal(typeof d.adopted, "boolean"); |
| 107 | for (const k of ["series", "notes", "quarters"]) assert.ok(Array.isArray(d[k]), k); |
| 108 | // Until adoption, the ledger must stay empty — no notes may exist before |
| 109 | // the model passes review and a series deliberately opens. |
| 110 | if (!d.adopted) for (const k of ["series", "notes", "quarters"]) assert.equal(d[k].length, 0, k); |
| 111 | }); |
| 112 | |
| 113 | // The Investing page must run the served module and read the committed |
| 114 | // ledger — never carry a copy of either. The nav is the SHARED builder |
| 115 | // (status-nav.js, #1222): pages declare their identity via data-title and |
| 116 | // the builder marks the current pill at runtime — no static nav markup. |
| 117 | await test("funding.html imports ./waterfall.js and fetches ./funding.json", () => { |
| 118 | const html = readFileSync(new URL("../web/status/funding.html", import.meta.url), "utf8"); |
| 119 | assert.ok(html.includes('from "./waterfall.js"')); |
| 120 | assert.ok(html.includes('fetch("./funding.json"')); |
| 121 | assert.ok(html.includes('data-title="Investing"')); |
| 122 | assert.ok(html.includes('src="./status-nav.js"')); |
| 123 | }); |
| 124 | |
| 125 | // Every status page mounts the shared nav, and the nav carries the |
| 126 | // Investing tab — the one place the link lives now (#1222). |
| 127 | await test("all status pages carry the Investing nav link", () => { |
| 128 | const nav = readFileSync(new URL("../web/status/status-nav.js", import.meta.url), "utf8"); |
| 129 | assert.ok(nav.includes('"funding.html"') || nav.includes('"./funding.html"')); |
| 130 | assert.ok(nav.includes('"Investing"')); |
| 131 | for (const page of ["index", "books", "economics", "carbon", "contributors", |
| 132 | "security", "prompts", "ssh", "hardening", "milestones"]) { |
| 133 | const html = readFileSync(new URL("../web/status/" + page + ".html", import.meta.url), "utf8"); |
| 134 | assert.ok(html.includes('src="./status-nav.js"'), page); |
| 135 | } |
| 136 | }); |
| 137 | |
| 138 | console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| 139 | process.exit(failures === 0 ? 0 : 1); |
| 140 | |