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

282 行
14 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 sourceRoot = new URL('../../unreal_tran_web/src/features/scene-legacy/', import.meta.url)
  8. const dataUrl = source => `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`
  9. const compile = file => ts.transpileModule(readFileSync(new URL(file, sourceRoot), 'utf8'), {
  10. compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
  11. }).outputText
  12. const clientUrl = dataUrl(compile('contentApiClient.ts'))
  13. const { LegacySceneOperationQueue, toPublishedModelAsset } = await import(clientUrl)
  14. const storeSource = compile('serverAssetStore.ts').replace(/from ['"]\.\/contentApiClient['"]/g, `from '${clientUrl}'`)
  15. const { SceneServerAssetStore } = await import(dataUrl(storeSource))
  16. const asset = (id, code, overrides = {}) => ({
  17. id, code, versionId: '201', name: `${code}.glb`, type: 'MODEL_FILE', status: 'READY',
  18. storageUri: `fsvc://test/${code}.glb`, mimeType: 'model/gltf-binary', sizeBytes: 12,
  19. sha256: 'a'.repeat(64), metadata: {}, sortOrder: 0, ...overrides,
  20. })
  21. const foxReference = (patch = {}) => ({ targetProjectId: '157', targetVersionId: '201',
  22. sourceAssetId: '2030', assetCode: 'FOX', resourceUrl: 'content://asset/model/157/201/2030', ...patch })
  23. function fixture(options = {}) {
  24. const calls = { versions: [], downloads: [], fallback: [], revoked: [], blobs: [] }
  25. const version = { id: '201', projectId: '157', versionNumber: 2, status: 'PUBLISHED',
  26. content: { modelResource: { assetCode: 'FOX' } },
  27. assets: [asset('2030', 'FOX'), asset('2031', 'BOX')], ...options.version }
  28. const project = { id: '10', version: 1, assets: [asset('81', 'LOCAL', { versionId: '11' })],
  29. dependencies: [{ relationType: 'SCENE_MODEL', targetProjectId: '157', targetVersionId: '201', required: true }],
  30. ...options.project }
  31. const client = {
  32. projectId: project.id,
  33. loadVersion: async (...args) => {
  34. calls.versions.push(args)
  35. return options.loadVersion ? options.loadVersion(...args) : structuredClone(version)
  36. },
  37. downloadAsset: async (...args) => {
  38. calls.downloads.push(args)
  39. return options.downloadAsset ? options.downloadAsset(...args) : new Blob([args.join('/')])
  40. },
  41. }
  42. const fallback = {
  43. put: async () => { throw new Error('Unexpected local upload') },
  44. hasReferenceSync: value => { calls.fallback.push(['has', value]); return true },
  45. acquireObjectUrl: async value => { calls.fallback.push(['acquire', value]); return 'blob:stale-local-cache' },
  46. releaseObjectUrl: value => calls.fallback.push(['release', value]),
  47. validateReferences: async value => { calls.fallback.push(['validate', value]); return { valid: true, missing: [] } },
  48. }
  49. const store = new SceneServerAssetStore({ client, project: () => project, fallback,
  50. operationQueue: new LegacySceneOperationQueue(), commitUploadedAsset: async () => {},
  51. urlApi: {
  52. createObjectURL: blob => { calls.blobs.push(blob); return `blob:server-${calls.blobs.length}` },
  53. revokeObjectURL: url => calls.revoked.push(url),
  54. },
  55. })
  56. return { store, project, version, calls }
  57. }
  58. test('fixed model reads the exact version and downloads through its visible scene dependency', async () => {
  59. const f = fixture()
  60. const reference = foxReference()
  61. const url = await f.store.acquireObjectUrl(reference)
  62. assert.equal(url, 'blob:server-1')
  63. assert.deepEqual(f.calls.versions, [['10', '201']])
  64. assert.deepEqual(f.calls.downloads, [['10', '2030']])
  65. assert.equal(await f.calls.blobs[0].text(), '10/2030')
  66. assert.deepEqual(f.calls.fallback, [])
  67. f.store.releaseObjectUrl(reference)
  68. assert.deepEqual(f.calls.revoked, [url])
  69. })
  70. test('an explicit missing code cannot fall back to the first READY asset', async () => {
  71. const f = fixture()
  72. await assert.rejects(f.store.acquireObjectUrl(foxReference({ assetCode: 'MISSING', sourceAssetId: '', resourceUrl: '' })))
  73. assert.deepEqual(f.calls.downloads, [])
  74. assert.deepEqual(f.calls.fallback, [])
  75. })
  76. test('asset code and id must refer to the same model file', async () => {
  77. const f = fixture()
  78. await assert.rejects(f.store.acquireObjectUrl(foxReference({ assetCode: 'BOX' })))
  79. assert.deepEqual(f.calls.downloads, [])
  80. assert.deepEqual(f.calls.fallback, [])
  81. })
  82. test('model URI project, version and asset id must agree with the descriptor', async () => {
  83. for (const resourceUrl of ['content://asset/model/158/201/2030',
  84. 'content://asset/model/157/202/2030', 'content://asset/model/157/201/2031']) {
  85. const f = fixture()
  86. await assert.rejects(f.store.acquireObjectUrl(foxReference({ resourceUrl })), undefined, resourceUrl)
  87. assert.deepEqual(f.calls.downloads, [])
  88. assert.deepEqual(f.calls.fallback, [])
  89. }
  90. })
  91. test('a non-pseudo resource URI must still match the selected fixed model storage URI', async () => {
  92. const f = fixture()
  93. await assert.rejects(f.store.acquireObjectUrl(foxReference({ resourceUrl: 'fsvc://test/OTHER.glb' })))
  94. assert.deepEqual(f.calls.downloads, [])
  95. assert.deepEqual(f.calls.fallback, [])
  96. })
  97. test('fixed model references cannot bypass identity checks with an unrelated built-in path', async () => {
  98. const f = fixture()
  99. await assert.rejects(f.store.acquireObjectUrl(foxReference({ resourceUrl: '/models/other.glb' })))
  100. assert.deepEqual(f.calls.fallback, [])
  101. })
  102. test('a valid fixed built-in file is verified against its version before using static delivery', async () => {
  103. const f = fixture({ version: { assets: [asset('2030', 'FOX', { storageUri: '/models/fox.glb' })] } })
  104. const reference = foxReference({ resourceUrl: '/models/fox.glb' })
  105. assert.equal((await f.store.validateReferences([reference])).valid, true)
  106. await f.store.acquireObjectUrl(reference)
  107. assert.deepEqual(f.calls.versions, [['10', '201']])
  108. assert.deepEqual(f.calls.downloads, [], 'Built-in files do not use the controlled-file endpoint')
  109. assert.equal(f.calls.fallback.filter(call => call[0] === 'acquire').length, 1)
  110. })
  111. test('a matching controlled storage URI is accepted for the fixed model file', async () => {
  112. const f = fixture()
  113. assert.equal(await f.store.acquireObjectUrl(foxReference({ resourceUrl: 'fsvc://test/FOX.glb' })), 'blob:server-1')
  114. assert.deepEqual(f.calls.downloads, [['10', '2030']])
  115. })
  116. test('non-READY files and READY images are not accepted as model files', async () => {
  117. for (const patch of [{ status: 'UPLOADING' }, { type: 'IMAGE' }]) {
  118. const f = fixture({ version: { assets: [asset('2030', 'FOX', patch)] } })
  119. await assert.rejects(f.store.acquireObjectUrl(foxReference()))
  120. assert.deepEqual(f.calls.downloads, [])
  121. assert.deepEqual(f.calls.fallback, [])
  122. }
  123. })
  124. test('a version response from a different project or version cannot supply a matching asset id', async () => {
  125. for (const patch of [{ id: '202' }, { projectId: '158' }]) {
  126. const f = fixture({ version: patch })
  127. await assert.rejects(f.store.acquireObjectUrl(foxReference()))
  128. assert.deepEqual(f.calls.downloads, [])
  129. }
  130. })
  131. test('a failed fixed-version lookup is evicted and the same reference can be retried', async () => {
  132. let attempt = 0
  133. const f = fixture({ loadVersion: async () => {
  134. if (++attempt === 1) throw new Error('Injected version timeout')
  135. return structuredClone(f.version)
  136. } })
  137. await assert.rejects(f.store.acquireObjectUrl(foxReference()), /Injected version timeout/)
  138. assert.equal(await f.store.acquireObjectUrl(foxReference()), 'blob:server-1')
  139. assert.equal(f.calls.versions.length, 2)
  140. assert.equal(f.calls.downloads.length, 1)
  141. assert.deepEqual(f.calls.fallback, [])
  142. })
  143. test('a controlled download failure does not return local cached bytes and can retry', async () => {
  144. let attempt = 0
  145. const f = fixture({ downloadAsset: async () => {
  146. if (++attempt === 1) throw new Error('Injected controlled download failure')
  147. return new Blob(['fresh controlled GLB'])
  148. } })
  149. await assert.rejects(f.store.acquireObjectUrl(foxReference({ assetKey: 'old-cached-fox' })), /controlled download failure/)
  150. assert.equal(await f.store.acquireObjectUrl(foxReference({ assetKey: 'old-cached-fox' })), 'blob:server-1')
  151. assert.equal(await f.calls.blobs[0].text(), 'fresh controlled GLB')
  152. assert.deepEqual(f.calls.fallback, [])
  153. })
  154. test('scene-owned code resolves only against the current scene assets', async () => {
  155. const f = fixture()
  156. assert.equal(await f.store.acquireObjectUrl({ assetCode: 'LOCAL', resourceUrl: 'content://asset/scene/10/LOCAL' }), 'blob:server-1')
  157. assert.deepEqual(f.calls.versions, [])
  158. assert.deepEqual(f.calls.downloads, [['10', '81']])
  159. })
  160. test('scene-owned code does not override a conflicting controlled storage URI', async () => {
  161. const f = fixture()
  162. await assert.rejects(f.store.acquireObjectUrl({ assetCode: 'LOCAL', resourceUrl: 'fsvc://test/OTHER.glb' }))
  163. assert.deepEqual(f.calls.downloads, [])
  164. assert.deepEqual(f.calls.fallback, [])
  165. })
  166. test('copied scene keeps its original pseudo URI but reads its own copied asset by stable code', async () => {
  167. const f = fixture()
  168. assert.equal(await f.store.acquireObjectUrl({ assetCode: 'LOCAL', resourceUrl: 'content://asset/scene/9/LOCAL' }), 'blob:server-1')
  169. assert.deepEqual(f.calls.downloads, [['10', '81']])
  170. assert.deepEqual(f.calls.fallback, [])
  171. })
  172. test('string content URIs are resolved for fixed models and scene-owned uploads', async () => {
  173. const f = fixture()
  174. await f.store.acquireObjectUrl('content://asset/model/157/201/2030')
  175. await f.store.acquireObjectUrl('content://asset/scene/10/LOCAL')
  176. assert.deepEqual(f.calls.downloads, [['10', '2030'], ['10', '81']])
  177. assert.deepEqual(f.calls.fallback, [])
  178. })
  179. test('a pooled fixed model URL does not validate another descriptor with a conflicting URI', async () => {
  180. const f = fixture()
  181. await f.store.acquireObjectUrl(foxReference())
  182. await assert.rejects(f.store.acquireObjectUrl(foxReference({ resourceUrl: 'content://asset/model/157/202/2030' })))
  183. assert.equal(f.calls.downloads.length, 1)
  184. })
  185. test('a pooled scene-owned code does not validate another descriptor with a conflicting asset id', async () => {
  186. const f = fixture()
  187. await f.store.acquireObjectUrl({ assetCode: 'LOCAL', sourceAssetId: '81' })
  188. await assert.rejects(f.store.acquireObjectUrl({ assetCode: 'LOCAL', sourceAssetId: '999' }))
  189. assert.equal(f.calls.downloads.length, 1)
  190. })
  191. test('scene-owned ID-only references isolate cached files and reject a missing ID', async () => {
  192. for (const idField of ['sourceAssetId', 'assetId']) {
  193. const f = fixture({ project: { assets: [asset('81', 'LOCAL'), asset('82', 'OTHER')] } })
  194. const first = { [idField]: '81' }, second = { [idField]: '82' }
  195. assert.equal(await f.store.acquireObjectUrl(first), 'blob:server-1')
  196. await assert.rejects(f.store.acquireObjectUrl({ [idField]: '999' }), /服务端模型资源不存在/)
  197. assert.equal(await f.store.acquireObjectUrl(second), 'blob:server-2')
  198. assert.deepEqual(f.calls.downloads, [['10', '81'], ['10', '82']])
  199. assert.deepEqual(f.calls.fallback, [])
  200. f.store.releaseObjectUrl(first)
  201. assert.deepEqual(f.calls.revoked, ['blob:server-1'])
  202. assert.equal(await f.store.acquireObjectUrl(second), 'blob:server-2')
  203. assert.equal(f.calls.downloads.length, 2)
  204. f.store.dispose()
  205. }
  206. })
  207. test('ID-only validation retains a missing reference when a valid reference follows it', async () => {
  208. for (const idField of ['sourceAssetId', 'assetId']) {
  209. const f = fixture()
  210. const result = await f.store.validateReferences([
  211. { [idField]: '999', sourceName: 'missing-file' },
  212. { [idField]: '81', sourceName: 'valid-file' },
  213. ])
  214. assert.equal(result.valid, false)
  215. assert.deepEqual(result.missing, ['missing-file'])
  216. assert.deepEqual(f.calls.fallback, [])
  217. assert.deepEqual(f.calls.downloads, [])
  218. f.store.dispose()
  219. }
  220. })
  221. test('validation does not deduplicate conflicting scene-owned descriptors into a valid one', async () => {
  222. const f = fixture()
  223. const result = await f.store.validateReferences([{ assetCode: 'LOCAL', sourceAssetId: '999' },
  224. { assetCode: 'LOCAL', sourceAssetId: '81' }])
  225. assert.equal(result.valid, false)
  226. assert.equal(result.missing.length, 1)
  227. assert.deepEqual(f.calls.fallback, [])
  228. })
  229. test('server-managed invalid references cannot pass using a populated fallback cache', async () => {
  230. const f = fixture()
  231. const result = await f.store.validateReferences([{ assetCode: 'MISSING', assetKey: 'old-cached-fox' }])
  232. assert.equal(result.valid, false)
  233. assert.deepEqual(f.calls.fallback, [])
  234. })
  235. test('plain local legacy references retain their fallback behavior', async () => {
  236. const f = fixture()
  237. assert.equal(await f.store.acquireObjectUrl({ assetKey: 'local-only-legacy' }), 'blob:stale-local-cache')
  238. assert.equal(f.calls.fallback.length, 1)
  239. assert.deepEqual(f.calls.downloads, [])
  240. })
  241. test('published model catalogue refuses an explicit missing code instead of choosing the first GLB', () => {
  242. const f = fixture({ version: { content: { modelResource: { assetCode: 'MISSING' } } } })
  243. assert.equal(toPublishedModelAsset({ projectId: '157', versionId: '201' }, f.version), null)
  244. })
  245. test('published model catalogue selects only a READY MODEL_FILE and preserves its fixed identity', () => {
  246. const f = fixture({ version: { assets: [asset('99', 'FOX', { type: 'IMAGE' }),
  247. asset('100', 'FOX', { status: 'UPLOADING' }), asset('2030', 'FOX')] } })
  248. const result = toPublishedModelAsset({ projectId: '157', versionId: '201' }, f.version)
  249. assert.equal(result.assetCode, 'FOX')
  250. assert.equal(result.sourceAssetId, '2030')
  251. assert.equal(result.targetVersionId, '201')
  252. assert.equal(result.resourceUrl, 'content://asset/model/157/201/2030')
  253. })