Files
feuerwerk/tests/core/self-transition.test.ts
T

207 lines
7.8 KiB
TypeScript
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.
/**
* A transition from a state back into itself (#163): it re-enters the state, so `On state exited`
* and `On state entered` fire for it – unlike `Set state` into the state the machine is already in,
* which stays silent. Drawn as a loop over the node's top edge.
*/
import { describe, expect, it } from 'vitest';
import {
CreateAssetCommand,
SetMachineScopeCommand,
SetMachineStartCommand,
} from '../../src/core/commands/assetCommands';
import { AddNodeCommand, ConnectCommand, NameTransitionCommand } from '../../src/core/commands/graphCommands';
import {
MACHINE_PORT,
STATE,
STATE_IN,
STATE_NAME_PARAM,
STATE_OUT,
TRANSITION_PARAM,
transitionsOf,
transitionTo,
} from '../../src/core/graph';
import { findAssetOf } from '../../src/core/model/Project';
import { arrowCorners, BORDER_GAP, loopPoint, selfLoop } from '../../src/editor/graph/transitions';
import {
graphEditor,
makeBehavior,
makeProject,
makeSpriteScene,
place,
type ProjectFixture,
roundTrip,
startWorld,
} from '../helpers';
/** A machine "Fighter" with the states Idle and Attack, starting in Attack. */
function makeFighter(f: ProjectFixture) {
const create = new CreateAssetCommand('statemachine', 'Fighter');
f.stack.execute(create);
const [idle, attack] = ['Idle', 'Attack'].map((name) => {
const add = new AddNodeCommand(create.asset.id, STATE, 0, 0, { [STATE_NAME_PARAM]: name });
f.stack.execute(add);
return add.node.id;
}) as [string, string];
f.stack.execute(new SetMachineStartCommand(create.asset.id, attack));
const asset = () => findAssetOf(f.project, create.asset.id, 'statemachine')!;
return { id: create.asset.id, idle, attack, asset };
}
function connect(f: ProjectFixture, machineId: string, from: string, to: string, name = '') {
const command = new ConnectCommand(
machineId,
{ node: from, port: STATE_OUT },
{ node: to, port: STATE_IN },
);
f.stack.execute(command);
if (name) f.stack.execute(new NameTransitionCommand(machineId, command.edge.id, name));
}
/** Logs every entry and exit of Attack; `trigger` runs `State transition "again"` on that event. */
function fighterBehavior(
f: ProjectFixture,
machineId: string,
attack: string,
name: string,
trigger?: 'OnStart' | 'OnUpdate',
) {
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 });
const inLog = edit.addNode('Log', { message: `${name}: entered attack` });
const outLog = edit.addNode('Log', { message: `${name}: exited attack` });
edit.wire([
[entered, attack, inLog, 'exec'],
[exited, attack, outLog, 'exec'],
]);
if (trigger) {
const event = edit.addNode(trigger);
const again = edit.addNode('StateTransition', { [MACHINE_PORT]: machineId, [TRANSITION_PARAM]: 'again' });
edit.wire([[event, 'exec', again, 'exec']]);
}
return behavior;
}
describe('a transition from a state into itself', () => {
it('can be drawn in a machine, survives a save, and undo takes it back', () => {
const f = makeProject();
const m = makeFighter(f);
connect(f, m.id, m.attack, m.attack);
expect(transitionsOf(m.asset().graph)).toEqual([{ from: m.attack, to: m.attack, name: '' }]);
const loaded = findAssetOf(roundTrip(f.project), m.id, 'statemachine')!;
expect(transitionsOf(loaded.graph)).toEqual([{ from: m.attack, to: m.attack, name: '' }]);
f.stack.undo();
expect(transitionsOf(m.asset().graph)).toEqual([]);
});
it('can come twice, the second under a name of its own, like every other pair (#162)', () => {
const f = makeProject();
const m = makeFighter(f);
connect(f, m.id, m.attack, m.attack);
connect(f, m.id, m.attack, m.attack);
expect(transitionsOf(m.asset().graph).map((t) => t.name)).toEqual(['', 'Transition 2']);
});
it('stays refused for every node that is not a state', () => {
const f = makeProject();
const edit = graphEditor(f, makeBehavior(f, 'B'));
const log = edit.addNode('Log');
expect(() => edit.connect(log, 'exec', log, 'exec')).toThrow(/itself/);
});
it('is where State transition leads from that state', () => {
const machine = {
name: 'Fighter',
states: [],
start: '',
transitions: [
{ from: 'attack', to: 'attack', name: 'again' },
{ from: 'attack', to: 'idle', name: 'done' },
],
};
expect(transitionTo(machine, 'attack', 'again')).toBe('attack');
expect(transitionTo(machine, 'idle', 'again')).toBe('');
});
it('fires exited, then entered, each time it is taken, and the machine stays where it was', () => {
const f = makeSpriteScene();
const m = makeFighter(f);
connect(f, m.id, m.attack, m.attack, 'again');
const behavior = fighterBehavior(f, m.id, m.attack, 'F', 'OnUpdate');
place(f, behavior.id, { x: 0, y: 0 });
const started = startWorld(f, { behaviors: true });
started.frame();
expect(started.world.log).toEqual(['F: entered attack', 'F: exited attack', 'F: entered attack']);
// Every frame again – where a Set state into the same state would say nothing at all.
started.frame();
expect(started.world.log.slice(3)).toEqual(['F: exited attack', 'F: entered attack']);
expect(started.behaviors?.machineStates(m.id).map((s) => s.state)).toEqual([m.attack]);
});
it('is heard by every instance once when the machine is global', () => {
const f = makeSpriteScene();
const m = makeFighter(f);
f.stack.execute(new SetMachineScopeCommand(m.id, 'global'));
connect(f, m.id, m.attack, m.attack, 'again');
const watcher = fighterBehavior(f, m.id, m.attack, 'W');
const mover = fighterBehavior(f, m.id, m.attack, 'M', 'OnStart');
place(f, watcher.id, { x: 0, y: 0 });
place(f, mover.id, { x: 10, y: 0 });
const started = startWorld(f, { behaviors: true });
started.frame();
expect(started.world.log).toEqual([
'W: entered attack',
'M: entered attack',
'W: exited attack',
'M: exited attack',
'W: entered attack',
'M: entered attack',
]);
});
it('does nothing from a state that has no transition of the name', () => {
const f = makeSpriteScene();
const m = makeFighter(f);
connect(f, m.id, m.idle, m.idle, 'again');
const behavior = fighterBehavior(f, m.id, m.attack, 'F', 'OnUpdate');
place(f, behavior.id, { x: 0, y: 0 });
const started = startWorld(f, { behaviors: true });
started.frame();
started.frame();
expect(started.world.log).toEqual(['F: entered attack']);
});
});
describe('the loop a self-transition is drawn as', () => {
const box = { x: 100, y: 200, w: 120, h: 40 };
const loop = selfLoop(box);
it('stands on the middle of the top edge, both ends clear of the border', () => {
expect(loop.start.y).toBe(box.y - BORDER_GAP);
expect(loop.end.y).toBe(box.y - BORDER_GAP);
expect((loop.start.x + loop.end.x) / 2).toBe(box.x + box.w / 2);
expect(loop.start.x).toBeLessThan(loop.end.x);
});
it('runs from its start to its end and rises above the node on the way', () => {
expect(loopPoint(loop, 0)).toEqual(loop.start);
expect(loopPoint(loop, 1)).toEqual(loop.end);
const apex = loopPoint(loop, 0.5);
expect(apex.x).toBe(box.x + box.w / 2);
expect(apex.y).toBeLessThan(box.y - 30);
// Left to right: the first quarter is still on the side it left from, the last on the side it enters.
expect(loopPoint(loop, 0.25).x).toBeLessThan(apex.x);
expect(loopPoint(loop, 0.75).x).toBeGreaterThan(apex.x);
});
it('ends in an arrow pointing down into the node', () => {
const [tip, a, b] = arrowCorners(loop.c2, loop.end)!;
expect(tip).toEqual(loop.end);
expect(a.y).toBeLessThan(tip.y);
expect(b.y).toBeLessThan(tip.y);
});
});