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' import { slideDesignFixture } from './fixtures/aiPptSlideDesign.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() dialog.loadOutline(clone(state.result.outline)) dialog.confirmed = clone(state.result.outline) await dialog.startGeneration() assert(state.calls.find(call => call.kind === 'generate').payload.visualPrompt.includes('文字层级'), '新课件生成请求必须携带实际模板设计要求') ;({ 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.notEqual(state.slides.slides[0].id, 'old', '显式打开草稿不再被排版校验拦住') assert(state.emitted[0][0].some(issue => issue.code === 'rendered_text_overlap'), '原排版问题继续保留在编辑器') assert(state.emitted[0][0].some(issue => issue.code === 'draft_layout_recovered' && issue.pageNo === 1)) ;({ 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) const callsBeforeRecovery = state.calls.length await dialog.openCompletionDraft() assert.equal(state.slides.slides.length, 6, '超容量页可作为原生可编辑草稿打开,页序不变') assert.equal(state.calls.length, callsBeforeRecovery, '恢复草稿不能重新调用模型') const recoveredTexts = state.slides.slides[2].elements.filter(e => e.type === 'text').map(e => e.content.replace(/<[^>]*>/g, '')).join('') for (const group of state.result.outline.pages[2].studentContent.comparisonGroups) { assert(recoveredTexts.includes(group.points[0]), '超长正文不可截断或仅用错误提示替代') } ;({ state, dialog } = mount()) state.result = { outline: slideDesignFixture(), context: {}, blockingMessage: '页面设计待完善' } state.result.outline.pages[0].pageDesign = { version: 1, elements: [] } state.result.outline.pages[1].pageDesign.elements[1].height = 12 await stageDraft(dialog) const sourceBeforeRecovery = clone(state.result.outline) const modelPreview = await renderToString(h(Preview, { outline: dialog.completionSource, templateId: 'blank', message: dialog.generationError })) assert(modelPreview.includes('data-draft-canvas="1920"'), '模型设计不完整时也可显示基础预览') assert(!modelPreview.includes('排版超出页面容量'), '不能把模型设计格式错误误报为页面容量不足') await dialog.openCompletionDraft() assert.equal(state.slides.slides.length, 2) assert(state.slides.slides.every(slide => slide.elements.some(e => e.type === 'text')), '所有失败页都有可编辑内容') assert.deepEqual(state.result.outline, sourceBeforeRecovery, '草稿恢复不改写原模型设计与教学内容') assert.equal(state.calls.length, 0) assert.equal(state.snapshots.length, 2) ;({ 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('不符')) ;({ state, dialog } = mount()) state.result = { outline: slideDesignFixture(), context: {}, blockingMessage: '' } dialog.loadOutline(clone(state.result.outline)) dialog.task = { taskId: 'model-designed', status: 'DONE' } state.renderedIssues = [{ code: 'rendered_text_overflow', pageNo: 2, severity: 'error', message: '正文实际换行超过预留高度' }] state.request = async function(kind, payload) { this.calls.push({ kind, payload: clone(payload) }) return { code: 200, data: kind === 'generate' ? { taskId: 'design-repaired', status: 'PENDING' } : kind === 'task' ? { taskId: 'design-repaired', status: 'DONE' } : clone(this.result) } } await dialog.enterEditor(clone(state.result), () => true) const repairs = state.calls.filter(call => call.kind === 'generate') assert.equal(repairs.length, 2, '同一渲染问题最多自动修复两轮') assert(repairs.every(call => call.payload.designOnly === true && call.payload.repairOnly === true)) assert.deepEqual(repairs[0].payload.renderFeedback, [{ pageNo: 2, code: 'rendered_text_overflow', message: '正文实际换行超过预留高度' }]) assert.deepEqual(repairs[0].payload.outline.pages.map(page => page.studentContent), state.result.outline.pages.map(page => page.studentContent)) assert.equal(state.emitted.length, 0, '仍未通过实际渲染检查时不得应用到课件') assert(dialog.completionSource && dialog.generationError, '失败时保留全部页面和错误原因') ;({ state, dialog } = mount()) state.result = { outline: slideDesignFixture(), context: {}, blockingMessage: '第2页视觉设计尚未完成', renderFeedback: [{ pageNo: 2, code: 'rendered_text_overflow', message: '正文实际换行超过预留高度' }] } await stageDraft(dialog) assert(dialog.layoutRepairSource && dialog.modelLayoutRepair, '服务端设计修复失败后继续保留设计修复入口') await dialog.retry() const repeated = state.calls.find(call => call.kind === 'generate') assert.equal(repeated.payload.designOnly, true) assert.deepEqual(repeated.payload.renderFeedback, state.result.renderFeedback, '不能丢失失败页的浏览器诊断而重复普通内容生成') ;({ state, dialog } = mount()) state.result = { outline: editorialFixture(), context: {}, blockingMessage: '' } state.renderedIssues = [{ code: 'rendered_text_overlap', pageNo: 1, severity: 'error', message: '浏览器实际正文重叠' }] dialog.loadOutline(clone(state.result.outline)) dialog.task = { taskId: 'completed-but-unrenderable', status: 'DONE' } await assert.rejects(() => dialog.enterEditor(clone(state.result), () => true)) assert(dialog.completionSource, '后端DONE但前端渲染失败时,完整结果仍必须可预览和打开草稿') assert.equal(state.slides.slides[0].id, 'old', '不能把渲染失败的课件当成成功应用') await dialog.openCompletionDraft() assert.equal(state.slides.slides.length, 6, '无需重新生成即可恢复已生成的正文') const { default: usePageRepair } = await vite.ssrLoadModule('/src/hooks/useAiCoursewarePageRepair.ts') const { aiCoursewareToSlides } = await vite.ssrLoadModule('/src/utils/aiCoursewareToSlides.ts') ;({ state } = mount()) const source = slideDesignFixture() source.pages[0].pageDesign = { version: 1, elements: [] } state.slides.setSlides(aiCoursewareToSlides(source, { recoverDraft: true }).slides) const firstBefore = JSON.stringify(state.slides.slides[0]) const updated = clone(source) updated.pages[1].studentContent.points[0] = '认真聆听,再表达声音的特点' state.result = { outline: updated, context: {}, blockingMessage: '' } state.request = async function(kind, payload) { this.calls.push({ kind, payload: clone(payload) }) return { code: 200, data: kind === 'generate' ? { taskId: 'single-page', status: 'PENDING' } : kind === 'task' ? { taskId: 'single-page', status: 'DONE' } : clone(this.result) } } const repairApp = renderer.createApp({ setup: () => usePageRepair(), render: () => null }) repairApp.mount({}) try { const repair = repairApp._instance.setupState repair.generated([], source, { minPages: 2, maxPages: 2, enableAnimations: false }) await repair.repair(2) assert.equal(repair.error, '', '生成本页不能被另一页的坏版式阻断') assert.equal(JSON.stringify(state.slides.slides[0]), firstBefore, '另一页草稿不被重排或替换') assert(state.slides.slides[1].elements.some(e => e.type === 'text' && e.content.includes('认真聆听,再表达声音的特点'))) const targetBefore = JSON.stringify(state.slides.slides[1]) state.result.outline.pages[1].pageDesign.elements[1].height = 12 await repair.repair(2) assert(repair.error, '目标页自身版式无效时,仍必须拒绝替换') assert.equal(JSON.stringify(state.slides.slides[1]), targetBefore) } finally { repairApp.unmount() } console.log('PASS: DONE后渲染失败仍保留完整草稿;单页生成不受其他坏版式阻断,目标页自身无效时不替换;模型修复两轮失败保留草稿') } finally { app?.unmount() await vite.close() delete globalThis[testKey] }