Several transitions between the same pair of states: make transition onto a state that is already connected in that direction adds another transition, which gets its own name right away (Transition 2, Transition 3, skipping names that already leave the state), so two unnamed transitions between one pair never exist; renaming is refused when another transition between the same pair in the same direction already carries the name, the empty name included, while equal names to other targets are only warned about as before; the behavior graph keeps Already connected; self transitions may repeat too, each further loop drawn as a ring around the previous one with the preview showing the next ring, and transition labels that would cover another one slide along their own edge until they are free (pure function freeLabelSpot); test hook transitionLabelAt also returns width and height, e2e 26e6 (#162)
game-maker / gate (push) Successful in 1m58s
game-maker / suite (push) Successful in 6m50s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-19 16:30:52 +02:00
1 parent 010b6a7f6c
commit 25ec7879a0
9 files changed
+402 -34

No files matched your search

+221
View File
@@ -0,0 +1,221 @@
/**
* 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);
});
});
+3 -2
View File
@@ -97,11 +97,12 @@ describe('a transition from a state into itself', () => {
expect(transitionsOf(m.asset().graph)).toEqual([]);
});
it('is one per state, like every other pair', () => {
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);
expect(() => connect(f, m.id, m.attack, m.attack)).toThrow('Already connected');
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', () => {