Files
feuerwerk/tools/e2e/26-state-machine.mjs
T
admin 7e5003b590
game-maker / suite (push) Successful in 6m48s
A test that waits for a deadline somebody guessed is waiting for the wrong thing, so it waits for the editor (#93)
The suite spent 88% of its step time asleep: 564 s of steps, 496 s of
them in sleep(n) - fixed spans estimated by hand and then copied from
step to step. That is why it was slow, and why it flickered.

The signal is afterFrames(count, timeout) in ui/render.ts, exposed as
__gm.afterFrames(). It wakes the loop itself, because an editor that
draws nothing would never redeem a promise of a frame, and it gives up
after a timeout instead of hanging the run. settle() in the harness
waits two drawn frames (~32 ms) instead of 150-300 ms and falls back to
sleep(150) when the hook is missing (an old build): silently not
waiting would make every later check a coin toss.

Every action goes through it now - click, doubleClick, wheel, drag,
dragAsset, press, typeText, select, mutate, undo, redo - and with that
about 550 sleep() calls behind those actions became redundant, helpers
that are nothing but a mutate in a coat included. What a frame cannot
answer got its own condition instead: waitUntil(expr), play()/stop()
waiting on __gm.state.mode rather than on 300 ms, saved() for the
autosave (the two hand-written polling loops sampled at 300 ms for
something that arrives after a second), and showTab() as a real tool -
47 places called evaluate("__gm.showTab(...)") raw, and 24 of them
waited for nothing at all and read the old panel.

564 s -> 243 s of step time, the waiting share 88% -> 76%. Three kinds
of wait were kept, and each one bit when it was taken away: loading
bytes (textures, fonts), game time that a repaint cannot stand in for
(time scale, shader and particle clocks, the live values a running
graph carries), and the inertia the scroll step measures on purpose.

Two things the new speed uncovered, both real: 25-animation read the
frame menu unfiltered and had frozen an incomplete list (the CI failure
on c990dbc), and 33-render-on-change measured idleness while the
autosave of the step before was still painting - it waits for
saveStatus to leave dirty/saving now, so it measures that nothing
happens any more instead of that it waited long enough.
2026-09-16 08:08:10 +02:00

595 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { edge, node } from '../lib/builders.mjs';
import { LANES, scaffoldBehavior } from './lib/scaffold.mjs';
/**
* State machines: an asset made from the + menu, its states edited as nodes in the State machine
* tab, read by a `State branch` and moved by a `Set state` in a behavior. The step removes
* everything it made.
*/
export async function stateMachine(t) {
const {
check,
sleep,
json,
evaluate,
shot,
mutate,
press,
click,
clickText,
locate,
has,
doubleClick,
drag,
mouse,
showTab,
} = t;
const { settle, typeText, undo, redo, world, play, stop } = t;
const machines = () =>
json(`__gm.state.project.assets
.filter((a) => a.type === 'statemachine')
.map((a) => ({ id: a.id, name: a.name, states: a.graph.nodes.map((n) => ({ id: n.id, name: n.params.name, x: n.x, y: n.y })) }))`);
const states = async () => (await machines())[0]?.states ?? [];
/** The overlay is drawn last, so a menu entry wins over a tab button or a row saying the same. */
const clickLast = async (text) => {
const p = await locate(text, true);
await click(p.x, p.y);
};
const stateLabel = (id) => json(`__gm.stateMachine.nodeLabel(${JSON.stringify(id)})`);
const selection = () => json('__gm.state.selection');
const outputsOf = async (nodeId) => (await json(`__gm.graph.nodeOutputs(${JSON.stringify(nodeId)})`)) ?? [];
const inputsOf = async (nodeId) => (await json(`__gm.graph.nodeInputs(${JSON.stringify(nodeId)})`)) ?? [];
const selectNode = async (nodeId) => {
await t.select({ kind: 'node', assetId: 'sm-logic', nodeId, nodeIds: [nodeId] });
};
await press('Escape');
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
await evaluate('__gm.state.select(null)');
// 26a. The + menu creates a state machine; selected, it opens its own tab.
await clickText('+');
await clickLast('State machine');
const made = (await machines())[0];
check(
'the + menu creates a state machine asset',
Boolean(made) && (await machines()).length === 1,
JSON.stringify(await machines()),
);
const picked = await selection();
check(
'the new machine is selected and its tab opens on it',
picked?.kind === 'asset' &&
picked.assetId === made?.id &&
(await json('__gm.tab()')) === 'statemachine' &&
(await json('__gm.stateMachine.current()')) === made?.id,
JSON.stringify({
picked,
tab: await json('__gm.tab()'),
current: await json('__gm.stateMachine.current()'),
}),
);
check(
'the tab button has room for its label',
(await json("__gm.shownLabel('State machine')")) === 'State machine',
String(await json("__gm.shownLabel('State machine')")),
);
// 26b. Right-click places a state where the pointer is, numbered and selected; no header button.
const canvas = await json('__gm.stateMachine.canvasRect()');
const addState = async (dy) => {
await click(canvas.x + 80, canvas.y + dy, 'right');
await clickLast('State');
};
await addState(60);
await addState(220);
const two = await states();
check(
'right-click → State adds a state each time, named by its number',
two.map((s) => s.name).join(',') === 'State 1,State 2',
JSON.stringify(two),
);
check('the tab header has no + State button', !(await has('+ State')));
check(
'the new state is selected and its node shows its name',
(await selection())?.nodeId === two[1]?.id && (await stateLabel(two[1]?.id)) === 'State 2',
JSON.stringify({ selection: await selection(), label: await stateLabel(two[1]?.id) }),
);
const rects = await Promise.all(
two.map((s) => json(`__gm.stateMachine.nodeRect(${JSON.stringify(s.id)})`)),
);
check(
'each state sits where its right-click was, the second below the first',
rects[0] && rects[1] && rects[0].y + rects[0].h <= rects[1].y,
JSON.stringify(rects),
);
await shot('01-two-states');
// 26b2. The header's Snap checkbox is the graph's: on, a dragged state lands on the 24 px grid.
const GRID = 24;
const stateXY = async (id) => (await states()).find((s) => s.id === id) ?? { x: NaN, y: NaN };
check('snapping is off until it is asked for', (await json('__gm.stateMachine.snapping()')) === false);
const snapLabel = await locate('Snap');
const preSnap = JSON.stringify(await states());
// The checkbox sits right of the "Snap" label; locate() gives the label's text centre.
await click(snapLabel.x + 30, snapLabel.y);
check(
'the Snap toggle switches it on without moving a state',
(await json('__gm.stateMachine.snapping()')) === true && JSON.stringify(await states()) === preSnap,
JSON.stringify(await states()),
);
// The selected state: dragging another would move the selection off it, and 26c renames the selected one.
const dragged = two[1]?.id;
const from = await json(`__gm.stateMachine.nodeAt(${JSON.stringify(dragged)})`);
const wasAt = await stateXY(dragged);
await drag(from.x, from.y, from.x + 50, from.y + 50);
const nowAt = await stateXY(dragged);
check(
'a dragged state lands on the grid, not where the pointer let go',
nowAt.x % GRID === 0 && nowAt.y % GRID === 0 && (nowAt.x !== wasAt.x || nowAt.y !== wasAt.y),
`${wasAt.x},${wasAt.y} → ${nowAt.x},${nowAt.y}`,
);
await undo();
const backAt = await stateXY(dragged);
check(
'one undo takes the snapped drag back',
backAt.x === wasAt.x && backAt.y === wasAt.y,
`${backAt.x},${backAt.y}`,
);
await click(snapLabel.x + 30, snapLabel.y);
check('the toggle switches snapping off again', (await json('__gm.stateMachine.snapping()')) === false);
// 26c. The inspector renames the selected state; the node follows.
const nameField = await json("__gm.inspectorField('Name', true)");
check('the inspector shows a Name field for the state', Boolean(nameField), JSON.stringify(nameField));
await doubleClick(nameField.x, nameField.y);
await typeText('Open');
await press('Enter');
const renamed = (await states())[1];
check(
'typing in the Name field renames the state, on the node too',
renamed?.name === 'Open' && (await stateLabel(renamed?.id)) === 'Open',
JSON.stringify({ renamed, label: await stateLabel(renamed?.id) }),
);
// 26d. Undo and redo walk the rename and the additions back and forth.
await undo();
const afterOne = (await states()).map((s) => s.name).join(',');
await undo();
const afterTwo = (await states()).map((s) => s.name).join(',');
await redo();
await redo();
const back = (await states()).map((s) => s.name).join(',');
check(
'undo takes the rename and then the state back, redo brings both',
afterOne === 'State 1,State 2' && afterTwo === 'State 1' && back === 'State 1,Open',
JSON.stringify({ afterOne, afterTwo, back }),
);
// 26e. The canvas palette offers a state and nothing else.
await click(canvas.x + canvas.w - 40, canvas.y + canvas.h - 40, 'right');
const offered = await json('__gm.overlayTexts()');
check(
'right-click offers a State and no behavior node',
offered.includes('State') && !offered.some((text) => /branch|Set state|Number|On start/i.test(text)),
JSON.stringify(offered),
);
await press('Escape');
// 26e2. A transition is an ordinary edge, but it hangs on ports the editor never draws: a
// transition joins two whole states, so there is no socket to aim at and none to grab.
const [first, second] = (await states()).map((s) => s.id);
const outPin = await json(`__gm.stateMachine.portAt(${JSON.stringify(first)}, 'out', true)`);
const inPin = await json(`__gm.stateMachine.portAt(${JSON.stringify(second)}, 'in', false)`);
check(
'every state still carries the out and in port an edge needs to exist at all',
outPin !== null && inPin !== null,
JSON.stringify({ outPin, inPin }),
);
const grabbable = async (id, port, isOutput) =>
json(`__gm.stateMachine.portGrabbable(${JSON.stringify(id)}, ${JSON.stringify(port)}, ${isOutput})`);
check(
'but neither can be grabbed, because neither is drawn',
(await grabbable(first, 'out', true)) === false && (await grabbable(second, 'in', false)) === false,
);
const transitions = () =>
json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).graph.edges`);
const centreOfState = async (id) => {
const r = await json(`__gm.stateMachine.nodeRect(${JSON.stringify(id)})`);
return { x: r.x + r.w / 2, y: r.y + r.h / 2 };
};
const makeTransition = async (fromId, toId) => {
const from = await centreOfState(fromId);
await click(from.x, from.y, 'right');
await clickLast('Make transition');
const to = await centreOfState(toId);
await click(to.x, to.y);
};
// The right-click way in, since there is no pin to drag from any more.
const firstCentre = await centreOfState(first);
await click(firstCentre.x, firstCentre.y, 'right');
check(
"a state's menu offers Make transition",
(await json('__gm.overlayTexts()')).includes('Make transition'),
JSON.stringify(await json('__gm.overlayTexts()')),
);
await clickLast('Make transition');
check(
'armed, the canvas says which click it is waiting for',
(await json('__gm.stateMachine.hint()')).includes('Click the state'),
JSON.stringify(await json('__gm.stateMachine.hint()')),
);
check(
'and the line to the pointer is already on screen',
(await json('__gm.stateMachine.danglingEdge()')) === true,
);
await shot('05a-armed');
await press('Escape');
check(
'Escape drops the attempt, drawing nothing',
(await transitions()).length === 0 && (await json('__gm.stateMachine.danglingEdge()')) === false,
);
await makeTransition(first, second);
const drawn = await transitions();
check(
'clicking the other state connects them',
drawn.length === 1 && drawn[0]?.from.node === first && drawn[0]?.to.node === second,
JSON.stringify(drawn),
);
check(
'the transition is really stroked, not dropped for want of an anchor',
(await json(`__gm.stateMachine.edgeDrawn(${JSON.stringify(drawn[0]?.id)})`)) === true,
);
await undo();
check('undo takes the transition back', (await transitions()).length === 0);
await redo();
check('redo puts it back', (await transitions()).length === 1);
await shot('05-transition');
// 26e3. Naming a transition, from the list the inspector shows on the state it leaves.
const edgeId = (await transitions())[0]?.id;
await t.select({ kind: 'node', assetId: made.id, nodeId: first, nodeIds: [first] });
check(
'the inspector lists the transitions of the selected state, both ways round',
(await has('TRANSITIONS OUT')) && (await has('TRANSITIONS IN')),
);
await evaluate(`__gm.stateMachine.nameTransition(${JSON.stringify(edgeId)}, 'alarm')`);
await settle();
check(
'a named transition carries the name on the edge itself',
(await transitions())[0]?.label === 'alarm',
JSON.stringify(await transitions()),
);
check(
'and the name is drawn on the arrow, not only listed in the panel',
(await json(`__gm.stateMachine.transitionLabel(${JSON.stringify(edgeId)})`)) === 'alarm',
);
// The field is only findable once it holds text, so the rename is what proves it is really a field.
const field = await locate('alarm', true);
// Double-click, so the name is selected and replaced – a single click only parks the caret in it.
await doubleClick(field.x, field.y);
await typeText('lost');
await press('Enter');
check(
'and the panel row is a real field: typing in it renames the transition',
(await transitions())[0]?.label === 'lost',
JSON.stringify(await transitions()),
);
await evaluate(`__gm.stateMachine.nameTransition(${JSON.stringify(edgeId)}, 'alarm')`);
await settle();
await shot('05b-named');
// 26e4. Both ways round between the same pair: the two arrows must not lie on top of each other.
await makeTransition(second, first);
const both = await transitions();
check('a transition back is allowed alongside the one there', both.length === 2, JSON.stringify(both));
const drawnBoth = await Promise.all(
both.map((e) => json(`__gm.stateMachine.edgeDrawn(${JSON.stringify(e.id)})`)),
);
check('both arrows are stroked', drawnBoth.every(Boolean), JSON.stringify(drawnBoth));
await shot('05c-both-ways');
await undo();
// 26f. A behavior reads the machine: Set state on start, State branch every frame.
const rig = await scaffoldBehavior(t, {
assetId: 'sm-logic',
name: 'Doors logic',
boxId: 'sm-box',
boxName: 'State box',
logicId: 'sm-brain',
y: LANES.stateMachine,
graph: {
nodes: [
node('sm-start', 'OnStart', 0, 0),
node('sm-set', 'SetState', 300, 0, { machine: made.id }),
node('sm-update', 'OnUpdate', 0, 240),
node('sm-branch', 'StateBranch', 300, 240, { machine: made.id }),
node('sm-log', 'Log', 600, 200, { message: 'first state' }),
node('sm-log2', 'Log', 600, 360, { message: 'second state' }),
],
edges: [
edge('sm-e1', 'sm-start', 'exec', 'sm-set', first),
edge('sm-e2', 'sm-update', 'exec', 'sm-branch', 'exec'),
edge('sm-e3', 'sm-branch', first, 'sm-log', 'exec'),
edge('sm-e4', 'sm-branch', second, 'sm-log2', 'exec'),
],
},
});
await selectNode('sm-branch');
check(
'selecting a behavior node opens the Behavior tab on that behavior',
(await json('__gm.tab()')) === 'graph' && (await json('__gm.graph.current()')) === 'sm-logic',
JSON.stringify({ tab: await json('__gm.tab()'), current: await json('__gm.graph.current()') }),
);
const branchOutputs = await outputsOf('sm-branch');
const setInputs = await inputsOf('sm-set');
const names = (lines) => lines.map((line) => line.trim());
check(
'State branch grows one output per state, named after it',
names(branchOutputs).includes('State 1') && names(branchOutputs).includes('Open'),
JSON.stringify(branchOutputs),
);
check(
'Set state grows one input per state, named after it',
names(setInputs).includes('State 1') && names(setInputs).includes('Open'),
JSON.stringify(setInputs),
);
await shot('02-behavior-ports');
await play();
await sleep(600);
const log = (await world())?.log ?? [];
check(
'in play the branch follows the state Set state put the machine in',
log.includes('first state') && !log.includes('second state'),
JSON.stringify([...new Set(log)]),
);
await stop();
// 26f2. What a named transition is for at runtime: State transition takes the step called "alarm".
await mutate(`
const b = __gm.state.project.assets.find((a) => a.id === 'sm-logic');
b.graph.nodes.push({
id: 'sm-next', type: 'StateTransition', x: 600, y: 0,
params: { machine: ${JSON.stringify(made.id)}, transition: 'alarm' },
});
b.graph.edges.push({
id: 'sm-e5', from: { node: 'sm-set', port: 'exec' }, to: { node: 'sm-next', port: 'exec' },
});
`);
await play();
await sleep(600);
const moved = (await world())?.log ?? [];
check(
'State transition follows the transition of that name, so the branch takes the state it points at',
moved.includes('second state') && !moved.includes('first state'),
JSON.stringify([...new Set(moved)]),
);
await stop();
// A name the current state does not carry moves nothing – the machine stays where it was put.
await mutate(`
const b = __gm.state.project.assets.find((a) => a.id === 'sm-logic');
b.graph.nodes.find((n) => n.id === 'sm-next').params.transition = 'no such transition';
`);
await play();
await sleep(600);
const stayed = (await world())?.log ?? [];
check(
'a name the state does not carry leaves the machine where it was',
stayed.includes('first state') && !stayed.includes('second state'),
JSON.stringify([...new Set(stayed)]),
);
await stop();
await mutate(`
const b = __gm.state.project.assets.find((a) => a.id === 'sm-logic');
b.graph.nodes.find((n) => n.id === 'sm-next').params.transition = 'alarm';
`);
// 26f3. A start state: marked from the state's own menu, worn as a ring, entered by every
// instance before anything reads the machine – and heard as `On state entered`.
await showTab('statemachine');
const startOf = () =>
json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).start ?? ''`);
const ringOn = (id) => json(`__gm.stateMachine.startRing(${JSON.stringify(id)})`);
const marks = () => json('__gm.stateMachine.stateMarks()');
const startCentre = await centreOfState(first);
await click(startCentre.x, startCentre.y, 'right');
const stateMenu = await json('__gm.overlayTexts()');
check(
'a state offers to become the start state',
stateMenu.includes('Set as start state'),
JSON.stringify(stateMenu),
);
await clickLast('Set as start state');
check(
'the mark is kept on the asset and drawn as a ring on that state alone',
(await startOf()) === first && (await ringOn(first)) === true && (await ringOn(second)) === false,
JSON.stringify({ start: await startOf(), first: await ringOn(first), second: await ringOn(second) }),
);
await shot('05-start-state');
// Nothing sets the machine any more: what the branch follows can only be the start state.
await mutate(`
const b = __gm.state.project.assets.find((a) => a.id === 'sm-logic');
b.graph.edges = b.graph.edges.filter((e) => e.id !== 'sm-e1');
b.graph.nodes.push(
${JSON.stringify(node('sm-entered', 'OnStateEntered', 900, 520, { machine: made.id }))},
${JSON.stringify(node('sm-entered-log', 'Log', 1200, 520, { message: 'entered the start' }))},
);
b.graph.edges.push(${JSON.stringify(edge('sm-e6', 'sm-entered', first, 'sm-entered-log', 'exec'))});
`);
await play();
await sleep(600);
const started = (await world())?.log ?? [];
check(
'every instance begins in the start state, with no Set state anywhere',
started.includes('first state') && !started.includes('second state'),
JSON.stringify([...new Set(started)]),
);
check(
'and hears On state entered for it',
started.filter((line) => line === 'entered the start').length === 1,
JSON.stringify(started.filter((line) => line === 'entered the start')),
);
// 26f4. The tab shows where the run is: weakly for any instance, strongly for the selected one.
await showTab('statemachine');
await evaluate('__gm.state.select(null)');
const loose = await marks();
check(
'while playing, the state the machine is in is marked',
loose[first] === 'held' && loose[second] === undefined,
JSON.stringify(loose),
);
await evaluate(
"__gm.state.select({ kind: 'object', holderId: __gm.state.activeScene.id, objectId: 'sm-box', objectIds: ['sm-box'] })",
);
const picked2 = await marks();
check(
"selecting the object makes its instance's state the strong one",
picked2[first] === 'current',
JSON.stringify(picked2),
);
await shot('06-live-state');
await stop();
await showTab('statemachine');
check(
'nothing is marked once the run is over',
Object.keys(await marks()).length === 0,
JSON.stringify(await marks()),
);
// 26f5. The same entry takes the mark off again, and the machine is back to starting nowhere.
await evaluate('__gm.state.select(null)');
const againCentre = await centreOfState(first);
await click(againCentre.x, againCentre.y, 'right');
const onMenu = await json('__gm.overlayTexts()');
check(
'the same place offers to take the mark off again',
onMenu.includes('Remove start state') && !onMenu.includes('Set as start state'),
JSON.stringify(onMenu),
);
await clickLast('Remove start state');
check(
'the mark and its ring are gone',
(await startOf()) === '' && (await ringOn(first)) === false,
JSON.stringify({ start: await startOf(), ring: await ringOn(first) }),
);
await play();
await sleep(600);
const nowhere = (await world())?.log ?? [];
check(
'without a start state the branch follows nothing again',
!nowhere.includes('first state') && !nowhere.includes('entered the start'),
JSON.stringify([...new Set(nowhere)]),
);
await stop();
// Back to the graph the later sections expect: the set on start, and no state watcher.
await mutate(`
const b = __gm.state.project.assets.find((a) => a.id === 'sm-logic');
b.graph.nodes = b.graph.nodes.filter((n) => n.id !== 'sm-entered' && n.id !== 'sm-entered-log');
b.graph.edges = b.graph.edges.filter((e) => e.id !== 'sm-e6');
b.graph.edges.push(${JSON.stringify(edge('sm-e1', 'sm-start', 'exec', 'sm-set', first))});
`);
// 26g. An unconnected machine input shows a picker; picking a machine grows the state ports.
await mutate(`
const a = __gm.state.project.assets.find((x) => x.id === 'sm-logic');
a.graph.nodes.push(${JSON.stringify(node('sm-fresh', 'StateBranch', 300, 520))});
`);
await selectNode('sm-fresh');
check('without a machine a State branch has no state outputs', (await outputsOf('sm-fresh')).length === 0);
// The row is named after the port, as the node body names it.
const machineField = await json("__gm.inspectorField('machine', true)");
await click(machineField.x, machineField.y);
const choices = await json('__gm.overlayTexts()');
check(
'the Machine picker offers the machine by name',
choices.includes(made.name),
JSON.stringify(choices),
);
await clickLast(made.name);
check(
'picking it grows the state outputs',
names(await outputsOf('sm-fresh')).includes('Open'),
JSON.stringify(await outputsOf('sm-fresh')),
);
await shot('03-machine-picked');
// 26i. A deleted state leaves its edge hanging; the port comes back as a ghost so it can be seen.
const named = await states();
const nameOf = (id) => named.find((s) => s.id === id)?.name;
check(
'before the deletion the edge to the second state is drawn',
(await json("__gm.graph.edgeDrawn('sm-e4')")) === true,
);
await mutate(`
const m = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)});
m.graph.nodes = m.graph.nodes.filter((n) => n.id !== ${JSON.stringify(second)});
`);
const ghosted = await outputsOf('sm-branch');
check(
'the deleted state leaves a row named after its key, and takes its name away',
names(ghosted).includes(second) && !names(ghosted).includes(nameOf(second)),
JSON.stringify({ ghosted, was: nameOf(second) }),
);
check(
'the edge on it is drawn again instead of vanishing',
(await json("__gm.graph.edgeDrawn('sm-e4')")) === true,
);
check(
'the node warns about it',
(await json("__gm.graph.nodeProblem('sm-branch')")) === '1 edge on a port that is gone',
JSON.stringify(await json("__gm.graph.nodeProblem('sm-branch')")),
);
check(
'the ghost pin can be reached, so the edge has a right-click handle',
(await json(`__gm.graph.portAt('sm-branch', ${JSON.stringify(second)}, true)`)) !== null,
);
const pin = await json(`__gm.graph.portAt('sm-branch', ${JSON.stringify(second)}, true)`);
const before = await json("__gm.graph.nodeRect('sm-branch')");
const links = () => json('__gm.state.project.assets.find((a) => a.id === "sm-logic").graph.edges.length');
const linksBefore = await links();
// Measured mid-drag: after the release nothing would show either way, because a ghost has no type
// and `openPaletteForDrop` gives up on that. The rubber band is the only visible difference.
const empty = await json('__gm.graph.emptySpot()');
await mouse('mouseMoved', pin.x, pin.y);
await mouse('mousePressed', pin.x, pin.y, 'left', {});
await mouse('mouseMoved', empty.x, empty.y, 'left', { buttons: 1 });
await sleep(150);
const dangling = await json('__gm.graph.danglingEdge()');
await mouse('mouseReleased', empty.x, empty.y, 'left', {});
await sleep(250);
const after = await json("__gm.graph.nodeRect('sm-branch')");
check(
'pressing a ghost pin starts no rubber band, adds no link, and leaves the node where it was',
dangling === false && (await links()) === linksBefore && after.x === before.x && after.y === before.y,
JSON.stringify({ dangling, linksBefore, links: await links(), moved: after.x - before.x }),
);
check(
'the state that is left keeps its edge and its name',
(await json("__gm.graph.edgeDrawn('sm-e3')")) === true &&
names(await outputsOf('sm-branch')).includes(nameOf(first)),
JSON.stringify({ lines: await outputsOf('sm-branch'), kept: nameOf(first) }),
);
await shot('04-ghost-port');
// 26h. Everything the step made goes away again.
const left = await rig.remove();
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== ${JSON.stringify(made.id)});
`);
await evaluate('__gm.state.select(null)');
check(
'the step leaves nothing behind',
left.objects === 0 && left.assets === 0 && (await machines()).length === 0,
JSON.stringify({ left, machines: await machines() }),
);
}