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>
186 lines
8.6 KiB
TypeScript
186 lines
8.6 KiB
TypeScript
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);
|
||
});
|
||
});
|