Oregami
Repositories/oxedyne/daimond

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
30import fs from 'node:fs';
31import { open, shot, errors, signInAs, scratch } from './harness.mjs';
32
33const out = [];
34let bad = 0;
35const 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.
42const known = [];
43const 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.
47const PROFILE = scratch('graph-profile');
48fs.rmSync(PROFILE, { recursive: true, force: true });
49
50const s = await open({ name: 'graph', connect: false, profile: PROFILE, defaults: false });
51const { page } = s;
52await 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.
56const 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 ──────────────────────────────────────────────────
63const 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});
82const id = fx.id, L = fx.L;
83const nameOf = {};
84[['A', 'Alpha'], ['B', 'Bravo'], ['C', 'Charlie'], ['D', 'Delta'],
85 ['E', 'Echo'], ['F', 'Foxtrot'], ['X', 'Xray']].forEach(([k, n]) => { nameOf[id[k]] = n; });
86const linkName = {};
87Object.keys(L).forEach(k => { linkName[L[k]] = k; });
88check(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 ────────────────────────────────────────────────
93async 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.
101const 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});
154await draw();
155let g = await drawn();
156check(g.hasSvg, `the pane drew an SVG: ${g.nodes.length} node(s), ${g.edges.length} edge(s)`);
157await shot(s, 'graph-fixture');
158
159// The store, read exactly as graph.js reads it.
160const stored = JSON.parse(await wasm(async (app) => await app.all_links()));
161const live = JSON.parse(await wasm(async (app) => await app.list_diamonds())).map(d => d.id);
162const liveSet = new Set(live);
163const dref = (r) => (String(r || '').slice(0, 8) === 'diamond:' ? String(r).slice(8) : null);
164const 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});
168check(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.
176const relsOf = (rel) => String(rel || '').split(',')
177 .map(r => r.trim().replace(/\s+/g, ' ')).filter(Boolean);
178let ones = 0, matched = 0, labelled = 0;
179for (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}
185check(ones === dd.length, `every stored Diamond-to-Diamond link is drawn EXACTLY once: ${ones}/${dd.length}`);
186check(matched === dd.length, `each drawn edge carries the stored from/to: ${matched}/${dd.length}`);
187check(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 ─────────────────────────
193const byId = {};
194stored.forEach(l => { byId[l.id] = l; });
195const 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});
199check(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` ──
204const nodeAt = {};
205g.nodes.forEach(n => { nodeAt[n.did] = n; });
206function 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}
218directed(L.ab, 'Alpha part-of Bravo runs Alpha → Bravo');
219directed(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.
233const wrongWay = g.edges.filter(e => {
234 const l = byId[e.lid];
235 return !l || dref(l.from) !== e.from;
236});
237check(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.
244function 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}
260const ddE = dd.map(l => ({ id: l.id, from: dref(l.from), to: dref(l.to) }));
261const reachAll = reaches(ddE);
262const onCycle = live.filter(v => reachAll[v].has(v));
263const 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`.
267check(backDrawn.length === 1 && backDrawn[0] === L.ca,
268 `exactly the derives-from link closes the cycle: ${JSON.stringify(backDrawn.map(x => linkName[x] || x))}`);
269const aBack = g.edges.find(e => e.back), aFwd = g.edges.find(e => !e.back);
270check(!!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.
276const leftover = reaches(ddE.filter(e => !backDrawn.includes(e.id)));
277check(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'}`);
279const 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.
283check(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(',')}`);
285check(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)`);
288check(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(',')}`);
291check(!(g.nodes.find(n => n.did === id.F) || {}).cycled,
292 'Foxtrot, a sink, carries no cycle badge');
293
294// ── 5. Parallel edges ────────────────────────────────────────────
295const par = g.edges.filter(e => e.from === id.A && e.to === id.D);
296const parWords = par.flatMap(e => e.labels);
297check(par.length === 2, `both relations between Alpha and Delta are drawn: ${parWords.join(', ')}`);
298check(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 ')}`);
301check(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.
318const 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.
338function 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.
361relsApart(await relsDrawn(page, id.A, id.D), 'Alpha ⇉ Delta');
362
363// ── 6. Artefacts, and a link to a Diamond that is gone ───────────
364const badged = g.nodes.filter(n => n.badge !== null);
365check(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]))}`);
367check(!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');
370check((g.stats || '').includes('1 link points at a Diamond that is gone'),
371 `the stats line confesses the dangling link: ${JSON.stringify(g.stats)}`);
372check((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 ─────────────────────────────────────────
377const iso = g.nodes.filter(n => n.isolate);
378check(iso.length === 1 && iso[0].did === id.E, `only Echo is marked unlinked: ${iso.map(n => nameOf[n.did]).join(',')}`);
379check(g.band !== null && /\S/.test(g.band), `the band is headed: ${JSON.stringify(g.band)}`);
380const connected = g.nodes.filter(n => !n.isolate);
381const lowestConnected = connected.length ? Math.max(...connected.map(n => n.at.y)) : null;
382check(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}`);
385check(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 ───────────────────────
389const serialise = () => page.evaluate(() => {
390 const svg = document.querySelector('#graph-body svg#graph-svg');
391 return svg ? new XMLSerializer().serializeToString(svg) : '';
392});
393const first = await serialise();
394const svgs = [first];
395for (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}
402check(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 ──────────
407g = await drawn();
408const before = g.edges.length;
409const 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 });
412await page.evaluate(() => document.dispatchEvent(new CustomEvent('daimond-links-changed')));
413await page.waitForTimeout(1200);
414let g2 = await drawn();
415check(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)`);
417await wasm(async (app, a) => await app.remove_link(a.owner, a.lid), { owner: id.D, lid: newLink });
418await page.evaluate(() => document.dispatchEvent(new CustomEvent('daimond-links-changed')));
419await page.waitForTimeout(1200);
420g2 = await drawn();
421check(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 ─────────────────────────────────
425await page.click(`g.graph-node[data-diamond-id="${id.C}"] path.graph-node-box`, { force: true });
426await page.waitForTimeout(1200);
427const cur = await page.evaluate(() => (window.DaimondDiamond.current() || {}).id || null);
428check(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.
435const gatewayNoise = /(401 \(Unauthorized\)|402 \(Payment Required\)|502 \(Bad Gateway\))/;
436const errsA = errors(s).filter(e => !gatewayNoise.test(e));
437check(errsA.length === 0, `no console errors beyond the gateway's answer: ${JSON.stringify(errsA.slice(0, 3))}`);
438await s.close();
439
440// ── 11. The empty paths, on a profile that has never held anything ──
441const PROFILE_B = scratch('graph-profile-empty');
442fs.rmSync(PROFILE_B, { recursive: true, force: true });
443const b = await open({ name: 'graphB', connect: false, profile: PROFILE_B, defaults: false });
444await b.page.waitForTimeout(2000);
445await b.page.evaluate(() => { DaimondPanels.show('graph'); DaimondGraph.refresh(); });
446await b.page.waitForTimeout(1000);
447const 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}));
451check(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
454const 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});
459await b.page.evaluate(() => DaimondGraph.refresh());
460await b.page.waitForTimeout(1000);
461const 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.
473check(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)}`);
476check(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`);
478check((noLinks.stats || '').includes('2 Diamonds') && (noLinks.stats || '').includes('0 links'),
479 `the stats line is still there and honest: ${JSON.stringify(noLinks.stats)}`);
480await 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.
485const 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);
493await b.page.evaluate(() => DaimondGraph.refresh());
494await b.page.waitForTimeout(1000);
495const twoLong = await relsDrawn(b.page, bIds[0], bIds[1]);
496check(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))}`);
500relsApart(twoLong, 'two long relations between one pair');
501await shot(b, 'graph-parallel-labels');
502const errsB = errors(b).filter(e => !gatewayNoise.test(e));
503check(errsB.length === 0, `no console errors on the empty session: ${JSON.stringify(errsB.slice(0, 3))}`);
504await b.close();
505
506console.log('\n' + out.join('\n'));
507if (known.length) console.log(`\nKNOWN, REPORTED NOT ASSERTED:\n - ${known.join('\n - ')}`);
508console.log(bad === 0 ? `\nALL ${out.length} CHECKS PASSED` : `\n${bad} of ${out.length} FAILED`);
509process.exit(bad === 0 ? 0 : 1);