The Container tab reuses SceneViewport against any object holder, so a container template is edited exactly like a scene. Right-click no longer changes the selection, which previously made nesting impossible: opening the menu on a container loaded it, and "Place in container" then targeted itself. Template edits (move, rename, override) now reach already-placed instances per slot, through a shared templateSync used by both the apply path and the plain object commands. Copies the user has touched keep their own value. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
429 lines
20 KiB
TypeScript
429 lines
20 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { DeleteAssetCommand } from '../../src/core/commands/assetCommands';
|
|
import { CommandError } from '../../src/core/commands/Command';
|
|
import { CompoundCommand } from '../../src/core/commands/CompoundCommand';
|
|
import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
|
|
import {
|
|
MoveSceneObjectCommand,
|
|
PlaceAssetCommand,
|
|
RemoveSceneObjectCommand,
|
|
RemoveSceneObjectsCommand,
|
|
ClearOverrideCommand,
|
|
RenameSceneObjectCommand,
|
|
SetOverrideCommand,
|
|
SetParentCommand,
|
|
} from '../../src/core/commands/sceneObjectCommands';
|
|
import { worldPositionOf } from '../../src/core/graph';
|
|
import type { ContainerAsset } from '../../src/core/model/Asset';
|
|
import { containerReaches } from '../../src/core/model/container';
|
|
import { descendantsOf, subtreeRoots, treeOrder } from '../../src/core/model/hierarchy';
|
|
import { findAsset, findScene } from '../../src/core/model/Project';
|
|
import { createSceneObject } from '../../src/core/model/SceneObject';
|
|
import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
|
|
|
|
/** Three boxes: a at (100,100,z1), b at (10,0) and c at (0,5) parented under b (local (-10,5)). */
|
|
function setup() {
|
|
const f = makeSpriteScene();
|
|
const a = place(f, f.spriteId, { x: 100, y: 100, z: 1 });
|
|
const b = place(f, f.spriteId, { x: 10, y: 0 });
|
|
const c = place(f, f.spriteId, { x: 0, y: 5 });
|
|
f.stack.execute(new SetParentCommand(f.sceneId, c, b));
|
|
return { ...f, a, b, c };
|
|
}
|
|
|
|
/** The container asset built from a and b (c comes along below b), plus lookups into its template. */
|
|
function templateSetup() {
|
|
const f = setup();
|
|
const create = new CreateContainerCommand(f.sceneId, [f.a, f.b]);
|
|
f.stack.execute(create);
|
|
const asset = () => findAsset(f.project, create.assetId) as ContainerAsset;
|
|
return { ...f, containerId: create.assetId, asset, templateIdOf: (id: string) => f.object(id).templateId };
|
|
}
|
|
|
|
/** Three container assets holding one box each, none of them nested yet. */
|
|
function makeContainers() {
|
|
const f = makeSpriteScene();
|
|
const build = () => {
|
|
const command = new CreateContainerCommand(f.sceneId, [place(f, f.spriteId, { x: 0, y: 0 })]);
|
|
f.stack.execute(command);
|
|
return command.assetId;
|
|
};
|
|
const assetOf = (id: string) => findAsset(f.project, id) as ContainerAsset;
|
|
return { ...f, outerId: build(), innerId: build(), deepId: build(), assetOf };
|
|
}
|
|
|
|
describe('subtree roots', () => {
|
|
it('drops ids that have a selected ancestor', () => {
|
|
const { sceneOf, a, b, c } = setup();
|
|
expect(subtreeRoots(sceneOf(), [a, b, c])).toEqual([a, b]);
|
|
expect(subtreeRoots(sceneOf(), [c, a])).toEqual([c, a]);
|
|
});
|
|
});
|
|
|
|
describe('CreateContainerCommand', () => {
|
|
it('centres the template on the selection and re-parents the roots, keeping world positions', () => {
|
|
const { project, stack, sceneId, spriteId, a, b, c, sceneOf, object, world } = setup();
|
|
const before = { a: world(a), b: world(b), c: world(c) };
|
|
const command = new CreateContainerCommand(sceneId, [a, b]);
|
|
stack.execute(command);
|
|
|
|
// Bounding box of (100,100) and (10,0) → centre (55,50); z is always 0.
|
|
const container = object(command.objectId);
|
|
expect(container.assetId).toBe(command.assetId);
|
|
expect(container.parentId).toBe('');
|
|
expect(container.position).toEqual({ x: 55, y: 50, z: 0 });
|
|
expect(object(a).parentId).toBe(command.objectId);
|
|
expect(object(a).position).toEqual({ x: 45, y: 50, z: 1 });
|
|
expect(object(b).position).toEqual({ x: -45, y: -50, z: 0 });
|
|
expect(object(c).parentId).toBe(b); // descendants keep their local frame
|
|
expect(object(c).position).toEqual({ x: -10, y: 5, z: 0 });
|
|
expect({ a: world(a), b: world(b), c: world(c) }).toEqual(before);
|
|
|
|
// The asset holds the same structure with private ids; the members point at it.
|
|
const asset = findAsset(project, command.assetId) as ContainerAsset;
|
|
expect(asset.type).toBe('container');
|
|
expect(asset.objects).toHaveLength(3);
|
|
const template = (id: string) => asset.objects.find((t) => t.id === object(id).templateId)!;
|
|
expect(template(a).position).toEqual({ x: 45, y: 50, z: 1 });
|
|
expect(template(a).parentId).toBe('');
|
|
expect(template(c).parentId).toBe(template(b).id);
|
|
expect(template(c).assetId).toBe(spriteId);
|
|
expect(asset.objects.map((t) => t.id)).not.toContain(a);
|
|
expect(treeOrder(sceneOf()).map((e) => e.object.id)).toEqual([command.objectId, a, b, c]);
|
|
|
|
stack.undo();
|
|
expect(sceneOf().objects.map((o) => o.id)).toEqual([a, b, c]);
|
|
expect(object(a).parentId).toBe('');
|
|
expect(object(a).position).toEqual({ x: 100, y: 100, z: 1 });
|
|
expect(object(a).templateId).toBe('');
|
|
expect(project.assets.some((x) => x.id === command.assetId)).toBe(false);
|
|
stack.redo();
|
|
expect(object(a).parentId).toBe(command.objectId);
|
|
expect(world(a)).toEqual(before.a);
|
|
});
|
|
|
|
it('places the container below the parent all roots share', () => {
|
|
const f = setup();
|
|
const { stack, sceneId, spriteId, b, c, object, world } = f;
|
|
const d = place(f, spriteId, { x: 30, y: 25 }, b);
|
|
const command = new CreateContainerCommand(sceneId, [c, d]);
|
|
stack.execute(command);
|
|
const container = object(command.objectId);
|
|
expect(container.parentId).toBe(b);
|
|
// World centre of c (0,5) and d (30,25) is (15,15); b is at (10,0) → local (5,15).
|
|
expect(container.position).toEqual({ x: 5, y: 15, z: 0 });
|
|
expect(world(c)).toEqual({ x: 0, y: 5, z: 0 });
|
|
expect(world(d)).toEqual({ x: 30, y: 25, z: 0 });
|
|
});
|
|
|
|
it('rejects an empty or unknown selection', () => {
|
|
const { stack, sceneId } = setup();
|
|
expect(() => stack.execute(new CreateContainerCommand(sceneId, []))).toThrow(/at least one/);
|
|
expect(() => stack.execute(new CreateContainerCommand(sceneId, ['nope']))).toThrow(/not found/);
|
|
});
|
|
});
|
|
|
|
describe('placing a container', () => {
|
|
it('instantiates the template with fresh ids below the placed object', () => {
|
|
const f = setup();
|
|
const { project, stack, sceneId, a, b, c, sceneOf, object, world } = f;
|
|
const create = new CreateContainerCommand(sceneId, [a, b]);
|
|
stack.execute(create);
|
|
const placed = place(f, create.assetId, { x: 1000, y: 0 });
|
|
|
|
const copies = sceneOf().objects.filter((o) => ![a, b, c, create.objectId].includes(o.id));
|
|
expect(copies).toHaveLength(4); // container + 3 template objects
|
|
const root = object(placed);
|
|
expect(root.assetId).toBe(create.assetId);
|
|
const asset = findAsset(project, create.assetId) as ContainerAsset;
|
|
const copyOf = (id: string) => copies.find((o) => o.templateId === object(id).templateId)!;
|
|
expect(copyOf(a).parentId).toBe(placed);
|
|
expect(world(copyOf(a).id)).toEqual({ x: 1045, y: 50, z: 1 });
|
|
expect(copyOf(c).parentId).toBe(copyOf(b).id);
|
|
expect(world(copyOf(c).id)).toEqual({ x: 945, y: -45, z: 0 });
|
|
expect(new Set(copies.map((o) => o.id)).size).toBe(4);
|
|
expect(asset.objects.map((t) => t.id)).not.toContain(copyOf(a).id);
|
|
|
|
stack.undo();
|
|
expect(sceneOf().objects).toHaveLength(4);
|
|
expect(sceneOf().objects.some((o) => o.id === placed)).toBe(false);
|
|
});
|
|
|
|
it('survives a save/load round trip', () => {
|
|
const { project, stack, sceneId, a, b } = setup();
|
|
const create = new CreateContainerCommand(sceneId, [a, b]);
|
|
stack.execute(create);
|
|
const loaded = roundTrip(project);
|
|
const asset = findAsset(loaded, create.assetId) as ContainerAsset;
|
|
expect(asset.type).toBe('container');
|
|
expect(asset.objects).toHaveLength(3);
|
|
const scene = findScene(loaded, sceneId)!;
|
|
expect(scene.objects.find((o) => o.id === a)?.templateId).toBe(asset.objects[0]!.id);
|
|
expect(scene.objects.find((o) => o.id === create.objectId)?.assetId).toBe(create.assetId);
|
|
});
|
|
|
|
it('deleting an asset removes its template objects from containers too', () => {
|
|
const { project, stack, sceneId, spriteId, a, b, sceneOf } = setup();
|
|
const create = new CreateContainerCommand(sceneId, [a, b]);
|
|
stack.execute(create);
|
|
const asset = () => findAsset(project, create.assetId) as ContainerAsset;
|
|
stack.execute(new DeleteAssetCommand(spriteId));
|
|
expect(asset().objects).toHaveLength(0);
|
|
expect(sceneOf().objects.map((o) => o.id)).toEqual([create.objectId]);
|
|
stack.undo();
|
|
expect(asset().objects).toHaveLength(3);
|
|
expect(sceneOf().objects).toHaveLength(4);
|
|
});
|
|
|
|
it('spawns a container with its content at runtime', () => {
|
|
const f = setup();
|
|
const { stack, sceneId, a, b, sceneOf } = f;
|
|
const create = new CreateContainerCommand(sceneId, [a, b]);
|
|
stack.execute(create);
|
|
const { world } = startWorld(f);
|
|
const spawned = world.spawn(create.assetId, { x: 500, y: 500, z: 0 }, null)!;
|
|
expect(spawned.type).toBe('container');
|
|
const children = world.entities.filter((e) => e.parent === spawned);
|
|
expect(children).toHaveLength(2);
|
|
const grandchild = world.entities.find((e) => e.parent && children.includes(e.parent))!;
|
|
expect(worldPositionOf(children.find((e) => e.z === 1)!)).toEqual({ x: 545, y: 550, z: 1 });
|
|
expect(worldPositionOf(grandchild)).toEqual({ x: 445, y: 455, z: 0 });
|
|
expect(world.syncToModel()).toBe(true);
|
|
expect(sceneOf().objects).toHaveLength(8);
|
|
});
|
|
});
|
|
|
|
describe('editing a container template', () => {
|
|
it('moves, renames, overrides and removes template objects addressed by the container id', () => {
|
|
const { stack, containerId, a, asset, object, templateIdOf } = templateSetup();
|
|
const templateA = templateIdOf(a);
|
|
const template = () => asset().objects.find((t) => t.id === templateA);
|
|
|
|
stack.execute(new MoveSceneObjectCommand(containerId, templateA, { x: 7, y: 8, z: 9 }));
|
|
stack.execute(new RenameSceneObjectCommand(containerId, templateA, 'Hero'));
|
|
stack.execute(new SetOverrideCommand(containerId, templateA, 'width', 55));
|
|
expect(template()!.position).toEqual({ x: 7, y: 8, z: 9 });
|
|
expect(template()!.name).toBe('Hero');
|
|
expect(template()!.overrides.width).toBe(55);
|
|
// a is a copy of that template and held its old values, so it follows all three.
|
|
expect(object(a).position).toEqual({ x: 7, y: 8, z: 9 });
|
|
expect(object(a).name).toBe('Hero');
|
|
expect(object(a).overrides.width).toBe(55);
|
|
|
|
stack.execute(new RemoveSceneObjectCommand(containerId, templateA));
|
|
expect(template()).toBeUndefined();
|
|
expect(asset().objects).toHaveLength(2);
|
|
|
|
stack.undo();
|
|
expect(template()!.overrides.width).toBe(55);
|
|
stack.undo();
|
|
expect(template()!.overrides.width).toBeUndefined();
|
|
stack.undo();
|
|
expect(template()!.name).toBe('');
|
|
stack.undo();
|
|
expect(template()!.position).toEqual({ x: 45, y: 50, z: 1 });
|
|
});
|
|
|
|
it('restores the template order when a removal is undone', () => {
|
|
const { stack, containerId, a, b, c, asset, templateIdOf } = templateSetup();
|
|
const ids = [templateIdOf(a), templateIdOf(b), templateIdOf(c)];
|
|
expect(asset().objects.map((t) => t.id)).toEqual(ids);
|
|
|
|
stack.execute(new RemoveSceneObjectsCommand(containerId, [ids[1]!]));
|
|
expect(asset().objects.map((t) => t.id)).toEqual([ids[0]]);
|
|
stack.undo();
|
|
expect(asset().objects.map((t) => t.id)).toEqual(ids);
|
|
expect(asset().objects[2]!.parentId).toBe(ids[1]);
|
|
});
|
|
|
|
it('rejects an object id that belongs to the scene rather than to the template', () => {
|
|
const { stack, containerId, a } = templateSetup();
|
|
expect(() => stack.execute(new RenameSceneObjectCommand(containerId, a, 'Hero'))).toThrow(/not found/);
|
|
});
|
|
});
|
|
|
|
describe('a template edit reaching the placed copies', () => {
|
|
/** The container placed a second time, with a lookup from a template object to that fresh copy. */
|
|
function placedSetup() {
|
|
const f = templateSetup();
|
|
const before = new Set(f.sceneOf().objects.map((o) => o.id));
|
|
place(f, f.containerId, { x: 1000, y: 0 });
|
|
return {
|
|
...f,
|
|
copyOf: (id: string) =>
|
|
f.sceneOf().objects.find((o) => !before.has(o.id) && o.templateId === f.templateIdOf(id))!,
|
|
};
|
|
}
|
|
|
|
it('carries an override to the copies that agreed and leaves a modified one alone', () => {
|
|
const { stack, sceneId, containerId, a, object, templateIdOf, copyOf } = placedSetup();
|
|
const modified = copyOf(a).id;
|
|
stack.execute(new SetOverrideCommand(sceneId, modified, 'width', 12));
|
|
stack.execute(new SetOverrideCommand(containerId, templateIdOf(a), 'width', 55));
|
|
|
|
expect(object(a).overrides.width).toBe(55);
|
|
expect(object(modified).overrides.width).toBe(12);
|
|
|
|
stack.undo();
|
|
expect(object(a).overrides.width).toBeUndefined();
|
|
expect(object(modified).overrides.width).toBe(12);
|
|
});
|
|
|
|
it('clears an override on the copies that shared it', () => {
|
|
const { stack, containerId, a, object, templateIdOf } = placedSetup();
|
|
const templateA = templateIdOf(a);
|
|
stack.execute(new SetOverrideCommand(containerId, templateA, 'width', 55));
|
|
expect(object(a).overrides.width).toBe(55);
|
|
stack.execute(new ClearOverrideCommand(containerId, templateA, 'width'));
|
|
expect(object(a).overrides.width).toBeUndefined();
|
|
stack.undo();
|
|
expect(object(a).overrides.width).toBe(55);
|
|
});
|
|
|
|
it('follows per axis, so a copy dragged sideways keeps only its x', () => {
|
|
const { stack, sceneId, containerId, a, object, templateIdOf, copyOf } = placedSetup();
|
|
const moved = copyOf(a).id;
|
|
stack.execute(new MoveSceneObjectCommand(sceneId, moved, { x: 999, y: 50, z: 1 }));
|
|
stack.execute(new MoveSceneObjectCommand(containerId, templateIdOf(a), { x: 7, y: 8, z: 9 }));
|
|
|
|
expect(object(a).position).toEqual({ x: 7, y: 8, z: 9 });
|
|
expect(object(moved).position).toEqual({ x: 999, y: 8, z: 9 });
|
|
|
|
stack.undo();
|
|
expect(object(moved).position).toEqual({ x: 999, y: 50, z: 1 });
|
|
});
|
|
|
|
it('does not leak an edit made in the scene to the other copies', () => {
|
|
const { stack, sceneId, a, object, copyOf } = placedSetup();
|
|
const copy = copyOf(a).id;
|
|
stack.execute(new SetOverrideCommand(sceneId, copy, 'width', 12));
|
|
stack.execute(new RenameSceneObjectCommand(sceneId, copy, 'Only me'));
|
|
expect(object(a).overrides.width).toBeUndefined();
|
|
expect(object(a).name).toBe('');
|
|
});
|
|
|
|
it('reaches the content of a container nested in another one', () => {
|
|
const f = makeContainers();
|
|
const { stack, outerId, innerId, sceneOf, assetOf } = f;
|
|
stack.execute(new PlaceAssetCommand(outerId, innerId, { x: 20, y: 0 }));
|
|
const placed = place(f, outerId, { x: 500, y: 0 });
|
|
const innerTemplate = assetOf(innerId).objects[0]!;
|
|
const deepCopy = sceneOf().objects.find((o) => o.templateId === innerTemplate.id)!;
|
|
|
|
stack.execute(new RenameSceneObjectCommand(innerId, innerTemplate.id, 'Deep'));
|
|
expect(deepCopy.name).toBe('Deep');
|
|
expect(descendantsOf(sceneOf(), placed)).toHaveLength(3);
|
|
|
|
stack.undo();
|
|
expect(deepCopy.name).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('placing into a container template', () => {
|
|
it('creates a single object, without expanding a placed container', () => {
|
|
const { stack, outerId, innerId, assetOf } = makeContainers();
|
|
const command = new PlaceAssetCommand(outerId, innerId, { x: 20, y: 0 });
|
|
stack.execute(command);
|
|
|
|
const objects = assetOf(outerId).objects;
|
|
expect(objects).toHaveLength(2);
|
|
const placed = objects.find((o) => o.id === command.objectId)!;
|
|
expect(placed.assetId).toBe(innerId);
|
|
expect(placed.position).toEqual({ x: 20, y: 0, z: 0 });
|
|
expect(objects.some((o) => o.parentId === command.objectId)).toBe(false);
|
|
|
|
stack.undo();
|
|
expect(assetOf(outerId).objects).toHaveLength(1);
|
|
});
|
|
|
|
it('expands the same container when it goes into a scene instead', () => {
|
|
const f = makeContainers();
|
|
const before = f.sceneOf().objects.length;
|
|
const placed = place(f, f.innerId, { x: 20, y: 0 });
|
|
expect(f.sceneOf().objects).toHaveLength(before + 2);
|
|
expect(descendantsOf(f.sceneOf(), placed)).toHaveLength(1);
|
|
});
|
|
|
|
it('expands the nested container once the outer one is placed into a scene', () => {
|
|
const f = makeContainers();
|
|
const { stack, outerId, innerId, spriteId, sceneOf, world } = f;
|
|
const nest = new PlaceAssetCommand(outerId, innerId, { x: 20, y: 0 });
|
|
stack.execute(nest);
|
|
const placed = place(f, outerId, { x: 500, y: 0 });
|
|
|
|
const children = sceneOf().objects.filter((o) => o.parentId === placed);
|
|
expect(children.map((o) => o.assetId).sort()).toEqual([innerId, spriteId].sort());
|
|
const nested = children.find((o) => o.assetId === innerId)!;
|
|
expect(nested.templateId).toBe(nest.objectId);
|
|
expect(world(nested.id)).toEqual({ x: 520, y: 0, z: 0 });
|
|
|
|
const content = sceneOf().objects.filter((o) => o.parentId === nested.id);
|
|
expect(content).toHaveLength(1);
|
|
expect(content[0]!.assetId).toBe(spriteId);
|
|
expect(world(content[0]!.id)).toEqual({ x: 520, y: 0, z: 0 });
|
|
});
|
|
|
|
it('refuses a placement that would nest a container inside itself', () => {
|
|
const { stack, outerId, innerId, deepId, assetOf } = makeContainers();
|
|
expect(() => stack.execute(new PlaceAssetCommand(outerId, outerId, { x: 0, y: 0 }))).toThrow(
|
|
/nests forever/,
|
|
);
|
|
expect(assetOf(outerId).objects).toHaveLength(1);
|
|
|
|
stack.execute(new PlaceAssetCommand(outerId, innerId, { x: 0, y: 0 }));
|
|
expect(() => stack.execute(new PlaceAssetCommand(innerId, outerId, { x: 0, y: 0 }))).toThrow(
|
|
CommandError,
|
|
);
|
|
|
|
stack.execute(new PlaceAssetCommand(innerId, deepId, { x: 0, y: 0 }));
|
|
expect(() => stack.execute(new PlaceAssetCommand(deepId, outerId, { x: 0, y: 0 }))).toThrow(CommandError);
|
|
expect(assetOf(deepId).objects).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('containerReaches', () => {
|
|
it('sees the container itself and its content, transitively', () => {
|
|
const { project, stack, outerId, innerId, deepId, spriteId, assetOf } = makeContainers();
|
|
expect(containerReaches(project, assetOf(outerId), outerId)).toBe(true);
|
|
expect(containerReaches(project, assetOf(outerId), innerId)).toBe(false);
|
|
|
|
stack.execute(new PlaceAssetCommand(outerId, innerId, { x: 0, y: 0 }));
|
|
stack.execute(new PlaceAssetCommand(innerId, deepId, { x: 0, y: 0 }));
|
|
expect(containerReaches(project, assetOf(outerId), innerId)).toBe(true);
|
|
expect(containerReaches(project, assetOf(outerId), deepId)).toBe(true);
|
|
expect(containerReaches(project, assetOf(deepId), outerId)).toBe(false);
|
|
expect(containerReaches(project, assetOf(outerId), spriteId)).toBe(false);
|
|
});
|
|
|
|
it('terminates on a cycle the commands would have refused to create', () => {
|
|
const { project, stack, outerId, innerId, deepId, assetOf } = makeContainers();
|
|
stack.execute(new PlaceAssetCommand(outerId, innerId, { x: 0, y: 0 }));
|
|
assetOf(innerId).objects.push(createSceneObject('cycle', outerId, { x: 0, y: 0 }));
|
|
|
|
expect(containerReaches(project, assetOf(outerId), deepId)).toBe(false);
|
|
expect(containerReaches(project, assetOf(innerId), outerId)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('multi-selection commands', () => {
|
|
it('removes several subtrees as one step, covering nested ids', () => {
|
|
const { stack, sceneId, a, b, c, sceneOf, object } = setup();
|
|
stack.execute(new RemoveSceneObjectsCommand(sceneId, [b, c, a]));
|
|
expect(sceneOf().objects).toHaveLength(0);
|
|
stack.undo();
|
|
expect(sceneOf().objects.map((o) => o.id)).toEqual([a, b, c]);
|
|
expect(object(c).parentId).toBe(b);
|
|
});
|
|
|
|
it('a compound move rolls back entirely when one part fails', () => {
|
|
const { stack, sceneId, a, object } = setup();
|
|
const compound = new CompoundCommand('Move objects', [
|
|
new MoveSceneObjectCommand(sceneId, a, { x: 1, y: 2, z: 3 }),
|
|
new MoveSceneObjectCommand(sceneId, 'missing', { x: 0, y: 0, z: 0 }),
|
|
]);
|
|
expect(() => stack.execute(compound)).toThrow(/not found/);
|
|
expect(object(a).position).toEqual({ x: 100, y: 100, z: 1 });
|
|
expect(stack.canUndo).toBe(true); // the earlier commands of setup()
|
|
});
|
|
});
|