Fix references into nested placements and on asset delete (#285, #286)

Two findings from #269. A reference from a container into a placement
nested in it was dead from the start, and deleting an asset left
references to the objects it took along showing "(missing)".

- A reference is stored the way its holder shows the object. In a
  container tab that is q~x for content of a nested placement q.
- referenceCandidates translates it when the container is placed: in
  placement r, q~x becomes r~q/x. pathOfId and idOfPath in instance.ts
  do the mapping, objectChoices offers the same spelling the other way.
  The rule holds at any depth.
- Storing q/x as proposed in #285 was dropped. The container tab would
  then have had to translate its own ids.
- No migration. All projects and snapshots hold three references, none
  of them with ~ or /.
- Create container lets a reference to content of a member placement
  follow it. It was left dead before.
- Paste lets such a reference point into the copy. It kept pointing at
  the original before. Both go through the new renamedReference.
- Delete asset records what every holder shows before the cascade and
  clears every reference to an object that is gone afterwards, with
  dropReferencesToGone from #269. The row shows None, undo restores
  the references (#286).

Tests:
- Gate: 198 files, 2882 tests (2873 + 9), exit 0
- Unit object-references: 26 passed (17 + 9)
- e2e 1-17,57: 566 checks passed, 57 has +57i and +57j
- Six unit counter-probes fail between 1 and 3 tests each
- The browser counter-probe fails 57i and 57j as described in the
  issues
- e2e total 1609 is computed, not measured in a full run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-01 07:28:49 +02:00
1 parent 214218d1b2
commit 5e21d473a4
9 files changed
+295 -17

No files matched your search

+133 -2
View File
@@ -4,7 +4,7 @@
* own copy of it.
*/
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { CreateAssetCommand, DeleteAssetCommand } 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';
@@ -19,7 +19,12 @@ import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/comman
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 {
objectChoices,
objectReference,
referenceCandidates,
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';
@@ -355,4 +360,130 @@ describe('removing an object from a container template (#269)', () => {
expect(targetOf(f, outerAsset.id, instanceChildId(inner, f.tWatcher))).toEqual({ $object: f.tCrate });
expect(targetOf(f, f.level2, nestedWatcher)).toEqual({ $object: f.tBarrel });
});
it('does the same when the asset of the object is deleted (#286)', () => {
const f = placedTwice();
f.stack.execute(new DeleteAssetCommand(f.spriteId));
for (const [sceneId, placed] of f.placements) {
expect(targetOf(f, sceneId, instanceChildId(placed, f.tWatcher))).toEqual(NO_OBJECT);
expect(editOf(f, sceneId, placed)).toBeUndefined();
}
f.stack.undo();
for (const [sceneId, placed] of f.placements)
expect(editOf(f, sceneId, placed)?.overrides.target).toEqual({ $object: f.tCrate });
});
});
describe('a reference into a placement nested in a container (#285)', () => {
/**
* The Crate made into "Inner", then the Watcher and that placement into "Outer", placed a second
* time. A Lookout outside points at the Crate as well.
*/
function nested() {
const f = watching();
const lookout = place(f, f.behavior.id, { x: 0, y: 0 });
f.stack.execute(new SetOverrideCommand(f.sceneId, lookout, 'target', objectReference(f.crate)));
const inner = new CreateContainerCommand(f.sceneId, [f.crate]);
f.stack.execute(inner);
const innerPlaced = inner.objectId;
const outer = new CreateContainerCommand(f.sceneId, [f.watcherId, innerPlaced]);
f.stack.execute(outer);
const second = new PlaceAssetCommand(f.sceneId, outer.assetId, { x: 500, y: 0 });
f.stack.execute(second);
const asset = findAsset(f.project, outer.assetId) as ContainerAsset;
const tWatcher = asset.objects.find((o) => o.assetId === f.behavior.id)!.id;
const tInner = asset.objects.find((o) => o.assetId === inner.assetId)!.id;
const tCrate = (findAsset(f.project, inner.assetId) as ContainerAsset).objects[0]!.id;
/** The Crate as Outer's own view names it. */
const crate = instanceChildId(tInner, tCrate);
/** The Crate's copy in a placement of Outer. */
const copyIn = (placed: string) => instanceChildId(placed, `${tInner}/${tCrate}`);
const placements = [outer.objectId, second.objectId] as const;
return { ...f, lookout, inner, innerPlaced, asset, tWatcher, crate, copyIn, placements };
}
it('is picked in the container as its view names it, and every placement reaches its own copy', () => {
const f = nested();
f.stack.execute(new SetOverrideCommand(f.asset.id, f.tWatcher, 'target', NO_OBJECT));
const choice = objectChoices(f.project, f.asset, f.tWatcher).find((c) => c.label === 'Crate')!;
expect(choice).toMatchObject({ ref: f.crate, objectId: f.crate });
f.stack.execute(new SetOverrideCommand(f.asset.id, f.tWatcher, 'target', objectReference(choice.ref)));
const run = startWorld(f);
for (const placed of f.placements) {
const watcher = run.world.findByObjectId(instanceChildId(placed, f.tWatcher))!;
expect(run.world.findReferenced(watcher, f.crate)?.objectId).toBe(f.copyIn(placed));
}
});
it('is shown as the Crate on a placement, and offered there with the same name', () => {
const f = nested();
const [, placed] = f.placements;
const choices = objectChoices(f.project, f.sceneOf(), instanceChildId(placed, f.tWatcher));
expect(choices.find((c) => c.ref === f.crate)).toMatchObject({
objectId: f.copyIn(placed),
label: 'Crate',
});
});
it('follows its object when the placement around it becomes container content', () => {
const f = nested();
expect(f.asset.objects.find((o) => o.id === f.tWatcher)!.overrides.target).toEqual({ $object: f.crate });
expect(f.object(f.lookout).overrides.target).toEqual({ $object: f.copyIn(f.placements[0]) });
const run = startWorld(f);
const lookout = run.world.findByObjectId(f.lookout)!;
expect(run.world.findReferenced(lookout, f.copyIn(f.placements[0]))?.objectId).toBe(
f.copyIn(f.placements[0]),
);
});
it('is spelled out as the copy it names when the container is unpacked', () => {
const f = nested();
const [, placed] = f.placements;
f.stack.execute(new UnpackContainerCommand(f.sceneId, placed));
expect(f.object(instanceChildId(placed, f.tWatcher)).overrides.target).toEqual({
$object: f.copyIn(placed),
});
});
it('reaches content nested deeper as well, from content of a placement', () => {
expect(referenceCandidates('r~q/x', 'p~z')).toEqual(['r~q/p/z', 'p~z']);
expect(referenceCandidates('r~x', 'p~z/y')).toEqual(['r~p/z/y', 'p~z/y']);
});
it('points a pasted copy at the content of the copied placement', () => {
const f = watching();
const inner = new CreateContainerCommand(f.sceneId, [f.crate]);
f.stack.execute(inner);
const tCrate = (findAsset(f.project, inner.assetId) as ContainerAsset).objects[0]!.id;
const both = copyObjects(f.project, f.sceneOf(), [f.watcherId, inner.objectId])!;
const paste = new PasteObjectsCommand(f.sceneId, both, {}, { x: 5, y: 0 });
f.stack.execute(paste);
const copies = paste.rootIds.map((id) => f.object(id));
const watcherCopy = copies.find((o) => o.assetId === f.behavior.id)!;
const innerCopy = copies.find((o) => o.assetId === inner.assetId)!;
expect(watcherCopy.overrides.target).toEqual({ $object: instanceChildId(innerCopy.id, tCrate) });
});
});
describe('deleting an asset (#286)', () => {
it('empties a reference to an object placed from it, and undo brings both back', () => {
const f = watching();
f.stack.execute(new DeleteAssetCommand(f.spriteId));
expect(f.object(f.watcherId).overrides.target).toEqual(NO_OBJECT);
f.stack.undo();
expect(f.object(f.watcherId).overrides.target).toEqual({ $object: f.crate });
expect(f.object(f.crate).name).toBe('Crate');
});
it('empties a reference into a placement of a deleted container', () => {
const f = watching();
const create = new CreateContainerCommand(f.sceneId, [f.crate]);
f.stack.execute(create);
const named = f.object(f.watcherId).overrides.target;
expect(referencedId(named)).toContain(create.objectId);
f.stack.execute(new DeleteAssetCommand(create.assetId));
expect(f.object(f.watcherId).overrides.target).toEqual(NO_OBJECT);
f.stack.undo();
expect(f.object(f.watcherId).overrides.target).toEqual(named);
});
});