aiPptDraftSelfTest.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. import assert from 'node:assert/strict'
  2. import { createServer } from 'vite'
  3. import { createRenderer, reactive, nextTick, h, ssrContextKey } from 'vue'
  4. import { renderToString } from '@vue/server-renderer'
  5. import { editorialFixture } from './fixtures/aiPptEditorial.mjs'
  6. import { comparisonCapacityFixture } from './fixtures/aiPptComparisonCapacity.mjs'
  7. // 测试实际对话框的续作/草稿状态流程。浏览器排版和媒体探测以明确结果模拟,
  8. // 此脚本不代表浏览器视觉验收,不访问生成服务或线上课件。
  9. const clone = value => JSON.parse(JSON.stringify(value))
  10. const testKey = '__aiDraftRegression'
  11. const stubs = {
  12. '/src/store': `export const useSlidesStore=()=>globalThis.${testKey}.slides;
  13. export const useMainStore=()=>({setActiveElementIdList(){}});
  14. export const useSnapshotStore=()=>({async addSnapshot(){globalThis.${testKey}.snapshots.push(JSON.stringify(globalThis.${testKey}.slides.slides))}});`,
  15. '/src/store/pptWork': `export default ()=>globalThis.${testKey}.work;`,
  16. '/src/queryParams': 'export default {fromType:"TEACHER"};',
  17. '/src/plugins/httpAjax': `export const httpAjax=(...args)=>globalThis.${testKey}.request(...args);`,
  18. '/src/api/aiCourseware': `export const aiCoursewareGenerateApi='generate',aiCoursewareOutlineTaskApi='outline',aiCoursewareResultApi='result',aiCoursewareTaskApi='task';`,
  19. '/src/utils/aiCoursewareDraft': `export const draftKey=async()=> 'draft';
  20. export const loadAiDraft=async()=>globalThis.${testKey}.saved;
  21. export const saveAiDraft=async value=>{globalThis.${testKey}.saved=JSON.parse(JSON.stringify(value))};`,
  22. '/src/utils/aiPptRenderedQuality': `export const inspectAiPptRenderedText=async()=>globalThis.${testKey}.renderedIssues;`,
  23. '/src/utils/aiPptResources': `export const inspectAiPptResources=async()=>globalThis.${testKey}.resourceIssues;`,
  24. '/src/utils/aiPptClientVersion': 'export const assertAiPptClientCurrent=async()=>{};',
  25. '/src/components/Modal.vue': 'export default {};',
  26. '/src/components/Button.vue': 'export default {};',
  27. '/src/views/components/ThumbnailSlide/index.vue': `import { h } from 'vue'; export default {
  28. props:['slide','size','viewportSize','viewportRatio'], render(){return h('div',{'data-draft-canvas':this.viewportSize,'data-ratio':this.viewportRatio,'data-elements':this.slide.elements.length})}};`,
  29. 'element-plus': `export const ElMessage={warning:text=>globalThis.${testKey}.messages.push(text),success:text=>globalThis.${testKey}.messages.push(text)};
  30. export const ElMessageBox={confirm:async()=>{if(!globalThis.${testKey}.confirm) throw new Error('cancel')}};`,
  31. 'vuedraggable': 'export default {};'
  32. }
  33. const stubKeys = Object.keys(stubs)
  34. const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom', logLevel: 'error',
  35. optimizeDeps: { noDiscovery: true, entries: [] }, ssr: { noExternal: ['element-plus', 'vuedraggable'] }, plugins: [{
  36. name: 'draft-flow-test-stubs', enforce: 'pre',
  37. resolveId(id) {
  38. const normalized = id.replaceAll('\\', '/').replace(/^@\//, '/src/')
  39. const index = stubKeys.findIndex(key => normalized === key || key.startsWith('/src/') && (normalized.endsWith(key) || normalized.endsWith(`${key}.ts`)))
  40. if (index >= 0) return `\0draft-test:${index}.js`
  41. },
  42. load(id) { if (id.startsWith('\0draft-test:')) return stubs[stubKeys[parseInt(id.slice('\0draft-test:'.length))]] }
  43. }] })
  44. const renderer = createRenderer({
  45. createElement: () => ({}), createText: () => ({}), createComment: () => ({}),
  46. insert() {}, remove() {}, setText() {}, setElementText() {}, patchProp() {},
  47. parentNode: () => null, nextSibling: () => null
  48. })
  49. const result = () => {
  50. const outline = editorialFixture()
  51. const page = outline.pages[1]
  52. page.type = 'scene_intro'
  53. page.requiredVisual = true
  54. page.imageStatus = 'generation_failed'
  55. page.imageReview = '未找到符合本页教学要求的图片,请补充或替换图片'
  56. page.visualPlan = { composition: 'image_right', visualMode: 'generated_image', imageNecessity: 'essential', imagePurpose: 'scene', mainVisual: '口风琴课堂练习' }
  57. return { outline, context: {}, blockingMessage: '第2页配图尚未完成', reviewMessage: '第3页:请补充对比音频' }
  58. }
  59. let app
  60. try {
  61. const { default: Dialog } = await vite.ssrLoadModule('/src/views/Editor/EditorHeader/AiCoursewareDialog.vue')
  62. const { default: Preview } = await vite.ssrLoadModule('/src/views/Editor/EditorHeader/AiCoursewareDraftPreview.vue')
  63. const mount = () => {
  64. app?.unmount()
  65. const state = globalThis[testKey] = {
  66. slides: reactive({ title: '原课件', slides: [{ id: 'old', elements: [] }], theme: {}, viewportSize: 1000, viewportRatio: .75, slideIndex: 0,
  67. setTitle(v){this.title=v},setSlides(v){this.slides=v},setTheme(v){this.theme=v},setViewportSize(v){this.viewportSize=v},setViewportRatio(v){this.viewportRatio=v},updateSlideIndex(v){this.slideIndex=v} }),
  68. work: reactive({ id: 'local-test', isSave: true }), snapshots: [], messages: [], calls: [], emitted: [], confirm: true,
  69. renderedIssues: [], resourceIssues: [], saved: undefined, result: result(),
  70. async request(kind, payload) {
  71. this.calls.push({ kind, payload: clone(payload) })
  72. return { code: 200, data: kind === 'generate' ? { taskId: 'continued', status: 'PENDING' }
  73. : kind === 'task' ? { taskId: 'continued', status: 'DRAFT', message: '第2页配图尚未完成' } : clone(this.result) }
  74. }
  75. }
  76. app = renderer.createApp({ ...Dialog, render: () => null }, { visible: true,
  77. onGenerated: (...args) => state.emitted.push(args), 'onUpdate:visible': () => {} })
  78. app.provide(ssrContextKey, {})
  79. app.mount({})
  80. return { state, dialog: app._instance.setupState }
  81. }
  82. const stageDraft = async dialog => {
  83. dialog.loadOutline(clone(globalThis[testKey].result.outline))
  84. dialog.task = { taskId: 'draft-result', status: 'DRAFT', message: '第2页配图尚未完成' }
  85. await dialog.enterEditor(clone(globalThis[testKey].result), () => true)
  86. await nextTick()
  87. }
  88. let { state, dialog } = mount()
  89. await stageDraft(dialog)
  90. assert(dialog.completionSource && dialog.showCompletionContent, '未完成任务自动展示预览')
  91. const before = JSON.stringify(state.slides)
  92. const input = clone(dialog.completionSource)
  93. const html = await renderToString(h(Preview, { outline: input, templateId: 'blank', message: dialog.generationError }))
  94. assert(html.includes('data-draft-canvas="1920"') && html.includes('data-ratio="0.5625"'), '草稿有独立的16:9画布')
  95. assert(html.includes('口风琴吹奏与表现') && html.includes('aria-label="配图待完善"'), '预览显示课题和缺图标记')
  96. assert.equal(JSON.stringify(state.slides), before, '预览不改原课件和画布尺寸')
  97. assert.deepEqual(input, clone(dialog.completionSource), '预览不修改生成结果')
  98. assert.equal(state.calls.length, 0, '预览不调用生成服务')
  99. await dialog.retry()
  100. await nextTick()
  101. assert(dialog.completionSource && dialog.showCompletionContent, '重试仍失败后保留逐页预览')
  102. assert.equal(state.calls.filter(c => c.kind === 'generate').length, 1)
  103. assert.equal(state.calls[0].payload.outline.pages.length, 6)
  104. const callsBeforeOpening = state.calls.length
  105. await dialog.openCompletionDraft()
  106. assert.equal(state.slides.slides.length, 6, '缺图不阻止显式打开草稿')
  107. assert.equal(state.slides.viewportSize, 1920)
  108. assert.equal(state.slides.viewportRatio, .5625)
  109. assert.equal(state.work.isSave, false)
  110. assert.equal(state.calls.length, callsBeforeOpening, '打开草稿无需重新生成')
  111. assert.equal(state.snapshots.length, 2, '替换前后均保留可撤销快照')
  112. assert.equal(JSON.parse(state.snapshots[0])[0].id, 'old')
  113. assert(state.emitted[0][0].some(issue => issue.code === 'missing_required_visual' && issue.pageNo === 2 && issue.severity === 'error'))
  114. assert(state.emitted[0][0].some(issue => issue.pageNo === 3 && issue.message.includes('音频')), '后端审核建议保留')
  115. assert(state.messages.some(message => message.startsWith('草稿已打开')))
  116. ;({ state, dialog } = mount())
  117. await stageDraft(dialog)
  118. state.renderedIssues = [{ code: 'rendered_text_overlap', pageNo: 1, severity: 'error', message: '正文重叠' }]
  119. await dialog.openCompletionDraft()
  120. assert.equal(state.slides.slides[0].id, 'old', '打开草稿仍保留排版检查')
  121. assert(dialog.completionSource && dialog.generationError.includes('文字过密'), '排版失败仍保留草稿供预览')
  122. assert.equal(state.emitted.length, 0)
  123. ;({ state, dialog } = mount())
  124. state.result.outline = comparisonCapacityFixture(true)
  125. await stageDraft(dialog)
  126. const isolatedHtml = await renderToString(h(Preview, { outline:dialog.completionSource,templateId:'blank',message:dialog.generationError,
  127. onLayoutErrors:dialog.recordCompletionLayoutErrors }))
  128. assert(isolatedHtml.includes('data-draft-canvas="1920"'), '别页排版失败不应让正常首页失去预览')
  129. assert(dialog.layoutRepairFeedback.includes('第3页'), '预览向续作流程提供具体失败页')
  130. await dialog.retry()
  131. assert(state.calls.find(call => call.kind === 'generate').payload.visualPrompt.includes('第3页'), '续作必须发送排版问题,不得只重复配图')
  132. assert.equal(state.calls.find(call => call.kind === 'generate').payload.repairOnly, true)
  133. ;({ state, dialog } = mount())
  134. state.saved = { outline: clone(state.result.outline), preferences: { minPages: 6, maxPages: 6 },
  135. completionSource: clone(state.result.outline), completionMessage: state.result.blockingMessage,
  136. reviewMessage: state.result.reviewMessage, taskId: 'expired', extraPrompt: '' }
  137. state.request = async () => ({ code: 404, message: '任务已过期' })
  138. await dialog.restore()
  139. await nextTick()
  140. assert(dialog.completionSource && dialog.showCompletionContent, '过期任务可从本地恢复预览')
  141. assert.equal(dialog.task, undefined)
  142. await dialog.openCompletionDraft()
  143. assert.equal(state.slides.slides.length, 6, '无需有效任务ID即可打开保存的草稿')
  144. assert(state.emitted[0][0].some(issue => issue.pageNo === 3 && issue.message.includes('音频')), '恢复后仍保留审核建议')
  145. ;({ state, dialog } = mount())
  146. state.result.outline.pages[1].image = { url: 'https://example.com/failed.png', width: 800, height: 600 }
  147. state.result.outline.pages[1].imageStatus = 'ready'
  148. await stageDraft(dialog)
  149. state.resourceIssues = [{ code: 'resource_unavailable', pageNo: 2, severity: 'error', message: '图片加载失败' }]
  150. await dialog.openCompletionDraft()
  151. assert.equal(state.slides.slides.length, 6)
  152. assert(state.emitted[0][0].some(issue => issue.code === 'resource_unavailable'), '资源失败仍显示在草稿问题中')
  153. assert.equal(state.emitted[0][1].pages[1].image, undefined, '后续完善不能复用已经加载失败的图片')
  154. assert.equal(state.emitted[0][1].pages[1].imageStatus, 'generation_failed')
  155. ;({ state, dialog } = mount())
  156. await stageDraft(dialog)
  157. state.slides.slides[0].elements.push({ id: 'manual', type: 'text', content: '老师的新修改' })
  158. state.confirm = false
  159. await dialog.openCompletionDraft()
  160. assert.equal(state.slides.slides[0].elements[0].content, '老师的新修改', '取消替换保留新修改')
  161. assert(dialog.completionSource, '取消替换保留生成草稿')
  162. assert.equal(state.snapshots.length, 0)
  163. ;({ state, dialog } = mount())
  164. await stageDraft(dialog)
  165. dialog.preferences.lessonTopic = { gradeName: '一年级', textbookName: '另一教材', unitName: '', chapterName: '另一课题' }
  166. await dialog.openCompletionDraft()
  167. assert.equal(state.slides.slides[0].id, 'old', '显式打开也不能混用课题')
  168. assert(dialog.generationError.includes('不符'))
  169. console.log('PASS: 草稿自动预览、独立画布、重复缺图续作、打开编辑、问题保留、快照、排版阻断、取消替换和课题校验')
  170. } finally {
  171. app?.unmount()
  172. await vite.close()
  173. delete globalThis[testKey]
  174. }