game-maker / suite (push) Successful in 10m22s
A state keeps its `in`/`out` exec pins in the model and the editor never draws
them. That is the whole trick: connecting, undo, deleting a state and saving all
keep working untouched, because `normalizeGraph` filters nodes and never edges,
and no edge ever checks its port key against a NodeDef. Only two places change --
`NodeView.redraw` stops drawing the row, and `portAt` refuses to hand one out, so
an invisible pin cannot be grabbed either.
Transitions are drawn by right-clicking a state, `Make transition`, then clicking
the target. The armed mode takes the same shape as a live drag, so the existing
preview draws it with no second code path, and it dies with Escape, a click on
empty canvas or a switch to another asset.
Removing the pins removed the only handle a transition had: edges have no
hit-test anywhere, and the pin right-click was the sole route to Disconnect.
The inspector's TRANSITIONS OUT / TRANSITIONS IN lists are therefore not a
convenience, they are the delete path.
`GraphEdge` gained an optional `label`, which `normalizeGraph` has to pass
through explicitly or a saved name is dropped on load. `State transition`
replaces `Next state`: it takes the machine on an input port and the name from a
picker, and resolves against the *current* state. Names are deliberately not
unique across a machine -- two states may both have an `alarm` -- so a node named
`alarm` moves whichever state has one, and an empty name follows the single
unnamed edge.
The geometry moved to a Pixi-free `transitions.ts`: a gap to the box border, and
a fan that puts a pair of opposite edges beside each other instead of on top of
each other. Keeping it as plain maths is what let a screenshot bug become a unit
test -- an earlier pin-to-pin line ran under the target node and took its
arrowhead with it, because `nodeLayer` sits above `edgeLayer`.
One limit stands and is not fixed here: `ConnectCommand` rejects a second edge
between the same two states ("Already connected"), because every transition
hangs off the same two ports. With names, `A --alarm--> B` beside
`A --seen--> B` is a sensible pair, so this is worth its own issue.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
524 lines
21 KiB
JavaScript
524 lines
21 KiB
JavaScript
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,
|
||
} = t;
|
||
const { 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);
|
||
await sleep(200);
|
||
};
|
||
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 evaluate(
|
||
`__gm.state.select({ kind: 'node', assetId: 'sm-logic', nodeId: ${JSON.stringify(nodeId)}, nodeIds: [${JSON.stringify(nodeId)}] })`,
|
||
);
|
||
await sleep(400);
|
||
};
|
||
|
||
await press('Escape');
|
||
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
|
||
await evaluate('__gm.state.select(null)');
|
||
await sleep(200);
|
||
|
||
// 26a. The + menu creates a state machine; selected, it opens its own tab.
|
||
await clickText('+');
|
||
await sleep(200);
|
||
await clickLast('State machine');
|
||
await sleep(400);
|
||
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 sleep(300);
|
||
await clickLast('State');
|
||
await sleep(300);
|
||
};
|
||
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);
|
||
await sleep(150);
|
||
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);
|
||
await sleep(200);
|
||
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);
|
||
await sleep(150);
|
||
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');
|
||
await sleep(300);
|
||
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');
|
||
await sleep(300);
|
||
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');
|
||
await sleep(200);
|
||
|
||
// 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 sleep(300);
|
||
await clickLast('Make transition');
|
||
await sleep(250);
|
||
const to = await centreOfState(toId);
|
||
await click(to.x, to.y);
|
||
await sleep(350);
|
||
};
|
||
|
||
// 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');
|
||
await sleep(300);
|
||
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');
|
||
await sleep(250);
|
||
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');
|
||
await sleep(250);
|
||
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();
|
||
await sleep(300);
|
||
check('undo takes the transition back', (await transitions()).length === 0);
|
||
await redo();
|
||
await sleep(300);
|
||
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 evaluate(
|
||
`__gm.state.select({ kind: 'node', assetId: ${JSON.stringify(made.id)}, nodeId: ${JSON.stringify(first)}, nodeIds: [${JSON.stringify(first)}] })`,
|
||
);
|
||
await sleep(400);
|
||
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 sleep(400);
|
||
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 sleep(200);
|
||
await typeText('lost');
|
||
await press('Enter');
|
||
await sleep(400);
|
||
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 sleep(300);
|
||
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();
|
||
await sleep(300);
|
||
|
||
// 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 sleep(300);
|
||
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 sleep(300);
|
||
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';
|
||
`);
|
||
await sleep(200);
|
||
await sleep(200);
|
||
|
||
// 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);
|
||
await sleep(300);
|
||
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);
|
||
await sleep(400);
|
||
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)});
|
||
`);
|
||
await sleep(400);
|
||
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)');
|
||
await sleep(300);
|
||
check(
|
||
'the step leaves nothing behind',
|
||
left.objects === 0 && left.assets === 0 && (await machines()).length === 0,
|
||
JSON.stringify({ left, machines: await machines() }),
|
||
);
|
||
}
|