|
|
@@ -0,0 +1,171 @@
|
|
|
+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'
|
|
|
+
|
|
|
+// 测试实际对话框的续作/草稿状态流程。浏览器排版和媒体探测以明确结果模拟,
|
|
|
+// 此脚本不代表浏览器视觉验收,不访问生成服务或线上课件。
|
|
|
+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.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]
|
|
|
+}
|