A live test of the music areas built a pattern, a song and a composition that stayed silent: no instrument at first, and later notes outside the C4 to B4 range of a new Notes node. The agent got no hint of either. The same test showed that new nodes left groups behind, that a comment could end up under other nodes, and that an end node like Output stayed left of the devices put in front of it. - get_asset lists under silent what of a pattern, song or composition is not heard. That is a missing instrument, an instrument that sounds no note, or notes outside what its Notes nodes take in, in a song after the transpose. - soundingNotes moves from the pattern tab to src/core/instrument/compile.ts. The tab and the agent use the same rule. - When new nodes go between two that have no room, everything from the fed node on moves right together. This holds for Arrange with a selection too, so no link runs backwards. - A new node linked to a node in a group joins that group, and the group grows in the same undo step. A group that would take in a foreign node stays as it is. - Arrange takes groups and comments along: a group whose nodes all moved the same keeps its size, otherwise it is fitted anew. The rules live in the new src/editor/graph/annotationFollow.ts. - add_comment and fit_group put a comment below its nodes when a node or a group title is in the way above. lint_graph counts covered comments as coveredNotes. - add_notes, get_asset, lint_graph and the relay instructions name the default range and how to mend a covered comment. Tests: - graph-follow (new, 12), agent-edit, agent-music (11) and the other agent, graph, pattern and relay tests green - 10 deliberate breakages, each one red in its own test - e2e 71: 25/25 - Gate: CHECK EXIT 0, 324 files, 5002 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
545 lines
23 KiB
TypeScript
545 lines
23 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('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',
|
||
'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');
|
||
});
|
||
});
|