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

378 行
18 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, publishing: 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. })
  194. const publishMethod = editorSource.slice(editorSource.indexOf(' async publishScene() {'), editorSource.indexOf(' /*\n * 原实现只设置原生 hidden')).trim()
  195. const { publishScene } = new Function(`return ({ ${publishMethod} })`)()
  196. function publishingEditor(saveDocument) {
  197. const editor = editorFixture(saveDocument)
  198. editor.canPublish = true
  199. editor.objects = []
  200. editor.assetStore = { validateReferences: async () => ({ valid: true }) }
  201. editor.verifyScene = () => [{ ok: true }]
  202. editor.saveProject = saveProject
  203. editor.publishCalls = 0
  204. editor.context.publishDocument = async () => { editor.publishCalls += 1 }
  205. return editor
  206. }
  207. const hostSource = readFileSync(new URL('../../unreal_tran_web/src/views/content/LegacyScenePageView.vue', import.meta.url), 'utf8')
  208. const leaveSource = hostSource.slice(hostSource.indexOf('async function confirmLeave('), hostSource.indexOf('function resetProjectSession()'))
  209. const leaveCompiled = ts.transpileModule(leaveSource, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None } }).outputText
  210. function leaveEditor(editor, {
  211. operations = new LegacySceneOperationQueue(), hostLoading = false,
  212. confirm = async () => undefined, preserveOnDiscard = false,
  213. } = {}) {
  214. return new Function('editorHandle', 'ElMessageBox', 'ElMessage', 'operationQueue', 'loading', 'preserveOnDiscard', `${leaveCompiled}\nreturn confirmLeave({ preserveOnDiscard })`)(
  215. editor,
  216. { confirm },
  217. { error: message => editor.toast(message, 'error'), info: message => editor.toast(message, 'info') },
  218. operations, { value: hostLoading }, preserveOnDiscard,
  219. )
  220. }
  221. test('publisher-only scene publishes the saved server version without attempting save or uploads', async () => {
  222. const editor = publishingEditor(() => { throw new Error('must not save') })
  223. editor.readOnly = true
  224. editor.assetStore.validateReferences = () => { throw new Error('must not validate local mutation state') }
  225. await publishScene.call(editor)
  226. assert.equal(editor.publishCalls, 1)
  227. assert.equal(editor.messages.at(-1).type, 'success')
  228. })
  229. test('update-only scene never starts saving as a side effect of denied publication', async () => {
  230. const editor = publishingEditor(() => { throw new Error('must not save') })
  231. editor.canPublish = false
  232. await publishScene.call(editor)
  233. assert.equal(editor.publishCalls, 0)
  234. })
  235. test('discard while switching roles preserves dirty recovery until all guards and role PUT succeed', async () => {
  236. const editor = publishingEditor(async () => ({}))
  237. editor.dirty = true
  238. const result = await leaveEditor(editor, { confirm: async () => { throw 'cancel' }, preserveOnDiscard: true })
  239. assert.equal(result, true)
  240. assert.equal(editor.dirty, true)
  241. })
  242. for (const status of [409, 503]) {
  243. test(`publish after PUT ${status} stays blocked and reports the save failure only once`, async () => {
  244. const editor = publishingEditor(async () => { throw Object.assign(new Error(`HTTP ${status}`), { status }) })
  245. await publishScene.call(editor)
  246. assert.equal(editor.publishCalls, 0)
  247. assert.equal(editor.dirty, true)
  248. assert.equal(editor.publishing, false)
  249. assert.equal(editor.messages.length, 1)
  250. assert.match(editor.messages[0].message, new RegExp(`HTTP ${status}`))
  251. })
  252. }
  253. test('save-and-leave keeps the editor open with one failure message', async () => {
  254. const editor = publishingEditor(async () => { throw new Error('version conflict') })
  255. editor.dirty = true
  256. assert.equal(await leaveEditor(editor), false)
  257. assert.equal(editor.dirty, true)
  258. assert.equal(editor.messages.length, 1)
  259. assert.match(editor.messages[0].message, /version conflict/)
  260. })
  261. for (const action of ['publish', 'leave']) {
  262. test(`${action} stays blocked when edits arrive during save, without claiming save failed`, async () => {
  263. const editor = publishingEditor(async () => { editor.changeRevision += 1; return {} })
  264. editor.dirty = true
  265. if (action === 'publish') await publishScene.call(editor)
  266. else assert.equal(await leaveEditor(editor), false)
  267. assert.equal(editor.publishCalls, 0)
  268. assert.equal(editor.dirty, true)
  269. assert.equal(editor.messages.length, 1)
  270. assert.match(editor.messages[0].message, /新修改仍待保存/)
  271. assert.doesNotMatch(editor.messages[0].message, /保存失败|未保存成功/)
  272. })
  273. }
  274. for (const state of ['host loading', 'restoring models', 'uploading model']) {
  275. test(`role switch blocks ${state} even before the scene becomes dirty`, async () => {
  276. const editor = publishingEditor(async () => ({}))
  277. if (state === 'restoring models') editor.readySettled = false
  278. if (state === 'uploading model') editor.pendingLoads = 1
  279. const allowed = await leaveEditor(editor, {
  280. hostLoading: state === 'host loading',
  281. confirm: () => assert.fail('must not offer discard during resource loading'),
  282. })
  283. assert.equal(allowed, false)
  284. assert.equal(editor.dirty, false)
  285. assert.equal(editor.messages.length, 1)
  286. assert.match(editor.messages[0].message, /加载或上传/)
  287. })
  288. }
  289. for (const outcome of ['saved', 'failed', 'new edits']) {
  290. test(`role switch waits for cover upload and save before checking final dirty: ${outcome}`, async () => {
  291. const operations = new LegacySceneOperationQueue()
  292. const barrier = defer()
  293. const editor = publishingEditor(() => operations.enqueue(() => barrier.promise))
  294. // Saving may start from a clean scene (for example, replacing its cover).
  295. const saving = editor.saveProject()
  296. let settled = false
  297. const leaving = leaveEditor(editor, {
  298. operations, confirm: () => assert.fail('must not bypass the current save with a second prompt'),
  299. }).then(result => { settled = true; return result })
  300. await new Promise(resolve => setImmediate(resolve))
  301. assert.equal(editor.saving, true)
  302. assert.equal(settled, false)
  303. if (outcome === 'failed') barrier.reject(new Error('HTTP 409'))
  304. else {
  305. if (outcome === 'new edits') editor.changeRevision += 1
  306. barrier.resolve({})
  307. }
  308. await saving
  309. assert.equal(await leaving, outcome === 'saved')
  310. assert.equal(editor.dirty, outcome !== 'saved')
  311. assert.equal(editor.messages.length, 1)
  312. if (outcome === 'failed') assert.match(editor.messages[0].message, /HTTP 409/)
  313. })
  314. }
  315. test('role switch waits through validation, save, and the later queued publish after dirty clears', async () => {
  316. const operations = new LegacySceneOperationQueue()
  317. const savingBarrier = defer()
  318. const publishingBarrier = defer()
  319. const editor = publishingEditor(() => operations.enqueue(() => savingBarrier.promise))
  320. editor.context.publishDocument = () => operations.enqueue(async () => {
  321. editor.publishCalls += 1
  322. await publishingBarrier.promise
  323. })
  324. const publishing = publishScene.call(editor)
  325. let settled = false
  326. const leaving = leaveEditor(editor, { operations }).then(result => { settled = true; return result })
  327. await new Promise(resolve => setImmediate(resolve))
  328. assert.equal(settled, false)
  329. savingBarrier.resolve({})
  330. await new Promise(resolve => setImmediate(resolve))
  331. assert.equal(editor.dirty, false)
  332. assert.equal(editor.publishCalls, 1)
  333. assert.equal(editor.publishing, true)
  334. assert.equal(settled, false)
  335. publishingBarrier.resolve()
  336. await publishing
  337. assert.equal(await leaving, true)
  338. })
  339. test('role switch rechecks resource uploads started while the save was finishing', async () => {
  340. const operations = new LegacySceneOperationQueue()
  341. const barrier = defer()
  342. const editor = publishingEditor(() => operations.enqueue(() => barrier.promise))
  343. const saving = editor.saveProject()
  344. const leaving = leaveEditor(editor, { operations })
  345. editor.pendingLoads = 1
  346. barrier.resolve({})
  347. await saving
  348. assert.equal(await leaving, false)
  349. assert.equal(editor.pendingLoads, 1)
  350. assert.match(editor.messages.at(-1).message, /加载或上传/)
  351. })