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 | |
| 40 | import fs from 'node:fs'; |
| 41 | import path from 'node:path'; |
| 42 | import { spawnSync } from 'node:child_process'; |
| 43 | import { open, scratch } from './harness.mjs'; |
| 44 | |
| 45 | const PROFILE = scratch('pw', 'office'); |
| 46 | |
| 47 | let pass = 0, fail = 0; |
| 48 | const 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. |
| 55 | const FIXTURE = new URL( |
| 56 | '../../../../../rust/fe2o3/fe2o3_file/tests/data/rich.docx', import.meta.url); |
| 57 | const 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. |
| 62 | const 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. |
| 68 | const 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. |
| 75 | const ODT = Array.from(fs.readFileSync(new URL( |
| 76 | '../../../../../rust/fe2o3/fe2o3_file/tests/data/foreign.odt', import.meta.url))); |
| 77 | const 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. |
| 83 | const PPTX = Array.from(fs.readFileSync(new URL( |
| 84 | '../../../../../rust/fe2o3/fe2o3_file/tests/data/foreign.pptx', import.meta.url))); |
| 85 | const 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. |
| 90 | const 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. |
| 94 | const PLAIN_ZIP = Array.from(Buffer.from('UEsFBgAAAAAAAAAAAAAAAAAAAAAAAA==', 'base64')); |
| 95 | |
| 96 | const s = await open({ name: 'office', profile: PROFILE, connect: false, defaults: false }); |
| 97 | const { page } = s; |
| 98 | |
| 99 | const 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. |
| 107 | const 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. |
| 125 | const 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 | |
| 147 | console.log('\n-- a Word document is drawn as what it says --'); |
| 148 | await put('office/report.docx', DOCX); |
| 149 | const info = await view('office/report.docx'); |
| 150 | const got = await seen(); |
| 151 | check(info.media === 'Docx', 'the probe names it a Word document', info.media); |
| 152 | check(!!got && got.handler === 'office', 'and it routes to the office tier, not the hex dump', |
| 153 | got ? got.handler : 'nothing rendered'); |
| 154 | check(!!got && !got.hasHex, 'there is no hex dump on screen', got && got.hasHex ? 'a dump' : ''); |
| 155 | check(!!got && got.headings.includes('H1:Quarterly Review'), |
| 156 | 'its title arrives as a heading', got ? JSON.stringify(got.headings) : 'nothing'); |
| 157 | check(!!got && got.headings.includes('H2:Findings'), |
| 158 | 'and so does its second-level heading', got ? JSON.stringify(got.headings) : 'nothing'); |
| 159 | check(!!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'); |
| 161 | check(!!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'); |
| 164 | check(!!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 | |
| 168 | console.log('\n-- and it says what it is --'); |
| 169 | check(!!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. |
| 173 | check(!!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 | |
| 177 | console.log('\n-- and it says what it could not draw, by name and by count --'); |
| 178 | await put('office/withpic.docx', WITHPIC); |
| 179 | await view('office/withpic.docx'); |
| 180 | const pic = await seen(); |
| 181 | check(!!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'); |
| 184 | check(!!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'); |
| 187 | check(!!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 | |
| 190 | console.log('\n-- a spreadsheet is drawn as the grid it is, with the STORED values --'); |
| 191 | await put('office/ledger.xlsx', XLSX); |
| 192 | const xinfo = await view('office/ledger.xlsx'); |
| 193 | const grid = await seen(); |
| 194 | check(xinfo.media === 'Xlsx', 'the probe names it a spreadsheet', xinfo.media); |
| 195 | check(!!grid && grid.handler === 'sheet', 'and it routes to the sheet tier, not the hex dump', |
| 196 | grid ? grid.handler : 'nothing rendered'); |
| 197 | check(!!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. |
| 200 | check(!!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. |
| 209 | const head = (grid && grid.grid.length ? grid.grid[0] : []).slice(0, 4); |
| 210 | check(!!grid && grid.grid.length > 1 && head.join('') === 'ABC', |
| 211 | 'the column letters and row numbers are drawn', JSON.stringify(head)); |
| 212 | const flat = grid ? grid.grid.map(r => r.join('|')).join('\n') : ''; |
| 213 | check(/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(' / ')); |
| 215 | check(!/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. |
| 218 | check(/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)); |
| 220 | check(/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'); |
| 222 | check(!!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 | |
| 226 | console.log('\n-- and OpenDocument reaches the same two tiers --'); |
| 227 | await put('office/report.odt', ODT); |
| 228 | const oinfo = await view('office/report.odt'); |
| 229 | const odt = await seen(); |
| 230 | check(oinfo.media === 'Odt', 'a .odt is named as one and not as a bare archive', oinfo.media); |
| 231 | check(!!odt && odt.handler === 'office', 'and it goes to the same tier a .docx does', |
| 232 | odt ? odt.handler : 'nothing rendered'); |
| 233 | check(!!odt && odt.headings.includes('H1:A Report On Something'), |
| 234 | 'its headings arrive as headings', odt ? JSON.stringify(odt.headings) : 'nothing'); |
| 235 | check(!!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'); |
| 238 | check(!!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 | |
| 241 | await put('office/ledger.ods', ODS); |
| 242 | const sinfo = await view('office/ledger.ods'); |
| 243 | const ods = await seen(); |
| 244 | check(sinfo.media === 'Ods', 'a .ods is named as one, from its own opening bytes', sinfo.media); |
| 245 | check(!!ods && ods.handler === 'sheet', 'and it goes to the same tier a .xlsx does', |
| 246 | ods ? ods.handler : 'nothing rendered'); |
| 247 | const oflat = ods ? ods.grid.map(r => r.join('|')).join('\n') : ''; |
| 248 | check(/North\|120\|3\.4\|408\b/.test(oflat), 'showing the value STORED in the file', |
| 249 | oflat.split('\n').slice(0, 4).join(' / ')); |
| 250 | check(/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. |
| 255 | await put('office/holiday.zip', ODT); |
| 256 | const renamed = await view('office/holiday.zip'); |
| 257 | check(renamed.media === 'Odt', 'a renamed .odt is still a .odt, by its own bytes', renamed.media); |
| 258 | |
| 259 | console.log('\n-- what cannot be read is named, not guessed at --'); |
| 260 | await put('office/secret.docx', ENCRYPTED); |
| 261 | await view('office/secret.docx'); |
| 262 | const enc = await seen(); |
| 263 | check(!!enc && /encrypted/i.test(enc.all), |
| 264 | 'an encrypted document says it is encrypted', enc ? enc.all.slice(0, 160) : 'nothing'); |
| 265 | check(!!enc && enc.hasHex, |
| 266 | 'and falls to the honest floor rather than a blank panel', enc ? 'no dump' : 'nothing'); |
| 267 | |
| 268 | console.log('\n-- the floor is still the floor --'); |
| 269 | await put('office/plain.zip', PLAIN_ZIP); |
| 270 | const zinfo = await view('office/plain.zip'); |
| 271 | const zip = await seen(); |
| 272 | check(zinfo.media === 'Zip', 'an ordinary archive is still an ordinary archive', zinfo.media); |
| 273 | check(!!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 | |
| 277 | const table = await page.evaluate(() => window.DaimondViewer.KIND_HANDLERS); |
| 278 | check(table.Docx === 'office', 'the table routes Docx', 'KIND_HANDLERS.Docx = ' + table.Docx); |
| 279 | check(table.Xlsx === 'sheet', 'and Xlsx, now that one can genuinely be read', |
| 280 | 'KIND_HANDLERS.Xlsx = ' + table.Xlsx); |
| 281 | check(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. |
| 287 | check(!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 | |
| 314 | const 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. |
| 321 | const 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. |
| 328 | const 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. |
| 335 | const 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. |
| 348 | const 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. |
| 363 | const 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. |
| 378 | const 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 | |
| 392 | console.log('\n-- a copy of the document reaches the user --'); |
| 393 | await view('office/report.docx'); |
| 394 | const dcon = await controls(); |
| 395 | check(!!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. |
| 400 | check(!!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'); |
| 403 | const copy = await saved(() => clickIf('#ov-host .fv-save')); |
| 404 | check(!!copy, 'pressing it hands a file over', copy ? copy.name : 'no download'); |
| 405 | check(!!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. |
| 409 | check(!!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 | |
| 414 | await view('office/ledger.xlsx'); |
| 415 | const scon = await controls(); |
| 416 | check(!!scon && scon.save.length === 1, 'a spreadsheet offers the same', |
| 417 | scon ? JSON.stringify(scon.save) : 'nothing rendered'); |
| 418 | |
| 419 | console.log('\n-- and Markdown becomes a real document --'); |
| 420 | const MD = '# Quarterly Review\n\nAn opening paragraph with **bold words**.\n\n' |
| 421 | + '## Findings\n\n- First finding\n- Second finding\n'; |
| 422 | await put('office/notes.md', Array.from(Buffer.from(MD))); |
| 423 | await view('office/notes.md'); |
| 424 | const mcon = await controls(); |
| 425 | check(!!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'); |
| 428 | check(!!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. |
| 434 | const 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 | }; |
| 439 | await chooseFmt('Docx'); |
| 440 | const doc = await saved(() => clickIf('#ov-host .fv-save')); |
| 441 | check(!!doc, 'pressing it hands a document over', doc ? doc.name : 'no download'); |
| 442 | check(!!doc && doc.name === 'notes.docx', |
| 443 | 'named after the text it came from, with the document’s own suffix', doc ? doc.name : ''); |
| 444 | check(!!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. |
| 451 | if (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 | |
| 463 | await chooseFmt('Odt'); |
| 464 | const opendoc = await saved(() => clickIf('#ov-host .fv-save')); |
| 465 | if (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 | |
| 477 | console.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. |
| 486 | const withPic = '# Trip\n\n\n\nSome prose.\n'; |
| 487 | await put('office/withpic.md', Array.from(Buffer.from(withPic))); |
| 488 | await view('office/withpic.md'); |
| 489 | const 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 }); |
| 498 | check(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)); |
| 501 | check(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. |
| 506 | check(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)); |
| 510 | check(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. |
| 515 | const flatShape = JSON.stringify(shapes); |
| 516 | check(!/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. |
| 520 | const 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 | })(); |
| 528 | check(!!leftSaid.file, 'the picker writes the format that is chosen', |
| 529 | leftSaid.file ? leftSaid.file.name : 'no download'); |
| 530 | check(/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)); |
| 533 | check(!/\{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. |
| 539 | const deckMd = '# One\n\nFirst slide.\n\n# Two\n\nSecond slide.\n'; |
| 540 | const 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 }); |
| 544 | check(deckLeft === null || Array.isArray(deckLeft), |
| 545 | 'a deck answers the same shape', JSON.stringify(deckLeft)); |
| 546 | |
| 547 | console.log('\n-- the six formats a picker offers, and what a refusal says --'); |
| 548 | const 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 | }); |
| 557 | check(!!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'); |
| 560 | check(!!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. |
| 564 | const savers = await page.evaluate(() => |
| 565 | document.querySelectorAll('#ov-host .fv-save').length); |
| 566 | check(savers === 1, 'through ONE control rather than one button per format', |
| 567 | savers + ' save control(s)'); |
| 568 | check(!!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. |
| 572 | check(!!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 | |
| 577 | console.log('\n-- a deck gets neither control, and not by an `if` --'); |
| 578 | await put('office/deck.pptx', PPTX); |
| 579 | const pinfo = await view('office/deck.pptx'); |
| 580 | const pcon = await controls(); |
| 581 | check(pinfo.media === 'Pptx', 'a .pptx is named as one', pinfo.media); |
| 582 | check(!!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'); |
| 585 | await put('office/deck.odp', ODP); |
| 586 | await view('office/deck.odp'); |
| 587 | const ocon = await controls(); |
| 588 | check(!!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. |
| 595 | const door = await page.evaluate(() => window.DaimondViewer.EDIT_DOOR); |
| 596 | check(!!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)); |
| 600 | check(!!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)); |
| 604 | const 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 | }); |
| 609 | console.log(' -- wasm doors in this bundle: ' + (doors.join(', ') || 'none')); |
| 610 | |
| 611 | console.log('\n-- and an edit is applied to the archive, not to a rendering of it --'); |
| 612 | if (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 | |
| 657 | console.log('\n-- and a cell is written into the workbook, not into a copy of the grid --'); |
| 658 | if (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 | |
| 707 | console.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. |
| 716 | const 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 | |
| 734 | await viewStandIn('office/report.docx', WITHPIC); |
| 735 | const wcon = await controls(); |
| 736 | check(!!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'); |
| 739 | check(!!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'); |
| 742 | await clickIf('#ov-host .fv-edit'); |
| 743 | const wrow = await controls(); |
| 744 | check(!!wrow && wrow.row && !wrow.row.hidden, 'pressing it opens the fields', |
| 745 | wrow ? JSON.stringify(wrow.row) : 'nothing'); |
| 746 | const 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 | }); |
| 750 | check(rowBox.w > 100 && rowBox.h > 12, 'and the row itself is on screen, not 0x0', |
| 751 | rowBox.w + 'x' + rowBox.h); |
| 752 | await fillIf('#ov-host [data-edit="find"]', 'Findings'); |
| 753 | await fillIf('#ov-host [data-edit="replace"]', 'Conclusions'); |
| 754 | await fillIf('#ov-host [data-edit="nth"]', '2'); |
| 755 | await clickIf('#ov-host [data-edit="apply"]'); |
| 756 | await page.waitForTimeout(300); |
| 757 | const calls = await page.evaluate(() => window.__editCalls); |
| 758 | check(calls.length === 1, 'apply calls the editor once', JSON.stringify(calls.map(c => c.given))); |
| 759 | check(calls.length === 1 && calls[0].media === 'Docx', |
| 760 | 'with the media label the reader was given', calls.length ? calls[0].media : ''); |
| 761 | check(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 : ''); |
| 764 | const sentEdits = calls.length ? JSON.parse(calls[0].edits) : []; |
| 765 | check(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)); |
| 769 | const after = await seen(); |
| 770 | check(!!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'); |
| 773 | check(!!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'); |
| 775 | const standCopy = await saved(() => clickIf('#ov-host .fv-save')); |
| 776 | check(!!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. |
| 782 | await viewStandIn('office/report.docx', WITHPIC); |
| 783 | await clickIf('#ov-host .fv-edit'); |
| 784 | await fillIf('#ov-host [data-edit="find"]', 'Findings'); |
| 785 | await clickIf('#ov-host [data-edit="apply"]'); |
| 786 | await page.waitForTimeout(200); |
| 787 | const blank = await page.evaluate(() => JSON.parse(window.__editCalls[0].edits)); |
| 788 | check(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 | |
| 792 | console.log('\n-- a cell is named the way the workbook names it --'); |
| 793 | const 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 | |
| 811 | await viewSheetStandIn('office/ledger.xlsx'); |
| 812 | await clickIf('#ov-host .fv-edit'); |
| 813 | const 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 | }); |
| 819 | check(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. |
| 824 | check(sheetRow.sheets.join(',') === 'Sales,Notes', |
| 825 | 'and the sheet names come off the workbook rather than being typed', |
| 826 | JSON.stringify(sheetRow.sheets)); |
| 827 | await fillIf('#ov-host [data-edit="ref"]', 'b2'); |
| 828 | await fillIf('#ov-host [data-edit="value"]', '3.5'); |
| 829 | await clickIf('#ov-host [data-edit="apply"]'); |
| 830 | await page.waitForTimeout(200); |
| 831 | const cell = await page.evaluate(() => JSON.parse(window.__sheetCalls[0].edits)); |
| 832 | check(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)); |
| 836 | await fillIf('#ov-host [data-edit="value"]', '=B2*C2'); |
| 837 | await clickIf('#ov-host [data-edit="apply"]'); |
| 838 | await page.waitForTimeout(200); |
| 839 | const formula = await page.evaluate(() => JSON.parse(window.__sheetCalls[1].edits)); |
| 840 | check(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 | |
| 844 | console.log('\n' + pass + ' passed, ' + fail + ' failed'); |
| 845 | await s.close(); |
| 846 | process.exit(fail ? 1 : 0); |