Files
feuerwerk/tests/editor/agent-catalog.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

216 lines
11 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { findAnnotation, VARIABLES } from '../../src/core/graph';
import { findAsset, findGraphAsset } from '../../src/core/model/Project';
import { runTool } from '../../src/editor/agent/agentTools';
import { normalizeBox } from '../../src/core/commands/annotationCommands';
import { groupBoxAround, noteBoxAbove } from '../../src/editor/graph/annotationDrag';
import { lintLayout } from '../../src/editor/graph/arrange';
import { layoutOf } from '../../src/editor/graph/graphLayout';
import { makeBehavior, makeSpriteScene, type SceneFixture } from '../helpers';
import { hostFor, text } from './agentTestHost';
/** The commands of #429 stage 5a: what an agent needs past nodes and edges to build a game. */
const run = (fixture: SceneFixture, steps: object[]) =>
runTool('run_commands', { steps }, hostFor(fixture).host);
const graphOf = (fixture: SceneFixture, assetId: string) => findGraphAsset(fixture.project, assetId)!.graph;
describe('variables in a graph', () => {
it('a Variables node reads a variable once add_port names it, in the kind of the variable', async () => {
const fixture = makeSpriteScene();
const behavior = makeBehavior(fixture, 'Walk', ['speed']);
const result = await run(fixture, [
{ command: 'add_node', args: { asset: behavior.id, node: 'Variables' }, as: 'vars' },
{ command: 'add_port', args: { asset: behavior.id, node: '$vars', key: 'speed' } },
{ command: 'set_port_write', args: { asset: behavior.id, node: '$vars', key: 'speed', write: true } },
]);
expect(result.isError).toBeUndefined();
const node = graphOf(fixture, behavior.id).nodes.find((n) => n.type === VARIABLES)!;
expect(node.ports).toEqual([{ key: 'speed', kind: 'number', write: true }]);
const read = (await runTool('get_graph', { asset: behavior.id }, hostFor(fixture).host))
.structuredContent as { nodes: { ports?: unknown; outputs: { key: string }[] }[] };
expect(read.nodes[0]!.ports).toEqual([{ key: 'speed', kind: 'number', write: true }]);
expect(read.nodes[0]!.outputs.map((port) => port.key)).toContain('speed');
});
it('refuses a port naming no variable, and lists the ones there are', async () => {
const fixture = makeSpriteScene();
const behavior = makeBehavior(fixture, 'Walk', ['speed', 'jump']);
const result = await run(fixture, [
{ command: 'add_node', args: { asset: behavior.id, node: 'Variables' }, as: 'vars' },
{ command: 'add_port', args: { asset: behavior.id, node: '$vars', key: 'sped' } },
]);
expect(result.isError).toBe(true);
expect(text(result)).toContain(
'Step 2 (add_port): "sped" is none of what this node can name: speed, jump',
);
expect(graphOf(fixture, behavior.id).nodes).toHaveLength(0);
});
it('a port named freely needs a kind', async () => {
const fixture = makeSpriteScene();
const behavior = makeBehavior(fixture, 'Point');
const steps = (kind?: string) => [
{ command: 'add_node', args: { asset: behavior.id, node: 'PackStruct' }, as: 'pack' },
{
command: 'add_port',
args: { asset: behavior.id, node: '$pack', key: 'x', ...(kind ? { kind } : {}) },
},
];
expect(text(await run(fixture, steps()))).toContain('Step 2 (add_port): Give the port a kind');
expect((await run(fixture, steps('number'))).isError).toBeUndefined();
expect(graphOf(fixture, behavior.id).nodes[0]!.ports).toEqual([{ key: 'x', kind: 'number' }]);
});
});
describe('groups and comments', () => {
it('a group made in the call that adds its nodes fits them where the call arranged them', async () => {
const fixture = makeSpriteScene();
const behavior = makeBehavior(fixture, 'Walk');
const link = (from: string, port: string, to: string, into = port) => ({
command: 'connect',
args: { asset: behavior.id, from: { node: from, port }, to: { node: to, port: into } },
});
// An Owner waits right of the others until arranging puts it before the Move: the group has to follow.
const result = await run(fixture, [
{ command: 'add_node', args: { asset: behavior.id, node: 'OnKeyHeld' }, as: 'key' },
{ command: 'add_node', args: { asset: behavior.id, node: 'Move' }, as: 'move' },
{ command: 'add_node', args: { asset: behavior.id, node: 'Owner' }, as: 'owner' },
link('$key', 'exec', '$move'),
link('$owner', 'object', '$move'),
{
command: 'add_group',
args: { asset: behavior.id, nodes: ['$key', '$move', '$owner'], title: 'Walk' },
},
{ command: 'add_comment', args: { asset: behavior.id, nodes: ['$key'], text: 'Held key moves it.' } },
]);
expect(result.isError).toBeUndefined();
const graph = graphOf(fixture, behavior.id);
const layout = layoutOf(fixture.project, behavior.id)!;
const owner = graph.nodes.find((node) => node.type === 'Owner')!;
const move = graph.nodes.find((node) => node.type === 'Move')!;
expect(owner.x).toBeLessThan(move.x);
expect(graph.groups!.map(({ x, y, w, h, text: title }) => ({ x, y, w, h, title }))).toEqual([
{ ...normalizeBox(groupBoxAround(layout.nodes)!), title: 'Walk' },
]);
const key = layout.nodes.find((node) => node.id === graph.nodes[0]!.id)!;
const { x, y, w, h } = graph.notes![0]!;
expect({ x, y, w, h }).toEqual(normalizeBox(noteBoxAbove([key])!));
expect(lintLayout(layout).overlaps).toBe(0);
await runTool('undo', {}, hostFor(fixture).host);
expect(graphOf(fixture, behavior.id).groups ?? []).toHaveLength(0);
expect(graphOf(fixture, behavior.id).nodes).toHaveLength(0);
});
it('edit_annotation changes title, color and box; remove_annotation leaves the nodes', async () => {
const fixture = makeSpriteScene();
const behavior = makeBehavior(fixture, 'Walk');
await run(fixture, [
{ command: 'add_node', args: { asset: behavior.id, node: 'Move' }, as: 'move' },
{ command: 'add_group', args: { asset: behavior.id, nodes: ['$move'], title: 'A', color: '#ff8800' } },
]);
const id = graphOf(fixture, behavior.id).groups![0]!.id;
expect(graphOf(fixture, behavior.id).groups![0]!.color).toBe(0xff8800);
await run(fixture, [
{ command: 'edit_annotation', args: { asset: behavior.id, id, text: 'B', color: '', w: 400 } },
]);
const group = findAnnotation(graphOf(fixture, behavior.id), id)!.annotation;
expect([group.text, group.color, group.w]).toEqual(['B', undefined, 400]);
await run(fixture, [{ command: 'remove_annotation', args: { asset: behavior.id, id } }]);
expect(graphOf(fixture, behavior.id).groups).toEqual([]);
expect(graphOf(fixture, behavior.id).nodes).toHaveLength(1);
});
});
describe('struct types', () => {
it('a field added to a Struct asset reaches the variables of that type', async () => {
const fixture = makeSpriteScene();
const behavior = makeBehavior(fixture, 'Walk');
const result = await run(fixture, [
{ command: 'create_asset', args: { type: 'struct', name: 'Point' }, as: 'point' },
{ command: 'add_variable', args: { asset: '$point', key: 'x', kind: 'number' } },
{ command: 'add_variable', args: { asset: behavior.id, key: 'at', kind: 'struct' } },
{ command: 'set_struct_type', args: { asset: behavior.id, key: 'at', type: '$point' } },
{ command: 'add_variable', args: { asset: '$point', key: 'y', kind: 'number' } },
]);
expect(result.isError).toBeUndefined();
const at = findAsset(fixture.project, behavior.id)!;
const def = 'variables' in at ? at.variables.find((v) => v.key === 'at') : undefined;
expect(def && 'fields' in def ? def.fields.map((field) => field.key) : []).toEqual(['x', 'y']);
});
it('make_struct_type hands later steps the Struct asset it made', async () => {
const fixture = makeSpriteScene();
const behavior = makeBehavior(fixture, 'Walk');
const result = await run(fixture, [
{ command: 'add_variable', args: { asset: behavior.id, key: 'at', kind: 'struct' } },
{ command: 'add_struct_field', args: { asset: behavior.id, key: 'at', field: 'x', kind: 'number' } },
{ command: 'make_struct_type', args: { asset: behavior.id, key: 'at' }, as: 'type' },
{ command: 'rename_asset', args: { asset: '$type', name: 'Spot' } },
]);
expect(result.isError).toBeUndefined();
expect(fixture.project.assets.find((asset) => asset.type === 'struct')?.name).toBe('Spot');
});
});
describe('objects', () => {
it('a container made of objects lists its content by the ids an override takes', async () => {
const fixture = makeSpriteScene();
const made = await run(fixture, [
{ command: 'place_asset', args: { in: fixture.sceneId, asset: fixture.spriteId, x: 0, y: 0 }, as: 'a' },
{
command: 'place_asset',
args: { in: fixture.sceneId, asset: fixture.spriteId, x: 64, y: 0 },
as: 'b',
},
{ command: 'add_collider', args: { in: fixture.sceneId, object: '$a' } },
{ command: 'create_container', args: { in: fixture.sceneId, objects: ['$a', '$b'] } },
]);
expect(made.isError).toBeUndefined();
const read = (await runTool('get_asset', { asset: fixture.sceneId }, hostFor(fixture).host))
.structuredContent as { objects: { id: string; fromContainer?: true }[] };
const content = read.objects.filter((object) => object.fromContainer);
// Both sprites, and the collider under the first.
expect(content).toHaveLength(3);
expect(content.every((object) => object.id.includes('~'))).toBe(true);
const set = await run(fixture, [
{
command: 'set_override',
args: { in: fixture.sceneId, object: content[0]!.id, key: 'width', value: 10 },
},
]);
expect(set.isError).toBeUndefined();
expect(fixture.object(content[0]!.id).overrides.width).toBe(10);
});
it('groups top-level objects and names the group it made', async () => {
const fixture = makeSpriteScene();
const result = await run(fixture, [
{ command: 'place_asset', args: { in: fixture.sceneId, asset: fixture.spriteId, x: 0, y: 0 }, as: 'a' },
{ command: 'group_objects', args: { in: fixture.sceneId, objects: ['$a'], name: 'Enemies' }, as: 'g' },
{ command: 'rename_object_group', args: { in: fixture.sceneId, group: '$g', name: 'Foes' } },
]);
expect(result.isError).toBeUndefined();
expect(fixture.sceneOf().groups?.map((group) => group.name)).toEqual(['Foes']);
});
});
describe('the project', () => {
it('list_assets gives the settings that the project commands change', async () => {
const fixture = makeSpriteScene();
await run(fixture, [
{ command: 'set_layer_names', args: { list: 'collisionLayers', names: ['Default', 'Player'] } },
{ command: 'set_play_from', args: { from: 'active' } },
]);
const listed = (await runTool('list_assets', {}, hostFor(fixture).host)).structuredContent as {
project: { collisionLayers: string[]; playFrom: string };
};
expect(listed.project).toMatchObject({ collisionLayers: ['Default', 'Player'], playFrom: 'active' });
});
});