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>
251 lines
11 KiB
TypeScript
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'] });
|
|
});
|
|
});
|