aiPptSelfTest.mjs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. import { createServer } from "vite"
  2. import { existsSync } from "node:fs"
  3. import { resolve } from "node:path"
  4. import assert from "node:assert/strict"
  5. const vite = await createServer({ server: { middlewareMode: true }, appType: "custom", logLevel: "error" })
  6. try {
  7. const { aiCoursewareToSlides } = await vite.ssrLoadModule("/src/utils/aiCoursewareToSlides.ts")
  8. const { inspectAiPptQuality } = await vite.ssrLoadModule("/src/utils/aiPptQuality.ts")
  9. const { AI_COURSEWARE_TEMPLATES } = await vite.ssrLoadModule("/src/config/aiCoursewareTemplates.ts")
  10. const { getAiCoursewareTemplateMaster, getTemplateMasterPage, resolveTemplatePageLayout } = await vite.ssrLoadModule("/src/config/aiCoursewareTemplateMasters.ts")
  11. const page = (pageNo, type, title, layout, points, extra = {}) => ({
  12. pageNo,
  13. type,
  14. title,
  15. bullets: points,
  16. studentContent: { points },
  17. imageKeywords: [],
  18. visualPlan: { layout, visualDensity: "medium" },
  19. ...extra
  20. })
  21. const elementText = element => {
  22. const content = element.type === "text" ? element.content : element.type === "shape" && element.text ? element.text.content : ""
  23. return String(content || "").replace(/<[^>]+>/g, "")
  24. }
  25. const missingStudentTexts = (outline, slides) =>
  26. outline.pages.flatMap((item, index) => {
  27. const expected = [item.studentContent?.headline, ...(item.studentContent?.points || item.bullets || [])].filter(Boolean)
  28. const rendered = (slides[index]?.elements || []).map(elementText).join("\n")
  29. return expected.filter(text => !rendered.includes(text)).map(text => ({ pageNo: item.pageNo, text }))
  30. })
  31. const singingOutline = {
  32. title: "凤鸣山谷的故事",
  33. gradeName: "一年级",
  34. textbookName: "音乐一年级上册",
  35. unitName: "第一单元 奇妙的声音世界",
  36. chapterName: "凤鸣山谷的故事",
  37. courseType: "music",
  38. style: "cartoon",
  39. pages: [
  40. page(1, "cover", "凤鸣山谷的故事", "cover_scene", ["聆听自然的声音", "发现音乐的强弱变化", "用声音和乐器讲故事"], { image: { url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" } }),
  41. page(2, "scene_intro", "山谷里传来了什么声音?", "story_stage", ["观察画面", "想一想……"], { interaction: { type: "question", question: "你觉得是谁在歌唱?", options: ["小鸟", "溪水", "风声"] } }),
  42. page(3, "objectives", "今天我们要学会", "goal_cards", ["分辨声音的高低", "感受音乐的强弱", "合作完成声音表演"]),
  43. page(4, "work_context", "走进凤鸣山谷", "split_image", ["清晨的山谷十分安静", "鸟鸣由远处慢慢靠近", "溪水一直轻轻流淌"]),
  44. page(5, "first_listen", "第一次完整聆听", "listen_wave", ["安静听完音乐", "用手势表示声音的远近", "记住最喜欢的声音"], { resourceBindings: [{ resourceId: "song-demo", resourceType: "SONG", name: "凤鸣山谷", contentUrl: "https://example.com/fengming.mp3", verified: true }] }),
  45. page(6, "lyric_read", "读一读山谷歌谣", "lyric_focus", ["小鸟唱得清脆", "溪水流得轻柔", "山风吹得悠长"]),
  46. page(7, "rhythm_practice", "跟着节奏拍一拍", "rhythm_grid", ["小鸟:短短拍", "溪水:连续拍", "山风:长长拍"], {
  47. studentContent: { headline: "跟着节奏拍一拍", points: ["小鸟:短短拍", "溪水:连续拍", "山风:长长拍"] },
  48. activity: { task: "教师示范不同节奏,引导学生模仿", steps: ["选择一种声音", "先慢速练习", "听指挥加入合奏"] }
  49. }),
  50. page(8, "melody_score", "旋律是怎样行走的?", "score_focus", ["向上表示声音变高", "向下表示声音变低", "平直表示保持稳定"]),
  51. page(9, "music_knowledge", "认识声音的强与弱", "knowledge_cards", ["强:有力量但不喊叫", "弱:轻柔但保持清楚", "强弱变化让音乐更生动"]),
  52. page(10, "compare_listen", "两段声音有什么不同?", "compare_listen", ["比较速度", "比较力度"], { interaction: { type: "compare", question: "哪一段更像远处的鸟鸣?", options: ["第一段更轻", "第二段更强", "第一段更慢", "第二段更快"] } }),
  53. page(11, "performance_activity", "合作表演山谷音乐会", "performance_stage", ["保持自己的声部", "关注指挥手势", "结束时一起收声"], { activity: { task: "用声音演出山谷从清晨到热闹的变化", steps: ["分成鸟鸣、溪水、山风三组", "商量进入顺序", "完整表演并互相评价"] } }),
  54. page(12, "summary", "我的音乐收获", "summary_checklist", ["我能分辨声音的高低", "我能表现声音的强弱", "我能与同伴合作表演"], { learningEvidence: "用一种声音表现你心中的凤鸣山谷" })
  55. ]
  56. }
  57. const appreciationOutline = {
  58. ...singingOutline,
  59. title: "月光下的琵琶",
  60. chapterName: "月光下的琵琶",
  61. courseType: "appreciation",
  62. style: "clean",
  63. pages: [
  64. page(1, "cover", "月光下的琵琶", "cover_scene", ["感受民族器乐的音色", "用语言描述音乐意境"]),
  65. page(2, "scene_intro", "月夜里传来了什么声音?", "hero_focus", ["观察演奏场景", "猜一猜正在演奏的乐器"]),
  66. page(3, "objectives", "带着三个问题走进音乐", "objectives", ["辨认琵琶音色", "感受速度与力度", "描述音乐意境"]),
  67. page(4, "first_listen", "完整聆听", "media_focus", ["安静聆听", "记录最打动你的片段", "想象音乐中的画面"]),
  68. page(5, "instrument_guide", "认识琵琶", "split_content", ["观察乐器外形", "了解基本演奏姿势", "辨认清脆明亮的音色"]),
  69. page(6, "music_knowledge", "音乐为什么有层次?", "diagram_focus", ["速度由慢到快", "力度由弱到强", "旋律推动情绪变化"]),
  70. page(7, "compare_listen", "两个片段有什么不同?", "compare", ["比较速度", "比较力度", "比较情绪"]),
  71. page(8, "creative_activity", "为音乐选择一幅画", "activity", ["小组交流选择理由", "用音乐要素支持判断", "向全班分享"]),
  72. page(9, "practice_check", "我能听出来", "summary", ["这是琵琶的音色", "这一段速度更快", "这一段力度更强"]),
  73. page(10, "summary", "我的欣赏收获", "text_focus", ["我能辨认琵琶音色", "我能描述速度和力度", "我能说出音乐带来的想象"])
  74. ]
  75. }
  76. const rhythmOutline = {
  77. ...singingOutline,
  78. title: "节奏变变变",
  79. chapterName: "节奏变变变",
  80. courseType: "rhythm",
  81. pages: [
  82. page(1, "cover", "节奏变变变", "cover_scene", ["发现生活中的节奏", "合作完成节奏接龙"]),
  83. page(2, "scene_intro", "哪些声音有规律?", "hero_focus", ["听脚步声", "听拍球声", "发现重复规律"]),
  84. page(3, "objectives", "今天的节奏任务", "objectives", ["稳定保持节拍", "模仿两种节奏型", "完成小组接龙"]),
  85. page(4, "rhythm_practice", "跟着节拍做动作", "diagram_focus", ["拍手", "跺脚", "拍腿"], { activity: { task: "保持稳定节拍", steps: ["看图模仿", "跟随口令", "独立完成"] } }),
  86. page(5, "compare_listen", "两种节奏一样吗?", "compare", ["比较声音长短", "比较疏密", "说出不同之处"]),
  87. page(6, "rhythm_practice", "组合新的节奏", "diagram_focus", ["选择两个节奏型", "按顺序连接", "反复练熟"], { activity: { task: "组合节奏", steps: ["选择", "连接", "练习"] } }),
  88. page(7, "creative_activity", "小组节奏接龙", "activity", ["保持共同速度", "轮到自己再进入", "结束动作要整齐"]),
  89. page(8, "practice_check", "节奏挑战", "summary", ["我能稳定拍击", "我能听出不同节奏", "我能完成接龙"]),
  90. page(9, "summary", "我的节奏收获", "text_focus", ["节拍要稳定", "节奏有长短疏密", "合作要互相倾听"])
  91. ]
  92. }
  93. const longOutline = {
  94. ...singingOutline,
  95. pages: [
  96. ...singingOutline.pages,
  97. page(13, "music_knowledge", "声音还能怎样变化?", "knowledge_cards", ["比较声音长短", "比较声音高低", "比较声音强弱"]),
  98. page(14, "creative_activity", "设计新的山谷回声", "activity", ["选择声音角色", "设计进入顺序", "合作完整展示"]),
  99. page(15, "summary", "把山谷音乐带回家", "summary", ["记录一种自然声音", "尝试用动作表现", "下节课与同学分享"])
  100. ]
  101. }
  102. const courseVariants = [
  103. { id: "singing", outline: singingOutline, expectedMediaPlaceholders: 0 },
  104. { id: "appreciation-no-lyrics", outline: appreciationOutline, expectedMediaPlaceholders: 0 },
  105. { id: "rhythm-no-lyrics-or-score", outline: rhythmOutline, expectedMediaPlaceholders: 0 },
  106. { id: "long-15-pages", outline: longOutline, expectedMediaPlaceholders: 0 }
  107. ]
  108. const preferences = { lessonMinutes: 40, minPages: 9, maxPages: 15, assetStyle: "illustration", sourceImagePolicy: "balanced", requireAudio: false, requireVideo: false, enableAnimations: true }
  109. const results = []
  110. const testAudio = index => ({ resourceId: `audio-${index}`, resourceType: "SONG", name: `测试音频${index}`, verified: true, contentUrl: `https://example.com/test-${index}.mp3` })
  111. const scoreImage = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
  112. // 此处是契约测试夹具,不是实际教材证据或媒体可播放性认证。
  113. const completeFixture = source => {
  114. const outline = structuredClone(source)
  115. outline.textbookResearch = { exactMatch: true, confidence: "HIGH", sources: [{ url: "https://example.com/test-textbook" }], teachingObjectives: ["用声音表现变化"] }
  116. outline.pages.forEach((item, index) => {
  117. item.durationMinutes = Math.floor(40 / outline.pages.length) + (index < 40 % outline.pages.length ? 1 : 0)
  118. item.teachingPurpose = "用声音表现变化"
  119. item.learningEvidence = "学生能用声音表现一种变化并说明理由"
  120. item.classroomAction = { teacherInstruction: "示范声音变化,邀请学生模仿并说明理由", studentTask: item.studentContent?.headline || item.title }
  121. item.speakerNotes = "示范后留出练习时间,根据学生表现调整难度。"
  122. item.objectiveIds = ["1"]
  123. if (/listen|phrase_sing|expressive_sing/.test(item.type)) item.resourceBindings = /compare/.test(item.type) ? [testAudio(1), testAudio(2)] : [testAudio(1)]
  124. if (item.type === "melody_score") item.resourceBindings = [{ resourceId: "test-score", resourceType: "MUSIC", verified: true, dedicatedPage: true, scoreImageUrl: scoreImage }]
  125. if (item.type === "practice_check") item.interaction = { type: "choice", question: "哪一种声音更轻?", options: ["轻声哼唱", "大声呼喊"], answer: "轻声哼唱", explanation: "比较相同距离下声音的强弱。" }
  126. })
  127. return outline
  128. }
  129. for (const template of AI_COURSEWARE_TEMPLATES) {
  130. for (const variant of courseVariants) {
  131. const outline = { ...completeFixture(variant.outline), templateId: template.id }
  132. const converted = aiCoursewareToSlides(outline, { templateId: template.id, enableAnimations: true })
  133. const report = inspectAiPptQuality(outline, converted.slides, preferences)
  134. const master = getAiCoursewareTemplateMaster(template.id)
  135. const coverMasterPage = getTemplateMasterPage(master, "cover")
  136. const backgrounds = new Set(converted.slides.map(slide => slide.background?.color || slide.background?.image || ""))
  137. const expectedBackgrounds = new Set(master.pages.flatMap(item => item.backgroundImages?.length ? item.backgroundImages : [item.backgroundImage || item.background]))
  138. const unknownBackgrounds = [...backgrounds].filter(item => !expectedBackgrounds.has(item))
  139. const missingBackgroundFiles = [...expectedBackgrounds]
  140. .filter(item => item.startsWith("/"))
  141. .filter(item => !existsSync(resolve("public", item.replace(/^\//, ""))))
  142. const layoutMismatchCount = outline.pages.filter((page, index) => {
  143. const layout = resolveTemplatePageLayout(page.type === "melody_score" ? "diagram_focus" : page.interaction?.question ? "text_focus" : page.visualPlan?.layout)
  144. const expectedPage = master.pages.find(item => item.layout === layout)
  145. const expectedBackgroundsForPage = expectedPage?.backgroundImages?.length
  146. ? expectedPage.backgroundImages
  147. : expectedPage ? [expectedPage.backgroundImage || expectedPage.background] : []
  148. const actualBackground = converted.slides[index]?.background?.color || converted.slides[index]?.background?.image || ""
  149. return expectedBackgroundsForPage.length > 0 && !expectedBackgroundsForPage.includes(actualBackground)
  150. }).length
  151. const mediaPlaceholderPages = converted.slides
  152. .map((slide, index) => ({
  153. pageNo: index + 1,
  154. found: slide.elements.some(element => {
  155. const content = element.type === "text" ? element.content : element.type === "shape" && element.text ? element.text.content : ""
  156. return String(content).includes("本页需要课堂音视频")
  157. })
  158. }))
  159. .filter(item => item.found)
  160. .map(item => item.pageNo)
  161. const emptyRemarkPages = converted.slides
  162. .map((slide, index) => ({ pageNo: index + 1, empty: index > 0 && !String(slide.remark || "").trim() }))
  163. .filter(item => item.empty)
  164. .map(item => item.pageNo)
  165. const missingVisibleContent = missingStudentTexts(outline, converted.slides)
  166. results.push({
  167. scenario: variant.id,
  168. expectedSlideCount: outline.pages.length,
  169. templateId: template.id,
  170. slideCount: converted.slides.length,
  171. masterPageCount: master.pages.length,
  172. coverMaster: getTemplateMasterPage(master, "cover").id,
  173. contentMaster: getTemplateMasterPage(master, "content").id,
  174. emptySlides: converted.slides.filter(slide => !slide.elements.length).length,
  175. contractPageCount: master.pages.filter(page => page.safeArea && page.reservedZones).length,
  176. coverVisualContractValid: coverMasterPage.backgroundVisual
  177. ? coverMasterPage.slots.find(slot => slot.kind === "image")?.enabled === false
  178. : coverMasterPage.slots.find(slot => slot.kind === "image")?.enabled !== false,
  179. layoutMismatchCount,
  180. expectedMediaPlaceholders: variant.expectedMediaPlaceholders,
  181. mediaPlaceholderPages,
  182. emptyRemarkPages,
  183. missingVisibleContent,
  184. unknownBackgrounds,
  185. missingBackgroundFiles,
  186. errors: report.errors,
  187. warnings: report.warnings
  188. })
  189. }
  190. }
  191. const failures = results.flatMap(result => {
  192. const messages = []
  193. if (result.slideCount !== result.expectedSlideCount) messages.push(`页数不一致:${result.slideCount}`)
  194. if (result.masterPageCount < 10) messages.push(`母版页型不足:${result.masterPageCount}`)
  195. if (result.emptySlides) messages.push(`空白页:${result.emptySlides}`)
  196. if (result.contractPageCount !== result.masterPageCount) messages.push(`空间契约缺失:${result.contractPageCount}/${result.masterPageCount}`)
  197. if (!result.coverVisualContractValid) messages.push("封面背景类型与图片槽契约不一致")
  198. if (result.layoutMismatchCount) messages.push(`视觉布局匹配错误:${result.layoutMismatchCount}`)
  199. if (result.mediaPlaceholderPages.length !== result.expectedMediaPlaceholders) messages.push(`媒体占位页不符合预期:${result.mediaPlaceholderPages.join("、") || "无"}`)
  200. if (result.emptyRemarkPages.length) messages.push(`教师备注为空:${result.emptyRemarkPages.join("、")}`)
  201. if (result.missingVisibleContent.length) messages.push(`学生内容未渲染:${result.missingVisibleContent.map(item => `P${item.pageNo} ${item.text}`).join("、")}`)
  202. if (result.unknownBackgrounds.length) messages.push(`未应用母版背景:${result.unknownBackgrounds.join(",")}`)
  203. if (result.missingBackgroundFiles.length) messages.push(`母版背景文件缺失:${result.missingBackgroundFiles.join(",")}`)
  204. if (result.errors.length) messages.push(...result.errors.map(item => `P${item.pageNo || "-"} ${item.code}: ${item.message}`))
  205. return messages.map(message => `${result.templateId}/${result.scenario}: ${message}`)
  206. })
  207. const valid = { ...completeFixture(rhythmOutline), templateId: "ethnic-flower" }
  208. const render = value => aiCoursewareToSlides(value).slides
  209. const codes = value => inspectAiPptQuality(value, render(value), preferences).errors.map(issue => issue.code)
  210. const noResearch = structuredClone(valid)
  211. noResearch.textbookResearch.exactMatch = false
  212. assert(codes(noResearch).includes("textbook_evidence"))
  213. const missingAudio = structuredClone(valid)
  214. missingAudio.pages[4].resourceBindings = []
  215. assert(codes(missingAudio).includes("missing_page_audio"))
  216. assert(!JSON.stringify(render(missingAudio)).includes("请老师在此补充"))
  217. const noScore = { ...completeFixture(singingOutline), templateId: "ethnic-flower" }
  218. noScore.pages[7].resourceBindings = []
  219. assert(codes(noScore).includes("missing_score"))
  220. assert.equal(render(noScore)[7].elements.filter(element => element.type === "line").length, 0, "缺谱不得绘制假五线谱")
  221. noScore.pages[7].resourceBindings = [{ resourceType: "MUSIC", resourceId: "verified-score", verified: true, dedicatedPage: true }]
  222. assert(render(noScore)[7].elements.some(element => element.subtype === "elf-sing-play" && element.sid === "verified-score"))
  223. const exercise = render(valid)[7]
  224. assert(exercise.elements.some(element => elementText(element).includes("哪一种声音更轻?")))
  225. assert(exercise.elements.some(element => elementText(element).includes("大声呼喊")))
  226. assert(exercise.remark.includes("参考答案:轻声哼唱") && exercise.remark.includes("比较相同距离"))
  227. const lost = render(valid)
  228. lost[7].elements = lost[7].elements.filter(element => !elementText(element).includes("哪一种声音更轻?"))
  229. assert(inspectAiPptQuality(valid, lost, preferences).errors.some(issue => issue.code === "missing_content"))
  230. const overtime = structuredClone(valid)
  231. overtime.pages[0].durationMinutes += 10
  232. assert(codes(overtime).includes("lesson_duration"))
  233. const dense = structuredClone(valid)
  234. dense.pages[3].studentContent.points = Array.from({length: 24}, (_, index) => `第${index + 1}条超长课堂说明,不应无限缩小字号塞进页面`)
  235. assert(codes(dense).some(code => ["text_overflow", "text_overlap", "out_of_bounds"].includes(code)))
  236. const compareAudio = render(valid)[4].elements.filter(element => element.subtype === "elf-audio")
  237. assert.equal(compareAudio.length, 2)
  238. assert(compareAudio.every(element => !element.autoplay))
  239. const editedCover = structuredClone(valid)
  240. editedCover.pages[0].title = "老师修改后的封面标题"
  241. assert(render(editedCover)[0].elements.some(element => elementText(element).includes("老师修改后的封面标题")))
  242. const noObjectives = structuredClone(valid)
  243. noObjectives.pages.forEach(page => page.objectiveIds = [])
  244. assert(codes(noObjectives).includes("objective_coverage"))
  245. const withVideo = structuredClone(valid)
  246. withVideo.pages[7].resourceBindings = [{resourceType:"VIDEO",resourceId:"test-video",contentUrl:"https://example.com/test.mp4",verified:true}]
  247. assert(render(withVideo)[7].elements.some(element => element.subtype === "elf-video"))
  248. console.log(JSON.stringify({ passed: failures.length === 0, scenarioCount: results.length, contractChecks: 13,
  249. scenarios: courseVariants.map(item => item.id), ...(process.argv.includes("--verbose") ? { results } : {}), failures }, null, 2))
  250. if (failures.length) process.exitCode = 1
  251. } finally {
  252. await vite.close()
  253. }