Files
feuerwerk/tools/e2e/26-state-machine.mjs
T
rootandClaude Opus 5 68c92daaa6
game-maker / suite (push) Successful in 10m22s
A transition carries a name, so a state needs no visible port (#52)
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>
2026-09-14 14:05:18 +02:00

524 lines
21 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,
} = 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() }),
);
}