Files
feuerwerk/tests/core/parallel-transitions.test.ts
T

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