Files
feuerwerk/tests/editor/edge-selection.test.ts
adminandClaude Opus 5.5 0e73fd85b9
game-maker / gate (push) Successful in 4m20s
game-maker / suite (push) Failing after 15m17s
Route sounds, songs and buses in one Master routing graph (#351)
A project bus could only go into one other bus, and a sound, an
instrument, a pattern or a song into exactly one bus. A shared reverb
needs a sound to go there in part and on dry at the same time. Instead
of a list of sends on every asset, the project gets one routing graph
like the routing of a song (#350), one level up.

- Menu -> Master routing opens a tab with one graph per project. It has
  a node per sound, instrument, pattern, song and bus, named after its
  asset, and one Master. Embedded assets have none, their song routes
  them.
- The nodes follow the assets. Every edit settles the routing in the
  same undo step (KeepRoutingCommand), so an undone delete brings the
  links of its asset back. A new asset gets a node and a link to the
  master. What went only into a deleted bus goes to the master.
- New nodes start in three columns: what plays, the buses, and the
  master a row above, so no link into it runs behind a bus.
- Links are sends: -60 to +12 dB, and a Mode on links out of a bus or a
  song. Out of a sound, an instrument or a pattern a link always takes
  the sound after its volume and FX. Loops are refused.
- A node without a link is silent and says "Goes nowhere".
- The rows Bus and Into are gone. Play nodes with a picked bus still
  overrule the routing.
- A click on a node shows its asset in the inspector, the Master shows
  the master volume and sound FX. A double click opens the asset's tab.
- The graph lives in project.routing and is held to the assets when a
  project is read, but nothing is added there. The editor adds the
  nodes when it opens a project. An asset without a node plays on the
  master, so old projects sound as before without a migration.
- In the mix an asset with one link at 0 dB goes straight into that bus.
  A sound or instrument with more gets a bus of its own with the sends.
  A pattern or song sends from the master of its playing, which is
  where its Mode taps. Buses of the mix can have sends now too.

Found:

- The check "the scheduler plans a looped bar" in e2e 34 wobbles. It
  failed in 2 of 5 runs here and in CI task 580 at 490fcba, before
  this change. HEAD passed it 2 of 2. Not fixed here.

Tests:

- Unit master-routing (new, 10), sound-library (2 new), buses
  rewritten onto the routing, 5 lists without the Bus row. 5 counter
  checks red.
- e2e 34 (melektaus-atx): ALL PASSED, 93 checks (3 new, 2 rewritten).
  Counter check with the Sound tab ignoring the routing: the routing
  check red.
- Gate (melektaus-atx): CHECK EXIT 0, 273 files, 4225 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:18:46 +02:00

238 lines
10 KiB
TypeScript

/**
* A link is picked on its line (#289): a click near the curve means that link, Ctrl adds another,
* a rubber band takes every line it crosses, and Delete removes what is picked in one undo step.
* A hidden link has no line, so it is never among the paths these rules are asked about.
*/
import { beforeEach, describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { AddNodeCommand, ConnectCommand, NameTransitionCommand } from '../../src/core/commands/graphCommands';
import { type Graph, STATE, STATE_IN, STATE_NAME_PARAM, STATE_OUT } from '../../src/core/graph';
import { createEmptyProject, findGraphAsset } from '../../src/core/model/Project';
import { hideLinks, removeGraphSelection, removeSelection } from '../../src/editor/actions';
import {
distanceToPath,
edgeAt,
type EdgePath,
edgesInBand,
pathMiddle,
} from '../../src/editor/graph/edgeHit';
import { selfLoop } from '../../src/editor/graph/transitions';
import { describeEdge, edgeSignature } from '../../src/editor/inspector/edgeSection';
import { edgeSelection, EditorState, graphSelection } from '../../src/editor/state';
const curve = (edgeId: string, y1: number, y2: number): EdgePath => ({
edgeId,
kind: 'curve',
start: { x: 0, y: y1 },
end: { x: 200, y: y2 },
});
describe('which link a point means', () => {
it('takes the link whose curve runs within the tolerance, and none further off', () => {
const paths = [curve('a', 0, 100)];
const on = pathMiddle(paths[0]!);
expect(on).toEqual({ x: 100, y: 50 });
expect(edgeAt(paths, { x: on.x + 4, y: on.y }, 6)).toBe('a');
expect(edgeAt(paths, { x: on.x + 40, y: on.y }, 6)).toBeNull();
// The straight line between the ports is not the curve: its quarter point is well off it.
expect(distanceToPath(paths[0]!, { x: 50, y: 25 })).toBeGreaterThan(6);
});
it('takes the one drawn last where two cross', () => {
const paths = [curve('under', 0, 100), curve('over', 100, 0)];
expect(edgeAt(paths, { x: 100, y: 50 }, 6)).toBe('over');
expect(edgeAt([...paths].reverse(), { x: 100, y: 50 }, 6)).toBe('under');
});
it('follows a straight transition and a loop back into its own state', () => {
const line: EdgePath = { edgeId: 'line', kind: 'line', start: { x: 0, y: 0 }, end: { x: 100, y: 100 } };
const loop: EdgePath = {
edgeId: 'loop',
kind: 'loop',
loop: selfLoop({ x: 300, y: 300, w: 100, h: 40 }),
};
expect(edgeAt([line, loop], { x: 52, y: 48 }, 6)).toBe('line');
expect(edgeAt([line, loop], { x: 80, y: 20 }, 6)).toBeNull();
expect(edgeAt([line, loop], pathMiddle(loop), 6)).toBe('loop');
// Inside the loop is not on it.
expect(edgeAt([line, loop], { x: 350, y: 285 }, 6)).toBeNull();
});
it('means the transition when its name plate is pressed, before any line under it', () => {
const paths = [curve('line', 0, 0)];
const plates = [{ edgeId: 'named', x: 80, y: -8, w: 40, h: 16 }];
expect(edgeAt(paths, { x: 100, y: 0 }, 6, plates)).toBe('named');
expect(edgeAt(paths, { x: 20, y: 0 }, 6, plates)).toBe('line');
});
it('gives a band every link it crosses, not only those with an end inside', () => {
const paths = [curve('a', 0, 100), curve('far', 400, 400)];
expect(edgesInBand(paths, { x1: 90, y1: 20, x2: 110, y2: 80 })).toEqual(['a']);
// Drawn the other way round it is the same rectangle.
expect(edgesInBand(paths, { x1: 110, y1: 80, x2: 90, y2: 20 })).toEqual(['a']);
expect(edgesInBand(paths, { x1: 90, y1: 150, x2: 110, y2: 200 })).toEqual([]);
expect(edgesInBand(paths, { x1: -10, y1: -10, x2: 300, y2: 500 })).toEqual(['a', 'far']);
});
});
function fixture() {
const state = new EditorState();
state.loadProject(createEmptyProject('p', 'P'), 1);
const create = new CreateAssetCommand('behavior', 'B');
expect(state.execute(create)).toBe(true);
const behaviorId = create.asset.id;
const graphOf = (): Graph => findGraphAsset(state.project, behaviorId)!.graph;
const addNode = (type: string): string => {
const command = new AddNodeCommand(behaviorId, type, 0, 0);
expect(state.execute(command)).toBe(true);
return command.node.id;
};
const connect = (from: string, to: string, port: string): string => {
const command = new ConnectCommand(behaviorId, { node: from, port: 'value' }, { node: to, port });
expect(state.execute(command)).toBe(true);
return command.edge.id;
};
const [one, two, add] = [addNode('Number'), addNode('Number'), addNode('Add')];
const [first, second] = [connect(one, add, 'a'), connect(two, add, 'b')];
const edgeIds = () => graphOf().edges.map((e) => e.id);
return { state, behaviorId, graphOf, one, two, add, first, second, edgeIds };
}
describe('a selection of links', () => {
let f: ReturnType<typeof fixture>;
beforeEach(() => {
f = fixture();
});
it('is its own kind, and keeps the graph of its asset on screen', () => {
f.state.select(edgeSelection(f.behaviorId, [f.first]));
expect(f.state.selection).toEqual({
kind: 'edge',
assetId: f.behaviorId,
edgeId: f.first,
edgeIds: [f.first],
});
expect(f.state.selectedEdgeIds).toEqual([f.first]);
expect(f.state.selectedBehaviorId).toBe(f.behaviorId);
});
it('adds and takes a link with the toggle, and falls back to the asset when emptied', () => {
f.state.select(edgeSelection(f.behaviorId, [f.first]));
f.state.toggleEdge(f.behaviorId, f.second);
expect(f.state.selection).toMatchObject({ kind: 'edge', edgeId: f.second, edgeIds: [f.first, f.second] });
f.state.toggleEdge(f.behaviorId, f.second);
f.state.toggleEdge(f.behaviorId, f.first);
expect(f.state.selection).toEqual({ kind: 'asset', assetId: f.behaviorId });
});
it('rides along with nodes: the node stays what the inspector shows, either toggle keeps the rest', () => {
f.state.select(graphSelection(f.behaviorId, [f.one], [], [f.first]));
expect(f.state.selection).toMatchObject({ kind: 'node', nodeId: f.one, edgeIds: [f.first] });
f.state.toggleEdge(f.behaviorId, f.second);
expect(f.state.selection).toMatchObject({ kind: 'node', nodeId: f.one, edgeIds: [f.first, f.second] });
f.state.toggleNode(f.behaviorId, f.two);
expect(f.state.selectedNodeIds).toEqual([f.one, f.two]);
expect(f.state.selectedEdgeIds).toEqual([f.first, f.second]);
// The last node toggled away leaves the links, as a selection of their own.
f.state.toggleNode(f.behaviorId, f.one);
f.state.toggleNode(f.behaviorId, f.two);
expect(f.state.selection).toMatchObject({ kind: 'edge', edgeIds: [f.first, f.second] });
});
it('deletes exactly the picked link on Delete, and one undo brings it back', () => {
f.state.select(edgeSelection(f.behaviorId, [f.first]));
expect(removeSelection(f.state)).toBe(true);
expect(f.edgeIds()).toEqual([f.second]);
// The link is gone, so the selection falls back to the graph it was in.
expect(f.state.selection).toEqual({ kind: 'asset', assetId: f.behaviorId });
f.state.undo();
expect(f.edgeIds()).toEqual([f.first, f.second]);
});
it('deletes several links, and a node with them, as one undo step', () => {
f.state.select(graphSelection(f.behaviorId, [f.one], [], [f.first, f.second]));
expect(removeSelection(f.state)).toBe(true);
expect(f.edgeIds()).toEqual([]);
expect(f.graphOf().nodes.map((n) => n.id)).toEqual([f.two, f.add]);
f.state.undo();
expect(f.edgeIds()).toEqual([f.first, f.second]);
expect(f.graphOf().nodes.map((n) => n.id)).toEqual([f.one, f.two, f.add]);
f.state.redo();
expect(f.edgeIds()).toEqual([]);
});
it('has nothing to delete when the link is already gone', () => {
expect(removeGraphSelection(f.state, f.behaviorId, [], [], ['gone'])).toBe(false);
expect(f.edgeIds()).toEqual([f.first, f.second]);
});
it('lets go of a link that is hidden, since it has no line left to be picked on', () => {
f.state.select(graphSelection(f.behaviorId, [f.one], [], [f.first]));
expect(hideLinks(f.state, f.behaviorId, [f.first])).toBe(true);
expect(f.graphOf().edges[0]!.folded).toBe(true);
expect(f.state.selection).toMatchObject({ kind: 'node', nodeId: f.one, edgeIds: [] });
});
it('drops a link from the selection when an undo takes it away', () => {
f.state.select(edgeSelection(f.behaviorId, [f.first, f.second]));
f.state.undo();
expect(f.state.selection).toMatchObject({ kind: 'edge', edgeIds: [f.first] });
});
it('describes a link by the names drawn at its two ends', () => {
expect(describeEdge(f.state.project, f.behaviorId, f.second)).toEqual({
transition: false,
from: 'Number',
output: 'value',
to: 'Add',
input: 'b',
type: 'number',
name: '',
send: null,
tapped: false,
});
expect(describeEdge(f.state.project, f.behaviorId, 'gone')).toBeNull();
expect(edgeSignature(f.state.project, f.behaviorId, f.first, [f.first, f.second])).toBe('many:2');
expect(edgeSignature(f.state.project, f.behaviorId, f.first, [f.first])).not.toBe(
edgeSignature(f.state.project, f.behaviorId, f.second, [f.second]),
);
});
});
describe('a picked transition', () => {
function machine() {
const state = new EditorState();
state.loadProject(createEmptyProject('p', 'P'), 1);
const create = new CreateAssetCommand('statemachine', 'Guard');
expect(state.execute(create)).toBe(true);
const id = create.asset.id;
const [patrol, chase] = ['Patrol', 'Chase'].map((name) => {
const add = new AddNodeCommand(id, STATE, 0, 0, { [STATE_NAME_PARAM]: name });
expect(state.execute(add)).toBe(true);
return add.node.id;
}) as [string, string];
const connect = new ConnectCommand(
id,
{ node: patrol, port: STATE_OUT },
{ node: chase, port: STATE_IN },
);
expect(state.execute(connect)).toBe(true);
return { state, id, edge: connect.edge.id };
}
it('is described by its two states and its name, and goes as a transition', () => {
const m = machine();
expect(m.state.execute(new NameTransitionCommand(m.id, m.edge, 'alarm'))).toBe(true);
expect(describeEdge(m.state.project, m.id, m.edge)).toMatchObject({
transition: true,
from: 'Patrol',
to: 'Chase',
name: 'alarm',
});
m.state.select(edgeSelection(m.id, [m.edge]));
expect(m.state.selectedStateMachineId).toBe(m.id);
expect(removeSelection(m.state)).toBe(true);
expect(findGraphAsset(m.state.project, m.id)!.graph.edges).toEqual([]);
});
});