An agent that built a graph through MCP left every new node in one row. In the first real test that put nine nodes on a line 2080 px long, with the Owner's links running straight through all the others. Placing nodes by hand cost nine move_node calls and guessed heights. The agent now gives no coordinates, and the editor lays the nodes out. - arrange.ts is a pure function. It puts the flow into columns from left to right and sets an Owner or Get right before the first node it feeds. It orders each column for the fewest crossings and lines nodes up by their linked ports without overlaps. - Node sizes come from the drawn NodeView in the browser and from an estimate in unit tests. Both share the constants in nodeBox.ts. - run_commands arranges the nodes that came without x and y when the call ends, inside the same undo step. Nodes that were there stay. - New command arrange_graph and new tool lint_graph. The lint counts overlaps, crossings, links through foreign nodes, backward links and the longest links, with example ids. - The relay instructions tell the agent not to give coordinates and to fetch far values with a second Get or Owner node. - An Arrange button next to the snap magnet in all eight graph tabs. It arranges the selected nodes, or all of them, as one undo step. Found while testing on a real project: - lint_graph missed a crossing where two links swap rows exactly mirrored. Both curves then share a sample point in the middle, and only a crossing between two sample points was counted. Links that meet on a point count now (segmentsMeet). - The Arrow-key graph with one Owner for four Moves cannot get below 8 crossings. The Owner feeds object in row 2 of Move, between exec and dt of the key, so it always crosses one of them. Tests: - graph-arrange, agent-edit, snap-toggle: 33/33. The swapped-rows test was red before the fix (0 instead of 1). - Four counter-checks that bend arrange or the edit plan, all red. - e2e 71 on melektaus-atx: 18/18. Lint after the build shows 0 overlaps, 0 links through nodes, 0 backward. Arrange takes a tangled graph from 3 backward links and 2 crossings to 0, and one Ctrl+Z restores it. - Gate on melektaus-atx: CHECK EXIT 0, 318 files, 4917 tests, measured before the lint fix. - Live through MCP on the user's project: lint_graph reports 8 crossings after Arrange, matching the screenshot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
460 lines
19 KiB
TypeScript
460 lines
19 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { type Command, CommandError } from '../../src/core/commands/Command';
|
||
import { findGraphAsset } from '../../src/core/model/Project';
|
||
import { argProblem } from '../../src/editor/agent/argCheck';
|
||
import { runTool, type ToolHost, type ToolResult } from '../../src/editor/agent/agentTools';
|
||
import type { AgentView } from '../../src/editor/agent/agentView';
|
||
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';
|
||
|
||
/** What an agent changes and reads through the tools (#429, stage 2). */
|
||
|
||
function hostFor(fixture: SceneFixture, deleteAnswer: string | null = null) {
|
||
const asked: string[][] = [];
|
||
const host: ToolHost = {
|
||
view: () => ({ play: 'editing' }) as AgentView,
|
||
screenshot: () => Promise.resolve({ data: '', width: 0, height: 0 }),
|
||
project: () => fixture.project,
|
||
execute: (command: Command) => {
|
||
try {
|
||
fixture.stack.execute(command);
|
||
return null;
|
||
} catch (error) {
|
||
if (error instanceof CommandError) return error.message;
|
||
throw error;
|
||
}
|
||
},
|
||
confirmDeletes: (ids) => {
|
||
asked.push([...ids]);
|
||
return Promise.resolve(deleteAnswer);
|
||
},
|
||
open: () => null,
|
||
select: () => null,
|
||
play: () => null,
|
||
undo: () => {
|
||
const label = fixture.stack.undoLabel;
|
||
if (label !== null) fixture.stack.undo();
|
||
return label;
|
||
},
|
||
redo: () => {
|
||
const label = fixture.stack.redoLabel;
|
||
if (label !== null) fixture.stack.redo();
|
||
return label;
|
||
},
|
||
};
|
||
return { host, asked };
|
||
}
|
||
|
||
const text = (result: ToolResult) =>
|
||
result.content.map((item) => (item.type === 'text' ? item.text : '')).join('\n');
|
||
|
||
/** 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('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('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 fixture = makeSpriteScene();
|
||
const listed = (await runTool('catalog', { topic: 'commands' }, hostFor(fixture).host))
|
||
.structuredContent as {
|
||
commands: { name: string }[];
|
||
};
|
||
expect(listed.commands.map((command) => command.name)).toEqual(names);
|
||
});
|
||
|
||
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',
|
||
'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');
|
||
});
|
||
});
|