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