Give a remap shape something to drag it by

A remap draws light rather than colour, so `pick/hit-op` let a click go THROUGH
it to what it lights, and `op-bounds` left it out of a marquee's reach. Both of
those rules are about getting PAST a shape, and neither has anything to say
about the shape you have in your hands — but the stage's move gesture IS that
hit-test, while `handles` hangs its box and corners off `pick/bounds-of`, which
reads the node's points and knows nothing about colour.

So a remap shape got a full set of handles over a shape that answered no point
on the stage at all. The first press on it started a marquee, the marquee
selected nothing, and the selection a timeline row had just given it was thrown
away: selectable in the timeline, and then neither movable nor resizable, with
the handles sitting right there. A knockout was the same bug for the same
reason, and both of them read as handles that do not work.

`hit-op` now takes what is selected, and what is selected is never
click-through. That is `choose`'s own rule — a click inside what is selected
keeps it, so a deep selection can be dragged — reaching one step further down;
clicking a remap you have NOT selected still goes through to what it lights,
which is the point of it. `op-bounds` keeps a hole's and a light's bounds like
anything else, so a marquee reaches one without going to the timeline for it.

`browser/remap.mjs` asserts it as `peg.mjs` does, with real pointer events
through the handles, and for the same reason: every assertion about the maths
passed throughout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-10-05 22:22:52 -04:00
parent 39c436584b
commit 2e021a13eb
5 changed files with 239 additions and 25 deletions

View file

@ -0,0 +1,174 @@
// Browser smoke test for a palette swapper on the stage: a remap shape is
// selected as a timeline row selects it, and the handles over it actually drag.
//
// WHAT A UNIT TEST CANNOT SEE, as `peg.mjs` says of a peg, and for a closely
// related reason. A remap draws no colour of its own — it lights what is under
// it — so `pick/hit-op` let a click go THROUGH it, and the stage's move gesture
// is that hit-test. `handles` meanwhile hangs its box and corners off
// `pick/bounds-of`, which reads the node's points and knows nothing about
// colour. So a remap shape got a full set of handles over a shape that answered
// no point on the stage at all: the first press on it started a marquee, the
// marquee selected nothing, and the selection it had been given by a timeline
// row was thrown away — which looks exactly like handles that do not work.
// Every assertion about the maths still passed, hence this.
//
// A plain shape goes through the same two drags as the control, so a harness
// that has stopped delivering pointer events cannot read as a pass.
//
// Uses an in-memory fixture and performs no server-side writes.
import { spawn } from 'node:child_process';
import { mkdtempSync } 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-remap-'));
const port = 9344;
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++;
};
// A press, two moves and a release, as a hand makes them: the stage only calls
// a gesture a gesture once the pointer has left the press by a pixel.
const drag = async (x, y, dx, dy) => {
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: x + fx, y: y + fy, button: 'left', buttons: 1, clickCount: 1,
pointerType: 'mouse',
});
await sleep(90);
}
await sleep(400);
};
// The handles as DOM, in client pixels: what a hand would aim at.
const rectOf = async sel => evalJS(`
const q = document.querySelector('${sel}');
if (!q) return null;
const r = q.getBoundingClientRect();
return {cx: r.x + r.width/2, cy: r.y + r.height/2, w: r.width, h: r.height};
`);
const state = async which => 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 n = c.get_in(c.get(e, k('clip')), v(k('symbols'), k('main'), k('nodes'), k('${which}')));
const ch = p => c.clj__GT_js(c.get_in(n, v(k('channels'), p, k('value'))));
return {pos: ch(v(k('xform'), k('pos'))), scale: ch(v(k('xform'), k('scale'))),
selection: c.clj__GT_js(c.get_in(db, v(k('ui'), k('selection'))))};
`);
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');
for (let i = 0; i < 200; i++) {
if (await evalJS('return !!(window.cljs && window.re_frame && window.arthur)')) break;
await sleep(250);
}
// Local route; a plain shape, and a remap shape beside it on its own ground.
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')));
let doc = arthur.domain.clip.blank();
doc = arthur.domain.paint.new_shape(doc, k('main'), k('plain'), 0,
v(40, 40, 90, 40, 90, 90, 40, 90), 1);
doc = arthur.domain.paint.new_shape(doc, k('main'), k('light'), 0,
v(140, 40, 200, 40, 200, 100, 140, 100), v(k('remap'), m(1, 2)));
const entry = m(k('clip'), doc, k('store'), m());
const cid = arthur.footage.store.install_BANG_(entry, 'remap-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));
return arthur.domain.symbol.remap_QMARK_(
c.get_in(doc, v(k('symbols'),k('main'),k('nodes'),k('light'),
k('channels'),v(k('style'),k('color')),k('value'))));
`);
ok(setup === true, 'the fixture holds a remap shape');
await sleep(800);
// The rule the fix must not break: with nothing selected, a click still goes
// through the light to what it lights.
const through = await evalJS(`
const c = cljs.core, v = c.vector;
const hit = arthur.ui.player.at(v(170, 70));
return hit ? JSON.stringify(c.clj__GT_js(hit)) : null;
`);
ok(through === null, 'with nothing selected a click goes through the light',
String(through));
for (const which of ['light', 'plain']) {
console.log(`\n${which}:`);
// Selected the way a timeline row selects it: a path, and no stage click.
await evalJS(`
const c = cljs.core, k = c.keyword, v = c.vector;
re_frame.core.dispatch_sync(v(k('arthur.events.ui/set-tool'), k('select')));
re_frame.core.dispatch_sync(v(k('arthur.events.ui/select'),
v(k('node'), k('main'), k('${which}'), v(k('${which}')))));
return true;
`);
await sleep(500);
const box = await rectOf('.paint-overlay .handles .box');
ok(!!box, 'the stage puts a box round it');
if (!box) continue;
const before = await state(which);
await drag(box.cx, box.cy, 25, 0);
const moved = await state(which);
ok(Array.isArray(moved.pos) && Math.abs(moved.pos[0] - 0) > 2,
'dragging its middle moves it', JSON.stringify(moved.pos));
ok(JSON.stringify(moved.selection) === JSON.stringify(before.selection),
'and the drag keeps the selection rather than marqueeing it away',
JSON.stringify(moved.selection));
const corner = await rectOf('.paint-overlay .handles .corner');
ok(!!corner, 'and corners to scale by');
if (!corner) continue;
await drag(corner.cx, corner.cy, 18, 18);
const scaled = await state(which);
ok(Array.isArray(scaled.scale) && Math.abs(scaled.scale[0] - 1) > 0.05,
'dragging a corner scales it', JSON.stringify(scaled.scale));
}
console.log(failures ? `\n${failures} FAILED`
: '\nPASS: a palette swapper can be grabbed, moved and scaled on the stage');
} finally {
if (ws) ws.close();
chrome.kill();
}