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>
167 lines
7.8 KiB
TypeScript
167 lines
7.8 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
ApplyOverrideToAssetCommand,
|
||
ApplyOverrideToContainerCommand,
|
||
ApplyPositionToContainerCommand,
|
||
RevertOverrideToTemplateCommand,
|
||
RevertPositionToTemplateCommand,
|
||
} from '../../src/core/commands/applyCommands';
|
||
import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
|
||
import { MoveSceneObjectCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||
import type { ContainerAsset } from '../../src/core/model/Asset';
|
||
import { instanceChildId, instanceTargetOf } from '../../src/core/model/instance';
|
||
import { findAsset } from '../../src/core/model/Project';
|
||
import { makeSpriteScene, place } from '../helpers';
|
||
|
||
/**
|
||
* A container made from two boxes (a at (100,100), b at (10,0)) and a second placed instance of it.
|
||
* `a` is the content of the first instance, `a2` the same template object in the second – neither
|
||
* is stored in the scene, both are resolved from the template.
|
||
*/
|
||
function setup() {
|
||
const f = makeSpriteScene();
|
||
const create = new CreateContainerCommand(f.sceneId, [
|
||
place(f, f.spriteId, { x: 100, y: 100 }),
|
||
place(f, f.spriteId, { x: 10, y: 0 }),
|
||
]);
|
||
f.stack.execute(create);
|
||
const instance = place(f, create.assetId, { x: 1000, y: 0 });
|
||
const container = () => findAsset(f.project, create.assetId) as ContainerAsset;
|
||
const templateA = container().objects[0]!.id;
|
||
return {
|
||
...f,
|
||
instanceId: create.objectId,
|
||
instance,
|
||
container,
|
||
templateA,
|
||
a: instanceChildId(create.objectId, templateA),
|
||
a2: instanceChildId(instance, templateA),
|
||
templateOfA: () => container().objects.find((t) => t.id === templateA)!,
|
||
};
|
||
}
|
||
|
||
describe('template link', () => {
|
||
it('resolves the container and template for resolved content only', () => {
|
||
const { project, a, a2, sceneOf, instanceId, container, templateOfA } = setup();
|
||
const scene = sceneOf();
|
||
expect(instanceTargetOf(project, scene, a2)?.asset.id).toBe(container().id);
|
||
expect(instanceTargetOf(project, scene, a2)?.template).toBe(templateOfA());
|
||
expect(instanceTargetOf(project, scene, a)?.template).toBe(templateOfA());
|
||
// The container object itself is stored, so it has no template behind it.
|
||
expect(instanceTargetOf(project, scene, instanceId)).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
describe('position apply / revert', () => {
|
||
it('applies one axis to the template; instances still at the template value follow', () => {
|
||
const { stack, sceneId, a, a2, object, templateOfA } = setup();
|
||
expect(templateOfA().position).toEqual({ x: 45, y: 50, z: 0 });
|
||
// a2 was moved by hand on x → it keeps that; on y it still matches the template.
|
||
stack.execute(new MoveSceneObjectCommand(sceneId, a2, { x: 0, y: 50, z: 0 }));
|
||
stack.execute(new MoveSceneObjectCommand(sceneId, a, { x: 60, y: 70, z: 0 }));
|
||
|
||
stack.execute(new ApplyPositionToContainerCommand(sceneId, a, 'x'));
|
||
expect(templateOfA().position.x).toBe(60);
|
||
expect(object(a2).position.x).toBe(0); // modified on its own → untouched
|
||
stack.execute(new ApplyPositionToContainerCommand(sceneId, a, 'y'));
|
||
expect(templateOfA().position.y).toBe(70);
|
||
expect(object(a2).position.y).toBe(70); // was at the template value → follows
|
||
|
||
stack.undo();
|
||
expect(templateOfA().position.y).toBe(50);
|
||
expect(object(a2).position.y).toBe(50);
|
||
stack.undo();
|
||
expect(templateOfA().position.x).toBe(45);
|
||
expect(object(a).position.x).toBe(60); // the source object itself is not changed by apply
|
||
});
|
||
|
||
it('reverts an axis (or the whole position) to the template', () => {
|
||
const { stack, sceneId, a, object } = setup();
|
||
stack.execute(new MoveSceneObjectCommand(sceneId, a, { x: 1, y: 2, z: 3 }));
|
||
stack.execute(new RevertPositionToTemplateCommand(sceneId, a, 'z'));
|
||
expect(object(a).position).toEqual({ x: 1, y: 2, z: 0 });
|
||
stack.execute(new RevertPositionToTemplateCommand(sceneId, a));
|
||
expect(object(a).position).toEqual({ x: 45, y: 50, z: 0 });
|
||
stack.undo();
|
||
expect(object(a).position).toEqual({ x: 1, y: 2, z: 0 });
|
||
});
|
||
|
||
it('rejects objects that are not part of a container', () => {
|
||
const f = setup();
|
||
const loose = place(f, f.spriteId, { x: 0, y: 0 });
|
||
expect(() => f.stack.execute(new ApplyPositionToContainerCommand(f.sceneId, loose))).toThrow(
|
||
/not part of a placed container/,
|
||
);
|
||
expect(f.sceneOf().objects.find((o) => o.id === loose)).toBeDefined();
|
||
});
|
||
});
|
||
|
||
describe('override apply / revert', () => {
|
||
it('applies an override to the container template and to instances that had no own value', () => {
|
||
const { stack, sceneId, a, a2, object, templateOfA } = setup();
|
||
stack.execute(new SetOverrideCommand(sceneId, a, 'width', 77));
|
||
stack.execute(new ApplyOverrideToContainerCommand(sceneId, a, 'width'));
|
||
expect(templateOfA().overrides.width).toBe(77);
|
||
expect(object(a2).overrides.width).toBe(77);
|
||
expect(object(a).overrides.width).toBe(77);
|
||
stack.undo();
|
||
expect(templateOfA().overrides.width).toBeUndefined();
|
||
expect(object(a2).overrides.width).toBeUndefined();
|
||
expect(object(a).overrides.width).toBe(77);
|
||
});
|
||
|
||
it('leaves instances alone that already had their own override', () => {
|
||
const { stack, sceneId, a, a2, object, templateOfA } = setup();
|
||
stack.execute(new SetOverrideCommand(sceneId, a2, 'width', 5));
|
||
stack.execute(new SetOverrideCommand(sceneId, a, 'width', 77));
|
||
stack.execute(new ApplyOverrideToContainerCommand(sceneId, a, 'width'));
|
||
expect(templateOfA().overrides.width).toBe(77);
|
||
expect(object(a2).overrides.width).toBe(5);
|
||
});
|
||
|
||
it('applies an override to the asset, dropping the override on object and template', () => {
|
||
const { project, stack, sceneId, spriteId, a, a2, object, templateOfA } = setup();
|
||
stack.execute(new SetOverrideCommand(sceneId, a, 'width', 77));
|
||
stack.execute(new ApplyOverrideToContainerCommand(sceneId, a, 'width'));
|
||
stack.execute(new ApplyOverrideToAssetCommand(sceneId, a, 'width'));
|
||
const sprite = () => findAsset(project, spriteId)!;
|
||
expect(sprite().props.width).toBe(77);
|
||
expect(object(a).overrides.width).toBeUndefined();
|
||
expect(templateOfA().overrides.width).toBeUndefined();
|
||
expect(object(a2).overrides.width).toBeUndefined(); // shared the template value → follows the asset now
|
||
stack.undo();
|
||
expect(sprite().props.width).toBe(100);
|
||
expect(object(a).overrides.width).toBe(77);
|
||
expect(templateOfA().overrides.width).toBe(77);
|
||
expect(object(a2).overrides.width).toBe(77);
|
||
});
|
||
|
||
it('applies to the asset for a plain object too', () => {
|
||
const f = setup();
|
||
const { project, stack, sceneId, spriteId, object } = f;
|
||
const loose = place(f, spriteId, { x: 0, y: 0 });
|
||
stack.execute(new SetOverrideCommand(sceneId, loose, 'height', 33));
|
||
stack.execute(new ApplyOverrideToAssetCommand(sceneId, loose, 'height'));
|
||
expect(findAsset(project, spriteId)!.props.height).toBe(33);
|
||
expect(object(loose).overrides.height).toBeUndefined();
|
||
expect(() => stack.execute(new ApplyOverrideToAssetCommand(sceneId, loose, 'nope'))).toThrow(
|
||
/not defined/,
|
||
);
|
||
});
|
||
|
||
it('reverts an override to the template value (or removes it when the template has none)', () => {
|
||
const { stack, sceneId, a, a2, object, templateOfA } = setup();
|
||
stack.execute(new SetOverrideCommand(sceneId, a, 'width', 77));
|
||
stack.execute(new ApplyOverrideToContainerCommand(sceneId, a, 'width'));
|
||
stack.execute(new SetOverrideCommand(sceneId, a2, 'width', 5));
|
||
stack.execute(new RevertOverrideToTemplateCommand(sceneId, a2, 'width'));
|
||
expect(object(a2).overrides.width).toBe(77);
|
||
expect(templateOfA().overrides.width).toBe(77);
|
||
stack.execute(new SetOverrideCommand(sceneId, a2, 'height', 9));
|
||
stack.execute(new RevertOverrideToTemplateCommand(sceneId, a2, 'height'));
|
||
expect(object(a2).overrides.height).toBeUndefined();
|
||
stack.undo();
|
||
expect(object(a2).overrides.height).toBe(9);
|
||
});
|
||
});
|