Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_interfacediagram.mjs

17.2 KiB, 1 run

created by r2519314175:489, 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_interfacediagram.mjs — the interface page's region map says what the
2// app does, and is still made of words on a phone.
3//
4// Two properties, and one of them was a live defect.
5//
6// 1. THE TOP BAR HAS NO SPEND METER. `#top-meter` has since gone from
7// www/index.html altogether -- markup, binding and the one line that
8// emptied it -- so nothing in the tree writes it and nothing could. The
9// check is unchanged and now rests on a stronger fact; what had to move is
10// the `liveMeter` break, which staged the contrary case off that deleted
11// line. See the note on it below.
12// The readout the guide was describing is the rail's `#spend-row`,
13// which `updateSpend()` fills with three cells and which `spend.js` wires
14// as the door to the Spending panel. So the guide must not put a meter in
15// the top bar, on `interface.html` or on `spending.html`, and the region
16// map must not draw one. Checked against the CODE, not against a list of
17// words: the assertion is "nothing writes #top-meter", so the day someone
18// revives the meter this check turns round and demands the guide say so.
19//
20// 2. THE DIAGRAM'S WORDS ARE STILL WORDS AT 360px. Scaled to a phone column
21// the whole schematic put its smallest labels near five pixels. Measured
22// as RENDERED INK -- the height of a label's own box on the page as the
23// reader has it -- and not as a font-size in the markup, which says
24// nothing once an SVG has been scaled to fit. The page must also not
25// scroll sideways: the figure scrolls inside its own box instead.
26//
27// EACH CHECK IS PROVED AGAINST A BROKEN PAGE FIRST. `--break <name>` damages a
28// copy of a file and serves it to the real browser through `page.route`, or
29// damages the input a static check reads, and the run is expected to FAIL.
30//
31// node dev/verify_interfacediagram.mjs --break meterprose # 1 fails
32// node dev/verify_interfacediagram.mjs --break meterdoor # 1 fails
33// node dev/verify_interfacediagram.mjs --break metersvg # 1 fails
34// node dev/verify_interfacediagram.mjs --break liveMeter # 1 fails, the other way
35// node dev/verify_interfacediagram.mjs --break tinylabels # 2 fails
36// node dev/verify_interfacediagram.mjs --break nofloor # 2 fails
37// node dev/verify_interfacediagram.mjs # and then, clean
38//
39// bash dev/world.sh 7 --up && eval "$(bash dev/world.sh 7 --env)"
40// node dev/verify_interfacediagram.mjs
41//
42// Needs dev/serve.mjs only: the guide is flat files and loads none of the app.
43// Writes its screenshots to dev/shots/ifdiag-*.png.
44import fs from 'node:fs';
45import os from 'node:os';
46import path from 'node:path';
47import { fileURLToPath, pathToFileURL } from 'node:url';
48
49// Chromium's ozone platform is chosen by autodetection and prefers Wayland whenever
50// `WAYLAND_DISPLAY` is set -- which it is in every rc session on argonaut -- so a headed
51// run under `xvfb-run` still went to the compositor and opened a window on the owner's
52// desktop. Importing this strips the two variables from `process.env`, which is all a
53// launcher that spreads `process.env` needs. See dev/display.mjs.
54import './display.mjs';
55const HERE = path.dirname(fileURLToPath(import.meta.url));
56const WWW = path.join(HERE, '..', 'www');
57const GUIDE = path.join(WWW, 'guide');
58const PAGE = path.join(GUIDE, 'interface.html');
59const SPEND = path.join(GUIDE, 'spending.html');
60const SHOTS = path.join(HERE, 'shots');
61/// A prove-run renders damaged pages, so its shots go to their own names. The
62/// first draft wrote over the real ones, and the clean 360px shot on disk was
63/// the one taken with the width floor deliberately removed.
64const tag = (n) => `ifdiag-${n}${BREAK ? '-BREAK-' + BREAK : ''}.png`;
65const APP = process.env.DAIMOND_APP || `http://localhost:${process.env.DAIMOND_PORT || 8777}`;
66const PW = process.env.DAIMOND_PW
67 || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs');
68const CHROME = process.env.DAIMOND_CHROME
69 || `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`;
70const SCRATCH = process.env.DAIMOND_SCRATCH || path.join(os.homedir(), '.cache/daimond');
71
72const BREAK = (() => {
73 const i = process.argv.indexOf('--break');
74 return i > 0 ? String(process.argv[i + 1] || '') : '';
75})();
76
77const ok = [], bad = [];
78const check = (name, pass, detail) => {
79 (pass ? ok : bad).push(name);
80 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
81};
82const die = (why) => { console.error('ABORT: ' + why); process.exit(2); };
83
84// ── The inputs, and the damage ───────────────────────────────────────
85let pageBytes = fs.readFileSync(PAGE, 'utf8');
86let spendBytes = fs.readFileSync(SPEND, 'utf8');
87let appJs = fs.readFileSync(path.join(WWW, 'js', 'daimond.js'), 'utf8');
88let spendJs = fs.readFileSync(path.join(WWW, 'js', 'spend.js'), 'utf8');
89
90const applied = [];
91switch (BREAK) {
92 case '': break;
93 case 'meterprose': {
94 // The sentence as it stood: a meter in the top bar, which is not there.
95 const before = pageBytes;
96 pageBytes = pageBytes.replace('Across the top sit the wordmark, a row of',
97 'Across the top sit the wordmark, a spend meter, a row of');
98 if (pageBytes === before) die('the meterprose break did not apply');
99 applied.push('put the spend meter back in the top bar paragraph');
100 break;
101 }
102 case 'meterdoor': {
103 // The other half of the same false claim, on the other page: the door to
104 // the Spending panel described as being in the top bar.
105 const before = spendBytes;
106 spendBytes = spendBytes.replace(/Open the Spending panel from the <strong>spend row<\/strong> at the foot of the rail/,
107 'Open the Spending panel from the <strong>spend meter</strong> in the top bar');
108 if (spendBytes === before) die('the meterdoor break did not apply');
109 applied.push('sent the reader to a top-bar meter for the Spending panel');
110 break;
111 }
112 case 'metersvg': {
113 // The pill-and-fill the region map drew for it, back in the top bar.
114 const before = pageBytes;
115 pageBytes = pageBytes.replace('<rect class="rm-cell" x="430" y="25" width="40" height="16" rx="8"/>',
116 '<rect class="rm-cell" x="322" y="24" width="86" height="18" rx="9"/>\n'
117 + '\t\t\t<rect class="rm-fill" x="326" y="28" width="34" height="10" rx="5" opacity=".55"/>\n'
118 + '\t\t\t<rect class="rm-cell" x="430" y="25" width="40" height="16" rx="8"/>');
119 if (pageBytes === before) die('the metersvg break did not apply');
120 applied.push('drew a meter in the region map again');
121 break;
122 }
123 case 'liveMeter': {
124 // The opposite failure: the app grows a meter and the guide stays silent.
125 // The check must notice that too, or it is a check on one wording.
126 //
127 // REPOINTED. This used to hang off `topMeter.textContent = '';` -- the one
128 // line that emptied the element -- and both the element and that line have
129 // since been deleted from the app, markup and binding with them. The
130 // anchor was gone, so the only break that could turn this check round no
131 // longer applied, and a check whose contrary case cannot be staged is a
132 // check that cannot fail. It now hangs off `updateMeters()` itself, which
133 // is where a revived meter would be written, and reaches the element the
134 // way anything outside this module would have to.
135 const before = appJs;
136 appJs = appJs.replace('\t\taiMeter.textContent = \'\';\n\t}',
137 '\t\taiMeter.textContent = \'\';\n'
138 + '\t\tdocument.getElementById(\'top-meter\').textContent = fmtUsd(spentToday());\n\t}');
139 if (appJs === before) die('the liveMeter break did not apply');
140 applied.push('made updateMeters() write a top-bar meter');
141 break;
142 }
143 case 'tinylabels':
144 case 'nofloor': {
145 // The width floor removed, so the schematic is scaled to the phone column
146 // and its labels go with it. This is what the page did before this file.
147 const before = pageBytes;
148 pageBytes = pageBytes.replace(/\.diagram\.scrolls svg \{ min-width: \d+px; \}/, '');
149 if (pageBytes === before) die('the width-floor break did not apply');
150 applied.push('let the region map scale down to the phone column');
151 break;
152 }
153 default: die(`no break called "${BREAK}"`);
154}
155if (BREAK) console.log(`BREAK ${BREAK}: ${applied.join('; ')}\n`);
156
157// ── 1. The top bar has no spend meter ────────────────────────────────
158//
159// The oracle is the code. `#top-meter` exists in the markup, so its presence
160// proves nothing; what decides the question is whether anything ever puts
161// characters in it. `updateMeters()` clears it and no other statement in the
162// tree assigns to it, so the element is furniture with no content. A guide that
163// names it is describing a control the reader cannot find.
164{
165 // Every assignment to the element, however it is reached: the variable
166 // daimond.js binds it to, and a fresh lookup anywhere else.
167 const writes = [];
168 const files = [];
169 const walk = (dir) => {
170 for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
171 const p = path.join(dir, e.name);
172 if (e.isDirectory()) walk(p);
173 else if (/\.(js|mjs)$/.test(e.name)) files.push(p);
174 }
175 };
176 walk(path.join(WWW, 'js'));
177 // The named binding, from daimond.js's own `var topMeter = getElementById(...)`.
178 const RE = /\b(topMeter|document\.getElementById\(\s*'top-meter'\s*\))\s*\.\s*(textContent|innerHTML|innerText|append\w*|replaceChildren)\s*(=[^=]|\()/g;
179 for (const f of files) {
180 const src = f.endsWith('daimond.js') ? appJs : (f.endsWith('spend.js') ? spendJs : fs.readFileSync(f, 'utf8'));
181 let m;
182 while ((m = RE.exec(src))) {
183 // An assignment of the empty string is a clearing, not a readout.
184 const tail = src.slice(m.index, m.index + 200);
185 if (/=\s*''\s*;/.test(tail.slice(0, 40))) continue;
186 writes.push(`${path.basename(f)}: ${tail.split('\n')[0].trim().slice(0, 60)}`);
187 }
188 }
189 const live = writes.length > 0;
190
191 // What the two pages say. The claim is "a spend readout in the top bar", in
192 // any wording: a named thing, in the named place, within one sentence.
193 const proseOf = (b) => (b.match(/<main[^>]*>([\s\S]*?)<\/main>/i) || [, ''])[1]
194 .replace(/<svg[\s\S]*?<\/svg>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ');
195 const CLAIM = /\bspend(ing)?\s+(meter|readout|total|figure)\b[^.]{0,60}\btop bar\b|\btop bar\b[^.]{0,60}\bspend(ing)?\s+(meter|readout|total|figure)\b/i;
196 const said = [];
197 if (CLAIM.test(proseOf(pageBytes))) said.push('interface.html');
198 if (CLAIM.test(proseOf(spendBytes))) said.push('spending.html');
199
200 // And the region map must not draw one. The meter was a filled pill in the
201 // top-bar strip: a `rm-fill` rectangle inside the bar's own band (y < 56),
202 // which is otherwise only the brand diamond, drawn rotated.
203 const svg = (pageBytes.match(/<svg class="regionmap"[\s\S]*?<\/svg>/) || [''])[0];
204 const drawn = [...svg.matchAll(/<rect class="rm-fill"[^>]*y="(\d+(?:\.\d+)?)"[^>]*>/g)]
205 .filter((m) => Number(m[1]) < 56 && !/rotate/.test(m[0]));
206
207 // The two halves have to agree with the code, in whichever direction it points.
208 const agrees = live ? (said.length === 2) : (said.length === 0 && drawn.length === 0);
209 check('the guide and the code agree about a spend meter in the top bar', agrees,
210 live ? `nothing writes #top-meter is FALSE (${writes[0]}), and the guide says it on: ${said.join(', ') || 'no page'}`
211 : `nothing writes #top-meter, but ${said.join(' and ') || 'the diagram'} still ${said.length ? 'says so' : 'draws one'}`);
212
213 // And the reader is sent to the door that exists. `spend.js` puts the click
214 // handler on `#spend-row`, so that is where the guide has to point.
215 const door = /getElementById\('spend-row'\)[\s\S]{0,600}?addEventListener\('click'/.test(spendJs);
216 const points = /spend row[^.]{0,60}(rail|foot of the rail)|(rail|foot of the rail)[^.]{0,60}spend row/i
217 .test(proseOf(spendBytes));
218 check('spending.html points at the door spend.js actually wires', !door || points,
219 door ? 'the handler is on #spend-row and the page does not say so' : 'no handler found');
220}
221
222// ── The browser ──────────────────────────────────────────────────────
223const { chromium } = await import(pathToFileURL(PW).href);
224const profile = path.join(SCRATCH, 'pw', 'ifdiag' + (BREAK ? '-' + BREAK : ''));
225fs.rmSync(profile, { recursive: true, force: true });
226fs.mkdirSync(profile, { recursive: true });
227fs.mkdirSync(SHOTS, { recursive: true });
228
229// A forwarded DISPLAY means no compositor frames, so requestAnimationFrame never
230// fires and every wait hangs. See dev/harness.mjs.
231const env = { ...process.env };
232delete env.DISPLAY;
233
234const browser = await chromium.launchPersistentContext(profile, {
235 executablePath: CHROME,
236 headless: false,
237 args: ['--no-sandbox', '--disable-dev-shm-usage', '--headless=new'],
238 env,
239 viewport: { width: 1100, height: 900 },
240});
241const page = browser.pages()[0] || await browser.newPage();
242const errs = [];
243page.on('pageerror', (e) => errs.push(String(e.message)));
244page.on('console', (m) => { if (m.type() === 'error') errs.push(m.text()); });
245
246await page.route('**/guide/interface.html*', (route) => {
247 route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: pageBytes });
248});
249
250const URL = `${APP}/guide/interface.html`;
251
252/// Put the page into one of the app's palettes, the way guide/frame.js does.
253const TONES = { light: ['light', 'dark'], dark: ['dark', 'light'] };
254async function wear(theme) {
255 await page.evaluate(({ t, m }) => {
256 const r = document.documentElement;
257 r.setAttribute('data-theme', t);
258 r.setAttribute('data-tone', m[0]);
259 r.setAttribute('data-ink', m[1]);
260 }, { t: theme, m: TONES[theme] });
261 await page.waitForTimeout(250);
262}
263
264/// The rendered height of the smallest label in the region map, in CSS pixels
265/// on the page as the reader has it. `<text>` only: a rectangle has no words in
266/// it, and a font-size in the markup says nothing once the SVG has been scaled.
267const measure = () => page.evaluate(() => {
268 const labs = [...document.querySelectorAll('.regionmap text')];
269 if (!labs.length) return null;
270 const rows = labs.map((l) => ({
271 t: l.textContent.trim().slice(0, 24),
272 h: l.getBoundingClientRect().height,
273 px: Number(getComputedStyle(l).fontSize.replace('px', '')),
274 })).sort((a, b) => a.h - b.h);
275 return { min: rows[0], n: rows.length, all: rows.slice(0, 4) };
276});
277
278// ── Desktop, both palettes ───────────────────────────────────────────
279for (const theme of ['light', 'dark']) {
280 await page.setViewportSize({ width: 1100, height: 900 });
281 await page.goto(URL, { waitUntil: 'load' });
282 await page.waitForTimeout(400);
283 await wear(theme);
284 const m = await measure();
285 console.log(` 1100px ${theme.padEnd(5)} smallest label ${m ? m.min.h.toFixed(1) + 'px (' + JSON.stringify(m.min.t) + ', font ' + m.min.px.toFixed(1) + 'px)' : 'none'}`);
286 const fig = await page.$('.diagram');
287 if (fig) await fig.screenshot({ path: path.join(SHOTS, tag(`1100-${theme}`)) });
288}
289
290// ── 360px, the narrowest width the guide supports ────────────────────
291{
292 // Eight pixels of rendered ink is the floor dev/verify_vocabulary.mjs holds
293 // the other diagram to: below that the strokes of a lower-case letter merge
294 // at this weight. Kept identical so the two figures cannot drift apart.
295 const FLOOR_PX = 8;
296 for (const theme of ['light', 'dark']) {
297 await page.setViewportSize({ width: 360, height: 900 });
298 await page.goto(URL, { waitUntil: 'load' });
299 await page.waitForTimeout(400);
300 await wear(theme);
301 const m = await measure();
302 console.log(` 360px ${theme.padEnd(5)} smallest label ${m ? m.min.h.toFixed(1) + 'px (' + JSON.stringify(m.min.t) + ', font ' + m.min.px.toFixed(1) + 'px)' : 'none'}`);
303 if (theme === 'light') {
304 const wide = await page.evaluate(() =>
305 document.documentElement.scrollWidth - document.documentElement.clientWidth);
306 check('the page does not scroll sideways at 360px', wide <= 1, `${wide}px of overflow`);
307 // The figure carries the scrolling instead, which is the whole trick:
308 // a floor with no overflow box would simply widen the page.
309 const inside = await page.evaluate(() => {
310 const f = document.querySelector('.diagram');
311 if (!f) return null;
312 return { scroll: f.scrollWidth, client: f.clientWidth,
313 overflow: getComputedStyle(f).overflowX };
314 });
315 check('the figure scrolls inside its own box',
316 !!inside && inside.overflow === 'auto' && inside.scroll > inside.client + 1,
317 inside ? `overflow-x: ${inside.overflow}, ${inside.scroll} in ${inside.client}` : 'no figure');
318 check('the region map\'s labels are still legible at 360px',
319 !!m && m.min.h >= FLOOR_PX,
320 m ? `smallest label renders ${m.min.h.toFixed(1)}px tall (${JSON.stringify(m.min.t)})` : 'no labels found');
321 }
322 await page.evaluate(() => window.scrollTo(0, 0));
323 await page.screenshot({ path: path.join(SHOTS, tag(`360-${theme}`)), fullPage: false });
324 }
325}
326
327check('the page threw nothing', errs.length === 0, errs.slice(0, 3).join(' | '));
328
329await browser.close();
330
331console.log(`\n${ok.length} ok, ${bad.length} failed`);
332if (BREAK) {
333 if (bad.length) { console.log('the break was caught, as it should be'); process.exit(0); }
334 console.log('THE BREAK WAS NOT CAUGHT: this check proves nothing');
335 process.exit(1);
336}
337process.exit(bad.length ? 1 : 0);