Files
feuerwerk/tests/core/container.test.ts
T
rootandClaude Opus 5 495756b500 Remove exports and methods nothing uses any more
A reference search over every export and method in src/ found code that
only tests still called, or that was never called at all:

- RemoveSceneObjectCommand: the product only removes through the
  multi-object command; the tests now use that one too.
- NodeRegistry.requireNodeDef: a second function of the same name in
  commands/require.ts raises a CommandError instead of throwing, and is
  the one the product uses. The twin invited the wrong import.
- isTextureAsset, listContainers, AssetTabEditor.show,
  FocusManager.destroy, KeyboardBridge.selectAll and the getters
  ColorField.value, ListView.selected, NumberField.number.

Also: Theme.controlActive now matches the resting control colour, and a
new note asks what editing the graph during play would take.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 20:15:49 +02:00

500 lines
23 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,
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 { World } from '../../src/runtime/World';
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().structure).toBe(true);
// Play bakes the placed container into stored objects, and the spawn adds a second set.
expect(f.sceneOf().objects).toHaveLength(8);
});
it('leaves the instance alone when baking is off, so the editor run cannot spend it', () => {
const f = templateSetup();
f.stack.execute(new SetOverrideCommand(f.sceneId, f.child(f.tA), 'width', 12));
expect(f.sceneOf().objects[0]!.instanceEdits[f.tA]?.overrides.width).toBe(12);
// Baking would write the content out as plain objects and drop the edits – a real, silent
// change to the saved project, which is why the editor run instantiates without it.
const world = World.instantiate(f.project, f.sceneId, { bakeContainers: false });
world.tick(1 / 60);
expect(world.syncToModel().structure).toBe(false);
expect(f.sceneOf().objects).toHaveLength(1);
expect(f.sceneOf().objects[0]!.instanceEdits[f.tA]).toBeDefined();
});
});
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 RemoveSceneObjectsCommand(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 RemoveSceneObjectsCommand(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 RemoveSceneObjectsCommand(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()
});
});