fix(core): preserve legacy site children during scene healing (#544)
This commit is contained in:
@@ -0,0 +1,75 @@
|
|||||||
|
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||||
|
import type { AnyNode } from '../schema'
|
||||||
|
import useScene from './use-scene'
|
||||||
|
|
||||||
|
describe('legacy site child migration', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useScene.setState({
|
||||||
|
nodes: {},
|
||||||
|
rootNodeIds: [],
|
||||||
|
dirtyNodes: new Set(),
|
||||||
|
collections: {},
|
||||||
|
} as never)
|
||||||
|
useScene.temporal.getState().clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps a flat building subtree referenced by an embedded site child', () => {
|
||||||
|
const embeddedBuilding = {
|
||||||
|
object: 'node',
|
||||||
|
id: 'building_legacy',
|
||||||
|
type: 'building',
|
||||||
|
parentId: null,
|
||||||
|
visible: true,
|
||||||
|
metadata: {},
|
||||||
|
children: ['level_legacy'],
|
||||||
|
position: [0, 0, 0],
|
||||||
|
rotation: [0, 0, 0],
|
||||||
|
}
|
||||||
|
|
||||||
|
useScene.getState().setScene(
|
||||||
|
{
|
||||||
|
site_legacy: {
|
||||||
|
object: 'node',
|
||||||
|
id: 'site_legacy',
|
||||||
|
type: 'site',
|
||||||
|
parentId: null,
|
||||||
|
visible: true,
|
||||||
|
metadata: {},
|
||||||
|
children: [embeddedBuilding],
|
||||||
|
},
|
||||||
|
building_legacy: embeddedBuilding,
|
||||||
|
level_legacy: {
|
||||||
|
object: 'node',
|
||||||
|
id: 'level_legacy',
|
||||||
|
type: 'level',
|
||||||
|
parentId: null,
|
||||||
|
visible: true,
|
||||||
|
metadata: {},
|
||||||
|
children: ['wall_legacy'],
|
||||||
|
level: 0,
|
||||||
|
},
|
||||||
|
wall_legacy: {
|
||||||
|
object: 'node',
|
||||||
|
id: 'wall_legacy',
|
||||||
|
type: 'wall',
|
||||||
|
parentId: 'level_legacy',
|
||||||
|
visible: true,
|
||||||
|
metadata: {},
|
||||||
|
children: [],
|
||||||
|
start: [0, 0],
|
||||||
|
end: [4, 0],
|
||||||
|
},
|
||||||
|
} as unknown as Record<string, AnyNode>,
|
||||||
|
['site_legacy'] as never,
|
||||||
|
)
|
||||||
|
|
||||||
|
const nodes = useScene.getState().nodes
|
||||||
|
expect(Object.keys(nodes).sort()).toEqual([
|
||||||
|
'building_legacy',
|
||||||
|
'level_legacy',
|
||||||
|
'site_legacy',
|
||||||
|
'wall_legacy',
|
||||||
|
])
|
||||||
|
expect((nodes.site_legacy as { children: string[] }).children).toEqual(['building_legacy'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -17,6 +17,28 @@ describe('healSceneNodes', () => {
|
|||||||
expect((nodes.wall_a as { children: string[] }).children).toEqual(['item_x'])
|
expect((nodes.wall_a as { children: string[] }).children).toEqual(['item_x'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('preserves legacy embedded site children for the scene migration', () => {
|
||||||
|
const building = {
|
||||||
|
id: 'building_legacy',
|
||||||
|
type: 'building',
|
||||||
|
parentId: null,
|
||||||
|
children: ['level_legacy'],
|
||||||
|
}
|
||||||
|
const { nodes, strippedChildRefs } = healSceneNodes({
|
||||||
|
site_legacy: {
|
||||||
|
id: 'site_legacy',
|
||||||
|
type: 'site',
|
||||||
|
parentId: null,
|
||||||
|
children: [building, null],
|
||||||
|
},
|
||||||
|
building_legacy: building,
|
||||||
|
level_legacy: { id: 'level_legacy', type: 'level', parentId: null, children: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(strippedChildRefs).toBe(1)
|
||||||
|
expect((nodes.site_legacy as { children: unknown[] }).children).toEqual([building])
|
||||||
|
})
|
||||||
|
|
||||||
test('drops childless zero-length walls and removes their parent reference', () => {
|
test('drops childless zero-length walls and removes their parent reference', () => {
|
||||||
const { nodes, droppedWallIds } = healSceneNodes({
|
const { nodes, droppedWallIds } = healSceneNodes({
|
||||||
level_0: { id: 'level_0', type: 'level', children: ['wall_zero', 'wall_real'] },
|
level_0: { id: 'level_0', type: 'level', children: ['wall_zero', 'wall_real'] },
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export interface HealSceneResult {
|
|||||||
nodes: Record<string, unknown>
|
nodes: Record<string, unknown>
|
||||||
/** Ids of zero-length walls that were dropped. */
|
/** Ids of zero-length walls that were dropped. */
|
||||||
droppedWallIds: string[]
|
droppedWallIds: string[]
|
||||||
/** Count of non-string (e.g. null) entries removed from `children` arrays. */
|
/** Count of invalid non-string (e.g. null) entries removed from `children` arrays. */
|
||||||
strippedChildRefs: number
|
strippedChildRefs: number
|
||||||
/**
|
/**
|
||||||
* Count of child references removed because the child's `parentId` points at
|
* Count of child references removed because the child's `parentId` points at
|
||||||
@@ -74,26 +74,43 @@ export function healSceneNodes(input: Record<string, unknown>): HealSceneResult
|
|||||||
let strippedChildRefs = 0
|
let strippedChildRefs = 0
|
||||||
let strippedStaleChildRefs = 0
|
let strippedStaleChildRefs = 0
|
||||||
|
|
||||||
// Pass 2: clean `children` arrays — drop non-string entries (the `[null]`
|
// Pass 2: clean `children` arrays — drop invalid non-string entries (the
|
||||||
// bug), references to walls we just removed, same-array duplicates, and
|
// `[null]` bug), references to walls we just removed, same-array duplicates,
|
||||||
// stale references whose child's `parentId` names a different parent.
|
// and stale references whose child's `parentId` names a different parent.
|
||||||
|
// Legacy sites embedded full child objects; keep those for migrateNodes to
|
||||||
|
// flatten after healing instead of disconnecting the entire building.
|
||||||
const nodes: Record<string, unknown> = {}
|
const nodes: Record<string, unknown> = {}
|
||||||
for (const [id, node] of Object.entries(kept)) {
|
for (const [id, node] of Object.entries(kept)) {
|
||||||
const children = (node as { children?: unknown })?.children
|
const children = (node as { children?: unknown })?.children
|
||||||
if (Array.isArray(children)) {
|
if (Array.isArray(children)) {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const cleaned = children.filter((c): c is string => {
|
const cleaned = children.filter((child) => {
|
||||||
if (typeof c !== 'string' || dropped.has(c)) {
|
const embeddedSiteChildId =
|
||||||
strippedChildRefs++
|
(node as { type?: unknown }).type === 'site' &&
|
||||||
return false
|
child &&
|
||||||
}
|
typeof child === 'object' &&
|
||||||
if (seen.has(c)) {
|
typeof (child as { id?: unknown }).id === 'string'
|
||||||
|
? (child as { id: string }).id
|
||||||
|
: null
|
||||||
|
if (embeddedSiteChildId) {
|
||||||
|
if (seen.has(embeddedSiteChildId)) {
|
||||||
strippedStaleChildRefs++
|
strippedStaleChildRefs++
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
seen.add(c)
|
seen.add(embeddedSiteChildId)
|
||||||
const child = kept[c] as { parentId?: unknown } | undefined
|
return true
|
||||||
if (child && typeof child.parentId === 'string' && child.parentId !== id) {
|
}
|
||||||
|
if (typeof child !== 'string' || dropped.has(child)) {
|
||||||
|
strippedChildRefs++
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (seen.has(child)) {
|
||||||
|
strippedStaleChildRefs++
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen.add(child)
|
||||||
|
const childNode = kept[child] as { parentId?: unknown } | undefined
|
||||||
|
if (childNode && typeof childNode.parentId === 'string' && childNode.parentId !== id) {
|
||||||
strippedStaleChildRefs++
|
strippedStaleChildRefs++
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user