Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_fileshow.mjs

29.2 KiB, 1 run

created by r2519314175:409, 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_fileshow.mjs — the daimon can put a file in front of the user, and it
2// is told the truth about what they are now looking at.
3//
4// THE DEFECT, in the daimon's own words, asked to compile a Typst source and
5// display the PDF:
6//
7// I do see a pre-existing thinking.pdf (1.08 MB) in the CheapThinking/
8// directory from a previous compile, but I cannot display a PDF inline here
9// — the file tools return raw bytes for it rather than a rendered view.
10//
11// Every clause of that is true about its TOOLBOX and false about Daimond.
12// `www/js/viewer.js` has handed `application/pdf` to the browser's own document
13// viewer since the `doc` tier was written. What was missing was any way for the
14// model to SAY SO, so it reasoned from the tools it held to a limitation of the
15// app, told the user about it, and apologised. This file pins the repair:
16//
17// 1. A FILE THE MODEL NAMES ENDS UP ON THE SCREEN. `window.DaimondDoc.show`
18// is the door `file_show` in src/tools.rs calls from the wasm, and what it
19// must produce is not an element with a blob URL on it — that is what a
20// broken PDF looks like too — but INK ON PAPER, read off a screenshot.
21// 2. AND THE MODEL IS TOLD WHAT IS ON IT, from the viewer's own table rather
22// than from a second opinion. The verdict must agree with the `data-viewer`
23// the panel actually drew, including for a `.md`, which goes to the EDITOR
24// and not to the viewer's markdown tier.
25// 3. A FORMAT WITH NO VIEWER IS STILL SHOWN, and reported as the hex dump it
26// is. There is no "cannot show" answer, and the model must not be handed
27// one: a refusal shaped like "no viewer for this format" is an invitation
28// to make the same false generalisation one file later.
29// 4. IT IS A LIVE HANDLE AND NOT A SNAPSHOT. Rewrite the file, show the same
30// path again, and what is on screen changes. This is what a recompiled
31// document needs in order to reach the reader at all.
32// 5. AND IT CAN BE AIMED. A PDF opened at page 3 is not a PDF opened at page
33// 1. Measured, because the whole question of whether a watched document can
34// keep a reader's place rests on it.
35// 6. AND SHOWING IT AGAIN KEEPS THE PLACE. A re-show with no page named lands
36// where the file was last aimed rather than snapping to page 1 — which,
37// with (4), is what a rebuilt document needs. It is also the MOST that is
38// available: nothing can read where the reader had scrolled to, so what is
39// restored is where we put them and never where they went.
40// 7. AND IT MAY NOT TAKE A SCREEN THAT BELONGS TO ANOTHER CONVERSATION. A
41// daimon whose Diamond is not the one in view had the panel open over
42// whatever the user was doing — reported live, from a daimon editing its
43// crystal while the user worked in a different Diamond. `Tool::FileShow`
44// already refused a dispatched WORKER for exactly this reason and in these
45// words: "the document panel belongs to the conversation the user is
46// actually in". The same question is now asked of every owner. The show is
47// held, not dropped, and opens when the user reaches that Diamond.
48//
49// WHAT THIS FILE DOES NOT PROVE, and nothing here should be read as proving:
50// the Rust half. `Tool::FileShow`, its guard, its refusals and the English it
51// composes are not in `www/pkg` until the wasm is rebuilt, so this drives the
52// JavaScript contract — `window.DaimondDoc` — which is exactly where the Rust
53// edge lands. The guard and the sentence are covered by the unit tests in
54// src/tools.rs.
55//
56// EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` serves a
57// deliberately damaged copy of a source file to the real page through
58// `page.route`, and the run is expected to FAIL. A break whose anchor does not
59// appear exactly once aborts rather than passing quietly.
60//
61// node dev/verify_fileshow.mjs --break seam # 1 fails: nothing reaches the panel
62// node dev/verify_fileshow.mjs --break verdict # 2 fails: a .md is reported as rendered
63// node dev/verify_fileshow.mjs --break floor # 2 and 3 fail: everything claims to be a document
64// node dev/verify_fileshow.mjs --break stale # 4 fails: the rewritten file is not redrawn
65// node dev/verify_fileshow.mjs --break aim # 5 fails: page 3 opens at page 1
66// node dev/verify_fileshow.mjs --break place # 6 fails: the rebuild loses the reader's place
67// node dev/verify_fileshow.mjs --break screen # 7 fails: a background Diamond takes the panel
68// node dev/verify_fileshow.mjs # and then, clean
69//
70// A break may redden more than the check it is named for, and two here do:
71// `stale` takes the aim checks with it, because a panel that never redraws never
72// re-aims either. What matters is that each check goes red for the break that
73// names it, and that no check is green when its own subject is broken.
74//
75// eval "$(bash dev/world.sh 4 --up)"
76// node dev/verify_fileshow.mjs
77//
78// Needs dev/serve.mjs only. No gateway, no mock LLM, no wasm rebuild.
79import fs from 'node:fs';
80import path from 'node:path';
81import { fileURLToPath } from 'node:url';
82import { open, scratch } from './harness.mjs';
83
84const HERE = path.dirname(fileURLToPath(import.meta.url));
85const WWW = path.join(HERE, '..', 'www');
86
87const BREAK = (() => {
88 const i = process.argv.indexOf('--break');
89 return i > 0 ? String(process.argv[i + 1] || '') : '';
90})();
91
92const PROFILE = scratch('pw', 'fileshow' + (BREAK ? '-' + BREAK : ''));
93fs.rmSync(PROFILE, { recursive: true, force: true });
94
95let bad = 0;
96const check = (pass, name, detail) => {
97 if (!pass) bad++;
98 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
99};
100
101// ── The seam ─────────────────────────────────────────────────────────
102//
103// Only `daimond.js` can open a file in the Doc panel: the panel, its header and
104// the routing between the editor and the viewer are all inside that module's
105// closure. So it hands its opener to `viewer.js`, which owns everything else
106// about what showing a file means, and that one line is the seam.
107//
108// It is checked rather than assumed. If it has not landed, the run says so as a
109// FAILURE and then serves a patched copy for the rest of the file, so the design
110// can be seen working without the green being borrowed from code that is not
111// there. Once the line is in `daimond.js`, nothing is patched at all.
112
113const SEAM_SPEC = { file: 'js/daimond.js', find: '\t})();\n\n\t// ── The Terminal panel' };
114const SEAM = '\t})();\n\n'
115 + '\t// The daimon\'s door to the Doc panel. `file_show` in src/tools.rs reaches it\n'
116 + '\t// from the wasm through `window.DaimondDoc`, which `js/viewer.js` installs --\n'
117 + '\t// but only THIS module can open a file: the panel, its header and the routing\n'
118 + '\t// between the editor and the viewer all live in this closure. So the opener is\n'
119 + '\t// handed over, and viewer.js keeps the question of what showing a file MEANS.\n'
120 + '\t//\n'
121 + '\t// Registered here rather than on first use of the panel: a tool call can arrive\n'
122 + '\t// before the user has opened anything, and a door wired lazily is a door the\n'
123 + '\t// model finds shut and reports as absent.\n'
124 + '\tif (window.DaimondViewer && DaimondViewer.opener) DaimondViewer.opener(Files.open);\n\n'
125 + '\t// ── The Terminal panel';
126
127const seamLanded = () => fs.readFileSync(path.join(WWW, 'js/daimond.js'), 'utf8')
128 .indexOf('DaimondViewer.opener(Files.open)') !== -1;
129
130// ── The breaks ───────────────────────────────────────────────────────
131// Each is a real edit to a real file, served in place of it.
132const BREAKS = {
133 // The door is never opened. This is the state the defect was reported from:
134 // the panel works, the viewer works, and nothing outside `daimond.js` can
135 // reach either — so a model asked to display a file correctly concludes it
136 // has no way to, which is the sentence this whole lane exists to delete.
137 seam: 'no-seam',
138 // The verdict stops asking the panel's routing question and answers with the
139 // viewer's tier. A `.md` is then reported to the model as rendered Markdown
140 // while the user is looking at an editor — a small lie the model will repeat.
141 verdict: [{
142 file: 'js/viewer.js',
143 find: '\t\tvar tier = editable(info) ? \'editor\' : info.handler;',
144 with: '\t\tvar tier = info.handler;',
145 }],
146 // A second opinion, which is what the whole shape of this was written to
147 // avoid: the verdict asserts a document rather than reading the table.
148 floor: [{
149 file: 'js/viewer.js',
150 find: '\t\tvar tier = editable(info) ? \'editor\' : info.handler;',
151 with: '\t\tvar tier = \'doc\';',
152 }],
153 // The classic wrong optimisation: opening the file that is already open is
154 // treated as nothing to do. Harmless until the file is REWRITTEN between the
155 // two calls, which is precisely what a recompile does — and then the view is
156 // a snapshot of the old bytes and the reader is never told.
157 //
158 // Note which check this reddens and which it does not. The verdict is probed
159 // fresh either way, so the SIZE the model is told is right while the screen is
160 // wrong: a check that only read the verdict would pass here. The ink is what
161 // catches it.
162 stale: [{
163 file: 'js/daimond.js',
164 find: '\t\tasync function openFile(path, opts) {',
165 with: '\t\tasync function openFile(path, opts) {\n\t\t\tif (curFile === path) return;',
166 }],
167 // The aim is remembered and never spent, so every document opens at the top.
168 aim: [{
169 file: 'js/viewer.js',
170 find: '\t\te.src = mint(blob) + aimFrag(path);',
171 with: '\t\te.src = mint(blob);',
172 }],
173 // A show with no page forgets where this file was. Harmless on a first show
174 // and ruinous on the second: it is what makes "the PDF was rebuilt" and "I
175 // lost where I was on page 214" the same event.
176 // The question is asked and the answer is thrown away, which is the state the
177 // defect was reported from: every owner may take every screen. Note that this
178 // is the ONLY break here that reddens a check about who is looking rather than
179 // about what is drawn — the panel is perfectly correct throughout, and shows
180 // the wrong person.
181 screen: [{
182 file: 'js/viewer.js',
183 find: '\t\tif (!owner || !screenOwner) return true;',
184 with: '\t\tif (!owner || !screenOwner || owner) return true;',
185 }],
186 place: [{
187 file: 'js/viewer.js',
188 find: '\t\tif (n > 0) { aim = { path: path, page: n }; return; }\n'
189 + '\t\tif (!aim || aim.path !== path) aim = null;',
190 with: '\t\taim = (n > 0) ? { path: path, page: n } : null;',
191 }],
192};
193
194if (BREAK && !BREAKS[BREAK]) {
195 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
196 process.exit(2);
197}
198
199/// Apply one edit to `src`, or stop dead. Nothing is served that was not verified
200/// to differ from the file on disk: a break whose anchor matched nothing would
201/// leave the run green against working code and prove the opposite of its claim.
202function apply(src, spec, why) {
203 const n = src.split(spec.find).length - 1;
204 if (n !== 1) {
205 console.error(`${why}: the anchor appears ${n} times in ${spec.file}, `
206 + 'so nothing was changed and the run below would prove nothing.');
207 process.exit(2);
208 }
209 return src.replace(spec.find, spec.with);
210}
211
212// ── The fixtures ─────────────────────────────────────────────────────
213
214const ascii = (s) => Array.from(s, (c) => c.charCodeAt(0) & 0xff);
215
216/// A COMPLETE, VALID PDF whose pages carry different amounts of ink.
217///
218/// The ink is the instrument. Every check below that says "the document is on
219/// screen" or "it opened at page 3" reads a screenshot, because the DOM cannot
220/// tell a rendered page from a grey box with a broken-page glyph in it — which
221/// is exactly how a PDF that had never once appeared passed five green checks
222/// for months in `verify_fileview.mjs`.
223///
224/// Built rather than pasted: the cross-reference table carries byte offsets into
225/// the file, and a hand-written one goes stale on the first edit.
226///
227/// # Arguments
228/// * `fracs` - One entry per page: how much of that page is a black bar.
229function buildPdf(fracs) {
230 const n = fracs.length;
231 const objs = ['<< /Type /Catalog /Pages 2 0 R >>'];
232 const kids = [];
233 for (let i = 0; i < n; i++) kids.push(`${3 + i * 2} 0 R`);
234 objs.push(`<< /Type /Pages /Kids [${kids.join(' ')}] /Count ${n} >>`);
235 for (let i = 0; i < n; i++) {
236 const h = Math.max(1, Math.round(600 * fracs[i]));
237 const stream = `0 0 0 rg\n0 0 400 ${h} re\nf\n`;
238 objs.push(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 600] /Contents ${4 + i * 2} 0 R >>`);
239 objs.push(`<< /Length ${stream.length} >>\nstream\n${stream}endstream`);
240 }
241 let out = '%PDF-1.4\n%\xe2\xe3\xcf\xd3\n';
242 const at = [];
243 objs.forEach((o, i) => { at.push(out.length); out += (i + 1) + ' 0 obj\n' + o + '\nendobj\n'; });
244 const startxref = out.length;
245 out += 'xref\n0 ' + (objs.length + 1) + '\n0000000000 65535 f \n';
246 at.forEach((x) => { out += String(x).padStart(10, '0') + ' 00000 n \n'; });
247 out += 'trailer\n<< /Size ' + (objs.length + 1) + ' /Root 1 0 R >>\n'
248 + 'startxref\n' + startxref + '\n%%EOF\n';
249 return ascii(out);
250}
251
252// Three pages, each with visibly more ink than the last, so a census of the
253// screen says which page the viewer chose to open at.
254const PDF3 = buildPdf([0.05, 0.50, 0.95]);
255// The same document REBUILT: one page, and much darker than page 1 above. This
256// is the recompile, and the check is that the reader sees the new one.
257const PDF_REDO = buildPdf([0.92]);
258
259const MD = ascii('# Heading\n\nsome *text*\n');
260
261// Sixteen readable bytes then a NUL: unrecognisable to the probe, so it lands on
262// the honest floor, and the readable run proves the dump is showing THESE bytes.
263const BIN_HEAD = 'DAIMOND BYTES!!!';
264const BIN = ascii(BIN_HEAD).concat([0x00]);
265while (BIN.length < 300) BIN.push((BIN.length * 37) & 0xff);
266
267// ── Driving ──────────────────────────────────────────────────────────
268
269/// Serve whatever this run needs to be different about the page.
270///
271/// One pass over the sources, because the seam and a break can land in the SAME
272/// file: `--break stale` damages `daimond.js`, which is also where the seam goes,
273/// and two `page.route` handlers for one URL would leave one of the two edits
274/// silently unserved.
275async function stub(page) {
276 const edited = {};
277 const edit = (spec, why) => {
278 const src = edited[spec.file]
279 || fs.readFileSync(path.join(WWW, spec.file), 'utf8');
280 edited[spec.file] = apply(src, spec, why);
281 };
282 // The seam, when it is not in the tree yet. `--break seam` withholds it EITHER
283 // WAY: it skips the patch while the line is absent, and STRIPS the line once
284 // it is there.
285 //
286 // The second half arrived with the seam itself. "Withhold" used to mean only
287 // "do not add", which is the same as doing nothing the moment the line landed
288 // in daimond.js -- and this break, the one that stages the very state the
289 // defect was reported from, then passed. A break that cannot fail proves
290 // nothing about the check it is named for.
291 if (BREAK === 'seam') {
292 if (seamLanded()) {
293 edit({
294 file: 'js/daimond.js',
295 find: '\tif (window.DaimondViewer && DaimondViewer.opener) DaimondViewer.opener(Files.open);\n',
296 with: '',
297 }, 'break \'seam\'');
298 }
299 } else if (!seamLanded()) {
300 edit({ ...SEAM_SPEC, with: SEAM }, 'the seam patch');
301 }
302 if (BREAK && BREAK !== 'seam') {
303 for (const spec of BREAKS[BREAK]) edit(spec, `break '${BREAK}'`);
304 }
305 for (const file of Object.keys(edited)) {
306 const body = edited[file];
307 await page.route('**/' + file, (r) => r.fulfill({
308 status: 200, contentType: 'application/javascript', body,
309 }));
310 }
311}
312
313const s = await open({ name: 'fileshow', profile: PROFILE, connect: false, defaults: false,
314 route: stub });
315const { page } = s;
316
317/// Write bytes into the workspace through the same door the app uses.
318const put = (p, bytes) => page.evaluate(async ({ p, bytes }) => {
319 const m = await import('/pkg/oxedyne_daimond.js');
320 const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true);
321 await app.write_bytes(p, new Uint8Array(bytes));
322}, { p, bytes });
323
324/// Call the door the daimon calls, and hand back what the model would be told.
325///
326/// Resolves `{ ok, verdict }` or `{ ok:false, error }`, because a rejection is an
327/// answer too: it is what the model gets when there is no panel to show a file in.
328const show = (p, pg, owner) => page.evaluate(async ({ p, pg, owner }) => {
329 if (!window.DaimondDoc || typeof DaimondDoc.show !== 'function') {
330 return { ok: false, error: 'window.DaimondDoc is not on the page' };
331 }
332 try {
333 // Three arguments and not two: `owner` is what the wasm passes from
334 // `ToolContext::daimon`, and a call that omits it is the user's own act.
335 const json = await DaimondDoc.show(p, pg, owner);
336 return { ok: true, verdict: JSON.parse(json) };
337 } catch (e) {
338 return { ok: false, error: String((e && e.message) || e) };
339 }
340}, { p, pg, owner });
341
342/// What the PREVIEW PANEL holds — `#pv-view`, the panel a person reads, never a
343/// host of this file's own. The claim being made is about the user's screen.
344///
345/// It was `#doc-view` until the Doc panel was split in two. A file that is not
346/// characters is a RENDERING of a file, so it is drawn in the Preview panel and the
347/// Doc panel is left holding whatever is being edited — which is the point of the
348/// split, and is why `docPre` is read as well: it is the assertion that the
349/// document beside this one was NOT replaced by it.
350const inPanel = () => page.evaluate(() => {
351 const fv = document.querySelector('#pv-view .fileview');
352 const pre = document.querySelector('#pv-view .files-view-body');
353 const em = document.querySelector('#pv-view .fileview embed');
354 const hex = document.querySelector('#pv-view .fileview .fv-hex');
355 const nm = document.getElementById('pv-name');
356 const dp = document.querySelector('#doc-view .files-view-body');
357 return {
358 viewer: fv ? fv.getAttribute('data-viewer') : null,
359 embed: em ? { type: em.getAttribute('type'), frag: (em.src.split('#')[1] || '') } : null,
360 hex: hex ? hex.textContent : null,
361 pre: pre ? pre.textContent.slice(0, 120) : null,
362 name: nm ? nm.textContent : null,
363 docPre: dp ? dp.textContent.slice(0, 120) : null,
364 };
365});
366
367/// A census of light and dark inside `sel`, decoded from a screenshot by the page
368/// itself. THE ONLY INSTRUMENT THAT CAN TELL A RENDERED PAGE FROM A BROKEN ONE:
369/// both are an element with a `blob:` URL on it.
370const inkCensus = async (sel) => {
371 const box = await page.locator(sel).boundingBox().catch(() => null);
372 if (!box) return null;
373 const png = await page.screenshot({ clip: box, timeout: 15000 }).catch(() => null);
374 if (!png) return null;
375 return page.evaluate((b64) => new Promise((res) => {
376 const img = new Image();
377 img.onload = () => {
378 const c = document.createElement('canvas');
379 c.width = img.naturalWidth; c.height = img.naturalHeight;
380 const g = c.getContext('2d');
381 g.drawImage(img, 0, 0);
382 const d = g.getImageData(0, 0, c.width, c.height).data;
383 let dark = 0, light = 0, n = 0;
384 for (let i = 0; i < d.length; i += 4) {
385 const l = 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2];
386 if (l < 40) dark++; else if (l > 230) light++;
387 n++;
388 }
389 res({ dark: dark / n, light: light / n });
390 };
391 img.onerror = () => res(null);
392 img.src = 'data:image/png;base64,' + b64;
393 }), png.toString('base64'));
394};
395
396const pct = (x) => (x == null ? '?' : (x * 100).toFixed(1) + '%');
397
398try {
399 await page.waitForTimeout(1200);
400
401 check(seamLanded(), 'the seam is in js/daimond.js: the panel hands its opener to the viewer',
402 seamLanded() ? '' : 'NOT YET — the rest of this run drives a patched copy served '
403 + 'through page.route, so the green below belongs to the design and not to the tree');
404
405 await put('show/report.pdf', PDF3);
406 await put('show/notes.md', MD);
407 await put('show/thing.bin', BIN);
408
409 // ── 1. A file the model names ends up on the screen ──────────
410 const one = await show('show/report.pdf');
411 check(one.ok, 'the daimon has a door to the document panel at all',
412 one.ok ? '' : one.error);
413 const panel1 = one.ok ? await inPanel() : null;
414 check(!!(panel1 && panel1.viewer === 'doc' && panel1.embed
415 && panel1.embed.type === 'application/pdf'),
416 'and naming a PDF puts it in the panel, typed for the browser’s own viewer',
417 panel1 ? JSON.stringify(panel1.embed) : 'the panel holds nothing');
418 check(!!(panel1 && panel1.name === 'show/report.pdf'),
419 'with the panel naming the file the model asked for',
420 panel1 ? String(panel1.name) : '');
421
422 // AND IT IS DRAWN. Everything above is true of a grey box with a
423 // broken-page glyph in it. The gap is enormous: a sandboxed frame over the
424 // same file scores 0.0% dark and 0.0% light, and a rendered page scores tens
425 // of percent of each, so 2% is nowhere near either state.
426 await page.waitForTimeout(2500);
427 const ink1 = await inkCensus('#pv-view .fv-doc');
428 check(!!ink1 && ink1.dark > 0.02 && ink1.light > 0.02,
429 'and the document is DRAWN — ink on paper, read off the screen',
430 ink1 ? `dark ${pct(ink1.dark)}, light ${pct(ink1.light)}` : 'nothing to measure');
431
432 // ── 2. The model is told what is on the screen ───────────────
433 const v1 = one.ok ? one.verdict : {};
434 check(v1.tier === 'doc' && v1.media === 'Pdf',
435 'the model is told which tier drew it and what the format is',
436 JSON.stringify({ tier: v1.tier, media: v1.media }));
437 check(v1.size === PDF3.length,
438 'and how big the file is, from the file rather than from a guess',
439 `said ${v1.size}, wrote ${PDF3.length}`);
440 check(!!v1.label && v1.label !== v1.media,
441 'in words rather than in an identifier, so the sentence reads',
442 JSON.stringify(v1.label));
443
444 // The verdict answers the PANEL'S routing question, not the viewer's. A `.md`
445 // keeps the editor — this is where DAIMOND.md and the role prompts are
446 // changed — and a model told "rendered Markdown" would describe a screen the
447 // user is not looking at.
448 const md = await show('show/notes.md');
449 const panelMd = await inPanel();
450 check(md.ok && md.verdict.tier === 'editor',
451 'a Markdown file is reported as being in the editor',
452 md.ok ? String(md.verdict.tier) : md.error);
453 // `docPre`, the DOC panel's body: an editable file goes there, beside whatever
454 // rendering the Preview panel is holding rather than over it.
455 check(!!(panelMd.docPre && /Heading/.test(panelMd.docPre)),
456 'and that is what the panel is really showing',
457 JSON.stringify(panelMd.docPre));
458
459 // ── 3. A format with no viewer is shown, and said ────────────
460 const bin = await show('show/thing.bin');
461 const panelBin = await inPanel();
462 check(bin.ok && bin.verdict.tier === 'hex',
463 'a format nothing recognises is reported as the hex dump it is',
464 bin.ok ? String(bin.verdict.tier) : bin.error);
465 check(!!(panelBin.hex && panelBin.hex.indexOf(BIN_HEAD) !== -1),
466 'and it IS shown — the dump on screen carries this file’s own bytes',
467 panelBin.hex ? JSON.stringify(panelBin.hex.slice(0, 60)) : 'no dump');
468
469 // ── 4. A live handle, not a snapshot ─────────────────────────
470 //
471 // The whole of what makes a recompiled document reach the reader. The tool
472 // names a PATH; the panel reads the file; so writing the file again and
473 // showing the same path again puts the new bytes on screen. A view handed
474 // CONTENT could not do this at all.
475 await show('show/report.pdf');
476 await page.waitForTimeout(2500);
477 const before = await inkCensus('#pv-view .fv-doc');
478 await put('show/report.pdf', PDF_REDO);
479 const redo = await show('show/report.pdf');
480 await page.waitForTimeout(2500);
481 const after = await inkCensus('#pv-view .fv-doc');
482 check(redo.ok && redo.verdict.size === PDF_REDO.length,
483 'showing the same path after a rewrite reports the NEW file’s size',
484 redo.ok ? `said ${redo.verdict.size}, wrote ${PDF_REDO.length}` : redo.error);
485 check(!!(before && after && Math.abs(after.dark - before.dark) > 0.05),
486 'and the reader is looking at the rewritten document, not the old one',
487 before && after ? `dark ${pct(before.dark)} → ${pct(after.dark)}` : 'nothing to measure');
488
489 // ── 5. And it can be aimed ───────────────────────────────────
490 //
491 // Measured because everything a watched document could do about the reader's
492 // place rests on it. `#page=N` on a `blob:` URL DOES reach the browser's PDF
493 // viewer; nothing reads the position back out, so a document can be reopened
494 // where it was last AIMED and never where the reader had scrolled to.
495 await put('show/report.pdf', PDF3);
496 await show('show/report.pdf', 1);
497 await page.waitForTimeout(2500);
498 const at1 = await inkCensus('#pv-view .fv-doc');
499 const aimed = await show('show/report.pdf', 3);
500 await page.waitForTimeout(2500);
501 const at3 = await inkCensus('#pv-view .fv-doc');
502 const panelAimed = await inPanel();
503 check(!!(panelAimed.embed && panelAimed.embed.frag === 'page=3'),
504 'a page asked for reaches the element as a fragment',
505 panelAimed.embed ? JSON.stringify(panelAimed.embed.frag) : 'no embed');
506 check(!!(at1 && at3 && at3.dark - at1.dark > 0.1),
507 'and page 3 is on screen rather than page 1 — the ink says so',
508 at1 && at3 ? `dark ${pct(at1.dark)} at page 1, ${pct(at3.dark)} at page 3` : 'nothing to measure');
509 check(aimed.ok && aimed.verdict.page === 3,
510 'with the model told which page it is actually looking at',
511 aimed.ok ? String(aimed.verdict.page) : aimed.error);
512
513 // ── 6. And showing it AGAIN keeps the place ──────────────────
514 //
515 // The two halves of a watched document: it must come back with the new bytes
516 // (4) and it must come back where the reader was (this). Nothing can read
517 // where they had SCROLLED to — the browser's PDF viewer answers no query and
518 // says nothing when it moves — so the most that is available is that a
519 // re-show with no page named lands where the file was last aimed, rather than
520 // snapping to page 1. That is what is checked here, and the page reported to
521 // the model is the one the panel used and not the argument it was given.
522 await put('show/report.pdf', PDF3); // the rebuild: same path, same three pages
523 const again = await show('show/report.pdf');
524 await page.waitForTimeout(2500);
525 const stillAt3 = await inkCensus('#pv-view .fv-doc');
526 check(again.ok && again.verdict.page === 3,
527 'showing it again with no page named reports the page it was left at',
528 again.ok ? String(again.verdict.page) : again.error);
529 check(!!(stillAt3 && at1 && stillAt3.dark - at1.dark > 0.1),
530 'and the reader is still on page 3 rather than back at page 1',
531 stillAt3 && at1 ? `dark ${pct(stillAt3.dark)} against ${pct(at1.dark)} at page 1`
532 : 'nothing to measure');
533
534 // ── 7. And it may not take a screen that belongs elsewhere ───
535 //
536 // The owner is stubbed rather than driven through the rail: what is under test
537 // is the RULE — does a show name the conversation asking, and is it compared
538 // with the one in view — and building two Diamonds to answer it would put the
539 // rail's own defects between this check and the thing it measures. The stub
540 // goes in through the same door `daimond.js` uses, so the seam being exercised
541 // is the shipped one and not a copy.
542 //
543 // Note what is asserted about the REFUSED show, because the weak version of
544 // this check is "it returned something": the panel must still hold the file
545 // from the show BEFORE it, which is the actual complaint — a user mid-document
546 // having it swapped underneath them.
547 await put('show/other.pdf', PDF3);
548 await page.evaluate(() => DaimondViewer.screenOwner(() => 'd-onscreen'));
549
550 const mine = await show('show/report.pdf', 1, 'd-onscreen');
551 await page.waitForTimeout(1500);
552 check(mine.ok && mine.verdict.shown === true,
553 'the Diamond the user is looking at may put a file on their screen',
554 mine.ok ? JSON.stringify(mine.verdict.shown) : mine.error);
555 const held = await inPanel();
556
557 const theirs = await show('show/other.pdf', 1, 'd-elsewhere');
558 await page.waitForTimeout(1500);
559 check(theirs.ok && theirs.verdict.shown === false,
560 'and a Diamond the user is NOT looking at is told its show did not land',
561 theirs.ok ? JSON.stringify(theirs.verdict.shown) : theirs.error);
562 const kept = await inPanel();
563 check(kept.name === held.name && held.name === 'show/report.pdf',
564 'while the panel goes on holding the document the user was actually reading',
565 `was ${JSON.stringify(held.name)}, now ${JSON.stringify(kept.name)}`);
566
567 // HELD, NOT DROPPED. The refusal is "not yet", so the file has to be waiting
568 // when the user reaches that Diamond — and waiting exactly once, or every
569 // later visit reopens a panel about a turn everyone has forgotten.
570 const waiting = await page.evaluate(() => [
571 DaimondViewer.takeDeferred('d-elsewhere'),
572 DaimondViewer.takeDeferred('d-elsewhere'),
573 ]);
574 check(waiting[0] === 'show/other.pdf',
575 'and the file it wanted shown is held for when the user opens that Diamond',
576 JSON.stringify(waiting[0]));
577 check(waiting[0] !== '' && waiting[1] === '',
578 'once, so a later visit does not reopen a panel about a forgotten turn',
579 JSON.stringify(waiting));
580} finally {
581 await s.close();
582}
583
584console.log(bad ? `\n${bad} check(s) FAILED` : '\nall checks passed');
585process.exit(bad ? 1 : 0);