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>
340 lines
14 KiB
TypeScript
340 lines
14 KiB
TypeScript
/**
|
||
* How the editor arranges a graph (#429): columns left to right, no overlaps, few crossings, what
|
||
* stays standing where it stood – and the counts the agent reads to see whether a graph reads well.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands';
|
||
import {
|
||
arrange,
|
||
COLUMN_GAP,
|
||
type LayoutEdge,
|
||
type LayoutGraph,
|
||
type LayoutNode,
|
||
lintLayout,
|
||
ROW_GAP,
|
||
} from '../../src/editor/graph/arrange';
|
||
import { layoutOf } from '../../src/editor/graph/graphLayout';
|
||
import { makeBehavior, makeProject } from '../helpers';
|
||
|
||
/** A node of `rows` port rows, `in`/`out` port keys from the top, 20 units a row under a 30-unit header. */
|
||
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,
|
||
exec = fromPort === 'exec',
|
||
): LayoutEdge {
|
||
edgeCount += 1;
|
||
return { id: `e${edgeCount}`, from: { node: from, port: fromPort }, to: { node: to, port: toPort }, exec };
|
||
}
|
||
|
||
function placed(graph: LayoutGraph, movable?: ReadonlySet<string>): LayoutGraph {
|
||
const spots = arrange(graph, movable);
|
||
return { ...graph, nodes: graph.nodes.map((n) => ({ ...n, ...(spots.get(n.id) ?? {}) })) };
|
||
}
|
||
|
||
/**
|
||
* What the agent built for "walk with the arrow keys": four keys, four moves, in one row as the
|
||
* agent left them – with one owner for all of them, or one owner next to each move.
|
||
*/
|
||
function arrowKeys(owners: 'one' | 'each' = 'one'): LayoutGraph {
|
||
const keys = ['left', 'right', 'up', 'down'];
|
||
const owner = (k: string) => (owners === 'one' ? 'owner' : `owner-${k}`);
|
||
return {
|
||
nodes: [
|
||
...keys.map((k, i) => node(`key-${k}`, [], ['exec', 'dt'], i * 260)),
|
||
...keys.map((k, i) => node(`move-${k}`, ['exec', 'object', 'dx', 'dy', 'dt'], ['exec'], (i + 4) * 260)),
|
||
...[...new Set(keys.map(owner))].map((id, i) => node(id, [], ['object'], (i + 8) * 260)),
|
||
],
|
||
edges: keys.flatMap((k) => [
|
||
edge(`key-${k}`, 'exec', `move-${k}`, 'exec'),
|
||
edge(`key-${k}`, 'dt', `move-${k}`, 'dt'),
|
||
edge(owner(k), 'object', `move-${k}`, 'object'),
|
||
]),
|
||
boxes: [],
|
||
};
|
||
}
|
||
|
||
describe('arrange', () => {
|
||
it('lines a chain up left to right, the flow running straight', () => {
|
||
const graph = placed({
|
||
nodes: [
|
||
node('c', ['exec'], ['exec'], 0, 300),
|
||
node('a', [], ['exec'], 500, 0),
|
||
node('b', ['exec'], ['exec'], 90, 90),
|
||
],
|
||
edges: [edge('a', 'exec', 'b', 'exec'), edge('b', 'exec', 'c', 'exec')],
|
||
boxes: [],
|
||
});
|
||
const at = Object.fromEntries(graph.nodes.map((n) => [n.id, n]));
|
||
expect(at.b!.x).toBe(at.a!.x + 176 + COLUMN_GAP);
|
||
expect(at.c!.x).toBe(at.b!.x + 176 + COLUMN_GAP);
|
||
// The exec ports sit level: a = 0 rows of inputs, b and c one, so the boxes line up as well.
|
||
expect(at.a!.y + at.a!.outputs.exec!).toBe(at.b!.y + at.b!.inputs.exec!);
|
||
expect(at.b!.y + at.b!.outputs.exec!).toBe(at.c!.y + at.c!.inputs.exec!);
|
||
// The whole keeps the top-left corner the nodes had.
|
||
expect(Math.min(...graph.nodes.map((n) => n.x))).toBe(0);
|
||
expect(Math.min(...graph.nodes.map((n) => n.y))).toBe(0);
|
||
});
|
||
|
||
it('turns the agent’s row of nine into a graph without overlaps, links over nodes or backward links', () => {
|
||
const row = arrowKeys();
|
||
const before = lintLayout(row);
|
||
const after = lintLayout(placed(row));
|
||
expect(before.throughNodes).toBeGreaterThan(10);
|
||
expect(after).toMatchObject({ overlaps: 0, throughNodes: 0, backwards: 0 });
|
||
// The owner's link crosses one link of each move's own key, and the links of the key in between
|
||
// on its way to the top and the bottom move: 4 + 2 + 2.
|
||
expect(after.crossings).toBeLessThanOrEqual(8);
|
||
expect(after.longest[0]!.length).toBeLessThan(before.longest[0]!.length / 3);
|
||
});
|
||
|
||
it('crosses less and keeps the links short when each move has an owner of its own', () => {
|
||
const lint = lintLayout(placed(arrowKeys('each')));
|
||
expect(lint).toMatchObject({ overlaps: 0, throughNodes: 0, backwards: 0 });
|
||
// The owner feeds the second row of a move, between the key's flow and its dt: one crossing each.
|
||
expect(lint.crossings).toBe(4);
|
||
expect(lint.longest[0]!.length).toBeLessThan(150);
|
||
});
|
||
|
||
it('runs the flow straight from a key into its move', () => {
|
||
const graph = placed(arrowKeys('each'));
|
||
const at = Object.fromEntries(graph.nodes.map((n) => [n.id, n]));
|
||
for (const k of ['left', 'right', 'up', 'down']) {
|
||
const key = at[`key-${k}`]!;
|
||
const move = at[`move-${k}`]!;
|
||
expect(Math.abs(key.y + key.outputs.exec! - (move.y + move.inputs.exec!))).toBeLessThanOrEqual(12);
|
||
}
|
||
});
|
||
|
||
it('puts the owner just left of the moves it feeds, among the keys, not on top of the column', () => {
|
||
const graph = placed(arrowKeys());
|
||
const owner = graph.nodes.find((n) => n.id === 'owner')!;
|
||
const keys = graph.nodes.filter((n) => n.id.startsWith('key-'));
|
||
const moves = graph.nodes.filter((n) => n.id.startsWith('move-'));
|
||
expect(owner.x).toBe(keys[0]!.x);
|
||
expect(moves.every((m) => m.x > owner.x)).toBe(true);
|
||
const above = keys.filter((k) => k.y < owner.y).length;
|
||
expect(above).toBeGreaterThan(0);
|
||
expect(above).toBeLessThan(4);
|
||
});
|
||
|
||
it('leaves the nodes that are not to move, and keeps clear of them and of groups', () => {
|
||
const graph: LayoutGraph = {
|
||
nodes: [
|
||
node('kept', [], ['exec'], 0, 0),
|
||
node('new-a', ['exec'], ['exec'], 0, 10),
|
||
node('new-b', ['exec'], [], 30, 20),
|
||
],
|
||
edges: [edge('kept', 'exec', 'new-a', 'exec'), edge('new-a', 'exec', 'new-b', 'exec')],
|
||
boxes: [{ id: 'group', kind: 'group', x: 200, y: -40, w: 300, h: 200 }],
|
||
};
|
||
const result = placed(graph, new Set(['new-a', 'new-b']));
|
||
const kept = result.nodes.find((n) => n.id === 'kept')!;
|
||
expect([kept.x, kept.y]).toEqual([0, 0]);
|
||
expect(lintLayout(result).overlaps).toBe(0);
|
||
const box = graph.boxes[0]!;
|
||
for (const n of result.nodes.filter((n) => n.id !== 'kept')) {
|
||
const clear = n.x >= box.x + box.w || n.x + n.w <= box.x || n.y >= box.y + box.h || n.y + n.h <= box.y;
|
||
expect(clear).toBe(true);
|
||
}
|
||
// Right of the node they hang on, a column away, out of the group's way above or below it.
|
||
const newA = result.nodes.find((n) => n.id === 'new-a')!;
|
||
expect(newA.x).toBe(kept.x + kept.w + COLUMN_GAP);
|
||
expect(lintLayout(result).backwards).toBe(0);
|
||
});
|
||
|
||
/** The walking graph of #429: an update and the arrow-key axis moving the owner. */
|
||
function walking(): LayoutGraph {
|
||
return {
|
||
nodes: [
|
||
node('owner', [], ['object'], 0, 0),
|
||
node('update', [], ['exec', 'dt'], 0, 80),
|
||
node('axis', [], ['x', 'y', 'active'], 0, 180),
|
||
node('move', ['exec', 'object', 'dx', 'dy', 'scale', 'dt'], ['exec'], 248, 67),
|
||
],
|
||
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: [],
|
||
};
|
||
}
|
||
|
||
it('puts a new node handing a value to one that stays left of it, where its link crosses least', () => {
|
||
// The live case of #429: a Variables node added to the walking graph, feeding the Move's scale.
|
||
// Above the Owner is nearer, with the axis this tall, but its link would cross the flow, the
|
||
// object and dx and dy there.
|
||
const graph = walking();
|
||
graph.nodes[2] = node('axis', [], ['x', 'y', 'active', 'a', 'b', 'c', 'd', 'e'], 0, 180);
|
||
graph.nodes.push(node('vars', [], ['speed'], 508, 0));
|
||
graph.edges.push(edge('vars', 'speed', 'move', 'scale'));
|
||
const result = placed(graph, new Set(['vars']));
|
||
const vars = result.nodes.find((n) => n.id === 'vars')!;
|
||
const axis = result.nodes.find((n) => n.id === 'axis')!;
|
||
expect(vars.x + vars.w + COLUMN_GAP).toBe(248);
|
||
const lint = lintLayout(result);
|
||
expect(lint).toMatchObject({ overlaps: 0, backwards: 0, crossings: 4 });
|
||
// Under the axis, as near the Move's scale as the column leaves room.
|
||
expect(vars.y).toBeGreaterThanOrEqual(axis.y + axis.h + ROW_GAP);
|
||
expect(vars.y).toBeLessThan(axis.y + axis.h + 2 * ROW_GAP);
|
||
});
|
||
|
||
it('lines the new node’s port up with the one it feeds where there is room', () => {
|
||
const graph: LayoutGraph = {
|
||
nodes: [
|
||
node('move', ['exec', 'object', 'dx', 'dy', 'scale'], ['exec'], 248, 0),
|
||
node('vars', [], ['speed'], 508, 0),
|
||
],
|
||
edges: [edge('vars', 'speed', 'move', 'scale')],
|
||
boxes: [],
|
||
};
|
||
const result = placed(graph, new Set(['vars']));
|
||
const [move, vars] = result.nodes;
|
||
expect(vars!.y + vars!.outputs.speed!).toBe(move!.y + move!.inputs.scale!);
|
||
});
|
||
|
||
it('puts the node giving the flow on top when two orders cross as often', () => {
|
||
// The Owner stood first; giving it the top or the update crosses three links either way.
|
||
const result = placed(walking());
|
||
const ids = result.nodes
|
||
.filter((n) => n.id !== 'move')
|
||
.sort((a, b) => a.y - b.y)
|
||
.map((n) => n.id);
|
||
expect(ids[0]).toBe('update');
|
||
expect(lintLayout(result).crossings).toBe(3);
|
||
});
|
||
|
||
it('arranges nodes inside the group that holds them where they are, rather than outside it', () => {
|
||
const graph: LayoutGraph = {
|
||
nodes: [node('a', [], ['exec'], 220, 0), node('b', ['exec'], [], 30, 20)],
|
||
edges: [edge('a', 'exec', 'b', 'exec')],
|
||
boxes: [{ id: 'group', kind: 'group', x: 0, y: -40, w: 700, h: 300 }],
|
||
};
|
||
const result = placed(graph);
|
||
expect(Math.min(...result.nodes.map((n) => n.x))).toBe(30);
|
||
});
|
||
|
||
it('survives a loop and a link to itself, and stacks parts no link joins', () => {
|
||
const graph = placed({
|
||
nodes: [
|
||
node('a', ['exec'], ['exec']),
|
||
node('b', ['exec'], ['exec']),
|
||
node('c', ['exec'], ['exec']),
|
||
node('lone', [], ['exec']),
|
||
node('other', ['exec'], []),
|
||
],
|
||
edges: [
|
||
edge('a', 'exec', 'b', 'exec'),
|
||
edge('b', 'exec', 'c', 'exec'),
|
||
edge('c', 'exec', 'a', 'exec'),
|
||
edge('b', 'exec', 'b', 'exec'),
|
||
edge('lone', 'exec', 'other', 'exec'),
|
||
],
|
||
boxes: [],
|
||
});
|
||
expect(lintLayout(graph).overlaps).toBe(0);
|
||
const xs = new Set(['a', 'b', 'c'].map((id) => graph.nodes.find((n) => n.id === id)!.x));
|
||
expect(xs.size).toBe(3);
|
||
});
|
||
|
||
it('gives the same answer every time', () => {
|
||
expect([...arrange(arrowKeys())]).toEqual([...arrange(arrowKeys())]);
|
||
});
|
||
});
|
||
|
||
describe('lintLayout', () => {
|
||
it('counts overlaps, crossings, links over nodes and links running backwards', () => {
|
||
const graph: LayoutGraph = {
|
||
nodes: [
|
||
node('a', [], ['exec', 'v'], 0, 0),
|
||
node('b', ['exec', 'v'], [], 400, 0),
|
||
node('mid', [], [], 200, 30),
|
||
node('on-mid', [], [], 210, 40),
|
||
node('back', ['exec'], [], -400, 200),
|
||
],
|
||
edges: [
|
||
edge('a', 'exec', 'b', 'v'),
|
||
edge('a', 'v', 'b', 'exec', false),
|
||
edge('a', 'exec', 'back', 'exec'),
|
||
],
|
||
boxes: [],
|
||
};
|
||
const lint = lintLayout(graph);
|
||
expect(lint.overlaps).toBe(1);
|
||
// The two links from a to b swap rows, and the one to `back` loops across the lower of them;
|
||
// it shares its start port with the upper one, which does not count.
|
||
expect(lint.crossings).toBe(2);
|
||
expect(lint.throughNodes).toBeGreaterThanOrEqual(2);
|
||
expect(lint.backwards).toBe(1);
|
||
expect(lint.examples.find((e) => e.kind === 'overlap')?.ids).toEqual(['mid', 'on-mid']);
|
||
});
|
||
|
||
it('counts two links that swap rows in a perfect mirror, meeting on a point of both curves', () => {
|
||
// A key and an owner side by side feeding one move, as the agent's arrange left them live:
|
||
// dt runs down from the key's second row, the owner's object up into the move's second row.
|
||
const graph: LayoutGraph = {
|
||
nodes: [
|
||
node('key', [], ['exec', 'dt'], 0, 0),
|
||
node('owner', [], ['object'], 0, 80),
|
||
node('move', ['exec', 'object', 'dx', 'dy', 'dt'], [], 248, 0),
|
||
],
|
||
edges: [
|
||
edge('key', 'exec', 'move', 'exec'),
|
||
edge('key', 'dt', 'move', 'dt'),
|
||
edge('owner', 'object', 'move', 'object'),
|
||
],
|
||
boxes: [],
|
||
};
|
||
const at = Object.fromEntries(graph.nodes.map((n) => [n.id, n]));
|
||
// The mirror: each link starts where the other ends, row for row.
|
||
expect(at.key!.outputs.dt).toBe(at.move!.inputs.object);
|
||
expect(at.owner!.y + at.owner!.outputs.object!).toBe(at.move!.inputs.dt);
|
||
expect(lintLayout(graph).crossings).toBe(1);
|
||
});
|
||
});
|
||
|
||
describe('layoutOf', () => {
|
||
it('reads a behavior’s nodes with their port rows, and which links carry the flow', () => {
|
||
const fixture = makeProject();
|
||
const behavior = makeBehavior(fixture);
|
||
const key = new AddNodeCommand(behavior.id, 'OnKeyHeld', 0, 0, {});
|
||
const move = new AddNodeCommand(behavior.id, 'Move', 300, 0, {});
|
||
fixture.stack.execute(key);
|
||
fixture.stack.execute(move);
|
||
fixture.stack.execute(
|
||
new ConnectCommand(
|
||
behavior.id,
|
||
{ node: key.node.id, port: 'exec' },
|
||
{ node: move.node.id, port: 'exec' },
|
||
),
|
||
);
|
||
fixture.stack.execute(
|
||
new ConnectCommand(behavior.id, { node: key.node.id, port: 'dt' }, { node: move.node.id, port: 'dt' }),
|
||
);
|
||
const layout = layoutOf(fixture.project, behavior.id)!;
|
||
expect(layout.edges.map((e) => e.exec)).toEqual([true, false]);
|
||
const [keyNode, moveNode] = layout.nodes;
|
||
expect(keyNode!.outputs.dt! - keyNode!.outputs.exec!).toBe(20);
|
||
expect(moveNode!.h).toBeGreaterThan(keyNode!.h);
|
||
expect(layoutOf(fixture.project, 'nope')).toBeNull();
|
||
});
|
||
});
|