Files
feuerwerk/tests/editor/graph-arrange.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

340 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();
});
});