|
- import assert from 'node:assert/strict'
- import test from 'node:test'
- import { readFileSync } from 'node:fs'
- import { createRequire } from 'node:module'
-
- const requireWeb = createRequire(new URL('../../unreal_tran_web/package.json', import.meta.url))
- const ts = requireWeb('typescript')
- const web = new URL('../../unreal_tran_web/src/features/', import.meta.url)
- const compile = source => ts.transpileModule(source, { compilerOptions: {
- target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext,
- } }).outputText
- const dataUrl = source => `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`
- const sceneAdapter = dataUrl(compile(readFileSync(new URL('scene-legacy/contentApiClient.ts', web), 'utf8')))
- const compiled = compile(readFileSync(new URL('training-legacy/sceneApiBridge.ts', web), 'utf8'))
- .replace(/import \{[^}]+\} from ['"]\.\.\/\.\.\/api\/content['"];?/, 'const downloadContentAsset = null, getContentCatalog = null, getContentVersion = null;')
- .replace(/from ['"]\.\.\/scene-legacy\/contentApiClient['"]/, `from '${sceneAdapter}'`)
- const { TrainingSceneApiBridge } = await import(dataUrl(compiled))
- const { normalizeTrainingProject } = await import(new URL('guide-legacy/demo-src/training-project-io.js', web))
- const editorSource = readFileSync(new URL('guide-legacy/demo-src/training-editor.js', web), 'utf8')
- const editorAst = ts.createSourceFile('training-editor.js', editorSource, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS)
- const editorClass = editorAst.statements.find(node => ts.isClassDeclaration(node) && node.members.some(member => member.name?.getText(editorAst) === 'bindPublishedScene'))
- const bindingMethods = editorClass.members.filter(node => ['bindPublishedScene', 'applyPublishedScene'].includes(node.name?.getText(editorAst)))
- assert.equal(bindingMethods.length, 2)
- const { BindingEditor } = await import(dataUrl(`const clone = value => structuredClone(value); export class BindingEditor { ${bindingMethods.map(node => node.getText(editorAst)).join('\n')} }`))
-
- const uri = 'content://asset/model/157/201/3001'
- const scene = () => ({
- id: '208', projectId: '160', type: 'SCENE', status: 'PUBLISHED', versionNumber: 2,
- publishedAt: '2026-09-05T12:00:00',
- content: { schema: 'edit3dv4.scene', name: 'Fox scene', type: 'outdoor',
- objects: [{ id: 'fox', type: 'imported', operable: true, resourceUrl: uri, position: [-7.5, 0, 0] }],
- environment: { type: 'outdoor' }, rendering: { camera: { position: [10, 6, 9] } } },
- dependencies: [{ relationType: 'SCENE_MODEL', targetProjectId: '157', targetVersionId: '201', required: true }],
- assets: [],
- })
- function fixture(options = {}) {
- const calls = [], created = [], revoked = []
- const version = scene()
- const api = {
- catalog: async query => ({ records: [{ projectId: '160', versionId: '208', name: 'Fox scene',
- content: { objectCount: 1 }, versionNumber: 2, publishedAt: version.publishedAt }], total: 1, pages: 1, size: 100 }),
- version: async (projectId, versionId) => { calls.push(['version', projectId, versionId]); return structuredClone(version) },
- download: async (...args) => { calls.push(['download', ...args]); return new Blob(['GLB']) },
- objectUrls: { createObjectURL: blob => { created.push(blob); return `blob:scene-${created.length}` }, revokeObjectURL: url => revoked.push(url) },
- ...options,
- }
- const fallback = { put: async () => ({}), get: async () => null,
- acquireObjectUrl: async value => value.startsWith('/models/') ? value : '', releaseObjectUrl() {} }
- return { bridge: new TrainingSceneApiBridge(fallback, api), api, version, calls, created, revoked }
- }
- const project = () => ({ id: '161', name: 'Fox training', type: 'TRAINING', trainingMode: 'VIRTUAL',
- status: 'DRAFT', content: { scenario: { sceneResource: { snapshot: { objects: [{ id: 'stale' }] } } } },
- dependencies: [{ relationType: 'TRAINING_SCENE', targetProjectId: '160', targetVersionId: '208', required: true, targetName: 'Fox scene' }] })
-
- test('catalog reads every server page and selects the catalog fixed version rather than a current draft', async () => {
- const queries = []
- const f = fixture({ catalog: async query => {
- queries.push(query)
- return { records: [{ projectId: String(159 + query.page), versionId: String(207 + query.page),
- name: `Scene ${query.page}`, versionNumber: 2, content: {} }], total: 101, pages: 2, size: 100 }
- } })
- const list = await f.bridge.listPublishedScenes()
- assert.equal(list.length, 2)
- assert.deepEqual(queries.map(q => [q.type, q.relationType, q.page]), [['SCENE', 'TRAINING_SCENE', 1], ['SCENE', 'TRAINING_SCENE', 2]])
- const loaded = await f.bridge.loadPublishedScene(list[0].id)
- assert.equal(loaded.sourceVersionId, '208')
- assert.deepEqual(f.calls[0], ['version', '160', '208'])
- })
-
- test('reopening refreshes snapshot from exact dependency, keeps project source immutable and survives editor normalization', async () => {
- const f = fixture()
- const source = project()
- const prepared = await f.bridge.prepare(source)
- const normalized = normalizeTrainingProject({ ...prepared.content, id: prepared.id, name: prepared.name })
- assert.equal(source.content.scenario.sceneResource.snapshot.objects[0].id, 'stale')
- assert.equal(normalized.scenario.sceneResource.snapshot.objects[0].id, 'fox')
- assert.equal(normalized.scenario.sceneResource.sourceProjectId, '160')
- assert.equal(normalized.scenario.sceneResource.sourceVersionId, '208')
- assert.deepEqual(f.bridge.dependencies(normalized, source.dependencies), [{
- targetProjectId: '160', targetVersionId: '208', relationType: 'TRAINING_SCENE', required: true,
- }])
- })
-
- test('changing scene replaces previous scene dependency while retaining explicit model dependencies', () => {
- const f = fixture()
- const next = { scenario: { sceneResource: { sourceProjectId: '159', sourceVersionId: '209' } } }
- const previous = [...project().dependencies, { relationType: 'TRAINING_MODEL', targetProjectId: '8', targetVersionId: '9', required: false }]
- assert.deepEqual(f.bridge.dependencies(next, previous), [
- { relationType: 'TRAINING_MODEL', targetProjectId: '8', targetVersionId: '9', required: false },
- { relationType: 'TRAINING_SCENE', targetProjectId: '159', targetVersionId: '209', required: true },
- ])
- assert.throws(() => f.bridge.dependencies({ scenario: { publishedSceneId: 'SCENE-demo' } }, previous), /重新选择/)
- })
-
- test('controlled model bytes use visible scene authorization, reuse one Blob and revoke only on disposal', async () => {
- const f = fixture()
- await f.bridge.prepare(project())
- const [a, b] = await Promise.all([f.bridge.assetStore.acquireObjectUrl(uri), f.bridge.assetStore.acquireObjectUrl(uri)])
- assert.equal(a, b)
- assert.deepEqual(f.calls.filter(call => call[0] === 'download'), [['download', '160', '3001']])
- assert.deepEqual(await f.bridge.assetStore.validateReferences([{ resourceUrl: uri }]), { valid: true, missing: [] })
- f.bridge.assetStore.releaseObjectUrl(uri)
- assert.equal(f.revoked.length, 0)
- f.bridge.dispose()
- await Promise.resolve()
- assert.deepEqual(f.revoked, [a])
- })
-
- test('unbound content references and mismatched model versions are rejected before download', async () => {
- const f = fixture()
- await assert.rejects(f.bridge.assetStore.acquireObjectUrl(uri), /不属于/)
- f.version.dependencies[0].targetVersionId = '202'
- await assert.rejects(f.bridge.prepare(project()), /版本不一致/)
- assert.equal(f.calls.filter(call => call[0] === 'download').length, 0)
- })
-
- test('draft or foreign scene version cannot replace a published binding', async () => {
- for (const patch of [{ status: 'DRAFT' }, { projectId: '999' }, { id: '999' }, { publishedAt: null }]) {
- const f = fixture()
- Object.assign(f.version, patch)
- await assert.rejects(f.bridge.prepare(project()), /已经发布/)
- }
- const f = fixture()
- f.version.status = 'ARCHIVED'
- assert.equal((await f.bridge.prepare(project())).content.scenario.sceneResource.sourceVersionId, '208')
- })
-
- test('transient download failure retries and disposal during download creates no leaked Blob URL', async () => {
- let attempts = 0
- const f = fixture({ download: async () => { if (!attempts++) throw new Error('unavailable'); return new Blob(['GLB']) } })
- await f.bridge.prepare(project())
- await assert.rejects(f.bridge.assetStore.acquireObjectUrl(uri), /unavailable/)
- assert.equal(await f.bridge.assetStore.acquireObjectUrl(uri), 'blob:scene-1')
- f.bridge.dispose()
- let finish
- const pending = fixture({ download: () => new Promise(resolve => { finish = resolve }) })
- await pending.bridge.prepare(project())
- const download = pending.bridge.assetStore.acquireObjectUrl(uri)
- pending.bridge.dispose()
- finish(new Blob(['GLB']))
- await assert.rejects(download, /已关闭/)
- assert.equal(pending.created.length, 0)
- })
-
- test('failed WebGL restore rolls scene binding back and never reports success or schedules save', async () => {
- const editor = new BindingEditor()
- const messages = []
- let closes = 0, changes = 0, renders = 0
- const oldScenario = { publishedSceneId: 'old', environment: {}, sceneResource: { sourceProjectId: '159', sourceVersionId: '209' } }
- editor.project = { scenario: structuredClone(oldScenario) }
- editor.root = { querySelectorAll: () => [] }
- editor.renderSaveState = () => {}
- editor.loadServerScene = async () => ({ id: '160@208', name: 'Fox', sourceProjectId: '160', sourceVersionId: '208', objects: [] })
- editor.assetStore = { validateReferences: async () => ({ valid: true }) }
- editor.clearEnvironmentPreview = () => {}
- editor.renderScene = async () => ++renders === 1 ? { error: new Error('GLB parse failed') } : {}
- editor.markDirty = () => { changes++ }
- editor.closeDialog = () => { closes++ }
- editor.toast = (message, type) => messages.push([message, type])
- await editor.bindPublishedScene('160@208')
- assert.deepEqual(editor.project.scenario, oldScenario)
- assert.deepEqual(messages, [['GLB parse failed', 'error']])
- assert.equal(changes, 0)
- assert.equal(closes, 0)
- assert.equal(editor.bindingScene, false)
- assert.equal(renders, 2)
- })
|