| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183 |
- import assert from 'node:assert/strict'
- import { createServer } from 'vite'
- import { createRenderer, reactive, nextTick, h, ssrContextKey } from 'vue'
- import { renderToString } from '@vue/server-renderer'
- import { editorialFixture } from './fixtures/aiPptEditorial.mjs'
- import { comparisonCapacityFixture } from './fixtures/aiPptComparisonCapacity.mjs'
- // 测试实际对话框的续作/草稿状态流程。浏览器排版和媒体探测以明确结果模拟,
- // 此脚本不代表浏览器视觉验收,不访问生成服务或线上课件。
- const clone = value => JSON.parse(JSON.stringify(value))
- const testKey = '__aiDraftRegression'
- const stubs = {
- '/src/store': `export const useSlidesStore=()=>globalThis.${testKey}.slides;
- export const useMainStore=()=>({setActiveElementIdList(){}});
- export const useSnapshotStore=()=>({async addSnapshot(){globalThis.${testKey}.snapshots.push(JSON.stringify(globalThis.${testKey}.slides.slides))}});`,
- '/src/store/pptWork': `export default ()=>globalThis.${testKey}.work;`,
- '/src/queryParams': 'export default {fromType:"TEACHER"};',
- '/src/plugins/httpAjax': `export const httpAjax=(...args)=>globalThis.${testKey}.request(...args);`,
- '/src/api/aiCourseware': `export const aiCoursewareGenerateApi='generate',aiCoursewareOutlineTaskApi='outline',aiCoursewareResultApi='result',aiCoursewareTaskApi='task';`,
- '/src/utils/aiCoursewareDraft': `export const draftKey=async()=> 'draft';
- export const loadAiDraft=async()=>globalThis.${testKey}.saved;
- export const saveAiDraft=async value=>{globalThis.${testKey}.saved=JSON.parse(JSON.stringify(value))};`,
- '/src/utils/aiPptRenderedQuality': `export const inspectAiPptRenderedText=async()=>globalThis.${testKey}.renderedIssues;`,
- '/src/utils/aiPptResources': `export const inspectAiPptResources=async()=>globalThis.${testKey}.resourceIssues;`,
- '/src/utils/aiPptClientVersion': 'export const assertAiPptClientCurrent=async()=>{};',
- '/src/components/Modal.vue': 'export default {};',
- '/src/components/Button.vue': 'export default {};',
- '/src/views/components/ThumbnailSlide/index.vue': `import { h } from 'vue'; export default {
- props:['slide','size','viewportSize','viewportRatio'], render(){return h('div',{'data-draft-canvas':this.viewportSize,'data-ratio':this.viewportRatio,'data-elements':this.slide.elements.length})}};`,
- 'element-plus': `export const ElMessage={warning:text=>globalThis.${testKey}.messages.push(text),success:text=>globalThis.${testKey}.messages.push(text)};
- export const ElMessageBox={confirm:async()=>{if(!globalThis.${testKey}.confirm) throw new Error('cancel')}};`,
- 'vuedraggable': 'export default {};'
- }
- const stubKeys = Object.keys(stubs)
- const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom', logLevel: 'error',
- optimizeDeps: { noDiscovery: true, entries: [] }, ssr: { noExternal: ['element-plus', 'vuedraggable'] }, plugins: [{
- name: 'draft-flow-test-stubs', enforce: 'pre',
- resolveId(id) {
- const normalized = id.replaceAll('\\', '/').replace(/^@\//, '/src/')
- const index = stubKeys.findIndex(key => normalized === key || key.startsWith('/src/') && (normalized.endsWith(key) || normalized.endsWith(`${key}.ts`)))
- if (index >= 0) return `\0draft-test:${index}.js`
- },
- load(id) { if (id.startsWith('\0draft-test:')) return stubs[stubKeys[parseInt(id.slice('\0draft-test:'.length))]] }
- }] })
- const renderer = createRenderer({
- createElement: () => ({}), createText: () => ({}), createComment: () => ({}),
- insert() {}, remove() {}, setText() {}, setElementText() {}, patchProp() {},
- parentNode: () => null, nextSibling: () => null
- })
- const result = () => {
- const outline = editorialFixture()
- const page = outline.pages[1]
- page.type = 'scene_intro'
- page.requiredVisual = true
- page.imageStatus = 'generation_failed'
- page.imageReview = '未找到符合本页教学要求的图片,请补充或替换图片'
- page.visualPlan = { composition: 'image_right', visualMode: 'generated_image', imageNecessity: 'essential', imagePurpose: 'scene', mainVisual: '口风琴课堂练习' }
- return { outline, context: {}, blockingMessage: '第2页配图尚未完成', reviewMessage: '第3页:请补充对比音频' }
- }
- let app
- try {
- const { default: Dialog } = await vite.ssrLoadModule('/src/views/Editor/EditorHeader/AiCoursewareDialog.vue')
- const { default: Preview } = await vite.ssrLoadModule('/src/views/Editor/EditorHeader/AiCoursewareDraftPreview.vue')
- const mount = () => {
- app?.unmount()
- const state = globalThis[testKey] = {
- slides: reactive({ title: '原课件', slides: [{ id: 'old', elements: [] }], theme: {}, viewportSize: 1000, viewportRatio: .75, slideIndex: 0,
- 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} }),
- work: reactive({ id: 'local-test', isSave: true }), snapshots: [], messages: [], calls: [], emitted: [], confirm: true,
- renderedIssues: [], resourceIssues: [], saved: undefined, result: result(),
- async request(kind, payload) {
- this.calls.push({ kind, payload: clone(payload) })
- return { code: 200, data: kind === 'generate' ? { taskId: 'continued', status: 'PENDING' }
- : kind === 'task' ? { taskId: 'continued', status: 'DRAFT', message: '第2页配图尚未完成' } : clone(this.result) }
- }
- }
- app = renderer.createApp({ ...Dialog, render: () => null }, { visible: true,
- onGenerated: (...args) => state.emitted.push(args), 'onUpdate:visible': () => {} })
- app.provide(ssrContextKey, {})
- app.mount({})
- return { state, dialog: app._instance.setupState }
- }
- const stageDraft = async dialog => {
- dialog.loadOutline(clone(globalThis[testKey].result.outline))
- dialog.task = { taskId: 'draft-result', status: 'DRAFT', message: '第2页配图尚未完成' }
- await dialog.enterEditor(clone(globalThis[testKey].result), () => true)
- await nextTick()
- }
- let { state, dialog } = mount()
- await stageDraft(dialog)
- assert(dialog.completionSource && dialog.showCompletionContent, '未完成任务自动展示预览')
- const before = JSON.stringify(state.slides)
- const input = clone(dialog.completionSource)
- const html = await renderToString(h(Preview, { outline: input, templateId: 'blank', message: dialog.generationError }))
- assert(html.includes('data-draft-canvas="1920"') && html.includes('data-ratio="0.5625"'), '草稿有独立的16:9画布')
- assert(html.includes('口风琴吹奏与表现') && html.includes('aria-label="配图待完善"'), '预览显示课题和缺图标记')
- assert.equal(JSON.stringify(state.slides), before, '预览不改原课件和画布尺寸')
- assert.deepEqual(input, clone(dialog.completionSource), '预览不修改生成结果')
- assert.equal(state.calls.length, 0, '预览不调用生成服务')
- await dialog.retry()
- await nextTick()
- assert(dialog.completionSource && dialog.showCompletionContent, '重试仍失败后保留逐页预览')
- assert.equal(state.calls.filter(c => c.kind === 'generate').length, 1)
- assert.equal(state.calls[0].payload.outline.pages.length, 6)
- const callsBeforeOpening = state.calls.length
- await dialog.openCompletionDraft()
- assert.equal(state.slides.slides.length, 6, '缺图不阻止显式打开草稿')
- assert.equal(state.slides.viewportSize, 1920)
- assert.equal(state.slides.viewportRatio, .5625)
- assert.equal(state.work.isSave, false)
- assert.equal(state.calls.length, callsBeforeOpening, '打开草稿无需重新生成')
- assert.equal(state.snapshots.length, 2, '替换前后均保留可撤销快照')
- assert.equal(JSON.parse(state.snapshots[0])[0].id, 'old')
- assert(state.emitted[0][0].some(issue => issue.code === 'missing_required_visual' && issue.pageNo === 2 && issue.severity === 'error'))
- assert(state.emitted[0][0].some(issue => issue.pageNo === 3 && issue.message.includes('音频')), '后端审核建议保留')
- assert(state.messages.some(message => message.startsWith('草稿已打开')))
- ;({ state, dialog } = mount())
- await stageDraft(dialog)
- state.renderedIssues = [{ code: 'rendered_text_overlap', pageNo: 1, severity: 'error', message: '正文重叠' }]
- await dialog.openCompletionDraft()
- assert.equal(state.slides.slides[0].id, 'old', '打开草稿仍保留排版检查')
- assert(dialog.completionSource && dialog.generationError.includes('文字过密'), '排版失败仍保留草稿供预览')
- assert.equal(state.emitted.length, 0)
- ;({ state, dialog } = mount())
- state.result.outline = comparisonCapacityFixture(true)
- await stageDraft(dialog)
- const isolatedHtml = await renderToString(h(Preview, { outline:dialog.completionSource,templateId:'blank',message:dialog.generationError,
- onLayoutErrors:dialog.recordCompletionLayoutErrors }))
- assert(isolatedHtml.includes('data-draft-canvas="1920"'), '别页排版失败不应让正常首页失去预览')
- assert(dialog.layoutRepairFeedback.includes('第3页'), '预览向续作流程提供具体失败页')
- await dialog.retry()
- assert(state.calls.find(call => call.kind === 'generate').payload.visualPrompt.includes('第3页'), '续作必须发送排版问题,不得只重复配图')
- assert.equal(state.calls.find(call => call.kind === 'generate').payload.repairOnly, true)
- ;({ state, dialog } = mount())
- state.saved = { outline: clone(state.result.outline), preferences: { minPages: 6, maxPages: 6 },
- completionSource: clone(state.result.outline), completionMessage: state.result.blockingMessage,
- reviewMessage: state.result.reviewMessage, taskId: 'expired', extraPrompt: '' }
- state.request = async () => ({ code: 404, message: '任务已过期' })
- await dialog.restore()
- await nextTick()
- assert(dialog.completionSource && dialog.showCompletionContent, '过期任务可从本地恢复预览')
- assert.equal(dialog.task, undefined)
- await dialog.openCompletionDraft()
- assert.equal(state.slides.slides.length, 6, '无需有效任务ID即可打开保存的草稿')
- assert(state.emitted[0][0].some(issue => issue.pageNo === 3 && issue.message.includes('音频')), '恢复后仍保留审核建议')
- ;({ state, dialog } = mount())
- state.result.outline.pages[1].image = { url: 'https://example.com/failed.png', width: 800, height: 600 }
- state.result.outline.pages[1].imageStatus = 'ready'
- await stageDraft(dialog)
- state.resourceIssues = [{ code: 'resource_unavailable', pageNo: 2, severity: 'error', message: '图片加载失败' }]
- await dialog.openCompletionDraft()
- assert.equal(state.slides.slides.length, 6)
- assert(state.emitted[0][0].some(issue => issue.code === 'resource_unavailable'), '资源失败仍显示在草稿问题中')
- assert.equal(state.emitted[0][1].pages[1].image, undefined, '后续完善不能复用已经加载失败的图片')
- assert.equal(state.emitted[0][1].pages[1].imageStatus, 'generation_failed')
- ;({ state, dialog } = mount())
- await stageDraft(dialog)
- state.slides.slides[0].elements.push({ id: 'manual', type: 'text', content: '老师的新修改' })
- state.confirm = false
- await dialog.openCompletionDraft()
- assert.equal(state.slides.slides[0].elements[0].content, '老师的新修改', '取消替换保留新修改')
- assert(dialog.completionSource, '取消替换保留生成草稿')
- assert.equal(state.snapshots.length, 0)
- ;({ state, dialog } = mount())
- await stageDraft(dialog)
- dialog.preferences.lessonTopic = { gradeName: '一年级', textbookName: '另一教材', unitName: '', chapterName: '另一课题' }
- await dialog.openCompletionDraft()
- assert.equal(state.slides.slides[0].id, 'old', '显式打开也不能混用课题')
- assert(dialog.generationError.includes('不符'))
- console.log('PASS: 草稿自动预览、独立画布、重复缺图续作、打开编辑、问题保留、快照、排版阻断、取消替换和课题校验')
- } finally {
- app?.unmount()
- await vite.close()
- delete globalThis[testKey]
- }
|