import { describe, expect, it } from 'vitest'; import { ApplyOverrideToAssetCommand, ApplyOverrideToContainerCommand, ApplyPositionToContainerCommand, RevertOverrideToTemplateCommand, RevertPositionToTemplateCommand, } from '../../src/core/commands/applyCommands'; import { CreateContainerCommand, UnpackContainerCommand } 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(); }); it('drops the link once the instance is unpacked, even though the ids stay', () => { const { project, stack, sceneId, sceneOf, instanceId, a } = setup(); stack.execute(new UnpackContainerCommand(sceneId, instanceId)); expect(sceneOf().objects.some((o) => o.id === a)).toBe(true); expect(instanceTargetOf(project, sceneOf(), a)).toBeUndefined(); stack.undo(); expect(instanceTargetOf(project, sceneOf(), a)?.asset.type).toBe('container'); }); }); 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, sceneOf, instanceId } = 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); // The template carries it now, so nothing is left to deviate – the edit has to be gone, not // merely equal. It is dropped by comparing against a base read *after* the write. expect(sceneOf().objects.find((o) => o.id === instanceId)!.instanceEdits).toEqual({}); 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); }); }); /** * A container inside a container: the value a scene edit falls back to is the template *plus* the * edits of every container in between – not the innermost template alone. */ function nested() { const f = makeSpriteScene(); const inner = new CreateContainerCommand(f.sceneId, [place(f, f.spriteId, { x: 0, y: 0 })]); f.stack.execute(inner); const innerAsset = () => findAsset(f.project, inner.assetId) as ContainerAsset; const leaf = innerAsset().objects[0]!.id; // The sprite is 100 wide anyway: an override carrying the asset's own value, the way putting a // value back by hand in a container editor leaves one behind. f.stack.execute(new SetOverrideCommand(inner.assetId, leaf, 'width', 100)); const outer = new CreateContainerCommand(f.sceneId, [inner.objectId]); f.stack.execute(outer); const outerAsset = () => findAsset(f.project, outer.assetId) as ContainerAsset; const middle = outerAsset().objects[0]!.id; // Inside the outer container, that one sprite is narrowed to 8. f.stack.execute(new SetOverrideCommand(outer.assetId, instanceChildId(middle, leaf), 'width', 8)); return { ...f, leaf, deep: instanceChildId(outer.objectId, `${middle}/${leaf}`) }; } describe('a base two levels up', () => { it('keeps a scene value that equals the inner template but not the container in between', () => { const { stack, sceneId, deep, object } = nested(); expect(object(deep).overrides.width).toBe(8); stack.execute(new SetOverrideCommand(sceneId, deep, 'width', 10)); expect(object(deep).overrides.width).toBe(10); // 100 is what the innermost template says – but the container in between says 8, so this is // a deviation and has to be kept. Comparing against the template alone dropped it. stack.execute(new SetOverrideCommand(sceneId, deep, 'width', 100)); expect(object(deep).overrides.width).toBe(100); stack.undo(); expect(object(deep).overrides.width).toBe(10); }); it('resets to the value the container in between gives, not to the inner template', () => { const { stack, sceneId, deep, object } = nested(); stack.execute(new SetOverrideCommand(sceneId, deep, 'width', 100)); stack.execute(new RevertOverrideToTemplateCommand(sceneId, deep, 'width')); expect(object(deep).overrides.width).toBe(8); }); it('drops an edit that matches the base, so the value follows again', () => { const { stack, sceneId, deep, object, sceneOf } = nested(); stack.execute(new SetOverrideCommand(sceneId, deep, 'width', 8)); const instance = sceneOf().objects.find((o) => o.id === deep.split('~')[0])!; expect(instance.instanceEdits).toEqual({}); expect(object(deep).overrides.width).toBe(8); }); });