/** * Several transitions between the same two states (#162): they are told apart by name, so a further * one gets a name of its own when it is drawn, and a rename may never make two of them alike. * In a behavior graph a second edge between the same ports stays refused. */ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, 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 { freeLabelSpot, loopPoint, loopRings, selfLoop, transitionOffsets, } from '../../src/editor/graph/transitions'; import { graphEditor, makeBehavior, makeProject, makeSpriteScene, place, type ProjectFixture, roundTrip, startWorld, } from '../helpers'; /** A machine "Guard" with the states Patrol, Chase and Search, starting in Patrol. */ function makeGuard(f: ProjectFixture) { const create = new CreateAssetCommand('statemachine', 'Guard'); f.stack.execute(create); const [patrol, chase, search] = ['Patrol', 'Chase', 'Search'].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, string]; f.stack.execute(new SetMachineStartCommand(create.asset.id, patrol)); const asset = () => findAssetOf(f.project, create.asset.id, 'statemachine')!; const names = () => transitionsOf(asset().graph).map((t) => t.name); return { id: create.asset.id, patrol, chase, search, asset, names }; } function connect(f: ProjectFixture, machineId: string, from: string, to: string): string { const command = new ConnectCommand( machineId, { node: from, port: STATE_OUT }, { node: to, port: STATE_IN }, ); f.stack.execute(command); return command.edge.id; } function rename(f: ProjectFixture, machineId: string, edgeId: string, name: string): void { f.stack.execute(new NameTransitionCommand(machineId, edgeId, name)); } describe('several transitions between the same two states', () => { it('gives the second and third one their own names', () => { const f = makeProject(); const g = makeGuard(f); connect(f, g.id, g.patrol, g.chase); connect(f, g.id, g.patrol, g.chase); connect(f, g.id, g.patrol, g.chase); expect(g.names()).toEqual(['', 'Transition 2', 'Transition 3']); }); it('skips a name already leaving the state, towards any state', () => { const f = makeProject(); const g = makeGuard(f); rename(f, g.id, connect(f, g.id, g.patrol, g.search), 'Transition 2'); connect(f, g.id, g.patrol, g.chase); connect(f, g.id, g.patrol, g.chase); expect(g.names()).toEqual(['Transition 2', '', 'Transition 3']); }); it('leaves the way back alone: it joins the pair in the other direction', () => { const f = makeProject(); const g = makeGuard(f); connect(f, g.id, g.patrol, g.chase); connect(f, g.id, g.chase, g.patrol); expect(g.names()).toEqual(['', '']); }); it('counts only transitions towards the same state when a rename is checked', () => { const f = makeProject(); const g = makeGuard(f); connect(f, g.id, g.patrol, g.search); const chase = connect(f, g.id, g.patrol, g.chase); rename(f, g.id, chase, 'seen'); // Two unnamed ones out of Patrol are only warned about in the inspector, not refused. rename(f, g.id, chase, ''); expect(g.names()).toEqual(['', '']); }); it('survives a save, and undo takes back the second one only, redo brings it back by the same name', () => { const f = makeProject(); const g = makeGuard(f); rename(f, g.id, connect(f, g.id, g.patrol, g.chase), 'seen'); connect(f, g.id, g.patrol, g.chase); const loaded = findAssetOf(roundTrip(f.project), g.id, 'statemachine')!; expect(transitionsOf(loaded.graph).map((t) => t.name)).toEqual(['seen', 'Transition 2']); f.stack.undo(); expect(g.names()).toEqual(['seen']); f.stack.redo(); expect(g.names()).toEqual(['seen', 'Transition 2']); }); it('refuses a rename that would make two of them alike, the empty name included', () => { const f = makeProject(); const g = makeGuard(f); const first = connect(f, g.id, g.patrol, g.chase); const second = connect(f, g.id, g.patrol, g.chase); expect(() => rename(f, g.id, second, '')).toThrow('already unnamed'); rename(f, g.id, first, 'seen'); expect(() => rename(f, g.id, second, ' seen ')).toThrow('already called "seen"'); // Once the first has a name, the second may go without one. rename(f, g.id, second, ''); expect(g.names()).toEqual(['seen', '']); }); it('lets State transition pick one of them by name', () => { const machine = { name: 'Guard', states: [], start: '', transitions: [ { from: 'patrol', to: 'chase', name: 'seen' }, { from: 'patrol', to: 'chase', name: 'alarm' }, ], }; expect(transitionTo(machine, 'patrol', 'seen')).toBe('chase'); expect(transitionTo(machine, 'patrol', 'alarm')).toBe('chase'); expect(transitionTo(machine, 'patrol', '')).toBe(''); }); it('is taken at runtime under the name it was given', () => { const f = makeSpriteScene(); const g = makeGuard(f); rename(f, g.id, connect(f, g.id, g.patrol, g.chase), 'seen'); rename(f, g.id, connect(f, g.id, g.patrol, g.chase), 'alarm'); const behavior = makeBehavior(f, 'Guarding'); const edit = graphEditor(f, behavior); const start = edit.addNode('OnStart'); const go = edit.addNode('StateTransition', { [MACHINE_PORT]: g.id, [TRANSITION_PARAM]: 'alarm' }); edit.wire([[start, 'exec', go, 'exec']]); place(f, behavior.id, { x: 0, y: 0 }); const started = startWorld(f, { behaviors: true }); started.frame(); expect(started.behaviors?.machineStates(g.id).map((s) => s.state)).toEqual([g.chase]); }); it('stays one edge per pair of ports in a behavior graph', () => { const f = makeProject(); const edit = graphEditor(f, makeBehavior(f, 'B')); const start = edit.addNode('OnStart'); const log = edit.addNode('Log'); edit.connect(start, 'exec', log, 'exec'); expect(() => edit.connect(start, 'exec', log, 'exec')).toThrow('Already connected'); }); }); describe('how several of them are drawn', () => { it('lays the ones in the same direction side by side', () => { const offsets = transitionOffsets([ { id: 'a', from: 'p', to: 'c' }, { id: 'b', from: 'p', to: 'c' }, { id: 'c', from: 'p', to: 'c' }, ]); expect(new Set(offsets.values()).size).toBe(3); expect(Math.abs(offsets.get('b')!)).toBe(0); }); it('puts a name at the middle of its arrow while nothing is there yet', () => { const spot = freeLabelSpot({ x: 50, y: 50 }, 40, 16, { x: 0, y: 1 }, [{ x: 0, y: 0, w: 10, h: 10 }]); expect(spot).toEqual({ x: 50, y: 50 }); }); it('slides a name along its own arrow until its plate is clear of the one already there', () => { // Two vertical arrows 14 apart: the names are far wider than that, so they would cover each other. const first = { x: 50 - 30, y: 100 - 8, w: 60, h: 16 }; const spot = freeLabelSpot({ x: 64, y: 100 }, 80, 16, { x: 0, y: 1 }, [first]); expect(spot.x).toBe(64); expect(spot.y).toBe(116); }); it('stays at the middle when nothing within reach is free', () => { const wall = { x: -1000, y: -1000, w: 2000, h: 2000 }; expect(freeLabelSpot({ x: 5, y: 5 }, 10, 10, { x: 1, y: 0 }, [wall])).toEqual({ x: 5, y: 5 }); }); it('nests the loops on one state, each outside the last, counting per state', () => { const rings = loopRings([ { id: 'a', from: 'p', to: 'p' }, { id: 'x', from: 'p', to: 'c' }, { id: 'b', from: 'p', to: 'p' }, { id: 'c', from: 'c', to: 'c' }, ]); expect([...rings]).toEqual([ ['a', 0], ['b', 1], ['c', 0], ]); const box = { x: 0, y: 100, w: 120, h: 40 }; const [inner, outer] = [selfLoop(box, 0), selfLoop(box, 1)]; expect(outer.start.x).toBeLessThan(inner.start.x); expect(outer.end.x).toBeGreaterThan(inner.end.x); // Room for a name between the two apexes, where each loop carries its own. expect(loopPoint(inner, 0.5).y - loopPoint(outer, 0.5).y).toBeGreaterThanOrEqual(16); }); });