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: 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[] }).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[] }).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); }); });