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.
 
 
 
 

267 rivejä
14 KiB

  1. import assert from 'node:assert/strict'
  2. import { readFile } from 'node:fs/promises'
  3. import test from 'node:test'
  4. import ts from 'typescript'
  5. const features = new URL('../../unreal_tran_web/src/features/', import.meta.url)
  6. async function moduleUrl(relative, replacements = {}) {
  7. let source = await readFile(new URL(relative, features), 'utf8')
  8. for (const [from, to] of Object.entries(replacements)) source = source.replace(from, to)
  9. const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 } })
  10. return `data:text/javascript;base64,${Buffer.from(output.outputText).toString('base64')}`
  11. }
  12. const sceneCaptureUrl = await moduleUrl('scene-legacy/sceneCoverCapture.ts')
  13. const { captureTrainingCover } = await import(await moduleUrl('training-legacy/trainingCoverCapture.ts', {
  14. '../scene-legacy/sceneCoverCapture': sceneCaptureUrl,
  15. }))
  16. const { saveTrainingWithCover } = await import(await moduleUrl('training-legacy/trainingCoverPersistence.ts'))
  17. const oldCover = { code: 'TRAINING_COVER', storageUri: 'fsvc://old', sortOrder: 1 }
  18. const audio = { code: 'NARRATION', storageUri: 'fsvc://audio', sortOrder: 0 }
  19. const newCover = { code: 'TRAINING_COVER', storageUri: 'fsvc://new', sortOrder: 1, uploadTicket: 'test-only-ticket' }
  20. function persistenceFixture({ captureError, uploadError, updateError, invalidateAfterUpload = false } = {}) {
  21. const calls = []
  22. let current = true
  23. const project = { id: '161', version: 8, coverUri: oldCover.storageUri, assets: [audio, oldCover] }
  24. const input = { type: 'TRAINING', version: 8, assets: [audio, oldCover], content: { steps: [{ stepCode: 'STEP-1' }] }, dependencies: [{ targetProjectId: '160', targetVersionId: '210', relationType: 'TRAINING_SCENE', required: true }] }
  25. const capture = async () => { if (captureError) throw captureError; return new Blob(['jpeg'], { type: 'image/jpeg' }) }
  26. const api = {
  27. async upload(file, params) { calls.push({ method: 'upload', file, params }); if (uploadError) throw uploadError; if (invalidateAfterUpload) current = false; return newCover },
  28. async update(body) { calls.push({ method: 'PUT', body }); if (updateError) throw updateError; return { ...project, ...body, version: 9 } },
  29. }
  30. const run = () => saveTrainingWithCover(project, input, capture, api, () => { if (!current) throw new Error('Project switched') })
  31. return { calls, project, input, run }
  32. }
  33. test('training cover ticket, content and fixed scene dependency share exactly one PUT', async () => {
  34. const f = persistenceFixture()
  35. const result = await f.run()
  36. assert.equal(result.coverFailed, false)
  37. assert.deepEqual(f.calls.map(x => x.method), ['upload', 'PUT'])
  38. assert.equal(f.calls[0].file.type, 'image/jpeg')
  39. assert.equal(f.calls[0].params.projectVersion, 8)
  40. assert.equal(f.calls[0].params.code, 'TRAINING_COVER')
  41. assert.deepEqual(f.calls[1].body.assets, [audio, newCover])
  42. assert.equal(f.calls[1].body.coverUri, 'fsvc://new')
  43. assert.deepEqual(f.calls[1].body.dependencies, f.input.dependencies)
  44. assert.deepEqual(f.calls[1].body.content, f.input.content)
  45. assert.deepEqual(f.input.assets, [audio, oldCover])
  46. })
  47. for (const failure of ['captureError', 'uploadError']) {
  48. test(`${failure} retains the old cover while saving training content`, async () => {
  49. const f = persistenceFixture({ [failure]: new Error('Unavailable') })
  50. const result = await f.run()
  51. assert.equal(result.coverFailed, true)
  52. const puts = f.calls.filter(x => x.method === 'PUT')
  53. assert.equal(puts.length, 1)
  54. assert.equal(puts[0].body.coverUri, 'fsvc://old')
  55. assert.deepEqual(puts[0].body.assets, [audio, oldCover])
  56. })
  57. }
  58. test('upload 409 stops without attempting a fallback PUT', async () => {
  59. const conflict = Object.assign(new Error('Conflict'), { status: 409 })
  60. const f = persistenceFixture({ uploadError: conflict })
  61. await assert.rejects(f.run(), error => error === conflict)
  62. assert.deepEqual(f.calls.map(x => x.method), ['upload'])
  63. })
  64. test('PUT 409 is propagated without retrying or replacing the prior cover', async () => {
  65. const conflict = Object.assign(new Error('Conflict'), { status: 409 })
  66. const f = persistenceFixture({ updateError: conflict })
  67. await assert.rejects(f.run(), error => error === conflict)
  68. assert.deepEqual(f.calls.map(x => x.method), ['upload', 'PUT'])
  69. assert.equal(f.project.coverUri, oldCover.storageUri)
  70. })
  71. test('project switch while uploading stops the final update', async () => {
  72. const f = persistenceFixture({ invalidateAfterUpload: true })
  73. await assert.rejects(f.run(), /Project switched/)
  74. assert.deepEqual(f.calls.map(x => x.method), ['upload'])
  75. })
  76. const value = initial => ({ value: initial, clone() { return value(this.value) }, copy(other) { this.value = other.value; return this } })
  77. function captureFixture({ drawError } = {}) {
  78. const grid = { type: 'GridHelper', visible: true }, path = { visible: true }
  79. const material = { emissive: value('selected'), emissiveIntensity: 0.55 }
  80. const object = { scale: value(1.04) }
  81. const events = []
  82. const record = action => events.push({ action, grid: grid.visible, path: path.visible, color: material.emissive.value, intensity: material.emissiveIntensity, scale: object.scale.value })
  83. const canvas = { width: 0, height: 0, getContext: () => ({ drawImage() { record('copy'); if (drawError) throw drawError } }), toBlob(callback, type) { queueMicrotask(() => callback(new Blob(['image'], { type }))) } }
  84. const editor = {
  85. scene: { traverse: visitor => visitor(grid) }, camera: {}, pathPreview: path,
  86. targetMaterials: new Map([['fox', [{ material, color: value('original'), intensity: 0 }]]]),
  87. targetScales: new Map([['fox', value(1)]]), targetObjects: new Map([['fox', object]]),
  88. renderer: { domElement: { width: 1600, height: 900 }, render() { record('render') } },
  89. }
  90. const options = { document: { createElement: () => canvas } }
  91. const restored = () => {
  92. assert.equal(grid.visible, true); assert.equal(path.visible, true)
  93. assert.equal(material.emissive.value, 'selected'); assert.equal(material.emissiveIntensity, 0.55)
  94. assert.equal(object.scale.value, 1.04)
  95. }
  96. return { editor, options, canvas, events, restored }
  97. }
  98. test('capture omits training selection glow, scale and helpers, preserving the current camera', async () => {
  99. const f = captureFixture()
  100. const camera = f.editor.camera
  101. const image = await captureTrainingCover(f.editor, f.options)
  102. const copied = f.events.find(event => event.action === 'copy')
  103. assert.deepEqual(copied, { action: 'copy', grid: false, path: false, color: 'original', intensity: 0, scale: 1 })
  104. f.restored()
  105. assert.equal(f.editor.camera, camera)
  106. assert.equal(f.canvas.width, 640); assert.equal(f.canvas.height, 360)
  107. assert.equal(image.type, 'image/jpeg')
  108. })
  109. test('capture failures restore all transient training presentation state', async () => {
  110. const f = captureFixture({ drawError: new Error('Canvas blocked') })
  111. await assert.rejects(captureTrainingCover(f.editor, f.options), /Canvas blocked/)
  112. f.restored()
  113. })
  114. test('loading, failed and disposed training viewports cannot overwrite the cover with a placeholder', async () => {
  115. for (const state of ['loading', 'error']) {
  116. const f = captureFixture(); f.editor.container = { dataset: { state } }
  117. await assert.rejects(captureTrainingCover(f.editor, f.options), /尚未加载完成/)
  118. assert.equal(f.events.length, 0)
  119. }
  120. const f = captureFixture(); f.editor.disposed = true
  121. await assert.rejects(captureTrainingCover(f.editor, f.options), /尚未加载完成/)
  122. const hidden = captureFixture(); hidden.editor.renderer.domElement.width = 2
  123. await assert.rejects(captureTrainingCover(hidden.editor, hidden.options), /尚未加载完成/)
  124. })
  125. // Exercise the actual vendored methods with a minimal host, avoiding a mock reimplementation.
  126. const editorSource = await readFile(new URL('guide-legacy/demo-src/training-editor.js', features), 'utf8')
  127. const saveMethod = editorSource.slice(editorSource.indexOf(' async saveDraft('), editorSource.indexOf(' showValidation()'))
  128. const publishMethod = editorSource.slice(editorSource.indexOf(' async publishProject('), editorSource.indexOf(' async runProject()'))
  129. const bindMethod = editorSource.slice(editorSource.indexOf(' async bindPublishedScene('), editorSource.indexOf(' async previewAudio('))
  130. const EditorMethods = new Function('saveTrainingProject', 'clone', 'publishTrainingProject', 'ENABLE_TRAINING_TOOLS', `return class { ${saveMethod} ${publishMethod} ${bindMethod} }`)(
  131. (_storage, project, options) => ({ ...project, status: options.status }), structuredClone,
  132. () => { throw new Error('Host-backed editor must not publish locally first') },
  133. false,
  134. )
  135. function editorFixture() {
  136. let resolve
  137. const pending = new Promise(done => { resolve = done })
  138. const editor = new EditorMethods()
  139. Object.assign(editor, {
  140. can: () => true, readOnly: false,
  141. project: { id: '161', status: 'draft', scenario: {}, steps: [] }, storage: {}, editRevision: 1,
  142. persisting: false, dirty: true, serverPending: true, root: { querySelector: () => null, querySelectorAll: () => [] },
  143. onPersist: () => pending, assetStore: { validateReferences: async () => ({ valid: true }) },
  144. })
  145. for (const name of ['renderSaveState', 'renderProjects', 'renderProjectHeader', 'rememberSavedBaseline', 'replaceCurrentHistoryState', 'renderAll', 'toast', 'showValidation']) editor[name] = () => {}
  146. return { editor, resolve }
  147. }
  148. test('training without update permission cannot manually save or autosave its recovery buffer', async () => {
  149. const { editor } = editorFixture()
  150. editor.readOnly = true
  151. editor.can = () => false
  152. editor.onPersist = () => { throw new Error('no persistence permitted') }
  153. assert.equal(await editor.saveDraft({ notify: true }), null)
  154. assert.equal(await editor.saveDraft(), null)
  155. assert.equal(await editor.publishProject(), null)
  156. })
  157. test('publisher-only training sends one publish intent without changing or saving local draft', async () => {
  158. const { editor } = editorFixture()
  159. editor.readOnly = true
  160. editor.can = permission => permission === 'content.training.publish'
  161. let calls = 0
  162. editor.assetStore.validateReferences = () => { throw new Error('must use saved server version') }
  163. editor.onPersist = async payload => { calls += 1; assert.equal(payload.reason, 'publish'); return { status: 'published' } }
  164. assert.ok(await editor.publishProject())
  165. assert.equal(calls, 1)
  166. assert.equal(editor.project.status, 'published')
  167. })
  168. test('update-only training never invokes host persistence for a denied publish action', async () => {
  169. const { editor } = editorFixture()
  170. editor.can = permission => permission === 'content.training.update'
  171. editor.onPersist = () => { throw new Error('no publication permitted') }
  172. assert.equal(await editor.publishProject(), null)
  173. })
  174. test('manual save remains pending until server acknowledgment and blocks duplicate writes', async () => {
  175. const f = editorFixture()
  176. const pending = f.editor.saveDraft({ notify: true })
  177. assert.equal(f.editor.persisting, true)
  178. assert.equal(await f.editor.saveDraft({ notify: true }), null)
  179. f.resolve({ status: 'draft' })
  180. assert.ok(await pending)
  181. assert.equal(f.editor.serverPending, false)
  182. assert.equal(f.editor.persisting, false)
  183. })
  184. test('failed host save stays dirty and never reports a successful local result', async () => {
  185. const f = editorFixture()
  186. const pending = f.editor.saveDraft({ notify: true })
  187. f.resolve(false)
  188. assert.equal(await pending, null)
  189. assert.equal(f.editor.serverPending, true)
  190. assert.equal(f.editor.dirty, true)
  191. })
  192. test('new edits during a server save remain pending after the older snapshot succeeds', async () => {
  193. const f = editorFixture()
  194. const pending = f.editor.saveDraft({ notify: true })
  195. f.editor.editRevision += 1
  196. f.editor.dirty = true
  197. f.resolve({ status: 'draft' })
  198. await pending
  199. assert.equal(f.editor.serverPending, true)
  200. assert.equal(f.editor.dirty, true)
  201. })
  202. test('publishing keeps the editor in draft until the server confirms publication', async () => {
  203. const f = editorFixture()
  204. const pending = f.editor.publishProject()
  205. await Promise.resolve()
  206. assert.equal(f.editor.project.status, 'draft')
  207. assert.equal(f.editor.persisting, true)
  208. f.resolve({ status: 'published' })
  209. assert.ok(await pending)
  210. assert.equal(f.editor.project.status, 'published')
  211. assert.equal(f.editor.serverPending, false)
  212. })
  213. test('server-rejected publication leaves draft status and pending changes intact', async () => {
  214. const f = editorFixture()
  215. const pending = f.editor.publishProject()
  216. f.resolve(false)
  217. assert.equal(await pending, null)
  218. assert.equal(f.editor.project.status, 'draft')
  219. assert.equal(f.editor.serverPending, true)
  220. })
  221. test('failed scene binding cannot persist a half-bound scene through save or publish', async () => {
  222. const { editor } = editorFixture()
  223. editor.project.scenario = { publishedSceneId: 'old@1', environment: {} }
  224. editor.dirty = false
  225. editor.serverPending = false
  226. editor.clearEnvironmentPreview = () => {}
  227. let writes = 0
  228. editor.onPersist = async () => { writes += 1; return { status: 'draft' } }
  229. editor.loadServerScene = async () => ({ id: 'new@2', name: 'New scene', sourceProjectId: '2', sourceVersionId: '2', objects: [] })
  230. let finishRender
  231. const loading = new Promise(resolve => { finishRender = resolve })
  232. let renders = 0
  233. editor.renderScene = () => ++renders === 1 ? loading : Promise.resolve({})
  234. const binding = editor.bindPublishedScene('new@2')
  235. await Promise.resolve(); await Promise.resolve(); await Promise.resolve()
  236. assert.equal(editor.bindingScene, true)
  237. assert.equal(editor.project.scenario.publishedSceneId, 'new@2')
  238. assert.equal(await editor.saveDraft({ notify: true }), null)
  239. assert.equal(await editor.publishProject(), null)
  240. assert.equal(writes, 0)
  241. finishRender({ error: new Error('Model loader failed') })
  242. await binding
  243. assert.equal(editor.bindingScene, false)
  244. assert.equal(editor.project.scenario.publishedSceneId, 'old@1')
  245. assert.equal(writes, 0)
  246. assert.equal(editor.dirty, false)
  247. assert.equal(editor.serverPending, false)
  248. })
  249. test('scene binding cannot replace the document while an existing save is in flight', async () => {
  250. const { editor } = editorFixture()
  251. editor.persisting = true
  252. let loads = 0
  253. editor.applyPublishedScene = async () => { loads += 1 }
  254. await editor.bindPublishedScene('new@2')
  255. assert.equal(loads, 0)
  256. })