Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_graphedit.mjs

39.2 KiB, 1 run

created by r2519314175:449, 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_graphedit.mjs — the Graph pane as an EDITOR, without losing the
2// instrument.
3//
4// verify_graph.mjs proves that the picture is a faithful function of the link
5// store. Phase I let the user move the picture, which is exactly the change
6// that could quietly destroy that property, so this is its sibling: it searches
7// for the properties the redesign turns on rather than confirming that the
8// script it was written beside still runs.
9//
10// The five properties, and why each is a property and not a feature:
11//
12// 1. DETERMINISM SURVIVES. The same store draws the same picture -- captured,
13// reloaded, captured again, required equal. Then a Diamond is dragged and
14// the SAME requirement is made of the new picture. A drag that wrote
15// nothing would pass the first half and fail the second; a drag that wrote
16// something unstable would fail the first half after it.
17// 2. EVERY MODE IS ESCAPABLE. Link mode, the context menu and the link form
18// are each entered and then left, by Escape AND by the pointer. A mode
19// with no way out is the one-way door dev/verify_reversible.mjs exists for.
20// The form edits a link's relations as a SET of chips -- one string in the
21// store, comma-separated -- so adding one keeps the ones already there and
22// closing a chip drops only that one. A form that treated the set as a
23// field would destroy a relation every time somebody added one.
24// 3. ORGANISE IS HONEST. Run twice from one state it gives one arrangement,
25// and it loses neither a Diamond nor a link.
26// 4. A CYCLE IS STILL DRAWN. After a drag and after an organise, the closing
27// edge is still dashed and the Diamonds on the cycle are still badged.
28// Making a cycle visible is the whole point of the instrument and is the
29// first thing a canvas would be tempted to tidy away.
30// 5. NOTHING IS LOST. A Diamond with no stored position still appears, and a
31// deleted Diamond does not leave its coordinates behind for ever.
32//
33// The fixture, built through the real wasm in one browser profile:
34//
35// Alpha ──feeds──▶ Bravo ──feeds──▶ Charlie
36// ▲ │
37// └────────── closes ────────────────┘ (a cycle, drawn dashed)
38// Delta ──notes──▶ Echo (a separate pair)
39// Foxtrot — linked to nothing (the unlinked band)
40//
41// Run with a world up:
42// eval "$(bash dev/world.sh 6 --up)" && node dev/verify_graphedit.mjs
43//
44// No gateway: the pane draws from OPFS and asks the network for nothing.
45
46import fs from 'node:fs';
47import { open, shot, errors, signInAs, scratch } from './harness.mjs';
48
49const out = [];
50let bad = 0;
51const check = (ok, what) => {
52 out.push(`${ok ? 'PASS' : 'FAIL'} ${what}`);
53 console.log(`${ok ? ' ok ' : ' FAIL '}${what}`);
54 if (!ok) bad++;
55};
56
57const PROFILE = scratch('graphedit-profile');
58fs.rmSync(PROFILE, { recursive: true, force: true });
59
60const s = await open({ name: 'graphedit', connect: false, profile: PROFILE, defaults: false });
61const { page } = s;
62await page.waitForTimeout(2500);
63
64/// Reach the real wasm directly. A fresh `DaimondApp` shares the page's OPFS,
65/// so this is the store the pane is reading, not a copy of it.
66const wasm = (fn, arg) => page.evaluate(async ({ src, arg }) => {
67 const m = await import('/pkg/oxedyne_daimond.js');
68 const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true);
69 return await (new Function('app', 'arg', `return (${src})(app, arg);`))(app, arg);
70}, { src: fn.toString(), arg });
71
72// ── The fixture ──────────────────────────────────────────────────
73const fx = await wasm(async (app) => {
74 const mk = (n) => app.create_diamond(n);
75 const A = await mk('Alpha'), B = await mk('Bravo'), C = await mk('Charlie');
76 const D = await mk('Delta'), E = await mk('Echo'), F = await mk('Foxtrot');
77 const ln = (owner, from, to, rel) => app.add_link(owner, 'diamond:' + from, 'diamond:' + to, rel, '', 'user');
78 const L = {};
79 L.ab = await ln(A, A, B, 'feeds');
80 L.bc = await ln(B, B, C, 'feeds');
81 L.ca = await ln(C, C, A, 'closes'); // the closing edge
82 L.de = await ln(D, D, E, 'notes');
83 return { id: { A, B, C, D, E, F }, L };
84});
85const id = fx.id, L = fx.L;
86const nameOf = {};
87[['A', 'Alpha'], ['B', 'Bravo'], ['C', 'Charlie'], ['D', 'Delta'],
88 ['E', 'Echo'], ['F', 'Foxtrot']].forEach(([k, n]) => { nameOf[id[k]] = n; });
89check(Object.values(id).every(Boolean) && Object.values(L).every(Boolean),
90 `fixture built: 6 Diamonds, 4 links, one three-Diamond cycle`);
91
92// ── Driving the pane ─────────────────────────────────────────────
93
94/// Shut the Admin drawer, which opens by itself on a profile with no model
95/// connected. In a wide window it sits beside the stage and is harmless; in a
96/// narrow one it lies over it, and a gesture aimed at the picture would land on
97/// one of its pulldowns instead.
98async function closeDrawer() {
99 await page.evaluate(() => {
100 const x = document.getElementById('admin-close');
101 if (x && x.getClientRects().length) x.click();
102 });
103 await page.waitForTimeout(250);
104}
105
106async function draw() {
107 await closeDrawer();
108 await page.evaluate(() => {
109 DaimondPanels.show('graph');
110 if (window.DaimondGraph) DaimondGraph.refresh();
111 });
112 await page.waitForTimeout(900);
113}
114
115/// The picture as plain data: where every box is, what every line's `d` is, and
116/// which of them are marked. This is the geometry the determinism checks
117/// compare, so it holds coordinates and nothing that could differ for a reason
118/// that does not matter (an id, a tooltip, the order the browser enumerated).
119const geom = () => page.evaluate(() => {
120 const svg = document.querySelector('#graph-body svg#graph-svg');
121 if (!svg) return null;
122 const at = (tr) => {
123 const m = /translate\(\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\)/.exec(tr || '');
124 return m ? [+m[1], +m[2]] : null;
125 };
126 return {
127 box: svg.getAttribute('viewBox'),
128 nodes: [...svg.querySelectorAll('g.graph-nodes > g.graph-node')].map(g => ({
129 id: g.dataset.diamondId,
130 at: at(g.getAttribute('transform')),
131 cycled: g.classList.contains('cycled'),
132 isolate: g.classList.contains('isolate'),
133 placed: g.classList.contains('placed'),
134 badge: !!g.querySelector('g.graph-cycle'),
135 })).sort((a, b) => (a.id < b.id ? -1 : 1)),
136 edges: [...svg.querySelectorAll('g.graph-edges > g.graph-edge')].map(g => ({
137 id: g.dataset.linkId, from: g.dataset.from, to: g.dataset.to,
138 back: g.classList.contains('back'),
139 d: (g.querySelector('path.graph-edge-line') || {}).getAttribute
140 ? g.querySelector('path.graph-edge-line').getAttribute('d') : null,
141 dash: getComputedStyle(g.querySelector('path.graph-edge-line')).strokeDasharray,
142 // A link carries a SET of relations, one chip each, so this is a list.
143 // Reading only the first would make a link that gained a second
144 // relation look unchanged.
145 labels: [...g.querySelectorAll('text.graph-edge-label')].map(t => t.textContent),
146 })).sort((a, b) => (a.id < b.id ? -1 : 1)),
147 band: (svg.querySelector('text.graph-band') || {}).textContent ?? null,
148 live: svg.querySelectorAll('g.graph-live path').length,
149 };
150});
151
152const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
153
154/// The layout as it is actually stored, read from outside the app.
155const stored = () => page.evaluate(() => {
156 try { return JSON.parse(localStorage.getItem('daimond-graph') || 'null'); }
157 catch { return null; }
158});
159
160/// Where a Diamond's box is on screen, so the pointer can be aimed at it.
161///
162/// The box is a PATH -- a flattened hexagon -- so it is asked for its rendered
163/// rectangle rather than for width and height it does not carry.
164const boxAt = (did) => page.evaluate((d) => {
165 const g = document.querySelector(`g.graph-node[data-diamond-id="${d}"] path.graph-node-box`);
166 if (!g) return null;
167 const r = g.getBoundingClientRect();
168 return { x: r.left + r.width / 2, y: r.top + r.height / 2, w: r.width, h: r.height };
169}, did);
170
171/// A point inside the picture that is over NOTHING -- no box, no line. Found
172/// by asking the document what is under it rather than by guessing, because a
173/// guess that happened to land on a node would make "clicking empty space
174/// cancels" pass for the wrong reason.
175const emptyPoint = () => page.evaluate(() => {
176 const svg = document.querySelector('#graph-body svg#graph-svg');
177 if (!svg) return null;
178 const r = svg.getBoundingClientRect();
179 for (let y = r.top + 6; y < Math.min(r.bottom, innerHeight) - 6; y += 7) {
180 for (let x = r.left + 4; x < Math.min(r.right, innerWidth) - 4; x += 11) {
181 const el = document.elementFromPoint(x, y);
182 if (!el) continue;
183 if (el.closest('.graph-node') || el.closest('.graph-edge')) continue;
184 if (!svg.contains(el) && el !== svg) continue;
185 return { x, y };
186 }
187 }
188 return null;
189});
190
191/// Drag a Diamond by a delta, the way a hand does it: press, move in steps so
192/// the module sees the gesture grow, release.
193async function dragBy(did, dx, dy) {
194 const b = await boxAt(did);
195 if (!b) throw new Error('no box for ' + did);
196 await page.mouse.move(b.x, b.y);
197 await page.mouse.down();
198 for (let i = 1; i <= 5; i++) await page.mouse.move(b.x + (dx * i) / 5, b.y + (dy * i) / 5);
199 await page.mouse.up();
200 await page.waitForTimeout(500);
201 return b;
202}
203
204/// Sign back in and reopen the pane, which is what a reload costs here.
205async function reload() {
206 await page.reload({ waitUntil: 'domcontentloaded' });
207 await signInAs(s, 'graphedit');
208 await page.waitForTimeout(1400);
209 await draw();
210}
211
212await draw();
213let g0 = await geom();
214check(!!g0 && g0.nodes.length === 6 && g0.edges.length === 4,
215 `the pane drew the fixture: ${g0 ? g0.nodes.length : 0} node(s), ${g0 ? g0.edges.length : 0} edge(s)`);
216await shot(s, 'graphedit-1-fixture');
217
218// ── 1. Determinism, before anything has been moved ───────────────
219await reload();
220const g1 = await geom();
221check(same(g0, g1), `the same store draws the same geometry across a reload`
222 + (same(g0, g1) ? '' : ` — ${JSON.stringify(g0.nodes)} vs ${JSON.stringify(g1.nodes)}`));
223const pre = await stored();
224check(!pre || !pre.pos || Object.keys(pre.pos).length === 0,
225 `nothing is stored until something is moved: ${JSON.stringify(pre && pre.pos)}`);
226
227// ── 2. A drag is written down, and the new picture is deterministic ──
228const before = (await geom()).nodes.find(n => n.id === id.B).at;
229await dragBy(id.B, 150, -60);
230const afterDrag = await geom();
231const movedNode = afterDrag.nodes.find(n => n.id === id.B);
232check(!same(movedNode.at, before) && movedNode.placed,
233 `dragging Bravo moved it and marked it placed: ${JSON.stringify(before)} → ${JSON.stringify(movedNode.at)}`);
234const rec = await stored();
235check(!!(rec && rec.pos && rec.pos[id.B]
236 && rec.pos[id.B].x === movedNode.at[0] && rec.pos[id.B].y === movedNode.at[1]
237 && typeof rec.pos[id.B].t === 'number' && rec.pos[id.B].t > 0),
238 `and the coordinates are in the store, stamped: ${JSON.stringify(rec && rec.pos && rec.pos[id.B])}`);
239const wrote = (rec && rec.pos) ? Object.keys(rec.pos) : [];
240check(wrote.length === 1,
241 `only the Diamond that was dragged was written: ${JSON.stringify(wrote.map(x => nameOf[x]))}`);
242// The Diamonds that were NOT dragged must not have moved, or a drag would be a
243// re-layout wearing a drag's clothes.
244check(afterDrag.nodes.length === g1.nodes.length,
245 `every Diamond still has a node after the drag: ${afterDrag.nodes.length} of ${g1.nodes.length}`
246 + (afterDrag.nodes.length === g1.nodes.length ? ''
247 : ` — missing ${JSON.stringify(g1.nodes.filter(n => !afterDrag.nodes.some(m => m.id === n.id))
248 .map(n => nameOf[n.id]))}`));
249const strayed = g1.nodes.filter(n => n.id !== id.B).filter(n => {
250 const now = afterDrag.nodes.find(m => m.id === n.id);
251 return !now || !same(n.at, now.at);
252});
253check(strayed.length === 0, `and no other Diamond moved when Bravo did`
254 + (strayed.length ? ` — ${JSON.stringify(strayed.map(n => nameOf[n.id]))}` : ''));
255await shot(s, 'graphedit-2-dragged');
256
257// A press and a sweep is a drag. To the browser it is also a TEXT SELECTION,
258// and a picture left striped in highlight is both ugly and a state a later
259// click acts inside. Read WHILE THE BUTTON IS STILL DOWN: the drop redraws the
260// picture, which destroys the selected nodes and takes the evidence with it, so
261// a check made afterwards would pass on a pane that stripes itself every time.
262{
263 const from = await boxAt(id.A);
264 await page.mouse.move(from.x, from.y);
265 await page.mouse.down();
266 for (let i = 1; i <= 8; i++) await page.mouse.move(from.x + 20 * i, from.y + 26 * i);
267 const swept = await page.evaluate(() => String(window.getSelection() || ''));
268 await page.keyboard.press('Escape'); // put it back; this is only a probe
269 await page.mouse.up();
270 await page.waitForTimeout(400);
271 check(swept === '', `a drag across the picture selects no text: ${JSON.stringify(swept.slice(0, 70))}`);
272 const stray = await stored();
273 check(!(stray && stray.pos && stray.pos[id.A]),
274 `and the drag Escape abandoned wrote nothing: ${JSON.stringify(stray && stray.pos && stray.pos[id.A])}`);
275}
276
277await reload();
278const g2 = await geom();
279check(same(afterDrag, g2), `the dragged picture survives a reload identically`
280 + (same(afterDrag, g2) ? '' : ` — Bravo at ${JSON.stringify(afterDrag.nodes.find(n => n.id === id.B).at)}`
281 + ` then ${JSON.stringify(g2.nodes.find(n => n.id === id.B).at)}`));
282check(!same(g1, g2), `and it is NOT the picture before the drag — the store really changed`);
283
284// ── 3. A cycle is still drawn and badged after a drag ────────────
285const cycleOf = (g) => ({
286 dashed: g.edges.filter(e => e.back).map(e => e.id).sort(),
287 badged: g.nodes.filter(n => n.cycled && n.badge).map(n => n.id).sort(),
288});
289const c1 = cycleOf(g1), c2 = cycleOf(g2);
290check(c1.dashed.length === 1 && c1.dashed[0] === L.ca && c1.badged.length === 3,
291 `before the drag: one dashed closing edge, three badged Diamonds — `
292 + `${c1.badged.map(x => nameOf[x]).join(',')}`);
293check(same(c1, c2), `after the drag the cycle is drawn and badged exactly as before: `
294 + `${JSON.stringify({ dashed: c2.dashed.length, badged: c2.badged.map(x => nameOf[x]) })}`);
295check(g2.edges.filter(e => e.back).every(e => /\d/.test(e.dash || '') && e.dash !== 'none'),
296 `and the closing edge is still DASHED, not merely classed: ${JSON.stringify(g2.edges.find(e => e.back).dash)}`);
297
298// ── 4. The arrowhead lands on the box, not in it ─────────────────
299//
300// Two properties of the routing, checked after the ordinary drag and again
301// after a drag that puts a target ABOVE its source -- which auto-layout can
302// never produce, and which is therefore the case a layered router was never
303// asked about.
304async function routingChecks(when) {
305const onPerimeter = await page.evaluate(() => {
306 // The node is a flattened hexagon, not a rectangle: its left and right sides
307 // kink out by KINK at the vertical midpoint. These mirror `BOX_D` in
308 // www/js/graph.js and must move with it.
309 //
310 // This matters more than it looks. A point sitting exactly ON a slanted side is
311 // INSIDE the bounding box, so a perimeter test written against a rectangle
312 // rejects a perfectly placed arrowhead. That is what happened on the first run
313 // after the shape changed: the check called the app broken when the app was
314 // right, and the only case it got away with was a link arriving at a midpoint,
315 // where the hexagon happens to touch the bounding box.
316 const W = 176, H = 44, KINK = 12, EPS = 0.6;
317 /// How far in from the bounding box the outline sits at height `ly`: KINK at the
318 /// top and bottom edges, nothing at the waist.
319 const inset = (ly) => KINK * Math.min(Math.abs(ly - H / 2) / (H / 2), 1);
320 const at = (g) => {
321 const m = /translate\(\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\)/.exec(g.getAttribute('transform') || '');
322 return m ? { x: +m[1], y: +m[2] } : null;
323 };
324 const pos = {};
325 document.querySelectorAll('g.graph-node').forEach(g => { pos[g.dataset.diamondId] = at(g); });
326 const bad = [];
327 document.querySelectorAll('g.graph-edge').forEach(g => {
328 const d = g.querySelector('path.graph-edge-line').getAttribute('d');
329 const m = /\s(-?[\d.]+),(-?[\d.]+)$/.exec(d);
330 if (!m) { bad.push([g.dataset.linkId, 'unreadable']); return; }
331 const x = +m[1], y = +m[2], b = pos[g.dataset.to];
332 if (!b) { bad.push([g.dataset.linkId, 'no target box']); return; }
333 const ly = y - b.y, lx = x - b.x;
334 const inY = ly >= -EPS && ly <= H + EPS;
335 // Top and bottom are flat, and shortened by KINK at each end.
336 const onFlat = (Math.abs(ly) < EPS || Math.abs(ly - H) < EPS)
337 && lx >= KINK - EPS && lx <= W - KINK + EPS;
338 // The slanted sides: where the outline is at THIS height, not where the
339 // bounding box is.
340 const side = inset(ly);
341 const onSide = inY && (Math.abs(lx - side) < EPS || Math.abs(lx - (W - side)) < EPS);
342 if (!(onFlat || onSide)) {
343 bad.push([g.dataset.linkId,
344 `(${x},${y}) is ${lx.toFixed(1)},${ly.toFixed(1)} in a box whose outline `
345 + `at that height runs ${side.toFixed(1)}..${(W - side).toFixed(1)}`]);
346 }
347 });
348 return bad;
349});
350const nEdges = await page.evaluate(() => document.querySelectorAll('g.graph-edge').length);
351check(onPerimeter.length === 0,
352 `${when}: every arrowhead lands on the edge of the box it points at: `
353 + (onPerimeter.length ? JSON.stringify(onPerimeter) : `${nEdges} edge(s) checked`));
354
355// And it approaches from the right SIDE of the box. Landing on the perimeter
356// is not enough: the layered routing leaves the bottom of the source and enters
357// the top of the target, and once a drag has put the target ABOVE its source
358// that same routing draws a line going up out of the bottom of one box and into
359// the top of the other -- both endpoints still perfectly on a perimeter, and the
360// line doubling back across both boxes to get there. So an edge whose target is
361// above its source must NOT leave through the source's bottom edge.
362const wrongSide = await page.evaluate(() => {
363 const W = 176, H = 44, EPS = 0.6;
364 const at = (g) => {
365 const m = /translate\(\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\)/.exec(g.getAttribute('transform') || '');
366 return m ? { x: +m[1], y: +m[2] } : null;
367 };
368 const pos = {};
369 document.querySelectorAll('g.graph-node').forEach(g => { pos[g.dataset.diamondId] = at(g); });
370 const bad = [];
371 document.querySelectorAll('g.graph-edge:not(.back)').forEach(g => {
372 const d = g.querySelector('path.graph-edge-line').getAttribute('d');
373 const m = /^M(-?[\d.]+),(-?[\d.]+)/.exec(d);
374 const a = pos[g.dataset.from], b = pos[g.dataset.to];
375 if (!m || !a || !b) return;
376 const y0 = +m[2];
377 const above = (b.y + H / 2) <= (a.y + H / 2);
378 const leftTheBottom = Math.abs(y0 - (a.y + H)) < EPS;
379 if (above && leftTheBottom) bad.push([g.dataset.linkId, `target above, yet the line leaves y=${y0} which is the source's bottom (${a.y + H})`]);
380 });
381 return bad;
382});
383check(wrongSide.length === 0,
384 `${when}: and no line goes UP out of the bottom of the box it starts at: `
385 + (wrongSide.length ? JSON.stringify(wrongSide) : `${nEdges} edge(s) checked`));
386}
387await routingChecks('after a sideways drag');
388
389// Now the case auto-layout cannot make: Charlie pulled up above Bravo, which it
390// points back at, so the B → C link runs upwards.
391const cBox = await boxAt(id.C);
392await dragBy(id.C, -90, -(cBox.y - 200));
393const flipped = await geom();
394const cy = flipped.nodes.find(n => n.id === id.C).at[1];
395const by = flipped.nodes.find(n => n.id === id.B).at[1];
396check(cy < by, `Charlie can be dragged above Bravo, which points at it: Charlie y=${cy}, Bravo y=${by}`);
397
398await shot(s, 'graphedit-2b-flipped');
399await routingChecks('with a target above its source');
400// And the cycle is still a cycle when the picture has been turned upside down.
401const cFlip = cycleOf(flipped);
402check(cFlip.dashed.length === 1 && cFlip.badged.length === 3,
403 `and the cycle survives being turned upside down: `
404 + `${cFlip.dashed.length} dashed, ${cFlip.badged.map(x => nameOf[x]).join(',')}`);
405// Put it back, so what follows starts from the picture the earlier checks left.
406await page.evaluate((d) => {
407 const l = JSON.parse(localStorage.getItem('daimond-graph'));
408 delete l.pos[d];
409 localStorage.setItem('daimond-graph', JSON.stringify(l));
410}, id.C);
411await page.reload({ waitUntil: 'domcontentloaded' });
412await signInAs(s, 'graphedit');
413await page.waitForTimeout(1400);
414await draw();
415
416// ── 5. Link mode is escapable, twice over ────────────────────────
417const linkArmed = () => page.evaluate(() =>
418 !!document.querySelector('#graph-link-btn.on'));
419const liveLines = () => page.evaluate(() =>
420 document.querySelectorAll('#graph-body g.graph-live path.graph-live-line').length);
421
422// Each probe ARMS the mode outright rather than pressing the toggle, and
423// asserts it is armed before trying to leave. A toggle pressed twice after a
424// failed escape leaves the mode OFF, and the next probe would then pass by
425// having nothing to escape from -- which is how a way-out check quietly stops
426// checking anything.
427const arm = async () => {
428 await page.evaluate(() => DaimondGraph.linkMode(true));
429 await page.waitForTimeout(250);
430 return await linkArmed();
431};
432check(await arm(), `the Link mode arms, and the control shows it`);
433await page.click('#graph-link-btn', { force: true });
434await page.waitForTimeout(250);
435check(!(await linkArmed()), `the Link control disarms it again`);
436
437check(await arm(), `armed again for the Escape probe`);
438await page.keyboard.press('Escape');
439await page.waitForTimeout(300);
440check(!(await linkArmed()), `Escape leaves it`);
441
442check(await arm(), `armed again for the pointer probe`);
443// Pick a source, so the mode is in its DEEPER state -- half a link drawn is the
444// state a way out is most needed from, and the one most easily forgotten.
445let b = await boxAt(id.A);
446await page.mouse.click(b.x, b.y);
447await page.waitForTimeout(300);
448await page.mouse.move(b.x + 90, b.y + 70);
449await page.waitForTimeout(200);
450const live = await liveLines();
451check(live === 1, `with a source picked, one live arrow follows the pointer: ${live} drawn`);
452await shot(s, 'graphedit-3-linkmode');
453const empty = await emptyPoint();
454check(!!empty, `found a point over nothing to click: ${JSON.stringify(empty)}`);
455await page.mouse.click(empty.x, empty.y);
456await page.waitForTimeout(400);
457check(!(await linkArmed()) && (await liveLines()) === 0,
458 `clicking empty space leaves link mode and takes the arrow with it`);
459
460// And the arrow was never anything but pointer feedback.
461await reload();
462check((await geom()).live === 0 && !JSON.stringify(await stored()).includes('live'),
463 `nothing about the live arrow was stored: the reloaded picture has no live layer content`);
464
465// ── 6. The context menu is escapable, twice over ─────────────────
466const menuUp = () => page.evaluate(() => {
467 const m = document.getElementById('graph-menu');
468 return !!(m && m.getClientRects().length);
469});
470b = await boxAt(id.C);
471await page.mouse.click(b.x, b.y, { button: 'right' });
472await page.waitForTimeout(300);
473check(await menuUp(), `right-clicking a Diamond opens a menu`);
474const items = await page.evaluate(() =>
475 [...document.querySelectorAll('#graph-menu .graph-menu-item')].map(x => x.textContent));
476check(items.some(x => /organise/i.test(x)), `and it carries Organise: ${JSON.stringify(items)}`);
477await shot(s, 'graphedit-4-menu');
478await page.keyboard.press('Escape');
479await page.waitForTimeout(300);
480check(!(await menuUp()), `Escape closes the menu`);
481
482await page.mouse.click(b.x, b.y, { button: 'right' });
483await page.waitForTimeout(300);
484const e2 = await emptyPoint();
485await page.mouse.click(e2.x, e2.y);
486await page.waitForTimeout(300);
487check(!(await menuUp()), `and so does clicking away from it`);
488
489// ── 7. Clicking a link edits it, and the form is escapable ───────
490const formUp = () => page.evaluate(() => {
491 const f = document.getElementById('graph-edit');
492 return !!(f && f.getClientRects().length);
493});
494/// A point on a drawn link's line that the pointer can actually reach.
495///
496/// Not simply the midpoint: the pane scrolls, so a midpoint can be under the
497/// panel's head or off the bottom of the window, and a click there would land
498/// on something else entirely and report "the form did not open" for a reason
499/// that has nothing to do with the form. So several fractions along the line
500/// are tried and the first one the DOCUMENT agrees is over that link is
501/// returned, with what it found instead when none is.
502const edgeAt = (lid) => page.evaluate((l) => {
503 const p = document.querySelector(`g.graph-edge[data-link-id="${l}"] path.graph-edge-line`);
504 if (!p) return { err: 'no such edge drawn' };
505 const svg = p.ownerSVGElement;
506 const r = svg.getBoundingClientRect();
507 const vb = svg.getAttribute('viewBox').split(/\s+/).map(Number);
508 const tried = [];
509 for (const f of [0.5, 0.4, 0.6, 0.3, 0.7, 0.25, 0.75]) {
510 const pt = p.getPointAtLength(p.getTotalLength() * f);
511 const x = r.left + pt.x * (r.width / vb[2]);
512 const y = r.top + pt.y * (r.height / vb[3]);
513 if (x < 0 || y < 0 || x > innerWidth || y > innerHeight) { tried.push([f, 'off screen']); continue; }
514 const el = document.elementFromPoint(x, y);
515 const g = el && el.closest ? el.closest('g.graph-edge') : null;
516 if (g && g.dataset.linkId === l) return { x, y };
517 tried.push([f, el ? el.tagName + '.' + (el.getAttribute('class') || '') : 'nothing']);
518 }
519 return { err: 'no reachable point on the line: ' + JSON.stringify(tried) };
520}, lid);
521
522let ep = await edgeAt(L.de);
523check(!ep.err, `the Delta → Echo line is reachable by a pointer: ${ep.err || JSON.stringify(ep)}`);
524await page.mouse.click(ep.x, ep.y);
525await page.waitForTimeout(400);
526check(await formUp(), `clicking a link opens its form`);
527// The link's own relations are CHIPS, and the box beside them is empty. That is
528// the difference between a set and a field: what is already on the link is shown
529// as things you can drop one at a time, and what you type is a further one. A box
530// pre-filled with `notes` would mean that typing `supersedes` REPLACED it, which
531// is how a relation gets lost by being edited.
532const relForm = await page.evaluate(() => ({
533 typed: (document.getElementById('graph-edit-rel') || {}).value,
534 chips: [...document.querySelectorAll('#graph-rel-chips .tag-chip')]
535 .map(c => (c.textContent || '').replace(/×\s*$/, '').trim()),
536}));
537check(relForm.chips.includes('notes'),
538 `the link's own relation is shown as a chip: ${JSON.stringify(relForm.chips)}`);
539check(relForm.typed === '',
540 `and the box you type into starts empty, so a word typed there is an ADDITION: `
541 + JSON.stringify(relForm.typed));
542await shot(s, 'graphedit-5-linkform');
543await page.keyboard.press('Escape');
544await page.waitForTimeout(300);
545check(!(await formUp()), `Escape closes the form and the link is untouched`);
546
547ep = await edgeAt(L.de);
548await page.mouse.click(ep.x, ep.y);
549await page.waitForTimeout(400);
550await page.click('#graph-edit-cancel', { force: true, timeout: 8000 });
551await page.waitForTimeout(300);
552check(!(await formUp()), `and so does its own Cancel`);
553
554// Now really edit it: a SECOND relation on a link that already has one.
555//
556// The word is left in the box rather than added with the `+`, because a word
557// typed and not added is a word the user meant and the form takes it on the way
558// out -- and because that is the path along which a set could quietly become a
559// replacement. What must come back is BOTH relations: the store holds them as
560// one comma-separated string, and `notes` was never edited, so losing it here
561// would be data destroyed by the act of adding something.
562ep = await edgeAt(L.de);
563await page.mouse.click(ep.x, ep.y);
564await page.waitForTimeout(400);
565await page.fill('#graph-edit-rel', 'supersedes');
566await page.fill('#graph-edit-note', 'changed by the verifier');
567await page.click('#graph-edit-ok', { force: true });
568await page.waitForTimeout(1000);
569const edited = JSON.parse(await wasm(async (app) => await app.all_links()))
570 .filter(l => l.from.endsWith(id.D) && l.to.endsWith(id.E));
571const editedRels = edited.length === 1
572 ? String(edited[0].rel || '').split(',').map(r => r.trim()).filter(Boolean) : [];
573check(edited.length === 1 && editedRels.length === 2
574 && editedRels.includes('notes') && editedRels.includes('supersedes')
575 && edited[0].note === 'changed by the verifier',
576 `saving KEEPS the relation the link had and adds the new one, in one link not two: `
577 + JSON.stringify(edited.map(l => [l.rel, l.note])));
578const drawnRel = (await geom()).edges.find(e => e.from === id.D && e.to === id.E);
579const drawnWords = (drawnRel && drawnRel.labels) || [];
580check(drawnWords.includes('notes') && drawnWords.includes('supersedes'),
581 `and the picture carries a chip for each of them without a reload: ${JSON.stringify(drawnWords)}`);
582
583// And the other half of a set: closing a chip drops that relation and leaves the
584// rest. A × that only redrew the form would look right and save the old string.
585ep = await edgeAt(L.de);
586await page.mouse.click(ep.x, ep.y);
587await page.waitForTimeout(400);
588const dropped = await page.evaluate(() => {
589 const chip = [...document.querySelectorAll('#graph-rel-chips .tag-chip')]
590 .find(c => /^notes/.test((c.textContent || '').trim()));
591 if (!chip) return false;
592 chip.querySelector('.tag-x').click();
593 return true;
594});
595check(dropped, `the chip for a relation offers a way to drop it`);
596await page.click('#graph-edit-ok', { force: true });
597await page.waitForTimeout(1000);
598const afterDrop = JSON.parse(await wasm(async (app) => await app.all_links()))
599 .filter(l => l.from.endsWith(id.D) && l.to.endsWith(id.E));
600check(afterDrop.length === 1 && afterDrop[0].rel === 'supersedes',
601 `closing a chip drops that relation and keeps the others: `
602 + JSON.stringify(afterDrop.map(l => l.rel)));
603
604// ── 8. A link drawn by clicking two Diamonds ─────────────────────
605const linksNow = () => wasm(async (app) => await app.all_links()).then(x => JSON.parse(x));
606const nLinks = (await linksNow()).length;
607await page.click('#graph-link-btn', { force: true });
608await page.waitForTimeout(250);
609b = await boxAt(id.F);
610await page.mouse.click(b.x, b.y);
611await page.waitForTimeout(300);
612let b2 = await boxAt(id.E);
613await page.mouse.click(b2.x, b2.y);
614await page.waitForTimeout(500);
615check(await formUp(), `clicking a source then a target offers the new link's form`);
616await page.fill('#graph-edit-rel', 'mentions');
617await page.click('#graph-edit-ok', { force: true });
618await page.waitForTimeout(1200);
619const made = (await linksNow()).filter(l => l.from === 'diamond:' + id.F && l.to === 'diamond:' + id.E);
620check(made.length === 1 && made[0].rel === 'mentions',
621 `and the link is asserted from Foxtrot to Echo: ${JSON.stringify(made.map(l => l.rel))}`);
622check((await linksNow()).length === nLinks + 1,
623 `exactly one link was added: ${nLinks} → ${(await linksNow()).length}`);
624check(!(await linkArmed()), `and the mode let go of itself once the link was drawn`);
625
626// ── 9. Organise: twice from one state is once ────────────────────
627await draw();
628const gBeforeOrg = await geom();
629await page.evaluate(() => DaimondGraph.organise());
630await page.waitForTimeout(700);
631const gOrg1 = await geom();
632const posOrg1 = ((await stored()) || {}).pos || {};
633await page.evaluate(() => DaimondGraph.organise());
634await page.waitForTimeout(700);
635const gOrg2 = await geom();
636const posOrg2 = ((await stored()) || {}).pos || {};
637const xy = (p) => Object.keys(p).sort().map(k => `${k}:${p[k].x},${p[k].y}`).join('|');
638check(xy(posOrg1) === xy(posOrg2),
639 `organising twice from one state writes one arrangement: ${Object.keys(posOrg1).length} position(s)`
640 + (xy(posOrg1) === xy(posOrg2) ? ', identical'
641 : `\n once: ${xy(posOrg1)}\n twice: ${xy(posOrg2)}`));
642check(same(gOrg1, gOrg2), `and draws one picture`);
643check(gOrg1.nodes.length === gBeforeOrg.nodes.length && gOrg1.edges.length === gBeforeOrg.edges.length,
644 `organise loses neither a Diamond nor a link: ${gBeforeOrg.nodes.length}/${gBeforeOrg.edges.length}`
645 + ` → ${gOrg1.nodes.length}/${gOrg1.edges.length}`);
646check(gOrg1.nodes.every(n => n.placed),
647 `and it WROTE the arrangement rather than merely drawing it: `
648 + `${gOrg1.nodes.filter(n => n.placed).length}/${gOrg1.nodes.length} marked placed`);
649await shot(s, 'graphedit-6-organised');
650
651// The cycle again, after organise.
652const c3 = cycleOf(gOrg1);
653check(c3.dashed.length === 1 && c3.dashed[0] === L.ca && c3.badged.length === 3,
654 `after organise the cycle is still drawn dashed and still badged: `
655 + `${c3.badged.map(x => nameOf[x]).join(',')}`);
656
657// And the organised picture reloads identically, which is the whole claim.
658await reload();
659check(same(gOrg1, await geom()), `the organised picture survives a reload identically`);
660
661// ── 10. Nothing is lost ──────────────────────────────────────────
662const fresh = await wasm(async (app) => await app.create_diamond('Golf'));
663await draw();
664const gNew = await geom();
665const golf = gNew.nodes.find(n => n.id === fresh);
666check(!!golf && !golf.placed && golf.at && golf.at[1] > 0,
667 `a Diamond made AFTER an organise has no stored position and is still drawn: `
668 + `${golf ? JSON.stringify(golf.at) : 'absent'}`);
669const afterFresh = await stored();
670check(!(afterFresh && afterFresh.pos && afterFresh.pos[fresh]),
671 `and nothing was invented for it in the store`);
672
673await wasm(async (app, a) => await app.delete_diamond(a), id.F);
674await draw();
675await page.waitForTimeout(400);
676const afterDelete = await stored();
677check(!(afterDelete && afterDelete.pos && afterDelete.pos[id.F]),
678 `deleting Foxtrot took its stored position with it: `
679 + `${Object.keys(afterDelete.pos).length} position(s) left, for `
680 + `${(await geom()).nodes.length} Diamond(s)`);
681check(Object.keys((afterDelete && afterDelete.pos) || {}).every(k => (gNew.nodes.some(n => n.id === k) && k !== id.F)),
682 `and no position is left for a Diamond that is not drawn`);
683
684// ── 11. Middle-drag pans, and the pan is remembered ──────────────
685//
686// In a window big enough to hold the whole picture there is nothing to pan, and
687// a check made there would pass on a module that had never implemented panning
688// at all. So the window is made too small on purpose first, and the room to
689// scroll is asserted before the gesture is made.
690await page.setViewportSize({ width: 880, height: 560 });
691await page.waitForTimeout(500);
692await draw();
693const room = await page.evaluate(() => {
694 const b = document.getElementById('graph-body');
695 return { x: b.scrollWidth - b.clientWidth, y: b.scrollHeight - b.clientHeight };
696});
697check(room.x > 20 && room.y > 20, `the picture is bigger than the window, so there is something to pan: `
698 + JSON.stringify(room));
699
700// A point the DOCUMENT agrees is over the picture. The centre of the body is
701// not good enough: with no model connected the Admin drawer stands open over
702// the stage, and a gesture that began on a pulldown would report "panning does
703// not work" about a control it never reached.
704const bodyBox = await emptyPoint();
705check(!!bodyBox, `found a point over the picture to start the pan from: ${JSON.stringify(bodyBox)}`);
706await page.mouse.move(bodyBox.x, bodyBox.y);
707await page.mouse.down({ button: 'middle' });
708for (let i = 1; i <= 6; i++) await page.mouse.move(bodyBox.x - (60 * i) / 6, bodyBox.y - (40 * i) / 6);
709await page.mouse.up({ button: 'middle' });
710await page.waitForTimeout(600);
711const panned = await page.evaluate(() => {
712 const b = document.getElementById('graph-body');
713 return { x: Math.round(b.scrollLeft), y: Math.round(b.scrollTop) };
714});
715check(panned.x > 0 && panned.y > 0, `a middle-drag pans the view: ${JSON.stringify(panned)}`);
716const panRec = await stored();
717check(!!(panRec && panRec.pan) && panRec.pan.x === panned.x && panRec.pan.y === panned.y,
718 `and where it was left is written down: ${JSON.stringify(panRec && panRec.pan)}`);
719await reload();
720const panBack = await page.evaluate(() => {
721 const b = document.getElementById('graph-body');
722 return { x: Math.round(b.scrollLeft), y: Math.round(b.scrollTop) };
723});
724check(panBack.x === panned.x && panBack.y === panned.y,
725 `and it comes back there after a reload: ${JSON.stringify(panBack)} (was ${JSON.stringify(panned)})`);
726await shot(s, 'graphedit-8-panned');
727await page.setViewportSize({ width: 1500, height: 950 });
728await page.waitForTimeout(400);
729
730// ── 12. The pair the sync parcel needs ───────────────────────────
731//
732// graph.js does not touch sync.js -- another agent owns it -- so what is checked
733// here is the CONTRACT it is offering: a snapshot that is the same bytes twice
734// running (which is what the push-skip in sync.js rests on: a section that
735// serialised in enumeration order would push for ever), and an adopt that moves
736// on a newer stamp, refuses an older one, and settles on a second application
737// of the same record.
738const snapTwice = await page.evaluate(() => [
739 JSON.stringify(DaimondGraph.snapshot()), JSON.stringify(DaimondGraph.snapshot())]);
740check(snapTwice[0] === snapTwice[1] && snapTwice[0].length > 20,
741 `two snapshots with nothing between them are the same bytes: ${snapTwice[0].length} chars`);
742const snapKeys = await page.evaluate(() => Object.keys(DaimondGraph.snapshot().pos));
743check(snapKeys.length > 1 && snapKeys.every((k, i) => i === 0 || snapKeys[i - 1] < k),
744 `the snapshot's Diamonds are in sorted order, not the order storage enumerated: `
745 + `${snapKeys.length} key(s)${snapKeys.every((k, i) => i === 0 || snapKeys[i - 1] < k) ? '' : ' — ' + JSON.stringify(snapKeys)}`);
746check(!snapTwice[0].includes('"pan"'),
747 `and the snapshot carries no pan — a scroll offset is this window's, not this account's`);
748
749const adoption = await page.evaluate((d) => {
750 const before = DaimondGraph.snapshot();
751 const mine = before.pos[d];
752 const newer = { v: 1, pos: { [d]: { x: 999, y: 777, t: (mine ? mine.t : 0) + 5000 } } };
753 const took = DaimondGraph.adopt(newer);
754 const after = DaimondGraph.snapshot().pos[d];
755 const again = DaimondGraph.adopt(newer);
756 const older = DaimondGraph.adopt({ v: 1, pos: { [d]: { x: 1, y: 1, t: 1 } } });
757 const end = DaimondGraph.snapshot().pos[d];
758 return { mine, took, after, again, older, end };
759}, id.A);
760check(adoption.took === true && adoption.after.x === 999 && adoption.after.y === 777,
761 `a newer position from another device is taken: ${JSON.stringify(adoption.after)}`);
762check(adoption.again === false,
763 `the same record applied twice changes nothing — no stamp moves, so the round after a `
764 + `convergence is quiet`);
765check(adoption.older === false && adoption.end.x === 999,
766 `and an older position is refused: ${JSON.stringify(adoption.end)}`);
767await page.waitForTimeout(900); // adopt redraws through the ordinary refresh
768const drawnAfterAdopt = (await geom()).nodes.find(n => n.id === id.A);
769check(!!drawnAfterAdopt && drawnAfterAdopt.at[0] === 999 && drawnAfterAdopt.at[1] === 777,
770 `and the picture redrew itself where the other device put it: `
771 + `${JSON.stringify(drawnAfterAdopt && drawnAfterAdopt.at)}`);
772
773// ── 13. Nothing threw ────────────────────────────────────────────
774const gatewayNoise = /(401 \(Unauthorized\)|402 \(Payment Required\)|502 \(Bad Gateway\))/;
775const errs = errors(s).filter(e => !gatewayNoise.test(e));
776check(errs.length === 0, `no console errors beyond the gateway's answer: ${JSON.stringify(errs.slice(0, 3))}`);
777
778await shot(s, 'graphedit-7-final');
779await s.close();
780
781console.log('\n' + out.join('\n'));
782console.log(bad === 0 ? `\nALL ${out.length} CHECKS PASSED` : `\n${bad} of ${out.length} FAILED`);
783process.exit(bad === 0 ? 0 : 1);