Files
feuerwerk/tests/core/container.test.ts
T
rootandClaude Opus 5 4568c6581b Add pixel-size fit and drag-to-reorder in the hierarchy
The aspect button now carries two offers, so it becomes a menu: fit one
side to the texture ratio, or set both sides to the exact pixel size as a
single undo step.

Dropping a row on the outer quarter of another row inserts it into that
gap instead of parenting into it, reordering among the siblings there.
Order is the stored order, so ReorderObjectCommand is a splice; equal z
falls back to tree order, which makes this the way to draw one object in
front of another. Rejected on container content like the other structural
edits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:20:46 +02:00

486 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import { DeleteAssetCommand } from '../../src/core/commands/assetCommands';
import { CommandError } from '../../src/core/commands/Command';
import { CompoundCommand } from '../../src/core/commands/CompoundCommand';
import { CreateContainerCommand, UnpackContainerCommand } from '../../src/core/commands/containerCommands';
import {
MoveSceneObjectCommand,
PlaceAssetCommand,
RemoveSceneObjectCommand,
RemoveSceneObjectsCommand,
ReorderObjectCommand,
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 { instanceChildId } from '../../src/core/model/instance';
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, b and c – which leaves the scene holding the container object
* alone. `child` addresses the content the instance resolves; `tA`/`tB`/`tC` are the template
* objects it comes from, in the order `CreateContainerCommand` built them (tree order).
*/
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;
const [tA, tB, tC] = asset().objects.map((t) => t.id) as [string, string, string];
return {
...f,
containerId: create.assetId,
instanceId: create.objectId,
asset,
tA,
tB,
tC,
template: (id: string) => asset().objects.find((t) => t.id === id)!,
child: (templateId: string, instanceId = create.objectId) => instanceChildId(instanceId, 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 shows it again through the placed container', () => {
const { project, stack, sceneId, spriteId, a, b, c, sceneOf, viewOf, 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.position).toEqual({ x: 55, y: 50, z: 0 });
// The members are gone from the scene: the container object is all that is stored.
expect(sceneOf().objects.map((o) => o.id)).toEqual([command.objectId]);
const asset = findAsset(project, command.assetId) as ContainerAsset;
expect(asset.type).toBe('container');
expect(asset.objects).toHaveLength(3);
const [tA, tB, tC] = asset.objects;
expect(tA!.position).toEqual({ x: 45, y: 50, z: 1 });
expect(tA!.parentId).toBe('');
expect(tC!.parentId).toBe(tB!.id);
expect(tC!.assetId).toBe(spriteId);
expect(asset.objects.map((t) => t.id)).not.toContain(a);
// What the editor lists: the container plus the content resolved from the template, and the
// three objects still stand exactly where they did.
const ids = treeOrder(viewOf()).map((e) => e.object.id);
expect(ids).toEqual([
command.objectId,
instanceChildId(command.objectId, tA!.id),
instanceChildId(command.objectId, tB!.id),
instanceChildId(command.objectId, tC!.id),
]);
expect(world(ids[1]!)).toEqual(before.a);
expect(world(ids[2]!)).toEqual(before.b);
expect(world(ids[3]!)).toEqual(before.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(project.assets.some((x) => x.id === command.assetId)).toBe(false);
stack.redo();
expect(world(instanceChildId(command.objectId, tA!.id))).toEqual(before.a);
});
it('places the container below the parent all roots share', () => {
const f = setup();
const { project, 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 });
const asset = findAsset(project, command.assetId) as ContainerAsset;
const [tc, td] = asset.objects.map((t) => instanceChildId(command.objectId, t.id));
expect(world(tc!)).toEqual({ x: 0, y: 5, z: 0 });
expect(world(td!)).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('stores one object and resolves the template below it', () => {
const f = templateSetup();
const { stack, sceneOf, viewOf, world, containerId, tA, tB, tC } = f;
const stored = sceneOf().objects.length;
const placed = place(f, containerId, { x: 1000, y: 0 });
expect(sceneOf().objects).toHaveLength(stored + 1);
expect(descendantsOf(viewOf(), placed).map((o) => o.id)).toEqual([
instanceChildId(placed, tA),
instanceChildId(placed, tB),
instanceChildId(placed, tC),
]);
expect(world(instanceChildId(placed, tA))).toEqual({ x: 1045, y: 50, z: 1 });
expect(world(instanceChildId(placed, tC))).toEqual({ x: 945, y: -45, z: 0 });
stack.undo();
expect(sceneOf().objects).toHaveLength(stored);
expect(viewOf().objects.some((o) => o.id === placed)).toBe(false);
});
it('survives a save/load round trip, instance edits included', () => {
const f = templateSetup();
const { project, stack, sceneId, containerId, instanceId, tA } = f;
stack.execute(new SetOverrideCommand(sceneId, f.child(tA), 'width', 12));
const loaded = roundTrip(project);
const asset = findAsset(loaded, containerId) as ContainerAsset;
expect(asset.objects).toHaveLength(3);
const scene = findScene(loaded, sceneId)!;
const instance = scene.objects.find((o) => o.id === instanceId)!;
expect(instance.assetId).toBe(containerId);
expect(instance.instanceEdits[tA]?.overrides.width).toBe(12);
});
it('deleting an asset removes its template objects, and the content with them', () => {
const f = templateSetup();
const { project, stack, spriteId, containerId, asset, viewOf } = f;
stack.execute(new DeleteAssetCommand(spriteId));
expect(asset().objects).toHaveLength(0);
expect(viewOf().objects).toHaveLength(1); // the container object, now empty
stack.undo();
expect(asset().objects).toHaveLength(3);
expect(viewOf().objects).toHaveLength(4);
expect(findAsset(project, containerId)).toBeDefined();
});
it('spawns a container with its content at runtime', () => {
const f = templateSetup();
const { world } = startWorld(f);
const spawned = world.spawn(f.containerId, { 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);
// Play bakes the placed container into stored objects, and the spawn adds a second set.
expect(f.sceneOf().objects).toHaveLength(8);
});
});
describe('editing a container template', () => {
it('reaches the content of every placed instance', () => {
const f = templateSetup();
const { stack, containerId, tA, template, object, child } = f;
const second = place(f, containerId, { x: 1000, y: 0 });
stack.execute(new MoveSceneObjectCommand(containerId, tA, { x: 7, y: 8, z: 9 }));
stack.execute(new RenameSceneObjectCommand(containerId, tA, 'Hero'));
stack.execute(new SetOverrideCommand(containerId, tA, 'width', 55));
expect(template(tA).position).toEqual({ x: 7, y: 8, z: 9 });
for (const instance of [f.instanceId, second]) {
expect(object(child(tA, instance)).position).toEqual({ x: 7, y: 8, z: 9 });
expect(object(child(tA, instance)).name).toBe('Hero');
expect(object(child(tA, instance)).overrides.width).toBe(55);
}
stack.execute(new RemoveSceneObjectCommand(containerId, tA));
expect(template(tA)).toBeUndefined();
expect(f.viewOf().objects.some((o) => o.id === child(tA, second))).toBe(false);
stack.undo();
expect(object(child(tA, second)).overrides.width).toBe(55);
stack.undo();
expect(object(child(tA, second)).overrides.width).toBeUndefined();
stack.undo();
expect(object(child(tA, second)).name).toBe('');
stack.undo();
expect(object(child(tA, second)).position).toEqual({ x: 45, y: 50, z: 1 });
});
it('restores the template order when a removal is undone', () => {
const { stack, containerId, asset, tA, tB, tC } = templateSetup();
expect(asset().objects.map((t) => t.id)).toEqual([tA, tB, tC]);
stack.execute(new RemoveSceneObjectsCommand(containerId, [tB]));
expect(asset().objects.map((t) => t.id)).toEqual([tA]);
stack.undo();
expect(asset().objects.map((t) => t.id)).toEqual([tA, tB, tC]);
expect(asset().objects[2]!.parentId).toBe(tB);
});
it('rejects an object id that belongs to the scene rather than to the template', () => {
const { stack, containerId, instanceId } = templateSetup();
expect(() => stack.execute(new RenameSceneObjectCommand(containerId, instanceId, 'Hero'))).toThrow(
/not found/,
);
});
});
describe('a value changed on one instance', () => {
it('is kept as a sparse edit and stops the value from following', () => {
const f = templateSetup();
const { stack, sceneId, containerId, instanceId, tA, object, child, sceneOf } = f;
const second = place(f, containerId, { x: 1000, y: 0 });
stack.execute(new SetOverrideCommand(sceneId, child(tA), 'width', 12));
stack.execute(new SetOverrideCommand(containerId, tA, 'width', 55));
expect(object(child(tA, second)).overrides.width).toBe(55);
expect(object(child(tA)).overrides.width).toBe(12);
const instance = sceneOf().objects.find((o) => o.id === instanceId)!;
expect(instance.instanceEdits[tA]?.overrides.width).toBe(12);
stack.undo();
expect(object(child(tA, second)).overrides.width).toBeUndefined();
expect(object(child(tA)).overrides.width).toBe(12);
});
it('forgets the edit again once the value is put back to the template’s', () => {
const { stack, sceneId, instanceId, tA, child, sceneOf, object } = templateSetup();
const edits = () => sceneOf().objects.find((o) => o.id === instanceId)!.instanceEdits;
stack.execute(new MoveSceneObjectCommand(sceneId, child(tA), { x: 1, y: 50, z: 1 }));
expect(edits()[tA]?.position).toEqual({ x: 1 });
stack.execute(new MoveSceneObjectCommand(sceneId, child(tA), { x: 45, y: 50, z: 1 }));
expect(edits()[tA]).toBeUndefined();
expect(object(child(tA)).position).toEqual({ x: 45, y: 50, z: 1 });
});
it('follows per axis, so an instance dragged sideways keeps only its x', () => {
const f = templateSetup();
const { stack, sceneId, containerId, tA, object, child } = f;
const second = place(f, containerId, { x: 1000, y: 0 });
stack.execute(new MoveSceneObjectCommand(sceneId, child(tA, second), { x: 999, y: 50, z: 1 }));
stack.execute(new MoveSceneObjectCommand(containerId, tA, { x: 7, y: 8, z: 9 }));
expect(object(child(tA)).position).toEqual({ x: 7, y: 8, z: 9 });
expect(object(child(tA, second)).position).toEqual({ x: 999, y: 8, z: 9 });
stack.undo();
expect(object(child(tA, second)).position).toEqual({ x: 999, y: 50, z: 1 });
});
it('clears an override on every instance that shared it', () => {
const { stack, containerId, tA, object, child } = templateSetup();
stack.execute(new SetOverrideCommand(containerId, tA, 'width', 55));
expect(object(child(tA)).overrides.width).toBe(55);
stack.execute(new ClearOverrideCommand(containerId, tA, 'width'));
expect(object(child(tA)).overrides.width).toBeUndefined();
stack.undo();
expect(object(child(tA)).overrides.width).toBe(55);
});
it('does not leak to the other instances', () => {
const f = templateSetup();
const { stack, sceneId, tA, object, child } = f;
const second = place(f, f.containerId, { x: 1000, y: 0 });
stack.execute(new SetOverrideCommand(sceneId, child(tA, second), 'width', 12));
stack.execute(new RenameSceneObjectCommand(sceneId, child(tA, second), 'Only me'));
expect(object(child(tA)).overrides.width).toBeUndefined();
expect(object(child(tA)).name).toBe('');
});
});
describe('the structure of a placed container', () => {
it('cannot be changed from the scene', () => {
const { stack, sceneId, tA, tB, child } = templateSetup();
expect(() => stack.execute(new RemoveSceneObjectCommand(sceneId, child(tA)))).toThrow(
/comes from a container template/,
);
expect(() => stack.execute(new SetParentCommand(sceneId, child(tA), child(tB)))).toThrow(
/comes from a container template/,
);
expect(() => stack.execute(new CreateContainerCommand(sceneId, [child(tA)]))).toThrow(
/comes from a container template/,
);
// Order is structure too: it comes from the template, so it is changed there.
expect(() => stack.execute(new ReorderObjectCommand(sceneId, child(tA), child(tB)))).toThrow(
/comes from a container template/,
);
});
it('refuses to take an object of the scene as a child', () => {
const f = templateSetup();
const loose = place(f, f.spriteId, { x: 0, y: 0 });
expect(() => f.stack.execute(new SetParentCommand(f.sceneId, loose, f.instanceId))).toThrow(/unpack it/);
});
it('opens up once it is unpacked, and stops following the template', () => {
const f = templateSetup();
const { stack, sceneId, containerId, instanceId, tA, child, sceneOf, object } = f;
stack.execute(new SetOverrideCommand(sceneId, child(tA), 'width', 12));
stack.execute(new UnpackContainerCommand(sceneId, instanceId));
// The ids survive the unpack, so anything pointing at the content keeps working.
expect(sceneOf().objects.map((o) => o.id)).toContain(child(tA));
expect(object(child(tA)).overrides.width).toBe(12);
stack.execute(new SetOverrideCommand(containerId, tA, 'width', 55));
expect(object(child(tA)).overrides.width).toBe(12);
stack.execute(new RemoveSceneObjectCommand(sceneId, child(tA)));
expect(sceneOf().objects.map((o) => o.id)).not.toContain(child(tA));
stack.undo();
stack.undo();
stack.undo();
expect(sceneOf().objects.map((o) => o.id)).toEqual([instanceId]);
expect(object(child(tA)).overrides.width).toBe(12);
});
});
describe('a container nested in another one', () => {
it('resolves through both levels, and a template edit reaches the deep content', () => {
const f = makeContainers();
const { stack, outerId, innerId, viewOf, assetOf, 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 innerTemplate = assetOf(innerId).objects[0]!;
const deep = instanceChildId(placed, `${nest.objectId}/${innerTemplate.id}`);
expect(descendantsOf(viewOf(), placed)).toHaveLength(3);
expect(world(deep)).toEqual({ x: 520, y: 0, z: 0 });
stack.execute(new RenameSceneObjectCommand(innerId, innerTemplate.id, 'Deep'));
expect(f.object(deep).name).toBe('Deep');
stack.undo();
expect(f.object(deep).name).toBe('');
});
it('keeps a value changed on the deep content out of the template', () => {
const f = makeContainers();
const { stack, sceneId, outerId, innerId, assetOf } = f;
const nest = new PlaceAssetCommand(outerId, innerId, { x: 20, y: 0 });
stack.execute(nest);
const placed = place(f, outerId, { x: 500, y: 0 });
const path = `${nest.objectId}/${assetOf(innerId).objects[0]!.id}`;
stack.execute(new SetOverrideCommand(sceneId, instanceChildId(placed, path), 'width', 7));
expect(f.object(instanceChildId(placed, path)).overrides.width).toBe(7);
expect(assetOf(innerId).objects[0]!.overrides.width).toBeUndefined();
expect(f.sceneOf().objects.find((o) => o.id === placed)!.instanceEdits[path]?.overrides.width).toBe(7);
});
});
describe('placing into a container template', () => {
it('creates a single object, without spelling the nested container out', () => {
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('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()
});
});