oxedyne/daimond/dev/verify_graph.mjs
29.3 KiB, 1 run
created by r2519314175:447, 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_graph.mjs — the Graph pane: what is stored is what is drawn, both ways. |
| 2 | // |
| 3 | // The pane's whole claim is that it is a verification instrument: a person looks at it to |
| 4 | // settle whether the association structure is the one they believe they built. That claim |
| 5 | // only holds if the picture is a FAITHFUL function of the store, so this proves the two |
| 6 | // directions separately -- every stored link between two live Diamonds is drawn exactly |
| 7 | // once, and every drawn edge names a link that is really there -- and then proves the |
| 8 | // things a faithful picture also has to get right: which way the arrow points, which edge |
| 9 | // closes a cycle, that two relations between one pair are two lines AND two readable chips |
| 10 | // (a link's relations are a set now, one chip apiece, and the store holds them as one |
| 11 | // comma-separated string), |
| 12 | // that an artefact is a count rather than a box, that a link to a deleted Diamond is |
| 13 | // confessed in the stats and drawn nowhere, that no hint stands over the boxes it points at, |
| 14 | // and that the same store draws the same bytes on every load. |
| 15 | // |
| 16 | // The fixture is built through the real wasm, in one browser profile: |
| 17 | // |
| 18 | // Alpha ──part-of──▶ Bravo ──part-of──▶ Charlie ──(no rel)──▶ Foxtrot |
| 19 | // │ ╲ ▲ │ |
| 20 | // │ ╲blocks,informs │relates-to │derives-from (closes the cycle) |
| 21 | // │ ╲ │ │ |
| 22 | // │ ▶ Delta ───────┘ ▼ |
| 23 | // ├─ file:x.md (an artefact: a count, not a box) back to Alpha |
| 24 | // └─ diamond:Xray, then Xray deleted (dangling) |
| 25 | // Echo — linked to nothing at all |
| 26 | // |
| 27 | // Run with dev/serve.mjs (DAIMOND_PORT, default 8777) up. No gateway needed, so it |
| 28 | // belongs in phase 1. |
| 29 | |
| 30 | import fs from 'node:fs'; |
| 31 | import { open, shot, errors, signInAs, scratch } from './harness.mjs'; |
| 32 | |
| 33 | const out = []; |
| 34 | let bad = 0; |
| 35 | const check = (ok, what) => { |
| 36 | out.push(`${ok ? 'PASS' : 'FAIL'} ${what}`); |
| 37 | console.log(`${ok ? ' ok ' : ' FAIL '}${what}`); |
| 38 | if (!ok) bad++; |
| 39 | }; |
| 40 | // A divergence that is real, understood, and reported rather than asserted: it is the |
| 41 | // pane's behaviour as shipped, not a regression this run introduced. |
| 42 | const known = []; |
| 43 | const note = (ok, what, why) => { if (!ok) known.push(`${what}\n ${why}`); }; |
| 44 | |
| 45 | // A fixed profile, emptied first, so a re-run starts from the same store rather than |
| 46 | // from the last run's Diamonds. Under ~/.cache/daimond, never the repo and never /tmp. |
| 47 | const PROFILE = scratch('graph-profile'); |
| 48 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 49 | |
| 50 | const s = await open({ name: 'graph', connect: false, profile: PROFILE, defaults: false }); |
| 51 | const { page } = s; |
| 52 | await page.waitForTimeout(2500); |
| 53 | |
| 54 | /// Reach the real wasm directly. A fresh `DaimondApp` shares the page's OPFS, so this is |
| 55 | /// the store the pane is reading, not a copy of it. |
| 56 | const wasm = (fn, arg) => page.evaluate(async ({ src, arg }) => { |
| 57 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 58 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 59 | return await (new Function('app', 'arg', `return (${src})(app, arg);`))(app, arg); |
| 60 | }, { src: fn.toString(), arg }); |
| 61 | |
| 62 | // ── The fixture ────────────────────────────────────────────────── |
| 63 | const fx = await wasm(async (app) => { |
| 64 | const mk = (n) => app.create_diamond(n); |
| 65 | const A = await mk('Alpha'), B = await mk('Bravo'), C = await mk('Charlie'); |
| 66 | const D = await mk('Delta'), E = await mk('Echo'), F = await mk('Foxtrot'), X = await mk('Xray'); |
| 67 | const ln = (owner, from, to, rel, note) => |
| 68 | app.add_link(owner, 'diamond:' + from, to, rel, note || '', 'user'); |
| 69 | const L = {}; |
| 70 | L.ab = await ln(A, A, 'diamond:' + B, 'part-of', 'the launch sits under the brand'); |
| 71 | L.bc = await ln(B, B, 'diamond:' + C, 'part-of'); |
| 72 | L.db = await ln(D, D, 'diamond:' + B, 'relates-to'); |
| 73 | L.ca = await ln(C, C, 'diamond:' + A, 'derives-from'); // closes the cycle |
| 74 | L.ad1 = await ln(A, A, 'diamond:' + D, 'blocks'); // parallel pair, one |
| 75 | L.ad2 = await ln(A, A, 'diamond:' + D, 'informs'); // parallel pair, two |
| 76 | L.art = await ln(A, A, 'file:x.md', 'produced'); // an artefact end |
| 77 | L.dang = await ln(A, A, 'diamond:' + X, 'references'); // about to dangle |
| 78 | L.cf = await ln(C, C, 'diamond:' + F, ''); // an empty relation |
| 79 | await app.delete_diamond(X); |
| 80 | return { id: { A, B, C, D, E, F, X }, L }; |
| 81 | }); |
| 82 | const id = fx.id, L = fx.L; |
| 83 | const nameOf = {}; |
| 84 | [['A', 'Alpha'], ['B', 'Bravo'], ['C', 'Charlie'], ['D', 'Delta'], |
| 85 | ['E', 'Echo'], ['F', 'Foxtrot'], ['X', 'Xray']].forEach(([k, n]) => { nameOf[id[k]] = n; }); |
| 86 | const linkName = {}; |
| 87 | Object.keys(L).forEach(k => { linkName[L[k]] = k; }); |
| 88 | check(Object.values(id).every(v => typeof v === 'string' && v.length > 0) |
| 89 | && Object.values(L).every(v => typeof v === 'string' && v.length > 0), |
| 90 | `fixture built: 7 Diamonds (Xray then deleted), 9 links — ${Object.keys(L).join(' ')}`); |
| 91 | |
| 92 | // ── Open the pane ──────────────────────────────────────────────── |
| 93 | async function draw() { |
| 94 | await page.evaluate(() => { |
| 95 | DaimondPanels.show('graph'); |
| 96 | if (window.DaimondGraph) DaimondGraph.refresh(); |
| 97 | }); |
| 98 | await page.waitForTimeout(1200); |
| 99 | } |
| 100 | /// Everything the pane put on screen, read back as plain data. |
| 101 | const drawn = () => page.evaluate(() => { |
| 102 | const svg = document.querySelector('#graph-body svg#graph-svg'); |
| 103 | const pos = (tr) => { |
| 104 | const m = /translate\(\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\)/.exec(tr || ''); |
| 105 | return m ? { x: +m[1], y: +m[2] } : null; |
| 106 | }; |
| 107 | const ends = (d) => { |
| 108 | const m = /^M(-?[\d.]+),(-?[\d.]+)\s+C.*\s(-?[\d.]+),(-?[\d.]+)$/.exec(d || ''); |
| 109 | return m ? { x0: +m[1], y0: +m[2], x3: +m[3], y3: +m[4] } : null; |
| 110 | }; |
| 111 | return { |
| 112 | hasSvg: !!svg, |
| 113 | edges: svg ? [...svg.querySelectorAll('g.graph-edges > g.graph-edge')].map(g => { |
| 114 | const path = g.querySelector('path.graph-edge-line'); |
| 115 | // A link carries a SET of relations, drawn as a chip each, so the |
| 116 | // labels are a list. A link with no relation has none, which is how an |
| 117 | // empty relation stays distinguishable from a blank one. |
| 118 | const labs = [...g.querySelectorAll('text.graph-edge-label')] |
| 119 | .map(t => t.textContent); |
| 120 | return { |
| 121 | lid: g.dataset.linkId, from: g.dataset.from, to: g.dataset.to, |
| 122 | back: g.classList.contains('back'), |
| 123 | labels: labs, |
| 124 | d: path ? path.getAttribute('d') : null, |
| 125 | ends: path ? ends(path.getAttribute('d')) : null, |
| 126 | marker: path ? path.getAttribute('marker-end') : null, |
| 127 | dash: path ? getComputedStyle(path).strokeDasharray : null, |
| 128 | }; |
| 129 | }) : [], |
| 130 | nodes: svg ? [...svg.querySelectorAll('g.graph-nodes > g.graph-node')].map(g => { |
| 131 | // The box is a PATH now -- a flattened hexagon, kinked out to a point |
| 132 | // at each side -- so there is no width or height attribute to read. |
| 133 | // Its extent is taken from the drawn geometry instead, which is what |
| 134 | // the direction checks want in any case: they ask where a line meets |
| 135 | // the box, and the box is whatever the box was drawn as. |
| 136 | const box = g.querySelector('path.graph-node-box'); |
| 137 | const bb = box ? box.getBBox() : null; |
| 138 | return { |
| 139 | did: g.dataset.diamondId, at: pos(g.getAttribute('transform')), |
| 140 | w: bb ? bb.width : null, |
| 141 | h: bb ? bb.height : null, |
| 142 | isolate: g.classList.contains('isolate'), |
| 143 | cycled: g.classList.contains('cycled'), |
| 144 | name: (g.querySelector('text.graph-node-name') || {}).textContent ?? null, |
| 145 | badge: (g.querySelector('g.graph-badge text') || {}).textContent ?? null, |
| 146 | cycleBadge: (g.querySelector('g.graph-cycle text') || {}).textContent ?? null, |
| 147 | }; |
| 148 | }) : [], |
| 149 | band: (svg && svg.querySelector('text.graph-band') || {}).textContent ?? null, |
| 150 | stats: (document.querySelector('#graph-body .graph-stats') || {}).textContent ?? null, |
| 151 | empty: [...document.querySelectorAll('#graph-body p.graph-empty')].map(e => e.textContent), |
| 152 | }; |
| 153 | }); |
| 154 | await draw(); |
| 155 | let g = await drawn(); |
| 156 | check(g.hasSvg, `the pane drew an SVG: ${g.nodes.length} node(s), ${g.edges.length} edge(s)`); |
| 157 | await shot(s, 'graph-fixture'); |
| 158 | |
| 159 | // The store, read exactly as graph.js reads it. |
| 160 | const stored = JSON.parse(await wasm(async (app) => await app.all_links())); |
| 161 | const live = JSON.parse(await wasm(async (app) => await app.list_diamonds())).map(d => d.id); |
| 162 | const liveSet = new Set(live); |
| 163 | const dref = (r) => (String(r || '').slice(0, 8) === 'diamond:' ? String(r).slice(8) : null); |
| 164 | const dd = stored.filter(l => { |
| 165 | const a = dref(l.from), b = dref(l.to); |
| 166 | return a && b && liveSet.has(a) && liveSet.has(b); |
| 167 | }); |
| 168 | check(stored.length === 9 && dd.length === 7 && live.length === 6, |
| 169 | `store: ${stored.length} links, ${dd.length} between live Diamonds, ${live.length} Diamonds`); |
| 170 | |
| 171 | // ── 1. Stored → drawn ──────────────────────────────────────────── |
| 172 | /// The relations one stored `rel` names, as graph.js reads it: one string in the |
| 173 | /// record, comma-separated, and each of them a chip on the line. Written here |
| 174 | /// rather than imported so the picture is compared against the STORE's own |
| 175 | /// meaning and not against the module's copy of it. |
| 176 | const relsOf = (rel) => String(rel || '').split(',') |
| 177 | .map(r => r.trim().replace(/\s+/g, ' ')).filter(Boolean); |
| 178 | let ones = 0, matched = 0, labelled = 0; |
| 179 | for (const l of dd) { |
| 180 | const hits = g.edges.filter(e => e.lid === l.id); |
| 181 | if (hits.length === 1) ones++; |
| 182 | if (hits.length === 1 && hits[0].from === dref(l.from) && hits[0].to === dref(l.to)) matched++; |
| 183 | if (hits.length === 1 && JSON.stringify(hits[0].labels) === JSON.stringify(relsOf(l.rel))) labelled++; |
| 184 | } |
| 185 | check(ones === dd.length, `every stored Diamond-to-Diamond link is drawn EXACTLY once: ${ones}/${dd.length}`); |
| 186 | check(matched === dd.length, `each drawn edge carries the stored from/to: ${matched}/${dd.length}`); |
| 187 | check(labelled === dd.length, |
| 188 | `every relation the store names is a chip on that line, and an empty relation gets none:` |
| 189 | + ` ${labelled}/${dd.length}` |
| 190 | + ` (empty-rel link ${linkName[L.cf]} labels=${JSON.stringify((g.edges.find(e => e.lid === L.cf) || {}).labels)})`); |
| 191 | |
| 192 | // ── 2. Drawn → stored: no invented edges ───────────────────────── |
| 193 | const byId = {}; |
| 194 | stored.forEach(l => { byId[l.id] = l; }); |
| 195 | const invented = g.edges.filter(e => { |
| 196 | const l = byId[e.lid]; |
| 197 | return !l || dref(l.from) !== e.from || dref(l.to) !== e.to; |
| 198 | }); |
| 199 | check(g.edges.length === dd.length && invented.length === 0, |
| 200 | `no edge is drawn that the store does not hold: ${g.edges.length} drawn, ${dd.length} stored` |
| 201 | + (invented.length ? ` — invented: ${JSON.stringify(invented.map(e => e.lid))}` : '')); |
| 202 | |
| 203 | // ── 3. Direction: the line starts at `from` and the arrow lands on `to` ── |
| 204 | const nodeAt = {}; |
| 205 | g.nodes.forEach(n => { nodeAt[n.did] = n; }); |
| 206 | function directed(lid, label) { |
| 207 | const e = g.edges.find(x => x.lid === lid); |
| 208 | if (!e || !e.ends) return check(false, `${label}: no drawable edge for ${lid}`); |
| 209 | const a = nodeAt[e.from], b = nodeAt[e.to]; |
| 210 | if (!a || !b) return check(false, `${label}: an end has no node`); |
| 211 | // A forward edge leaves the bottom-centre of `from` and lands on the top-centre of `to`. |
| 212 | const okStart = Math.abs(e.ends.x0 - (a.at.x + a.w / 2)) < 0.001 && Math.abs(e.ends.y0 - (a.at.y + a.h)) < 0.001; |
| 213 | const okEnd = Math.abs(e.ends.x3 - (b.at.x + b.w / 2)) < 0.001 && Math.abs(e.ends.y3 - b.at.y) < 0.001; |
| 214 | check(okStart && okEnd && e.marker === 'url(#gm-arrow)', |
| 215 | `${label}: starts on ${nameOf[e.from]} (${e.ends.x0},${e.ends.y0}) and the arrow lands on ` |
| 216 | + `${nameOf[e.to]} (${e.ends.x3},${e.ends.y3}); boxes at ${a.at.y}+${a.h} and ${b.at.y}`); |
| 217 | } |
| 218 | directed(L.ab, 'Alpha part-of Bravo runs Alpha → Bravo'); |
| 219 | directed(L.bc, 'Bravo part-of Charlie runs Bravo → Charlie'); |
| 220 | // And the back edge, which leaves and arrives on the right-hand side instead. |
| 221 | { |
| 222 | const e = g.edges.find(x => x.lid === L.ca); |
| 223 | const a = e && nodeAt[e.from], b = e && nodeAt[e.to]; |
| 224 | check(!!(e && e.ends && a && b) |
| 225 | && Math.abs(e.ends.x0 - (a.at.x + a.w)) < 0.001 && Math.abs(e.ends.y0 - (a.at.y + a.h / 2)) < 0.001 |
| 226 | && Math.abs(e.ends.x3 - (b.at.x + b.w)) < 0.001 && Math.abs(e.ends.y3 - (b.at.y + b.h / 2)) < 0.001 |
| 227 | && e.marker === 'url(#gm-arrow-back)', |
| 228 | 'the closing edge runs Charlie → Alpha out to the right: ' |
| 229 | + (e && e.ends ? `(${e.ends.x0},${e.ends.y0}) → (${e.ends.x3},${e.ends.y3})` : 'no such edge drawn')); |
| 230 | } |
| 231 | // A drawn arrow must never point the way the store does not. Checked over every edge, so a |
| 232 | // swap that happened to leave one edge alone would still be caught. |
| 233 | const wrongWay = g.edges.filter(e => { |
| 234 | const l = byId[e.lid]; |
| 235 | return !l || dref(l.from) !== e.from; |
| 236 | }); |
| 237 | check(wrongWay.length === 0, `no edge is drawn against its stored direction: ${g.edges.length} checked`); |
| 238 | |
| 239 | // ── 4. The cycle ───────────────────────────────────────────────── |
| 240 | // |
| 241 | // An oracle worked out a different way from the pane's depth-first search: a Diamond is on a |
| 242 | // cycle when it can reach itself in one or more steps. Agreeing with a reachability closure |
| 243 | // means something; agreeing with a copy of findCycles would not. |
| 244 | function reaches(edges) { |
| 245 | const adj = {}; |
| 246 | edges.forEach(e => { (adj[e.from] || (adj[e.from] = [])).push(e.to); }); |
| 247 | const from = {}; |
| 248 | live.forEach(v => { |
| 249 | const seen = new Set(), stack = [...(adj[v] || [])]; |
| 250 | while (stack.length) { |
| 251 | const n = stack.pop(); |
| 252 | if (seen.has(n)) continue; |
| 253 | seen.add(n); |
| 254 | (adj[n] || []).forEach(m => stack.push(m)); |
| 255 | } |
| 256 | from[v] = seen; |
| 257 | }); |
| 258 | return from; |
| 259 | } |
| 260 | const ddE = dd.map(l => ({ id: l.id, from: dref(l.from), to: dref(l.to) })); |
| 261 | const reachAll = reaches(ddE); |
| 262 | const onCycle = live.filter(v => reachAll[v].has(v)); |
| 263 | const backDrawn = g.edges.filter(e => e.back).map(e => e.lid); |
| 264 | // Which edge is the closing one is decided, and stays decided: the search starts at the |
| 265 | // lowest Diamond id (Alpha) and follows out-edges in link-id order, so it walks |
| 266 | // Alpha → Bravo → Charlie and meets Alpha again on `derives-from`. |
| 267 | check(backDrawn.length === 1 && backDrawn[0] === L.ca, |
| 268 | `exactly the derives-from link closes the cycle: ${JSON.stringify(backDrawn.map(x => linkName[x] || x))}`); |
| 269 | const aBack = g.edges.find(e => e.back), aFwd = g.edges.find(e => !e.back); |
| 270 | check(!!aBack && g.edges.filter(e => e.back).every(e => /\d/.test(e.dash || '') && e.dash !== 'none') |
| 271 | && g.edges.filter(e => !e.back).every(e => (e.dash || 'none') === 'none'), |
| 272 | `the closing edge is dashed and nothing else is: back=${JSON.stringify(aBack && aBack.dash)}, ` |
| 273 | + `forward=${JSON.stringify(aFwd && aFwd.dash)}`); |
| 274 | // Removing what the pane called closing edges must leave an acyclic graph, or the dashes |
| 275 | // are not marking cycle-closers at all. |
| 276 | const leftover = reaches(ddE.filter(e => !backDrawn.includes(e.id))); |
| 277 | check(live.every(v => !leftover[v].has(v)), |
| 278 | `with the closing edge gone the rest is acyclic: ${live.filter(v => leftover[v].has(v)).map(v => nameOf[v]).join(',') || 'no cycle left'}`); |
| 279 | const cycled = g.nodes.filter(n => n.cycled).map(n => n.did); |
| 280 | // Both directions against the independent reachability oracle: every Diamond |
| 281 | // really on a cycle is badged (Delta joins through a cross edge, the case a |
| 282 | // back-edge stack segment misses), and nothing else is. |
| 283 | check(cycled.length === 4 && [id.A, id.B, id.C, id.D].every(x => cycled.includes(x)), |
| 284 | `the Diamonds on the closed cycle are badged, Delta included: ${cycled.map(x => nameOf[x]).sort().join(',')}`); |
| 285 | check(g.nodes.filter(n => n.cycled).every(n => n.cycleBadge === '⟲') |
| 286 | && g.nodes.filter(n => !n.cycled).every(n => n.cycleBadge === null), |
| 287 | `each cycled node carries the ⟲ badge and no other node does: ${g.nodes.filter(n => n.cycleBadge).length} badge(s)`); |
| 288 | check(cycled.every(v => onCycle.includes(v)) && onCycle.every(v => cycled.includes(v)), |
| 289 | `the badged set IS the oracle's cycle set, both ways: oracle says ${onCycle.map(x => nameOf[x]).sort().join(',')}, ` |
| 290 | + `pane badges ${cycled.map(x => nameOf[x]).sort().join(',')}`); |
| 291 | check(!(g.nodes.find(n => n.did === id.F) || {}).cycled, |
| 292 | 'Foxtrot, a sink, carries no cycle badge'); |
| 293 | |
| 294 | // ── 5. Parallel edges ──────────────────────────────────────────── |
| 295 | const par = g.edges.filter(e => e.from === id.A && e.to === id.D); |
| 296 | const parWords = par.flatMap(e => e.labels); |
| 297 | check(par.length === 2, `both relations between Alpha and Delta are drawn: ${parWords.join(', ')}`); |
| 298 | check(par.length === 2 && par[0].d !== par[1].d && par[0].ends && par[1].ends |
| 299 | && par[0].ends.x0 !== par[1].ends.x0, |
| 300 | `and are separated rather than laid on top of each other: x0 ${par.map(e => e.ends && e.ends.x0).join(' vs ')}`); |
| 301 | check(new Set(parWords).size === parWords.length && parWords.length === par.length, |
| 302 | `each parallel line carries its own relation: ${JSON.stringify(par.map(e => e.labels))}`); |
| 303 | |
| 304 | /// Every relation drawn between one pair, and where on the SCREEN each of them landed. |
| 305 | /// |
| 306 | /// A measurement in the TEST is free; the same measurement inside the layout would not be, |
| 307 | /// because a picture that placed a word by how wide the browser drew it would place it |
| 308 | /// differently on another machine. So this asks the document, and the pane never does. |
| 309 | /// |
| 310 | /// Measured in client coordinates rather than in the SVG's own. A relation is a chip |
| 311 | /// inside a group the pane translates to a point on the line, so a chip's own `getBBox` |
| 312 | /// is stated about its stack's origin and two chips on two different lines would both |
| 313 | /// report the same place -- which would make the overlap check below say "no overlap" |
| 314 | /// about words printed one over the other, and say it for ever. The client rect is the |
| 315 | /// only frame in which "these two words are on top of each other" is a question with an |
| 316 | /// answer. The chip's BOX is measured, not its text: the box is opaque and is what the |
| 317 | /// reader sees one of covering the other. |
| 318 | const relsDrawn = (pg, from, to) => pg.evaluate(({ from, to }) => |
| 319 | [...document.querySelectorAll('#graph-body g.graph-edge')] |
| 320 | .filter(g => g.dataset.from === from && g.dataset.to === to) |
| 321 | .flatMap(g => [...g.querySelectorAll('g.graph-chip')].map(c => { |
| 322 | const tx = c.querySelector('text.graph-edge-label'); |
| 323 | const r = c.getBoundingClientRect(); |
| 324 | return { |
| 325 | lid: g.dataset.linkId, text: tx ? tx.textContent : '', |
| 326 | x: r.left, y: r.top, w: r.width, h: r.height, |
| 327 | cy: r.top + r.height / 2, |
| 328 | }; |
| 329 | })), { from, to }); |
| 330 | |
| 331 | /// The relations between one pair, asserted apart in both senses: no two of them sit |
| 332 | /// within a chip's height of each other along the lines, and no two of the boxes they |
| 333 | /// are drawn in intersect. |
| 334 | /// |
| 335 | /// Over every PAIR rather than over the first two. Two links between one pair may carry |
| 336 | /// any number of relations between them, and a check that read two entries would go on |
| 337 | /// passing while a third was printed over one of them. |
| 338 | function relsApart(chips, what) { |
| 339 | const pairs = []; |
| 340 | for (let i = 0; i < chips.length; i++) |
| 341 | for (let j = i + 1; j < chips.length; j++) pairs.push([chips[i], chips[j]]); |
| 342 | const tooClose = pairs.filter(([u, v]) => Math.abs(u.cy - v.cy) < Math.max(u.h, v.h)); |
| 343 | check(chips.length > 1 && tooClose.length === 0, |
| 344 | `${what}: every relation is set apart ALONG the lines by at least a chip's height — ` |
| 345 | + `${chips.length} chip(s), ` |
| 346 | + (tooClose.length |
| 347 | ? tooClose.map(([u, v]) => `${JSON.stringify(u.text)}@${u.cy.toFixed(1)} vs ` |
| 348 | + `${JSON.stringify(v.text)}@${v.cy.toFixed(1)}`).join('; ') |
| 349 | : chips.map(z => `${JSON.stringify(z.text)}@${z.cy.toFixed(1)}`).join(', '))); |
| 350 | const hit = pairs.filter(([u, v]) => |
| 351 | !(u.x + u.w <= v.x || v.x + v.w <= u.x || u.y + u.h <= v.y || v.y + v.h <= u.y)); |
| 352 | check(chips.length > 1 && hit.length === 0, |
| 353 | `${what}: and none of the boxes they are drawn in intersect — ` |
| 354 | + (hit.length |
| 355 | ? hit.map(([u, v]) => `${JSON.stringify(u.text)} over ${JSON.stringify(v.text)}`).join('; ') |
| 356 | : chips.map(z => `${JSON.stringify(z.text)} [${z.x.toFixed(1)},${z.y.toFixed(1)} ` |
| 357 | + `${z.w.toFixed(1)}×${z.h.toFixed(1)}]`).join(' vs '))); |
| 358 | } |
| 359 | // Nudging the PATHS apart was never the whole job: a relation rides its own path, so two |
| 360 | // parallel ones landed at the same height and printed one over the other. |
| 361 | relsApart(await relsDrawn(page, id.A, id.D), 'Alpha ⇉ Delta'); |
| 362 | |
| 363 | // ── 6. Artefacts, and a link to a Diamond that is gone ─────────── |
| 364 | const badged = g.nodes.filter(n => n.badge !== null); |
| 365 | check(badged.length === 1 && badged[0].did === id.A && badged[0].badge === '◈ 1', |
| 366 | `only Alpha carries an artefact badge, and it reads "◈ 1": ${JSON.stringify(badged.map(n => [nameOf[n.did], n.badge]))}`); |
| 367 | check(!g.edges.some(e => e.from === id.X || e.to === id.X) |
| 368 | && !g.nodes.some(n => n.did === id.X), |
| 369 | 'the deleted Diamond has no node and no edge'); |
| 370 | check((g.stats || '').includes('1 link points at a Diamond that is gone'), |
| 371 | `the stats line confesses the dangling link: ${JSON.stringify(g.stats)}`); |
| 372 | check((g.stats || '').includes('6 Diamonds') && (g.stats || '').includes('7 links between Diamonds') |
| 373 | && (g.stats || '').includes('1 link closes a cycle'), |
| 374 | `the stats line counts what is drawn: ${JSON.stringify(g.stats)}`); |
| 375 | |
| 376 | // ── 7. The unlinked band ───────────────────────────────────────── |
| 377 | const iso = g.nodes.filter(n => n.isolate); |
| 378 | check(iso.length === 1 && iso[0].did === id.E, `only Echo is marked unlinked: ${iso.map(n => nameOf[n.did]).join(',')}`); |
| 379 | check(g.band !== null && /\S/.test(g.band), `the band is headed: ${JSON.stringify(g.band)}`); |
| 380 | const connected = g.nodes.filter(n => !n.isolate); |
| 381 | const lowestConnected = connected.length ? Math.max(...connected.map(n => n.at.y)) : null; |
| 382 | check(iso.length === 1 && lowestConnected !== null && iso[0].at.y > lowestConnected, |
| 383 | `the band sits below every connected Diamond: Echo at y=${iso.length ? iso[0].at.y : '(not marked)'}, ` |
| 384 | + `lowest connected y=${lowestConnected}`); |
| 385 | check(g.nodes.length === 6 && g.nodes.map(n => n.did).join(',') === live.slice().sort().join(','), |
| 386 | `every Diamond has a node, in id order: ${g.nodes.map(n => nameOf[n.did]).join(',')}`); |
| 387 | |
| 388 | // ── 8. The same store draws the same bytes ─────────────────────── |
| 389 | const serialise = () => page.evaluate(() => { |
| 390 | const svg = document.querySelector('#graph-body svg#graph-svg'); |
| 391 | return svg ? new XMLSerializer().serializeToString(svg) : ''; |
| 392 | }); |
| 393 | const first = await serialise(); |
| 394 | const svgs = [first]; |
| 395 | for (let i = 0; i < 2; i++) { |
| 396 | await page.reload({ waitUntil: 'domcontentloaded' }); |
| 397 | await signInAs(s, 'graph'); |
| 398 | await page.waitForTimeout(1200); |
| 399 | await draw(); |
| 400 | svgs.push(await serialise()); |
| 401 | } |
| 402 | check(svgs[0].length > 500 && svgs[1] === svgs[0] && svgs[2] === svgs[0], |
| 403 | `the same store serialises byte-identical across three loads: ${svgs.map(x => x.length).join('/')} chars` |
| 404 | + (svgs[1] === svgs[0] ? '' : ` — first difference at ${[...svgs[0]].findIndex((c, i) => c !== svgs[1][i])}`)); |
| 405 | |
| 406 | // ── 9. A link changing redraws the pane, with no reload ────────── |
| 407 | g = await drawn(); |
| 408 | const before = g.edges.length; |
| 409 | const newLink = await wasm(async (app, a) => |
| 410 | await app.add_link(a.d, 'diamond:' + a.d, 'diamond:' + a.f, 'mentions', '', 'user'), |
| 411 | { d: id.D, f: id.F }); |
| 412 | await page.evaluate(() => document.dispatchEvent(new CustomEvent('daimond-links-changed'))); |
| 413 | await page.waitForTimeout(1200); |
| 414 | let g2 = await drawn(); |
| 415 | check(g2.edges.length === before + 1 && g2.edges.some(e => e.lid === newLink), |
| 416 | `a new link appears on the event alone, no reload: ${before} → ${g2.edges.length} edge(s)`); |
| 417 | await wasm(async (app, a) => await app.remove_link(a.owner, a.lid), { owner: id.D, lid: newLink }); |
| 418 | await page.evaluate(() => document.dispatchEvent(new CustomEvent('daimond-links-changed'))); |
| 419 | await page.waitForTimeout(1200); |
| 420 | g2 = await drawn(); |
| 421 | check(g2.edges.length === before && !g2.edges.some(e => e.lid === newLink), |
| 422 | `and a removed link goes the same way: ${g2.edges.length} edge(s)`); |
| 423 | |
| 424 | // ── 10. A node opens its Diamond ───────────────────────────────── |
| 425 | await page.click(`g.graph-node[data-diamond-id="${id.C}"] path.graph-node-box`, { force: true }); |
| 426 | await page.waitForTimeout(1200); |
| 427 | const cur = await page.evaluate(() => (window.DaimondDiamond.current() || {}).id || null); |
| 428 | check(cur === id.C, `clicking a node selects that Diamond: ${nameOf[cur] || cur} (wanted Charlie)`); |
| 429 | |
| 430 | // The dev server proxies /api to a gateway that is either absent (502 from the proxy) or |
| 431 | // running and unwilling to serve this throwaway identity (401, 402). None of the three is the |
| 432 | // pane: the Graph draws from OPFS and asks the network for nothing whatever, so whichever |
| 433 | // answer the gateway happens to be giving today is the environment talking, not this suite. |
| 434 | // Everything else a page logs is still an error this run has to answer for. |
| 435 | const gatewayNoise = /(401 \(Unauthorized\)|402 \(Payment Required\)|502 \(Bad Gateway\))/; |
| 436 | const errsA = errors(s).filter(e => !gatewayNoise.test(e)); |
| 437 | check(errsA.length === 0, `no console errors beyond the gateway's answer: ${JSON.stringify(errsA.slice(0, 3))}`); |
| 438 | await s.close(); |
| 439 | |
| 440 | // ── 11. The empty paths, on a profile that has never held anything ── |
| 441 | const PROFILE_B = scratch('graph-profile-empty'); |
| 442 | fs.rmSync(PROFILE_B, { recursive: true, force: true }); |
| 443 | const b = await open({ name: 'graphB', connect: false, profile: PROFILE_B, defaults: false }); |
| 444 | await b.page.waitForTimeout(2000); |
| 445 | await b.page.evaluate(() => { DaimondPanels.show('graph'); DaimondGraph.refresh(); }); |
| 446 | await b.page.waitForTimeout(1000); |
| 447 | const bare = await b.page.evaluate(() => ({ |
| 448 | empty: [...document.querySelectorAll('#graph-body p.graph-empty')].map(e => e.textContent), |
| 449 | svg: !!document.querySelector('#graph-body svg#graph-svg'), |
| 450 | })); |
| 451 | check(bare.empty.length === 1 && /no Diamonds yet/i.test(bare.empty[0]) && !bare.svg, |
| 452 | `with no Diamonds at all the pane says so and draws nothing: ${JSON.stringify(bare)}`); |
| 453 | |
| 454 | const bIds = await b.page.evaluate(async () => { |
| 455 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 456 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 457 | return [await app.create_diamond('Only one'), await app.create_diamond('And another')]; |
| 458 | }); |
| 459 | await b.page.evaluate(() => DaimondGraph.refresh()); |
| 460 | await b.page.waitForTimeout(1000); |
| 461 | const noLinks = await b.page.evaluate(() => ({ |
| 462 | empty: [...document.querySelectorAll('#graph-body p.graph-empty')].map(e => e.textContent), |
| 463 | nodes: [...document.querySelectorAll('#graph-body g.graph-node')].map(g => g.dataset.diamondId), |
| 464 | edges: document.querySelectorAll('#graph-body g.graph-edge').length, |
| 465 | iso: document.querySelectorAll('#graph-body g.graph-node.isolate').length, |
| 466 | band: (document.querySelector('#graph-body text.graph-band') || {}).textContent ?? null, |
| 467 | stats: (document.querySelector('#graph-body .graph-stats') || {}).textContent ?? null, |
| 468 | })); |
| 469 | // The hint promised "the picture appears here" from ABOVE the band it was pointing at: the |
| 470 | // Diamonds were already drawn, right underneath it, in the band that says they are unlinked. |
| 471 | // A hint that has to be read past the thing it describes is not a hint, so it is gone -- the |
| 472 | // band and the stats line say the same thing where the thing itself is. |
| 473 | check(noLinks.empty.length === 0 && !!noLinks.band && /\S/.test(noLinks.band), |
| 474 | `Diamonds but no links: no hint stands over the boxes, and the band speaks for itself — ` |
| 475 | + `hint ${JSON.stringify(noLinks.empty)}, band ${JSON.stringify(noLinks.band)}`); |
| 476 | check(noLinks.nodes.length === 2 && bIds.every(x => noLinks.nodes.includes(x)) && noLinks.edges === 0, |
| 477 | `and the Diamonds are still drawn: ${noLinks.nodes.length} node(s), ${noLinks.edges} edge(s), ${noLinks.iso} unlinked`); |
| 478 | check((noLinks.stats || '').includes('2 Diamonds') && (noLinks.stats || '').includes('0 links'), |
| 479 | `the stats line is still there and honest: ${JSON.stringify(noLinks.stats)}`); |
| 480 | await shot(b, 'graph-empty'); |
| 481 | |
| 482 | // ── 12. Two LONG relations between one pair ────────────────────── |
| 483 | // The case the overlap was reported against, built on its own: one pair, two relations, both |
| 484 | // far too long to be pulled apart by the eighteen pixels the paths are nudged. |
| 485 | const longIds = await b.page.evaluate(async (a) => { |
| 486 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 487 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 488 | return [ |
| 489 | await app.add_link(a[0], 'diamond:' + a[0], 'diamond:' + a[1], 'is-a-precondition-for', '', 'user'), |
| 490 | await app.add_link(a[0], 'diamond:' + a[0], 'diamond:' + a[1], 'supersedes-and-replaces', '', 'user'), |
| 491 | ]; |
| 492 | }, bIds); |
| 493 | await b.page.evaluate(() => DaimondGraph.refresh()); |
| 494 | await b.page.waitForTimeout(1000); |
| 495 | const twoLong = await relsDrawn(b.page, bIds[0], bIds[1]); |
| 496 | check(longIds.every(x => typeof x === 'string' && x.length > 0) |
| 497 | && ['is-a-precondition-for', 'supersedes-and-replaces'] |
| 498 | .every(w => twoLong.some(z => z.text === w)), |
| 499 | `two long relations between one pair are both drawn and both labelled: ${JSON.stringify(twoLong.map(z => z.text))}`); |
| 500 | relsApart(twoLong, 'two long relations between one pair'); |
| 501 | await shot(b, 'graph-parallel-labels'); |
| 502 | const errsB = errors(b).filter(e => !gatewayNoise.test(e)); |
| 503 | check(errsB.length === 0, `no console errors on the empty session: ${JSON.stringify(errsB.slice(0, 3))}`); |
| 504 | await b.close(); |
| 505 | |
| 506 | console.log('\n' + out.join('\n')); |
| 507 | if (known.length) console.log(`\nKNOWN, REPORTED NOT ASSERTED:\n - ${known.join('\n - ')}`); |
| 508 | console.log(bad === 0 ? `\nALL ${out.length} CHECKS PASSED` : `\n${bad} of ${out.length} FAILED`); |
| 509 | process.exit(bad === 0 ? 0 : 1); |