GraphCanvas had grown to 1406 lines. Wiring, dragging and the frame
views each carry their own state, so they now live in their own
classes behind a narrow host interface. Behaviour is unchanged.
- GraphCanvasHost is what the new classes see of the canvas.
- Wiring owns dragging a pin, the palette on a drop into empty space
and "Make transition".
- GraphDrags owns moving nodes and frames, including snapping.
- AnnotationLayer holds the frame views. The test hooks stay on the
canvas and read the views through the layer.
- BeadClock moves into beads.ts and can now be tested without a
browser.
- The canvas builds host, wiring and drags in its constructor because
state is a parameter property and not set during field init.
- GraphCanvas is down to 1019 lines.
Tests:
- ./check.sh: 145 files, 2192 tests, CHECK EXIT 0 (+2 for BeadClock)
- e2e 1-28,31,36: ALL PASSED, 932 checks
- New e2e check in 36k: a frame caught by the rubber band is also
drawn as selected. No check covered that before. Against a bent
build it fails with selected: false.
- Counter-checks for drop target, snapping and beads fail exactly
the expected checks.
- Editor check sum is now 1314 (CI measured 1313 at 487b3aa).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
338 lines
15 KiB
JavaScript
338 lines
15 KiB
JavaScript
import { node } from '../lib/builders.mjs';
|
||
|
||
/**
|
||
* Groups and comments in a graph (#38). Two separate things on purpose: a group is a titled box
|
||
* that carries what it encloses, a comment is a card of text that frames nothing.
|
||
*
|
||
* What only a running editor can answer is in here: that a group takes the nodes whose *centre*
|
||
* lies in it and leaves the one beside it, that deleting the box leaves every node where it was,
|
||
* and that the text on a freely picked colour flips between black and white so it stays readable –
|
||
* none of which a unit test can see, because all three are about what is drawn and dragged.
|
||
*/
|
||
|
||
const GRAPH = {
|
||
nodes: [
|
||
node('gg-start', 'OnStart', 0, 0),
|
||
node('gg-self', 'Self', 0, 200),
|
||
node('gg-far', 'Self', 900, 600),
|
||
],
|
||
// No links: this step is about the boxes behind the nodes, not about what runs.
|
||
edges: [],
|
||
};
|
||
|
||
export async function graphGroups(t) {
|
||
const {
|
||
check,
|
||
json,
|
||
evaluate,
|
||
mutate,
|
||
click,
|
||
clickText,
|
||
drag,
|
||
locate,
|
||
press,
|
||
typeText,
|
||
shot,
|
||
showTab,
|
||
settle,
|
||
} = t;
|
||
|
||
const boxes = () => json('__gm.graph.annotations()');
|
||
const one = async (kind) => (await boxes()).find((a) => a.kind === kind) ?? null;
|
||
const nodeAt = (id) =>
|
||
json(
|
||
`(() => { const g = __gm.state.project.assets.find((a) => a.id === 'gg-logic').graph;` +
|
||
` const n = g.nodes.find((n) => n.id === ${JSON.stringify(id)});` +
|
||
` return n ? { x: n.x, y: n.y } : null; })()`,
|
||
);
|
||
|
||
await press('Escape');
|
||
await evaluate('__gm.state.select(null)');
|
||
|
||
// 36a. A graph of its own: two nodes close together and a third far away, which is what makes
|
||
// "the group took the right ones" a question with a wrong answer available.
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
const logic = structuredClone(p.assets.find((a) => a.id === 'mover'));
|
||
Object.assign(logic, {
|
||
id: 'gg-logic',
|
||
name: 'Grouped',
|
||
props: { enabled: true, runInEditor: false },
|
||
variables: [],
|
||
graph: ${JSON.stringify(GRAPH)},
|
||
});
|
||
p.assets.push(logic);`);
|
||
await evaluate("__gm.state.select({ kind: 'asset', assetId: 'gg-logic' })");
|
||
await showTab('graph');
|
||
await evaluate('__gm.graph.current()');
|
||
|
||
// 36b. The palette is the one gesture the canvas has for putting something on itself, so both
|
||
// boxes are offered there – above the nodes, and reachable by the same search field.
|
||
const spot = await json('__gm.graph.emptySpot()');
|
||
await click(spot.x, spot.y, 'right');
|
||
const offered = await json('__gm.graph.paletteRows()');
|
||
check(
|
||
'the drop palette offers a group and a comment before the nodes',
|
||
offered[0]?.type === 'add-group' && offered[1]?.type === 'add-comment',
|
||
JSON.stringify(offered.slice(0, 4)),
|
||
);
|
||
// The search reaches them by what they are, not by the "Add" they both start with: that word is
|
||
// what somebody types to find the `Add` node, and two entries jumping the list for it would cost
|
||
// more than they help.
|
||
await typeText('add');
|
||
const searchedAdd = await json('__gm.graph.paletteRows()');
|
||
await press('a', { ctrl: true });
|
||
await typeText('comment');
|
||
const searchedComment = await json('__gm.graph.paletteRows()');
|
||
check(
|
||
'the search finds them by what they are, and leaves the query "add" to the nodes',
|
||
searchedAdd.every((row) => !row.type.startsWith('add-')) && searchedComment[0]?.type === 'add-comment',
|
||
JSON.stringify({ add: searchedAdd.slice(0, 2), comment: searchedComment.slice(0, 2) }),
|
||
);
|
||
await clickText('Add comment');
|
||
const comment = await one('note');
|
||
check(
|
||
'a comment is drawn where the palette was opened, in white on the default colour',
|
||
comment !== null && comment.text === 'Comment' && comment.textColor === '#ffffff',
|
||
JSON.stringify(comment),
|
||
);
|
||
|
||
// 36c. A group out of a selection: the two near nodes, right-clicked as a selection.
|
||
await evaluate(
|
||
"__gm.state.select({ kind: 'node', assetId: 'gg-logic', nodeIds: ['gg-start', 'gg-self'] })",
|
||
);
|
||
const start = await json('__gm.graph.nodeAt("gg-start")');
|
||
await click(start.x, start.y, 'right');
|
||
const menu = await json('__gm.menuRows()');
|
||
await clickText('Group these 2 nodes');
|
||
const group = await one('group');
|
||
const farBefore = await nodeAt('gg-far');
|
||
check(
|
||
'the node menu groups the whole selection, and the box is drawn around it',
|
||
menu.some((row) => (row ?? '') === 'Group these 2 nodes') &&
|
||
group !== null &&
|
||
group.text === 'Group' &&
|
||
(await json('__gm.state.selection')).kind === 'annotation',
|
||
JSON.stringify({ menu: menu.filter(Boolean).slice(0, 3), group }),
|
||
);
|
||
await shot('36-group');
|
||
|
||
// 36d. Dragging the title bar. The far node is the check that matters: it never moves, because
|
||
// its centre is nowhere near the box – that is the whole membership rule.
|
||
const startBefore = await nodeAt('gg-start');
|
||
const commentBefore = (await one('note')).x;
|
||
const title = await json(`__gm.graph.annotationAt(${JSON.stringify(group.id)})`);
|
||
await drag(title.x, title.y, title.x + 80, title.y + 60);
|
||
await settle();
|
||
const startAfter = await nodeAt('gg-start');
|
||
const farAfter = await nodeAt('gg-far');
|
||
const movedBox = await one('group');
|
||
check(
|
||
'moving a group takes the nodes inside it and leaves the one beside it',
|
||
startAfter.x > startBefore.x &&
|
||
startAfter.y > startBefore.y &&
|
||
movedBox.x > group.x &&
|
||
farAfter.x === farBefore.x &&
|
||
farAfter.y === farBefore.y &&
|
||
(await one('note')).x === commentBefore,
|
||
JSON.stringify({ startBefore, startAfter, farBefore, farAfter }),
|
||
);
|
||
|
||
// 36e. One undo for the whole gesture: the box and every node it carried, in one step.
|
||
await t.undo();
|
||
check(
|
||
'one undo takes the move back whole',
|
||
JSON.stringify(await nodeAt('gg-start')) === JSON.stringify(startBefore) &&
|
||
(await one('group')).x === group.x,
|
||
JSON.stringify({ node: await nodeAt('gg-start'), box: (await one('group')).x }),
|
||
);
|
||
|
||
// 36f. The corner resizes and nothing else: membership is derived from where the box ends up,
|
||
// so a node the corner sweeps over is simply inside it afterwards – it is not dragged.
|
||
const corner = await json(`__gm.graph.annotationAt(${JSON.stringify(group.id)}, true)`);
|
||
await drag(corner.x, corner.y, corner.x + 60, corner.y + 40);
|
||
await settle();
|
||
const resized = await one('group');
|
||
check(
|
||
'the corner resizes the box while every node stays put',
|
||
resized.w > group.w &&
|
||
resized.h > group.h &&
|
||
JSON.stringify(await nodeAt('gg-start')) === JSON.stringify(startBefore),
|
||
JSON.stringify({ before: { w: group.w, h: group.h }, after: { w: resized.w, h: resized.h } }),
|
||
);
|
||
|
||
// 36f2. With snapping on, the corner obeys the grid like a dragged node does – and it is the far
|
||
// edge that lands on it, because the near one is exactly what a resize must not move.
|
||
const magnet = await json('__gm.graph.snapButton()');
|
||
await click(magnet.x, magnet.y);
|
||
const held = await one('group');
|
||
const snapCorner = await json(`__gm.graph.annotationAt(${JSON.stringify(group.id)}, true)`);
|
||
// 53 and 37 are deliberately ragged: a multiple of 24 afterwards cannot come from the gesture.
|
||
await drag(snapCorner.x, snapCorner.y, snapCorner.x + 53, snapCorner.y + 37);
|
||
await settle();
|
||
const snapped = await one('group');
|
||
check(
|
||
'with snapping on the resized box ends on the grid, without moving its near corner',
|
||
(snapped.x + snapped.w) % 24 === 0 &&
|
||
(snapped.y + snapped.h) % 24 === 0 &&
|
||
snapped.x === held.x &&
|
||
snapped.y === held.y &&
|
||
snapped.w > held.w,
|
||
JSON.stringify({ x: snapped.x, y: snapped.y, w: snapped.w, h: snapped.h }),
|
||
);
|
||
await click(magnet.x, magnet.y);
|
||
|
||
// 36g. A freely picked colour, typed as hex into the inspector. The point is the second colour:
|
||
// the text flips to black on a light fill, which is what makes a free picker safe.
|
||
await evaluate(
|
||
`__gm.state.select({ kind: 'annotation', assetId: 'gg-logic', annotationId: ${JSON.stringify(group.id)} })`,
|
||
);
|
||
const field = await json("__gm.inspectorField('Color')");
|
||
await click(field.x, field.y);
|
||
// The field already holds the current hex; typing without this appends to it and parses as nothing.
|
||
await press('a', { ctrl: true });
|
||
await typeText('#ffe08a');
|
||
await press('Enter');
|
||
const colored = await one('group');
|
||
check(
|
||
'a group takes any colour, and its title turns black to stay readable on a light one',
|
||
colored.color === '#ffe08a' && colored.textColor === '#000000',
|
||
JSON.stringify({ color: colored.color, textColor: colored.textColor }),
|
||
);
|
||
await shot('36-group-colored');
|
||
|
||
// 36h. Delete takes the box and nothing else. A group that silently deleted what it framed
|
||
// would be the most expensive surprise in the editor.
|
||
const nodesBefore = await json(
|
||
"__gm.state.project.assets.find((a) => a.id === 'gg-logic').graph.nodes.length",
|
||
);
|
||
await press('Delete');
|
||
check(
|
||
'deleting a group leaves every node it framed exactly where it was',
|
||
(await one('group')) === null &&
|
||
(await json("__gm.state.project.assets.find((a) => a.id === 'gg-logic').graph.nodes.length")) ===
|
||
nodesBefore &&
|
||
JSON.stringify(await nodeAt('gg-start')) === JSON.stringify(startBefore),
|
||
JSON.stringify({ nodes: nodesBefore, boxes: (await boxes()).map((a) => a.kind) }),
|
||
);
|
||
|
||
// 36i. And it all survives the round trip through the store, which is what writing a note is for.
|
||
await t.saved();
|
||
const stored = await t.api(`projects/${t.projectId}`);
|
||
const saved = stored.project.assets.find((a) => a.id === 'gg-logic');
|
||
check(
|
||
'the comment is part of the saved document, the deleted group is not',
|
||
(saved.graph.notes ?? []).length === 1 &&
|
||
(saved.graph.notes ?? [])[0].text === 'Comment' &&
|
||
(saved.graph.groups ?? []).length === 0,
|
||
JSON.stringify({ notes: saved.graph.notes, groups: saved.graph.groups }),
|
||
);
|
||
// 36j. Fit frames the boxes too (#95). A comment far from every node is the case that tells the
|
||
// two answers apart: framing the nodes alone leaves it off screen, and nothing in the
|
||
// picture says why. The graph itself never moves – only the view does.
|
||
const away = await one('note');
|
||
// Far enough that framing the nodes alone leaves it off screen, near enough that the fit can
|
||
// still show everything: the view stops zooming out at 0.25, and past that no fit can succeed.
|
||
await mutate(`
|
||
const g = __gm.state.project.assets.find((a) => a.id === 'gg-logic').graph;
|
||
const note = g.notes.find((n) => n.id === ${JSON.stringify(away.id)});
|
||
note.x = -700; note.y = -500;`);
|
||
// The button, not a hook: "click Fit" is the gesture the issue is about.
|
||
const fitButton = await locate('Fit');
|
||
await click(fitButton.x, fitButton.y);
|
||
const canvas = await json('__gm.graph.canvasRect()');
|
||
const noteRect = await json(`__gm.graph.annotationRect(${JSON.stringify(away.id)})`);
|
||
const startRect = await json('__gm.graph.nodeRect("gg-start")');
|
||
const inside = (r) =>
|
||
r !== null &&
|
||
r.x >= canvas.x - 1 &&
|
||
r.y >= canvas.y - 1 &&
|
||
r.x + r.w <= canvas.x + canvas.w + 1 &&
|
||
r.y + r.h <= canvas.y + canvas.h + 1;
|
||
check(
|
||
'fit brings a far-off comment into view without losing the nodes',
|
||
inside(noteRect) && inside(startRect),
|
||
JSON.stringify({ canvas, note: noteRect, node: startRect }),
|
||
);
|
||
await shot('36-fit-with-comment');
|
||
|
||
// 36k. The rubber band catches boxes as well (#95). Everything is on screen after the fit, so a
|
||
// band across the whole canvas must come back holding both sorts at once – and it stays a
|
||
// node selection, which is what keeps the inspector showing what it showed before.
|
||
await evaluate('__gm.state.select(null)');
|
||
const band = await json(`__gm.graph.annotationRect(${JSON.stringify(away.id)})`);
|
||
await drag(canvas.x + 3, canvas.y + 3, canvas.x + canvas.w - 3, canvas.y + canvas.h - 3);
|
||
const caught = await json('__gm.state.selection');
|
||
check(
|
||
'a band over the whole graph catches the comment along with the nodes',
|
||
caught?.kind === 'node' &&
|
||
(caught.annotationIds ?? []).includes(away.id) &&
|
||
caught.nodeIds.includes('gg-start'),
|
||
JSON.stringify(caught),
|
||
);
|
||
const drawnCaught = (await boxes()).find((box) => box.id === away.id);
|
||
check(
|
||
'the comment the band caught is drawn as selected',
|
||
drawnCaught?.selected === true,
|
||
JSON.stringify(drawnCaught),
|
||
);
|
||
|
||
// 36l. But a band that merely crosses a box leaves it alone – the rule that keeps a group, which
|
||
// wraps the nodes it holds, from joining every band drawn over any of them. It starts to the
|
||
// left of the comment on empty canvas and stops halfway across it.
|
||
await evaluate('__gm.state.select(null)');
|
||
await drag(band.x - 8, band.y + band.h / 2, band.x + band.w / 2, band.y + band.h / 2 + 4);
|
||
const grazed = await json('__gm.state.selectedAnnotationIds ?? []');
|
||
const stillThere = await one('note');
|
||
check(
|
||
'a band that only crosses a box does not catch it, and does not move it either',
|
||
!grazed.includes(away.id) && stillThere.x === -700 && stillThere.y === -500,
|
||
JSON.stringify({ grazed, note: { x: stillThere.x, y: stillThere.y } }),
|
||
);
|
||
|
||
// 36m. What one band caught, one drag moves – nodes and boxes by the same offset. The group here
|
||
// is the trap: it carries the nodes inside it, and those are selected too, so a naive
|
||
// implementation offsets them twice. Equal deltas are what says it did not.
|
||
await mutate(`
|
||
const g = __gm.state.project.assets.find((a) => a.id === 'gg-logic').graph;
|
||
g.groups = [{ id: 'gg-box', x: -20, y: -44, w: 216, h: 320, text: 'Held' }];`);
|
||
const fitAgain = await locate('Fit');
|
||
await click(fitAgain.x, fitAgain.y);
|
||
const sheet = await json('__gm.graph.canvasRect()');
|
||
await evaluate('__gm.state.select(null)');
|
||
await drag(sheet.x + 3, sheet.y + 3, sheet.x + sheet.w - 3, sheet.y + sheet.h - 3);
|
||
const before = {
|
||
start: await nodeAt('gg-start'),
|
||
far: await nodeAt('gg-far'),
|
||
group: (await boxes()).find((a) => a.id === 'gg-box'),
|
||
note: await one('note'),
|
||
};
|
||
const grip = await json('__gm.graph.nodeAt("gg-far")');
|
||
await drag(grip.x, grip.y, grip.x + 70, grip.y + 50);
|
||
const after = {
|
||
start: await nodeAt('gg-start'),
|
||
far: await nodeAt('gg-far'),
|
||
group: (await boxes()).find((a) => a.id === 'gg-box'),
|
||
note: await one('note'),
|
||
};
|
||
const delta = (key) => ({ x: after[key].x - before[key].x, y: after[key].y - before[key].y });
|
||
const moved = delta('far');
|
||
check(
|
||
'dragging one node moves every caught box and node by the same step, none of them twice',
|
||
moved.x > 0 &&
|
||
moved.y > 0 &&
|
||
['start', 'group', 'note'].every((key) => delta(key).x === moved.x && delta(key).y === moved.y),
|
||
JSON.stringify({ far: moved, start: delta('start'), group: delta('group'), note: delta('note') }),
|
||
);
|
||
|
||
// And one undo takes the whole mixed gesture back, boxes included.
|
||
await t.undo();
|
||
check(
|
||
'one undo returns the nodes and the boxes together',
|
||
JSON.stringify(await nodeAt('gg-start')) === JSON.stringify(before.start) &&
|
||
(await boxes()).find((a) => a.id === 'gg-box').x === before.group.x &&
|
||
(await one('note')).x === before.note.x,
|
||
JSON.stringify({ start: await nodeAt('gg-start'), note: (await one('note')).x }),
|
||
);
|
||
}
|