arthur/frontend/test/browser/peg.mjs
Your Name 39c436584b Place a peg's pivot with ⌃/⌘ rather than ⌥
⌥-drag does not survive the trip. Most Linux window managers grab Alt-drag to
move the window, so the page never sees the pointer and the gesture is simply
absent — on the machine it is absent from, with no error and nothing to find.
Reported from one.

⌃ (⌘ on a Mac) now places the pivot, which is the modifier the rest of this
stage already reaches for. ⌥ keeps working for anyone whose desktop leaves it
alone; ⇧ is deliberately not it, since it means CONSTRAIN everywhere else here —
uniform scale, 15° turn steps — and is what a snap to the child's corners will
want when this drag grows one.

The menu row says so, because a modifier nothing mentions is a modifier nobody
finds: "add peg · ⌃/⌘-drag its cross to place the pivot".

`peg.mjs` now asserts each modifier through real pointer events instead of
reading the handler, which is the only way this class of bug shows up — all
three place the pivot with a child drift of 0, and an unmodified drag still
translates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkinzDz1VtahZVsujAGRBD
2026-10-05 21:48:59 -04:00

296 lines
15 KiB
JavaScript

// Browser smoke test for pegs: a peg is made over a shape, nothing moves, and
// the stage gives it handles that actually drag.
//
// WHAT A UNIT TEST CANNOT SEE. A peg is a `:group`, so it draws nothing, and
// both of the stage's ways in key off a DRAWN op: `pick/choose` hit-tests the
// ops, and `handles` hangs its box, corners and knob off `pick/bounds-of`. So a
// peg was selectable only from a timeline row and had nothing to grab — the
// pivot that `gesture/refusal` tells you to make was unusable on the stage, and
// every assertion about the maths still passed. Hence this: the handles are
// asserted as DOM, with real pointer events through them.
//
// Uses an in-memory fixture and performs no server-side writes.
import { spawn } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import assert from 'node:assert/strict';
const url = process.env.ARTHUR_URL ?? 'http://localhost:8778/';
const profile = mkdtempSync(join(tmpdir(), 'arthur-peg-'));
const port = 9341;
const chrome = spawn(process.env.CHROME ?? '/usr/bin/chromium', [
'--headless=new', '--no-sandbox', '--disable-gpu', '--no-first-run',
'--no-default-browser-check', '--mute-audio', '--window-size=1440,1000',
`--user-data-dir=${profile}`, `--remote-debugging-port=${port}`, url,
], { stdio: 'ignore' });
const sleep = ms => new Promise(r => setTimeout(r, ms));
let ws, id = 0;
const pending = new Map();
const send = (method, params) => new Promise(res => {
const n = ++id; pending.set(n, res);
ws.send(JSON.stringify({ id: n, method, params }));
});
const evalJS = async expr => {
const r = await send('Runtime.evaluate',
{ expression: `(function(){${expr}})()`, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? JSON.stringify(r.exceptionDetails));
return r.result.value;
};
let failures = 0;
const ok = (cond, what, extra = '') => {
console.log(` ${cond ? 'ok ' : 'FAIL'} ${what}${extra ? ' — ' + extra : ''}`);
if (!cond) failures++;
};
try {
let target;
for (let i = 0; i < 100 && !target; i++) {
await sleep(100);
try {
target = (await fetch(`http://127.0.0.1:${port}/json/list`).then(r => r.json()))
.find(t => t.type === 'page' && t.url.startsWith(url));
} catch {}
}
assert(target, 'editor page');
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise(r => ws.addEventListener('open', r));
ws.addEventListener('message', e => {
const m = JSON.parse(e.data);
if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id); }
});
await send('Runtime.enable');
await send('Page.enable');
for (let i = 0; i < 160; i++) {
if (await evalJS('return !!(window.cljs && window.re_frame && window.arthur)')) break;
await sleep(250);
}
// Local route, a turned+scaled shape in :main, selected.
const setup = await evalJS(`
const c = cljs.core, k = c.keyword, v = c.vector, m = c.hash_map;
c.swap_BANG_(re_frame.db.app_db, db => c.assoc(db, k('route'), k('local-test')));
const blank = arthur.domain.clip.blank();
let doc = arthur.domain.paint.new_shape(blank, k('main'), k('shape'), 0,
v(100, 80, 140, 80, 140, 110, 100, 110), k('brow'));
doc = c.update_in(doc, v(k('symbols'), k('main'), k('nodes'), k('shape'), k('channels')),
ch => c.merge(ch, m(v(k('xform'), k('rot')), arthur.domain.channel.framed(0.4),
v(k('xform'), k('scale')), arthur.domain.channel.framed(v(1.5, 0.75)))));
const entry = m(k('clip'), doc, k('store'), m());
const cid = arthur.footage.store.install_BANG_(entry, 'peg-probe');
c.swap_BANG_(re_frame.db.app_db, db =>
c.assoc(c.assoc_in(db, v(k('ui'), k('open')), k('main')),
k('clip/current'), cid, k('paint/revision'), 0));
re_frame.core.dispatch_sync(v(k('arthur.events.ui/select'),
v(k('node'), k('main'), k('shape'), v(k('shape')))));
return {cid: String(cid)};
`);
console.log('setup:', JSON.stringify(setup));
await sleep(600);
const before = await evalJS(`
const c = cljs.core, k = c.keyword, v = c.vector;
const db = c.deref(re_frame.db.app_db);
const e = arthur.footage.store.entry(c.get(db, k('clip/current')));
const res = arthur.domain.clip.resolver(c.get(e,k('clip')), k('main'), c.get(e,k('store')),
arthur.domain.palette.index_of, null);
const ops = res.call(null, 0);
return Array.from(c.get(c.first(ops), k('pts')));
`);
ok(Array.isArray(before) && before.length === 8, 'the shape draws before the peg', JSON.stringify(before));
// Add the peg, exactly as the menu item does.
const made = await evalJS(`
const c = cljs.core, k = c.keyword, v = c.vector;
re_frame.core.dispatch_sync(v(k('arthur.events.ui/peg'), v(k('shape'))));
const db = c.deref(re_frame.db.app_db);
const e = arthur.footage.store.entry(c.get(db, k('clip/current')));
const doc = c.get(e, k('clip'));
const nodes = c.get_in(doc, v(k('symbols'), k('main'), k('nodes')));
const shape = c.get(nodes, k('shape'));
const pegId = c.get(shape, k('parent'));
const peg = c.get(nodes, pegId);
const res = arthur.domain.clip.resolver(doc, k('main'), c.get(e,k('store')),
arthur.domain.palette.index_of, null);
const sel = c.clj__GT_js(c.get_in(db, v(k('ui'), k('selection'))));
return {
kind: String(c.get(peg, k('kind'))),
pegPos: c.clj__GT_js(c.get_in(peg, v(k('channels'), v(k('xform'),k('pos')), k('value')))),
hasPinv: !!c.get(shape, k('pinv')),
pts: Array.from(c.get(c.first(res.call(null, 0)), k('pts'))),
selection: sel ? sel.map(String) : null,
problems: c.clj__GT_js(arthur.domain.clip.problems(doc)),
};
`);
ok(made.kind === ':group', 'the peg is a group', made.kind);
ok(made.hasPinv, 'the child got a :pinv');
ok((made.problems ?? []).length === 0, 'the document still validates', JSON.stringify(made.problems));
const moved = Math.max(...before.map((b, i) => Math.abs(b - made.pts[i])));
ok(moved < 1e-9, 'nothing moved when the peg appeared', `max drift ${moved}`);
ok(String(made.selection?.[2]) !== ':shape', 'the peg is selected afterwards', JSON.stringify(made.selection));
console.log('peg pos:', JSON.stringify(made.pegPos));
await sleep(700);
// THE BIT THAT UNIT TESTS CANNOT SEE: does the stage render grabbable handles?
const dom = await evalJS(`
const q = s => document.querySelector(s);
const g = q('.paint-overlay .handles');
const grab = q('.paint-overlay .handles .peg-grab');
const r = grab && grab.getBoundingClientRect();
return {
handles: !!g,
pegGrab: !!grab,
pegGrabEvents: grab ? getComputedStyle(grab).pointerEvents : null,
knobs: document.querySelectorAll('.paint-overlay .handles .knob').length,
corners: document.querySelectorAll('.paint-overlay .handles .corner').length,
boxes: document.querySelectorAll('.paint-overlay .handles .box').length,
rect: r ? {x: r.x + r.width/2, y: r.y + r.height/2, w: r.width} : null,
};
`);
console.log('dom:', JSON.stringify(dom));
ok(dom.handles, 'the stage draws handles for the selected peg');
ok(dom.pegGrab, 'there is a .peg-grab to drag it by');
ok(dom.pegGrabEvents && dom.pegGrabEvents !== 'none', 'and it accepts pointer events', String(dom.pegGrabEvents));
ok(dom.knobs === 1, 'a turn knob', String(dom.knobs));
ok(dom.corners === 4, 'four scale corners', String(dom.corners));
ok(dom.boxes === 0, 'and no box, because a peg draws nothing', String(dom.boxes));
// Drag it, through real pointer events on that element.
if (dom.rect) {
const { x, y } = dom.rect;
for (const [type, dx] of [['mousePressed', 0], ['mouseMoved', 18], ['mouseMoved', 36], ['mouseReleased', 36]]) {
await send('Input.dispatchMouseEvent', {
type, x: x + dx, y, button: 'left', buttons: 1, clickCount: 1, pointerType: 'mouse',
});
await sleep(80);
}
await sleep(400);
const after = await evalJS(`
const c = cljs.core, k = c.keyword, v = c.vector;
const db = c.deref(re_frame.db.app_db);
const e = arthur.footage.store.entry(c.get(db, k('clip/current')));
const doc = c.get(e, k('clip'));
const nodes = c.get_in(doc, v(k('symbols'), k('main'), k('nodes')));
const pegId = c.get(c.get(nodes, k('shape')), k('parent'));
const res = arthur.domain.clip.resolver(doc, k('main'), c.get(e,k('store')),
arthur.domain.palette.index_of, null);
return {pos: c.clj__GT_js(c.get_in(nodes, v(pegId, k('channels'), v(k('xform'),k('pos')), k('value')))),
pts: Array.from(c.get(c.first(res.call(null, 0)), k('pts')))};
`);
const dx = after.pts[0] - made.pts[0];
const dy = after.pts[1] - made.pts[1];
console.log('after drag: peg pos', JSON.stringify(after.pos), 'child moved by', dx.toFixed(3), dy.toFixed(3));
ok(dx > 2, 'dragging the peg cross moved its child', `dx=${dx.toFixed(3)}`);
ok(Math.abs(dy) < 1.5, 'and only horizontally, as dragged', `dy=${dy.toFixed(3)}`);
}
// ⌥-drag the cross: the PIVOT moves and the child must not.
const dom2 = await evalJS(`
const q = document.querySelector('.paint-overlay .handles .peg-grab');
const r = q && q.getBoundingClientRect();
const corner = document.querySelector('.paint-overlay .handles .corner');
const cr = corner && corner.getBoundingClientRect();
return {
grab: r ? {x: r.x + r.width/2, y: r.y + r.height/2, w: r.width} : null,
// The corner's own middle must hit the CORNER, not the move disc over it.
cornerHit: cr ? (document.elementFromPoint(cr.x + cr.width/2, cr.y + cr.height/2) || {}).className
?.baseVal ?? null : null,
};
`);
console.log('dom2:', JSON.stringify(dom2));
ok(dom2.cornerHit === 'corner', 'a scale corner is on top of the move disc, not under it',
String(dom2.cornerHit));
if (dom2.grab) {
// Every modifier that has to place the pivot. CDP modifiers: 1=alt, 2=ctrl,
// 4=meta. ⌥ alone was the whole gesture once, and on a desktop whose window
// manager grabs Alt-drag the page never sees it — so each is asserted
// through real pointer events rather than by reading the handler.
const { x, y } = dom2.grab;
let after2;
for (const [name, modifiers, sign] of [['alt', 1, -1], ['ctrl', 2, 1], ['meta', 4, -1]]) {
const pre = await evalJS(`
const c = cljs.core, k = c.keyword, v = c.vector;
const db = c.deref(re_frame.db.app_db);
const e = arthur.footage.store.entry(c.get(db, k('clip/current')));
const doc = c.get(e, k('clip'));
const nodes = c.get_in(doc, v(k('symbols'), k('main'), k('nodes')));
const pegId = c.get(c.get(nodes, k('shape')), k('parent'));
const res = arthur.domain.clip.resolver(doc, k('main'), c.get(e,k('store')),
arthur.domain.palette.index_of, null);
return {pts: Array.from(c.get(c.first(res.call(null, 0)), k('pts'))),
pos: c.clj__GT_js(c.get_in(nodes, v(pegId, k('channels'), v(k('xform'),k('pos')), k('value'))))};
`);
// Where the cross is NOW, since the previous modifier already moved it.
const g = await evalJS(`
const q = document.querySelector('.paint-overlay .handles .peg-grab');
const r = q && q.getBoundingClientRect();
return r ? {x: r.x + r.width/2, y: r.y + r.height/2} : null;
`);
const dx = 30 * sign, dy = 20 * sign;
for (const [type, fx, fy] of [['mousePressed', 0, 0], ['mouseMoved', dx/2, dy/2],
['mouseMoved', dx, dy], ['mouseReleased', dx, dy]]) {
await send('Input.dispatchMouseEvent', {
type, x: g.x + fx, y: g.y + fy, button: 'left', buttons: 1, clickCount: 1,
pointerType: 'mouse', modifiers,
});
await sleep(90);
}
await sleep(450);
const post = await evalJS(`
const c = cljs.core, k = c.keyword, v = c.vector;
const db = c.deref(re_frame.db.app_db);
const e = arthur.footage.store.entry(c.get(db, k('clip/current')));
const doc = c.get(e, k('clip'));
const nodes = c.get_in(doc, v(k('symbols'), k('main'), k('nodes')));
const pegId = c.get(c.get(nodes, k('shape')), k('parent'));
const res = arthur.domain.clip.resolver(doc, k('main'), c.get(e,k('store')),
arthur.domain.palette.index_of, null);
return {pts: Array.from(c.get(c.first(res.call(null, 0)), k('pts'))),
pos: c.clj__GT_js(c.get_in(nodes, v(pegId, k('channels'), v(k('xform'),k('pos')), k('value'))))};
`);
const drift = Math.max(...pre.pts.map((b, i) => Math.abs(b - post.pts[i])));
const moved = Math.hypot(post.pos[0] - pre.pos[0], post.pos[1] - pre.pos[1]);
ok(drift < 1e-6, `${name}-drag places the pivot and moves NOTHING under the peg`,
`drift ${drift}`);
ok(moved > 2, `${name}-drag actually moved the pivot`, `by ${moved.toFixed(2)}`);
after2 = post;
}
// And a plain drag still translates, which is the other half of the split.
{
const g = await evalJS(`
const q = document.querySelector('.paint-overlay .handles .peg-grab');
const r = q && q.getBoundingClientRect();
return r ? {x: r.x + r.width/2, y: r.y + r.height/2} : null;
`);
const pre = after2;
for (const [type, fx] of [['mousePressed', 0], ['mouseMoved', 12], ['mouseReleased', 12]]) {
await send('Input.dispatchMouseEvent', {
type, x: g.x + fx, y: g.y, button: 'left', buttons: 1, clickCount: 1, pointerType: 'mouse',
});
await sleep(90);
}
await sleep(450);
const post = await evalJS(`
const c = cljs.core, k = c.keyword, v = c.vector;
const db = c.deref(re_frame.db.app_db);
const e = arthur.footage.store.entry(c.get(db, k('clip/current')));
const res = arthur.domain.clip.resolver(c.get(e, k('clip')), k('main'), c.get(e,k('store')),
arthur.domain.palette.index_of, null);
return Array.from(c.get(c.first(res.call(null, 0)), k('pts')));
`);
ok(Math.abs(post[0] - pre.pts[0]) > 2, 'and an UNMODIFIED drag still translates the child',
`dx=${(post[0] - pre.pts[0]).toFixed(2)}`);
}
}
console.log(failures ? `\n${failures} FAILED` : '\nPASS: a peg can be made, moved, and have its pivot placed');
} finally {
if (ws) ws.close();
chrome.kill();
try {
rmSync(profile, { recursive: true, force: true });
} catch (error) {
if (error.code !== 'ENOTEMPTY') throw error;
}
}
if (failures) process.exitCode = 1;