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: '' }] editor.renderBottom('validation') assert.equal(editor.bottomContent.querySelector('img') === null, true) assert.match(editor.bottomContent.textContent, / 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)) })