Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_spendcats.mjs

17.3 KiB, 1 run

created by r2519314175:687, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// verify_spendcats.mjs — the Spending page can name every category the gateway
2// can charge under, in every language.
3//
4// This exists because of a defect that has now happened twice from one cause: a
5// list of spend categories written out by hand in one place and extended in
6// another. `infer` became a category in the gateway on 2026-07-17 and `storage`
7// on 2026-07-21. The operator console's consumption chart named four and drew
8// neither for three weeks; `www/js/spend.js` named eight and showed a user the
9// bare tokens "search", "storage" and "infer" beside their own money, in all
10// eight languages, because `catLabel` fell back to whatever string the gateway
11// sent.
12//
13// So the check is deliberately NOT "the seven strings we have today exist" --
14// that check would have passed on 17 July and every day since. It derives the
15// truth from the gateway and fails when the page has fallen behind it.
16//
17// Two halves.
18//
19// STATIC (no browser, no gateway, no server). Parses `LedgerEntry::category()`
20// in gateway/src/schema.rs -- the function itself, not the constant beside it,
21// because a new category is BORN in that match and the constant is one more
22// hand-written copy that can go stale. From it:
23//
24// * every string `category()` can return, split into metered spends (the
25// reference-prefix arms) and credit-side movements (the kind arms);
26// * `SPEND_CATEGORIES` must name exactly the metered set -- this is the check
27// that would have caught 17 July, had the constant existed;
28// * `CATS` in www/js/spend.js must contain every one of them, and invent none;
29// * `spend.cat_<name>` must exist in ALL EIGHT locales, non-empty, and not be
30// the raw token wearing a translation's clothes.
31//
32// BROWSER. Drives the real Spending panel with a stubbed `/api/ledger` holding
33// one movement per category plus one category the build cannot know, switches
34// through all eight locales, and reads the rendered labels back. A key present
35// in a table but not reaching the screen, or reaching it with a stray space or
36// an unfilled placeholder, is only visible here. It also proves the fallback is
37// loud: an unnameable category draws `spend.cat_unlisted` and reports the token
38// to the console, rather than printing it at the user.
39//
40// node dev/verify_spendcats.mjs # both halves
41// node dev/verify_spendcats.mjs --static # the drift check alone
42//
43// `DAIMOND_TREE` points the STATIC half at another checkout of this app, which
44// is how the check was proved red against the code as it stood before the fix.
45// The browser half always drives whatever the dev server is serving.
46//
47// The browser half needs dev/serve.mjs (DAIMOND_PORT) and the mock provider; no
48// gateway, and no model is asked to think.
49import fs from 'node:fs';
50import path from 'node:path';
51import { fileURLToPath } from 'node:url';
52
53const HERE = path.dirname(fileURLToPath(import.meta.url));
54const TREE = process.env.DAIMOND_TREE || path.join(HERE, '..');
55const STATIC = process.argv.includes('--static');
56
57const ok = [], bad = [];
58const check = (name, pass, detail) => {
59 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
60 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
61};
62const setEq = (a, b) => a.length === b.length && a.every(x => b.includes(x));
63
64// ── The gateway, read as the authority ──────────────────────────────
65
66const RUST = fs.readFileSync(path.join(TREE, 'gateway/src/schema.rs'), 'utf8');
67
68/// The body of `LedgerEntry::category()`, from its signature to the brace that
69/// closes it. Every inner brace is indented deeper than the four spaces the
70/// function's own closing brace sits at, so the first `\n }` after the
71/// signature is the end of it.
72function categoryBody(src) {
73 const i = src.indexOf('pub fn category(&self)');
74 if (i < 0) return null;
75 const j = src.indexOf('\n }', i);
76 return j < 0 ? null : src.slice(i, j);
77}
78
79const body = categoryBody(RUST);
80check('gateway: LedgerEntry::category() found', !!body,
81 body ? body.split('\n').length + ' lines' : 'schema.rs did not parse');
82if (!body) process.exit(1);
83
84// A kind arm names its category outright (`LedgerKind::Topup => "topup"`); a
85// metered spend names it as the value of a prefix test (`{ "web" }`), including
86// the `else` that catches an unknown prefix. `=> {` opens the Spend block and is
87// not a category, which is why the kind pattern demands a quote straight after
88// the arrow.
89const kindCats = [...body.matchAll(/=>\s*"([a-z_]+)"/g)].map(m => m[1]);
90const spendCats = [...body.matchAll(/\{\s*"([a-z_]+)"\s*\}/g)].map(m => m[1]);
91const prefixes = [...body.matchAll(/starts_with\("([a-z_]+):"\)/g)].map(m => m[1]);
92const derived = kindCats.concat(spendCats);
93
94// A parse that quietly matched nothing would let every check below pass on an
95// empty set, so the shape of what was read is asserted before it is used. Each
96// prefix test yields one category and the `else` yields one more.
97check('gateway: every prefix arm was read', spendCats.length === prefixes.length + 1,
98 `${prefixes.length} prefixes (${prefixes.join(' ')}) → ${spendCats.length} categories`);
99check('gateway: the credit-side kinds were read', kindCats.length >= 4, kindCats.join(' '));
100
101// `SPEND_CATEGORIES` is what everything drawing a breakdown is told to iterate.
102// It is a second hand-written list, so it is checked against the function rather
103// than trusted as the source: it going stale is the same defect one step up.
104const cm = RUST.match(/pub const SPEND_CATEGORIES: \[&str; (\d+)\] = \[([\s\S]*?)\];/);
105const constCats = cm ? [...cm[2].matchAll(/"([a-z_]+)"/g)].map(m => m[1]) : [];
106check('gateway: SPEND_CATEGORIES found and its arity matches its contents',
107 !!cm && constCats.length === Number(cm[1]),
108 cm ? `[&str; ${cm[1]}] holding ${constCats.length}` : 'constant not found');
109check('gateway: SPEND_CATEGORIES names exactly what category() can return for a spend',
110 setEq(constCats, spendCats),
111 `constant: ${constCats.join(' ')} | category(): ${spendCats.join(' ')}`);
112
113// ── The page's copy ─────────────────────────────────────────────────
114
115const SPENDJS = fs.readFileSync(path.join(TREE, 'www/js/spend.js'), 'utf8');
116const jm = SPENDJS.match(/var CATS = \[([\s\S]*?)\];/);
117const pageCats = jm ? [...jm[1].matchAll(/'([a-z_]+)'/g)].map(m => m[1]) : [];
118check('spend.js: CATS found', !!jm && pageCats.length > 0, pageCats.join(' '));
119
120const unnamed = derived.filter(c => !pageCats.includes(c));
121const invented = pageCats.filter(c => !derived.includes(c));
122check('spend.js: CATS names every category the gateway can produce',
123 unnamed.length === 0,
124 unnamed.length ? 'the page cannot name: ' + unnamed.join(' ') : derived.length + ' categories');
125check('spend.js: CATS invents no category the gateway cannot produce',
126 invented.length === 0, invented.join(' ') || undefined);
127
128// ── Eight locales ───────────────────────────────────────────────────
129
130const I18NDIR = path.join(TREE, 'www/i18n');
131/// A locale table, loaded the way the browser loads it: the file registers
132/// itself against a stub window.
133function loadTable(code) {
134 let table = null;
135 const w = { DaimondI18n: { register: (c, t) => { table = t; } } };
136 new Function('window', fs.readFileSync(path.join(I18NDIR, code + '.js'), 'utf8'))(w);
137 return table || {};
138}
139const codes = fs.readdirSync(I18NDIR).filter(f => f.endsWith('.js')).map(f => f.slice(0, -3)).sort();
140const tables = {};
141for (const c of codes) tables[c] = loadTable(c);
142check('all eight locales load', codes.length === 8, codes.join(' '));
143
144// The label a user reads for an unnameable category, which must itself exist
145// everywhere -- a loud fallback that falls back to its own key is not loud.
146const WANTED = derived.map(c => 'spend.cat_' + c).concat(['spend.cat_fallback', 'spend.cat_unlisted']);
147/// The category a `spend.cat_*` key names.
148const tokenOf = (k) => k.slice('spend.cat_'.length);
149for (const code of codes) {
150 const t = tables[code];
151 const missing = WANTED.filter(k => typeof t[k] !== 'string' || !t[k].trim());
152 check(code + ': every category has a label', missing.length === 0,
153 missing.join(' ') || WANTED.length + ' labels');
154 // A "translation" that is the category token is the defect with a key in
155 // front of it: `'spend.cat_infer': 'infer'` reads no better in Japanese. It
156 // is only a defect where English says something else -- English calls the
157 // mail category "Mail" and German agrees, and neither is untranslated.
158 if (code === 'en') continue;
159 const tokens = WANTED.filter(k => typeof t[k] === 'string'
160 && t[k].trim().toLowerCase() === tokenOf(k)
161 && String(tables.en[k]).trim().toLowerCase() !== tokenOf(k));
162 check(code + ': no label is the raw token', tokens.length === 0, tokens.join(' ') || undefined);
163}
164
165if (STATIC) {
166 console.log(`\nspendcats (static): ${ok.length} ok, ${bad.length} failed.`);
167 if (bad.length) console.log('FAILED:\n ' + bad.join('\n '));
168 process.exit(bad.length ? 1 : 0);
169}
170
171// ── The page itself, in eight languages ─────────────────────────────
172
173const { open, shot } = await import('./harness.mjs');
174
175// One movement per category the gateway can write, plus one it cannot: `quartz`
176// stands for the next category to be invented, and is the whole point of the
177// fallback being loud rather than printing whatever arrived.
178const UNKNOWN = 'quartz';
179const NS = Date.now() * 1e6; // the API dates a movement in nanoseconds
180const entries = derived.map((c, i) => ({
181 ts: NS - i * 3600e9,
182 kind: kindCats.includes(c) ? c : 'spend',
183 category: c,
184 // Debits are negative, and only debits reach the breakdown; the credit-side
185 // kinds are positive and appear in the movements table alone.
186 delta_minor: kindCats.includes(c) ? 100 + i : -(100 + i),
187 balance: 5000,
188 ref: c + ':x',
189})).concat([{
190 ts: NS - 99 * 3600e9, kind: 'spend', category: UNKNOWN,
191 delta_minor: -77, balance: 5000, ref: UNKNOWN + ':x',
192}]);
193
194const s = await open({ name: 'spendcats' + Date.now() });
195const page = s.page;
196await page.waitForFunction(
197 () => !!window.DaimondSpend && !!window.DaimondI18n && !!window.DaimondGateway,
198 null, { timeout: 15000 }).catch(() => {});
199
200// Stand in for the gateway: an authed account with a ledger holding every
201// category. `refreshBalance` is silenced because it publishes `daimond:credits`,
202// which refreshes an open Spending panel, which refreshes the balance.
203await page.evaluate((rows) => {
204 const g = window.DaimondGateway;
205 window.__gwReal = { state: g.state, refreshBalance: g.refreshBalance, ledger: g.ledger };
206 g.state = () => ({ authed: true, credits: 5000, currency: 'usd' });
207 g.refreshBalance = async () => {};
208 g.ledger = async () => rows;
209}, entries);
210
211/// Every category label on screen: the breakdown rows, and the "What" column of
212/// the movements table.
213const labels = () => page.evaluate(() => ({
214 breakdown: [...document.querySelectorAll('#spend-view .spend-bd-label')].map(n => n.textContent),
215 movements: [...document.querySelectorAll('#spend-view tr')]
216 .filter(tr => tr.querySelector('td.spend-when'))
217 .map(tr => tr.children[1].textContent),
218}));
219
220// The Admin drawer is left open by the harness's model connect, and it sits over
221// the rail; close it so the shots below are of the Spending panel and nothing
222// else.
223await page.evaluate(() => { try { document.getElementById('admin-close').click(); } catch (e) {} });
224await page.evaluate(() => window.DaimondSpend.show());
225await page.waitForTimeout(600);
226
227/// A shot of the credits breakdown itself, scrolled to.
228async function panelShot(label) {
229 await page.evaluate(() => {
230 const v = document.getElementById('spend-view');
231 const n = document.querySelector('#spend-view .spend-breakdown');
232 if (v && n) v.scrollTop += n.getBoundingClientRect().top - v.getBoundingClientRect().top - 60;
233 });
234 await page.waitForTimeout(150);
235 await page.locator('#panel-spend')
236 .screenshot({ path: path.join(HERE, 'shots', label + '.png'), timeout: 8000 })
237 .catch(() => {});
238}
239
240for (const code of codes) {
241 const mark = s.logs.length;
242 await page.evaluate(c => window.DaimondI18n.setLocale(c), code);
243 await page.waitForTimeout(200);
244 await page.evaluate(() => window.DaimondSpend.refresh());
245 await page.waitForTimeout(300);
246
247 const seen = await labels();
248 const all = seen.breakdown.concat(seen.movements);
249 const t = tables[code];
250
251 // Every category the gateway can write reaches the screen under this
252 // locale's own words for it.
253 const absent = derived.filter(c => !all.includes(t['spend.cat_' + c]));
254 check(code + ': every category is drawn with its own label', absent.length === 0,
255 absent.length ? absent.map(c => c + '→' + t['spend.cat_' + c]).join(' ')
256 : derived.length + ' labels drawn');
257
258 // The token itself must never be on screen. `derived` is what a stale page
259 // leaks; `spend.cat_` is what a missing key leaks.
260 const raw = all.filter(x => derived.includes(x.trim()) || /^spend\.cat_/.test(x.trim()));
261 check(code + ': no raw category token on screen', raw.length === 0, raw.join(' ') || undefined);
262
263 // Composed and read back: a stray space or an unfilled placeholder is
264 // invisible in the source string and plain here.
265 const badly = all.filter(x => x !== x.trim() || /\s\s/.test(x) || /[{}]/.test(x) || !x.length);
266 // The drawn breakdown is quoted whether it passes or not: this line is the
267 // read-back, and a label that is wrong rather than malformed is caught by
268 // somebody's eye on it and by nothing else.
269 check(code + ': labels compose cleanly', badly.length === 0,
270 badly.map(x => JSON.stringify(x)).join(' ') || seen.breakdown.join(' · '));
271
272 // A shot per language, because "the string is right" and "the row reads
273 // right at this width" are different questions.
274 await panelShot('spendcats-' + code);
275
276 // The unnameable category is drawn as unaccounted for, and the token goes to
277 // the console instead of to the user.
278 check(code + ': an unnameable category is drawn as unaccounted for',
279 all.includes(t['spend.cat_unlisted']), t['spend.cat_unlisted']);
280 const gaps = s.logs.slice(mark).filter(l => /i18n: no string for "spend\.cat_/.test(l));
281 check(code + ': no missing-label warning', gaps.length === 0, gaps.slice(0, 3).join(' | ') || undefined);
282}
283
284check('the unnameable category was reported to the console',
285 s.logs.some(l => l.includes('cannot label') && l.includes(UNKNOWN)),
286 s.logs.filter(l => l.includes('cannot label'))[0] || 'nothing said');
287// Reported ONCE per token, however many rows and redraws carried it: eight
288// locales times a redraw each is eight chances to turn a diagnostic into a
289// flood.
290check('and only once, however many redraws carried it',
291 s.logs.filter(l => l.includes('cannot label') && l.includes(UNKNOWN)).length === 1,
292 s.logs.filter(l => l.includes('cannot label')).length + ' warning(s)');
293
294await page.evaluate(c => window.DaimondI18n.setLocale(c), 'en');
295await page.waitForTimeout(200);
296await page.evaluate(() => window.DaimondSpend.refresh());
297await page.waitForTimeout(300);
298await shot(s, 'spendcats-page'); // the whole page, English, for context
299
300// ── Negative control ────────────────────────────────────────────────
301// Everything above passes on a page that draws the right words. It would also
302// pass on a check that asserts nothing, so the check is shown failing on a page
303// that has lost a label: the English table is re-registered without
304// `spend.cat_infer`, which is exactly the shape of a category added to the
305// gateway and not to the locales.
306{
307 const before = await labels();
308 const lost = await page.evaluate(async () => {
309 const src = await (await fetch('/i18n/en.js')).text();
310 let tbl = null;
311 new Function('window', src)({ DaimondI18n: { register: (c, t) => { tbl = t; } } });
312 const full = Object.assign({}, tbl);
313 delete tbl['spend.cat_infer'];
314 window.DaimondI18n.register('en', tbl);
315 await window.DaimondSpend.refresh();
316 const shown = [...document.querySelectorAll('#spend-view .spend-bd-label')].map(n => n.textContent);
317 window.DaimondI18n.register('en', full); // put it back
318 await window.DaimondSpend.refresh();
319 return shown;
320 });
321 const en = tables.en;
322 check('negative control: the check fails when a label is removed',
323 before.breakdown.includes(en['spend.cat_infer']) && !lost.includes(en['spend.cat_infer']),
324 'lost row read: ' + (lost.find(x => /spend\.cat_/.test(x)) || '(none)'));
325 const back = await labels();
326 check('negative control: and passes again once it is restored',
327 back.breakdown.includes(en['spend.cat_infer']));
328}
329
330await page.evaluate(() => {
331 const g = window.DaimondGateway, r = window.__gwReal;
332 if (r) { g.state = r.state; g.refreshBalance = r.refreshBalance; g.ledger = r.ledger; }
333});
334
335// No gateway runs for this check -- the ledger is stubbed in the page -- so the
336// bootstrap's /api calls come back 502 from dev/serve.mjs. That is the harness,
337// not the page, and it is the only noise allowed through.
338const errs = s.errs.filter(e => !/502|Bad Gateway|quartz/.test(e));
339check('no page errors', errs.length === 0, errs.slice(0, 3).join(' | ') || undefined);
340
341await s.close();
342console.log(`\nspendcats: ${ok.length} ok, ${bad.length} failed.`);
343if (bad.length) console.log('FAILED:\n ' + bad.join('\n '));
344process.exit(bad.length ? 1 : 0);