Files
feuerwerk/tests/editor/graph-arrange.test.ts
T
adminandClaude Opus 5.5 0d8fd6ff52
feuerwerk / gate (push) Successful in 5m10s
feuerwerk / suite (push) Failing after 18m49s
Let the agent use the graph, scene and project commands (#429)
The agent knew 23 of the editor's commands. It could not point a
Variables node at a variable, group nodes, edit ports, transitions,
containers, colliders or the project settings. Stage 5a brings the
catalog to 85 commands in five areas and keeps every answer short.

- The catalog lists names and descriptions only. With a query (an
  area or command names) it adds the arguments. All schemas at once
  would pass the 60 000 character limit of one answer.
- add_port points a Variables node at a variable. The port takes the
  variable's kind, and an unknown name is refused with the list of
  the ones that exist.
- add_group and add_comment take nodes instead of coordinates. They
  follow the nodes after the arrange step, in the same undo step.
- The rule for which list a port picks from moved into portChoices.ts.
  Inspector and agent now share it.
- The frame of a group around nodes moved into annotationDrag.ts, so
  the button and the agent get the same frame.
- list_assets returns the project settings. get_asset shows the
  content of placed containers with the ids overrides need. get_graph
  shows declared ports and frame colors.
- A new node is placed at the height of its port, in the free spot
  whose links cross the fewest others. A source goes left of the node
  it feeds. Before, new nodes always went right of the rest.
- Arrange breaks a tie in crossings by putting the flow on top, and
  exec links pull harder when a column is sorted.
- A comment over a group keeps clear of the group's title bar.
- The agent's instructions and lint_graph ask to look at crossings
  too and to call arrange_graph when there are more than needed.

Left out on purpose: file assets (no upload), paste from the
clipboard, internal wrapper commands and switching plugins on.

Found on the way:
- Variable commands on a Struct asset did not run through
  WithStructTypes. New fields did not reach variables of that type.
- A query "variables" also matched set_variables_scope. A word that
  names an area now means only that area.

Tests:
- agent-catalog (new, 10), agent-edit, graph-arrange (3 new) and
  relay green. 11 bends of the code, all red.
- e2e 71 on atx: 22/22, the new check sees port speed and a group
  around all 4 drawn nodes.
- Live on a test project: a speed variable of 300 moved the player
  to x 150.03 in 0.5 s. A re-placed Variables node landed below Axis
  input with 4 crossings instead of 7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:01:15 +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', 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', 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();
});
});