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. |
| 49 | import fs from 'node:fs'; |
| 50 | import path from 'node:path'; |
| 51 | import { fileURLToPath } from 'node:url'; |
| 52 | |
| 53 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 54 | const TREE = process.env.DAIMOND_TREE || path.join(HERE, '..'); |
| 55 | const STATIC = process.argv.includes('--static'); |
| 56 | |
| 57 | const ok = [], bad = []; |
| 58 | const check = (name, pass, detail) => { |
| 59 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 60 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 61 | }; |
| 62 | const setEq = (a, b) => a.length === b.length && a.every(x => b.includes(x)); |
| 63 | |
| 64 | // ── The gateway, read as the authority ────────────────────────────── |
| 65 | |
| 66 | const 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. |
| 72 | function 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 | |
| 79 | const body = categoryBody(RUST); |
| 80 | check('gateway: LedgerEntry::category() found', !!body, |
| 81 | body ? body.split('\n').length + ' lines' : 'schema.rs did not parse'); |
| 82 | if (!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. |
| 89 | const kindCats = [...body.matchAll(/=>\s*"([a-z_]+)"/g)].map(m => m[1]); |
| 90 | const spendCats = [...body.matchAll(/\{\s*"([a-z_]+)"\s*\}/g)].map(m => m[1]); |
| 91 | const prefixes = [...body.matchAll(/starts_with\("([a-z_]+):"\)/g)].map(m => m[1]); |
| 92 | const 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. |
| 97 | check('gateway: every prefix arm was read', spendCats.length === prefixes.length + 1, |
| 98 | `${prefixes.length} prefixes (${prefixes.join(' ')}) → ${spendCats.length} categories`); |
| 99 | check('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. |
| 104 | const cm = RUST.match(/pub const SPEND_CATEGORIES: \[&str; (\d+)\] = \[([\s\S]*?)\];/); |
| 105 | const constCats = cm ? [...cm[2].matchAll(/"([a-z_]+)"/g)].map(m => m[1]) : []; |
| 106 | check('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'); |
| 109 | check('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 | |
| 115 | const SPENDJS = fs.readFileSync(path.join(TREE, 'www/js/spend.js'), 'utf8'); |
| 116 | const jm = SPENDJS.match(/var CATS = \[([\s\S]*?)\];/); |
| 117 | const pageCats = jm ? [...jm[1].matchAll(/'([a-z_]+)'/g)].map(m => m[1]) : []; |
| 118 | check('spend.js: CATS found', !!jm && pageCats.length > 0, pageCats.join(' ')); |
| 119 | |
| 120 | const unnamed = derived.filter(c => !pageCats.includes(c)); |
| 121 | const invented = pageCats.filter(c => !derived.includes(c)); |
| 122 | check('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'); |
| 125 | check('spend.js: CATS invents no category the gateway cannot produce', |
| 126 | invented.length === 0, invented.join(' ') || undefined); |
| 127 | |
| 128 | // ── Eight locales ─────────────────────────────────────────────────── |
| 129 | |
| 130 | const 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. |
| 133 | function 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 | } |
| 139 | const codes = fs.readdirSync(I18NDIR).filter(f => f.endsWith('.js')).map(f => f.slice(0, -3)).sort(); |
| 140 | const tables = {}; |
| 141 | for (const c of codes) tables[c] = loadTable(c); |
| 142 | check('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. |
| 146 | const WANTED = derived.map(c => 'spend.cat_' + c).concat(['spend.cat_fallback', 'spend.cat_unlisted']); |
| 147 | /// The category a `spend.cat_*` key names. |
| 148 | const tokenOf = (k) => k.slice('spend.cat_'.length); |
| 149 | for (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 | |
| 165 | if (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 | |
| 173 | const { 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. |
| 178 | const UNKNOWN = 'quartz'; |
| 179 | const NS = Date.now() * 1e6; // the API dates a movement in nanoseconds |
| 180 | const 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 | |
| 194 | const s = await open({ name: 'spendcats' + Date.now() }); |
| 195 | const page = s.page; |
| 196 | await 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. |
| 203 | await 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. |
| 213 | const 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. |
| 223 | await page.evaluate(() => { try { document.getElementById('admin-close').click(); } catch (e) {} }); |
| 224 | await page.evaluate(() => window.DaimondSpend.show()); |
| 225 | await page.waitForTimeout(600); |
| 226 | |
| 227 | /// A shot of the credits breakdown itself, scrolled to. |
| 228 | async 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 | |
| 240 | for (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 | |
| 284 | check('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. |
| 290 | check('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 | |
| 294 | await page.evaluate(c => window.DaimondI18n.setLocale(c), 'en'); |
| 295 | await page.waitForTimeout(200); |
| 296 | await page.evaluate(() => window.DaimondSpend.refresh()); |
| 297 | await page.waitForTimeout(300); |
| 298 | await 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 | |
| 330 | await 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. |
| 338 | const errs = s.errs.filter(e => !/502|Bad Gateway|quartz/.test(e)); |
| 339 | check('no page errors', errs.length === 0, errs.slice(0, 3).join(' | ') || undefined); |
| 340 | |
| 341 | await s.close(); |
| 342 | console.log(`\nspendcats: ${ok.length} ok, ${bad.length} failed.`); |
| 343 | if (bad.length) console.log('FAILED:\n ' + bad.join('\n ')); |
| 344 | process.exit(bad.length ? 1 : 0); |