Files
feuerwerk/tools/e2e/26-state-machine.mjs
T

673 lines
28 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();
// 26e5. (#163) The click that lands on the state the transition started from makes one back into
// it – a loop over the node, which re-enters the state when it is taken.
const beforeLoop = (await transitions()).length;
await makeTransition(first, first);
const looped = await transitions();
const loop = looped.find((e) => e.from.node === first && e.to.node === first);
check(
'clicking the state the transition started from makes one back into itself',
looped.length === beforeLoop + 1 && loop !== undefined,
JSON.stringify(looped),
);
await evaluate(`__gm.stateMachine.nameTransition(${JSON.stringify(loop?.id)}, 'again')`);
await settle();
const loopLabel = await json(`__gm.stateMachine.transitionLabelAt(${JSON.stringify(loop?.id)})`);
const firstRect = await json(`__gm.stateMachine.nodeRect(${JSON.stringify(first)})`);
check(
'it is stroked as a loop over the state: its name sits above the node, within its width',
(await json(`__gm.stateMachine.edgeDrawn(${JSON.stringify(loop?.id)})`)) === true &&
loopLabel !== null &&
loopLabel.y < firstRect.y &&
loopLabel.x > firstRect.x &&
loopLabel.x < firstRect.x + firstRect.w,
JSON.stringify({ loopLabel, firstRect }),
);
await shot('05d-self');
await undo();
await undo();
check('undo takes the loop back', (await transitions()).length === beforeLoop);
// 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');
// "else" is the one output it always has: a branch that continues nowhere cannot be seen.
const freshOutputs = await outputsOf('sm-fresh');
check(
'without a machine a State branch has no state outputs, only else',
freshOutputs.map((line) => line.trim()).join() === 'else',
JSON.stringify(freshOutputs),
);
// 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');
// 26j. A machine that arrives as a value has no state ports at all: a text holding its id is not
// followed, on purpose. `Set state by name` names the state instead, and the branch leaves
// through `else` – the two halves of #58, measured in a running game.
await mutate(`
const b = __gm.state.project.assets.find((a) => a.id === 'sm-logic');
b.graph.nodes.push(
${JSON.stringify(node('sm-id', 'String', 0, 700, { value: made.id }))},
${JSON.stringify(node('sm-byname', 'SetStateByName', 300, 700, { state: nameOf(first) }))},
${JSON.stringify(node('sm-dyn', 'StateBranch', 600, 700))},
${JSON.stringify(node('sm-elselog', 'Log', 900, 700, { message: 'else taken' }))},
${JSON.stringify(node('sm-miss', 'SetStateByName', 1200, 700, { state: 'Nope' }))},
${JSON.stringify(node('sm-nflog', 'Log', 1500, 700, { message: 'no such state' }))},
);
b.graph.edges = b.graph.edges.filter((e) => e.id !== 'sm-e1');
b.graph.edges.push(
${JSON.stringify(edge('sm-d1', 'sm-id', 'value', 'sm-byname', 'machine'))},
${JSON.stringify(edge('sm-d2', 'sm-id', 'value', 'sm-dyn', 'machine'))},
${JSON.stringify(edge('sm-d3', 'sm-id', 'value', 'sm-miss', 'machine'))},
${JSON.stringify(edge('sm-d4', 'sm-start', 'exec', 'sm-byname', 'exec'))},
${JSON.stringify(edge('sm-d5', 'sm-byname', 'exec', 'sm-dyn', 'exec'))},
${JSON.stringify(edge('sm-d6', 'sm-dyn', 'else', 'sm-elselog', 'exec'))},
${JSON.stringify(edge('sm-d7', 'sm-elselog', 'exec', 'sm-miss', 'exec'))},
${JSON.stringify(edge('sm-d8', 'sm-miss', 'notFound', 'sm-nflog', 'exec'))},
);
`);
await selectNode('sm-dyn');
const dynOutputs = await outputsOf('sm-dyn');
check(
'a machine coming from a value grows no state outputs, so else is the whole branch',
dynOutputs.map((line) => line.trim()).join() === 'else',
JSON.stringify(dynOutputs),
);
await play();
await sleep(600);
const byName = (await world())?.log ?? [];
check(
'Set state by name moves a machine the graph cannot name, and the static branch sees it',
byName.includes('first state') && byName.includes('else taken') && byName.includes('no such state'),
JSON.stringify([...new Set(byName)]),
);
await stop();
await shot('05-by-name');
// 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() }),
);
}