1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237 | // THE PRESENTATION LAYER — paging windows, day buckets, account filters and
// the compact row's words, tested in node with no browser in sight.
//
// The design rule under test is one sentence: PAGING IS PRESENTATION, NEVER A
// DATA CAP. The fold keeps every payment forever (§3, retention — a balance is
// a function of all history), `view/history` and `view/ledger` return all of
// it, and what changed at this commit is only how much of that unbounded list
// a screen builds DOM for at once. So every function here must be provably
// unable to lose a row: a page plan accounts for every row as shown-or-hidden,
// a day grouping is a partition that preserves order, and a filter with no
// query is the SAME array.
//
// The second rule: THE STATE WORD NEVER COLLAPSES. Rows render compact now
// (who · state chip · amount) and expand for the sentence and the receipt
// button — but the chip stays on the compact line, because a provisional
// payment that LOOKS done is the exact lie this app exists to prevent
// (docs/PROTOCOL.md §11). The wiring half of that is pinned against the source
// below, the way source-hygiene pins the receipt button's gate.
//
// Runs against test-dist/testlib.js — the built output — never src/ (except
// the two deliberate WIRING pins, which read app/ui.cljs raw exactly as
// test/source-hygiene.test.mjs reads it).
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import * as L from "../test-dist/testlib.js";
const V = JSON.parse(readFileSync(new URL("./vectors/fold.json", import.meta.url), "utf8"));
const A = V.actors;
const BANK = A.bank;
async function snapOf(entries) {
const f = new L.BankFold(BANK, null);
for (const e of structuredClone(entries)) f.ingest(e);
await f.awaitVerified();
return f.snapshot();
}
const scenario = (name) => V.scenarios.find((s) => s.name === name);
const UI_SRC = readFileSync(new URL("../src/banca/app/ui.cljs", import.meta.url), "utf8");
// ---- paging: a window, never a cap -------------------------------------------
test("page-plan accounts for every row — shown or hidden, never gone", () => {
assert.ok(Number.isInteger(L.PAGE_INIT) && L.PAGE_INIT >= 10, "the first page is a real page");
assert.ok(Number.isInteger(L.PAGE_STEP) && L.PAGE_STEP >= L.PAGE_INIT, "a step reveals at least a page");
// the invariants, over the whole shape of the space
for (const total of [0, 1, L.PAGE_INIT - 1, L.PAGE_INIT, L.PAGE_INIT + 1, 65, 1000, 10000]) {
for (const limit of [L.PAGE_INIT, L.PAGE_INIT + L.PAGE_STEP, 500, total]) {
const p = L.pagePlan(total, limit);
assert.equal(p.total, total);
assert.ok(p.show >= Math.min(total, L.PAGE_INIT), "never less than the first page");
assert.ok(p.show <= total, "never more rows than exist");
assert.equal(p.show + p.hidden, total, "every row is shown or counted hidden");
assert.equal(p.next, Math.min(p.hidden, L.PAGE_STEP), "one step reveals a page, or the rest");
}
}
// a fresh screen over a deep history
const p0 = L.pagePlan(1000, L.PAGE_INIT);
assert.deepEqual(p0, { show: L.PAGE_INIT, hidden: 1000 - L.PAGE_INIT, next: L.PAGE_STEP, total: 1000 });
// "show more" grows the window and moves nothing else
const p1 = L.pagePlan(1000, p0.show + p0.next);
assert.equal(p1.show, L.PAGE_INIT + L.PAGE_STEP);
assert.equal(p1.total, 1000);
// "show all" is exactly all — and a limit far beyond the data is still all,
// because the plan windows the RENDER, and there is nothing to cap
assert.deepEqual(L.pagePlan(1000, 1000), { show: 1000, hidden: 0, next: 0, total: 1000 });
assert.equal(L.pagePlan(500, 1e9).show, 500);
// a short list needs no controls
assert.deepEqual(L.pagePlan(3, L.PAGE_INIT), { show: 3, hidden: 0, next: 0, total: 3 });
// the last step can be smaller than a page
assert.equal(L.pagePlan(L.PAGE_INIT + 7, L.PAGE_INIT).next, 7);
});
test("a junk limit still shows the first page — a paging bug must not blank a history", () => {
for (const bad of [null, undefined, -5, NaN, "x", 0]) {
assert.equal(L.pagePlan(100, bad).show, L.PAGE_INIT, `limit ${bad}`);
}
// junk totals answer an empty, consistent plan rather than throwing
for (const bad of [null, undefined, -1, NaN, "x", 1.5]) {
assert.deepEqual(L.pagePlan(bad, L.PAGE_INIT), { show: 0, hidden: 0, next: 0, total: 0 }, `total ${bad}`);
}
});
// ---- day buckets --------------------------------------------------------------
test("day-key is the local calendar day, and junk is undated rather than mis-dated", () => {
// npm test runs under TZ=UTC, but the assertion is computed the same way the
// function computes — local components — so it holds in any zone
const ts = Date.UTC(2026, 7, 30, 12, 0, 0);
const d = new Date(ts);
const want = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
assert.equal(L.dayKey(ts), want);
assert.notEqual(L.dayKey(ts), L.dayKey(ts + 24 * 3600 * 1000), "a day apart is a different bucket");
for (const junk of [null, undefined, 0, -5, NaN, "2026", {}, Infinity]) {
assert.equal(L.dayKey(junk), null, `dayKey(${String(junk)})`);
}
});
test("group-days is a partition: order preserved, nothing dropped, nothing invented", () => {
const D1 = Date.UTC(2026, 7, 30, 10);
const D1b = Date.UTC(2026, 7, 30, 8);
const D2 = Date.UTC(2026, 7, 29, 22);
const rows = [{ h: "a" }, { h: "b" }, { h: "c" }, { h: "d" }, { h: "e" }];
const ts = { a: D1, b: D1b, c: D2, e: D2 }; // d is undated on purpose
const groups = L.groupDays(rows, (h) => ts[h]);
assert.deepEqual(
groups.map((g) => [g.day, g.rows.map((r) => r.h)]),
[
[L.dayKey(D1), ["a", "b"]], // same local day, consecutive → one bucket
[L.dayKey(D2), ["c"]],
[null, ["d"]], // undated: no header, never a made-up one
[L.dayKey(D2), ["e"]], // NOT merged across the undated run — order wins
],
);
assert.equal(groups[0].ts, D1, "the header timestamp is the first row's");
// the partition property, said outright
assert.deepEqual(groups.flatMap((g) => g.rows.map((r) => r.h)), rows.map((r) => r.h));
assert.deepEqual(L.groupDays([], () => null), []);
});
test("grouping a real fold history preserves it row for row", async () => {
const sc = scenario("the-banker-pays-out-of-their-own-bank");
const s = await snapOf(sc.entries);
const rows = L.historyOf(s, BANK);
assert.ok(rows.length > 0, "the fixture really has rows");
const tsOf = new Map(sc.entries.map((e) => [e.hash, e.op?.ts]));
const groups = L.groupDays(rows, (h) => tsOf.get(h));
assert.deepEqual(groups.flatMap((g) => g.rows), rows, "the same rows, in the same order");
});
// ---- account filters -----------------------------------------------------------
test("the account filter matches name or key, ignores case, and cannot hide the bank", () => {
assert.ok(Number.isInteger(L.FILTER_THRESHOLD) && L.FILTER_THRESHOLD > 1,
"the threshold is a real list length");
const accounts = [
{ id: "KeyAAA111", name: "Ana" },
{ id: "KeyBBB222", name: "Barbu" },
{ id: "KeyCCC333", name: "" }, // an account that never chose a name
];
// no usable query: the SAME array — not a copy, not a reorder, not a cap
assert.equal(L.filterAccounts(accounts, ""), accounts);
assert.equal(L.filterAccounts(accounts, " "), accounts);
assert.equal(L.filterAccounts(accounts, null), accounts);
assert.equal(L.accountMatches("", accounts[0]), true);
// by name, case-insensitively
assert.deepEqual(L.filterAccounts(accounts, "ana").map((a) => a.name), ["Ana"]);
assert.deepEqual(L.filterAccounts(accounts, "BARBU").map((a) => a.name), ["Barbu"]);
// a nameless account is still findable — by its key
assert.deepEqual(L.filterAccounts(accounts, "ccc333").map((a) => a.id), ["KeyCCC333"]);
assert.deepEqual(L.filterAccounts(accounts, "keyb").map((a) => a.id), ["KeyBBB222"]);
// nobody matched is an empty list, not an error
assert.deepEqual(L.filterAccounts(accounts, "zzz"), []);
});
// ---- the compact row's words ---------------------------------------------------
test("who-desc chooses each row's sentence exactly as the screen once did, over every scenario", async () => {
// the cond that lived inline in app/ui.cljs, now pure — mirrored here so a
// regression in either direction is a named row in a named scenario
const KEYS = ["pay.mint", "pay.mint.to", "pay.burn", "pay.in", "pay.out", "pay.between"];
const seen = new Set();
for (const sc of V.scenarios) {
const s = await snapOf(sc.entries);
for (const viewer of [BANK, A.payer, A.payee, null]) {
for (const r of [...L.historyOf(s, viewer), ...L.ledgerOf(s, viewer)]) {
const want =
r.kind === "mint" && r.dir === "in" ? "pay.mint"
: r.kind === "mint" ? "pay.mint.to"
: r.kind === "burn" ? "pay.burn"
: r.dir === "in" ? "pay.in"
: r.dir === "out" ? "pay.out"
: "pay.between";
const d = L.whoDesc(s, r);
assert.equal(d.k, want, `${sc.name}/${r.h} viewed by ${viewer ?? "an outsider"}`);
seen.add(d.k);
// every slot the EN template carries is filled — no sentence with a hole
for (const m of L.catalogValue("en", d.k).matchAll(/\{(\w+)\}/g)) {
const v = d.vars ? d.vars[m[1]] : undefined;
assert.ok(typeof v === "string" && v.length > 0, `${d.k} leaves {${m[1]}} unfilled`);
}
}
}
}
for (const k of KEYS) assert.ok(seen.has(k), `${k} never produced — the corpus stopped covering it`);
for (const lang of ["en", "ro", "hu"]) {
for (const k of KEYS) assert.ok(L.catalogValue(lang, k), `${lang}/${k}`);
}
});
// ---- the strings the new screens ask for ---------------------------------------
test("every string the presentation layer can ask for exists, in all three languages", () => {
const keys = ["hist.more", "hist.all", "filter.accounts.ph", "filter.none",
"sec.send.summary", "sec.issue.summary", "sec.burn.summary", "sec.rate.summary",
"invite.read-note"];
for (const lang of ["en", "ro", "hu"]) {
for (const k of keys) assert.ok(L.catalogValue(lang, k) !== null, `${lang}/${k}`);
// "show N more" is a count and pluralizes; the rest are sentences
assert.equal(typeof L.catalogValue(lang, "hist.more"), "object", `${lang}/hist.more must be plural`);
assert.ok(L.catalogValue(lang, "hist.all").includes("{n}"), `${lang}/hist.all names the count`);
}
});
// ---- the two wiring pins -------------------------------------------------------
test("the invite sheet says reading was never gated — beside the link, before it is handed out", () => {
// requirement 7: the ledger is member-visible before approval (reading is
// gated by the LINK, transacting by approval). invite.body already says the
// link replicates everything; this line says approval takes none of that
// back — and it has to sit where the link is surfaced, or it is a spec note.
assert.ok(UI_SRC.includes('(t "invite.read-note")'),
"show-link! must render invite.read-note next to the link");
for (const lang of ["en", "ro", "hu"]) {
const v = L.catalogValue(lang, "invite.read-note");
assert.ok(typeof v === "string" && v.length > 60,
`${lang}/invite.read-note is too short to be saying anything`);
}
});
test("the STATE chip sits on the compact head line — the word never collapses", () => {
// requirement 6: a compact row must never read as done when it is not. The
// chip is appended into the HEAD (always visible); only pay-detail collapses.
assert.match(UI_SRC,
/"pay-head"\)\s*\(dom\/txt "span" "pay-who" who\)\s*chip\s*\(dom\/txt "span" "pay-amt"/,
"the chip must sit between who and amount on the always-visible head line");
assert.ok(!UI_SRC.includes("(dom/add! note chip)"),
"the chip must not live in a region that can collapse");
// and the screens page through the pure plan rather than slicing on their own
for (const call of ["view/page-plan", "view/group-days", "view/filter-accounts", "view/who-desc"]) {
assert.ok(UI_SRC.includes(call), `app/ui.cljs must go through ${call}`);
}
});
|