Files
feuerwerk/tests/editor/edge-selection.test.ts
T
adminandClaude Opus 5.5 daeffe0f9d
game-maker / gate (push) Successful in 3m47s
game-maker / suite (push) Failing after 14m20s
Route the tracks of a song through a graph (#350)
Every track of a song went straight into its output. Now the Song tab
has a second view, Routing: a graph with a node per track and one
Output, the links between them sends. A track can go into other
tracks, into several at once, and a link has its own volume and the
point of the track it taps.

- The song asset has a graph like an instrument (SongTrack, SongOutput).
  heldSongGraph keeps it in step with the tracks on load and in
  SetSongCommand: a node per track named after it, one output, a new
  track and every track of an old song linked to the output. Links of
  gone tracks, doubled links and links closing a loop are dropped.
- A link carries an optional send (volume in dB, tap). The inspector
  shows Volume and Mode for a picked link of a song: Post volume (Post
  pan), Pre volume (Post FX), Pre volume (Pre FX). SetSendCommand.
- NodeDef has two new flags. `fixed` nodes are never added or deleted
  by hand. Links between `acyclic` nodes may not close a loop, checked
  in canConnect, dropTargetPort and ConnectCommand.
- songBuses gives each track its sends instead of one `into`, and a
  bus `song:<song>:<track>:#items` for its own items. That bus is what
  solo closes on a track that only passes a soloed one on.
- Mute and solo now act on the sends (trackGates). A muted track passes
  nothing on, on any tap. A soloed track is heard with what goes into
  it, through the tracks it goes through, their own items silent.
  Only sends on a way through a soloed track stay open, so a track
  going into it and into the output is heard through it only.
- SoundLibrary: a bus has either `into` or sends. Each send is tap,
  delay, level, target. busLatency takes the latest way (arrivalOf),
  and each send is held back so all ways of a track arrive together
  (sendDelays). Taps are wired again whenever a chain comes or goes.
- The Song tab has Timeline and Routing in its header. The routing
  uses GraphCanvas with an empty palette. A track linked nowhere shows
  "Goes nowhere". Delete takes links, never a track node.
- Game playings route the same way, the output being the master of
  the playing.

Decided without asking: a send ranges -60 to +12 dB like a track,
default 0 dB Post. Mute closes the sends, the track's own level stays.

Found on the way: the inspector built the Mode row with the send as it
was when the rows were made. Setting Volume and then Mode wrote the old
volume back. Both rows now read the current send when they change.

Tests:
- Unit song-routing (new): 13 green. Sound library: 4 new, 94 green.
  11 counter-probes (loop on load, canConnect, solo, solo past the
  soloed track, mute, preFx latency, graph sync, delete guard, delays,
  taps, glide), each red.
- Existing tests adjusted to the new bus names and send shape: song,
  bus-fx, song-play, graph registry, edge description.
- e2e 65 on melektaus-atx: ALL PASSED, 43 checks (5 new). The first
  run caught the inspector bug. Counter-probe with the old closure:
  2 FAILED.
- Gate on melektaus-atx: typecheck, lint, format clean, 2 tests red
  (registry rule, edge shape), fixed and rerun locally: 56 green.
- Not rerun after the per-send solo fix: e2e 65 and the gate.

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

237 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,
});
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([]);
});
});