222 lines
8.4 KiB
TypeScript
222 lines
8.4 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|