import { createServer } from "vite" import { existsSync } from "node:fs" import { resolve } from "node:path" const vite = await createServer({ server: { middlewareMode: true }, appType: "custom", logLevel: "error" }) try { const { aiCoursewareToSlides } = await vite.ssrLoadModule("/src/utils/aiCoursewareToSlides.ts") const { inspectAiPptQuality } = await vite.ssrLoadModule("/src/utils/aiPptQuality.ts") const { AI_COURSEWARE_TEMPLATES } = await vite.ssrLoadModule("/src/config/aiCoursewareTemplates.ts") const { getAiCoursewareTemplateMaster, getTemplateMasterPage, resolveTemplatePageLayout } = await vite.ssrLoadModule("/src/config/aiCoursewareTemplateMasters.ts") const page = (pageNo, type, title, layout, points, extra = {}) => ({ pageNo, type, title, bullets: points, studentContent: { points }, imageKeywords: [], visualPlan: { layout, visualDensity: "medium" }, ...extra }) const singingOutline = { title: "凤鸣山谷的故事", gradeName: "一年级", textbookName: "音乐一年级上册", unitName: "第一单元 奇妙的声音世界", chapterName: "凤鸣山谷的故事", courseType: "music", style: "cartoon", pages: [ page(1, "cover", "凤鸣山谷的故事", "cover_scene", ["聆听自然的声音", "发现音乐的强弱变化"], { image: { url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" } }), page(2, "scene_intro", "山谷里传来了什么声音?", "story_stage", ["观察画面", "模仿你听到的声音"], { interaction: { type: "question", question: "你觉得是谁在歌唱?", options: ["小鸟", "溪水", "风声"] } }), page(3, "objectives", "今天我们要学会", "goal_cards", ["分辨声音的高低", "感受音乐的强弱", "合作完成声音表演"]), page(4, "work_context", "走进凤鸣山谷", "split_image", ["清晨的山谷十分安静", "鸟鸣由远处慢慢靠近", "溪水一直轻轻流淌"]), page(5, "first_listen", "第一次完整聆听", "listen_wave", ["安静听完音乐", "用手势表示声音的远近", "记住最喜欢的声音"], { resourceBindings: [{ resourceId: "song-demo", resourceType: "SONG", name: "凤鸣山谷", contentUrl: "https://example.com/fengming.mp3", verified: true }] }), page(6, "lyric_read", "读一读山谷歌谣", "lyric_focus", ["小鸟唱得清脆", "溪水流得轻柔", "山风吹得悠长"]), page(7, "rhythm_practice", "跟着节奏拍一拍", "rhythm_grid", ["小鸟:短短拍", "溪水:连续拍", "山风:长长拍"], { activity: { task: "三组同学接力表现", steps: ["选择一种声音", "先慢速练习", "听指挥加入合奏"] } }), page(8, "melody_score", "旋律是怎样行走的?", "score_focus", ["向上表示声音变高", "向下表示声音变低", "平直表示保持稳定"]), page(9, "music_knowledge", "认识声音的强与弱", "knowledge_cards", ["强:有力量但不喊叫", "弱:轻柔但保持清楚", "强弱变化让音乐更生动"]), page(10, "compare_listen", "两段声音有什么不同?", "compare_listen", ["比较速度", "比较力度"], { interaction: { type: "compare", question: "哪一段更像远处的鸟鸣?", options: ["第一段更轻", "第二段更强", "第一段更慢", "第二段更快"] } }), page(11, "performance_activity", "合作表演山谷音乐会", "performance_stage", ["保持自己的声部", "关注指挥手势", "结束时一起收声"], { activity: { task: "用声音演出山谷从清晨到热闹的变化", steps: ["分成鸟鸣、溪水、山风三组", "商量进入顺序", "完整表演并互相评价"] } }), page(12, "summary", "我的音乐收获", "summary_checklist", ["我能分辨声音的高低", "我能表现声音的强弱", "我能与同伴合作表演"], { learningEvidence: "用一种声音表现你心中的凤鸣山谷" }) ] } const appreciationOutline = { ...singingOutline, title: "月光下的琵琶", chapterName: "月光下的琵琶", courseType: "appreciation", style: "clean", pages: [ page(1, "cover", "月光下的琵琶", "cover_scene", ["感受民族器乐的音色", "用语言描述音乐意境"]), page(2, "scene_intro", "月夜里传来了什么声音?", "hero_focus", ["观察演奏场景", "猜一猜正在演奏的乐器"]), page(3, "objectives", "带着三个问题走进音乐", "objectives", ["辨认琵琶音色", "感受速度与力度", "描述音乐意境"]), page(4, "first_listen", "完整聆听", "media_focus", ["安静聆听", "记录最打动你的片段", "想象音乐中的画面"]), page(5, "instrument_guide", "认识琵琶", "split_content", ["观察乐器外形", "了解基本演奏姿势", "辨认清脆明亮的音色"]), page(6, "music_knowledge", "音乐为什么有层次?", "diagram_focus", ["速度由慢到快", "力度由弱到强", "旋律推动情绪变化"]), page(7, "compare_listen", "两个片段有什么不同?", "compare", ["比较速度", "比较力度", "比较情绪"]), page(8, "creative_activity", "为音乐选择一幅画", "activity", ["小组交流选择理由", "用音乐要素支持判断", "向全班分享"]), page(9, "practice_check", "我能听出来", "summary", ["这是琵琶的音色", "这一段速度更快", "这一段力度更强"]), page(10, "summary", "我的欣赏收获", "text_focus", ["我能辨认琵琶音色", "我能描述速度和力度", "我能说出音乐带来的想象"]) ] } const rhythmOutline = { ...singingOutline, title: "节奏变变变", chapterName: "节奏变变变", courseType: "rhythm", pages: [ page(1, "cover", "节奏变变变", "cover_scene", ["发现生活中的节奏", "合作完成节奏接龙"]), page(2, "scene_intro", "哪些声音有规律?", "hero_focus", ["听脚步声", "听拍球声", "发现重复规律"]), page(3, "objectives", "今天的节奏任务", "objectives", ["稳定保持节拍", "模仿两种节奏型", "完成小组接龙"]), page(4, "rhythm_practice", "跟着节拍做动作", "diagram_focus", ["拍手", "跺脚", "拍腿"], { activity: { task: "保持稳定节拍", steps: ["看图模仿", "跟随口令", "独立完成"] } }), page(5, "compare_listen", "两种节奏一样吗?", "compare", ["比较声音长短", "比较疏密", "说出不同之处"]), page(6, "rhythm_practice", "组合新的节奏", "diagram_focus", ["选择两个节奏型", "按顺序连接", "反复练熟"], { activity: { task: "组合节奏", steps: ["选择", "连接", "练习"] } }), page(7, "creative_activity", "小组节奏接龙", "activity", ["保持共同速度", "轮到自己再进入", "结束动作要整齐"]), page(8, "practice_check", "节奏挑战", "summary", ["我能稳定拍击", "我能听出不同节奏", "我能完成接龙"]), page(9, "summary", "我的节奏收获", "text_focus", ["节拍要稳定", "节奏有长短疏密", "合作要互相倾听"]) ] } const longOutline = { ...singingOutline, pages: [ ...singingOutline.pages, page(13, "music_knowledge", "声音还能怎样变化?", "knowledge_cards", ["比较声音长短", "比较声音高低", "比较声音强弱"]), page(14, "creative_activity", "设计新的山谷回声", "activity", ["选择声音角色", "设计进入顺序", "合作完整展示"]), page(15, "summary", "把山谷音乐带回家", "summary", ["记录一种自然声音", "尝试用动作表现", "下节课与同学分享"]) ] } const courseVariants = [ { id: "singing", outline: singingOutline, expectedMediaPlaceholders: 0 }, { id: "appreciation-no-lyrics", outline: appreciationOutline, expectedMediaPlaceholders: 1 }, { id: "rhythm-no-lyrics-or-score", outline: rhythmOutline, expectedMediaPlaceholders: 0 }, { id: "long-15-pages", outline: longOutline, expectedMediaPlaceholders: 0 } ] const preferences = { lessonMinutes: 40, minPages: 9, maxPages: 15, assetStyle: "illustration", sourceImagePolicy: "balanced", requireAudio: false, requireVideo: false, enableAnimations: true } const results = [] for (const template of AI_COURSEWARE_TEMPLATES) { for (const variant of courseVariants) { const outline = { ...variant.outline, templateId: template.id } const converted = aiCoursewareToSlides(outline, { templateId: template.id, enableAnimations: true }) const report = inspectAiPptQuality(outline, converted.slides, preferences) const master = getAiCoursewareTemplateMaster(template.id) const coverMasterPage = getTemplateMasterPage(master, "cover") const backgrounds = new Set(converted.slides.map(slide => slide.background?.color || slide.background?.image || "")) const expectedBackgrounds = new Set(master.pages.flatMap(item => item.backgroundImages?.length ? item.backgroundImages : [item.backgroundImage || item.background])) const unknownBackgrounds = [...backgrounds].filter(item => !expectedBackgrounds.has(item)) const missingBackgroundFiles = [...expectedBackgrounds] .filter(item => item.startsWith("/")) .filter(item => !existsSync(resolve("public", item.replace(/^\//, "")))) const layoutMismatchCount = outline.pages.filter((page, index) => { const layout = resolveTemplatePageLayout(page.visualPlan?.layout) const expectedPage = master.pages.find(item => item.layout === layout) const expectedBackgroundsForPage = expectedPage?.backgroundImages?.length ? expectedPage.backgroundImages : expectedPage ? [expectedPage.backgroundImage || expectedPage.background] : [] const actualBackground = converted.slides[index]?.background?.color || converted.slides[index]?.background?.image || "" return expectedBackgroundsForPage.length > 0 && !expectedBackgroundsForPage.includes(actualBackground) }).length const mediaPlaceholderPages = converted.slides .map((slide, index) => ({ pageNo: index + 1, found: slide.elements.some(element => { const content = element.type === "text" ? element.content : element.type === "shape" && element.text ? element.text.content : "" return String(content).includes("本页需要课堂音视频") }) })) .filter(item => item.found) .map(item => item.pageNo) const emptyRemarkPages = converted.slides .map((slide, index) => ({ pageNo: index + 1, empty: index > 0 && !String(slide.remark || "").trim() })) .filter(item => item.empty) .map(item => item.pageNo) results.push({ scenario: variant.id, expectedSlideCount: outline.pages.length, templateId: template.id, slideCount: converted.slides.length, masterPageCount: master.pages.length, coverMaster: getTemplateMasterPage(master, "cover").id, contentMaster: getTemplateMasterPage(master, "content").id, emptySlides: converted.slides.filter(slide => !slide.elements.length).length, contractPageCount: master.pages.filter(page => page.safeArea && page.reservedZones).length, coverVisualContractValid: coverMasterPage.backgroundVisual ? coverMasterPage.slots.find(slot => slot.kind === "image")?.enabled === false : coverMasterPage.slots.find(slot => slot.kind === "image")?.enabled !== false, layoutMismatchCount, expectedMediaPlaceholders: variant.expectedMediaPlaceholders, mediaPlaceholderPages, emptyRemarkPages, unknownBackgrounds, missingBackgroundFiles, errors: report.errors, warnings: report.warnings }) } } const failures = results.flatMap(result => { const messages = [] if (result.slideCount !== result.expectedSlideCount) messages.push(`页数不一致:${result.slideCount}`) if (result.masterPageCount < 10) messages.push(`母版页型不足:${result.masterPageCount}`) if (result.emptySlides) messages.push(`空白页:${result.emptySlides}`) if (result.contractPageCount !== result.masterPageCount) messages.push(`空间契约缺失:${result.contractPageCount}/${result.masterPageCount}`) if (!result.coverVisualContractValid) messages.push("封面背景类型与图片槽契约不一致") if (result.layoutMismatchCount) messages.push(`视觉布局匹配错误:${result.layoutMismatchCount}`) if (result.mediaPlaceholderPages.length !== result.expectedMediaPlaceholders) messages.push(`媒体占位页不符合预期:${result.mediaPlaceholderPages.join("、") || "无"}`) if (result.emptyRemarkPages.length) messages.push(`教师备注为空:${result.emptyRemarkPages.join("、")}`) if (result.unknownBackgrounds.length) messages.push(`未应用母版背景:${result.unknownBackgrounds.join(",")}`) if (result.missingBackgroundFiles.length) messages.push(`母版背景文件缺失:${result.missingBackgroundFiles.join(",")}`) if (result.errors.length) messages.push(...result.errors.map(item => `P${item.pageNo || "-"} ${item.code}: ${item.message}`)) return messages.map(message => `${result.templateId}/${result.scenario}: ${message}`) }) console.log(JSON.stringify({ passed: failures.length === 0, scenarios: courseVariants.map(item => item.id), results, failures }, null, 2)) if (failures.length) process.exitCode = 1 } finally { await vite.close() }