Files
feuerwerk/tests/core/object-references.test.ts
T
adminandClaude Opus 5.5 214218d1b2 Clear references to a template object in every scene on delete (#269)
Deleting an object in a container tab only cleared references inside
the template. Placements in scenes and other containers kept their
instance edit and showed "(missing)" in the inspector.

- shownObjectIds records which objects every holder shows before the
  delete. dropReferencesToGone compares that with the state after it
  and clears each reference that no longer resolves.
- A reference is resolved from its own place with referenceCandidates,
  the same way the runtime does. One rule covers instance edits,
  nested placements and full ids like p~crate.
- A cleared instance edit is dropped when it now says the same as the
  template's own value. Without such a value a marked None stays, as
  if picked by hand, and Reset hands the row back to the template.
- All cleared references come back with a single undo.
- References that were already dead in saved projects stay untouched.

Findings, not fixed here:
- A reference from container D into a nested placement q is stored as
  q~<id>, but the copy is named r~q/<id> in every placement of D. The
  runtime finds nothing, so that reference is dead from the start.
- Delete asset removes placed objects by cascade but does not clear
  references to them.

Tests:
- Unit object-references: 17 passed (13 + 4)
- Gate: 198 files, 2873 tests, exit 0
- e2e 1-17,57: 564 checks passed, 57 has 13 (+1 for 57h)
- Five unit counter-probes and one browser counter-probe fail as
  expected
- e2e total 1607 is computed, not measured in a full run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 22:35:51 +02:00

359 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* An object's place names another object of the same scene (#258): picked on a placed object,
* stored as its id, the object itself once the game runs – and inside a container, each placement's
* own copy of it.
*/
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { CreateContainerCommand, UnpackContainerCommand } from '../../src/core/commands/containerCommands';
import { PasteObjectsCommand } from '../../src/core/commands/pasteCommands';
import { AddPortsCommand } from '../../src/core/commands/portCommands';
import {
PlaceAssetCommand,
RemoveSceneObjectsCommand,
RenameSceneObjectCommand,
SetOverrideCommand,
SetParentCommand,
} from '../../src/core/commands/sceneObjectCommands';
import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands';
import type { BehaviorAsset, ContainerAsset } from '../../src/core/model/Asset';
import { copyObjects } from '../../src/core/model/clipboard';
import { instanceChildId, resolvedHolder } from '../../src/core/model/instance';
import { objectChoices, objectReference, referencedId } from '../../src/core/model/objectRefs';
import { NO_OBJECT } from '../../src/core/model/Properties';
import { findAsset, findObjectHolder } from '../../src/core/model/Project';
import { makeItem } from '../../src/core/properties/listItems';
import { coerceValue, variableDef } from '../../src/core/properties/PropertySchema';
import {
graphEditor,
makeBehavior,
makeSpriteScene,
place,
roundTrip,
type SceneFixture,
startWorld,
} from '../helpers';
/** A behavior whose object variable `target` is logged on every update. */
function watcher(f: SceneFixture): BehaviorAsset {
const behavior = makeBehavior(f, 'Watcher');
f.stack.execute(new AddVariableCommand(behavior.id, 'target', 'object'));
const edit = graphEditor(f, behavior);
const log = edit.addNode('Log');
edit.wire([
[edit.addNode('OnUpdate'), 'exec', log, 'exec'],
[edit.variables(['target']), 'target', log, 'message'],
]);
return behavior;
}
/** A Watcher and a box called "Crate" in the scene, the Watcher pointed at the box. */
function watching() {
const f = makeSpriteScene();
const behavior = watcher(f);
const watcherId = place(f, behavior.id, { x: 0, y: 0 });
const crate = place(f, f.spriteId, { x: 10, y: 0 });
f.stack.execute(new RenameSceneObjectCommand(f.sceneId, crate, 'Crate'));
f.stack.execute(new SetOverrideCommand(f.sceneId, watcherId, 'target', objectReference(crate)));
return { ...f, behavior, watcherId, crate };
}
describe('a picked object at rest', () => {
it('is kept by the override, the file, a struct field and a list item', () => {
const f = watching();
expect(f.object(f.watcherId).overrides.target).toEqual({ $object: f.crate });
const loaded = roundTrip(f.project);
const scene = loaded.assets.find((a) => a.id === f.sceneId) as {
objects: { id: string; overrides: object }[];
};
expect(scene.objects.find((o) => o.id === f.watcherId)!.overrides).toEqual({
target: { $object: f.crate },
});
const def = variableDef('held', 'struct', undefined, [{ key: 'who', kind: 'object' }]);
expect(coerceValue(def, { who: objectReference('x') })).toEqual({ who: { $object: 'x' } });
expect(makeItem('object', objectReference('x'))).toEqual({ $object: 'x' });
});
it('is nothing for anything that names no object', () => {
const def = variableDef('target', 'object');
expect(coerceValue(def, 'Crate')).toEqual(NO_OBJECT);
expect(coerceValue(def, objectReference(''))).toEqual(NO_OBJECT);
expect(referencedId('Crate')).toBe('');
});
});
describe('a picked object in a running game', () => {
it('is the object it names, and nothing once that is destroyed', () => {
const f = watching();
const run = startWorld(f, { behaviors: true });
run.frame();
expect(run.world.log.at(-1)).toBe('Crate');
run.world.remove(run.world.findByObjectId(f.crate)!);
run.frame();
expect(run.world.log.at(-1)).toBe('');
});
it('is the object inside a struct field and a list item too', () => {
const f = makeSpriteScene();
const behavior = makeBehavior(f, 'Holder');
f.stack.execute(new AddVariableCommand(behavior.id, 'held', 'struct'));
f.stack.execute(new AddStructFieldCommand(behavior.id, 'held', { key: 'who', kind: 'object' }));
f.stack.execute(new AddVariableCommand(behavior.id, 'crowd', 'list'));
const edit = graphEditor(f, behavior);
const expand = edit.addNode('ExpandStruct');
f.stack.execute(new AddPortsCommand(behavior.id, expand.id, [{ key: 'who', kind: 'object' }]));
const first = edit.addNode('ItemAt', { index: 0 });
const join = edit.addNode('Join');
const log = edit.addNode('Log');
const get = edit.variables(['held', 'crowd']);
edit.wire([
[edit.addNode('OnUpdate'), 'exec', log, 'exec'],
[get, 'held', expand, 'value'],
[get, 'crowd', first, 'list'],
[expand, 'who', join, 'a'],
[first, 'item', join, 'b'],
[join, 'text', log, 'message'],
]);
const holder = place(f, behavior.id, { x: 0, y: 0 });
const crate = place(f, f.spriteId, { x: 0, y: 0 });
const barrel = place(f, f.spriteId, { x: 0, y: 0 });
f.stack.execute(new RenameSceneObjectCommand(f.sceneId, crate, 'Crate'));
f.stack.execute(new RenameSceneObjectCommand(f.sceneId, barrel, 'Barrel'));
f.stack.execute(new SetOverrideCommand(f.sceneId, holder, 'held', { who: objectReference(crate) }));
f.stack.execute(new SetOverrideCommand(f.sceneId, holder, 'crowd', [objectReference(barrel)]));
const run = startWorld(f, { behaviors: true });
run.frame();
expect(run.world.log.at(-1)).toBe('CrateBarrel');
});
it('stays in the scene when a graph puts another object into the place (editor run)', () => {
const f = watching();
const edit = graphEditor(f, f.behavior);
const spawn = edit.addNode('Spawn', { asset: f.spriteId });
const set = edit.variables(['target'], { write: true });
edit.wire([
[edit.addNode('OnStart'), 'exec', spawn, 'exec'],
[spawn, 'exec', set, 'exec'],
[spawn, 'object', set, 'target'],
]);
const run = startWorld(f, { behaviors: true });
run.frame();
run.world.syncToModel();
run.world.syncFromModel();
run.frame();
expect(run.world.log.at(-1)).toBe('Box');
expect(f.object(f.watcherId).overrides.target).toEqual({ $object: f.crate });
});
it('follows a pick made while the game runs', () => {
const f = watching();
const barrel = place(f, f.spriteId, { x: 0, y: 0 });
f.stack.execute(new RenameSceneObjectCommand(f.sceneId, barrel, 'Barrel'));
const run = startWorld(f, { behaviors: true });
run.frame();
f.stack.execute(new SetOverrideCommand(f.sceneId, f.watcherId, 'target', objectReference(barrel)));
run.world.syncFromModel();
run.frame();
expect(run.world.log.slice(-2)).toEqual(['Crate', 'Barrel']);
});
});
describe('a picked object inside a container', () => {
/** The Watcher and its Crate made into a container, placed a second time beside the first. */
function contained() {
const f = watching();
const create = new CreateContainerCommand(f.sceneId, [f.watcherId, f.crate]);
f.stack.execute(create);
const second = new PlaceAssetCommand(f.sceneId, create.assetId, { x: 500, y: 0 });
f.stack.execute(second);
const asset = findAsset(f.project, create.assetId) as ContainerAsset;
const [tWatcher, tCrate] = asset.objects.map((o) => o.id) as [string, string];
return { ...f, create, asset, tWatcher, tCrate, first: create.objectId, second: second.objectId };
}
it('names the template copy, and each placement reaches its own', () => {
const f = contained();
expect(f.asset.objects[0]!.overrides.target).toEqual({ $object: f.tCrate });
const run = startWorld(f);
for (const placed of [f.first, f.second]) {
const watcher = run.world.findByObjectId(instanceChildId(placed, f.tWatcher))!;
const found = run.world.findReferenced(watcher, f.tCrate);
expect(found?.objectId).toBe(instanceChildId(placed, f.tCrate));
}
});
it('offers the content of its own placement, named as the template names it', () => {
const f = contained();
const choices = objectChoices(f.project, f.sceneOf(), instanceChildId(f.second, f.tWatcher));
expect(choices.map((c) => [c.ref, c.depth])).toEqual([
[f.tWatcher, 0],
[f.tCrate, 0],
]);
});
it('points a reference from outside at what the placed container shows', () => {
const f = watching();
const other = place(f, f.behavior.id, { x: 0, y: 0 });
f.stack.execute(new SetOverrideCommand(f.sceneId, other, 'target', objectReference(f.crate)));
const create = new CreateContainerCommand(f.sceneId, [f.crate]);
f.stack.execute(create);
const tCrate = (findAsset(f.project, create.assetId) as ContainerAsset).objects[0]!.id;
expect(f.object(other).overrides.target).toEqual({ $object: instanceChildId(create.objectId, tCrate) });
f.stack.undo();
expect(f.object(other).overrides.target).toEqual({ $object: f.crate });
});
it('is spelled out as the copy it names when the container is unpacked', () => {
const f = contained();
f.stack.execute(new UnpackContainerCommand(f.sceneId, f.second));
const watcher = f.object(instanceChildId(f.second, f.tWatcher));
expect(watcher.overrides.target).toEqual({ $object: instanceChildId(f.second, f.tCrate) });
});
});
describe('the objects of a scene', () => {
it('are offered in hierarchy order, the object itself included', () => {
const f = watching();
const lid = place(f, f.spriteId, { x: 0, y: 0 });
f.stack.execute(new SetParentCommand(f.sceneId, lid, f.crate));
f.stack.execute(new RenameSceneObjectCommand(f.sceneId, lid, 'Lid'));
const choices = objectChoices(f.project, f.sceneOf(), f.watcherId);
expect(choices.map((c) => [c.label, c.depth])).toEqual([
['Watcher', 0],
['Crate', 0],
['Lid', 1],
]);
expect(choices[1]!.ref).toBe(f.crate);
});
});
describe('removing and pasting', () => {
it('drops a reference to a removed object, and undo brings it back', () => {
const f = watching();
f.stack.execute(new RemoveSceneObjectsCommand(f.sceneId, [f.crate]));
expect(f.object(f.watcherId).overrides.target).toEqual(NO_OBJECT);
f.stack.undo();
expect(f.object(f.watcherId).overrides.target).toEqual({ $object: f.crate });
});
it('points a pasted copy at the copy of what it named, and leaves one out of the copy alone', () => {
const f = watching();
const both = copyObjects(f.project, f.sceneOf(), [f.watcherId, f.crate])!;
const pasteBoth = new PasteObjectsCommand(f.sceneId, both, {}, { x: 5, y: 0 });
f.stack.execute(pasteBoth);
const [watcherCopy, crateCopy] = pasteBoth.rootIds as [string, string];
expect(f.object(watcherCopy).overrides.target).toEqual({ $object: crateCopy });
const alone = copyObjects(f.project, f.sceneOf(), [f.watcherId])!;
const pasteAlone = new PasteObjectsCommand(f.sceneId, alone, {}, { x: 5, y: 0 });
f.stack.execute(pasteAlone);
expect(f.object(pasteAlone.rootIds[0]!).overrides.target).toEqual({ $object: f.crate });
});
});
describe('removing an object from a container template (#269)', () => {
/**
* Watcher, Crate and Barrel made into a container whose Watcher points at nothing, placed in
* "Level" and in "Level 2", each placement's Watcher pointed at its own Crate.
*/
function placedTwice() {
const f = watching();
const barrel = place(f, f.spriteId, { x: 20, y: 0 });
const create = new CreateContainerCommand(f.sceneId, [f.watcherId, f.crate, barrel]);
f.stack.execute(create);
const asset = findAsset(f.project, create.assetId) as ContainerAsset;
const [tWatcher, tCrate, tBarrel] = asset.objects.map((o) => o.id) as [string, string, string];
f.stack.execute(new SetOverrideCommand(asset.id, tWatcher, 'target', NO_OBJECT));
const level2 = new CreateAssetCommand('scene', 'Level 2');
f.stack.execute(level2);
const other = new PlaceAssetCommand(level2.asset.id, asset.id, { x: 0, y: 0 });
f.stack.execute(other);
const placements = [
[f.sceneId, create.objectId],
[level2.asset.id, other.objectId],
] as const;
for (const [sceneId, placed] of placements)
f.stack.execute(
new SetOverrideCommand(sceneId, instanceChildId(placed, tWatcher), 'target', objectReference(tCrate)),
);
return { ...f, asset, tWatcher, tCrate, tBarrel, level2: level2.asset.id, placements };
}
/** What the inspector shows as `target` on the object, container content resolved. */
function targetOf(f: SceneFixture, holderId: string, objectId: string) {
const holder = resolvedHolder(f.project, findObjectHolder(f.project, holderId)!);
return holder.objects.find((o) => o.id === objectId)!.overrides.target;
}
/** The edit a placement holds for its Watcher. */
function editOf(f: ReturnType<typeof placedTwice>, sceneId: string, placed: string) {
const holder = findObjectHolder(f.project, sceneId)!;
return holder.objects.find((o) => o.id === placed)!.instanceEdits[f.tWatcher];
}
it('empties the placements pointing at it in every scene, and undo brings them back', () => {
const f = placedTwice();
f.stack.execute(new RemoveSceneObjectsCommand(f.asset.id, [f.tCrate]));
for (const [sceneId, placed] of f.placements) {
expect(targetOf(f, sceneId, instanceChildId(placed, f.tWatcher))).toEqual(NO_OBJECT);
// None is what the template says too, so the placement follows it again.
expect(editOf(f, sceneId, placed)).toBeUndefined();
}
f.stack.undo();
for (const [sceneId, placed] of f.placements) {
expect(targetOf(f, sceneId, instanceChildId(placed, f.tWatcher))).toEqual({ $object: f.tCrate });
expect(editOf(f, sceneId, placed)?.overrides.target).toEqual({ $object: f.tCrate });
}
});
it('leaves a placement at None where the template names something else', () => {
const f = placedTwice();
f.stack.execute(new SetOverrideCommand(f.asset.id, f.tWatcher, 'target', objectReference(f.tBarrel)));
f.stack.execute(new RemoveSceneObjectsCommand(f.asset.id, [f.tCrate]));
const [sceneId, placed] = f.placements[0];
expect(targetOf(f, sceneId, instanceChildId(placed, f.tWatcher))).toEqual(NO_OBJECT);
expect(editOf(f, sceneId, placed)?.overrides.target).toEqual(NO_OBJECT);
});
it('empties a reference from outside the placement, and leaves one to what stays alone', () => {
const f = placedTwice();
const [, placed] = f.placements[0];
const toCrate = place(f, f.behavior.id, { x: 0, y: 0 });
const toBarrel = place(f, f.behavior.id, { x: 0, y: 0 });
const crate = objectReference(instanceChildId(placed, f.tCrate));
const barrel = objectReference(instanceChildId(placed, f.tBarrel));
f.stack.execute(new SetOverrideCommand(f.sceneId, toCrate, 'target', crate));
f.stack.execute(new SetOverrideCommand(f.sceneId, toBarrel, 'target', barrel));
f.stack.execute(new RemoveSceneObjectsCommand(f.asset.id, [f.tCrate]));
expect(f.object(toCrate).overrides.target).toEqual(NO_OBJECT);
expect(f.object(toBarrel).overrides.target).toEqual(barrel);
f.stack.undo();
expect(f.object(toCrate).overrides.target).toEqual(crate);
});
it('reaches placements nested in another container, template and scene alike', () => {
const f = placedTwice();
const [, placed] = f.placements[0];
// The placement in "Level" becomes the content of Outer and takes its edit into Outer's template.
const outer = new CreateContainerCommand(f.sceneId, [placed]);
f.stack.execute(outer);
const outerAsset = findAsset(f.project, outer.assetId) as ContainerAsset;
const inner = outerAsset.objects[0]!.id;
const outerPlaced = new PlaceAssetCommand(f.level2, outerAsset.id, { x: 0, y: 0 });
f.stack.execute(outerPlaced);
const nestedWatcher = instanceChildId(outerPlaced.objectId, `${inner}/${f.tWatcher}`);
f.stack.execute(new SetOverrideCommand(f.level2, nestedWatcher, 'target', objectReference(f.tBarrel)));
f.stack.execute(new RemoveSceneObjectsCommand(f.asset.id, [f.tCrate]));
expect(targetOf(f, outerAsset.id, instanceChildId(inner, f.tWatcher))).toEqual(NO_OBJECT);
expect(targetOf(f, f.level2, nestedWatcher)).toEqual({ $object: f.tBarrel });
f.stack.execute(new RemoveSceneObjectsCommand(f.asset.id, [f.tBarrel]));
expect(targetOf(f, f.level2, nestedWatcher)).toEqual(NO_OBJECT);
f.stack.undo();
f.stack.undo();
expect(targetOf(f, outerAsset.id, instanceChildId(inner, f.tWatcher))).toEqual({ $object: f.tCrate });
expect(targetOf(f, f.level2, nestedWatcher)).toEqual({ $object: f.tBarrel });
});
});