You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

379 regels
19 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. import { sceneDocumentSignature } from '../../unreal_tran_web/src/features/guide-legacy/demo-src/scene-history.js'
  6. const requireWeb = createRequire(new URL('../../unreal_tran_web/package.json', import.meta.url))
  7. const ts = requireWeb('typescript')
  8. const source = readFileSync(new URL('../../unreal_tran_web/src/features/scene-legacy/contentApiClient.ts', import.meta.url), 'utf8')
  9. const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText
  10. const { LegacySceneContentApiClient, LegacySceneOperationQueue, saveSceneDocumentWithCover } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`)
  11. const clone = value => structuredClone(value)
  12. const jpeg = () => new Blob(['scene screenshot'], { type: 'image/jpeg' })
  13. const oldUri = `content://sha256/${'a'.repeat(64)}`
  14. const defer = () => {
  15. let resolve, reject
  16. const promise = new Promise((yes, no) => { resolve = yes; reject = no })
  17. return { promise, resolve, reject }
  18. }
  19. function fixture() {
  20. const state = {
  21. id: '987', type: 'SCENE', code: 'SCENE_TEST', name: 'Test scene', description: '', categoryCode: 'OUTDOOR',
  22. version: 7, status: 'DRAFT', currentVersionId: '107', publishedVersionId: '106', coverUri: oldUri,
  23. content: { objects: [] }, dependencies: [],
  24. assets: [{ id: '200', code: 'SCENE_COVER', name: 'Old cover', type: 'IMAGE', storageUri: oldUri,
  25. mimeType: 'image/jpeg', sizeBytes: 12, sha256: 'a'.repeat(64), metadata: {}, status: 'READY', sortOrder: 2 }],
  26. }
  27. const controls = { uploadStatus: 200, putStatus: 200, uploadBarrier: null, uploadPatch: null }
  28. const requests = []
  29. const published = clone(state)
  30. const client = new LegacySceneContentApiClient(state.id, {
  31. tokenStorage: { accessToken: () => '', refreshToken: () => '', store() {}, clear() {}, notifyExpired() {} },
  32. lockManager: null,
  33. fetchImpl: async (url, init) => {
  34. const reply = (status, data) => new Response(JSON.stringify({ code: status, message: status >= 400 ? 'test failure' : 'ok', data }), { status })
  35. if (init.method === 'POST') {
  36. assert.equal(url, '/api/tran/v1/content/projects/987/assets/upload')
  37. const form = init.body
  38. requests.push({ method: 'POST', version: Number(form.get('projectVersion')), code: form.get('code'),
  39. type: form.get('type'), file: form.get('file'), metadata: JSON.parse(form.get('metadata')) })
  40. if (controls.uploadBarrier) await controls.uploadBarrier.promise
  41. if (controls.uploadStatus !== 200) return reply(controls.uploadStatus, null)
  42. assert.equal(Number(form.get('projectVersion')), state.version)
  43. return reply(200, { code: form.get('code'), name: form.get('name'), type: form.get('type'),
  44. storageUri: `content://sha256/${'b'.repeat(64)}`, mimeType: form.get('file').type, sizeBytes: form.get('file').size,
  45. sha256: 'b'.repeat(64), metadata: JSON.parse(form.get('metadata')), status: 'READY',
  46. sortOrder: Number(form.get('sortOrder')), uploadTicket: 'scene-cover-ticket', ...controls.uploadPatch })
  47. }
  48. assert.equal(init.method, 'PUT')
  49. const body = JSON.parse(init.body)
  50. requests.push({ method: 'PUT', body })
  51. if (controls.putStatus !== 200) return reply(controls.putStatus, null)
  52. assert.equal(body.version, state.version)
  53. Object.assign(state, clone(body), { version: state.version + 1 })
  54. state.assets.forEach(asset => { delete asset.uploadTicket })
  55. return reply(200, state)
  56. },
  57. })
  58. return { client, state, controls, requests, published }
  59. }
  60. test('scene image ticket, fixed asset replacement, dependencies and coverUri commit in one versioned PUT', async () => {
  61. const f = fixture()
  62. const original = clone(f.state)
  63. const document = { objects: [{ targetProjectId: '156', targetVersionId: '184', position: [2, 0, 0] }] }
  64. const saved = await saveSceneDocumentWithCover(f.client, original, document, { image: jpeg() })
  65. assert.deepEqual(f.requests.map(r => r.method), ['POST', 'PUT'])
  66. assert.equal(f.requests[0].code, 'SCENE_COVER')
  67. assert.equal(f.requests[0].type, 'IMAGE')
  68. assert.equal(f.requests[0].file.type, 'image/jpeg')
  69. assert.equal(f.requests[0].file.name, 'scene-cover.jpg')
  70. assert.equal(f.requests[0].metadata.usage, 'scene-cover')
  71. const put = f.requests[1].body
  72. assert.equal(put.assets.length, 1)
  73. assert.equal(put.assets[0].uploadTicket, 'scene-cover-ticket')
  74. assert.equal(put.assets[0].sortOrder, 2)
  75. assert.equal(put.coverUri, put.assets[0].storageUri)
  76. assert.deepEqual(put.content, document)
  77. assert.deepEqual(put.dependencies, [{ targetProjectId: '156', targetVersionId: '184', relationType: 'SCENE_MODEL', required: true }])
  78. assert.equal(saved.project.version, 8)
  79. assert.equal(saved.coverFailed, false)
  80. assert.deepEqual(original, f.published, 'input and immutable published snapshot remain unchanged')
  81. })
  82. test('capture failure saves content with the previous cover and reports a warning result', async () => {
  83. const f = fixture()
  84. const saved = await saveSceneDocumentWithCover(f.client, clone(f.state), { objects: [], label: 'edited' }, { error: new Error('tainted canvas') })
  85. assert.deepEqual(f.requests.map(r => r.method), ['PUT'])
  86. assert.equal(saved.coverFailed, true)
  87. assert.equal(saved.project.coverUri, oldUri)
  88. assert.equal(saved.project.content.label, 'edited')
  89. })
  90. test('upload failure keeps the previous cover but still saves scene content once', async () => {
  91. const f = fixture()
  92. f.controls.uploadStatus = 503
  93. const saved = await saveSceneDocumentWithCover(f.client, clone(f.state), { objects: [], label: 'edited' }, { image: jpeg() })
  94. assert.deepEqual(f.requests.map(r => r.method), ['POST', 'PUT'])
  95. assert.equal(saved.coverFailed, true)
  96. assert.equal(saved.project.coverUri, oldUri)
  97. assert.equal(f.requests[1].body.assets[0].uploadTicket, undefined)
  98. })
  99. test('upload conflict propagates without any content PUT', async () => {
  100. const f = fixture()
  101. f.controls.uploadStatus = 409
  102. await assert.rejects(saveSceneDocumentWithCover(f.client, clone(f.state), { objects: [] }, { image: jpeg() }), error => error.status === 409)
  103. assert.deepEqual(f.requests.map(r => r.method), ['POST'])
  104. assert.equal(f.state.version, 7)
  105. })
  106. for (const status of [409, 503]) {
  107. test(`final PUT ${status} keeps old project and never leaks the ticket into a later plain save`, async () => {
  108. const f = fixture()
  109. const original = clone(f.state)
  110. f.controls.putStatus = status
  111. await assert.rejects(saveSceneDocumentWithCover(f.client, original, { objects: [], label: 'failed' }, { image: jpeg() }), error => error.status === status)
  112. assert.deepEqual(original, f.published)
  113. assert.deepEqual(f.state, f.published)
  114. f.controls.putStatus = 200
  115. const saved = await saveSceneDocumentWithCover(f.client, original, { objects: [], label: 'plain' })
  116. assert.equal(saved.project.coverUri, oldUri)
  117. assert.equal(f.requests.at(-1).body.assets[0].uploadTicket, undefined)
  118. assert.deepEqual(f.requests.map(r => r.method), ['POST', 'PUT', 'PUT'])
  119. })
  120. }
  121. test('malformed image upload response is treated as optional cover failure', async () => {
  122. const f = fixture()
  123. f.controls.uploadPatch = { storageUri: 'blob:temporary' }
  124. const saved = await saveSceneDocumentWithCover(f.client, clone(f.state), { objects: [] }, { image: jpeg() })
  125. assert.equal(saved.coverFailed, true)
  126. assert.equal(saved.project.coverUri, oldUri)
  127. })
  128. test('switching project while upload is pending prevents the old document PUT', async () => {
  129. const f = fixture()
  130. f.controls.uploadBarrier = defer()
  131. let current = true
  132. const pending = saveSceneDocumentWithCover(f.client, clone(f.state), { objects: [] }, { image: jpeg() }, () => {
  133. if (!current) throw new Error('project switched')
  134. })
  135. current = false
  136. f.controls.uploadBarrier.resolve()
  137. await assert.rejects(pending, /project switched/)
  138. assert.deepEqual(f.requests.map(r => r.method), ['POST'])
  139. assert.equal(f.state.version, 7)
  140. })
  141. test('the shared operation queue serializes cover upload and final PUT before the next write', async () => {
  142. const f = fixture()
  143. const queue = new LegacySceneOperationQueue()
  144. f.controls.uploadBarrier = defer()
  145. const first = queue.enqueue(() => saveSceneDocumentWithCover(f.client, clone(f.state), { objects: [], label: 'cover' }, { image: jpeg() }))
  146. const second = queue.enqueue(() => saveSceneDocumentWithCover(f.client, clone(f.state), { objects: [], label: 'next' }))
  147. await new Promise(resolve => setImmediate(resolve))
  148. assert.deepEqual(f.requests.map(r => r.method), ['POST'])
  149. f.controls.uploadBarrier.resolve()
  150. await Promise.all([first, second])
  151. assert.deepEqual(f.requests.map(r => r.method), ['POST', 'PUT', 'PUT'])
  152. assert.deepEqual(f.requests.filter(r => r.body).map(r => r.body.version), [7, 8])
  153. })
  154. 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')
  155. const saveMethod = editorSource.slice(editorSource.indexOf(' async saveProject() {'), editorSource.indexOf(' /**\n * 读取已存场景')).trim()
  156. const { saveProject } = new Function('sceneDocumentSignature', `return ({ ${saveMethod} })`)(sceneDocumentSignature)
  157. function editorFixture(saveDocument) {
  158. return {
  159. readySettled: true, pendingLoads: 0, readOnly: false, destroyed: false, saving: false, publishing: false, dirty: false, changeRevision: 0,
  160. context: { saveDocument }, serialize: () => ({ objects: [] }), applyAccessMode() {}, setSaveState() {},
  161. updateHistoryControls() {}, messages: [], toast(message, type) { this.messages.push({ message, type }) },
  162. }
  163. }
  164. test('editor displays the partial-save warning instead of a success toast', async () => {
  165. const editor = editorFixture(async () => ({ warning: '场景已保存,封面更新失败,已保留原封面' }))
  166. await saveProject.call(editor)
  167. assert.equal(editor.dirty, false)
  168. assert.equal(editor.saving, false)
  169. assert.equal(editor.messages.at(-1).type, 'warn')
  170. assert.match(editor.messages.at(-1).message, /封面更新失败/)
  171. })
  172. test('editor failure remains dirty and duplicate clicks do not issue a second save', async () => {
  173. const barrier = defer()
  174. let calls = 0
  175. const editor = editorFixture(async () => { calls += 1; await barrier.promise })
  176. const pending = saveProject.call(editor)
  177. await saveProject.call(editor)
  178. assert.equal(calls, 1)
  179. barrier.reject(new Error('conflict'))
  180. await pending
  181. assert.equal(editor.dirty, true)
  182. assert.equal(editor.saving, false)
  183. assert.match(editor.messages.at(-1).message, /保存失败.*conflict/)
  184. })
  185. test('changes made while cover uploads remain dirty after the earlier snapshot saves', async () => {
  186. const barrier = defer()
  187. const editor = editorFixture(() => barrier.promise)
  188. const pending = saveProject.call(editor)
  189. editor.changeRevision += 1
  190. barrier.resolve({})
  191. await pending
  192. assert.equal(editor.dirty, true)
  193. assert.match(editor.messages.at(-1).message, /新修改仍待保存/)
  194. })
  195. const publishMethod = editorSource.slice(editorSource.indexOf(' async publishScene() {'), editorSource.indexOf(' /*\n * 原实现只设置原生 hidden')).trim()
  196. const { publishScene } = new Function(`return ({ ${publishMethod} })`)()
  197. function publishingEditor(saveDocument) {
  198. const editor = editorFixture(saveDocument)
  199. editor.canPublish = true
  200. editor.objects = []
  201. editor.assetStore = { validateReferences: async () => ({ valid: true }) }
  202. editor.verifyScene = () => [{ ok: true }]
  203. editor.saveProject = saveProject
  204. editor.publishCalls = 0
  205. editor.context.publishDocument = async () => { editor.publishCalls += 1 }
  206. return editor
  207. }
  208. const hostSource = readFileSync(new URL('../../unreal_tran_web/src/views/content/LegacyScenePageView.vue', import.meta.url), 'utf8')
  209. const leaveSource = hostSource.slice(hostSource.indexOf('async function confirmLeave('), hostSource.indexOf('function resetProjectSession()'))
  210. const leaveCompiled = ts.transpileModule(leaveSource, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None } }).outputText
  211. function leaveEditor(editor, {
  212. operations = new LegacySceneOperationQueue(), hostLoading = false,
  213. confirm = async () => undefined, preserveOnDiscard = false,
  214. } = {}) {
  215. return new Function('editorHandle', 'ElMessageBox', 'ElMessage', 'operationQueue', 'loading', 'preserveOnDiscard', `const creationNavigation = false, creatingProject = false;\n${leaveCompiled}\nreturn confirmLeave({ preserveOnDiscard })`)(
  216. editor,
  217. { confirm },
  218. { error: message => editor.toast(message, 'error'), info: message => editor.toast(message, 'info') },
  219. operations, { value: hostLoading }, preserveOnDiscard,
  220. )
  221. }
  222. test('publisher-only scene publishes the saved server version without attempting save or uploads', async () => {
  223. const editor = publishingEditor(() => { throw new Error('must not save') })
  224. editor.readOnly = true
  225. editor.assetStore.validateReferences = () => { throw new Error('must not validate local mutation state') }
  226. await publishScene.call(editor)
  227. assert.equal(editor.publishCalls, 1)
  228. assert.equal(editor.messages.at(-1).type, 'success')
  229. })
  230. test('update-only scene never starts saving as a side effect of denied publication', async () => {
  231. const editor = publishingEditor(() => { throw new Error('must not save') })
  232. editor.canPublish = false
  233. await publishScene.call(editor)
  234. assert.equal(editor.publishCalls, 0)
  235. })
  236. test('discard while switching roles preserves dirty recovery until all guards and role PUT succeed', async () => {
  237. const editor = publishingEditor(async () => ({}))
  238. editor.dirty = true
  239. const result = await leaveEditor(editor, { confirm: async () => { throw 'cancel' }, preserveOnDiscard: true })
  240. assert.equal(result, true)
  241. assert.equal(editor.dirty, true)
  242. })
  243. for (const status of [409, 503]) {
  244. test(`publish after PUT ${status} stays blocked and reports the save failure only once`, async () => {
  245. const editor = publishingEditor(async () => { throw Object.assign(new Error(`HTTP ${status}`), { status }) })
  246. await publishScene.call(editor)
  247. assert.equal(editor.publishCalls, 0)
  248. assert.equal(editor.dirty, true)
  249. assert.equal(editor.publishing, false)
  250. assert.equal(editor.messages.length, 1)
  251. assert.match(editor.messages[0].message, new RegExp(`HTTP ${status}`))
  252. })
  253. }
  254. test('save-and-leave keeps the editor open with one failure message', async () => {
  255. const editor = publishingEditor(async () => { throw new Error('version conflict') })
  256. editor.dirty = true
  257. assert.equal(await leaveEditor(editor), false)
  258. assert.equal(editor.dirty, true)
  259. assert.equal(editor.messages.length, 1)
  260. assert.match(editor.messages[0].message, /version conflict/)
  261. })
  262. for (const action of ['publish', 'leave']) {
  263. test(`${action} stays blocked when edits arrive during save, without claiming save failed`, async () => {
  264. const editor = publishingEditor(async () => { editor.changeRevision += 1; return {} })
  265. editor.dirty = true
  266. if (action === 'publish') await publishScene.call(editor)
  267. else assert.equal(await leaveEditor(editor), false)
  268. assert.equal(editor.publishCalls, 0)
  269. assert.equal(editor.dirty, true)
  270. assert.equal(editor.messages.length, 1)
  271. assert.match(editor.messages[0].message, /新修改仍待保存/)
  272. assert.doesNotMatch(editor.messages[0].message, /保存失败|未保存成功/)
  273. })
  274. }
  275. for (const state of ['host loading', 'restoring models', 'uploading model']) {
  276. test(`role switch blocks ${state} even before the scene becomes dirty`, async () => {
  277. const editor = publishingEditor(async () => ({}))
  278. if (state === 'restoring models') editor.readySettled = false
  279. if (state === 'uploading model') editor.pendingLoads = 1
  280. const allowed = await leaveEditor(editor, {
  281. hostLoading: state === 'host loading',
  282. confirm: () => assert.fail('must not offer discard during resource loading'),
  283. })
  284. assert.equal(allowed, false)
  285. assert.equal(editor.dirty, false)
  286. assert.equal(editor.messages.length, 1)
  287. assert.match(editor.messages[0].message, /加载或上传/)
  288. })
  289. }
  290. for (const outcome of ['saved', 'failed', 'new edits']) {
  291. test(`role switch waits for cover upload and save before checking final dirty: ${outcome}`, async () => {
  292. const operations = new LegacySceneOperationQueue()
  293. const barrier = defer()
  294. const editor = publishingEditor(() => operations.enqueue(() => barrier.promise))
  295. // Saving may start from a clean scene (for example, replacing its cover).
  296. const saving = editor.saveProject()
  297. let settled = false
  298. const leaving = leaveEditor(editor, {
  299. operations, confirm: () => assert.fail('must not bypass the current save with a second prompt'),
  300. }).then(result => { settled = true; return result })
  301. await new Promise(resolve => setImmediate(resolve))
  302. assert.equal(editor.saving, true)
  303. assert.equal(settled, false)
  304. if (outcome === 'failed') barrier.reject(new Error('HTTP 409'))
  305. else {
  306. if (outcome === 'new edits') editor.changeRevision += 1
  307. barrier.resolve({})
  308. }
  309. await saving
  310. assert.equal(await leaving, outcome === 'saved')
  311. assert.equal(editor.dirty, outcome !== 'saved')
  312. assert.equal(editor.messages.length, 1)
  313. if (outcome === 'failed') assert.match(editor.messages[0].message, /HTTP 409/)
  314. })
  315. }
  316. test('role switch waits through validation, save, and the later queued publish after dirty clears', async () => {
  317. const operations = new LegacySceneOperationQueue()
  318. const savingBarrier = defer()
  319. const publishingBarrier = defer()
  320. const editor = publishingEditor(() => operations.enqueue(() => savingBarrier.promise))
  321. editor.context.publishDocument = () => operations.enqueue(async () => {
  322. editor.publishCalls += 1
  323. await publishingBarrier.promise
  324. })
  325. const publishing = publishScene.call(editor)
  326. let settled = false
  327. const leaving = leaveEditor(editor, { operations }).then(result => { settled = true; return result })
  328. await new Promise(resolve => setImmediate(resolve))
  329. assert.equal(settled, false)
  330. savingBarrier.resolve({})
  331. await new Promise(resolve => setImmediate(resolve))
  332. assert.equal(editor.dirty, false)
  333. assert.equal(editor.publishCalls, 1)
  334. assert.equal(editor.publishing, true)
  335. assert.equal(settled, false)
  336. publishingBarrier.resolve()
  337. await publishing
  338. assert.equal(await leaving, true)
  339. })
  340. test('role switch rechecks resource uploads started while the save was finishing', async () => {
  341. const operations = new LegacySceneOperationQueue()
  342. const barrier = defer()
  343. const editor = publishingEditor(() => operations.enqueue(() => barrier.promise))
  344. const saving = editor.saveProject()
  345. const leaving = leaveEditor(editor, { operations })
  346. editor.pendingLoads = 1
  347. barrier.resolve({})
  348. await saving
  349. assert.equal(await leaving, false)
  350. assert.equal(editor.pendingLoads, 1)
  351. assert.match(editor.messages.at(-1).message, /加载或上传/)
  352. })