Files
adminandClaude Opus 5.5 e4ccf5f72f
feuerwerk / gate (push) Successful in 5m18s
feuerwerk / suite (push) Successful in 19m7s
Keep other nodes out of a group the agent makes round new nodes
A group made in an agent call is fitted round its nodes only after
the call has arranged them, and the arrangement knew nothing of it.
With On key held, Move and Owner placed fresh, a group round key and
move and one round the owner, the Owner landed beside the key. Move
is taller than the key, so the Owner stood in the first group and the
two groups lay on each other. lint_graph counted none of it. Found
while testing copy_nodes (#434).

- arrange takes the new option blocks: sets of arranged nodes a group
  is fitted round afterwards. Once the columns are laid out, whatever
  would stand in such a group goes below it, a node or the group of
  another set, with everything arranged under it.
- The flow keeps its columns. A first try laid each set out as one
  block instead, but then the link from the Owner ran over the key
  inside the group (throughNodes 1).
- AgentEdit passes every group made in the call whose nodes are all
  new. Groups round nodes already there and the Arrange button stay
  as they were.

Tests:
- agent-edit: new test with the setup that showed the fault. No node
  stands in a group not its own, the groups lie apart, lint_graph has
  0 overlaps, throughNodes, backwards and coveredNotes.
- Counter-check: without the new step exactly that test is red.
- Agent, graph-arrange and graph-follow tests 120/120.
- e2e 71 on atx: ALL PASSED, 26 checks.
- Gate on atx: CHECK EXIT 0, 327 files, 5025 tests.

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

187 lines
8.6 KiB
TypeScript
Raw Permalink 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: 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);
});
});