Recurring cleanup round over the code since round 7 (bc688f3). The
issue now also asks to fix bugs and small inconsistencies found on the
way, so unlike the rounds before, this one changes behaviour in a few
places. Duplicated code in api went from 23 clones with 228 lines to
none (jscpd, at least 50 tokens). In src it stays at 22 clones with
271 lines, the same before and after.
Bugs:
- nginx served project and snapshot files as they were, so anyone who
knew a project id could read it without signing in. Every file the
API writes now goes through AtomicFile (temp file, mode, rename).
Projects, snapshots, releases, accounts and sessions are 0640, and
nginx answers 403 for them.
- Any signed-in account could delete a shared image, font or sound,
which projects of other accounts may wear. Deleting one is now for
administrators only. The editor never deleted a file, so the
client's delete is gone.
- Two saves of one project could run at once. The lock file was
removed after each save, so a waiting save and the next one locked
different files. The lock file now stays, and deleting a project
takes the lock as well.
- Bytes that are no picture got a 500 instead of a 422, because the
error handler also threw for warnings silenced with @.
- A session file was rewritten in place. A request reading it at that
moment found it empty and was signed out. It is replaced atomically.
- Delete did nothing for a shape picked in the vector art list. Delete
now goes through removeSelection, one case per kind of selection,
and a kind without a case fails the type check.
Inconsistencies:
- Escape on a question does what its Cancel does. Cancel on "Delete
project?" and "Restore snapshot?" leads back to the picker or the
list like the other questions did, and so does deleting a project.
- Editor texts use American spelling throughout, like the port names:
48 British spellings in 45 lines (colour, centre, initialised).
- objectName and groupName replace eleven copies of "own name, else
the asset's" and three of "Group". One copy fell back to the object
id instead of "?".
Duplicates, dead code, comments:
- routeFiles serves images, fonts and sounds instead of three copies.
HashedFileStore holds what the three stores shared: keeping a file
under its hash with its name, listing and deleting. The stores keep
only which bytes they take and what a record says.
- AtomicFile replaces nine copies of temp file plus rename.
- removeShapes is the one way the canvas, the inspector button and
Delete take shapes out.
- GraphInterpreter.fire was called by tests only. The 54 calls use a
helper in tests/helpers.ts now.
- FileStore.delete, HttpFileStore.delete and the PluginApi type were
unused. A doc comment in actions.ts sat above the wrong function.
Checked and left as is:
- solidCells is used by tests only. It is the per-cell view before the
merge into rectangles, the one place the largest-scale rule shows.
- An undo checker ran all 124 commands of the unit tests and compared
the project before and after undo. The only differences are empty
ports, groups and notes lists where there were none, which the file
format drops anyway.
Docs:
- README: the check sum.
- Wiki, own commit: API (admin-only deletes, 422, file modes, lock
file), Home (the "Not yet" list named three things that exist),
Concepts, Keyboard, Vector art, Architecture and Development, and
the 38 characters Gitea flagged as ambiguous on eight pages.
- .gitignore: the lock files, and pasted-*.png in any folder, which
keeps the screenshot archive out.
Tests:
- ./check.sh: 195 files, 2817 tests, exit 0 (+2 for the name helpers).
- PHP: accounts 52 in the web container and 53 in the CI image with
the race test (+2), snapshots 15 (+1), releases 20.
- Full e2e run: ALL PASSED, 1586 checks (+6 in 12, 47, 49 and 50).
- Smokes jumprun, tetris, actor: 24, 19 and 21 checks. The example
project files were restored byte for byte afterwards.
- The file stores of HEAD and of this commit give byte-identical
output for the live folders and for store, list and delete in a
throwaway folder.
- Counter-checks, all fail as expected: a 500 for bad bytes fails 12,
no admin check fails 49, removing the lock file again loses saves
in 3 of 3 runs (151 to 170 of 321), Delete ignoring shapes fails 47,
Escape that only closes fails 50, and dropping the shape case fails
the type check (TS2366).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
711 lines
31 KiB
TypeScript
711 lines
31 KiB
TypeScript
/**
|
||
* A state machine is an asset whose graph holds one `State` node per state. A behavior reads it
|
||
* through `State branch` (one exec output per state) and moves it through `Set state` (one exec
|
||
* input per state); the current state lives with the behavior instance, like its variables.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
CreateAssetCommand,
|
||
SetMachineScopeCommand,
|
||
SetMachineStartCommand,
|
||
} from '../../src/core/commands/assetCommands';
|
||
import {
|
||
AddNodeCommand,
|
||
ConnectCommand,
|
||
NameTransitionCommand,
|
||
RemoveNodeCommand,
|
||
SetNodeParamCommand,
|
||
} from '../../src/core/commands/graphCommands';
|
||
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||
import {
|
||
BEHAVIOR_PARAM,
|
||
BEHAVIOR_START,
|
||
GraphInterpreter,
|
||
MACHINE_PORT,
|
||
nextStateName,
|
||
resolveDef,
|
||
STATE,
|
||
STATE_IN,
|
||
STATE_NAME_PARAM,
|
||
STATE_OUT,
|
||
startStateOf,
|
||
statesOf,
|
||
transitionNames,
|
||
transitionsOf,
|
||
transitionTo,
|
||
} from '../../src/core/graph';
|
||
import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
|
||
import type { StateMachineAsset } from '../../src/core/model/Asset';
|
||
import { isPlaceableType } from '../../src/core/model/AssetTypes';
|
||
import { graphLookup } from '../../src/core/model/graphLookup';
|
||
import { findAsset, findAssetOf } from '../../src/core/model/Project';
|
||
import { deserializeProject } from '../../src/core/store/serialize';
|
||
import { paletteEntries } from '../../src/editor/graph/paletteEntries';
|
||
import {
|
||
fakeContext,
|
||
graphEditor,
|
||
makeBehavior,
|
||
makeProject,
|
||
makeSpriteScene,
|
||
place,
|
||
type ProjectFixture,
|
||
roundTrip,
|
||
startWorld,
|
||
fire,
|
||
} from '../helpers';
|
||
|
||
/** A machine "Doors" with the states Open and Closed, added the way the tab does it. */
|
||
function makeMachine(fixture: ProjectFixture, names = ['Open', 'Closed']) {
|
||
const create = new CreateAssetCommand('statemachine', 'Doors');
|
||
fixture.stack.execute(create);
|
||
const ids = names.map((name) => {
|
||
const add = new AddNodeCommand(create.asset.id, STATE, 0, 0, { [STATE_NAME_PARAM]: name });
|
||
fixture.stack.execute(add);
|
||
return add.node.id;
|
||
});
|
||
return {
|
||
id: create.asset.id,
|
||
asset: () => findAssetOf(fixture.project, create.asset.id, 'statemachine')!,
|
||
ids,
|
||
};
|
||
}
|
||
|
||
describe('a state machine asset', () => {
|
||
it('is created empty, is not placeable, and takes states as nodes', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
expect(isPlaceableType('statemachine')).toBe(false);
|
||
expect(statesOf(machine.asset().graph)).toEqual([
|
||
{ id: machine.ids[0], name: 'Open' },
|
||
{ id: machine.ids[1], name: 'Closed' },
|
||
]);
|
||
f.stack.undo();
|
||
expect(statesOf(machine.asset().graph).map((s) => s.name)).toEqual(['Open']);
|
||
});
|
||
|
||
it('names a state by its setting, and falls back to "State" for an empty name', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f, ['Open']);
|
||
f.stack.execute(new SetNodeParamCommand(machine.id, machine.ids[0]!, STATE_NAME_PARAM, ''));
|
||
expect(statesOf(machine.asset().graph)[0]?.name).toBe('State');
|
||
const node = machine.asset().graph.nodes[0]!;
|
||
expect(resolveDef(node, machine.asset().graph, graphLookup(f.project))?.label).toBe('State');
|
||
});
|
||
|
||
it('survives a save and load, keeping only its states', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const loaded = findAssetOf(roundTrip(f.project), machine.id, 'statemachine');
|
||
expect(loaded && statesOf(loaded.graph)).toEqual(statesOf(machine.asset().graph));
|
||
|
||
// A node of any other type has no business in a machine and is dropped on load.
|
||
const smuggled = deserializeProject({
|
||
id: 'p',
|
||
assets: [
|
||
{
|
||
id: 'm',
|
||
type: 'statemachine',
|
||
name: 'M',
|
||
graph: {
|
||
nodes: [
|
||
{ id: 's', type: STATE, x: 0, y: 0, params: { name: 'Idle' } },
|
||
{ id: 'n', type: 'Number', x: 0, y: 0, params: { value: 1 } },
|
||
],
|
||
edges: [],
|
||
},
|
||
},
|
||
],
|
||
});
|
||
const asset = findAsset(smuggled, 'm') as StateMachineAsset;
|
||
expect(asset.graph.nodes.map((n) => n.type)).toEqual([STATE]);
|
||
});
|
||
|
||
it('keeps its State node out of every behavior palette: it is registered, not built in', () => {
|
||
expect(BUILT_IN_NODES.some((d) => d.type === STATE)).toBe(false);
|
||
expect(BUILT_IN_NODES.some((d) => d.category === 'state')).toBe(false);
|
||
});
|
||
|
||
it('offers one palette entry, a State named by its number', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const entries = paletteEntries(f.project, machine.asset());
|
||
expect(entries.map((e) => e.def.type)).toEqual([STATE]);
|
||
expect(entries[0]?.params).toEqual({ [STATE_NAME_PARAM]: 'State 3' });
|
||
expect(nextStateName(machine.asset().graph)).toBe('State 3');
|
||
f.stack.undo();
|
||
expect(nextStateName(machine.asset().graph)).toBe('State 2');
|
||
});
|
||
|
||
it('is a kind of variable holding the asset id', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const behavior = makeBehavior(f, 'B');
|
||
f.stack.execute(new AddVariableCommand(behavior.id, 'doors', 'statemachine'));
|
||
findAsset(f.project, behavior.id)!.props.doors = machine.id;
|
||
const back = findAssetOf(roundTrip(f.project), behavior.id, 'behavior');
|
||
expect(back?.variables.map((v) => `${v.key}:${v.kind}`)).toEqual(['doors:statemachine']);
|
||
expect(back?.props.doors).toBe(machine.id);
|
||
});
|
||
});
|
||
|
||
describe('the state ports of State branch and Set state', () => {
|
||
const setup = () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const behavior = makeBehavior(f, 'B');
|
||
const edit = graphEditor(f, behavior);
|
||
const ports = (node: { id: string }, side: 'inputs' | 'outputs') =>
|
||
resolveDef(
|
||
behavior.graph.nodes.find((n) => n.id === node.id)!,
|
||
behavior.graph,
|
||
graphLookup(f.project),
|
||
)!
|
||
[side].filter((p) => p.type === 'exec')
|
||
.map((p) => `${p.key}:${p.label}`);
|
||
return { f, machine, behavior, edit, ports };
|
||
};
|
||
|
||
it('are one exec port per state, keyed by the state id and labelled by its name', () => {
|
||
const { machine, edit, ports } = setup();
|
||
const branch = edit.addNode('StateBranch', { [MACHINE_PORT]: machine.id });
|
||
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||
const expected = [`${machine.ids[0]}:Open`, `${machine.ids[1]}:Closed`];
|
||
// The branch carries one more: the way out for everything no state covers.
|
||
expect(ports(branch, 'outputs')).toEqual([...expected, 'else:else']);
|
||
expect(ports(set, 'inputs')).toEqual(expected);
|
||
});
|
||
|
||
it('follow a State machine literal wired into the machine input', () => {
|
||
const { machine, edit, ports } = setup();
|
||
const literal = edit.addNode('StateMachine', { value: machine.id });
|
||
const branch = edit.addNode('StateBranch');
|
||
expect(ports(branch, 'outputs')).toEqual(['else:else']);
|
||
edit.connect(literal, 'value', branch, MACHINE_PORT);
|
||
expect(ports(branch, 'outputs')).toEqual([
|
||
`${machine.ids[0]}:Open`,
|
||
`${machine.ids[1]}:Closed`,
|
||
'else:else',
|
||
]);
|
||
});
|
||
|
||
it('cannot follow a variable: a port is derived from the graph, not from a running value', () => {
|
||
const { f, machine, behavior, edit, ports } = setup();
|
||
f.stack.execute(new AddVariableCommand(behavior.id, 'doors', 'statemachine'));
|
||
findAsset(f.project, behavior.id)!.props.doors = machine.id;
|
||
const variables = edit.variables(['doors']);
|
||
const branch = edit.addNode('StateBranch', { [MACHINE_PORT]: machine.id });
|
||
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||
edit.connect(variables, 'doors', branch, MACHINE_PORT);
|
||
edit.connect(variables, 'doors', set, MACHINE_PORT);
|
||
// `else` is all that is left of the branch, and Set state has nothing at all – which is why
|
||
// "Set state by name" exists: it names the state instead of wiring it.
|
||
expect(ports(branch, 'outputs')).toEqual(['else:else']);
|
||
expect(ports(set, 'inputs')).toEqual([]);
|
||
// Only the State machine literal is followed – a text holding the very same id is not.
|
||
const text = edit.addNode('String', { value: machine.id });
|
||
edit.connect(text, 'value', branch, MACHINE_PORT);
|
||
expect(ports(branch, 'outputs')).toEqual(['else:else']);
|
||
});
|
||
|
||
it('follow a rename, and lose the edges of a state that is deleted', () => {
|
||
const { f, machine, behavior, edit, ports } = setup();
|
||
const start = edit.addNode('OnStart');
|
||
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||
edit.connect(start, 'exec', set, machine.ids[0]!);
|
||
f.stack.execute(new SetNodeParamCommand(machine.id, machine.ids[0]!, STATE_NAME_PARAM, 'Ajar'));
|
||
expect(ports(set, 'inputs')[0]).toBe(`${machine.ids[0]}:Ajar`);
|
||
expect(behavior.graph.edges).toHaveLength(1);
|
||
// Picking another machine orphans the edge, as any param that decides the ports does.
|
||
f.stack.execute(new SetNodeParamCommand(behavior.id, set.id, MACHINE_PORT, ''));
|
||
expect(behavior.graph.edges).toHaveLength(0);
|
||
f.stack.undo();
|
||
expect(behavior.graph.edges).toHaveLength(1);
|
||
});
|
||
});
|
||
|
||
describe('a running state machine', () => {
|
||
/** OnStart sets Open; OnUpdate branches: Open logs "open" and sets Closed, Closed logs "closed". */
|
||
function build(f: ProjectFixture, machineId: string, [open, closed]: readonly string[]) {
|
||
const behavior = makeBehavior(f, 'Door');
|
||
const edit = graphEditor(f, behavior);
|
||
const start = edit.addNode('OnStart');
|
||
const setOpen = edit.addNode('SetState', { [MACHINE_PORT]: machineId });
|
||
const update = edit.addNode('OnUpdate');
|
||
const branch = edit.addNode('StateBranch', { [MACHINE_PORT]: machineId });
|
||
const logOpen = edit.addNode('Log', { message: 'open' });
|
||
const setClosed = edit.addNode('SetState', { [MACHINE_PORT]: machineId });
|
||
const logClosed = edit.addNode('Log', { message: 'closed' });
|
||
edit.wire([
|
||
[start, 'exec', setOpen, open!],
|
||
[update, 'exec', branch, 'exec'],
|
||
[branch, open!, logOpen, 'exec'],
|
||
[logOpen, 'exec', setClosed, closed!],
|
||
[branch, closed!, logClosed, 'exec'],
|
||
]);
|
||
return behavior;
|
||
}
|
||
|
||
it('is in no state until Set state ran, then follows the state it was put in', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const behavior = build(f, machine.id, machine.ids);
|
||
const ctx = fakeContext();
|
||
const interpreter = new GraphInterpreter(behavior.graph, graphLookup(f.project));
|
||
fire(interpreter, 'OnUpdate', ctx);
|
||
expect(ctx.logs).toEqual([]);
|
||
fire(interpreter, 'OnStart', ctx);
|
||
expect(ctx.states).toEqual({ [machine.id]: machine.ids[0] });
|
||
fire(interpreter, 'OnUpdate', ctx);
|
||
fire(interpreter, 'OnUpdate', ctx);
|
||
expect(ctx.logs).toEqual(['open', 'closed']);
|
||
expect(ctx.states).toEqual({ [machine.id]: machine.ids[1] });
|
||
});
|
||
|
||
it('keeps one state per behavior instance', () => {
|
||
const f = makeSpriteScene();
|
||
const machine = makeMachine(f);
|
||
const behavior = build(f, machine.id, machine.ids);
|
||
place(f, behavior.id, { x: 0, y: 0 });
|
||
place(f, behavior.id, { x: 10, y: 0 });
|
||
const started = startWorld(f, { behaviors: true });
|
||
started.frame();
|
||
// Shared state would let the second instance find the door the first one just closed.
|
||
expect(started.world.log).toEqual(['open', 'open']);
|
||
started.frame();
|
||
expect(started.world.log).toEqual(['open', 'open', 'closed', 'closed']);
|
||
});
|
||
|
||
it('starts a called behavior in no state, whatever the caller set', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const inner = makeBehavior(f, 'Inner');
|
||
const innerEdit = graphEditor(f, inner);
|
||
const innerStart = innerEdit.addNode(BEHAVIOR_START);
|
||
const branch = innerEdit.addNode('StateBranch', { [MACHINE_PORT]: machine.id });
|
||
const log = innerEdit.addNode('Log', { message: 'inner saw open' });
|
||
innerEdit.wire([
|
||
[innerStart, 'exec', branch, 'exec'],
|
||
[branch, machine.ids[0]!, log, 'exec'],
|
||
]);
|
||
const outer = makeBehavior(f, 'Outer');
|
||
const edit = graphEditor(f, outer);
|
||
const start = edit.addNode('OnStart');
|
||
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||
const call = edit.addNode('RunBehavior', { [BEHAVIOR_PARAM]: inner.id });
|
||
edit.wire([
|
||
[start, 'exec', set, machine.ids[0]!],
|
||
[set, 'exec', call, 'exec'],
|
||
]);
|
||
const ctx = fakeContext();
|
||
fire(new GraphInterpreter(outer.graph, graphLookup(f.project)), 'OnStart', ctx);
|
||
expect(ctx.states).toEqual({ [machine.id]: machine.ids[0] });
|
||
expect(ctx.logs).toEqual([]);
|
||
});
|
||
|
||
it('is driven by name where the machine arrives as a value, and branches through else', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const behavior = makeBehavior(f, 'Door');
|
||
f.stack.execute(new AddVariableCommand(behavior.id, 'doors', 'statemachine'));
|
||
findAsset(f.project, behavior.id)!.props.doors = machine.id;
|
||
const edit = graphEditor(f, behavior);
|
||
const variables = edit.variables(['doors']);
|
||
const start = edit.addNode('OnStart');
|
||
const set = edit.addNode('SetStateByName', { state: 'Closed' });
|
||
const missing = edit.addNode('SetStateByName', { state: 'Ajar' });
|
||
const logMissing = edit.addNode('Log', { message: 'no such state' });
|
||
const update = edit.addNode('OnUpdate');
|
||
const branch = edit.addNode('StateBranch');
|
||
const logElse = edit.addNode('Log', { message: 'unknown to the graph' });
|
||
edit.wire([
|
||
[variables, 'doors', set, MACHINE_PORT],
|
||
[variables, 'doors', missing, MACHINE_PORT],
|
||
[variables, 'doors', branch, MACHINE_PORT],
|
||
[start, 'exec', set, 'exec'],
|
||
[set, 'exec', missing, 'exec'],
|
||
[missing, 'notFound', logMissing, 'exec'],
|
||
[update, 'exec', branch, 'exec'],
|
||
[branch, 'else', logElse, 'exec'],
|
||
]);
|
||
const ctx = fakeContext({ variables: { doors: machine.id } });
|
||
const interpreter = new GraphInterpreter(behavior.graph, graphLookup(f.project));
|
||
fire(interpreter, 'OnStart', ctx);
|
||
// The name moved the machine; the one nothing answers to took "not found" instead of ending.
|
||
expect(ctx.states).toEqual({ [machine.id]: machine.ids[1] });
|
||
expect(ctx.logs).toEqual(['no such state']);
|
||
// The branch has no state outputs at all here, so even a state that is set leaves through else.
|
||
fire(interpreter, 'OnUpdate', ctx);
|
||
expect(ctx.logs).toEqual(['no such state', 'unknown to the graph']);
|
||
});
|
||
|
||
it('refuses a connection to a state port the machine does not have', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const behavior = makeBehavior(f, 'B');
|
||
const edit = graphEditor(f, behavior);
|
||
const start = edit.addNode('OnStart');
|
||
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||
expect(() =>
|
||
f.stack.execute(
|
||
new ConnectCommand(behavior.id, { node: start.id, port: 'exec' }, { node: set.id, port: 'nope' }),
|
||
),
|
||
).toThrow('Port not found');
|
||
});
|
||
});
|
||
|
||
describe('the state a machine starts in', () => {
|
||
it('is kept on the asset, can be taken off again, and survives a save', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const [open] = machine.ids as [string, string];
|
||
f.stack.execute(new SetMachineStartCommand(machine.id, open));
|
||
expect(machine.asset().start).toBe(open);
|
||
expect(findAssetOf(roundTrip(f.project), machine.id, 'statemachine')?.start).toBe(open);
|
||
|
||
// The same entry takes the mark off: only one state can be the start, so there is one field.
|
||
f.stack.execute(new SetMachineStartCommand(machine.id, ''));
|
||
expect(machine.asset().start).toBeUndefined();
|
||
f.stack.undo();
|
||
expect(machine.asset().start).toBe(open);
|
||
});
|
||
|
||
it('reads as no start once that state is gone', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const [open] = machine.ids as [string, string];
|
||
f.stack.execute(new SetMachineStartCommand(machine.id, open));
|
||
f.stack.execute(new RemoveNodeCommand(machine.id, open));
|
||
// The dead id stays in the document; what the graph agrees to is what counts.
|
||
expect(machine.asset().start).toBe(open);
|
||
expect(startStateOf(machine.asset().graph, machine.asset().start)).toBe('');
|
||
expect(graphLookup(f.project).machine(machine.id)?.start).toBe('');
|
||
f.stack.undo();
|
||
expect(startStateOf(machine.asset().graph, machine.asset().start)).toBe(open);
|
||
});
|
||
|
||
it('puts every instance in it before anything reads the machine', () => {
|
||
const f = makeSpriteScene();
|
||
const machine = makeMachine(f);
|
||
const [open, closed] = machine.ids as [string, string];
|
||
f.stack.execute(new SetMachineStartCommand(machine.id, open));
|
||
const behavior = makeBehavior(f, 'Door');
|
||
const edit = graphEditor(f, behavior);
|
||
const update = edit.addNode('OnUpdate');
|
||
const branch = edit.addNode('StateBranch', { [MACHINE_PORT]: machine.id });
|
||
const logOpen = edit.addNode('Log', { message: 'open' });
|
||
const logClosed = edit.addNode('Log', { message: 'closed' });
|
||
edit.wire([
|
||
[update, 'exec', branch, 'exec'],
|
||
[branch, open, logOpen, 'exec'],
|
||
[branch, closed, logClosed, 'exec'],
|
||
]);
|
||
place(f, behavior.id, { x: 0, y: 0 });
|
||
const started = startWorld(f, { behaviors: true });
|
||
started.frame();
|
||
// Without a start state this branch would follow nothing at all (that is the old behaviour).
|
||
expect(started.world.log).toEqual(['open']);
|
||
});
|
||
});
|
||
|
||
describe('On state entered and On state exited', () => {
|
||
/** A behavior that logs every entry and exit of the machine, one Log per state and direction. */
|
||
function watcher(f: ProjectFixture, machineId: string, ids: readonly string[], name = 'Watcher') {
|
||
const behavior = makeBehavior(f, name);
|
||
const edit = graphEditor(f, behavior);
|
||
const entered = edit.addNode('OnStateEntered', { [MACHINE_PORT]: machineId });
|
||
const exited = edit.addNode('OnStateExited', { [MACHINE_PORT]: machineId });
|
||
for (const [index, state] of ids.entries()) {
|
||
const label = index === 0 ? 'open' : 'closed';
|
||
const inLog = edit.addNode('Log', { message: `${name}: entered ${label}` });
|
||
const outLog = edit.addNode('Log', { message: `${name}: exited ${label}` });
|
||
edit.wire([
|
||
[entered, state, inLog, 'exec'],
|
||
[exited, state, outLog, 'exec'],
|
||
]);
|
||
}
|
||
return { behavior, edit };
|
||
}
|
||
|
||
it('fires for the start state too, with nothing to exit before it', () => {
|
||
const f = makeSpriteScene();
|
||
const machine = makeMachine(f);
|
||
f.stack.execute(new SetMachineStartCommand(machine.id, machine.ids[0]!));
|
||
const { behavior } = watcher(f, machine.id, machine.ids);
|
||
place(f, behavior.id, { x: 0, y: 0 });
|
||
const started = startWorld(f, { behaviors: true });
|
||
started.frame();
|
||
expect(started.world.log).toEqual(['Watcher: entered open']);
|
||
// Once, not once per frame: the instance is already in that state the second time round.
|
||
started.frame();
|
||
expect(started.world.log).toEqual(['Watcher: entered open']);
|
||
});
|
||
|
||
it('fires exited before entered, after the flow that moved the machine', () => {
|
||
const f = makeSpriteScene();
|
||
const machine = makeMachine(f);
|
||
const [open, closed] = machine.ids as [string, string];
|
||
f.stack.execute(new SetMachineStartCommand(machine.id, open));
|
||
const { behavior, edit } = watcher(f, machine.id, machine.ids);
|
||
// The pulse that moves the machine logs after the move: its own line comes first, the events after.
|
||
const key = edit.addNode('OnUpdate');
|
||
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||
const done = edit.addNode('Log', { message: 'flow finished' });
|
||
edit.wire([
|
||
[key, 'exec', set, closed],
|
||
[set, 'exec', done, 'exec'],
|
||
]);
|
||
place(f, behavior.id, { x: 0, y: 0 });
|
||
const started = startWorld(f, { behaviors: true });
|
||
started.frame();
|
||
expect(started.world.log).toEqual([
|
||
'Watcher: entered open',
|
||
'flow finished',
|
||
'Watcher: exited open',
|
||
'Watcher: entered closed',
|
||
]);
|
||
});
|
||
|
||
it('says nothing when a set puts the machine into the state it is already in', () => {
|
||
const f = makeSpriteScene();
|
||
const machine = makeMachine(f);
|
||
const [open] = machine.ids as [string, string];
|
||
f.stack.execute(new SetMachineStartCommand(machine.id, open));
|
||
const { behavior, edit } = watcher(f, machine.id, machine.ids);
|
||
const update = edit.addNode('OnUpdate');
|
||
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||
edit.wire([[update, 'exec', set, open]]);
|
||
place(f, behavior.id, { x: 0, y: 0 });
|
||
const started = startWorld(f, { behaviors: true });
|
||
started.frame();
|
||
started.frame();
|
||
expect(started.world.log).toEqual(['Watcher: entered open']);
|
||
});
|
||
|
||
it('reaches only the instance that moved while the machine is local', () => {
|
||
const f = makeSpriteScene();
|
||
const machine = makeMachine(f);
|
||
const [open, closed] = machine.ids as [string, string];
|
||
const { behavior, edit } = watcher(f, machine.id, machine.ids);
|
||
// Both instances move themselves; what is asserted is that neither hears the other's move.
|
||
const start = edit.addNode('OnStart');
|
||
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||
edit.wire([[start, 'exec', set, closed]]);
|
||
place(f, behavior.id, { x: 0, y: 0 });
|
||
place(f, behavior.id, { x: 10, y: 0 });
|
||
const started = startWorld(f, { behaviors: true });
|
||
started.frame();
|
||
// Each instance hears its own move and nobody else's: two entries, no exits (no state before).
|
||
expect(started.world.log).toEqual(['Watcher: entered closed', 'Watcher: entered closed']);
|
||
expect(started.world.log.filter((line) => line.includes('exited'))).toEqual([]);
|
||
expect(started.behaviors?.machineStates(machine.id).map((s) => s.state)).toEqual([closed, closed]);
|
||
expect(open).not.toBe('');
|
||
});
|
||
|
||
it('reaches every instance when the machine is global, and enters its start once', () => {
|
||
const f = makeSpriteScene();
|
||
const machine = makeMachine(f);
|
||
const [open, closed] = machine.ids as [string, string];
|
||
f.stack.execute(new SetMachineScopeCommand(machine.id, 'global'));
|
||
f.stack.execute(new SetMachineStartCommand(machine.id, open));
|
||
const { behavior } = watcher(f, machine.id, machine.ids);
|
||
const mover = watcher(f, machine.id, machine.ids, 'Mover');
|
||
const start = mover.edit.addNode('OnStart');
|
||
const set = mover.edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||
mover.edit.wire([[start, 'exec', set, closed]]);
|
||
place(f, behavior.id, { x: 0, y: 0 });
|
||
place(f, mover.behavior.id, { x: 10, y: 0 });
|
||
const started = startWorld(f, { behaviors: true });
|
||
started.frame();
|
||
// The start is entered once for the whole run and heard by both; so is the move one of them made.
|
||
expect(started.world.log).toEqual([
|
||
'Watcher: entered open',
|
||
'Mover: entered open',
|
||
'Watcher: exited open',
|
||
'Mover: exited open',
|
||
'Watcher: entered closed',
|
||
'Mover: entered closed',
|
||
]);
|
||
});
|
||
|
||
it('stops a run whose state events keep moving the machine', () => {
|
||
const f = makeSpriteScene();
|
||
const machine = makeMachine(f);
|
||
const [open, closed] = machine.ids as [string, string];
|
||
f.stack.execute(new SetMachineStartCommand(machine.id, open));
|
||
const behavior = makeBehavior(f, 'Flapper');
|
||
const edit = graphEditor(f, behavior);
|
||
const entered = edit.addNode('OnStateEntered', { [MACHINE_PORT]: machine.id });
|
||
const toClosed = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||
const toOpen = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||
edit.wire([
|
||
[entered, open, toClosed, closed],
|
||
[entered, closed, toOpen, open],
|
||
]);
|
||
place(f, behavior.id, { x: 0, y: 0 });
|
||
const started = startWorld(f, { behaviors: true });
|
||
started.frame();
|
||
expect(started.world.stopRequested).toBe(true);
|
||
expect(started.world.log.some((line) => line.includes('endless loop'))).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('reading the current state', () => {
|
||
it('hands out the name to show and the id to compare', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const [open] = machine.ids as [string, string];
|
||
f.stack.execute(new SetMachineStartCommand(machine.id, open));
|
||
const behavior = makeBehavior(f, 'B');
|
||
const edit = graphEditor(f, behavior);
|
||
const start = edit.addNode('OnStart');
|
||
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||
const current = edit.addNode('CurrentState', { [MACHINE_PORT]: machine.id });
|
||
const log = edit.addNode('Log');
|
||
edit.wire([
|
||
[start, 'exec', set, open],
|
||
[set, 'exec', log, 'exec'],
|
||
[current, 'name', log, 'message'],
|
||
]);
|
||
const ctx = fakeContext();
|
||
fire(new GraphInterpreter(behavior.graph, graphLookup(f.project)), 'OnStart', ctx);
|
||
expect(ctx.logs).toEqual(['Open']);
|
||
});
|
||
|
||
it('is one boolean per state, false everywhere while the machine is in none', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const [open, closed] = machine.ids as [string, string];
|
||
const behavior = makeBehavior(f, 'B');
|
||
const edit = graphEditor(f, behavior);
|
||
const isIn = edit.addNode('IsInState', { [MACHINE_PORT]: machine.id });
|
||
const node = behavior.graph.nodes.find((n) => n.id === isIn.id)!;
|
||
const def = resolveDef(node, behavior.graph, graphLookup(f.project))!;
|
||
expect(def.outputs.map((p) => `${p.key}:${p.type}`)).toEqual([`${open}:boolean`, `${closed}:boolean`]);
|
||
const ctx = fakeContext({ states: { [machine.id]: closed } });
|
||
expect(def.read!(ctx, {}, () => machine.id, def, graphLookup(f.project))).toEqual({
|
||
[open]: false,
|
||
[closed]: true,
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('transitions between states', () => {
|
||
/** Draws a transition the way the canvas does: out of one state's `out`, into the other's `in`. */
|
||
const connect = (f: ProjectFixture, machineId: string, from: string, to: string) =>
|
||
f.stack.execute(
|
||
new ConnectCommand(machineId, { node: from, port: STATE_OUT }, { node: to, port: STATE_IN }),
|
||
);
|
||
|
||
it('hangs on the two exec ports every state carries', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const node = machine.asset().graph.nodes[0]!;
|
||
const def = resolveDef(node, machine.asset().graph, graphLookup(f.project))!;
|
||
expect(def.inputs.map((p) => p.key)).toEqual([STATE_IN]);
|
||
expect(def.outputs.map((p) => p.key)).toEqual([STATE_OUT]);
|
||
// Without a port there is no anchor to draw, drag or right-click an edge on.
|
||
expect(def.inputs[0]?.type).toBe('exec');
|
||
expect(def.outputs[0]?.type).toBe('exec');
|
||
});
|
||
|
||
it('is an ordinary edge, so drawing, undo and a save round trip come for free', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const [open, closed] = machine.ids as [string, string];
|
||
connect(f, machine.id, open, closed);
|
||
expect(transitionsOf(machine.asset().graph)).toEqual([{ from: open, to: closed, name: '' }]);
|
||
|
||
const loaded = findAssetOf(roundTrip(f.project), machine.id, 'statemachine')!;
|
||
expect(transitionsOf(loaded.graph)).toEqual([{ from: open, to: closed, name: '' }]);
|
||
|
||
f.stack.undo();
|
||
expect(transitionsOf(machine.asset().graph)).toEqual([]);
|
||
});
|
||
|
||
it('goes away with the state it hangs on, because both live in the one graph', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const [open, closed] = machine.ids as [string, string];
|
||
connect(f, machine.id, open, closed);
|
||
f.stack.execute(new RemoveNodeCommand(machine.id, closed));
|
||
expect(transitionsOf(machine.asset().graph)).toEqual([]);
|
||
f.stack.undo();
|
||
expect(transitionsOf(machine.asset().graph)).toEqual([{ from: open, to: closed, name: '' }]);
|
||
});
|
||
|
||
it('is what the lookup hands a running graph', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const [open, closed] = machine.ids as [string, string];
|
||
connect(f, machine.id, open, closed);
|
||
expect(graphLookup(f.project).machine(machine.id)?.transitions).toEqual([
|
||
{ from: open, to: closed, name: '' },
|
||
]);
|
||
});
|
||
|
||
it('moves the machine one step when a behavior runs State transition', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const [open, closed] = machine.ids as [string, string];
|
||
connect(f, machine.id, open, closed);
|
||
const behavior = makeBehavior(f, 'B');
|
||
const edit = graphEditor(f, behavior);
|
||
const start = edit.addNode('OnStart');
|
||
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||
const next = edit.addNode('StateTransition', { [MACHINE_PORT]: machine.id });
|
||
edit.wire([
|
||
[start, 'exec', set, open],
|
||
[set, 'exec', next, 'exec'],
|
||
]);
|
||
const ctx = fakeContext();
|
||
fire(new GraphInterpreter(behavior.graph, graphLookup(f.project)), 'OnStart', ctx);
|
||
expect(ctx.states).toEqual({ [machine.id]: closed });
|
||
});
|
||
|
||
it('carries the name it was given through a save, and undo takes it back', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const [open, closed] = machine.ids as [string, string];
|
||
connect(f, machine.id, open, closed);
|
||
const edge = machine.asset().graph.edges[0]!;
|
||
f.stack.execute(new NameTransitionCommand(machine.id, edge.id, ' alarm '));
|
||
// Trimmed on the way in, so a stray space never makes a second name that looks the same.
|
||
expect(transitionsOf(machine.asset().graph)).toEqual([{ from: open, to: closed, name: 'alarm' }]);
|
||
|
||
const loaded = findAssetOf(roundTrip(f.project), machine.id, 'statemachine')!;
|
||
expect(transitionsOf(loaded.graph)).toEqual([{ from: open, to: closed, name: 'alarm' }]);
|
||
|
||
f.stack.undo();
|
||
expect(transitionsOf(machine.asset().graph)).toEqual([{ from: open, to: closed, name: '' }]);
|
||
});
|
||
|
||
it('clears a name back to unnamed rather than keeping an empty one', () => {
|
||
const f = makeProject();
|
||
const machine = makeMachine(f);
|
||
const [open, closed] = machine.ids as [string, string];
|
||
connect(f, machine.id, open, closed);
|
||
const edge = machine.asset().graph.edges[0]!;
|
||
f.stack.execute(new NameTransitionCommand(machine.id, edge.id, 'alarm'));
|
||
f.stack.execute(new NameTransitionCommand(machine.id, edge.id, ' '));
|
||
expect(edge.label).toBeUndefined();
|
||
expect(transitionsOf(machine.asset().graph)).toEqual([{ from: open, to: closed, name: '' }]);
|
||
});
|
||
|
||
it('reads the same name out of whichever state the machine is in', () => {
|
||
const machine = {
|
||
name: 'Guard',
|
||
states: [],
|
||
start: '',
|
||
transitions: [
|
||
{ from: 'patrolling', to: 'chasing', name: 'alarm' },
|
||
{ from: 'searching', to: 'chasing', name: 'alarm' },
|
||
{ from: 'chasing', to: 'searching', name: 'lost' },
|
||
],
|
||
};
|
||
expect(transitionTo(machine, 'patrolling', 'alarm')).toBe('chasing');
|
||
expect(transitionTo(machine, 'searching', 'alarm')).toBe('chasing');
|
||
expect(transitionTo(machine, 'chasing', 'alarm')).toBe('');
|
||
expect(transitionNames(machine)).toEqual(['alarm', 'lost']);
|
||
});
|
||
});
|