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))
})