/** * 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); }); });