/** * 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, objectHolders } from '../../src/core/model/Project'; import { defaultSceneVisibility } from '../../src/core/model/sceneVisibility'; import { instanceChildId } from '../../src/core/model/instance'; import { findUsages } from '../../src/core/model/usages'; 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('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: '', startSceneId: '', plugins: [], collisionLayers: ['Default'], symbolScale: 1, playFrom: 'start' as const, whilePlaying: 'live' as const, gameScale: 1, gameLinkZoom: 'auto' as const, sceneVisibility: defaultSceneVisibility(), }; 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); }); });