From 5e21d473a45a33864e5eb52ec79df72314ec27f0 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Thu, 1 Oct 2026 07:28:49 +0200 Subject: [PATCH] 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 --- README.md | 2 +- src/core/commands/assetCommands.ts | 9 +- src/core/commands/containerCommands.ts | 17 +++- src/core/commands/pasteCommands.ts | 4 +- src/core/model/instance.ts | 10 ++ src/core/model/objectRefs.ts | 33 +++++- tests/core/object-references.test.ts | 135 ++++++++++++++++++++++++- tools/e2e/57-object-references.mjs | 101 +++++++++++++++++- tools/e2e/lib/scaffold.mjs | 1 + 9 files changed, 295 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 00e8c2e9..dd272371 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1607 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1609 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index 658bdc47..c27285a3 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -15,6 +15,7 @@ import { isFontFile } from '../model/font'; import { DEFAULT_PALETTE, PIXEL_CANVAS_DEFAULT } from '../model/pixelart'; import { isSoundFile } from '../model/sound'; import { removeSubtrees } from '../model/hierarchy'; +import { dropReferencesToGone, shownObjectIds } from '../model/objectRefs'; import type { PropertyBag, PropertyValue } from '../model/Properties'; import { objectHolders, type Project } from '../model/Project'; import { @@ -30,6 +31,7 @@ import { isTextureFile } from '../model/texture'; import { cellsWithout } from '../model/tilemap'; import { coerceValue, defaultsFor, initialOf, initialPropsFor } from '../properties/PropertySchema'; import { type Removed, removeById, removeFirst, restoreAt } from '../util/list'; +import { Recorder } from '../util/recorder'; import { type Command, CommandError } from './Command'; import { requireAsset, requirePropDef, requireStateMachine, requireVariablesAsset } from './require'; import { SetBagValueCommand } from './SetBagValueCommand'; @@ -212,13 +214,15 @@ export class CreateSoundCommand extends ImportFileCommand { /** * Deletes an asset and, as a cascade, every scene object referencing it, together with the objects * parented below those – in container templates as well as in scenes. A tile takes the cells it is - * painted on along (#268), which would otherwise stay in the file and draw nothing. + * painted on along (#268), which would otherwise stay in the file and draw nothing. A reference to + * an object that goes is emptied, as when the object is removed by itself (#286). */ export class DeleteAssetCommand implements Command { readonly label = 'Delete asset'; private removed?: Removed; private removedObjects: { holderId: string; removed: Removed[] }[] = []; private emptied: { layer: TilemapLayer; cells: Record }[] = []; + private readonly references = new Recorder(); private previousActiveScene = ''; constructor(private readonly assetId: string) {} @@ -227,6 +231,7 @@ export class DeleteAssetCommand implements Command { const asset = requireAsset(project, this.assetId); this.removedObjects = []; this.emptied = []; + const before = shownObjectIds(project); if (asset.type === 'tile') for (const map of project.assets) { if (map.type !== 'tilemap') continue; @@ -245,6 +250,7 @@ export class DeleteAssetCommand implements Command { this.removedObjects.push({ holderId: holder.id, removed: removeSubtrees(holder, roots) }); } this.removed = removeFirst(project.assets, (a) => a.id === this.assetId); + dropReferencesToGone(project, before, this.references); this.previousActiveScene = project.activeSceneId; if (project.activeSceneId === this.assetId) { const next = project.assets.find((a) => a.type === 'scene'); @@ -254,6 +260,7 @@ export class DeleteAssetCommand implements Command { undo(project: Project): void { if (!this.removed) return; + this.references.revert(); restoreAt(project.assets, [this.removed]); const holders = new Map(objectHolders(project).map((h) => [h.id, h])); for (const entry of this.removedObjects) { diff --git a/src/core/commands/containerCommands.ts b/src/core/commands/containerCommands.ts index 022ce55d..f7147b33 100644 --- a/src/core/commands/containerCommands.ts +++ b/src/core/commands/containerCommands.ts @@ -1,8 +1,14 @@ import { newId } from '../ids'; import type { ContainerAsset } from '../model/Asset'; import { findObject, removeSubtrees, subtreeIds, subtreeRoots, treeOrder } from '../model/hierarchy'; -import { instanceChildId, type InstanceEdit, isLiveInstance, materializeInstance } from '../model/instance'; -import { anchorReferences, swapReferences } from '../model/objectRefs'; +import { + instanceChildId, + type InstanceEdit, + isLiveInstance, + materializeInstance, + pathOfId, +} from '../model/instance'; +import { anchorReferences, renamedReference, swapReferences } from '../model/objectRefs'; import { apply, invert, multiply, translation } from '../model/matrix'; import type { Position } from '../model/Properties'; import { findAsset, findObjectHolder, type Project } from '../model/Project'; @@ -114,12 +120,13 @@ export class CreateContainerCommand implements Command { if (!containerParentId && groupId) container.groupId = groupId; this.removed = removeSubtrees(holder, [...roots]); // A reference to a member follows it (#258): inside the template to its template id, from the - // rest of the holder to what the placed container shows of it. - const templateIdOf = (ref: string) => templateIds.get(ref); + // rest of the holder to what the placed container shows of it – content of a member placement + // included (#285). + const templateIdOf = (ref: string) => renamedReference(ref, templateIds); swapReferences(asset.objects, templateIdOf, this.references); const shown = (ref: string) => { const moved = templateIdOf(ref); - return moved && instanceChildId(this.objectId, moved); + return moved && instanceChildId(this.objectId, pathOfId(moved)); }; swapReferences(holder.objects, shown, this.references); project.assets.push(asset); diff --git a/src/core/commands/pasteCommands.ts b/src/core/commands/pasteCommands.ts index 06b96471..9f32731d 100644 --- a/src/core/commands/pasteCommands.ts +++ b/src/core/commands/pasteCommands.ts @@ -5,7 +5,7 @@ import { newId } from '../ids'; import type { GraphClip, ObjectClip } from '../model/clipboard'; import { findGroup } from '../model/hierarchy'; import { isLiveInstance } from '../model/instance'; -import { swapReferences } from '../model/objectRefs'; +import { renamedReference, swapReferences } from '../model/objectRefs'; import { Recorder } from '../util/recorder'; import { placementRejection } from '../model/placement'; import type { Vec2 } from '../model/Properties'; @@ -79,7 +79,7 @@ export class PasteObjectsCommand implements Command { return copy; }); // A reference among the copied objects points at the copy; one out of them stays (#258). - swapReferences(pasted, (ref) => this.ids.get(ref), new Recorder()); + swapReferences(pasted, (ref) => renamedReference(ref, this.ids), new Recorder()); holder.objects.push(...pasted); } diff --git a/src/core/model/instance.ts b/src/core/model/instance.ts index a9817a81..d7b05982 100644 --- a/src/core/model/instance.ts +++ b/src/core/model/instance.ts @@ -105,6 +105,16 @@ export function splitInstanceId(id: string): { instanceId: string; path: string return { instanceId: id.slice(0, at), path: id.slice(at + 1) }; } +/** The template path an id of a holder's own view stands for once the holder is placed: `q~x` → `q/x`. */ +export function pathOfId(id: string): string { + return id.replace(ID_SEP, PATH_SEP); +} + +/** The id a holder's own view gives the object at a template path: `q/x` → `q~x`. */ +export function idOfPath(path: string): string { + return path.replace(PATH_SEP, ID_SEP); +} + /** * Whether the object is only resolved from a template rather than stored in the holder. The * question is where it lives, not what its id looks like: unpacking keeps the derived id – so the diff --git a/src/core/model/objectRefs.ts b/src/core/model/objectRefs.ts index 60f06261..eece9b03 100644 --- a/src/core/model/objectRefs.ts +++ b/src/core/model/objectRefs.ts @@ -1,11 +1,21 @@ /** * An object's place names another object of the same scene by its id (#258): `{"$object": ""}`, - * empty for none. Inside a container the id is the one the template gives it, so every placement of - * the container points at its own copy – which is why a reference is looked up from where it sits. + * empty for none. Inside a container the id is the one the container's own view gives it, so every + * placement of the container points at its own copy – which is why a reference is looked up from + * where it sits. Content of a placement nested in the container is `q~x` there and `r~q/x` in its + * placement r (#285). */ import { findAsset, type ObjectHolder, objectHolders, type Project } from './Project'; import { treeOrder, type ObjectTree } from './hierarchy'; -import { instanceChildId, instanceTargetOf, isEmptyEdit, resolveObjects } from './instance'; +import { + idOfPath, + instanceChildId, + instanceTargetOf, + isEmptyEdit, + pathOfId, + resolveObjects, + splitInstanceId, +} from './instance'; import { mapNested, type PropertyBag, @@ -45,7 +55,19 @@ function scopeOf(objectId: string): string { export function referenceCandidates(fromId: string, ref: string): string[] { if (!ref) return []; const scope = scopeOf(fromId); - return scope ? [scope + ref, ref] : [ref]; + return scope ? [scope + pathOfId(ref), ref] : [ref]; +} + +/** + * `ref` once the objects in `ids` are renamed: the object itself, or content of a placement that is + * renamed – the copy of the placement shows the same content. Undefined when it stays as it is. + */ +export function renamedReference(ref: string, ids: ReadonlyMap): string | undefined { + const own = ids.get(ref); + if (own) return own; + const split = splitInstanceId(ref); + const placement = split && ids.get(split.instanceId); + return placement && instanceChildId(placement, split.path); } export interface ObjectChoice { @@ -70,7 +92,8 @@ export function objectChoices(project: Project, holder: ObjectTree, objectId: st for (const { object, depth } of treeOrder({ ...holder, objects })) { if (!object.id.startsWith(scope)) continue; const label = objectName(object, findAsset(project, object.assetId)); - choices.push({ ref: object.id.slice(scope.length), objectId: object.id, label, depth }); + const ref = scope ? idOfPath(object.id.slice(scope.length)) : object.id; + choices.push({ ref, objectId: object.id, label, depth }); } if (!scope) return choices; // The container's own content sits one level below it in the tree. diff --git a/tests/core/object-references.test.ts b/tests/core/object-references.test.ts index e94d7102..5dab761a 100644 --- a/tests/core/object-references.test.ts +++ b/tests/core/object-references.test.ts @@ -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); + }); }); diff --git a/tools/e2e/57-object-references.mjs b/tools/e2e/57-object-references.mjs index 5673c842..4c56305a 100644 --- a/tools/e2e/57-object-references.mjs +++ b/tools/e2e/57-object-references.mjs @@ -10,6 +10,9 @@ * Variables tab it goes to the scene view and comes back with Take. * * Removing an object in the Container tab empties a reference a placement of it holds (#269). + * Inside a container the Aim can pick content of a placement nested there, and the container's + * placement in the scene reaches it (#285). Deleting that nested container's asset empties the + * reference again (#286). * * Everything it adds carries an `or57` id and goes again at the end. */ @@ -340,6 +343,102 @@ export async function objectReferences(t) { JSON.stringify({ rowBefore, tabThen, templateLeft, emptied, rowAfter, restored }), ); + // 57i. Outer 57 holds an Aim and a placement of Inner 57 (#285). In the Container tab the Aim + // picks Inner's Nest 57, stored as Outer's own view names it. Outer's placement in the scene + // shows that name on its Aim, and a run reads the copy of Nest 57 there. + await t.showTab('scene'); + await mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + ${MAKE_OBJECT} + const sprite = p.assets.find((a) => a.type === 'sprite'); + p.assets.push({ + id: 'or57-inner', + type: 'container', + name: 'Inner 57', + props: {}, + objects: [make('or57-i-nest', sprite.id, '', 0, 'Nest 57')], + }); + p.assets.push({ + id: 'or57-outer', + type: 'container', + name: 'Outer 57', + props: {}, + objects: [ + make('or57-o-aim', 'or57-aim', '', 0, 'Aim 57'), + make('or57-o-inner', 'or57-inner', '', 50, 'Inner 57'), + ], + }); + scene.objects.push(make('or57-outer-placed', 'or57-outer', '', ${LANES.nestedReference}, 'Outer 57')); + `); + const outerTarget = () => + json( + "__gm.state.project.assets.find((a) => a.id === 'or57-outer').objects.find((o) => o.id === 'or57-o-aim')?.overrides.target ?? null", + ); + const nestName = JSON.stringify({ $object: 'or57-o-inner~or57-i-nest' }); + await select({ kind: 'object', holderId: 'or57-outer', objectId: 'or57-o-aim' }); + await sleep(200); + const outerTab = await json('__gm.tab()'); + await press_(await json("__gm.inspectorField('target')"), 'the target field in the Container tab'); + const inOuter = (await json('__gm.overlayTexts()')) ?? []; + // Nest 57 sits one level below Inner 57, so the menu indents it. + const nestEntry = inOuter.find((text) => text.trim() === 'Nest 57'); + await press_(await json(`__gm.locate(${JSON.stringify(nestEntry)}, true)`), 'Nest 57 in the menu'); + const stored = await outerTarget(); + await t.showTab('scene'); + const nestedAim = { kind: 'object', holderId: sceneId, objectId: 'or57-outer-placed~or57-o-aim' }; + await select(nestedAim); + await sleep(200); + const nestedRow = { nest: await inInspector('Nest 57'), missing: await inInspector('(missing)') }; + await shot('05-nested-reference'); + await evaluate('__gm.state.select(null)'); + const runFrom = t.consoleLog.length; + await t.play(); + await sleep(600); + await stop(); + const nestedLogged = t.consoleLog + .slice(runFrom) + .filter((line) => line.startsWith('[behavior Aim 57]')) + .map((line) => line.split(']').pop().trim()); + check( + 'a pick in the Container tab reaches into a nested placement, and the placement in the scene shows and reads it', + outerTab === 'container' && + nestEntry !== undefined && + JSON.stringify(stored) === nestName && + nestedRow.nest && + !nestedRow.missing && + nestedLogged.includes('Nest 57'), + JSON.stringify({ outerTab, inOuter, stored, nestedRow, nestedLogged }), + ); + + // 57j. Deleting Inner 57 in the asset list (#286) takes its placement out of Outer 57 and empties + // the Aim's reference to Nest 57 with it: the row says None, not (missing). One undo brings + // both back. + const innerRow = await json("__gm.assetRow('or57-inner')"); + if (!innerRow) throw new Error('Inner 57 is not in the asset list'); + await click(innerRow.x, innerRow.y, 'right'); + await sleep(150); + await press_(await json("__gm.locate('Delete…', true)"), 'Delete… in the row menu'); + await press_(await json("__gm.locate('Delete', true)"), 'Delete in the dialog'); + const innerLeft = await json("__gm.state.project.assets.some((a) => a.id === 'or57-inner')"); + const cleared = await outerTarget(); + await select(nestedAim); + await sleep(200); + const clearedRow = await targetRow(); + await shot('06-asset-deleted'); + await undo(); + await sleep(150); + const reinstated = await outerTarget(); + check( + 'deleting an asset empties a reference to an object it took along, undo brings it back', + !innerLeft && + JSON.stringify(cleared) === JSON.stringify({ $object: '' }) && + clearedRow.none && + !clearedRow.missing && + JSON.stringify(reinstated) === nestName, + JSON.stringify({ innerLeft, cleared, clearedRow, reinstated }), + ); + // Clean up. Back to the scene view first: removing Aim 57 closes 57g's Variables tab, and the // editor then shows whatever document earlier steps left open, not the scene view. await t.showTab('scene'); @@ -348,6 +447,6 @@ export async function objectReferences(t) { const p = __gm.state.project; const scene = __gm.state.activeScene; scene.objects = scene.objects.filter((o) => !o.id.startsWith('or57-')); - p.assets = p.assets.filter((a) => a.id !== 'or57-aim' && a.id !== 'or57-box'); + p.assets = p.assets.filter((a) => !a.id.startsWith('or57-')); `); } diff --git a/tools/e2e/lib/scaffold.mjs b/tools/e2e/lib/scaffold.mjs index 6a801859..48970222 100644 --- a/tools/e2e/lib/scaffold.mjs +++ b/tools/e2e/lib/scaffold.mjs @@ -29,6 +29,7 @@ export const LANES = { pixelArt: -5400, eyedropper: -5800, containerReference: -6200, + nestedReference: -6600, }; /**