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] }