Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_office.mjs

44.2 KiB, 1 run

created by r2519314175:553, 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_office.mjs — a Word document opens as what it SAYS, and never as a hex dump.
2//
3// THE DEFECT THIS CLOSES WAS LIVE, NOT MISSING. `oxedyne_fe2o3_stds::media` has
4// named `.docx` correctly since it was written -- `Media::Docx`, "Word document"
5// -- and `KIND_HANDLERS` in `www/js/viewer.js` had no entry for it. So clicking
6// somebody's CV filled the Doc panel with a PAGED HEX DUMP, under a header
7// naming the format it had just declined to draw. Every other tier in that file
8// exists to stop exactly this, and the commonest document format on earth fell
9// through the one gap in the table.
10//
11// The checks read the RENDERED DOM, on a document THIS PROJECT DID NOT WRITE:
12// the fixture is a `.docx` LibreOffice produced from HTML whose content we
13// chose, so the intent is ours and the bytes are somebody else's. A verifier
14// that rendered our own writer's output would prove that the writer and the
15// reader share their assumptions, which is the thing worth doubting.
16//
17// * a `.docx` routes to the `office` tier and NOT to `hex`;
18// * its headings arrive as headings and its list as a list, in the DOM;
19// * the panel SAYS it is a reading view, because a rendering that claimed to
20// be the document would be claiming something it cannot do;
21// * an encrypted document is NAMED and falls to the honest floor, rather than
22// being shown as the rubble it decodes to;
23// * and the hex dump is still what an unknown ZIP gets, so the fix routed one
24// format rather than opening a door for every archive.
25//
26// TO SEE THESE FAIL, break it like this:
27//
28// * `viewer.js`, `KIND_HANDLERS`: remove the `Docx: 'office'` line. The first
29// three checks go red and the panel is a hex dump again -- that line IS the
30// defect, and this is it reproduced.
31// * `viewer.js`, `office`: drop the `fileview.office_reading` paragraph and
32// the "says it is a reading view" check goes red.
33// * `wasm/office.rs`, `office_read_docx`: return the markdown without calling
34// `say_undrawn`, and the band check stays green while the app stops being
35// able to tell anyone what it left out. That check is therefore ALSO in the
36// Rust suite, over the counting itself, where breaking it is visible.
37//
38// Run: node dev/verify_office.mjs
39
40import fs from 'node:fs';
41import path from 'node:path';
42import { spawnSync } from 'node:child_process';
43import { open, scratch } from './harness.mjs';
44
45const PROFILE = scratch('pw', 'office');
46
47let pass = 0, fail = 0;
48const check = (ok, name, detail) => {
49 if (ok) { pass++; console.log(' ok ' + name); }
50 else { fail++; console.log(' FAIL ' + name + (detail ? ' — ' + detail : '')); }
51};
52
53/// The fixture, shared with the Rust suite so one document is the subject of
54/// both. It is LibreOffice's output, not ours.
55const FIXTURE = new URL(
56 '../../../../../rust/fe2o3/fe2o3_file/tests/data/rich.docx', import.meta.url);
57const DOCX = Array.from(fs.readFileSync(FIXTURE));
58
59/// The same writer's output, holding a picture: the one thing in this set a
60/// reading view genuinely cannot draw, and therefore the one that proves the
61/// band says so rather than merely being able to.
62const WITHPIC = Array.from(fs.readFileSync(new URL(
63 '../../../../../rust/fe2o3/fe2o3_file/tests/data/withpic.docx', import.meta.url)));
64
65/// A `.xlsx` LibreOffice wrote, holding a formula whose cached value is what
66/// everyone sees, a date stored as a serial under a custom number format, and a
67/// row that skips two columns.
68const XLSX = Array.from(fs.readFileSync(new URL(
69 '../../../../../rust/fe2o3/fe2o3_file/tests/data/foreign.xlsx', import.meta.url)));
70
71/// The OpenDocument pair, both written by LibreOffice. They reach the SAME two
72/// tiers as their Microsoft counterparts, which is the claim worth checking:
73/// what a reader wants out of a text document does not depend on which
74/// vocabulary it was written in.
75const ODT = Array.from(fs.readFileSync(new URL(
76 '../../../../../rust/fe2o3/fe2o3_file/tests/data/foreign.odt', import.meta.url)));
77const ODS = Array.from(fs.readFileSync(new URL(
78 '../../../../../rust/fe2o3/fe2o3_file/tests/data/foreign.ods', import.meta.url)));
79
80/// The two decks. They are here to prove an ABSENCE: both can be read, and
81/// neither may be offered an edit, because a slide is a position on a canvas and
82/// changing the words without knowing the geometry puts text over other text.
83const PPTX = Array.from(fs.readFileSync(new URL(
84 '../../../../../rust/fe2o3/fe2o3_file/tests/data/foreign.pptx', import.meta.url)));
85const ODP = Array.from(fs.readFileSync(new URL(
86 '../../../../../rust/fe2o3/fe2o3_file/tests/data/foreign.odp', import.meta.url)));
87
88/// The leading bytes of an OLE compound file, which is what an encrypted Office
89/// document is: the real document is inside it and there is no password here.
90const ENCRYPTED = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]
91 .concat(new Array(512).fill(0x00));
92
93/// A ZIP that is not an Office document, so the floor is still the floor.
94const PLAIN_ZIP = Array.from(Buffer.from('UEsFBgAAAAAAAAAAAAAAAAAAAAAAAA==', 'base64'));
95
96const s = await open({ name: 'office', profile: PROFILE, connect: false, defaults: false });
97const { page } = s;
98
99const put = (path, bytes) => page.evaluate(async ({ path, bytes }) => {
100 const m = await import('/pkg/oxedyne_daimond.js');
101 const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true);
102 await app.write_bytes(path, new Uint8Array(bytes));
103}, { path, bytes });
104
105/// Renders one file into a host of this test's own and answers what the probe
106/// said, so a check reads the viewer rather than the panel around it.
107const view = (path) => page.evaluate(async (path) => {
108 let host = document.getElementById('ov-host');
109 if (!host) {
110 host = document.createElement('div');
111 host.id = 'ov-host';
112 host.style.cssText = 'position:fixed;left:0;bottom:0;width:520px;height:420px;'
113 + 'display:flex;flex-direction:column;z-index:99999';
114 document.body.appendChild(host);
115 }
116 const info = await window.DaimondViewer.probe(path, {});
117 await window.DaimondViewer.show(host, path, info, {
118 t: (k, v) => (window.DaimondI18n ? DaimondI18n.t(k, v) : k),
119 onError: (e) => { window.__ovLastError = String(e && e.message ? e.message : e); },
120 });
121 return info;
122}, path);
123
124/// What is on screen, read by meaning rather than by the viewer's bookkeeping.
125const seen = () => page.evaluate(() => {
126 const root = document.querySelector('#ov-host .fileview');
127 if (!root) return null;
128 const md = root.querySelector('.fv-md');
129 const grid = Array.from(root.querySelectorAll('.fv-table tr')).map(
130 (tr) => Array.from(tr.children).map((c) => c.textContent));
131 return {
132 grid: grid,
133 sheetNames: Array.from(root.querySelectorAll('.fv-sheetname')).map(n => n.textContent),
134 handler: root.getAttribute('data-viewer'),
135 notes: Array.from(root.querySelectorAll('.fv-note, .fv-warn')).map(n => n.textContent),
136 headings: Array.from(root.querySelectorAll('.fv-md h1, .fv-md h2'))
137 .map(n => n.tagName + ':' + n.textContent.trim()),
138 items: Array.from(root.querySelectorAll('.fv-md li')).map(n => n.textContent.trim()),
139 links: Array.from(root.querySelectorAll('.fv-md a')).map(n => n.getAttribute('href')),
140 strong: Array.from(root.querySelectorAll('.fv-md strong')).map(n => n.textContent),
141 text: md ? md.textContent : '',
142 hasHex: !!root.querySelector('.fv-hex, .fv-hexpage'),
143 all: root.textContent,
144 };
145});
146
147console.log('\n-- a Word document is drawn as what it says --');
148await put('office/report.docx', DOCX);
149const info = await view('office/report.docx');
150const got = await seen();
151check(info.media === 'Docx', 'the probe names it a Word document', info.media);
152check(!!got && got.handler === 'office', 'and it routes to the office tier, not the hex dump',
153 got ? got.handler : 'nothing rendered');
154check(!!got && !got.hasHex, 'there is no hex dump on screen', got && got.hasHex ? 'a dump' : '');
155check(!!got && got.headings.includes('H1:Quarterly Review'),
156 'its title arrives as a heading', got ? JSON.stringify(got.headings) : 'nothing');
157check(!!got && got.headings.includes('H2:Findings'),
158 'and so does its second-level heading', got ? JSON.stringify(got.headings) : 'nothing');
159check(!!got && got.items.some(t => t.startsWith('First finding')),
160 'its list arrives as a list', got ? JSON.stringify(got.items.slice(0, 3)) : 'nothing');
161check(!!got && got.links.includes('https://example.org/detail'),
162 'a link keeps the target the relationships part gave it',
163 got ? JSON.stringify(got.links) : 'nothing');
164check(!!got && got.strong.some(t => t.indexOf('bold words') !== -1),
165 'and bold text is bold rather than a font size',
166 got ? JSON.stringify(got.strong) : 'nothing');
167
168console.log('\n-- and it says what it is --');
169check(!!got && got.notes.some(t => /reading view/i.test(t)),
170 'the panel says this is a reading view, not how the document prints',
171 got ? JSON.stringify(got.notes) : 'nothing');
172// This fixture has nothing undrawable in it, so the band must NOT invent one.
173check(!!got && !got.notes.some(t => /not drawn/i.test(t)),
174 'and it claims nothing is missing only when nothing is',
175 got ? JSON.stringify(got.notes) : 'nothing');
176
177console.log('\n-- and it says what it could not draw, by name and by count --');
178await put('office/withpic.docx', WITHPIC);
179await view('office/withpic.docx');
180const pic = await seen();
181check(!!pic && pic.notes.some(t => /1 thing.*not drawn/i.test(t)),
182 'a document with a picture says one thing is not drawn',
183 pic ? JSON.stringify(pic.notes) : 'nothing');
184check(!!pic && pic.notes.some(t => /1 image/i.test(t)),
185 'and names WHAT, because the kind is half the information',
186 pic ? JSON.stringify(pic.notes) : 'nothing');
187check(!!pic && /Text after the picture/.test(pic.text),
188 'the prose after the drawing is still there', pic ? pic.text.slice(0, 120) : 'nothing');
189
190console.log('\n-- a spreadsheet is drawn as the grid it is, with the STORED values --');
191await put('office/ledger.xlsx', XLSX);
192const xinfo = await view('office/ledger.xlsx');
193const grid = await seen();
194check(xinfo.media === 'Xlsx', 'the probe names it a spreadsheet', xinfo.media);
195check(!!grid && grid.handler === 'sheet', 'and it routes to the sheet tier, not the hex dump',
196 grid ? grid.handler : 'nothing rendered');
197check(!!grid && !grid.hasHex, 'there is no hex dump on screen', grid && grid.hasHex ? 'a dump' : '');
198// Both sheets, because a workbook whose second sheet is silently absent is one a
199// person makes a decision on without knowing what they missed.
200check(!!grid && grid.sheetNames.join(',') === 'Sales,Notes', 'every sheet is drawn under its name',
201 grid ? JSON.stringify(grid.sheetNames) : 'nothing');
202// The column letters and row numbers, which are how a person names a cell to
203// somebody else and how sheet_read takes a range.
204// The guard is `&&`-chained all the way to the indexing, and the DETAIL is too.
205// A check that throws where it should report FAIL stops the run, and every later
206// check then reports nothing at all -- which reads as a smaller failure than it
207// is. Reaching into `grid.grid[0]` in the detail argument did exactly that the
208// first time this was driven red.
209const head = (grid && grid.grid.length ? grid.grid[0] : []).slice(0, 4);
210check(!!grid && grid.grid.length > 1 && head.join('') === 'ABC',
211 'the column letters and row numbers are drawn', JSON.stringify(head));
212const flat = grid ? grid.grid.map(r => r.join('|')).join('\n') : '';
213check(/North\|120\|3\.4\|408\b/.test(flat), 'a formula shows the value STORED in the file',
214 flat.split('\n').slice(0, 4).join(' / '));
215check(!/B2\*C2/.test(flat), 'and never the formula in place of it',
216 flat.indexOf('B2*C2') !== -1 ? 'the formula is in the grid' : '');
217// The date trap: a serial with no style applied would read as 46095.
218check(/2026-03-14/.test(flat), 'a date is a date and not a five-digit number',
219 /46095/.test(flat) ? 'the serial number is on screen' : flat.slice(0, 80));
220check(/Total\|\|\|1343/.test(flat), 'a row that skips columns keeps its alignment',
221 flat.split('\n').find(l => l.indexOf('Total') !== -1) || 'no total row');
222check(!!grid && grid.notes.some(t => /not recalculated/i.test(t)),
223 'and the panel says the values are stored rather than computed',
224 grid ? JSON.stringify(grid.notes) : 'nothing');
225
226console.log('\n-- and OpenDocument reaches the same two tiers --');
227await put('office/report.odt', ODT);
228const oinfo = await view('office/report.odt');
229const odt = await seen();
230check(oinfo.media === 'Odt', 'a .odt is named as one and not as a bare archive', oinfo.media);
231check(!!odt && odt.handler === 'office', 'and it goes to the same tier a .docx does',
232 odt ? odt.handler : 'nothing rendered');
233check(!!odt && odt.headings.includes('H1:A Report On Something'),
234 'its headings arrive as headings', odt ? JSON.stringify(odt.headings) : 'nothing');
235check(!!odt && odt.strong.some(t => t.indexOf('bold words') !== -1),
236 'and its bold text is bold -- which needs the style resolved by its PROPERTIES',
237 odt ? JSON.stringify(odt.strong) : 'nothing');
238check(!!odt && odt.items.some(t => t.startsWith('First bullet')),
239 'its list arrives as a list', odt ? JSON.stringify(odt.items.slice(0, 2)) : 'nothing');
240
241await put('office/ledger.ods', ODS);
242const sinfo = await view('office/ledger.ods');
243const ods = await seen();
244check(sinfo.media === 'Ods', 'a .ods is named as one, from its own opening bytes', sinfo.media);
245check(!!ods && ods.handler === 'sheet', 'and it goes to the same tier a .xlsx does',
246 ods ? ods.handler : 'nothing rendered');
247const oflat = ods ? ods.grid.map(r => r.join('|')).join('\n') : '';
248check(/North\|120\|3\.4\|408\b/.test(oflat), 'showing the value STORED in the file',
249 oflat.split('\n').slice(0, 4).join(' / '));
250check(/2026-03-14/.test(oflat), 'and a date rather than a serial number', oflat.slice(0, 80));
251
252// A file somebody renamed is still what it is: the format is read from the
253// archive's own `mimetype` member, which OpenDocument requires to be first and
254// stored for exactly this purpose.
255await put('office/holiday.zip', ODT);
256const renamed = await view('office/holiday.zip');
257check(renamed.media === 'Odt', 'a renamed .odt is still a .odt, by its own bytes', renamed.media);
258
259console.log('\n-- what cannot be read is named, not guessed at --');
260await put('office/secret.docx', ENCRYPTED);
261await view('office/secret.docx');
262const enc = await seen();
263check(!!enc && /encrypted/i.test(enc.all),
264 'an encrypted document says it is encrypted', enc ? enc.all.slice(0, 160) : 'nothing');
265check(!!enc && enc.hasHex,
266 'and falls to the honest floor rather than a blank panel', enc ? 'no dump' : 'nothing');
267
268console.log('\n-- the floor is still the floor --');
269await put('office/plain.zip', PLAIN_ZIP);
270const zinfo = await view('office/plain.zip');
271const zip = await seen();
272check(zinfo.media === 'Zip', 'an ordinary archive is still an ordinary archive', zinfo.media);
273check(!!zip && zip.handler === 'hex',
274 'and it is shown as its bytes, so one format was routed and not every archive',
275 zip ? zip.handler : 'nothing rendered');
276
277const table = await page.evaluate(() => window.DaimondViewer.KIND_HANDLERS);
278check(table.Docx === 'office', 'the table routes Docx', 'KIND_HANDLERS.Docx = ' + table.Docx);
279check(table.Xlsx === 'sheet', 'and Xlsx, now that one can genuinely be read',
280 'KIND_HANDLERS.Xlsx = ' + table.Xlsx);
281check(table.Odt === 'office' && table.Ods === 'sheet',
282 'and the OpenDocument pair share those two tiers',
283 'Odt = ' + table.Odt + ', Ods = ' + table.Ods);
284// Both CAN be read; what they lack is a tier that draws slides rather than
285// paragraphs, and the wording for one. Until that exists the dump is the honest
286// answer, because it tells a person that nothing was interpreted for them.
287check(!table.Pptx && !table.Odp,
288 'and neither presentation format is claimed yet: a handler that opens and then apologises '
289 + 'is worse than the dump',
290 'Pptx = ' + table.Pptx + ', Odp = ' + table.Odp);
291
292// ── AND WHAT A READER MAY DO TO IT ───────────────────────────────────
293//
294// Reading was the whole of it. `office_write_docx` and `office_write_left` were
295// exported from the wasm with NO CALLER IN `www/js/` AT ALL: the app could turn
296// Markdown into a real document, and nothing a person could press asked it to.
297// That is the third time this project has listed a capability as done with no
298// production caller, so these checks are about REACH first and correctness
299// second -- a control that is absent, or 0x0, or wired to nothing, is the defect.
300//
301// TO SEE THESE FAIL:
302//
303// * `viewer.js`, `actions`: return before appending `save`. Every "a copy
304// reaches the user" check goes red.
305// * `viewer.js`, `markdown`: drop the `saveAsRow` call and the writer has no
306// caller again -- which is the state this section was written against.
307// * `viewer.js`, `KIND_HANDLERS`: add `Pptx: 'office'` and the routing check
308// goes red. `EDIT_DOOR`: add `Pptx` and the table check goes red. Neither
309// break moves the OTHER one, and that is why both are here: a deck reaches
310// no reading tier, so it reaches no controls, so a DOM check alone would
311// stay green while an editor sat waiting behind a routing line one commit
312// from changing.
313
314const SOFFICE = '/usr/bin/soffice';
315
316/// Click `sel` if it is on screen, and say whether it was. A check that THROWS
317/// where it should report FAIL stops the run, and every later check then reports
318/// nothing at all -- which reads as a smaller failure than it is. This file
319/// already carries that lesson about an index into a grid; a control that has
320/// been removed is the same trap wearing a different hat.
321const clickIf = async (sel) => {
322 if (!(await page.$(sel))) return false;
323 await page.click(sel);
324 return true;
325};
326
327/// Fill `sel` if it is on screen.
328const fillIf = async (sel, text) => {
329 if (!(await page.$(sel))) return false;
330 await page.fill(sel, text);
331 return true;
332};
333
334/// The bytes of the next download, and the name the browser was given.
335const saved = async (click) => {
336 const wait = page.waitForEvent('download', { timeout: 15000 }).catch(() => null);
337 if (!(await click())) return null;
338 const dl = await wait;
339 if (!dl) return null;
340 const out = scratch('office-out', dl.suggestedFilename());
341 await dl.saveAs(out);
342 return { name: dl.suggestedFilename(), bytes: fs.readFileSync(out), path: out };
343};
344
345/// What LibreOffice reads out of a file this code wrote. THE ORACLE: a verifier
346/// that read our own output back with our own reader would prove the writer and
347/// the reader share their assumptions, which is the thing worth doubting.
348const oracleText = (file) => {
349 const dir = scratch('office-lo', 'out');
350 fs.mkdirSync(dir, { recursive: true });
351 const r = spawnSync(SOFFICE, ['--headless',
352 '-env:UserInstallation=file://' + scratch('office-lo', 'profile'),
353 '--convert-to', 'txt:Text', file, '--outdir', dir], { timeout: 180000 });
354 if (r.status !== 0) return null;
355 const txt = path.join(dir, path.basename(file).replace(/\.[^.]+$/, '.txt'));
356 return fs.existsSync(txt) ? fs.readFileSync(txt, 'utf8') : null;
357};
358
359/// What LibreOffice makes of a workbook this code wrote. CSV rather than text
360/// because a cell is the unit: the conversion recalculates on load, so a formula
361/// written with unbracketed references comes back as `Err:510` here and as a
362/// perfectly good stored number in any reader of ours.
363const oracleCsv = (file) => {
364 const dir = scratch('office-lo', 'csv');
365 fs.mkdirSync(dir, { recursive: true });
366 const r = spawnSync(SOFFICE, ['--headless',
367 '-env:UserInstallation=file://' + scratch('office-lo', 'profile'),
368 '--convert-to', 'csv:Text - txt - csv (StarCalc)', file, '--outdir', dir],
369 { timeout: 180000 });
370 if (r.status !== 0) return null;
371 const out = path.join(dir, path.basename(file).replace(/\.[^.]+$/, '.csv'));
372 return fs.existsSync(out) ? fs.readFileSync(out, 'utf8') : null;
373};
374
375/// The controls on screen, with their measured boxes. A control the DOM has and
376/// the screen does not is the failure `daimond.js:8323` records, and reading
377/// `querySelector` alone would report it as present.
378const controls = () => page.evaluate(() => {
379 const root = document.querySelector('#ov-host .fileview');
380 if (!root) return null;
381 const box = (n) => { const r = n.getBoundingClientRect();
382 return { text: n.textContent, w: Math.round(r.width), h: Math.round(r.height) }; };
383 const row = root.querySelector('.fv-editrow');
384 return {
385 save: Array.from(root.querySelectorAll('.fv-save')).map(box),
386 edit: Array.from(root.querySelectorAll('.fv-edit')).map(box),
387 row: row ? { hidden: row.hidden, fields:
388 Array.from(row.querySelectorAll('[data-edit]')).map(n => n.getAttribute('data-edit')) } : null,
389 };
390});
391
392console.log('\n-- a copy of the document reaches the user --');
393await view('office/report.docx');
394const dcon = await controls();
395check(!!dcon && dcon.save.length === 1, 'a text document offers to save a copy',
396 dcon ? JSON.stringify(dcon.save) : 'nothing rendered');
397// NOT `querySelector` alone. A settings-pane control that could not be found on
398// screen is already recorded in this tree, and a lane put a switch in the same
399// place last session and had to move it.
400check(!!dcon && dcon.save[0] && dcon.save[0].w > 40 && dcon.save[0].h > 12,
401 'and the control has a box on screen rather than being 0x0',
402 dcon && dcon.save[0] ? dcon.save[0].w + 'x' + dcon.save[0].h : 'no button');
403const copy = await saved(() => clickIf('#ov-host .fv-save'));
404check(!!copy, 'pressing it hands a file over', copy ? copy.name : 'no download');
405check(!!copy && copy.name === 'report.docx', 'under the name it already had', copy ? copy.name : '');
406// THE SAME BYTES. An unedited save is the file, not a re-encoding of it: the
407// contract's whole rule about a stranger's document surviving intact is void if
408// the copy is already different before anybody has edited anything.
409check(!!copy && copy.bytes.length === DOCX.length
410 && Buffer.compare(copy.bytes, Buffer.from(DOCX)) === 0,
411 'and the copy is the file, byte for byte, not a re-encoding of it',
412 copy ? copy.bytes.length + ' vs ' + DOCX.length : 'no download');
413
414await view('office/ledger.xlsx');
415const scon = await controls();
416check(!!scon && scon.save.length === 1, 'a spreadsheet offers the same',
417 scon ? JSON.stringify(scon.save) : 'nothing rendered');
418
419console.log('\n-- and Markdown becomes a real document --');
420const MD = '# Quarterly Review\n\nAn opening paragraph with **bold words**.\n\n'
421 + '## Findings\n\n- First finding\n- Second finding\n';
422await put('office/notes.md', Array.from(Buffer.from(MD)));
423await view('office/notes.md');
424const mcon = await controls();
425check(!!mcon && mcon.save.length >= 1,
426 'a Markdown file offers to be written out as a document — the writer had no caller at all',
427 mcon ? JSON.stringify(mcon.save.map(b => b.text)) : 'nothing rendered');
428check(!!mcon && mcon.save.some(b => b.w > 40 && b.h > 12),
429 'and that control has a box on screen too',
430 mcon ? JSON.stringify(mcon.save) : 'nothing');
431// The FORMAT IS CHOSEN and then one control saves it. Six buttons became one
432// picker plus one button: six labels in eight languages, and 1400px of chrome
433// above a document that is 380px wide on a phone.
434const chooseFmt = async (media) => {
435 if (!(await page.$('#ov-host [data-media-pick]'))) return false;
436 await page.selectOption('#ov-host [data-media-pick]', media);
437 return true;
438};
439await chooseFmt('Docx');
440const doc = await saved(() => clickIf('#ov-host .fv-save'));
441check(!!doc, 'pressing it hands a document over', doc ? doc.name : 'no download');
442check(!!doc && doc.name === 'notes.docx',
443 'named after the text it came from, with the document’s own suffix', doc ? doc.name : '');
444check(!!doc && doc.bytes.length > 1000 && doc.bytes[0] === 0x50 && doc.bytes[1] === 0x4B,
445 'and it is a real ZIP, not text with a new name',
446 doc ? doc.bytes.length + ' bytes, starts ' + doc.bytes.slice(0, 2).toString('hex') : '');
447
448// THE EXTERNAL ORACLE. LibreOffice is not ours and did not write this file; if
449// it can read the heading, the bold run and the list back out, a real reader
450// agrees with what the app claims to have written.
451if (doc && fs.existsSync(SOFFICE)) {
452 const read = oracleText(doc.path);
453 check(!!read && /Quarterly Review/.test(read),
454 'LibreOffice reads the heading back out of it', read ? read.slice(0, 60) : 'no conversion');
455 check(!!read && /bold words/.test(read),
456 'and the prose', read ? read.slice(0, 80) : 'no conversion');
457 check(!!read && /First finding/.test(read) && /Second finding/.test(read),
458 'and both list items', read ? JSON.stringify(read.slice(0, 120)) : 'no conversion');
459} else {
460 console.log(' -- LibreOffice is not on this machine; the oracle checks did not run');
461}
462
463await chooseFmt('Odt');
464const opendoc = await saved(() => clickIf('#ov-host .fv-save'));
465if (opendoc) {
466 check(opendoc.name === 'notes.odt', 'and the same text becomes an OpenDocument too', opendoc.name);
467 if (fs.existsSync(SOFFICE)) {
468 const read = oracleText(opendoc.path);
469 check(!!read && /Quarterly Review/.test(read) && /First finding/.test(read),
470 'which LibreOffice reads back with its heading and its list',
471 read ? JSON.stringify(read.slice(0, 90)) : 'no conversion');
472 }
473} else {
474 console.log(' -- office_write is not in this bundle, so only Word is offered');
475}
476
477console.log('\n-- and it says what the document does NOT carry, per kind and per name --');
478// `office_write_left` used to hand back a finished English sentence, which was the
479// one string in this panel a translation pass could not reach -- in a file whose
480// own header says it holds none. It now answers `[{kind, n, names}]` and the panel
481// composes the wording, exactly as the reading view does for `undrawn`.
482//
483// `names` is what `undrawn` has no equivalent for: a document being READ has no
484// source names for what it could not draw, and Markdown does -- they are paths the
485// author wrote. So the picture can be NAMED rather than counted.
486const withPic = '# Trip\n\n![The harbour](photos/harbour.png)\n\nSome prose.\n';
487await put('office/withpic.md', Array.from(Buffer.from(withPic)));
488await view('office/withpic.md');
489const shapes = await page.evaluate(async ({ md }) => {
490 const m = await import('/pkg/oxedyne_daimond.js');
491 const out = {};
492 for (const media of ['Docx', 'Odt', 'Xlsx', 'Ods', 'Pptx', 'Odp']) {
493 try { out[media] = m.office_write_left(md, media); }
494 catch (e) { out[media] = 'threw: ' + e.message; }
495 }
496 return out;
497}, { md: withPic });
498check(shapes.Docx !== null && Array.isArray(shapes.Docx),
499 'a Word document reports what it leaves out as a LIST, not a sentence',
500 JSON.stringify(shapes.Docx));
501check(Array.isArray(shapes.Docx) && shapes.Docx.length === 1
502 && shapes.Docx[0].kind === 'image' && shapes.Docx[0].n === 1,
503 'one image, named by kind and counted', JSON.stringify(shapes.Docx));
504// The names are the AUTHOR'S OWN PATHS out of their Markdown, so the sentence can
505// say which picture rather than how many.
506check(Array.isArray(shapes.Docx) && shapes.Docx[0].names
507 && shapes.Docx[0].names.join(',').indexOf('harbour') !== -1,
508 'and the source it was written with, so the sentence can name it',
509 JSON.stringify(shapes.Docx[0].names));
510check(shapes.Xlsx === null && shapes.Ods === null,
511 'a spreadsheet written from prose has nothing to say and says nothing',
512 JSON.stringify({ Xlsx: shapes.Xlsx, Ods: shapes.Ods }));
513// NO ENGLISH ANYWHERE IN THE ANSWER. That is the whole of the change: a phrase
514// built in Rust is English no locale can reach.
515const flatShape = JSON.stringify(shapes);
516check(!/is not carried|are not carried|image is|images are/.test(flatShape),
517 'and none of it is an English sentence built in Rust', flatShape.slice(0, 200));
518
519// The panel's own wording, composed from that shape, on screen.
520const leftSaid = await (async () => {
521 await view('office/withpic.md');
522 await page.selectOption('#ov-host [data-media-pick]', 'Docx');
523 const got = await saved(() => clickIf('#ov-host .fv-save'));
524 return { file: got, notes: await page.evaluate(() =>
525 Array.from(document.querySelectorAll('#ov-host .fv-bar ~ .fv-note, #ov-host .fv-bar ~ .fv-warn'))
526 .filter(n => !n.hidden).map(n => n.textContent).join(' | ')) };
527})();
528check(!!leftSaid.file, 'the picker writes the format that is chosen',
529 leftSaid.file ? leftSaid.file.name : 'no download');
530check(/image/i.test(leftSaid.notes) && /harbour/.test(leftSaid.notes),
531 'and the panel says one image did not reach the document, and which',
532 leftSaid.notes.slice(0, 200));
533check(!/\{n\}|\{names\}|\{parts\}/.test(leftSaid.notes),
534 'with every placeholder filled — a raw {n} on screen is the composition failing',
535 leftSaid.notes.slice(0, 160));
536
537// A deck reports unwritten speaker's notes, which the old English never mentioned
538// at all. Reachable only because all six formats are offered from prose.
539const deckMd = '# One\n\nFirst slide.\n\n# Two\n\nSecond slide.\n';
540const deckLeft = await page.evaluate(async ({ md }) => {
541 const m = await import('/pkg/oxedyne_daimond.js');
542 try { return m.office_write_left(md, 'Pptx'); } catch (e) { return 'threw: ' + e.message; }
543}, { md: deckMd });
544check(deckLeft === null || Array.isArray(deckLeft),
545 'a deck answers the same shape', JSON.stringify(deckLeft));
546
547console.log('\n-- the six formats a picker offers, and what a refusal says --');
548const picker = await page.evaluate(() => {
549 const sel = document.querySelector('#ov-host [data-media-pick]');
550 if (!sel) return null;
551 const r = sel.getBoundingClientRect();
552 return { values: Array.from(sel.options).map(o => o.value),
553 labels: Array.from(sel.options).map(o => o.textContent),
554 w: Math.round(r.width), h: Math.round(r.height),
555 aria: sel.getAttribute('aria-label') };
556});
557check(!!picker && picker.values.join(',') === 'Docx,Odt,Xlsx,Ods,Pptx,Odp',
558 'all six formats the writer supports are offered from prose',
559 picker ? picker.values.join(',') : 'no picker');
560check(!!picker && picker.w > 40 && picker.h > 12,
561 'the picker has a box on screen', picker ? picker.w + 'x' + picker.h : 'no picker');
562// One control, not six buttons: six would be about 1400px of chrome above a
563// document that is 380px wide on a phone, and six labels in eight languages.
564const savers = await page.evaluate(() =>
565 document.querySelectorAll('#ov-host .fv-save').length);
566check(savers === 1, 'through ONE control rather than one button per format',
567 savers + ' save control(s)');
568check(!!picker && !!picker.aria,
569 'and the picker has an accessible name', picker ? String(picker.aria) : '');
570// The labels come from the open `fileview.fmt.` family, which is why six formats
571// cost one new key rather than eight.
572check(!!picker && picker.labels.some(l => /Word/.test(l))
573 && picker.labels.some(l => /OpenDocument/.test(l)),
574 'named from the library\'s own labels, through the family the header reads',
575 picker ? JSON.stringify(picker.labels) : '');
576
577console.log('\n-- a deck gets neither control, and not by an `if` --');
578await put('office/deck.pptx', PPTX);
579const pinfo = await view('office/deck.pptx');
580const pcon = await controls();
581check(pinfo.media === 'Pptx', 'a .pptx is named as one', pinfo.media);
582check(!!pcon && pcon.save.length === 0 && pcon.edit.length === 0,
583 'and offers neither save nor edit: a slide is a position on a canvas',
584 pcon ? JSON.stringify(pcon) : 'nothing rendered');
585await put('office/deck.odp', ODP);
586await view('office/deck.odp');
587const ocon = await controls();
588check(!!ocon && ocon.save.length === 0 && ocon.edit.length === 0,
589 'nor does the OpenDocument deck', ocon ? JSON.stringify(ocon) : 'nothing rendered');
590// AND THE SECOND LOCK, ON ITS OWN. The two checks above are the observable
591// consequence of the ROUTING table -- a deck reaches no reading tier, so it
592// reaches no controls either -- and they would stay green if somebody gave a
593// deck an editor without also routing it. That is two locks a test can only see
594// together, which is one lock. This reads the table itself.
595const door = await page.evaluate(() => window.DaimondViewer.EDIT_DOOR);
596check(!!door && !door.Pptx && !door.Odp,
597 'and no deck has an editor in the table either: an edit that changes the words '
598 + 'without knowing the geometry puts text over other text',
599 'Pptx = ' + (door && door.Pptx) + ', Odp = ' + (door && door.Odp));
600check(!!door && door.Docx === 'office_edit_doc' && door.Odt === 'office_edit_doc'
601 && door.Xlsx === 'office_edit_sheet' && door.Ods === 'office_edit_sheet',
602 'while the four formats the contract allows each name their own door',
603 JSON.stringify(door));
604const doors = await page.evaluate(async () => {
605 const m = await import('/pkg/oxedyne_daimond.js');
606 return ['office_write', 'office_write_docx', 'office_write_left',
607 'office_edit_doc', 'office_edit_sheet'].filter(n => typeof m[n] === 'function');
608});
609console.log(' -- wasm doors in this bundle: ' + (doors.join(', ') || 'none'));
610
611console.log('\n-- and an edit is applied to the archive, not to a rendering of it --');
612if (doors.indexOf('office_edit_doc') !== -1) {
613 await view('office/report.docx');
614 await clickIf('#ov-host .fv-edit');
615 const opened = await controls();
616 check(!!opened && opened.row && !opened.row.hidden,
617 'the edit control opens a row of fields', opened ? JSON.stringify(opened.row) : 'nothing');
618 check(!!opened && opened.row && opened.row.fields.join(',') === 'find,replace,nth,apply',
619 'find, replace, which one, and apply',
620 opened && opened.row ? opened.row.fields.join(',') : 'no row');
621 await fillIf('#ov-host [data-edit="find"]', 'Findings');
622 await fillIf('#ov-host [data-edit="replace"]', 'Conclusions');
623 await clickIf('#ov-host [data-edit="apply"]');
624 await page.waitForTimeout(300);
625 const done = await seen();
626 check(!!done && done.headings.includes('H2:Conclusions'),
627 'the word is changed, read back out of the archive the editor produced',
628 done ? JSON.stringify(done.headings) : 'nothing');
629 check(!!done && done.notes.some(t => /has not changed/i.test(t)),
630 'and the panel says the file itself has not changed',
631 done ? JSON.stringify(done.notes) : 'nothing');
632 const edited = await saved(() => clickIf('#ov-host .fv-save'));
633 check(!!edited && edited.bytes.length > 1000,
634 'saving now hands over the EDITED bytes', edited ? edited.bytes.length + ' bytes' : 'no download');
635 if (edited && fs.existsSync(SOFFICE)) {
636 const read = oracleText(edited.path);
637 check(!!read && /Conclusions/.test(read) && !/Findings/.test(read),
638 'and LibreOffice reads the change out of the saved copy',
639 read ? JSON.stringify(read.slice(0, 120)) : 'no conversion');
640 check(!!read && /Quarterly Review/.test(read),
641 'while the rest of the document is still there', read ? read.slice(0, 60) : 'no conversion');
642 }
643 // An unmatched `find` is an error naming the string, never a silent no-op.
644 await fillIf('#ov-host [data-edit="find"]', 'nothing in this document says this');
645 await clickIf('#ov-host [data-edit="apply"]');
646 await page.waitForTimeout(200);
647 const missed = await page.evaluate(() =>
648 Array.from(document.querySelectorAll('#ov-host .fv-warn')).map(n => n.textContent).join(' | '));
649 check(/not made/i.test(missed) && /nothing in this document says this/.test(missed),
650 'a find that matches nothing is refused, and the string is named',
651 missed.slice(0, 160) || 'nothing said');
652} else {
653 check(false, 'office_edit_doc is in the bundle',
654 'not built yet — the client half is checked below against a stand-in');
655}
656
657console.log('\n-- and a cell is written into the workbook, not into a copy of the grid --');
658if (doors.indexOf('office_edit_sheet') !== -1) {
659 // ODS, because OpenFormula is where the trap is: a reference written
660 // unbracketed makes LibreOffice recalculate and store `Err:510` over the
661 // value. Only a real reader can say whether it happened, which is why this
662 // check ends in LibreOffice and not in our own reader.
663 await put('office/foreign.ods', ODS);
664 await view('office/foreign.ods');
665 await clickIf('#ov-host .fv-edit');
666 await fillIf('#ov-host [data-edit="ref"]', 'a9');
667 await fillIf('#ov-host [data-edit="value"]', 'Edited here');
668 await clickIf('#ov-host [data-edit="apply"]');
669 await page.waitForTimeout(400);
670 const cells = await seen();
671 const flat9 = cells ? cells.grid.map(r => r.join('|')).join('\n') : '';
672 check(/Edited here/.test(flat9),
673 'a value lands in the cell that was named, read back out of the workbook',
674 flat9.split('\n').slice(0, 12).join(' / '));
675 await fillIf('#ov-host [data-edit="ref"]', 'b9');
676 await fillIf('#ov-host [data-edit="value"]', '=B2*C2');
677 await clickIf('#ov-host [data-edit="apply"]');
678 await page.waitForTimeout(400);
679 const book = await saved(() => clickIf('#ov-host .fv-save'));
680 check(!!book && book.name === 'foreign.ods', 'and the workbook saves as itself',
681 book ? book.name : 'no download');
682 if (book && fs.existsSync(SOFFICE)) {
683 const csv = oracleCsv(book.path);
684 check(!!csv && /Edited here/.test(csv),
685 'LibreOffice reads the written value out of the saved workbook',
686 csv ? JSON.stringify(csv.slice(0, 120)) : 'no conversion');
687 // THE WHOLE POINT OF THIS SECTION. `Err:510` is what LibreOffice stores
688 // when it recalculates a formula whose references were not bracketed, and
689 // it is invisible to any reader of ours that shows the STORED value.
690 check(!!csv && !/Err:5\d\d/.test(csv),
691 'and the formula is not Err:510 — OpenFormula wants of:=[.B2]*[.C2]',
692 csv ? (csv.match(/Err:\d+/) || ['no error'])[0] : 'no conversion');
693 check(!!csv && /\b408\b/.test(csv),
694 'it computes 408, which is 120 x 3.4 out of the cells it points at',
695 csv ? JSON.stringify(csv.slice(0, 200)) : 'no conversion');
696 }
697 // A sheet the workbook has not got is an error naming what it has, and the
698 // bytes are left alone.
699 await fillIf('#ov-host [data-edit="ref"]', 'ZZZ99999');
700 await fillIf('#ov-host [data-edit="value"]', 'x');
701 await clickIf('#ov-host [data-edit="apply"]');
702 await page.waitForTimeout(200);
703} else {
704 check(false, 'office_edit_sheet is in the bundle', 'not built yet');
705}
706
707console.log('\n-- and the panel’s own wiring, with the editor stood in for --');
708// WHAT THIS PROVES AND WHAT IT DOES NOT. The stand-in returns the bytes of a
709// DIFFERENT real document, so it proves the panel hands over the file, takes
710// what comes back, re-READS it and redraws from the archive rather than
711// painting the replacement into the old markdown. It proves nothing whatever
712// about the editor, which is checked above, in the Rust suite, and against
713// LibreOffice. It is here so the client half is provable while the wasm half is
714// still being built, and it stays afterwards because it is the half that breaks
715// when somebody rearranges this file.
716const viewStandIn = (path, replacement) => page.evaluate(async ({ path, replacement }) => {
717 const real = await import('/pkg/oxedyne_daimond.js');
718 const stand = {};
719 for (const k of Object.keys(real)) stand[k] = real[k];
720 window.__editCalls = [];
721 stand.office_edit_doc = function (bytes, media, edits) {
722 window.__editCalls.push({ media: media, edits: edits, given: bytes.length });
723 return new Uint8Array(replacement);
724 };
725 let host = document.getElementById('ov-host');
726 const info = await window.DaimondViewer.probe(path, { wasm: stand });
727 await window.DaimondViewer.show(host, path, info, {
728 wasm: stand,
729 t: (k, v) => (window.DaimondI18n ? DaimondI18n.t(k, v) : k),
730 });
731 return info;
732}, { path, replacement });
733
734await viewStandIn('office/report.docx', WITHPIC);
735const wcon = await controls();
736check(!!wcon && wcon.edit.length === 1,
737 'the edit control appears exactly when the wasm door behind it does',
738 wcon ? JSON.stringify(wcon.edit) : 'nothing rendered');
739check(!!wcon && wcon.edit[0] && wcon.edit[0].w > 40 && wcon.edit[0].h > 12,
740 'and it has a box on screen', wcon && wcon.edit[0]
741 ? wcon.edit[0].w + 'x' + wcon.edit[0].h : 'no button');
742await clickIf('#ov-host .fv-edit');
743const wrow = await controls();
744check(!!wrow && wrow.row && !wrow.row.hidden, 'pressing it opens the fields',
745 wrow ? JSON.stringify(wrow.row) : 'nothing');
746const rowBox = await page.evaluate(() => {
747 const r = document.querySelector('#ov-host .fv-editrow').getBoundingClientRect();
748 return { w: Math.round(r.width), h: Math.round(r.height) };
749});
750check(rowBox.w > 100 && rowBox.h > 12, 'and the row itself is on screen, not 0x0',
751 rowBox.w + 'x' + rowBox.h);
752await fillIf('#ov-host [data-edit="find"]', 'Findings');
753await fillIf('#ov-host [data-edit="replace"]', 'Conclusions');
754await fillIf('#ov-host [data-edit="nth"]', '2');
755await clickIf('#ov-host [data-edit="apply"]');
756await page.waitForTimeout(300);
757const calls = await page.evaluate(() => window.__editCalls);
758check(calls.length === 1, 'apply calls the editor once', JSON.stringify(calls.map(c => c.given)));
759check(calls.length === 1 && calls[0].media === 'Docx',
760 'with the media label the reader was given', calls.length ? calls[0].media : '');
761check(calls.length === 1 && calls[0].given === DOCX.length,
762 'and the WHOLE archive, because the edit is made in the archive',
763 calls.length ? calls[0].given + ' of ' + DOCX.length : '');
764const sentEdits = calls.length ? JSON.parse(calls[0].edits) : [];
765check(sentEdits.length === 1 && sentEdits[0].find === 'Findings'
766 && sentEdits[0].replace === 'Conclusions' && sentEdits[0].nth === 2,
767 'the edits are the contract’s own shape, with `nth` 1-based',
768 JSON.stringify(sentEdits));
769const after = await seen();
770check(!!after && /Text after the picture/.test(after.text),
771 'and the panel redraws from what came BACK, not from what it already had',
772 after ? after.text.slice(0, 100) : 'nothing');
773check(!!after && after.notes.some(t => /has not changed/i.test(t)),
774 'saying the file itself has not changed', after ? JSON.stringify(after.notes) : 'nothing');
775const standCopy = await saved(() => clickIf('#ov-host .fv-save'));
776check(!!standCopy && standCopy.bytes.length === WITHPIC.length,
777 'and saving hands over the bytes the editor returned, not the ones it was given',
778 standCopy ? standCopy.bytes.length + ' vs ' + WITHPIC.length + ' / ' + DOCX.length : 'no download');
779
780// `nth` absent means EVERY occurrence, which is the format's rule; a blank box
781// must therefore send no `nth` rather than a zero.
782await viewStandIn('office/report.docx', WITHPIC);
783await clickIf('#ov-host .fv-edit');
784await fillIf('#ov-host [data-edit="find"]', 'Findings');
785await clickIf('#ov-host [data-edit="apply"]');
786await page.waitForTimeout(200);
787const blank = await page.evaluate(() => JSON.parse(window.__editCalls[0].edits));
788check(blank.length === 1 && !('nth' in blank[0]),
789 'a blank “which one” sends no `nth` at all, which is what means every one',
790 JSON.stringify(blank));
791
792console.log('\n-- a cell is named the way the workbook names it --');
793const viewSheetStandIn = (path) => page.evaluate(async (path) => {
794 const real = await import('/pkg/oxedyne_daimond.js');
795 const stand = {};
796 for (const k of Object.keys(real)) stand[k] = real[k];
797 window.__sheetCalls = [];
798 stand.office_edit_sheet = function (bytes, media, edits) {
799 window.__sheetCalls.push({ media: media, edits: edits });
800 return bytes; // unchanged, so the redraw is the same workbook
801 };
802 const host = document.getElementById('ov-host');
803 const info = await window.DaimondViewer.probe(path, { wasm: stand });
804 await window.DaimondViewer.show(host, path, info, {
805 wasm: stand,
806 t: (k, v) => (window.DaimondI18n ? DaimondI18n.t(k, v) : k),
807 });
808 return info;
809}, path);
810
811await viewSheetStandIn('office/ledger.xlsx');
812await clickIf('#ov-host .fv-edit');
813const sheetRow = await page.evaluate(() => {
814 const r = document.querySelector('#ov-host .fv-editrow');
815 const sel = r.querySelector('[data-edit="sheet"]');
816 return { fields: Array.from(r.querySelectorAll('[data-edit]')).map(n => n.getAttribute('data-edit')),
817 sheets: sel ? Array.from(sel.options).map(o => o.value) : [] };
818});
819check(sheetRow.fields.join(',') === 'sheet,ref,value,apply',
820 'a spreadsheet asks for a sheet, a cell and a value', sheetRow.fields.join(','));
821// The tab names are read OFF THE FILE. A sheet name is the one part of a cell
822// reference nobody can guess, and a free-text box for it is a box people get
823// wrong.
824check(sheetRow.sheets.join(',') === 'Sales,Notes',
825 'and the sheet names come off the workbook rather than being typed',
826 JSON.stringify(sheetRow.sheets));
827await fillIf('#ov-host [data-edit="ref"]', 'b2');
828await fillIf('#ov-host [data-edit="value"]', '3.5');
829await clickIf('#ov-host [data-edit="apply"]');
830await page.waitForTimeout(200);
831const cell = await page.evaluate(() => JSON.parse(window.__sheetCalls[0].edits));
832check(cell.length === 1 && cell[0].sheet === 'Sales' && cell[0].ref === 'B2'
833 && cell[0].value === '3.5' && !('formula' in cell[0]),
834 'an ordinary value goes as a value, and the reference is upper-cased',
835 JSON.stringify(cell));
836await fillIf('#ov-host [data-edit="value"]', '=B2*C2');
837await clickIf('#ov-host [data-edit="apply"]');
838await page.waitForTimeout(200);
839const formula = await page.evaluate(() => JSON.parse(window.__sheetCalls[1].edits));
840check(formula.length === 1 && formula[0].formula === '=B2*C2' && !('value' in formula[0]),
841 'and a leading “=” makes it a formula — the convention every spreadsheet already taught',
842 JSON.stringify(formula));
843
844console.log('\n' + pass + ' passed, ' + fail + ' failed');
845await s.close();
846process.exit(fail ? 1 : 0);