您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符
 
 
 
 

168 行
9.9 KiB

  1. import assert from 'node:assert/strict'
  2. import test from 'node:test'
  3. import { readFileSync } from 'node:fs'
  4. import { createRequire } from 'node:module'
  5. const requireWeb = createRequire(new URL('../../unreal_tran_web/package.json', import.meta.url))
  6. const ts = requireWeb('typescript')
  7. const web = new URL('../../unreal_tran_web/src/features/', import.meta.url)
  8. const compile = source => ts.transpileModule(source, { compilerOptions: {
  9. target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext,
  10. } }).outputText
  11. const dataUrl = source => `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`
  12. const sceneAdapter = dataUrl(compile(readFileSync(new URL('scene-legacy/contentApiClient.ts', web), 'utf8')))
  13. const compiled = compile(readFileSync(new URL('training-legacy/sceneApiBridge.ts', web), 'utf8'))
  14. .replace(/import \{[^}]+\} from ['"]\.\.\/\.\.\/api\/content['"];?/, 'const downloadContentAsset = null, getContentCatalog = null, getContentVersion = null;')
  15. .replace(/from ['"]\.\.\/scene-legacy\/contentApiClient['"]/, `from '${sceneAdapter}'`)
  16. const { TrainingSceneApiBridge } = await import(dataUrl(compiled))
  17. const { normalizeTrainingProject } = await import(new URL('guide-legacy/demo-src/training-project-io.js', web))
  18. const editorSource = readFileSync(new URL('guide-legacy/demo-src/training-editor.js', web), 'utf8')
  19. const editorAst = ts.createSourceFile('training-editor.js', editorSource, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS)
  20. const editorClass = editorAst.statements.find(node => ts.isClassDeclaration(node) && node.members.some(member => member.name?.getText(editorAst) === 'bindPublishedScene'))
  21. const bindingMethods = editorClass.members.filter(node => ['bindPublishedScene', 'applyPublishedScene'].includes(node.name?.getText(editorAst)))
  22. assert.equal(bindingMethods.length, 2)
  23. const { BindingEditor } = await import(dataUrl(`const clone = value => structuredClone(value); export class BindingEditor { ${bindingMethods.map(node => node.getText(editorAst)).join('\n')} }`))
  24. const uri = 'content://asset/model/157/201/3001'
  25. const scene = () => ({
  26. id: '208', projectId: '160', type: 'SCENE', status: 'PUBLISHED', versionNumber: 2,
  27. publishedAt: '2026-09-05T12:00:00',
  28. content: { schema: 'edit3dv4.scene', name: 'Fox scene', type: 'outdoor',
  29. objects: [{ id: 'fox', type: 'imported', operable: true, resourceUrl: uri, position: [-7.5, 0, 0] }],
  30. environment: { type: 'outdoor' }, rendering: { camera: { position: [10, 6, 9] } } },
  31. dependencies: [{ relationType: 'SCENE_MODEL', targetProjectId: '157', targetVersionId: '201', required: true }],
  32. assets: [],
  33. })
  34. function fixture(options = {}) {
  35. const calls = [], created = [], revoked = []
  36. const version = scene()
  37. const api = {
  38. catalog: async query => ({ records: [{ projectId: '160', versionId: '208', name: 'Fox scene',
  39. content: { objectCount: 1 }, versionNumber: 2, publishedAt: version.publishedAt }], total: 1, pages: 1, size: 100 }),
  40. version: async (projectId, versionId) => { calls.push(['version', projectId, versionId]); return structuredClone(version) },
  41. download: async (...args) => { calls.push(['download', ...args]); return new Blob(['GLB']) },
  42. objectUrls: { createObjectURL: blob => { created.push(blob); return `blob:scene-${created.length}` }, revokeObjectURL: url => revoked.push(url) },
  43. ...options,
  44. }
  45. const fallback = { put: async () => ({}), get: async () => null,
  46. acquireObjectUrl: async value => value.startsWith('/models/') ? value : '', releaseObjectUrl() {} }
  47. return { bridge: new TrainingSceneApiBridge(fallback, api), api, version, calls, created, revoked }
  48. }
  49. const project = () => ({ id: '161', name: 'Fox training', type: 'TRAINING', trainingMode: 'VIRTUAL',
  50. status: 'DRAFT', content: { scenario: { sceneResource: { snapshot: { objects: [{ id: 'stale' }] } } } },
  51. dependencies: [{ relationType: 'TRAINING_SCENE', targetProjectId: '160', targetVersionId: '208', required: true, targetName: 'Fox scene' }] })
  52. test('catalog reads every server page and selects the catalog fixed version rather than a current draft', async () => {
  53. const queries = []
  54. const f = fixture({ catalog: async query => {
  55. queries.push(query)
  56. return { records: [{ projectId: String(159 + query.page), versionId: String(207 + query.page),
  57. name: `Scene ${query.page}`, versionNumber: 2, content: {} }], total: 101, pages: 2, size: 100 }
  58. } })
  59. const list = await f.bridge.listPublishedScenes()
  60. assert.equal(list.length, 2)
  61. assert.deepEqual(queries.map(q => [q.type, q.relationType, q.page]), [['SCENE', 'TRAINING_SCENE', 1], ['SCENE', 'TRAINING_SCENE', 2]])
  62. const loaded = await f.bridge.loadPublishedScene(list[0].id)
  63. assert.equal(loaded.sourceVersionId, '208')
  64. assert.deepEqual(f.calls[0], ['version', '160', '208'])
  65. })
  66. test('reopening refreshes snapshot from exact dependency, keeps project source immutable and survives editor normalization', async () => {
  67. const f = fixture()
  68. const source = project()
  69. const prepared = await f.bridge.prepare(source)
  70. const normalized = normalizeTrainingProject({ ...prepared.content, id: prepared.id, name: prepared.name })
  71. assert.equal(source.content.scenario.sceneResource.snapshot.objects[0].id, 'stale')
  72. assert.equal(normalized.scenario.sceneResource.snapshot.objects[0].id, 'fox')
  73. assert.equal(normalized.scenario.sceneResource.sourceProjectId, '160')
  74. assert.equal(normalized.scenario.sceneResource.sourceVersionId, '208')
  75. assert.deepEqual(f.bridge.dependencies(normalized, source.dependencies), [{
  76. targetProjectId: '160', targetVersionId: '208', relationType: 'TRAINING_SCENE', required: true,
  77. }])
  78. })
  79. test('changing scene replaces previous scene dependency while retaining explicit model dependencies', () => {
  80. const f = fixture()
  81. const next = { scenario: { sceneResource: { sourceProjectId: '159', sourceVersionId: '209' } } }
  82. const previous = [...project().dependencies, { relationType: 'TRAINING_MODEL', targetProjectId: '8', targetVersionId: '9', required: false }]
  83. assert.deepEqual(f.bridge.dependencies(next, previous), [
  84. { relationType: 'TRAINING_MODEL', targetProjectId: '8', targetVersionId: '9', required: false },
  85. { relationType: 'TRAINING_SCENE', targetProjectId: '159', targetVersionId: '209', required: true },
  86. ])
  87. assert.throws(() => f.bridge.dependencies({ scenario: { publishedSceneId: 'SCENE-demo' } }, previous), /重新选择/)
  88. })
  89. test('controlled model bytes use visible scene authorization, reuse one Blob and revoke only on disposal', async () => {
  90. const f = fixture()
  91. await f.bridge.prepare(project())
  92. const [a, b] = await Promise.all([f.bridge.assetStore.acquireObjectUrl(uri), f.bridge.assetStore.acquireObjectUrl(uri)])
  93. assert.equal(a, b)
  94. assert.deepEqual(f.calls.filter(call => call[0] === 'download'), [['download', '160', '3001']])
  95. assert.deepEqual(await f.bridge.assetStore.validateReferences([{ resourceUrl: uri }]), { valid: true, missing: [] })
  96. f.bridge.assetStore.releaseObjectUrl(uri)
  97. assert.equal(f.revoked.length, 0)
  98. f.bridge.dispose()
  99. await Promise.resolve()
  100. assert.deepEqual(f.revoked, [a])
  101. })
  102. test('unbound content references and mismatched model versions are rejected before download', async () => {
  103. const f = fixture()
  104. await assert.rejects(f.bridge.assetStore.acquireObjectUrl(uri), /不属于/)
  105. f.version.dependencies[0].targetVersionId = '202'
  106. await assert.rejects(f.bridge.prepare(project()), /版本不一致/)
  107. assert.equal(f.calls.filter(call => call[0] === 'download').length, 0)
  108. })
  109. test('draft or foreign scene version cannot replace a published binding', async () => {
  110. for (const patch of [{ status: 'DRAFT' }, { projectId: '999' }, { id: '999' }, { publishedAt: null }]) {
  111. const f = fixture()
  112. Object.assign(f.version, patch)
  113. await assert.rejects(f.bridge.prepare(project()), /已经发布/)
  114. }
  115. const f = fixture()
  116. f.version.status = 'ARCHIVED'
  117. assert.equal((await f.bridge.prepare(project())).content.scenario.sceneResource.sourceVersionId, '208')
  118. })
  119. test('transient download failure retries and disposal during download creates no leaked Blob URL', async () => {
  120. let attempts = 0
  121. const f = fixture({ download: async () => { if (!attempts++) throw new Error('unavailable'); return new Blob(['GLB']) } })
  122. await f.bridge.prepare(project())
  123. await assert.rejects(f.bridge.assetStore.acquireObjectUrl(uri), /unavailable/)
  124. assert.equal(await f.bridge.assetStore.acquireObjectUrl(uri), 'blob:scene-1')
  125. f.bridge.dispose()
  126. let finish
  127. const pending = fixture({ download: () => new Promise(resolve => { finish = resolve }) })
  128. await pending.bridge.prepare(project())
  129. const download = pending.bridge.assetStore.acquireObjectUrl(uri)
  130. pending.bridge.dispose()
  131. finish(new Blob(['GLB']))
  132. await assert.rejects(download, /已关闭/)
  133. assert.equal(pending.created.length, 0)
  134. })
  135. test('failed WebGL restore rolls scene binding back and never reports success or schedules save', async () => {
  136. const editor = new BindingEditor()
  137. const messages = []
  138. let closes = 0, changes = 0, renders = 0
  139. const oldScenario = { publishedSceneId: 'old', environment: {}, sceneResource: { sourceProjectId: '159', sourceVersionId: '209' } }
  140. editor.project = { scenario: structuredClone(oldScenario) }
  141. editor.root = { querySelectorAll: () => [] }
  142. editor.renderSaveState = () => {}
  143. editor.loadServerScene = async () => ({ id: '160@208', name: 'Fox', sourceProjectId: '160', sourceVersionId: '208', objects: [] })
  144. editor.assetStore = { validateReferences: async () => ({ valid: true }) }
  145. editor.clearEnvironmentPreview = () => {}
  146. editor.renderScene = async () => ++renders === 1 ? { error: new Error('GLB parse failed') } : {}
  147. editor.markDirty = () => { changes++ }
  148. editor.closeDialog = () => { closes++ }
  149. editor.toast = (message, type) => messages.push([message, type])
  150. await editor.bindPublishedScene('160@208')
  151. assert.deepEqual(editor.project.scenario, oldScenario)
  152. assert.deepEqual(messages, [['GLB parse failed', 'error']])
  153. assert.equal(changes, 0)
  154. assert.equal(closes, 0)
  155. assert.equal(editor.bindingScene, false)
  156. assert.equal(renders, 2)
  157. })