Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.
 
 
 
 

208 rindas
11 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 source = readFileSync(new URL('../../unreal_tran_web/src/features/scene-legacy/contentApiClient.ts', import.meta.url), 'utf8')
  8. const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText
  9. const { LegacySceneContentApiClient, LegacySceneOperationQueue, saveSceneDocumentWithCover } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`)
  10. const clone = value => structuredClone(value)
  11. const jpeg = () => new Blob(['scene screenshot'], { type: 'image/jpeg' })
  12. const oldUri = `content://sha256/${'a'.repeat(64)}`
  13. const defer = () => {
  14. let resolve, reject
  15. const promise = new Promise((yes, no) => { resolve = yes; reject = no })
  16. return { promise, resolve, reject }
  17. }
  18. function fixture() {
  19. const state = {
  20. id: '987', type: 'SCENE', code: 'SCENE_TEST', name: 'Test scene', description: '', categoryCode: 'OUTDOOR',
  21. version: 7, status: 'DRAFT', currentVersionId: '107', publishedVersionId: '106', coverUri: oldUri,
  22. content: { objects: [] }, dependencies: [],
  23. assets: [{ id: '200', code: 'SCENE_COVER', name: 'Old cover', type: 'IMAGE', storageUri: oldUri,
  24. mimeType: 'image/jpeg', sizeBytes: 12, sha256: 'a'.repeat(64), metadata: {}, status: 'READY', sortOrder: 2 }],
  25. }
  26. const controls = { uploadStatus: 200, putStatus: 200, uploadBarrier: null, uploadPatch: null }
  27. const requests = []
  28. const published = clone(state)
  29. const client = new LegacySceneContentApiClient(state.id, {
  30. tokenStorage: { accessToken: () => '', refreshToken: () => '', store() {}, clear() {}, notifyExpired() {} },
  31. lockManager: null,
  32. fetchImpl: async (url, init) => {
  33. const reply = (status, data) => new Response(JSON.stringify({ code: status, message: status >= 400 ? 'test failure' : 'ok', data }), { status })
  34. if (init.method === 'POST') {
  35. assert.equal(url, '/api/tran/v1/content/projects/987/assets/upload')
  36. const form = init.body
  37. requests.push({ method: 'POST', version: Number(form.get('projectVersion')), code: form.get('code'),
  38. type: form.get('type'), file: form.get('file'), metadata: JSON.parse(form.get('metadata')) })
  39. if (controls.uploadBarrier) await controls.uploadBarrier.promise
  40. if (controls.uploadStatus !== 200) return reply(controls.uploadStatus, null)
  41. assert.equal(Number(form.get('projectVersion')), state.version)
  42. return reply(200, { code: form.get('code'), name: form.get('name'), type: form.get('type'),
  43. storageUri: `content://sha256/${'b'.repeat(64)}`, mimeType: form.get('file').type, sizeBytes: form.get('file').size,
  44. sha256: 'b'.repeat(64), metadata: JSON.parse(form.get('metadata')), status: 'READY',
  45. sortOrder: Number(form.get('sortOrder')), uploadTicket: 'scene-cover-ticket', ...controls.uploadPatch })
  46. }
  47. assert.equal(init.method, 'PUT')
  48. const body = JSON.parse(init.body)
  49. requests.push({ method: 'PUT', body })
  50. if (controls.putStatus !== 200) return reply(controls.putStatus, null)
  51. assert.equal(body.version, state.version)
  52. Object.assign(state, clone(body), { version: state.version + 1 })
  53. state.assets.forEach(asset => { delete asset.uploadTicket })
  54. return reply(200, state)
  55. },
  56. })
  57. return { client, state, controls, requests, published }
  58. }
  59. test('scene image ticket, fixed asset replacement, dependencies and coverUri commit in one versioned PUT', async () => {
  60. const f = fixture()
  61. const original = clone(f.state)
  62. const document = { objects: [{ targetProjectId: '156', targetVersionId: '184', position: [2, 0, 0] }] }
  63. const saved = await saveSceneDocumentWithCover(f.client, original, document, { image: jpeg() })
  64. assert.deepEqual(f.requests.map(r => r.method), ['POST', 'PUT'])
  65. assert.equal(f.requests[0].code, 'SCENE_COVER')
  66. assert.equal(f.requests[0].type, 'IMAGE')
  67. assert.equal(f.requests[0].file.type, 'image/jpeg')
  68. assert.equal(f.requests[0].file.name, 'scene-cover.jpg')
  69. assert.equal(f.requests[0].metadata.usage, 'scene-cover')
  70. const put = f.requests[1].body
  71. assert.equal(put.assets.length, 1)
  72. assert.equal(put.assets[0].uploadTicket, 'scene-cover-ticket')
  73. assert.equal(put.assets[0].sortOrder, 2)
  74. assert.equal(put.coverUri, put.assets[0].storageUri)
  75. assert.deepEqual(put.content, document)
  76. assert.deepEqual(put.dependencies, [{ targetProjectId: '156', targetVersionId: '184', relationType: 'SCENE_MODEL', required: true }])
  77. assert.equal(saved.project.version, 8)
  78. assert.equal(saved.coverFailed, false)
  79. assert.deepEqual(original, f.published, 'input and immutable published snapshot remain unchanged')
  80. })
  81. test('capture failure saves content with the previous cover and reports a warning result', async () => {
  82. const f = fixture()
  83. const saved = await saveSceneDocumentWithCover(f.client, clone(f.state), { objects: [], label: 'edited' }, { error: new Error('tainted canvas') })
  84. assert.deepEqual(f.requests.map(r => r.method), ['PUT'])
  85. assert.equal(saved.coverFailed, true)
  86. assert.equal(saved.project.coverUri, oldUri)
  87. assert.equal(saved.project.content.label, 'edited')
  88. })
  89. test('upload failure keeps the previous cover but still saves scene content once', async () => {
  90. const f = fixture()
  91. f.controls.uploadStatus = 503
  92. const saved = await saveSceneDocumentWithCover(f.client, clone(f.state), { objects: [], label: 'edited' }, { image: jpeg() })
  93. assert.deepEqual(f.requests.map(r => r.method), ['POST', 'PUT'])
  94. assert.equal(saved.coverFailed, true)
  95. assert.equal(saved.project.coverUri, oldUri)
  96. assert.equal(f.requests[1].body.assets[0].uploadTicket, undefined)
  97. })
  98. test('upload conflict propagates without any content PUT', async () => {
  99. const f = fixture()
  100. f.controls.uploadStatus = 409
  101. await assert.rejects(saveSceneDocumentWithCover(f.client, clone(f.state), { objects: [] }, { image: jpeg() }), error => error.status === 409)
  102. assert.deepEqual(f.requests.map(r => r.method), ['POST'])
  103. assert.equal(f.state.version, 7)
  104. })
  105. for (const status of [409, 503]) {
  106. test(`final PUT ${status} keeps old project and never leaks the ticket into a later plain save`, async () => {
  107. const f = fixture()
  108. const original = clone(f.state)
  109. f.controls.putStatus = status
  110. await assert.rejects(saveSceneDocumentWithCover(f.client, original, { objects: [], label: 'failed' }, { image: jpeg() }), error => error.status === status)
  111. assert.deepEqual(original, f.published)
  112. assert.deepEqual(f.state, f.published)
  113. f.controls.putStatus = 200
  114. const saved = await saveSceneDocumentWithCover(f.client, original, { objects: [], label: 'plain' })
  115. assert.equal(saved.project.coverUri, oldUri)
  116. assert.equal(f.requests.at(-1).body.assets[0].uploadTicket, undefined)
  117. assert.deepEqual(f.requests.map(r => r.method), ['POST', 'PUT', 'PUT'])
  118. })
  119. }
  120. test('malformed image upload response is treated as optional cover failure', async () => {
  121. const f = fixture()
  122. f.controls.uploadPatch = { storageUri: 'blob:temporary' }
  123. const saved = await saveSceneDocumentWithCover(f.client, clone(f.state), { objects: [] }, { image: jpeg() })
  124. assert.equal(saved.coverFailed, true)
  125. assert.equal(saved.project.coverUri, oldUri)
  126. })
  127. test('switching project while upload is pending prevents the old document PUT', async () => {
  128. const f = fixture()
  129. f.controls.uploadBarrier = defer()
  130. let current = true
  131. const pending = saveSceneDocumentWithCover(f.client, clone(f.state), { objects: [] }, { image: jpeg() }, () => {
  132. if (!current) throw new Error('project switched')
  133. })
  134. current = false
  135. f.controls.uploadBarrier.resolve()
  136. await assert.rejects(pending, /project switched/)
  137. assert.deepEqual(f.requests.map(r => r.method), ['POST'])
  138. assert.equal(f.state.version, 7)
  139. })
  140. test('the shared operation queue serializes cover upload and final PUT before the next write', async () => {
  141. const f = fixture()
  142. const queue = new LegacySceneOperationQueue()
  143. f.controls.uploadBarrier = defer()
  144. const first = queue.enqueue(() => saveSceneDocumentWithCover(f.client, clone(f.state), { objects: [], label: 'cover' }, { image: jpeg() }))
  145. const second = queue.enqueue(() => saveSceneDocumentWithCover(f.client, clone(f.state), { objects: [], label: 'next' }))
  146. await new Promise(resolve => setImmediate(resolve))
  147. assert.deepEqual(f.requests.map(r => r.method), ['POST'])
  148. f.controls.uploadBarrier.resolve()
  149. await Promise.all([first, second])
  150. assert.deepEqual(f.requests.map(r => r.method), ['POST', 'PUT', 'PUT'])
  151. assert.deepEqual(f.requests.filter(r => r.body).map(r => r.body.version), [7, 8])
  152. })
  153. const editorSource = readFileSync(new URL('../../unreal_tran_web/src/features/guide-legacy/demo-src/scene-editor.js', import.meta.url), 'utf8').replace(/\r\n?/g, '\n')
  154. const saveMethod = editorSource.slice(editorSource.indexOf(' async saveProject() {'), editorSource.indexOf(' /**\n * 读取已存场景')).trim()
  155. const { saveProject } = new Function(`return ({ ${saveMethod} })`)()
  156. function editorFixture(saveDocument) {
  157. return {
  158. readySettled: true, pendingLoads: 0, readOnly: false, destroyed: false, saving: false, dirty: false, changeRevision: 0,
  159. context: { saveDocument }, serialize: () => ({ objects: [] }), applyAccessMode() {}, setSaveState() {},
  160. updateHistoryControls() {}, messages: [], toast(message, type) { this.messages.push({ message, type }) },
  161. }
  162. }
  163. test('editor displays the partial-save warning instead of a success toast', async () => {
  164. const editor = editorFixture(async () => ({ warning: '场景已保存,封面更新失败,已保留原封面' }))
  165. await saveProject.call(editor)
  166. assert.equal(editor.dirty, false)
  167. assert.equal(editor.saving, false)
  168. assert.equal(editor.messages.at(-1).type, 'warn')
  169. assert.match(editor.messages.at(-1).message, /封面更新失败/)
  170. })
  171. test('editor failure remains dirty and duplicate clicks do not issue a second save', async () => {
  172. const barrier = defer()
  173. let calls = 0
  174. const editor = editorFixture(async () => { calls += 1; await barrier.promise })
  175. const pending = saveProject.call(editor)
  176. await saveProject.call(editor)
  177. assert.equal(calls, 1)
  178. barrier.reject(new Error('conflict'))
  179. await pending
  180. assert.equal(editor.dirty, true)
  181. assert.equal(editor.saving, false)
  182. assert.match(editor.messages.at(-1).message, /保存失败.*conflict/)
  183. })
  184. test('changes made while cover uploads remain dirty after the earlier snapshot saves', async () => {
  185. const barrier = defer()
  186. const editor = editorFixture(() => barrier.promise)
  187. const pending = saveProject.call(editor)
  188. editor.changeRevision += 1
  189. barrier.resolve({})
  190. await pending
  191. assert.equal(editor.dirty, true)
  192. assert.match(editor.messages.at(-1).message, /新修改仍待保存/)
  193. })