game-maker / suite (push) Successful in 6m22s
Fit measured the nodes alone: a group drawn around them was cut off, and a graph holding nothing but a comment framed the empty origin. It now frames both sorts together. The rubber band ignored boxes entirely. It catches them now, into the *same* selection as the nodes — `nodeIds` and `annotationIds` side by side — so one drag moves all of it, one Delete removes all of it, and one undo takes it back. A box counts only when the band holds all of it, where a node counts on touching: a group wraps the nodes it holds and therefore overlaps every band drawn over any of them, and since it drags those nodes along, catching it by accident would be felt at once. A node inside a selected group is moved once, not twice — `travellers()` collects everything one gesture carries and lets each thing appear in it a single time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
333 lines
15 KiB
JavaScript
333 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 snapLabel = await locate('Snap');
|
||
// The checkbox sits right of the "Snap" label; locate() gives the label's text centre.
|
||
await click(snapLabel.x + 30, snapLabel.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(snapLabel.x + 30, snapLabel.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),
|
||
);
|
||
|
||
// 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 }),
|
||
);
|
||
}
|