|
- import assert from 'node:assert/strict'
- import test from 'node:test'
- import { readFileSync } from 'node:fs'
- import { createRequire } from 'node:module'
- import * as historyModule from '../../unreal_tran_web/src/features/guide-legacy/demo-src/scene-history.js'
- import { chooseSceneSelection } from '../../unreal_tran_web/src/features/guide-legacy/demo-src/scene-selection.js'
- import { findDuplicateSceneBindingIds } from '../../unreal_tran_web/src/features/guide-legacy/demo-src/scene-validation.js'
- import * as rendering from '../../unreal_tran_web/src/features/guide-legacy/demo-src/scene-rendering-profile.js'
- import * as environment from '../../unreal_tran_web/src/features/guide-legacy/demo-src/scene-environment.js'
- import { SceneSelectionHighlight } from '../../unreal_tran_web/src/features/guide-legacy/demo-src/scene-selection-highlight.js'
-
- const requireWeb = createRequire(new URL('../../unreal_tran_web/package.json', import.meta.url))
- const requirePrototype = createRequire(new URL('../../unreal_tran/package.json', import.meta.url))
- const THREE = requireWeb('three')
- const { Window } = requirePrototype('happy-dom')
- const { SceneHistory } = historyModule
- const source = readFileSync(new URL('../../unreal_tran_web/src/features/guide-legacy/demo-src/scene-editor.js', import.meta.url), 'utf8')
- // Execute the shipped class methods with real Three.js objects and a DOM. Only
- // WebGL construction / external GLB transport are replaced in these Node tests.
- const body = source.slice(source.indexOf('const STORAGE_KEY'), source.indexOf('/** 当前挂载的实例'))
-
- function fixture() {
- const window = new Window()
- const document = window.document
- const bindings = {
- THREE, window, document, ...historyModule, chooseSceneSelection,
- ...rendering, ...environment, findDuplicateSceneBindingIds,
- modelUrl: value => value, listPublishedModelAssets: () => [],
- refreshSceneModelBounds: root => { root.updateMatrixWorld(true); return new THREE.Box3().setFromObject(root) },
- parseSceneScale: (value, current) => Number(value) > 0 ? Number(value) : current,
- formatSceneScale: value => String(value),
- applyModelEditorOverrides: () => ({}),
- isBridgeErectorVehicleReference: () => false,
- createGarageEnclosureController: () => ({}),
- createGarageEnclosureControls: () => ({ attach() {} }),
- assetStore: {}, setTimeout, clearTimeout,
- }
- const { WorkshopSceneEditor, SCENE_PRESETS } = new Function(...Object.keys(bindings), `${body}; return {WorkshopSceneEditor, SCENE_PRESETS}`)(...Object.values(bindings))
- const editor = Object.create(WorkshopSceneEditor.prototype)
- const root = document.createElement('div')
- document.body.append(root)
- Object.assign(editor, {
- root, context: { remoteControlled: true }, currentPreset: SCENE_PRESETS.find(p => p.id === 'workshop-1'),
- readySettled: true, readOnly: false, canPublish: true, pendingLoads: 0, loadGeneration: 0,
- objects: [], selected: null, assetSerial: 0, snap: true, dirty: false, changeRevision: 0,
- history: new SceneHistory(), historyInitialized: false, historyBusy: false,
- scene: new THREE.Scene(), camera: new THREE.PerspectiveCamera(),
- contentRoot: new THREE.Group(), environmentRoot: new THREE.Group(),
- controls: { target: new THREE.Vector3(), update() {} },
- transform: { attach() {}, detach() {}, setMode() {} },
- renderer: { toneMappingExposure: 1 },
- ambient: new THREE.HemisphereLight(), sun: new THREE.DirectionalLight(),
- grid: { visible: true }, selectionHighlight: { setObject() {}, clear() {} },
- assetStore: { hasReferenceSync: () => true },
- garageEnclosure: { setMode() {}, unregisterTree() {} },
- messages: [], toast(message) { this.messages.push(message) },
- })
- editor.renderShell()
- editor.cacheElements()
- return { editor, window, document, WorkshopSceneEditor, SCENE_PRESETS }
- }
-
- function keyboard(target, key, options = {}) {
- return { target, key, ctrlKey: false, metaKey: false, altKey: false, shiftKey: false,
- ...options, prevented: false, preventDefault() { this.prevented = true } }
- }
-
- test('scene granular update permission enables editing without the retired shared code', async () => {
- const f = fixture()
- class WithoutWebGL extends f.WorkshopSceneEditor {
- renderShell() {} cacheElements() {} applyAccessMode() {} initThree() {} bindEvents() {} animate() {} renderAssets() {}
- async loadPreset() {}
- }
- const editor = new WithoutWebGL(f.editor.root, { storage: f.window.localStorage, can: code => code === 'content.scene.update' })
- await editor.ready
- assert.equal(editor.readOnly, false)
- assert.equal(editor.canPublish, false)
- })
-
- test('typing undo/redo stays inside text controls and inactive cached editors ignore global shortcuts', () => {
- const { editor, document } = fixture()
- const input = document.createElement('input')
- const calls = []
- editor.undo = () => calls.push('undo'); editor.redo = () => calls.push('redo')
- editor.deleteSelected = () => calls.push('delete'); editor.setTransform = mode => calls.push(mode)
- for (const key of ['z', 'y']) editor.handleKeyboard(keyboard(input, key, { ctrlKey: true }))
- assert.deepEqual(calls, [])
- editor.saveProject = () => calls.push('save')
- const save = keyboard(input, 's', { ctrlKey: true })
- editor.handleKeyboard(save)
- assert.deepEqual(calls, ['save'])
- assert.equal(save.prevented, true)
- calls.length = 0
- editor.paused = true
- for (const key of ['Delete', 'w', 'e', 'r', 'z']) editor.handleKeyboard(keyboard(document.body, key, { ctrlKey: key === 'z' }))
- assert.deepEqual(calls, [])
- })
-
- test('preview locks mutation controls and Delete while Escape restores editing', () => {
- const { editor, document } = fixture()
- let deletes = 0
- editor.deleteSelected = () => deletes++
- editor.togglePreview()
- editor.handleKeyboard(keyboard(document.body, 'Delete'))
- assert.equal(deletes, 0)
- assert.equal(editor.writeUnavailable(), true)
- assert.equal(editor.root.querySelector('[data-action="open"]').disabled, true)
- editor.handleKeyboard(keyboard(document.body, 'Escape'))
- assert.equal(editor.previewing, false)
- assert.equal(editor.writeUnavailable(), false)
- })
-
- test('readonly templates have disabled affordances and cannot replace the scene', () => {
- const { editor } = fixture()
- editor.readOnly = true
- editor.renderPresets()
- editor.applyAccessMode()
- const card = editor.root.querySelector('[data-preset-id="terrain-1"]')
- let changed = false
- editor.loadPreset = () => { changed = true }
- editor.handleClick({ target: card })
- assert.equal(changed, false)
- assert.equal(card.disabled, true)
- })
-
- test('top view returns to the previous perspective through the visible perspective button', () => {
- const { editor } = fixture()
- editor.camera.position.set(12, 9, 18)
- editor.controls.target.set(2, 1, 3)
- editor.setTopView()
- editor.setTopView()
- editor.handleClick({ target: editor.root.querySelector('[data-camera="perspective"]') })
- assert.deepEqual(editor.camera.position.toArray(), [12, 9, 18])
- assert.deepEqual(editor.controls.target.toArray(), [2, 1, 3])
- })
-
- test('explicitly cleared bindings and display name survive serialized restore', () => {
- const { editor } = fixture()
- const object = new THREE.Group()
- object.userData = { displayName: 'old name', deviceId: 'OLD-DEVICE', modelId: 'OLD-MODEL' }
- editor.applySerializedObject(object, { name: '', deviceId: '', modelId: '' })
- assert.equal(object.userData.displayName, '')
- assert.equal(object.userData.deviceId, '')
- assert.equal(object.userData.modelId, '')
- })
-
- test('binding table reports the actual semantic value without claiming missing bindings are complete', () => {
- const { editor } = fixture()
- const object = new THREE.Group()
- object.userData = { displayName: '区域', deviceId: '', modelId: '', semantic: '安全区域' }
- editor.objects = [object]
- editor.renderBottom('binding')
- assert.match(editor.bottomContent.textContent, /安全区域/)
- assert.doesNotMatch(editor.bottomContent.textContent, /已绑定/)
- editor.renderBottom('logs')
- assert.doesNotMatch(editor.bottomContent.textContent, /本地存储/)
- })
-
- test('real Three ray hits on hidden descendants are excluded before selecting a root or part', () => {
- const root = new THREE.Group()
- root.userData.assetRoot = true
- const hidden = new THREE.Group()
- hidden.visible = false
- const mesh = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshBasicMaterial())
- hidden.add(mesh); root.add(hidden)
- root.updateMatrixWorld(true)
- const ray = new THREE.Raycaster(new THREE.Vector3(0, 0, 3), new THREE.Vector3(0, 0, -1))
- const hits = ray.intersectObjects([root], true)
- assert.ok(hits.length > 0, 'Three returns intersections even when an ancestor is hidden')
- assert.equal(chooseSceneSelection(hits, [root]) === null, true)
- hidden.visible = true
- assert.equal(chooseSceneSelection(hits, [root]), root)
- mesh.userData.sceneDeleted = true
- assert.equal(chooseSceneSelection(hits, [root]) === null, true)
- })
-
- test('deleting a root disposes its geometry and materials after clearing selection', () => {
- const { editor } = fixture()
- const mesh = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshBasicMaterial())
- mesh.userData = { sceneId: 'mesh', displayName: 'mesh' }
- editor.objects = [mesh]; editor.contentRoot.add(mesh); editor.selected = mesh
- let geometry = 0, material = 0
- mesh.geometry.addEventListener('dispose', () => geometry++)
- mesh.material.addEventListener('dispose', () => material++)
- editor.deleteSelected()
- assert.equal(editor.objects.length, 0)
- assert.equal(geometry, 1)
- assert.equal(material, 1)
- })
-
- test('loading and history restoration preserve an authored camera rather than auto-focusing a vehicle', async () => {
- const { editor } = fixture()
- const project = { presetId: 'workshop-1', objects: [], rendering: { camera: { position: [7, 8, 9], target: [1, 2, 3] } } }
- editor.context.readDocument = () => project
- editor.focusPublishedVehicle = () => editor.camera.position.set(99, 99, 99)
- await editor.loadPreset('workshop-1', { initial: true })
- assert.deepEqual(editor.camera.position.toArray(), [7, 8, 9])
- editor.camera.position.set(11, 11, 11)
- await editor.restoreHistoryState({ project, selectedSceneId: '', dirty: false })
- assert.deepEqual(editor.camera.position.toArray(), [7, 8, 9])
- })
-
- test('save B then undo A stays dirty, and redo B returns to the actual saved baseline', async () => {
- const { editor } = fixture()
- const object = editor.addAsset('equipment', new THREE.Vector3(), true)
- editor.historyInitialized = true
- editor.history.reset(editor.captureHistoryState())
- object.position.x = 4
- editor.markDirty('move to B')
- const writes = []
- editor.context.saveDocument = async doc => { writes.push(structuredClone(doc)) }
- await editor.saveProject()
- assert.equal(editor.dirty, false)
- await editor.undo()
- assert.equal(object.position.x, 0)
- assert.equal(editor.dirty, true, 'A is no longer the server version after B was saved')
- assert.equal(editor.root.querySelector('#se-save-state').textContent, '未保存')
- await editor.redo()
- assert.equal(object.position.x, 4)
- assert.equal(editor.dirty, false)
- assert.equal(writes.length, 1, 'history navigation never saves by itself')
- })
-
- test('new resource bindings do not reuse a saved serial after earlier objects were deleted', () => {
- const { editor } = fixture()
- editor.restoreObject({ type: 'equipment', name: 'kept', deviceId: 'EQ-002', modelId: 'MODEL-EQUIPMENT-02' })
- const added = editor.addAsset('equipment', new THREE.Vector3(), true)
- assert.notEqual(added.userData.deviceId, 'EQ-002')
- assert.notEqual(added.userData.modelId, 'MODEL-EQUIPMENT-02')
- })
-
- test('formal new delegates to the host even for create-only sessions and leaves this document intact', async () => {
- const { editor } = fixture()
- editor.readOnly = true; editor.canCreate = true
- let created = 0
- editor.context.onNew = async () => { created++ }
- editor.loadPreset = () => { throw new Error('must not reset the current document') }
- editor.applyAccessMode()
- assert.equal(editor.root.querySelector('[data-action="new"]').disabled, false)
- await editor.newProject()
- assert.equal(created, 1)
- editor.pendingLoads = 1
- await editor.newProject()
- assert.equal(created, 1)
- })
-
- test('cancelled async template replacement retains the existing scene and history', async () => {
- const { editor } = fixture()
- editor.dirty = true
- let prompts = 0, clears = 0
- editor.context.confirmDiscard = async () => { prompts++; return false }
- editor.clearContent = () => { clears++ }
- await editor.loadPreset('terrain-1')
- assert.equal(prompts, 1)
- assert.equal(clears, 0)
- assert.equal(editor.currentPreset.id, 'workshop-1')
- })
-
- test('a failed replacement GLB keeps the previous live scene rather than clearing an unsaved draft', async () => {
- const { editor } = fixture()
- const kept = editor.addAsset('equipment', new THREE.Vector3(3, 0, 1), true)
- editor.dirty = true
- editor.context.confirmDiscard = async () => true
- editor.context.readDocument = () => null
- editor.placeAsset = async () => { throw new Error('fixture model unavailable') }
- try { await editor.loadPreset('terrain-1') } catch {}
- assert.equal(editor.currentPreset.id, 'workshop-1')
- assert.equal(editor.objects.length, 1)
- assert.equal(editor.objects[0] === kept, true)
- assert.equal(kept.parent === editor.contentRoot, true)
- assert.equal(editor.dirty, true)
- })
-
- test('failed undo restoration retains live objects and keeps the undo target available for retry', async () => {
- const { editor } = fixture()
- const kept = editor.addAsset('equipment', new THREE.Vector3(), true)
- editor.historyInitialized = true
- const earlier = editor.captureHistoryState()
- earlier.project.objects.push({ sceneId: 'missing', type: 'imported', name: 'missing.glb' })
- editor.history.reset(earlier)
- editor.markDirty('delete missing asset')
- editor.restoreImportedObject = async () => { throw new Error('fixture model unavailable') }
- await editor.undo()
- assert.equal(editor.objects[0] === kept, true)
- assert.equal(editor.history.canUndo, true)
- assert.equal(editor.history.canRedo, false)
- })
-
- test('validation text and imported object IDs cannot create executable DOM markup', () => {
- const { editor } = fixture()
- editor.verifyScene = () => [{ ok: false, label: 'duplicate', detail: '<img src=x onerror="void 0">' }]
- editor.renderBottom('validation')
- assert.equal(editor.bottomContent.querySelector('img') === null, true)
- assert.match(editor.bottomContent.textContent, /<img/)
- editor.objects = [{ userData: { sceneId: 'a" onclick="void 0', displayName: 'safe' } }]
- editor.renderBottom('objects')
- assert.equal(editor.bottomContent.querySelector('[onclick]') === null, true)
- })
-
- test('selection highlight cleanup restores source materials and releases its own GPU helper', () => {
- const scene = new THREE.Scene()
- const material = new THREE.MeshStandardMaterial()
- const mesh = new THREE.Mesh(new THREE.BoxGeometry(), material)
- const highlight = new SceneSelectionHighlight(scene)
- highlight.setObject(mesh)
- assert.notEqual(mesh.material, material)
- let geometry = 0, helperMaterial = 0
- highlight.helper.geometry.addEventListener('dispose', () => geometry++)
- highlight.helper.material.addEventListener('dispose', () => helperMaterial++)
- assert.equal(typeof highlight.dispose, 'function')
- highlight.dispose()
- assert.equal(mesh.material, material)
- assert.equal(highlight.helper.parent, null)
- assert.equal(geometry, 1)
- assert.equal(helperMaterial, 1)
- })
-
- test('replacement failure waits for other started loads before restoring the original scene', async () => {
- const { editor } = fixture()
- editor.context.remoteControlled = false
- const kept = editor.addAsset('equipment', new THREE.Vector3(), true)
- let completeLate
- const late = new Promise(resolve => { completeLate = resolve })
- editor.context.readDocument = () => ({ presetId: 'terrain-1', objects: [{ fail: true }, { fail: false }] })
- editor.restoreObject = async item => {
- if (item.fail) throw new Error('fixture failed asset')
- await late
- return editor.addAsset('workbench', new THREE.Vector3(), true)
- }
- let finished = false
- const load = editor.loadPreset('terrain-1').then(() => { finished = true })
- await Promise.resolve(); await Promise.resolve(); await Promise.resolve()
- assert.equal(finished, false)
- assert.equal(editor.writeUnavailable(), true)
- completeLate()
- await load
- assert.equal(editor.objects.length, 1)
- assert.equal(editor.objects[0] === kept, true)
- assert.equal(editor.pendingLoads, 0)
- })
-
- test('activity log reflects actual session events and keeps API error details transient', () => {
- const { editor, WorkshopSceneEditor } = fixture()
- WorkshopSceneEditor.prototype.toast.call(editor, '场景工程已保存到服务端', 'success')
- WorkshopSceneEditor.prototype.toast.call(editor, '接口失败详情', 'warn')
- editor.renderBottom('logs')
- assert.match(editor.bottomContent.textContent, /场景工程已保存到服务端/)
- assert.match(editor.bottomContent.textContent, /操作未完成/)
- assert.doesNotMatch(editor.bottomContent.textContent, /接口失败详情/)
- assert.equal(editor.operationLog.length, 2)
- assert.ok(editor.operationLog.every(entry => Number.isFinite(Date.parse(entry.at))))
- })
-
- test('formal template cards use fresh seeds even when returning to the original project preset', async () => {
- const { editor } = fixture()
- let reads = 0
- editor.context.readDocument = () => { reads++; return { objects: [] } }
- editor.context.confirmDiscard = async () => true
- await editor.loadPreset('workshop-2')
- assert.equal(editor.objects.length, 3)
- await editor.loadPreset('workshop-1')
- assert.equal(editor.objects.length, 0)
- assert.equal(reads, 0)
- })
-
- test('a pending pre-save recovery timer cannot overwrite the host checkpoint during a slow save', async () => {
- const { editor } = fixture()
- let checkpoints = 0
- editor.context.onDocumentChanged = () => { checkpoints++ }
- editor.markDirty('edit before save')
- clearTimeout(editor.recoveryTimer)
- editor.recoveryTimer = setTimeout(() => editor.context.onDocumentChanged(editor.serialize()), 15)
- editor.context.saveDocument = async () => { await new Promise(resolve => setTimeout(resolve, 60)) }
- await editor.saveProject()
- assert.equal(editor.dirty, false)
- assert.equal(checkpoints, 0)
- assert.equal(editor.recoveryTimer, 0)
- })
-
- test('verify opens live validation and whitespace binding edits immediately invalidate the active tab', () => {
- const { editor } = fixture()
- const object = editor.addAsset('equipment', new THREE.Vector3(), true)
- editor.selectObject(object)
- editor.verifyScene(true)
- assert.equal(editor.root.querySelector('[data-bottom-tab="validation"]').classList.contains('active'), true)
- const device = editor.root.querySelector('[data-inspector-field="deviceId"]')
- device.value = ' '
- editor.handleInput({ target: device })
- assert.equal(editor.verifyScene(false).find(item => item.label === '对象设备编号完整').ok, false)
- assert.ok(editor.bottomContent.querySelector('.warn'))
- editor.renderBottom('binding')
- const name = editor.root.querySelector('[data-inspector-field="name"]')
- name.value = 'Live edited name'
- editor.handleInput({ target: name })
- assert.match(editor.bottomContent.textContent, /Live edited name/)
- assert.equal(editor.root.querySelector('[data-bottom-tab="binding"]').classList.contains('active'), true)
- })
-
- test('model source navigation locates the published owner and falls back without source permission', () => {
- const { editor } = fixture()
- const root = editor.addAsset('equipment', new THREE.Vector3(), true)
- root.userData.targetProjectId = '157'
- const part = new THREE.Group()
- part.userData = { modelPartTarget: true, sceneOwnerId: root.userData.sceneId }
- editor.selected = part
- const requests = []
- editor.context.navigate = (...args) => requests.push(args)
- const button = editor.root.querySelector('[data-action="open-model-editor"]')
- editor.handleClick({ target: button })
- assert.deepEqual(requests.pop(), ['editor', '?edit=157'])
- editor.context.can = () => false
- editor.handleClick({ target: button })
- assert.deepEqual(requests.pop(), ['editor', ''])
- })
-
- test('gizmo scaling across its pivot follows the same strictly positive scale contract as inspector edits', () => {
- const { editor } = fixture()
- const model = editor.addAsset('equipment', new THREE.Vector3(), true)
- editor.selectObject(model)
- editor.transform.mode = 'scale'
- model.scale.set(-1.25, 0, 0.034)
- editor.handleTransformChange()
- assert.deepEqual(model.scale.toArray(), [0.000001, 0.000001, 0.034])
- assert.equal(editor.root.querySelector('[data-inspector-field="sx"]').value, '0.000001')
- assert.equal(editor.dirty, true)
- assert.equal(editor.transformChanged, true)
- })
-
- test('default history caps at 60 snapshots, stops at either edge and discards redo only after a new branch', () => {
- const history = new SceneHistory()
- assert.equal(history.undo(), null)
- assert.equal(history.redo(), null)
- history.reset({ revision: 0 })
- for (let revision = 1; revision <= 75; revision++) history.push({ revision }, `edit ${revision}`)
- assert.equal(history.past.length, 60)
- let oldest
- for (let i = 0; i < 59; i++) oldest = history.undo()
- assert.equal(oldest.state.revision, 16)
- assert.equal(history.undo(), null)
- assert.equal(history.canUndo, false)
- for (let i = 0; i < 59; i++) assert.ok(history.redo())
- assert.equal(history.redo(), null)
- assert.equal(history.past.at(-1).state.revision, 75)
- const previous = history.undo()
- previous.state.revision = -1
- assert.equal(history.past.at(-1).state.revision, 74)
- assert.equal(history.push({ revision: 74 }), false)
- assert.equal(history.canRedo, true)
- history.push({ revision: 100 }, 'new branch')
- assert.equal(history.canRedo, false)
- assert.equal(history.redo(), null)
- })
-
- const truckPartDefinitions = [{ interactionId: 'interaction-source-wheel', editorId: 'wheel-editor-id', nodeName: 'Wheel' }]
- function addTruckInstance(editor, id) {
- const root = new THREE.Group()
- root.userData = { sceneId: id, assetType: 'imported', modelEditorAssetId: 'truck-published', displayName: id }
- const part = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshStandardMaterial())
- part.name = 'Wheel'; part.userData = { interactionId: 'interaction-source-wheel', editorId: 'wheel-editor-id' }
- root.add(part); editor.contentRoot.add(root); editor.objects.push(root)
- return { root, part }
- }
-
- test('two published instances have unique tree part IDs and editing the second persists independently after reopen', () => {
- const { editor } = fixture()
- const first = addTruckInstance(editor, 'TRUCK-A'), second = addTruckInstance(editor, 'TRUCK-B')
- editor.registerModelPartTargets(first.root, truckPartDefinitions)
- editor.registerModelPartTargets(second.root, truckPartDefinitions)
- assert.equal(first.part.userData.sceneId, 'scene-part:TRUCK-A:interaction-source-wheel')
- assert.equal(second.part.userData.sceneId, 'scene-part:TRUCK-B:interaction-source-wheel')
- assert.equal(second.part.userData.interactionId, first.part.userData.interactionId)
- assert.equal(second.part.userData.editorId, first.part.userData.editorId)
- editor.renderTree()
- const secondRow = editor.root.querySelector(`[data-object-id="${second.part.userData.sceneId}"]`)
- editor.handleClick({ target: secondRow })
- assert.equal(editor.selected === second.part, true)
- const input = editor.root.querySelector('[data-inspector-field="x"]')
- input.value = '3.75'; editor.handleInput({ target: input }); editor.handleChange({ target: input })
- assert.equal(second.part.position.x, 3.75)
- assert.equal(first.part.position.x, 0)
- const saved = [first, second].map(instance => editor.serializeObject(instance.root))
- const reopened = fixture().editor
- reopened.publishedModelAsset = () => ({ parts: truckPartDefinitions })
- for (const item of saved) reopened.applySerializedObject(addTruckInstance(reopened, item.sceneId).root, item)
- assert.equal(reopened.findSceneSelectionById(first.part.userData.sceneId).position.x, 0)
- assert.equal(reopened.findSceneSelectionById(second.part.userData.sceneId).position.x, 3.75)
- assert.deepEqual(reopened.objects.map(root => reopened.captureModelPartTargets(root)), saved.map(item => item.modelPartTargets))
- })
-
- test('legacy duplicate instance IDs migrate only later owners in document order despite reversed GLB completion', async () => {
- const makeSaved = (id, x) => ({ sceneId: id, type: 'imported', name: id, modelEditorAssetId: 'truck-published',
- modelPartTargets: [{ sceneId: 'legacy-wheel', interactionId: 'interaction-source-wheel', sourceInteractionId: 'interaction-source-wheel', editorId: 'wheel-editor-id', sourceNodeName: 'Wheel' }],
- partOverrides: { 'interaction-source-wheel': { sceneId: 'legacy-wheel', sourceInteractionId: 'interaction-source-wheel', position: [x, 0, 0] } } })
- const original = [makeSaved('TRUCK-A', 1), makeSaved('TRUCK-B', 2)]
- const { editor } = fixture()
- editor.publishedModelAsset = () => ({ parts: truckPartDefinitions })
- editor.restoreObject = async item => {
- await new Promise(resolve => setTimeout(resolve, item.sceneId === 'TRUCK-A' ? 20 : 1))
- const instance = addTruckInstance(editor, item.sceneId)
- editor.applySerializedObject(instance.root, item)
- return instance.root
- }
- await editor.restoreSceneObjects(original, editor.loadGeneration)
- assert.equal(editor.findSceneSelectionById('legacy-wheel').userData.sceneOwnerId, 'TRUCK-A')
- assert.equal(editor.findSceneSelectionById('scene-part:TRUCK-B:interaction-source-wheel').position.x, 2)
- assert.equal(original[1].modelPartTargets[0].sceneId, 'legacy-wheel', 'the source document is not modified in place')
- const persisted = editor.objects.map(root => editor.serializeObject(root))
- assert.deepEqual(editor.normalizeModelPartSceneIds(persisted), persisted, 'migration is stable after saving in completion order')
- const single = fixture().editor
- assert.deepEqual(single.normalizeModelPartSceneIds([original[0]]), [original[0]], 'legacy single-instance references remain unchanged')
- })
-
- test('successful parallel restores retain document order after delayed GLBs and split-object batches finish out of order', async () => {
- const { editor } = fixture()
- const kept = editor.addAsset('equipment', new THREE.Vector3(), true)
- const completed = []
- editor.restoreObject = async item => {
- await new Promise(resolve => setTimeout(resolve, item.delay))
- completed.push(item.id)
- const roots = item.parts.map(id => {
- const root = new THREE.Group(); root.userData = { sceneId: id, assetType: 'imported' }
- editor.contentRoot.add(root); editor.objects.push(root); return root
- })
- return item.parts.length === 1 ? roots[0] : roots
- }
- await editor.restoreSceneObjects([
- { id: 'slow-first', parts: ['TRUCK-A'], delay: 30 },
- { id: 'fast-second', parts: ['TRUCK-B'], delay: 1 },
- { id: 'split-third', parts: ['SPLIT-1', 'SPLIT-2'], delay: 10 },
- ], editor.loadGeneration)
- assert.deepEqual(completed, ['fast-second', 'split-third', 'slow-first'])
- assert.deepEqual(editor.objects.map(root => root.userData.sceneId), [kept.userData.sceneId, 'TRUCK-A', 'TRUCK-B', 'SPLIT-1', 'SPLIT-2'])
- assert.deepEqual(editor.serialize().objects.filter(item => !environment.isSceneEnvironmentSnapshotObject(item)).map(item => item.sceneId).filter(Boolean), editor.objects.map(root => root.userData.sceneId))
- })
|