Fix findUsages/undo/node-lookup bugs, split Viewport and Editor, share command and inspector helpers

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 09:22:49 +02:00
1 parent 1eb28314cf
commit f140c2fd77
58 files changed
+1969 -1171

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
/**
* The undo contract every "write one key into a property bag" command shares: a key that was
* not in the bag before the edit must be *absent* again afterwards, not present as undefined.
* Serialization and the "is this an override?" checks in the inspector go by key presence.
*/
import { describe, expect, it } from 'vitest';
import { ResetAssetPropertyCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { SetNodeParamCommand } from '../../src/core/commands/graphCommands';
import { ClearOverrideCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { findAsset } from '../../src/core/model/Project';
import { graphEditor, makeBehavior, makeSpriteScene, place } from '../helpers';
describe('bag value commands', () => {
it('undoing an override leaves no key behind', () => {
const fixture = makeSpriteScene();
const box = place(fixture, fixture.spriteId, { x: 0, y: 0 });
fixture.stack.execute(new SetOverrideCommand(fixture.sceneId, box, 'width', 33));
expect(fixture.object(box).overrides).toEqual({ width: 33 });
fixture.stack.undo();
expect('width' in fixture.object(box).overrides).toBe(false);
});
it('undoing a cleared override puts the old value back', () => {
const fixture = makeSpriteScene();
const box = place(fixture, fixture.spriteId, { x: 0, y: 0 });
fixture.stack.execute(new SetOverrideCommand(fixture.sceneId, box, 'width', 33));
fixture.stack.execute(new ClearOverrideCommand(fixture.sceneId, box, 'width'));
expect('width' in fixture.object(box).overrides).toBe(false);
fixture.stack.undo();
expect(fixture.object(box).overrides.width).toBe(33);
});
it('coerces on set and restores the untouched default on undo of a reset', () => {
const fixture = makeSpriteScene();
const sprite = () => findAsset(fixture.project, fixture.spriteId)!;
// 'width' is a number property, so a numeric string is coerced.
fixture.stack.execute(new SetAssetPropertyCommand(fixture.spriteId, 'width', '250'));
expect(sprite().props.width).toBe(250);
fixture.stack.execute(new ResetAssetPropertyCommand(fixture.spriteId, 'width'));
expect(sprite().props.width).toBe(100);
fixture.stack.undo();
expect(sprite().props.width).toBe(250);
});
it('restores a node param that had no value before', () => {
const fixture = makeSpriteScene();
const behavior = makeBehavior(fixture, 'B');
// `Owner` declares no params, so 'value' is an inline input value: stored as given.
const node = graphEditor(fixture, behavior).addNode('Owner');
fixture.stack.execute(new SetNodeParamCommand(behavior.id, node.id, 'value', 7));
expect(node.params.value).toBe(7);
fixture.stack.undo();
expect('value' in node.params).toBe(false);
});
});
+15 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { DisconnectCommand, RemoveNodeCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands';
import { CommandError } from '../../src/core/commands/Command';
import { AddNodeCommand, DisconnectCommand, RemoveNodeCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands';
import { AddVariableCommand, RemoveVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands';
import { GraphInterpreter, allNodeDefs, findNode, portsCompatible } from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset';
@@ -296,3 +297,16 @@ describe('behavior serialization', () => {
expect(cleaned.graph.edges.length).toBe(behavior.graph.edges.length);
});
});
describe('unknown node types', () => {
it('are rejected as a CommandError by execute, not thrown from the constructor', () => {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'B');
// Building the command must not throw: callers construct it outside the editor's
// error handling (`state.execute(new AddNodeCommand(…))`), so a throw here would crash.
const command = new AddNodeCommand(behavior.id, 'DoesNotExist', 0, 0);
expect(command.label).toBe('Add DoesNotExist');
expect(() => command.execute(fixture.project)).toThrow(CommandError);
expect(behavior.graph.nodes).toHaveLength(0);
});
});
+113
View File
@@ -0,0 +1,113 @@
/**
* Container assets hold placed objects just like scenes do. Anything that walks "all placed
* objects" has to cover both – the tests here pin the places that used to look at scenes only.
* The history tests pin that a rejected undo leaves the stacks usable.
*/
import { describe, expect, it } from 'vitest';
import type { Command } from '../../src/core/commands/Command';
import { 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, findUsages, objectHolders } from '../../src/core/model/Project';
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]!;
return { ...f, behaviorId: behavior.id, objectId, template };
}
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('finds usages inside container templates, not just in scenes', () => {
const { project, behaviorId, template } = containerWithOverride();
// The placed object and the template copy the container was built from.
expect(findUsages(project, behaviorId)).toHaveLength(2);
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, objectId, sceneOf, template } = containerWithOverride();
const placed = () => sceneOf().objects.find((o) => o.id === objectId)!;
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);
});
});
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();
});
});
/** Applies a marker to the project; its undo refuses to run. */
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);
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { removeById, removeFirst, restoreAt } from '../../src/core/util/list';
const items = () => [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
describe('removeFirst', () => {
it('takes the match out and reports the index it had', () => {
const list = items();
expect(removeFirst(list, (i) => i.id === 'b')).toEqual({ item: { id: 'b' }, index: 1 });
expect(list.map((i) => i.id)).toEqual(['a', 'c']);
});
it('stops at the first match', () => {
const list = [{ id: 'a' }, { id: 'a' }];
expect(removeFirst(list, (i) => i.id === 'a')?.index).toBe(0);
expect(list).toHaveLength(1);
});
it('leaves the list untouched when nothing matches', () => {
const list = items();
expect(removeFirst(list, (i) => i.id === 'zz')).toBeUndefined();
expect(list.map((i) => i.id)).toEqual(['a', 'b', 'c']);
});
it('round-trips with restoreAt', () => {
const list = items();
const removed = removeFirst(list, (i) => i.id === 'b');
restoreAt(list, removed ? [removed] : []);
expect(list.map((i) => i.id)).toEqual(['a', 'b', 'c']);
});
});
describe('removeById', () => {
it('returns a new list without that item and does not mutate the old one', () => {
const list = items();
const next = removeById(list, 'a');
expect(next.map((i) => i.id)).toEqual(['b', 'c']);
expect(list).toHaveLength(3);
expect(next).not.toBe(list);
});
it('keeps every item when the id is unknown', () => {
expect(removeById(items(), 'zz').map((i) => i.id)).toEqual(['a', 'b', 'c']);
});
});
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
import { type EntityHandle, worldPositionOf } from '../../src/core/graph';
import { hasParentCycle, parentChain } from '../../src/core/model/walk';
import { makeSpriteScene, place, startWorld } from '../helpers';
interface Node {
id: string;
parent: Node | null;
}
const parentOf = (node: Node) => node.parent;
/** a → b → c, where c is parented back to a (only reachable through a broken document or a bug). */
function cycle(): { a: Node; b: Node; c: Node } {
const a: Node = { id: 'a', parent: null };
const b: Node = { id: 'b', parent: a };
const c: Node = { id: 'c', parent: b };
a.parent = c;
return { a, b, c };
}
describe('parentChain', () => {
it('yields the item and its ancestors, nearest first', () => {
const a: Node = { id: 'a', parent: null };
const b: Node = { id: 'b', parent: a };
expect([...parentChain(b, parentOf)].map((n) => n.id)).toEqual(['b', 'a']);
});
it('terminates on a cyclic parent chain, visiting every item once', () => {
const { c } = cycle();
expect([...parentChain(c, parentOf)].map((n) => n.id)).toEqual(['c', 'b', 'a']);
});
it('reports a cycle only when the parents never reach a root', () => {
const { c } = cycle();
expect(hasParentCycle(c, parentOf)).toBe(true);
expect(hasParentCycle({ id: 'lone', parent: null }, parentOf)).toBe(false);
});
});
describe('cycle-safe walks in the runtime', () => {
it('world position of a cyclic chain terminates and counts each ancestor once', () => {
const a: EntityHandle = { id: 'a', assetId: 'x', name: 'a', x: 1, y: 10, z: 0, props: {}, parent: null };
const b: EntityHandle = { id: 'b', assetId: 'x', name: 'b', x: 2, y: 20, z: 0, props: {}, parent: a };
a.parent = b;
expect(worldPositionOf(b)).toEqual({ x: 3, y: 30, z: 0 });
});
// Guards `travelsWith`: before the shared iterator it walked parents without a seen-set and hung here.
it('moveAndSlide terminates when a collider sits in a cyclic parent chain', () => {
const f = makeSpriteScene();
const moverId = place(f, f.spriteId, { x: 0, y: 0 });
const wallId = place(f, f.spriteId, { x: 200, y: 0 });
const moverCollider = new AddColliderCommand(f.sceneId, moverId);
const wallCollider = new AddColliderCommand(f.sceneId, wallId);
f.stack.execute(moverCollider);
f.stack.execute(wallCollider);
const { world, collisions } = startWorld(f, { collisions: true });
const mover = world.findByObjectId(moverId)!;
const wall = world.findByObjectId(wallId)!;
const wallColliderEntity = world.findByObjectId(wallCollider.objectId)!;
// Close the loop: the wall's collider is its child and the wall is parented to that collider.
wall.parent = wallColliderEntity;
expect(collisions.moveAndSlide(world, mover, 10, 0)).toEqual({ blockedX: false, blockedY: false });
expect(mover.x).toBe(10);
}, 2000);
});