Files
feuerwerk/tests/editor/agent-copy.test.ts
T
adminandClaude Opus 5.5 fb981b5b0f Let the agent copy nodes, also into another graph (#434)
The agent built every part again that already stood in a graph,
node by node, while the editor has Ctrl+C and Ctrl+V for it. The new
command `copy_nodes` in the area graphs copies nodes into the same
graph or another one, in one undo step with the rest of the call.

- The links between the copied nodes come along, and so do groups
  and comments that are only about copied nodes. A node belongs to a
  group by its middle, as when it is dragged. `boxesAbout` in
  annotationFollow.ts decides this.
- `made` names each copy by the id of its original, so later steps
  link to it with `$copy.<id>`. `$copy` alone is the first one.
- Without x and y the copies are arranged at the end of the call like
  new nodes. With them they keep their layout, top left corner there.
- Nodes the target graph does not take are refused by name, not left
  out as Ctrl+V does. The rule is the graph tab's: `fitsGraph` and
  `fittingPart` moved from clipboard.ts to paletteEntries.ts.
- A copied Variables node takes the variables of its behavior along
  into the target behavior (`CarryVariablesCommand`), with default,
  value, Keep and Private. A variable of that name of another kind
  there is refused.
- The relay's instructions name `copy_nodes`.

Found on the way:
- Groups made by the agent can take in a node of another group once
  the nodes are arranged. lint_graph does not count that.
- Undo after a paste leaves an empty `groups: []`, as
  AddAnnotationCommand does. It is the same as no list.

Tests:
- agent-copy (new, 3 tests) with the agent, relay, clipboard and
  palette tests: 118/118.
- Counter-probes: 6 bendings, all compile, all red.
- e2e 71 (atx): ALL PASSED, 26 checks.
- Gate (atx): CHECK EXIT 0, 327 files, 5024 tests.
- Live in the MCP Test Project: 5 nodes of Arrow keys walk into a new
  behavior, 6 links, group, comment and speed (300) along, lint_graph
  0 everywhere, taken back with undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:39:37 +02:00

186 lines
8.6 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.
import { describe, expect, it } from 'vitest';
import { isKeptVariable } from '../../src/core/model/Asset';
import { findAssetOf, findGraphAsset, type Project } from '../../src/core/model/Project';
import { runTool, type ToolHost } from '../../src/editor/agent/agentTools';
import { makeSpriteScene, type SceneFixture } from '../helpers';
import { hostFor, text } from './agentTestHost';
/** Copying nodes through the agent's tools (#434). */
/**
* Behavior Walk, placed by hand so no group takes in a node not its own: a key held moving its owner, a Variables node reading `speed` (kept), a group round
* key and move, a comment above the Variables node, and a group round the owner alone.
*/
async function walkAndRun(fixture: SceneFixture, host: ToolHost) {
const steps = [
{ command: 'create_asset', args: { type: 'behavior', name: 'Walk' }, as: 'walk' },
{ command: 'create_asset', args: { type: 'behavior', name: 'Run' }, as: 'run' },
{ command: 'add_variable', args: { asset: '$walk', key: 'speed', kind: 'number' } },
{ command: 'set_variable_kept', args: { asset: '$walk', key: 'speed', kept: true } },
{ command: 'add_node', args: { asset: '$walk', node: 'OnKeyHeld', x: 0, y: 0 }, as: 'key' },
{
command: 'add_node',
args: { asset: '$walk', node: 'Move', params: { dx: -200 }, x: 300, y: 0 },
as: 'move',
},
{ command: 'add_node', args: { asset: '$walk', node: 'Owner', x: 0, y: 400 }, as: 'owner' },
{ command: 'add_node', args: { asset: '$walk', node: 'Variables', x: 0, y: 700 }, as: 'vars' },
{ command: 'add_port', args: { asset: '$walk', node: '$vars', key: 'speed' } },
{
command: 'connect',
args: { asset: '$walk', from: { node: '$key', port: 'exec' }, to: { node: '$move', port: 'exec' } },
},
{
command: 'connect',
args: { asset: '$walk', from: { node: '$key', port: 'dt' }, to: { node: '$move', port: 'dt' } },
},
{
command: 'connect',
args: {
asset: '$walk',
from: { node: '$owner', port: 'object' },
to: { node: '$move', port: 'object' },
},
},
{ command: 'add_group', args: { asset: '$walk', nodes: ['$key', '$move'], title: 'Walk' } },
{ command: 'add_group', args: { asset: '$walk', nodes: ['$owner'], title: 'Who' } },
{ command: 'add_comment', args: { asset: '$walk', nodes: ['$vars'], text: 'How fast' } },
];
const result = await runTool('run_commands', { steps, label: 'walk' }, host);
expect(result.isError, text(result)).toBeFalsy();
const walk = findAssetOf(fixture.project, assetNamed(fixture, 'Walk'), 'behavior')!;
const run = findAssetOf(fixture.project, assetNamed(fixture, 'Run'), 'behavior')!;
const id = (type: string) => walk.graph.nodes.find((node) => node.type === type)!.id;
return { walk, run, key: id('OnKeyHeld'), move: id('Move'), owner: id('Owner'), vars: id('Variables') };
}
/** The project as JSON, an empty list of groups or comments as none – undo leaves one, as for Add group. */
function plain(project: Project): string {
return JSON.stringify(project, (key, value: unknown) =>
(key === 'groups' || key === 'notes') && Array.isArray(value) && value.length === 0 ? undefined : value,
);
}
function assetNamed(fixture: SceneFixture, name: string): string {
return fixture.project.assets.find((asset) => asset.name === name)!.id;
}
describe('copy_nodes', () => {
it('copies nodes into another behavior with their links, groups, comments and variables', async () => {
const fixture = makeSpriteScene('Hero');
const { host } = hostFor(fixture);
const { walk, run, key, move, vars } = await walkAndRun(fixture, host);
const before = plain(fixture.project);
const steps = [
{ command: 'copy_nodes', args: { from: walk.id, nodes: [key, move, vars], to: run.id }, as: 'copy' },
{ command: 'add_node', args: { asset: run.id, node: 'Owner' }, as: 'owner' },
{
command: 'connect',
args: {
asset: run.id,
from: { node: '$owner', port: 'object' },
to: { node: `$copy.${move}`, port: 'object' },
},
},
];
const result = await runTool('run_commands', { steps, label: 'copy' }, host);
expect(result.isError, text(result)).toBeFalsy();
const graph = findGraphAsset(fixture.project, run.id)!.graph;
expect(graph.nodes.map((node) => node.type)).toEqual(['OnKeyHeld', 'Move', 'Variables', 'Owner']);
const [copiedKey, copiedMove, copiedVars] = graph.nodes;
expect(copiedMove!.params.dx).toBe(-200);
expect(copiedVars!.ports).toEqual([{ key: 'speed', kind: 'number' }]);
// The link from the Owner left behind stays behind; the one drawn after the copy is there.
expect(graph.edges.map((e) => `${e.from.node}.${e.from.port}>${e.to.node}.${e.to.port}`)).toEqual([
`${copiedKey!.id}.exec>${copiedMove!.id}.exec`,
`${copiedKey!.id}.dt>${copiedMove!.id}.dt`,
`${graph.nodes[3]!.id}.object>${copiedMove!.id}.object`,
]);
expect((graph.groups ?? []).map((group) => group.text)).toEqual(['Walk']);
expect((graph.notes ?? []).map((note) => note.text)).toEqual(['How fast']);
const { step, ...made } = (result.structuredContent as { made: Record<string, unknown>[] }).made[0]!;
expect(step).toBe(1);
expect(made).toMatchObject({
id: copiedKey!.id,
[key]: copiedKey!.id,
[move]: copiedMove!.id,
[vars]: copiedVars!.id,
});
// The first node's copy, three copies by their originals, the group and the comment.
expect(Object.keys(made)).toHaveLength(6);
const runNow = findAssetOf(fixture.project, run.id, 'behavior')!;
expect(runNow.variables.map((v) => [v.key, v.kind])).toEqual([['speed', 'number']]);
expect(isKeptVariable(runNow, 'speed')).toBe(true);
// The originals are untouched.
expect(walk.graph.nodes).toHaveLength(4);
await runTool('undo', {}, host);
expect(plain(fixture.project)).toBe(before);
});
it('keeps the layout of a copy placed by hand, its top left where it is told', async () => {
const fixture = makeSpriteScene('Hero');
const { host } = hostFor(fixture);
const { walk, key, move } = await walkAndRun(fixture, host);
const [from, to] = [key, move].map((nodeId) => walk.graph.nodes.find((n) => n.id === nodeId)!);
const steps = [{ command: 'copy_nodes', args: { from: walk.id, nodes: [move, key], x: 2000, y: 900 } }];
const result = await runTool('run_commands', { steps, label: 'copy' }, host);
expect(result.isError, text(result)).toBeFalsy();
const [copiedKey, copiedMove] = walk.graph.nodes.slice(4);
const group = walk.graph.groups!.find((g) => g.text === 'Walk' && g.x >= 2000)!;
expect(Math.min(copiedKey!.x, group.x)).toBe(2000);
expect(Math.min(copiedKey!.y, group.y)).toBe(900);
expect(copiedMove!.x - copiedKey!.x).toBe(to!.x - from!.x);
expect(copiedMove!.y - copiedKey!.y).toBe(to!.y - from!.y);
// The comment is about the Variables node left behind, the second group about the Owner.
expect(walk.graph.notes).toHaveLength(1);
expect(walk.graph.groups).toHaveLength(3);
// The copy of the first node given answers to "$copy" alone.
const made = (result.structuredContent as { made: Record<string, string>[] }).made[0]!;
expect(made.id).toBe(copiedMove!.id);
});
it('refuses nodes the other graph cannot hold, and a variable of another kind there', async () => {
const fixture = makeSpriteScene('Hero');
const { host } = hostFor(fixture);
const { walk, run, key, vars } = await walkAndRun(fixture, host);
const made = await runTool(
'run_commands',
{
steps: [
{ command: 'create_asset', args: { type: 'shader', name: 'Glow' } },
{ command: 'add_variable', args: { asset: run.id, key: 'speed', kind: 'text' } },
],
label: 'more',
},
host,
);
expect(made.isError, text(made)).toBeFalsy();
const before = JSON.stringify(fixture.project);
const shader = assetNamed(fixture, 'Glow');
const intoShader = await runTool(
'run_commands',
{ steps: [{ command: 'copy_nodes', args: { from: walk.id, nodes: [key], to: shader } }], label: 'x' },
host,
);
expect(intoShader.isError).toBe(true);
expect(text(intoShader)).toContain(`Glow cannot hold`);
expect(text(intoShader)).toContain(`(${key}) – leave them out`);
const clash = await runTool(
'run_commands',
{ steps: [{ command: 'copy_nodes', args: { from: walk.id, nodes: [vars], to: run.id } }], label: 'x' },
host,
);
expect(clash.isError).toBe(true);
expect(text(clash)).toContain('"speed" is a number in Walk but a text in Run');
expect(JSON.stringify(fixture.project)).toBe(before);
});
});