Files
feuerwerk/tests/editor/graph-follow.test.ts
T
adminandClaude Opus 5.5 19902a9f02
feuerwerk / gate (push) Successful in 4m47s
feuerwerk / suite (push) Successful in 19m5s
Keep groups, comments and silent music in view for the agent (#429)
A live test of the music areas built a pattern, a song and a
composition that stayed silent: no instrument at first, and later
notes outside the C4 to B4 range of a new Notes node. The agent got
no hint of either. The same test showed that new nodes left groups
behind, that a comment could end up under other nodes, and that an
end node like Output stayed left of the devices put in front of it.

- get_asset lists under silent what of a pattern, song or
  composition is not heard. That is a missing instrument, an
  instrument that sounds no note, or notes outside what its Notes
  nodes take in, in a song after the transpose.
- soundingNotes moves from the pattern tab to
  src/core/instrument/compile.ts. The tab and the agent use the
  same rule.
- When new nodes go between two that have no room, everything from
  the fed node on moves right together. This holds for Arrange with
  a selection too, so no link runs backwards.
- A new node linked to a node in a group joins that group, and the
  group grows in the same undo step. A group that would take in a
  foreign node stays as it is.
- Arrange takes groups and comments along: a group whose nodes all
  moved the same keeps its size, otherwise it is fitted anew. The
  rules live in the new src/editor/graph/annotationFollow.ts.
- add_comment and fit_group put a comment below its nodes when a
  node or a group title is in the way above. lint_graph counts
  covered comments as coveredNotes.
- add_notes, get_asset, lint_graph and the relay instructions name
  the default range and how to mend a covered comment.

Tests:
- graph-follow (new, 12), agent-edit, agent-music (11) and the
  other agent, graph, pattern and relay tests green
- 10 deliberate breakages, each one red in its own test
- e2e 71: 25/25
- Gate: CHECK EXIT 0, 324 files, 5002 tests

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

251 lines
11 KiB
TypeScript

/**
* Groups and comments going along when nodes are arranged (#429), new nodes joining the group of
* what they link to, room made for nodes put between, and comments kept clear of other nodes.
*/
import { describe, expect, it } from 'vitest';
import { freeNoteBox, groupBoxAround, noteBoxAbove } from '../../src/editor/graph/annotationDrag';
import { adoptingGroups, followedBoxes } from '../../src/editor/graph/annotationFollow';
import {
arrange,
COLUMN_GAP,
type LayoutBox,
type LayoutEdge,
type LayoutGraph,
type LayoutNode,
lintLayout,
} from '../../src/editor/graph/arrange';
function node(id: string, inputs: string[], outputs: string[], x = 0, y = 0): LayoutNode {
const rows = (keys: string[]) => Object.fromEntries(keys.map((key, i) => [key, 30 + i * 20 + 10]));
return {
id,
x,
y,
w: 176,
h: 36 + Math.max(inputs.length, outputs.length) * 20,
inputs: rows(inputs),
outputs: rows(outputs),
};
}
let edgeCount = 0;
function edge(from: string, fromPort: string, to: string, toPort: string): LayoutEdge {
edgeCount += 1;
return {
id: `e${edgeCount}`,
from: { node: from, port: fromPort },
to: { node: to, port: toPort },
exec: fromPort === 'exec',
};
}
/** Arranges as the editor does: the nodes, then the groups and comments after them. */
function placed(graph: LayoutGraph, movable?: ReadonlySet<string>, fresh = false): LayoutGraph {
const joining = fresh && movable ? adoptingGroups(graph, movable) : [];
const spots = arrange(graph, movable, { open: new Set(joining), fresh });
const boxes = followedBoxes(graph, spots, { groups: joining, nodes: [...(movable ?? [])] });
return {
...graph,
nodes: graph.nodes.map((n) => ({ ...n, ...(spots.get(n.id) ?? {}) })),
boxes: graph.boxes.map((b) => ({ ...b, ...boxes.get(b.id)! })),
};
}
const at = (graph: LayoutGraph, id: string) => graph.nodes.find((n) => n.id === id)!;
const box = (graph: LayoutGraph, id: string) => graph.boxes.find((b) => b.id === id)!;
const centredIn = (b: LayoutBox, n: LayoutNode) =>
n.x + n.w / 2 >= b.x && n.x + n.w / 2 <= b.x + b.w && n.y + n.h / 2 >= b.y && n.y + n.h / 2 <= b.y + b.h;
/** The walking graph of #429 in its group, with the comment above it, as the user has it. */
function walking(): LayoutGraph {
const nodes = [
node('update', [], ['exec', 'dt'], 0, 0),
node('owner', [], ['object'], 0, 100),
node('axis', [], ['x', 'y', 'active'], 0, 180),
node('move', ['exec', 'object', 'dx', 'dy', 'scale', 'dt'], ['exec'], 248, 0),
];
const group = { id: 'group', kind: 'group' as const, ...groupBoxAround(nodes)! };
const note = { id: 'note', kind: 'note' as const, ...noteBoxAbove(nodes)! };
return {
nodes,
edges: [
edge('update', 'exec', 'move', 'exec'),
edge('update', 'dt', 'move', 'dt'),
edge('owner', 'object', 'move', 'object'),
edge('axis', 'x', 'move', 'dx'),
edge('axis', 'y', 'move', 'dy'),
],
boxes: [group, note],
};
}
describe('new nodes and groups', () => {
it('puts a new node feeding one in a group into that group, which grows round it', () => {
const graph = walking();
graph.nodes.push(node('vars', [], ['speed'], 508, 0));
graph.edges.push(edge('vars', 'speed', 'move', 'scale'));
expect(adoptingGroups(graph, new Set(['vars']))).toEqual(['group']);
const result = placed(graph, new Set(['vars']), true);
const group = box(result, 'group');
for (const n of result.nodes) expect(centredIn(group, n)).toBe(true);
// It grew down for the new node only; the nodes there stay, and the comment stays above it.
expect(group.y).toBe(box(graph, 'group').y);
expect(group.h).toBeGreaterThan(box(graph, 'group').h);
expect(box(result, 'note')).toEqual(box(graph, 'note'));
expect(lintLayout(result)).toMatchObject({ overlaps: 0, backwards: 0, coveredNotes: 0, crossings: 4 });
});
it('leaves a group as it is when growing round the new node would take in a node not its own', () => {
const graph = walking();
graph.nodes.push(node('vars', [], ['speed'], 508, 0));
graph.edges.push(edge('vars', 'speed', 'move', 'scale'));
// A stranger right under the group, where the new node would go.
const group = box(graph, 'group');
graph.nodes.push(node('stranger', [], [], 0, group.y + group.h + 8));
const result = placed(graph, new Set(['vars']), true);
expect(box(result, 'group')).toEqual(group);
});
it('puts a new node taking from one in a group next to it, not where it waited far off', () => {
const graph = walking();
graph.nodes.push(node('far', [], [], 2000, 400));
// Where the agent's new node waits: right of everything.
graph.nodes.push(node('after', ['exec'], [], 2260, 0));
graph.edges.push(edge('move', 'exec', 'after', 'exec'));
const result = placed(graph, new Set(['after']), true);
const move = at(result, 'move');
expect(at(result, 'after').x).toBe(move.x + move.w + COLUMN_GAP);
expect(centredIn(box(result, 'group'), at(result, 'after'))).toBe(true);
expect(centredIn(box(result, 'group'), at(result, 'far'))).toBe(false);
});
it('takes nodes in only while none of the new ones stands in a group already', () => {
const graph = walking();
graph.nodes.push(node('vars', [], ['speed'], 40, 60));
graph.edges.push(edge('vars', 'speed', 'move', 'scale'));
expect(adoptingGroups(graph, new Set(['vars']))).toEqual([]);
});
});
describe('arranging with groups and comments', () => {
it('fits a group round its nodes again after Arrange, with its comment going along', () => {
const graph = walking();
// In one row, as the agent leaves new nodes, and grouped like that.
graph.nodes.forEach((n, i) => Object.assign(n, { x: i * 260, y: 0 }));
graph.boxes[0] = { id: 'group', kind: 'group', ...groupBoxAround(graph.nodes)! };
graph.boxes[1] = { id: 'note', kind: 'note', ...noteBoxAbove(graph.nodes)! };
const result = placed(graph);
const group = box(result, 'group');
expect(group).toMatchObject(groupBoxAround(result.nodes)!);
expect(group.w).toBeLessThan(box(graph, 'group').w / 2);
const note = box(result, 'note');
// Just above the group, as far from it as before.
expect(group.y - (note.y + note.h)).toBe(
box(graph, 'group').y - (box(graph, 'note').y + box(graph, 'note').h),
);
expect(lintLayout(result).coveredNotes).toBe(0);
});
it('moves a group whose nodes all moved alike as it is, keeping its size', () => {
const graph: LayoutGraph = {
nodes: [node('a', [], ['exec'], 0, 0), node('b', ['exec'], [], 248, 0)],
edges: [edge('a', 'exec', 'b', 'exec')],
boxes: [{ id: 'roomy', kind: 'group', x: -100, y: -100, w: 700, h: 400 }],
};
const spots = new Map([
['a', { x: 50, y: 30 }],
['b', { x: 298, y: 30 }],
]);
expect(followedBoxes(graph, spots).get('roomy')).toEqual({ x: -50, y: -70, w: 700, h: 400 });
});
});
describe('room for nodes put between', () => {
/** A fresh instrument: Notes and Output close together, and the chain the agent puts between. */
function instrument(): LayoutGraph {
return {
nodes: [
node('notes', [], ['note'], 0, 0),
node('output', ['audio'], [], 248, 0),
node('beside', [], [], 400, 200),
node('chip', ['note'], ['audio'], 600, 0),
node('env', ['audio'], ['audio'], 860, 0),
node('delay', ['audio'], ['audio'], 1120, 0),
],
edges: [
edge('notes', 'note', 'chip', 'note'),
edge('chip', 'audio', 'env', 'audio'),
edge('env', 'audio', 'delay', 'audio'),
edge('delay', 'audio', 'output', 'audio'),
],
boxes: [{ id: 'about', kind: 'note', x: 248, y: -120, w: 220, h: 96 }],
};
}
it('pushes what they feed, and everything right of it, far enough right', () => {
const graph = instrument();
const result = placed(graph, new Set(['chip', 'env', 'delay']), true);
expect(lintLayout(result)).toMatchObject({ overlaps: 0, backwards: 0, crossings: 0 });
const notes = at(result, 'notes');
const delay = at(result, 'delay');
expect(at(result, 'chip').x).toBe(notes.x + notes.w + COLUMN_GAP);
expect(at(result, 'output').x).toBe(delay.x + delay.w + COLUMN_GAP);
expect([notes.x, notes.y]).toEqual([0, 0]);
// What stood right of the output moved by as much, and so did the comment on it.
const by = at(result, 'output').x - 248;
expect(at(result, 'beside')).toMatchObject({ x: 400 + by, y: 200 });
expect(box(result, 'about')).toMatchObject({ x: 248 + by, y: -120 });
});
it('pushes nothing where there is room between already', () => {
const graph = instrument();
at(graph, 'output').x = 1500;
const result = placed(graph, new Set(['chip', 'env', 'delay']), true);
expect(at(result, 'output').x).toBe(1500);
expect(at(result, 'beside').x).toBe(400);
expect(lintLayout(result)).toMatchObject({ overlaps: 0, backwards: 0 });
});
});
describe('freeNoteBox', () => {
const targets = [{ id: 'a', x: 0, y: 200, w: 176, h: 76 }];
const above = noteBoxAbove(targets)!;
const empty = { nodes: [], groups: [], notes: [] };
it('puts a comment above its nodes while that is free', () => {
expect(freeNoteBox(targets, empty)).toEqual(above);
});
it('puts it below them where a node stands above, and above again when below is taken too', () => {
const over = { x: 0, y: above.y + 10, w: 176, h: 40 };
const below = freeNoteBox(targets, { ...empty, nodes: [over] })!;
expect(below.y).toBe(200 + 76 + 40);
const under = { x: 0, y: below.y + 10, w: 176, h: 40 };
expect(freeNoteBox(targets, { ...empty, nodes: [over, under] })).toEqual(above);
});
it('may lie inside a group, but not over its title bar or across its edge', () => {
const roomy = { x: -50, y: above.y - 50, w: 400, h: 500 };
expect(freeNoteBox(targets, { ...empty, groups: [roomy] })).toEqual(above);
const titled = { ...roomy, y: above.y - 10 };
expect(freeNoteBox(targets, { ...empty, groups: [titled] })!.y).toBe(200 + 76 + 40);
});
});
describe('lintLayout', () => {
it('counts a comment a node lies on', () => {
const graph: LayoutGraph = {
nodes: [node('a', [], [], 0, 0), node('b', [], [], 0, 300)],
edges: [],
boxes: [
{ id: 'hidden', kind: 'note', x: 50, y: 20, w: 220, h: 96 },
{ id: 'clear', kind: 'note', x: 0, y: 150, w: 220, h: 96 },
{ id: 'group', kind: 'group', x: -20, y: -44, w: 216, h: 120 },
],
};
const lint = lintLayout(graph);
expect(lint.coveredNotes).toBe(1);
expect(lint.examples).toContainEqual({ kind: 'covered', ids: ['hidden', 'a'] });
});
});