Files
feuerwerk/tests/core/holders.test.ts
T
rootandClaude Opus 5 ead649eec3 Resolve container content from the template instead of copying it
A scene now stores only the container object; its content is resolved from
the template on every read, with deviations kept as sparse instanceEdits on
the container object. This drops the propagation bookkeeping entirely --
templateSync.ts is gone and the three structural gaps (add, remove, reparent)
cannot diverge any more.

Values stay editable on an instance, structure does not; Unpack writes the
content into the scene, keeping ids. Play mode bakes containers into the
throwaway copy before entities are built. No migration: formatVersion is 2
and the seeders write the new format.

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

173 lines
6.8 KiB
TypeScript

/**
* Container assets hold placed objects just like scenes do, so anything that walks "all placed
* objects" has to cover both. Plus: a rejected undo leaves the stacks usable.
*/
import { describe, expect, it } from 'vitest';
import { type Command, CommandError } from '../../src/core/commands/Command';
import { CommandStack } from '../../src/core/commands/CommandStack';
import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { RemoveVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands';
import type { ContainerAsset } from '../../src/core/model/Asset';
import {
findAsset,
findObjectHolder,
findUsages,
listContainers,
objectHolders,
} from '../../src/core/model/Project';
import { instanceChildId } from '../../src/core/model/instance';
import { makeBehavior, makeSpriteScene, place } from '../helpers';
/** A behavior with a `speed` variable, placed and overridden to 42, then wrapped in a container. */
function containerWithOverride() {
const f = makeSpriteScene();
const behavior = makeBehavior(f, 'B', ['speed']);
const objectId = place(f, behavior.id, { x: 0, y: 0 });
f.stack.execute(new SetOverrideCommand(f.sceneId, objectId, 'speed', 42));
const container = new CreateContainerCommand(f.sceneId, [objectId]);
f.stack.execute(container);
const template = () => (findAsset(f.project, container.assetId) as ContainerAsset).objects[0]!;
// The object moved into the template; what the scene shows is resolved from it.
const placed = () => f.object(instanceChildId(container.objectId, template().id));
return { ...f, behaviorId: behavior.id, containerId: container.assetId, objectId, template, placed };
}
describe('object holders', () => {
it('counts scenes and container assets alike', () => {
const { project } = containerWithOverride();
expect(
objectHolders(project)
.map((h) => h.type)
.sort(),
).toEqual(['container', 'scene']);
});
it('resolves a scene or a container by id, but no other asset', () => {
const { project, sceneId, containerId, spriteId, behaviorId } = containerWithOverride();
expect(findObjectHolder(project, sceneId)?.type).toBe('scene');
expect(findObjectHolder(project, containerId)?.type).toBe('container');
expect(findObjectHolder(project, spriteId)).toBeUndefined();
expect(findObjectHolder(project, behaviorId)).toBeUndefined();
expect(findObjectHolder(project, 'nope')).toBeUndefined();
});
it('lists the container assets on their own', () => {
const { project, containerId } = containerWithOverride();
expect(listContainers(project).map((c) => c.id)).toEqual([containerId]);
});
it('finds usages inside container templates, not just in scenes', () => {
const { project, behaviorId, containerId, template } = containerWithOverride();
// Only the template: what the scene shows is resolved from it, never stored.
const usages = findUsages(project, behaviorId);
expect(usages).toHaveLength(1);
expect(usages[0]!.holder.id).toBe(containerId);
expect(template().overrides.speed).toBe(42);
});
});
describe('RemoveVariableCommand', () => {
it('clears the override on container templates too, and puts it back on undo', () => {
const { stack, behaviorId, template, placed } = containerWithOverride();
stack.execute(new RemoveVariableCommand(behaviorId, 'speed'));
expect(placed().overrides.speed).toBeUndefined();
expect(template().overrides.speed).toBeUndefined();
stack.undo();
expect(placed().overrides.speed).toBe(42);
expect(template().overrides.speed).toBe(42);
});
});
/**
* A behavior inside a placed container is not stored in the scene, and a value changed on it lives
* in the instance's `instanceEdits`. Anything rewriting overrides project-wide has to reach in
* there, or renaming a variable silently drops the value.
*/
describe('variables overridden on container content', () => {
function withInstanceEdit() {
const f = containerWithOverride();
f.stack.execute(new SetOverrideCommand(f.sceneId, f.placed().id, 'speed', 7));
const edits = () =>
f.sceneOf().objects.find((o) => o.id !== f.objectId)!.instanceEdits[f.template().id]!.overrides;
return { ...f, edits };
}
it('renames the key kept on the instance', () => {
const { stack, behaviorId, edits, placed } = withInstanceEdit();
expect(edits().speed).toBe(7);
stack.execute(new RenameVariableCommand(behaviorId, 'speed', 'velocity'));
expect(edits().speed).toBeUndefined();
expect(edits().velocity).toBe(7);
expect(placed().overrides.velocity).toBe(7);
stack.undo();
expect(edits().speed).toBe(7);
});
it('removes the value kept on the instance, and puts it back on undo', () => {
const { stack, behaviorId, edits } = withInstanceEdit();
stack.execute(new RemoveVariableCommand(behaviorId, 'speed'));
expect(edits().speed).toBeUndefined();
stack.undo();
expect(edits().speed).toBe(7);
});
});
describe('RenameVariableCommand', () => {
it('renames the override on container templates too', () => {
const { stack, behaviorId, template } = containerWithOverride();
stack.execute(new RenameVariableCommand(behaviorId, 'speed', 'velocity'));
expect(template().overrides.speed).toBeUndefined();
expect(template().overrides.velocity).toBe(42);
stack.undo();
expect(template().overrides.speed).toBe(42);
expect(template().overrides.velocity).toBeUndefined();
});
});
class RefusingUndo implements Command {
readonly label = 'Refusing';
execute(project: { name: string }): void {
project.name = 'executed';
}
undo(): void {
throw new CommandError('target is gone');
}
}
describe('CommandStack', () => {
it('keeps a command on the undo stack when its undo is rejected', () => {
const { project, stack } = makeSpriteScene();
stack.execute(new RefusingUndo());
expect(stack.canUndo).toBe(true);
expect(() => stack.undo()).toThrow(CommandError);
// Not swallowed by the history: still undoable, and not queued for redo.
expect(stack.canUndo).toBe(true);
expect(stack.canRedo).toBe(false);
expect(project.name).toBe('executed');
});
it('keeps a command on the redo stack when its redo is rejected', () => {
const project = { formatVersion: 1, id: 'p', name: 'P', assets: [], activeSceneId: '' };
const stack = new CommandStack(project);
let failing = false;
stack.execute({
label: 'Toggle',
execute: () => {
if (failing) throw new CommandError('nope');
},
undo: () => {},
});
stack.undo();
failing = true;
expect(() => stack.redo()).toThrow(CommandError);
expect(stack.canRedo).toBe(true);
expect(stack.canUndo).toBe(false);
});
});