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>
581 lines
25 KiB
TypeScript
581 lines
25 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { findGraphAsset } from '../../src/core/model/Project';
|
||
import { argProblem } from '../../src/editor/agent/argCheck';
|
||
import { runTool } from '../../src/editor/agent/agentTools';
|
||
import { COMMANDS } from '../../src/editor/agent/commandCatalog';
|
||
import { NODE_STEP } from '../../src/editor/agent/editPlan';
|
||
import { COLUMN_GAP, type GraphLint } from '../../src/editor/graph/arrange';
|
||
import { NODE_WIDTH } from '../../src/editor/graph/nodeBox';
|
||
import TOOLS from '../../src/editor/agent/tools.json';
|
||
import { makeBehavior, makeSpriteScene, type SceneFixture } from '../helpers';
|
||
import { hostFor, text } from './agentTestHost';
|
||
|
||
/** What an agent changes and reads through the tools (#429, stage 2). */
|
||
|
||
/** An object that walks left while the left arrow is held: behavior, placement, three nodes, three edges. */
|
||
function walkLeft(fixture: SceneFixture) {
|
||
return [
|
||
{ command: 'create_asset', args: { type: 'behavior', name: 'Walk' }, as: 'walk' },
|
||
{
|
||
command: 'place_asset',
|
||
args: { in: fixture.sceneId, asset: fixture.spriteId, x: 100, y: 50 },
|
||
as: 'player',
|
||
},
|
||
{
|
||
command: 'place_asset',
|
||
args: { in: fixture.sceneId, asset: '$walk', x: 100, y: 50, parent: '$player' },
|
||
},
|
||
{
|
||
command: 'add_node',
|
||
args: { asset: '$walk', node: 'OnKeyHeld', params: { key: 'ArrowLeft' } },
|
||
as: 'key',
|
||
},
|
||
{ command: 'add_node', args: { asset: '$walk', node: 'Move', params: { dx: -200 } }, as: 'move' },
|
||
{ command: 'add_node', args: { asset: '$walk', node: 'Owner' }, as: 'owner' },
|
||
{
|
||
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' },
|
||
},
|
||
},
|
||
];
|
||
}
|
||
|
||
describe('run_commands', () => {
|
||
it('builds an object with a behavior in one call, as one undo step that takes all of it back', async () => {
|
||
const fixture = makeSpriteScene('Hero');
|
||
const { host } = hostFor(fixture);
|
||
const before = JSON.stringify(fixture.project);
|
||
const result = await runTool('run_commands', { steps: walkLeft(fixture), label: 'walk left' }, host);
|
||
expect(result.isError).toBeFalsy();
|
||
expect(fixture.stack.undoLabel).toBe('Agent: walk left');
|
||
|
||
const behavior = fixture.project.assets.find((asset) => asset.name === 'Walk')!;
|
||
const graph = findGraphAsset(fixture.project, behavior.id)!.graph;
|
||
expect(graph.nodes.map((node) => node.type)).toEqual(['OnKeyHeld', 'Move', 'Owner']);
|
||
expect(graph.nodes[0]!.params.key).toBe('ArrowLeft');
|
||
expect(graph.nodes[1]!.params.dx).toBe(-200);
|
||
expect(graph.edges.map((edge) => `${edge.from.port}>${edge.to.port}`)).toEqual([
|
||
'exec>exec',
|
||
'dt>dt',
|
||
'object>object',
|
||
]);
|
||
const scene = fixture.sceneOf();
|
||
const [player, walk] = scene.objects;
|
||
expect(walk).toMatchObject({ assetId: behavior.id, parentId: player!.id });
|
||
expect(text(result)).toContain(`Step 1 made {"id":"${behavior.id}"}`);
|
||
|
||
const after = JSON.stringify(fixture.project);
|
||
expect(text(await runTool('undo', {}, host))).toBe('Undone: Agent: walk left.');
|
||
expect(JSON.stringify(fixture.project)).toBe(before);
|
||
await runTool('redo', {}, host);
|
||
expect(JSON.stringify(fixture.project)).toBe(after);
|
||
});
|
||
|
||
it('arranges the nodes it gave no place right of those the graph has, which stay put', async () => {
|
||
const fixture = makeSpriteScene();
|
||
const behavior = makeBehavior(fixture, 'Jump');
|
||
const { host } = hostFor(fixture);
|
||
await runTool(
|
||
'run_commands',
|
||
{ steps: [{ command: 'add_node', args: { asset: behavior.id, node: 'OnStart', x: 40, y: 80 } }] },
|
||
host,
|
||
);
|
||
await runTool(
|
||
'run_commands',
|
||
{
|
||
steps: [
|
||
{ command: 'add_node', args: { asset: behavior.id, node: 'Owner' }, as: 'owner' },
|
||
{ command: 'add_node', args: { asset: behavior.id, node: 'Move' }, as: 'move' },
|
||
{
|
||
command: 'connect',
|
||
args: {
|
||
asset: behavior.id,
|
||
from: { node: '$owner', port: 'object' },
|
||
to: { node: '$move', port: 'object' },
|
||
},
|
||
},
|
||
],
|
||
},
|
||
host,
|
||
);
|
||
const [start, owner, move] = behavior.graph.nodes;
|
||
expect([start!.x, start!.y]).toEqual([40, 80]);
|
||
// The new part keeps the place it waited at, right of the start, and reads left to right.
|
||
expect(owner!.x).toBe(40 + NODE_STEP);
|
||
expect(move!.x).toBe(owner!.x + NODE_WIDTH + COLUMN_GAP);
|
||
expect(fixture.stack.undoLabel).toBe('Agent: add_node, add_node, connect');
|
||
const lint = JSON.parse(text(await runTool('lint_graph', { asset: behavior.id }, host))) as GraphLint;
|
||
expect(lint).toMatchObject({
|
||
nodes: 3,
|
||
edges: 1,
|
||
overlaps: 0,
|
||
crossings: 0,
|
||
throughNodes: 0,
|
||
backwards: 0,
|
||
});
|
||
});
|
||
|
||
it('leaves a node given a place where it was put, and arranges the rest around it', async () => {
|
||
const fixture = makeSpriteScene();
|
||
const behavior = makeBehavior(fixture, 'Jump');
|
||
const { host } = hostFor(fixture);
|
||
await runTool(
|
||
'run_commands',
|
||
{
|
||
steps: [
|
||
{ command: 'add_node', args: { asset: behavior.id, node: 'OnStart', x: 0, y: 0 }, as: 'start' },
|
||
{ command: 'add_node', args: { asset: behavior.id, node: 'Move' }, as: 'move' },
|
||
{
|
||
command: 'connect',
|
||
args: {
|
||
asset: behavior.id,
|
||
from: { node: '$start', port: 'exec' },
|
||
to: { node: '$move', port: 'exec' },
|
||
},
|
||
},
|
||
],
|
||
},
|
||
host,
|
||
);
|
||
const [start, move] = behavior.graph.nodes;
|
||
expect([start!.x, start!.y]).toEqual([0, 0]);
|
||
expect(move!.x).toBeGreaterThanOrEqual(NODE_WIDTH);
|
||
});
|
||
|
||
it('arrange_graph arranges what it is given as one step of the edit, and undo puts it back', async () => {
|
||
const fixture = makeSpriteScene();
|
||
const behavior = makeBehavior(fixture, 'Jump');
|
||
const { host } = hostFor(fixture);
|
||
const steps = [
|
||
{ command: 'add_node', args: { asset: behavior.id, node: 'OnStart', x: 500, y: 0 }, as: 'start' },
|
||
{ command: 'add_node', args: { asset: behavior.id, node: 'Move', x: 0, y: 0 }, as: 'move' },
|
||
{
|
||
command: 'connect',
|
||
args: {
|
||
asset: behavior.id,
|
||
from: { node: '$start', port: 'exec' },
|
||
to: { node: '$move', port: 'exec' },
|
||
},
|
||
},
|
||
];
|
||
await runTool('run_commands', { steps }, host);
|
||
const tangled = JSON.stringify(behavior.graph.nodes);
|
||
const before = JSON.parse(text(await runTool('lint_graph', { asset: behavior.id }, host))) as GraphLint;
|
||
expect(before.backwards).toBe(1);
|
||
|
||
const result = await runTool(
|
||
'run_commands',
|
||
{ steps: [{ command: 'arrange_graph', args: { asset: behavior.id } }], label: 'tidy' },
|
||
host,
|
||
);
|
||
expect(result.isError).toBeFalsy();
|
||
const [start, move] = behavior.graph.nodes;
|
||
expect(move!.x).toBe(start!.x + NODE_WIDTH + COLUMN_GAP);
|
||
const after = JSON.parse(text(await runTool('lint_graph', { asset: behavior.id }, host))) as GraphLint;
|
||
expect(after.backwards).toBe(0);
|
||
await runTool('undo', {}, host);
|
||
expect(JSON.stringify(behavior.graph.nodes)).toBe(tangled);
|
||
|
||
const unknown = await runTool(
|
||
'run_commands',
|
||
{ steps: [{ command: 'arrange_graph', args: { asset: behavior.id, nodes: ['nope'] } }] },
|
||
host,
|
||
);
|
||
expect(text(unknown)).toContain('Step 1 (arrange_graph): Jump has no node nope');
|
||
});
|
||
|
||
it('puts a new node linked into a group into it, the group growing in the same undo step', async () => {
|
||
const fixture = makeSpriteScene();
|
||
const behavior = makeBehavior(fixture, 'Jump');
|
||
const { host } = hostFor(fixture);
|
||
const link = (from: string, fromPort: string, to: string, toPort: string) => ({
|
||
command: 'connect',
|
||
args: { asset: behavior.id, from: { node: from, port: fromPort }, to: { node: to, port: toPort } },
|
||
});
|
||
await runTool(
|
||
'run_commands',
|
||
{
|
||
steps: [
|
||
{ command: 'add_node', args: { asset: behavior.id, node: 'OnStart' }, as: 'start' },
|
||
{ command: 'add_node', args: { asset: behavior.id, node: 'Move' }, as: 'move' },
|
||
link('$start', 'exec', '$move', 'exec'),
|
||
{ command: 'add_group', args: { asset: behavior.id, nodes: ['$start', '$move'], title: 'Jump' } },
|
||
],
|
||
},
|
||
host,
|
||
);
|
||
const [start, move] = behavior.graph.nodes;
|
||
const before = { ...behavior.graph.groups![0]! };
|
||
const result = await runTool(
|
||
'run_commands',
|
||
{
|
||
steps: [
|
||
{ command: 'add_node', args: { asset: behavior.id, node: 'Owner' }, as: 'owner' },
|
||
link('$owner', 'object', move!.id, 'object'),
|
||
],
|
||
},
|
||
host,
|
||
);
|
||
expect(result.isError).toBeFalsy();
|
||
const owner = behavior.graph.nodes[2]!;
|
||
const group = behavior.graph.groups![0]!;
|
||
expect(owner.x + NODE_WIDTH + COLUMN_GAP).toBe(move!.x);
|
||
expect(owner.x + NODE_WIDTH / 2).toBeGreaterThan(group.x);
|
||
expect(owner.y).toBeGreaterThan(group.y);
|
||
expect(owner.y).toBeLessThan(group.y + group.h);
|
||
// Under the start, where the group had room already.
|
||
expect(group.h).toBeGreaterThanOrEqual(before.h);
|
||
expect([start!.x, start!.y]).toEqual([behavior.graph.nodes[0]!.x, behavior.graph.nodes[0]!.y]);
|
||
await runTool('undo', {}, host);
|
||
expect(behavior.graph.groups![0]).toEqual(before);
|
||
});
|
||
|
||
it('gives a group made round new nodes room, what else would stand in it going below', async () => {
|
||
const fixture = makeSpriteScene('Hero');
|
||
const { host } = hostFor(fixture);
|
||
const steps = [
|
||
...walkLeft(fixture),
|
||
{ command: 'add_group', args: { asset: '$walk', nodes: ['$key', '$move'], title: 'Walk' } },
|
||
{ command: 'add_group', args: { asset: '$walk', nodes: ['$owner'], title: 'Who' } },
|
||
];
|
||
const result = await runTool('run_commands', { steps, label: 'walk' }, host);
|
||
expect(result.isError, text(result)).toBeFalsy();
|
||
|
||
const behavior = fixture.project.assets.find((asset) => asset.name === 'Walk')!;
|
||
const { graph } = findGraphAsset(fixture.project, behavior.id)!;
|
||
const [walk, who] = graph.groups!;
|
||
const centred = (box: { x: number; y: number; w: number; h: number }) =>
|
||
graph.nodes
|
||
.filter((node) => {
|
||
const cx = node.x + NODE_WIDTH / 2;
|
||
const cy = node.y + 20;
|
||
return cx >= box.x && cx <= box.x + box.w && cy >= box.y && cy <= box.y + box.h;
|
||
})
|
||
.map((node) => node.type);
|
||
// Move is taller than the key: before, the Owner beside the key came to stand in the Walk group.
|
||
expect(centred(walk!)).toEqual(['OnKeyHeld', 'Move']);
|
||
expect(centred(who!)).toEqual(['Owner']);
|
||
const apart =
|
||
who!.x + who!.w <= walk!.x ||
|
||
walk!.x + walk!.w <= who!.x ||
|
||
who!.y + who!.h <= walk!.y ||
|
||
walk!.y + walk!.h <= who!.y;
|
||
expect(apart).toBe(true);
|
||
const lint = JSON.parse(text(await runTool('lint_graph', { asset: behavior.id }, host))) as GraphLint;
|
||
expect(lint).toMatchObject({ overlaps: 0, throughNodes: 0, backwards: 0, coveredNotes: 0 });
|
||
});
|
||
|
||
it('changes nothing when a step fails, and names the step and why', async () => {
|
||
const fixture = makeSpriteScene();
|
||
const { host } = hostFor(fixture);
|
||
const before = JSON.stringify(fixture.project);
|
||
const steps = walkLeft(fixture);
|
||
steps[7] = {
|
||
command: 'connect',
|
||
args: { asset: '$walk', from: { node: '$key', port: 'dt' }, to: { node: '$move', port: 'exec' } },
|
||
};
|
||
const result = await runTool('run_commands', { steps }, host);
|
||
expect(result.isError).toBe(true);
|
||
expect(text(result)).toBe('Nothing was changed. Step 8 (connect): Cannot connect number to exec');
|
||
expect(JSON.stringify(fixture.project)).toBe(before);
|
||
expect(fixture.stack.canUndo).toBe(true); // the fixture's own two assets, nothing of the agent's
|
||
expect(fixture.stack.undoLabel).not.toMatch(/^Agent/);
|
||
});
|
||
|
||
it('checks every step’s arguments against its command, and refuses an unknown command or node', async () => {
|
||
const fixture = makeSpriteScene();
|
||
const behavior = makeBehavior(fixture);
|
||
const { host } = hostFor(fixture);
|
||
const one = async (step: object) => text(await runTool('run_commands', { steps: [step] }, host));
|
||
expect(await one({ command: 'rename_asset', args: { asset: fixture.spriteId } })).toBe(
|
||
'Nothing was changed. Step 1 (rename_asset): args.name is missing',
|
||
);
|
||
expect(await one({ command: 'create_asset', args: { type: 'texture', name: 'T' } })).toMatch(
|
||
/Step 1 \(create_asset\): args\.type has to be one of scene, /,
|
||
);
|
||
expect(await one({ command: 'teleport', args: {} })).toMatch(/Step 1 \(teleport\): no such command/);
|
||
expect(await one({ command: 'add_node', args: { asset: behavior.id, node: 'Fly' } })).toMatch(
|
||
/Step 1 \(add_node\): A behavior graph has no node "Fly" – look it up with catalog/,
|
||
);
|
||
expect(
|
||
await one({ command: 'rename_asset', args: { asset: fixture.spriteId, name: 'X', colour: 1 } }),
|
||
).toMatch(/args has no colour/);
|
||
expect(text(await runTool('run_commands', { steps: [] }, host))).toBe(
|
||
'arguments.steps needs at least 1 item(s)',
|
||
);
|
||
});
|
||
|
||
it('finds a node by its label too, and keeps a typed-in input value as given', async () => {
|
||
const fixture = makeSpriteScene();
|
||
const behavior = makeBehavior(fixture);
|
||
const { host } = hostFor(fixture);
|
||
await runTool(
|
||
'run_commands',
|
||
{ steps: [{ command: 'add_node', args: { asset: behavior.id, node: 'on key held' } }] },
|
||
host,
|
||
);
|
||
expect(behavior.graph.nodes.map((node) => node.type)).toEqual(['OnKeyHeld']);
|
||
});
|
||
|
||
it('asks the user before deleting an asset that was there, and does nothing on a no', async () => {
|
||
const fixture = makeSpriteScene();
|
||
const no = hostFor(fixture, 'The user said no; nothing was changed.');
|
||
const deleting = { steps: [{ command: 'delete_asset', args: { asset: fixture.spriteId } }] };
|
||
const refused = await runTool('run_commands', deleting, no.host);
|
||
expect(no.asked).toEqual([[fixture.spriteId]]);
|
||
expect(text(refused)).toBe('The user said no; nothing was changed.');
|
||
expect(fixture.project.assets.some((asset) => asset.id === fixture.spriteId)).toBe(true);
|
||
|
||
const yes = hostFor(fixture);
|
||
await runTool('run_commands', deleting, yes.host);
|
||
expect(fixture.project.assets.some((asset) => asset.id === fixture.spriteId)).toBe(false);
|
||
|
||
// What the agent made in the same call is its own to take away again.
|
||
const own = hostFor(fixture);
|
||
await runTool(
|
||
'run_commands',
|
||
{
|
||
steps: [
|
||
{ command: 'create_asset', args: { type: 'sprite', name: 'Tmp' }, as: 'tmp' },
|
||
{ command: 'delete_asset', args: { asset: '$tmp' } },
|
||
],
|
||
},
|
||
own.host,
|
||
);
|
||
expect(own.asked).toEqual([]);
|
||
});
|
||
|
||
it('refuses a name for a step that makes nothing, or a part a step did not make', async () => {
|
||
const fixture = makeSpriteScene();
|
||
const { host } = hostFor(fixture);
|
||
const rename = { command: 'rename_asset', args: { asset: fixture.spriteId, name: 'A' }, as: 'r' };
|
||
expect(text(await runTool('run_commands', { steps: [rename] }, host))).toMatch(
|
||
/Step 1 \(rename_asset\): makes nothing a later step could call "r"/,
|
||
);
|
||
const steps = [
|
||
{ command: 'create_asset', args: { type: 'sprite', name: 'S' }, as: 's' },
|
||
{ command: 'rename_asset', args: { asset: '$s.closing', name: 'T' } },
|
||
];
|
||
expect(text(await runTool('run_commands', { steps }, host))).toMatch(
|
||
/Step 2 \(rename_asset\): "\$s.closing": that step made no closing/,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('the editor following the agent', () => {
|
||
it('shows the graph a call built, with every node it touched selected and the removed ones left out', async () => {
|
||
const fixture = makeSpriteScene();
|
||
const { host, shown } = hostFor(fixture, null, true);
|
||
await runTool('run_commands', { steps: walkLeft(fixture) }, host);
|
||
const walk = fixture.project.assets.find((asset) => asset.name === 'Walk')!;
|
||
const nodes = findGraphAsset(fixture.project, walk.id)!.graph.nodes.map((node) => node.id);
|
||
expect(shown).toEqual([{ kind: 'nodes', asset: walk.id, ids: nodes }]);
|
||
|
||
const steps = [
|
||
{ command: 'add_node', args: { asset: walk.id, node: 'OnStart' }, as: 'start' },
|
||
{ command: 'set_node_param', args: { asset: walk.id, node: nodes[1], key: 'dx', value: -100 } },
|
||
{ command: 'remove_node', args: { asset: walk.id, node: '$start' } },
|
||
];
|
||
await runTool('run_commands', { steps }, host);
|
||
expect(shown[1]).toEqual({ kind: 'nodes', asset: walk.id, ids: [nodes[1]] });
|
||
});
|
||
|
||
it('switches to the scene of the objects a call ends on, inside the same undo step', async () => {
|
||
const fixture = makeSpriteScene();
|
||
const { host, shown } = hostFor(fixture, null, true);
|
||
const made = await runTool(
|
||
'run_commands',
|
||
{ steps: [{ command: 'create_asset', args: { type: 'scene', name: 'Cave' } }] },
|
||
host,
|
||
);
|
||
const cave = (made.structuredContent as { made: { id: string }[] }).made[0]!.id;
|
||
expect(shown).toEqual([{ kind: 'asset', asset: cave }]);
|
||
expect(fixture.project.activeSceneId).toBe(fixture.sceneId);
|
||
|
||
const place = {
|
||
command: 'place_asset',
|
||
args: { in: cave, asset: fixture.spriteId, x: 0, y: 0 },
|
||
as: 'bat',
|
||
};
|
||
const placed = await runTool('run_commands', { steps: [place], label: 'bat' }, host);
|
||
const bat = (placed.structuredContent as { made: { id: string }[] }).made[0]!.id;
|
||
expect(shown[1]).toEqual({ kind: 'objects', in: cave, ids: [bat] });
|
||
expect(fixture.project.activeSceneId).toBe(cave);
|
||
|
||
expect(text(await runTool('undo', {}, host))).toBe('Undone: Agent: bat.');
|
||
expect(fixture.project.activeSceneId).toBe(fixture.sceneId);
|
||
});
|
||
|
||
it('leaves the view and the scene alone when the user switched following off', async () => {
|
||
const fixture = makeSpriteScene();
|
||
const { host, shown } = hostFor(fixture);
|
||
const steps = [
|
||
{ command: 'create_asset', args: { type: 'scene', name: 'Cave' }, as: 'cave' },
|
||
{ command: 'place_asset', args: { in: '$cave', asset: fixture.spriteId, x: 0, y: 0 } },
|
||
];
|
||
expect((await runTool('run_commands', { steps }, host)).isError).toBeFalsy();
|
||
expect(shown).toEqual([]);
|
||
expect(fixture.project.activeSceneId).toBe(fixture.sceneId);
|
||
});
|
||
});
|
||
|
||
describe('reading', () => {
|
||
it('get_graph lists each node’s ports as they are, and the edges', async () => {
|
||
const fixture = makeSpriteScene();
|
||
const { host } = hostFor(fixture);
|
||
await runTool('run_commands', { steps: walkLeft(fixture) }, host);
|
||
const walk = fixture.project.assets.find((asset) => asset.name === 'Walk')!;
|
||
const graph = (await runTool('get_graph', { asset: walk.id }, host)).structuredContent as {
|
||
nodes: { type: string; inputs: { key: string; type: string }[] }[];
|
||
edges: unknown[];
|
||
};
|
||
expect(graph.edges).toHaveLength(3);
|
||
expect(graph.nodes.find((node) => node.type === 'Move')!.inputs.map((port) => port.key)).toEqual([
|
||
'exec',
|
||
'object',
|
||
'dx',
|
||
'dy',
|
||
'scale',
|
||
'dt',
|
||
]);
|
||
});
|
||
|
||
it('get_asset counts a graph instead of spelling it out, and names a scene’s objects', async () => {
|
||
const fixture = makeSpriteScene('Hero');
|
||
const { host } = hostFor(fixture);
|
||
await runTool('run_commands', { steps: walkLeft(fixture) }, host);
|
||
const walk = fixture.project.assets.find((asset) => asset.name === 'Walk')!;
|
||
const asset = (await runTool('get_asset', { asset: walk.id }, host)).structuredContent as Record<
|
||
string,
|
||
unknown
|
||
>;
|
||
expect(asset.graph).toEqual({ nodes: 3, edges: 3, read: 'get_graph' });
|
||
const scene = (await runTool('get_asset', { asset: fixture.sceneId }, host)).structuredContent as {
|
||
objects: { name: string }[];
|
||
};
|
||
expect(scene.objects.map((object) => object.name)).toEqual(['Hero', 'Walk']);
|
||
expect(text(await runTool('get_asset', { asset: 'nope' }, host))).toMatch(/There is no asset nope/);
|
||
});
|
||
|
||
it('list_assets filters by type and name', async () => {
|
||
const fixture = makeSpriteScene('Hero');
|
||
makeBehavior(fixture, 'Jump');
|
||
const { host } = hostFor(fixture);
|
||
const listed = (query: Record<string, unknown>) =>
|
||
runTool('list_assets', query, host).then(
|
||
(r) => (r.structuredContent as { assets: { name: string }[] }).assets,
|
||
);
|
||
expect((await listed({})).map((asset) => asset.name)).toEqual(['Level', 'Hero', 'Jump']);
|
||
expect((await listed({ type: 'behavior' })).map((asset) => asset.name)).toEqual(['Jump']);
|
||
expect((await listed({ query: 'he' })).map((asset) => asset.name)).toEqual(['Hero']);
|
||
});
|
||
|
||
it('the node catalog of a graph answers a search in full, and lists every node without one', async () => {
|
||
const fixture = makeSpriteScene();
|
||
const behavior = makeBehavior(fixture);
|
||
const { host } = hostFor(fixture);
|
||
const nodes = async (query?: string) =>
|
||
(
|
||
(await runTool('catalog', { topic: 'nodes', asset: behavior.id, ...(query ? { query } : {}) }, host))
|
||
.structuredContent as { nodes: { id: string; inputs?: unknown[]; settings?: { key: string }[] }[] }
|
||
).nodes;
|
||
const found = await nodes('key held');
|
||
expect(found[0]).toMatchObject({ id: 'OnKeyHeld', inputs: [] });
|
||
expect(found[0]!.settings!.map((setting) => setting.key)).toContain('key');
|
||
const all = await nodes();
|
||
expect(all.some((entry) => entry.id === 'Move')).toBe(true);
|
||
expect(all[0]!.inputs).toBeUndefined();
|
||
expect(text(await runTool('catalog', { topic: 'nodes' }, host))).toMatch(/Name the graph asset/);
|
||
});
|
||
});
|
||
|
||
describe('the catalog', () => {
|
||
it('has every command once, each with arguments the check understands', async () => {
|
||
const names = COMMANDS.map((command) => command.name);
|
||
expect(new Set(names).size).toBe(names.length);
|
||
for (const command of COMMANDS) {
|
||
expect(command.args).toMatchObject({ type: 'object', additionalProperties: false });
|
||
expect(argProblem(command.args, {})).toMatch(/is missing|^null$/);
|
||
}
|
||
const { host } = hostFor(makeSpriteScene());
|
||
const listed = (await runTool('catalog', { topic: 'commands' }, host)).structuredContent as {
|
||
commands: { areas: { area: string; commands: { name: string; args?: unknown }[] }[] };
|
||
};
|
||
const areas = listed.commands.areas;
|
||
expect(areas.flatMap((area) => area.commands.map((command) => command.name))).toEqual(names);
|
||
expect(areas.flatMap((area) => area.commands).some((command) => 'args' in command)).toBe(false);
|
||
// Each area in full fits one answer, which is cut beyond 60 000 characters.
|
||
for (const { area, commands } of areas) {
|
||
const full = await runTool('catalog', { topic: 'commands', query: area }, host);
|
||
expect(full.isError).toBeUndefined();
|
||
expect(text(full).length).toBeLessThan(60_000);
|
||
const rows = (full.structuredContent as { commands: { name: string; args: unknown }[] }).commands;
|
||
expect(rows.map((row) => row.name)).toEqual(commands.map((command) => command.name));
|
||
expect(rows.every((row) => row.args !== undefined)).toBe(true);
|
||
}
|
||
});
|
||
|
||
it('a query of command names answers just those in full, and an unknown one says so', async () => {
|
||
const { host } = hostFor(makeSpriteScene());
|
||
const found = await runTool('catalog', { topic: 'commands', query: 'add_port, add_group' }, host);
|
||
const rows = (found.structuredContent as { commands: { area: string; name: string }[] }).commands;
|
||
expect(rows.map((row) => `${row.area}/${row.name}`)).toEqual(['graphs/add_group', 'graphs/add_port']);
|
||
const none = await runTool('catalog', { topic: 'commands', query: 'teleport' }, host);
|
||
expect(none.isError).toBe(true);
|
||
expect(text(none)).toContain('No area or command matches "teleport"');
|
||
});
|
||
|
||
it('the tools are the ones the relay lists, each with a schema', () => {
|
||
expect(TOOLS.map((tool) => tool.name)).toEqual([
|
||
'get_view',
|
||
'screenshot',
|
||
'list_assets',
|
||
'list_files',
|
||
'get_asset',
|
||
'get_graph',
|
||
'lint_graph',
|
||
'catalog',
|
||
'run_commands',
|
||
'open',
|
||
'select',
|
||
'play',
|
||
'play_input',
|
||
'get_runtime',
|
||
'undo',
|
||
'redo',
|
||
]);
|
||
for (const tool of TOOLS)
|
||
expect(tool.inputSchema).toMatchObject({ type: 'object', additionalProperties: false });
|
||
});
|
||
});
|
||
|
||
describe('argProblem', () => {
|
||
it('names the place of the first problem', () => {
|
||
const schema = {
|
||
type: 'object' as const,
|
||
properties: {
|
||
at: { type: 'array' as const, items: { type: 'integer' as const, minimum: 0 } },
|
||
kind: { type: 'string' as const, enum: ['a', 'b'] },
|
||
},
|
||
required: ['kind'],
|
||
additionalProperties: false as const,
|
||
};
|
||
expect(argProblem(schema, { kind: 'a', at: [1, 2] })).toBeNull();
|
||
expect(argProblem(schema, { kind: 'c' })).toBe('arguments.kind has to be one of a, b');
|
||
expect(argProblem(schema, { kind: 'a', at: [1, -1] })).toBe('arguments.at[1] has to be 0 or more');
|
||
expect(argProblem(schema, { kind: 'a', at: [1.5] })).toBe('arguments.at[0] has to be a whole number');
|
||
expect(argProblem(schema, [])).toBe('arguments has to be an object');
|
||
});
|
||
});
|