aiPptSelfTest.mjs 51 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583
  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. import { classroomFixture } from "./fixtures/aiPptClassroom.mjs"
  6. import { semanticDesignFixture, densityRegressionFixture, heroImageRegressionFixture } from "./fixtures/aiPptSemanticDesign.mjs"
  7. import { modelDesignFixture } from "./fixtures/aiPptModelDesign.mjs"
  8. import { inquiryFixture } from './fixtures/aiPptInquiry.mjs'
  9. import { lessonRepairFixture } from './fixtures/aiPptLessonRepair.mjs'
  10. import { contentGateFixture } from './fixtures/aiPptContentGate.mjs'
  11. import { liveRegressionFixture } from './fixtures/aiPptLiveRegression.mjs'
  12. import { storyVisualFixture } from './fixtures/aiPptStoryVisual.mjs'
  13. import { nativeVisualFixture } from './fixtures/aiPptNativeVisual.mjs'
  14. import { editorialFixture } from './fixtures/aiPptEditorial.mjs'
  15. import { comparisonCapacityFixture } from './fixtures/aiPptComparisonCapacity.mjs'
  16. const vite = await createServer({ server: { middlewareMode: true }, appType: "custom", logLevel: "error" })
  17. try {
  18. const { aiCoursewareToSlides } = await vite.ssrLoadModule("/src/utils/aiCoursewareToSlides.ts")
  19. const { inspectAiPptQuality } = await vite.ssrLoadModule("/src/utils/aiPptQuality.ts")
  20. const { buildAiPptSlidePreviews } = await vite.ssrLoadModule('/src/utils/aiPptTemplateEngine.ts')
  21. const comparison = comparisonCapacityFixture()
  22. const comparisonSlides = aiCoursewareToSlides(comparison).slides
  23. assert.equal(comparisonSlides.length, 6, '长对比组放不下卡片装饰时应使用紧凑分栏')
  24. assert.deepEqual(inspectAiPptQuality(comparison, comparisonSlides).errors.filter(issue => ['out_of_bounds','text_overlap','text_overflow','rendered_text_geometry'].includes(issue.code)), [])
  25. const overloadedComparison = comparisonCapacityFixture(true)
  26. assert.throws(() => aiCoursewareToSlides(overloadedComparison), /第3页.*投屏安全区/, '异常必须携带具体页码')
  27. const previews = buildAiPptSlidePreviews(overloadedComparison)
  28. assert.equal(previews.length, 6, '失败页也保留页序位置')
  29. assert(previews[2].error.includes('第3页'))
  30. assert.equal(previews.filter(page => page.slide).length, 5, '单页失败不得清空其他预览')
  31. const textGeometry = slide => slide.elements.filter(e => e.type === 'text').map(({left,top,width,height,content}) => ({left,top,width,height,content}))
  32. assert.deepEqual(textGeometry(previews[3].slide), textGeometry(comparisonSlides[3]), '失败后的页面仍沿用正确页序和布局规则')
  33. const editorial = editorialFixture(), editorialSlides = aiCoursewareToSlides(editorial).slides
  34. const editorialErrors = inspectAiPptQuality(editorial, editorialSlides, { minPages: 6, maxPages: 6 }).errors
  35. assert.deepEqual(editorialErrors.filter(issue => ['out_of_bounds', 'text_overlap', 'text_overflow', 'rendered_text_geometry', 'missing_content', 'small_font'].includes(issue.code)), [])
  36. const stepText = editorialSlides[3].elements.filter(element => element.type === 'text')
  37. const stepNumbers = stepText.filter(element => /^[1-4]$/.test(element.content.replace(/<[^>]*>/g, '')))
  38. assert.equal(stepNumbers.length, 4)
  39. assert(stepNumbers.every((element, i) => !i || element.left === stepNumbers[0].left && element.top > stepNumbers[i - 1].top), '长步骤使用纵向阅读顺序')
  40. for (const step of editorial.pages[3].studentContent.steps) assert(stepText.some(element => element.content.replace(/<[^>]*>/g, '').includes(step)), '步骤原文完整保留')
  41. assert(editorialSlides[1].elements.filter(element => element.type === 'text' && element.content.includes('<strong>')).length === 3, '明确标注的要点形成主次层级')
  42. assert(editorialSlides[0].elements.find(element => element.type === 'text' && element.content.includes(editorial.title)).content.includes('text-align:center'), '文字封面以课题为中心')
  43. const gateFixture = contentGateFixture(), gateSlides = aiCoursewareToSlides(gateFixture).slides
  44. const layoutCodes = ['out_of_bounds', 'text_overlap', 'text_overflow', 'rendered_text_geometry', 'missing_content', 'small_font']
  45. const live = liveRegressionFixture(), liveSlides = aiCoursewareToSlides(live).slides
  46. assert.deepEqual(inspectAiPptQuality(live, liveSlides).errors.filter(issue => layoutCodes.includes(issue.code)), [], '真实长文本不得重叠或丢失')
  47. assert(liveSlides[9].remark.includes(live.pages[9].studentContent.example), '教师示范完整保留在备注')
  48. const liveText = slide => slide.elements.filter(e => e.type === 'text').map(e => e.content.replace(/<[^>]*>/g, '')).join('\n')
  49. assert(liveText(liveSlides[6]).includes(live.pages[6].studentContent.example), '真实长对话范例逐字保留')
  50. assert(!liveText(liveSlides[7]).includes('老虎吼(强)'), '听辨画面不能提前显示声源答案')
  51. assert(liveSlides[7].remark.includes('老虎吼(强)'), '移出的声源标注保留到教师备注')
  52. assert(liveSlides.every(slide => slide.elements.every(e => e.type === 'line' || e.width > 0 && e.height > 0)), '不得生成负高度或零尺寸')
  53. const liveImage = liveSlides[0].elements.find(e => e.type === 'image')
  54. assert(liveImage.width * liveImage.height / (1920 * 1080) >= .3, '封面主体保留足够观察面积')
  55. assert(Math.abs(liveImage.width / liveImage.height - 1.38) < .001, '教材图保持完整比例')
  56. assert.deepEqual(inspectAiPptQuality(gateFixture, gateSlides).errors.filter(issue => layoutCodes.includes(issue.code)), [], '线上结构回归应完整可读')
  57. const dots = slide => slide.elements.filter(element => element.type === 'shape' && element.path === 'M100,0 A100,100 0 1,1 99.99,0 Z')
  58. assert.equal(dots(liveSlides[10]).length, 2, '混合概念图只为强弱绘制圆形,长短画条形')
  59. const bars = liveSlides[10].elements.filter(e => e.type === 'shape' && !dots(liveSlides[10]).includes(e))
  60. assert.equal(bars.length, 2)
  61. assert(bars[0].width > bars[1].width, '长条必须比短条长')
  62. const { assessmentAnnotationTexts } = await vite.ssrLoadModule('/src/utils/aiPptContent.ts')
  63. const labelChoice = structuredClone(live.pages[7])
  64. labelChoice.interaction.question = '哪组强弱标注正确?'
  65. assert.deepEqual(assessmentAnnotationTexts(labelChoice), [], '辨析标注的选择题不得误删候选项')
  66. const { blockingAiPptLayoutIssues } = await vite.ssrLoadModule('/src/utils/aiPptQuality.ts')
  67. const damaged = structuredClone(liveSlides)
  68. damaged[6].elements.filter(e => e.type === 'text').slice(-2).forEach(e => { e.top = 450 })
  69. assert(blockingAiPptLayoutIssues(inspectAiPptQuality(live, damaged)).some(issue => issue.pageNo === 7), '重叠页必须阻止写入编辑器')
  70. assert.equal(dots(gateSlides[2]).length, 4, '一拍一声:四拍四个发声点')
  71. assert.equal(dots(gateSlides[3]).length, 8, '一拍两声:四拍八个发声点')
  72. const cells = slide => slide.elements.filter(element => element.type === 'shape' && element.width > 200 && element.height > 80 && element.height < 200)
  73. assert.deepEqual(cells(gateSlides[2]).map(({left,width}) => ({left,width})), cells(gateSlides[3]).map(({left,width}) => ({left,width})), '两页拍格宽度及位置一致')
  74. assert.equal(gateSlides[5].elements.filter(el => el.type === 'text' && /^[1-9]$/.test(el.content.replace(/<[^>]+>/g, ''))).length, 3, '创编例子不编号成第四个步骤')
  75. const leaked = structuredClone(gateFixture)
  76. leaked.pages[6].interaction.question += '(第一次更响)'
  77. assert(inspectAiPptQuality(leaked, aiCoursewareToSlides(leaked).slides).errors.some(issue => issue.code === 'answer_leak'))
  78. assert(!inspectAiPptQuality(gateFixture, gateSlides).errors.some(issue => issue.code === 'answer_leak'), '正确答案作为候选项不算泄露')
  79. const { teachingReviewIssues } = await vite.ssrLoadModule('/src/utils/aiPptFeedback.ts')
  80. const reviewItems = teachingReviewIssues('第5页:缺少studentContent.example;第10页:题干泄露答案;整课一致性复核暂未完成')
  81. assert.deepEqual(reviewItems.map(issue => issue.pageNo), [5, 10, undefined])
  82. assert(reviewItems.every(issue => issue.severity === 'error') && reviewItems[0].message.includes('学生可见范例'))
  83. const inquiry = inquiryFixture()
  84. const inquiryResult = aiCoursewareToSlides(inquiry)
  85. const inquiryReport = inspectAiPptQuality(inquiry, inquiryResult.slides, {
  86. lessonMinutes: 40, minPages: 9, maxPages: 15, resourceMode: 'teacher_supplement', requireAudio: true, requireVideo: true
  87. })
  88. assert.deepEqual(inquiryReport.errors, [], `无媒体教学样例:${JSON.stringify(inquiryReport.errors)}`)
  89. assert(inquiryResult.slides.some(slide => slide.background.color.toUpperCase() === '#FAF8F2'), '保留整课纸底主题色')
  90. assert(new Set(inquiryResult.slides.map(slide => slide.background.color)).size >= 3, '封面、情境和原生图示使用有层次的主题底色')
  91. const story = storyVisualFixture(), storySlides = aiCoursewareToSlides(story).slides
  92. assert.deepEqual(inspectAiPptQuality(story, storySlides).errors.filter(issue => layoutCodes.includes(issue.code)), [], '角色配图、步骤与完整范例不得重叠或缺字')
  93. const roleImage = storySlides[1].elements.find(e => e.type === 'image')
  94. assert(roleImage && roleImage.width * roleImage.height / (1920 * 1080) >= .15, '角色活动图必须具有可观察面积')
  95. assert(Math.abs(roleImage.width / roleImage.height - 1264 / 704) < .001, '真实横图不能被压缩变形')
  96. assert(liveText(storySlides[1]).includes(story.pages[1].studentContent.example), '学生范例完整保留在页面')
  97. const native = nativeVisualFixture(), nativeSlides = aiCoursewareToSlides(native).slides
  98. assert.deepEqual(inspectAiPptQuality(native, nativeSlides).errors.filter(issue => layoutCodes.includes(issue.code)), [], '无图课件的色块、任务图标和文字应完整且不重叠')
  99. assert(nativeSlides.every(slide => slide.elements.every(e => e.type !== 'image')), '原生视觉不应新增图片资源')
  100. const taskIcons = slide => slide.elements.filter(e => e.name?.startsWith('教学图标:'))
  101. assert.deepEqual(taskIcons(nativeSlides[1]).map(e => e.name), ['教学图标:聆听', '教学图标:观察', '教学图标:交流'])
  102. assert.equal(taskIcons(nativeSlides[2]).length, 3)
  103. assert.equal(taskIcons(nativeSlides[3]).length, 0, '节奏图示不混入任务装饰图标')
  104. assert(taskIcons(nativeSlides[1]).every(e => e.type === 'shape' && e.fixedRatio && /^[MLCZ0-9. ,]+$/.test(e.path)), '图标应是可编辑且可导出的矢量路径')
  105. const { teachingActionIcon } = await vite.ssrLoadModule('/src/config/aiPptTeachingIcons.ts')
  106. assert.equal(teachingActionIcon('听起来很轻巧'), undefined, '音色描述不能被误判为聆听任务')
  107. assert(inquiryResult.slides.every(slide => !slide.elements.some(element => element.type === 'elf')), '媒体由教师补充时不生成空播放器或谱例')
  108. assert(inquiryResult.slides[3].elements.filter(element => element.type === 'shape').length >= 4, '强弱图须绘制为可编辑圆形')
  109. assert(inquiryResult.slides[5].remark.includes('教学示范:') && inquiryResult.slides[5].remark.includes('判断标准:'), '示范与反馈必须进入备注')
  110. const { AI_COURSEWARE_TEMPLATES } = await vite.ssrLoadModule("/src/config/aiCoursewareTemplates.ts")
  111. const { getAiCoursewareTemplateMaster, getTemplateMasterPage, resolveTemplatePageLayout } = await vite.ssrLoadModule("/src/config/aiCoursewareTemplateMasters.ts")
  112. const { resolveAiPptLayout } = await vite.ssrLoadModule("/src/utils/aiPptTemplateEngine.ts")
  113. const page = (pageNo, type, title, layout, points, extra = {}) => ({
  114. pageNo,
  115. type,
  116. title,
  117. bullets: points,
  118. studentContent: { points },
  119. imageKeywords: [],
  120. visualPlan: { layout, visualDensity: "medium" },
  121. ...extra
  122. })
  123. const elementText = element => {
  124. const content = element.type === "text" ? element.content : element.type === "shape" && element.text ? element.text.content : ""
  125. return String(content || "").replace(/<[^>]+>/g, "")
  126. }
  127. const missingStudentTexts = (outline, slides) =>
  128. outline.pages.flatMap((item, index) => {
  129. const expected = [item.studentContent?.headline, ...(item.studentContent?.points || item.bullets || [])].filter(Boolean)
  130. const rendered = (slides[index]?.elements || []).map(elementText).join("\n")
  131. return expected.filter(text => !rendered.includes(text)).map(text => ({ pageNo: item.pageNo, text }))
  132. })
  133. const repairFixture = lessonRepairFixture(inquiry.pages[0].image.url)
  134. const repaired = aiCoursewareToSlides(repairFixture, { enableAnimations: true }).slides
  135. assert.deepEqual(inspectAiPptQuality(repairFixture, repaired).errors.filter(issue => ['out_of_bounds', 'text_overlap', 'text_overflow', 'rendered_text_geometry', 'missing_content', 'small_font', 'invalid_teaching_diagram'].includes(issue.code)), [])
  136. const repairedCoverImage = repaired[0].elements.find(el => el.type === 'image')
  137. assert(repairedCoverImage.width * repairedCoverImage.height / (1920 * 1080) >= .2, '真实封面结构不得把主图压成小横幅')
  138. const durationBars = repaired[2].elements.filter(el => el.type === 'shape')
  139. assert.equal(durationBars.length, 4, '每个短音独立呈现')
  140. assert(Math.abs(durationBars[0].width / durationBars[1].width - 2) < .01, '时值条严格使用提供的2:1比例')
  141. const pitchLabels = ['do', 're', 'mi'].map(label => repaired[3].elements.find(el => elementText(el) === label))
  142. assert(pitchLabels[0].top > pitchLabels[1].top && pitchLabels[1].top > pitchLabels[2].top, '相对音高逐级升高,不能只是水平流程')
  143. assert(repaired[3].remark.includes('不表示频率'), '图示依据保留在教师备注')
  144. assert(repaired[5].remark.includes('do → re → mi') && !repaired[5].elements.map(elementText).join('').includes('do → re → mi'), '答案仅供教师查看')
  145. for (const slide of repaired) {
  146. assert.equal(slide.animations.length, slide.elements.length, '标题、正文、形状和线条作为整体呈现')
  147. assert.equal(slide.animations[0].trigger, 'auto')
  148. assert(slide.animations.slice(1).every(animation => animation.trigger === 'meantime'), '不按文本元素顺序拆散教学图示')
  149. }
  150. const { imagePreparationMessage, teacherReviewMessage } = await vite.ssrLoadModule('/src/utils/aiPptFeedback.ts')
  151. assert(!imagePreparationMessage({ imageStatus: 'review_rejected', imageReview: '评分45低于65,mustInclude缺失,模型说明...' }).includes('评分'))
  152. assert(teacherReviewMessage('第6页请补充studentContent.steps').includes('学生操作步骤'))
  153. const singingOutline = {
  154. title: "凤鸣山谷的故事",
  155. gradeName: "一年级",
  156. textbookName: "音乐一年级上册",
  157. unitName: "第一单元 奇妙的声音世界",
  158. chapterName: "凤鸣山谷的故事",
  159. courseType: "music",
  160. style: "cartoon",
  161. pages: [
  162. page(1, "cover", "凤鸣山谷的故事", "cover_scene", ["聆听自然的声音", "发现音乐的强弱变化", "用声音和乐器讲故事"], { image: { url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" } }),
  163. page(2, "scene_intro", "山谷里传来了什么声音?", "story_stage", ["观察画面", "想一想……"], { interaction: { type: "question", question: "你觉得是谁在歌唱?", options: ["小鸟", "溪水", "风声"] } }),
  164. page(3, "objectives", "今天我们要学会", "goal_cards", ["分辨声音的高低", "感受音乐的强弱", "合作完成声音表演"]),
  165. page(4, "work_context", "走进凤鸣山谷", "split_image", ["清晨的山谷十分安静", "鸟鸣由远处慢慢靠近", "溪水一直轻轻流淌"]),
  166. page(5, "first_listen", "第一次完整聆听", "listen_wave", ["安静听完音乐", "用手势表示声音的远近", "记住最喜欢的声音"], { resourceBindings: [{ resourceId: "song-demo", resourceType: "SONG", name: "凤鸣山谷", contentUrl: "https://example.com/fengming.mp3", verified: true }] }),
  167. page(6, "lyric_read", "读一读山谷歌谣", "lyric_focus", ["小鸟唱得清脆", "溪水流得轻柔", "山风吹得悠长"]),
  168. page(7, "rhythm_practice", "跟着节奏拍一拍", "rhythm_grid", ["小鸟:短短拍", "溪水:连续拍", "山风:长长拍"], {
  169. studentContent: { headline: "跟着节奏拍一拍", points: ["小鸟:短短拍", "溪水:连续拍", "山风:长长拍"] },
  170. activity: { task: "教师示范不同节奏,引导学生模仿", steps: ["选择一种声音", "先慢速练习", "听指挥加入合奏"] }
  171. }),
  172. page(8, "melody_score", "旋律是怎样行走的?", "score_focus", ["向上表示声音变高", "向下表示声音变低", "平直表示保持稳定"]),
  173. page(9, "music_knowledge", "认识声音的强与弱", "knowledge_cards", ["强:有力量但不喊叫", "弱:轻柔但保持清楚", "强弱变化让音乐更生动"]),
  174. page(10, "compare_listen", "两段声音有什么不同?", "compare_listen", ["比较速度", "比较力度"], { interaction: { type: "compare", question: "哪一段更像远处的鸟鸣?", options: ["第一段更轻", "第二段更强", "第一段更慢", "第二段更快"] } }),
  175. page(11, "performance_activity", "合作表演山谷音乐会", "performance_stage", ["保持自己的声部", "关注指挥手势", "结束时一起收声"], { activity: { task: "用声音演出山谷从清晨到热闹的变化", steps: ["分成鸟鸣、溪水、山风三组", "商量进入顺序", "完整表演并互相评价"] } }),
  176. page(12, "summary", "我的音乐收获", "summary_checklist", ["我能分辨声音的高低", "我能表现声音的强弱", "我能与同伴合作表演"], { learningEvidence: "用一种声音表现你心中的凤鸣山谷" })
  177. ]
  178. }
  179. const appreciationOutline = {
  180. ...singingOutline,
  181. title: "月光下的琵琶",
  182. chapterName: "月光下的琵琶",
  183. courseType: "appreciation",
  184. style: "clean",
  185. pages: [
  186. page(1, "cover", "月光下的琵琶", "cover_scene", ["感受民族器乐的音色", "用语言描述音乐意境"]),
  187. page(2, "scene_intro", "月夜里传来了什么声音?", "hero_focus", ["观察演奏场景", "猜一猜正在演奏的乐器"]),
  188. page(3, "objectives", "带着三个问题走进音乐", "objectives", ["辨认琵琶音色", "感受速度与力度", "描述音乐意境"]),
  189. page(4, "first_listen", "完整聆听", "media_focus", ["安静聆听", "记录最打动你的片段", "想象音乐中的画面"]),
  190. page(5, "instrument_guide", "认识琵琶", "split_content", ["观察乐器外形", "了解基本演奏姿势", "辨认清脆明亮的音色"]),
  191. page(6, "music_knowledge", "音乐为什么有层次?", "diagram_focus", ["速度由慢到快", "力度由弱到强", "旋律推动情绪变化"]),
  192. page(7, "compare_listen", "两个片段有什么不同?", "compare", ["比较速度", "比较力度", "比较情绪"]),
  193. page(8, "creative_activity", "为音乐选择一幅画", "activity", ["小组交流选择理由", "用音乐要素支持判断", "向全班分享"]),
  194. page(9, "practice_check", "我能听出来", "summary", ["这是琵琶的音色", "这一段速度更快", "这一段力度更强"]),
  195. page(10, "summary", "我的欣赏收获", "text_focus", ["我能辨认琵琶音色", "我能描述速度和力度", "我能说出音乐带来的想象"])
  196. ]
  197. }
  198. const rhythmOutline = {
  199. ...singingOutline,
  200. title: "节奏变变变",
  201. chapterName: "节奏变变变",
  202. courseType: "rhythm",
  203. pages: [
  204. page(1, "cover", "节奏变变变", "cover_scene", ["发现生活中的节奏", "合作完成节奏接龙"]),
  205. page(2, "scene_intro", "哪些声音有规律?", "hero_focus", ["听脚步声", "听拍球声", "发现重复规律"]),
  206. page(3, "objectives", "今天的节奏任务", "objectives", ["稳定保持节拍", "模仿两种节奏型", "完成小组接龙"]),
  207. page(4, "rhythm_practice", "跟着节拍做动作", "diagram_focus", ["拍手", "跺脚", "拍腿"], { activity: { task: "保持稳定节拍", steps: ["看图模仿", "跟随口令", "独立完成"] } }),
  208. page(5, "compare_listen", "两种节奏一样吗?", "compare", ["比较声音长短", "比较疏密", "说出不同之处"]),
  209. page(6, "rhythm_practice", "组合新的节奏", "diagram_focus", ["选择两个节奏型", "按顺序连接", "反复练熟"], { activity: { task: "组合节奏", steps: ["选择", "连接", "练习"] } }),
  210. page(7, "creative_activity", "小组节奏接龙", "activity", ["保持共同速度", "轮到自己再进入", "结束动作要整齐"]),
  211. page(8, "practice_check", "节奏挑战", "summary", ["我能稳定拍击", "我能听出不同节奏", "我能完成接龙"]),
  212. page(9, "summary", "我的节奏收获", "text_focus", ["节拍要稳定", "节奏有长短疏密", "合作要互相倾听"])
  213. ]
  214. }
  215. const longOutline = {
  216. ...singingOutline,
  217. pages: [
  218. ...singingOutline.pages,
  219. page(13, "music_knowledge", "声音还能怎样变化?", "knowledge_cards", ["比较声音长短", "比较声音高低", "比较声音强弱"]),
  220. page(14, "creative_activity", "设计新的山谷回声", "activity", ["选择声音角色", "设计进入顺序", "合作完整展示"]),
  221. page(15, "summary", "把山谷音乐带回家", "summary", ["记录一种自然声音", "尝试用动作表现", "下节课与同学分享"])
  222. ]
  223. }
  224. const courseVariants = [
  225. { id: "singing", outline: singingOutline, expectedMediaPlaceholders: 0 },
  226. { id: "appreciation-no-lyrics", outline: appreciationOutline, expectedMediaPlaceholders: 0 },
  227. { id: "rhythm-no-lyrics-or-score", outline: rhythmOutline, expectedMediaPlaceholders: 0 },
  228. { id: "long-15-pages", outline: longOutline, expectedMediaPlaceholders: 0 }
  229. ]
  230. const preferences = { lessonMinutes: 40, minPages: 9, maxPages: 15, assetStyle: "illustration", sourceImagePolicy: "balanced", requireAudio: false, requireVideo: false, enableAnimations: true }
  231. const results = []
  232. const testAudio = index => ({ resourceId: `audio-${index}`, resourceType: "SONG", name: `测试音频${index}`, verified: true, contentUrl: `https://example.com/test-${index}.mp3` })
  233. const scoreImage = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
  234. // 此处是契约测试夹具,不是实际教材证据或媒体可播放性认证。
  235. const completeFixture = source => {
  236. const outline = structuredClone(source)
  237. outline.textbookResearch = { exactMatch: true, confidence: "HIGH", sources: [{ url: "https://example.com/test-textbook" }], teachingObjectives: ["用声音表现变化"] }
  238. outline.pages.forEach((item, index) => {
  239. item.durationMinutes = Math.floor(40 / outline.pages.length) + (index < 40 % outline.pages.length ? 1 : 0)
  240. item.teachingPurpose = "用声音表现变化"
  241. item.learningEvidence = "学生能用声音表现一种变化并说明理由"
  242. item.classroomAction = { teacherInstruction: "示范声音变化,邀请学生模仿并说明理由", studentTask: item.studentContent?.headline || item.title }
  243. item.speakerNotes = "示范后留出练习时间,根据学生表现调整难度。"
  244. item.objectiveIds = ["1"]
  245. if (/listen|phrase_sing|expressive_sing/.test(item.type)) item.resourceBindings = /compare/.test(item.type) ? [testAudio(1), testAudio(2)] : [testAudio(1)]
  246. if (item.type === "melody_score") item.resourceBindings = [{ resourceId: "test-score", resourceType: "MUSIC", verified: true, dedicatedPage: true, scoreImageUrl: scoreImage }]
  247. if (item.type === "practice_check") item.interaction = { type: "choice", question: "哪一种声音更轻?", options: ["轻声哼唱", "大声呼喊"], answer: "轻声哼唱", explanation: "比较相同距离下声音的强弱。" }
  248. })
  249. return outline
  250. }
  251. for (const template of AI_COURSEWARE_TEMPLATES) {
  252. for (const variant of courseVariants) {
  253. const outline = { ...completeFixture(variant.outline), templateId: template.id }
  254. const converted = aiCoursewareToSlides(outline, { templateId: template.id, enableAnimations: true })
  255. const report = inspectAiPptQuality(outline, converted.slides, preferences)
  256. const master = getAiCoursewareTemplateMaster(template.id)
  257. const coverMasterPage = getTemplateMasterPage(master, "cover")
  258. const backgrounds = new Set(converted.slides.map(slide => slide.background?.color || slide.background?.image || ""))
  259. const expectedBackgrounds = new Set(master.pages.flatMap(item => item.backgroundImages?.length ? item.backgroundImages : [item.backgroundImage || item.background]))
  260. const unknownBackgrounds = [...backgrounds].filter(item => !expectedBackgrounds.has(item))
  261. const missingBackgroundFiles = [...expectedBackgrounds]
  262. .filter(item => item.startsWith("/"))
  263. .filter(item => !existsSync(resolve("public", item.replace(/^\//, ""))))
  264. const layoutMismatchCount = outline.pages.filter((page, index) => {
  265. const layout = resolveTemplatePageLayout(resolveAiPptLayout(page, index))
  266. const expectedPage = master.pages.find(item => item.layout === layout)
  267. const expectedBackgroundsForPage = expectedPage?.backgroundImages?.length
  268. ? expectedPage.backgroundImages
  269. : expectedPage ? [expectedPage.backgroundImage || expectedPage.background] : []
  270. const actualBackground = converted.slides[index]?.background?.color || converted.slides[index]?.background?.image || ""
  271. return expectedBackgroundsForPage.length > 0 && !expectedBackgroundsForPage.includes(actualBackground)
  272. }).length
  273. const mediaPlaceholderPages = converted.slides
  274. .map((slide, index) => ({
  275. pageNo: index + 1,
  276. found: slide.elements.some(element => {
  277. const content = element.type === "text" ? element.content : element.type === "shape" && element.text ? element.text.content : ""
  278. return String(content).includes("本页需要课堂音视频")
  279. })
  280. }))
  281. .filter(item => item.found)
  282. .map(item => item.pageNo)
  283. const emptyRemarkPages = converted.slides
  284. .map((slide, index) => ({ pageNo: index + 1, empty: index > 0 && !String(slide.remark || "").trim() }))
  285. .filter(item => item.empty)
  286. .map(item => item.pageNo)
  287. const missingVisibleContent = missingStudentTexts(outline, converted.slides)
  288. results.push({
  289. scenario: variant.id,
  290. expectedSlideCount: outline.pages.length,
  291. templateId: template.id,
  292. slideCount: converted.slides.length,
  293. masterPageCount: master.pages.length,
  294. coverMaster: getTemplateMasterPage(master, "cover").id,
  295. contentMaster: getTemplateMasterPage(master, "content").id,
  296. emptySlides: converted.slides.filter(slide => !slide.elements.length).length,
  297. contractPageCount: master.pages.filter(page => page.safeArea && page.reservedZones).length,
  298. coverVisualContractValid: coverMasterPage.backgroundVisual
  299. ? coverMasterPage.slots.find(slot => slot.kind === "image")?.enabled === false
  300. : coverMasterPage.slots.find(slot => slot.kind === "image")?.enabled !== false,
  301. layoutMismatchCount,
  302. expectedMediaPlaceholders: variant.expectedMediaPlaceholders,
  303. mediaPlaceholderPages,
  304. emptyRemarkPages,
  305. missingVisibleContent,
  306. unknownBackgrounds,
  307. missingBackgroundFiles,
  308. errors: report.errors,
  309. warnings: report.warnings
  310. })
  311. }
  312. }
  313. const failures = results.flatMap(result => {
  314. const messages = []
  315. if (result.slideCount !== result.expectedSlideCount) messages.push(`页数不一致:${result.slideCount}`)
  316. if (result.masterPageCount < 10) messages.push(`母版页型不足:${result.masterPageCount}`)
  317. if (result.emptySlides) messages.push(`空白页:${result.emptySlides}`)
  318. if (result.contractPageCount !== result.masterPageCount) messages.push(`空间契约缺失:${result.contractPageCount}/${result.masterPageCount}`)
  319. if (!result.coverVisualContractValid) messages.push("封面背景类型与图片槽契约不一致")
  320. if (result.layoutMismatchCount) messages.push(`视觉布局匹配错误:${result.layoutMismatchCount}`)
  321. if (result.mediaPlaceholderPages.length !== result.expectedMediaPlaceholders) messages.push(`媒体占位页不符合预期:${result.mediaPlaceholderPages.join("、") || "无"}`)
  322. if (result.emptyRemarkPages.length) messages.push(`教师备注为空:${result.emptyRemarkPages.join("、")}`)
  323. if (result.missingVisibleContent.length) messages.push(`学生内容未渲染:${result.missingVisibleContent.map(item => `P${item.pageNo} ${item.text}`).join("、")}`)
  324. if (result.unknownBackgrounds.length) messages.push(`未应用母版背景:${result.unknownBackgrounds.join(",")}`)
  325. if (result.missingBackgroundFiles.length) messages.push(`母版背景文件缺失:${result.missingBackgroundFiles.join(",")}`)
  326. if (result.errors.length) messages.push(...result.errors.map(item => `P${item.pageNo || "-"} ${item.code}: ${item.message}`))
  327. return messages.map(message => `${result.templateId}/${result.scenario}: ${message}`)
  328. })
  329. const valid = { ...completeFixture(rhythmOutline), templateId: "ethnic-flower" }
  330. const render = value => aiCoursewareToSlides(value).slides
  331. const codes = value => inspectAiPptQuality(value, render(value), preferences).errors.map(issue => issue.code)
  332. const noResearch = structuredClone(valid)
  333. noResearch.textbookResearch.exactMatch = false
  334. assert(codes(noResearch).includes("textbook_evidence"))
  335. const missingAudio = structuredClone(valid)
  336. missingAudio.pages[4].resourceBindings = []
  337. assert(!codes(missingAudio).includes("missing_page_audio"))
  338. assert(inspectAiPptQuality(missingAudio, render(missingAudio), preferences).warnings.some(issue => issue.code === "missing_page_audio"))
  339. assert(render(missingAudio)[4].remark.includes("补充对应音频"))
  340. assert(!JSON.stringify(render(missingAudio)).includes("请老师在此补充"))
  341. const noScore = { ...completeFixture(singingOutline), templateId: "ethnic-flower" }
  342. noScore.pages[7].resourceBindings = []
  343. assert(!codes(noScore).includes("missing_score"), '缺少谱例不再阻止课件交付')
  344. assert(inspectAiPptQuality(noScore, render(noScore), preferences).warnings.some(issue => issue.code === 'missing_score'))
  345. assert(render(noScore)[7].elements.some(element => elementText(element).includes('向上表示声音变高')), '缺谱仍保留完整教学内容')
  346. assert.equal(render(noScore)[7].elements.filter(element => element.type === "line").length, 0, "缺谱不得绘制假五线谱")
  347. noScore.pages[7].resourceBindings = [{ resourceType: "MUSIC", resourceId: "verified-score", verified: true, dedicatedPage: true }]
  348. assert(render(noScore)[7].elements.some(element => element.subtype === "elf-sing-play" && element.sid === "verified-score"))
  349. const exercise = render(valid)[7]
  350. assert(exercise.elements.some(element => elementText(element).includes("哪一种声音更轻?")))
  351. assert(exercise.elements.some(element => elementText(element).includes("大声呼喊")))
  352. assert(exercise.remark.includes("参考答案:轻声哼唱") && exercise.remark.includes("比较相同距离"))
  353. const lost = render(valid)
  354. lost[7].elements = lost[7].elements.filter(element => !elementText(element).includes("哪一种声音更轻?"))
  355. assert(inspectAiPptQuality(valid, lost, preferences).errors.some(issue => issue.code === "missing_content"))
  356. const overtime = structuredClone(valid)
  357. overtime.pages[0].durationMinutes += 10
  358. assert(codes(overtime).includes("lesson_duration"))
  359. const dense = structuredClone(valid)
  360. dense.pages[3].studentContent.points = Array.from({length: 24}, (_, index) => `第${index + 1}条超长课堂说明,不应无限缩小字号塞进页面`)
  361. assert(codes(dense).some(code => ["text_overflow", "text_overlap", "out_of_bounds"].includes(code)))
  362. const compareAudio = render(valid)[4].elements.filter(element => element.subtype === "elf-audio")
  363. assert.equal(compareAudio.length, 2)
  364. assert(compareAudio.every(element => !element.autoplay))
  365. const editedCover = structuredClone(valid)
  366. editedCover.pages[0].title = "老师修改后的封面标题"
  367. assert(render(editedCover)[0].elements.some(element => elementText(element).includes("老师修改后的封面标题")))
  368. const noObjectives = structuredClone(valid)
  369. noObjectives.pages.forEach(page => page.objectiveIds = [])
  370. assert(codes(noObjectives).includes("objective_coverage"))
  371. const cultureResponse = structuredClone(valid)
  372. cultureResponse.textbookResearch.teachingObjectives = ['初步了解伶伦制律的文化背景']
  373. cultureResponse.pages.forEach(page => { page.objectiveIds = [] })
  374. const culturePage = cultureResponse.pages[2]
  375. culturePage.type = 'work_context'; culturePage.objectiveIds = ['1']; culturePage.optional = false
  376. culturePage.classroomAction = { teacherInstruction: '讲述故事后请学生回答。',
  377. studentTask: '听完故事,举手回答:伶伦是怎样发现音律的?',
  378. modelExample: '模仿凤凰叫声,再请学生回答伶伦听到了什么。', successCriteria: '学生能说出“听凤凰叫声”。' }
  379. assert(!codes(cultureResponse).includes('objective_coverage'), '有具体口头作答和判断标准的背景页应计入目标评价')
  380. culturePage.classroomAction.successCriteria = '学生认真听讲。'
  381. assert(codes(cultureResponse).includes('objective_coverage'), '单纯听讲不能冒充目标评价')
  382. const { modelPageContent: responsePageContent } = await vite.ssrLoadModule('/src/utils/aiPptContent.ts')
  383. const sameInstruction = { ...culturePage, type:'listen_task', title:'声音捉迷藏',
  384. studentContent:{ headline:'听到哪种声音,就伸出几个手指?', points:['1 — 风声','2 — 水声','3 — 鸟鸣'] },
  385. classroomAction:{ ...culturePage.classroomAction, studentTask:'听到哪种声音,就伸出几个手指?' } }
  386. assert.equal(responsePageContent(sameInstruction).task, '', '相同引导语与任务在学生画面只显示一次')
  387. assert.equal(responsePageContent(sameInstruction).heading, sameInstruction.studentContent.headline)
  388. const repairedInstruction = structuredClone(sameInstruction)
  389. const completeStep = '跟随指挥,各组依次加入表演,再交换乐器'
  390. repairedInstruction.studentContent = { headline: completeStep, points: [], steps: [completeStep, '听到结束信号后停下'] }
  391. repairedInstruction.classroomAction.studentTask = completeStep
  392. repairedInstruction.interaction = undefined
  393. assert.deepEqual(responsePageContent(repairedInstruction).steps, [completeStep, '听到结束信号后停下'], '续作修复后保留完整步骤和顺序')
  394. assert.equal(responsePageContent(repairedInstruction).task, '', '同一句任务不在步骤上方重复绘制')
  395. assert.equal(responsePageContent(repairedInstruction).heading, '', '步骤不能被移到标题区')
  396. const { coursewareCompletionMessage: completionMessage, isAdvisoryCompletion } = await vite.ssrLoadModule('/src/utils/aiPptFeedback.ts')
  397. assert(completionMessage({ blockingMessage: '第1页配图尚未完成(图片生成配置不完整)' }).includes('请管理员恢复配图服务'), '配置错误必须解释为何重试无效')
  398. const stepAdvice = '第8页:学生操作步骤过多,请合并同一动作,将教师组织细节放入备注,节奏与例子放points'
  399. assert(isAdvisoryCompletion(stepAdvice), '旧草稿仅有步骤数量建议时应能打开已有课件')
  400. assert(isAdvisoryCompletion(`${stepAdvice};第2页:学生文字超过180字,请精简`))
  401. for (const blocking of ['', '第8页:内容尚未生成', '第1页配图尚未完成', '第8页:教学范例错误', `${stepAdvice};第8页:缺少具体教学示范`])
  402. assert(!isAdvisoryCompletion(blocking), '不能用密度建议掩盖必需内容或图片缺失')
  403. assert(!teacherReviewMessage(stepAdvice).includes('points'), '教师提示不能泄露内部字段名')
  404. const withVideo = structuredClone(valid)
  405. withVideo.pages[7].resourceBindings = [{resourceType:"VIDEO",resourceId:"test-video",contentUrl:"https://example.com/test.mp4",verified:true}]
  406. assert(render(withVideo)[7].elements.some(element => element.subtype === "elf-video"))
  407. const { getAiPptImageAspectRatios } = await vite.ssrLoadModule("/src/utils/aiPptTemplateEngine.ts")
  408. for (const template of AI_COURSEWARE_TEMPLATES) {
  409. const illustrated = structuredClone(valid)
  410. illustrated.templateId = template.id
  411. const target = illustrated.pages[7]
  412. target.visualPlan = { layout: "text_focus", imagePurpose: "observation", imageFit: "contain" }
  413. target.image = { url: "https://example.com/whole-instrument.png", width: 1800, height: 600, provider: "TEST", reviewStatus: "verified" }
  414. target.requiredVisual = true
  415. const slides = render(illustrated)
  416. const image = slides[7].elements.find(element => element.type === "image" && element.src === target.image.url)
  417. assert(image, `${template.id}: 观察题必须显示教学图片`)
  418. assert.equal(image.clip, undefined, `${template.id}: 不裁掉教学主体`)
  419. assert(Math.abs(image.width / image.height - 3) < 0.001, `${template.id}: 不拉伸教学图片`)
  420. assert(slides[7].elements.some(element => elementText(element).includes(target.interaction.question)))
  421. assert(!inspectAiPptQuality(illustrated, slides, preferences).errors.some(issue => ["missing_visual_render", "out_of_bounds", "text_overlap"].includes(issue.code)))
  422. const ratios = getAiPptImageAspectRatios(template.id)
  423. assert(ratios.split_content > 0 && ratios["split_content:media"] > 0)
  424. const imageSlot = getTemplateMasterPage(getAiCoursewareTemplateMaster(template.id), "content", "split_content").slots.find(slot => slot.kind === "image" && slot.enabled !== false)
  425. assert.equal(ratios["split_content:media"], imageSlot.width / imageSlot.height)
  426. if (template.id === "dark-lute") {
  427. const body = slides[7].elements.find(element => elementText(element).includes(target.interaction.question))
  428. assert.equal(body.defaultColor, "#F7F6F1", "深色背景上的正文必须保持可读")
  429. }
  430. }
  431. const unreviewed = structuredClone(valid)
  432. unreviewed.pages[1].image = { url: "https://example.com/scene.png", provider: "TEST", reviewStatus: "unverified" }
  433. assert(inspectAiPptQuality(unreviewed, render(unreviewed), preferences).warnings.some(issue => issue.code === "image_review"))
  434. const classroom = classroomFixture(scoreImage)
  435. const classroomSlides = render(classroom)
  436. assert.equal(resolveAiPptLayout(classroom.pages[0], 0), "cover_scene", "requiredVisual 不能把封面改成分栏")
  437. assert(new Set(classroom.pages.map(resolveAiPptLayout)).size >= 6, "统一 split 的历史大纲应恢复不同教学版式")
  438. for (const index of [0, 10, 11]) {
  439. assert(!classroomSlides[index].elements.map(elementText).join(" ").includes(classroom.pages[index].classroomAction.studentTask), "教案表述不得进入学生画面")
  440. assert(classroomSlides[index].remark.includes(classroom.pages[index].classroomAction.studentTask), "移出的教师说明必须保留在备注")
  441. }
  442. assert(classroomSlides[6].elements.some(element => element.type === "text" && element.width > 1200), "无图听唱页应使用宽版正文")
  443. assert(classroomSlides[9].elements.some(element => element.type === "text" && element.width > 1200), "无图检测页不得保留半页空白")
  444. const classroomReport = inspectAiPptQuality(classroom, classroomSlides)
  445. assert(!classroomReport.errors.some(issue => ["out_of_bounds", "text_overlap", "text_overflow", "rendered_text_geometry", "missing_content", "small_font"].includes(issue.code)), JSON.stringify(classroomReport.errors))
  446. assert.equal(classroomReport.issues.filter(issue => issue.code === "missing_required_visual").length, 6)
  447. assert(classroomReport.issues.filter(issue => issue.code === "missing_required_visual").every(issue => issue.severity === 'error' && /重新生成|继续完成|尚未取得/.test(issue.message) && !/手动插图|补充正确图片|模型说明|评分|mustInclude/.test(issue.message)))
  448. // 配图不能迫使对比、活动、总结、目标页统一分栏,也不能在保留版式后丢图。
  449. for (const layout of ["compare", "activity", "summary", "objectives"]) {
  450. for (const template of AI_COURSEWARE_TEMPLATES) {
  451. const sample = structuredClone(valid)
  452. sample.templateId = template.id
  453. sample.pages[1] = page(2, "content", "观察并完成任务", layout, ["找一找主体", "说说变化"], {
  454. image: { url: scoreImage, width: 960, height: 720 }, requiredVisual: true,
  455. visualPlan: { layout, imagePurpose: "observation" }
  456. })
  457. const sampleSlides = render(sample)
  458. assert.equal(resolveAiPptLayout(sample.pages[1], 1), layout)
  459. assert(sampleSlides[1].elements.some(element => element.type === "image"), `${template.id}/${layout} 丢失配图`)
  460. assert(!inspectAiPptQuality(sample, sampleSlides).errors.some(issue => issue.pageNo === 2 && ["out_of_bounds", "text_overlap", "text_overflow", "rendered_text_geometry", "missing_content"].includes(issue.code)))
  461. }
  462. }
  463. const { PAGE_COMPOSITIONS, compilePageDesign } = await vite.ssrLoadModule('/src/utils/aiPptPageDesign.ts')
  464. let modelDesignChecks = 0
  465. const geometry = slide => slide.elements.filter(el => ['text', 'image', 'shape'].includes(el.type)).map(el => [el.type, el.left, el.top, el.width, el.height])
  466. const modelFixture = modelDesignFixture(scoreImage)
  467. const modelSlides = render(modelFixture)
  468. const modelReport = inspectAiPptQuality(modelFixture, modelSlides)
  469. assert(!modelReport.errors.some(issue => ['out_of_bounds', 'text_overlap', 'text_overflow', 'rendered_text_geometry', 'missing_content', 'small_font'].includes(issue.code)), JSON.stringify(modelReport.errors))
  470. assert(!modelReport.issues.some(issue => ['missing_required_visual', 'generated_focal_visual', 'rendered_layout_repeat'].includes(issue.code)), JSON.stringify(modelReport.issues))
  471. assert(new Set(modelSlides.map(slide => JSON.stringify(geometry(slide)))).size >= 8, '模型设计应实际产生不同坐标与内容组织')
  472. for (const template of AI_COURSEWARE_TEMPLATES) {
  473. for (const composition of PAGE_COMPOSITIONS) {
  474. for (const visualShare of [.35, .7]) {
  475. for (const textPresentation of ['plain', 'cards', 'steps', 'grid']) {
  476. for (const illustrated of [true, false]) {
  477. const sample = structuredClone(modelFixture)
  478. sample.templateId = template.id
  479. const target = sample.pages[1] = page(2, 'scene_intro', '观察角色的动作', 'split_content', ['找一找主体', '说说变化'], {
  480. studentContent: { headline: '它在做什么?', points: ['找一找主体', '说说变化'] },
  481. image: illustrated ? { url: scoreImage, width: 960, height: 720 } : undefined,
  482. requiredVisual: false, visualPlan: { composition, visualShare, textPresentation, visualMode: illustrated ? 'generated_image' : 'native_diagram', imagePurpose: illustrated ? 'observation' : 'none' }
  483. })
  484. const slides = render(sample)
  485. const errors = inspectAiPptQuality(sample, slides).errors.filter(issue => issue.pageNo === 2 && ['out_of_bounds', 'text_overlap', 'text_overflow', 'rendered_text_geometry', 'missing_content', 'small_font'].includes(issue.code))
  486. assert.equal(errors.length, 0, `${template.id}/${composition}/${visualShare}/${textPresentation}/${illustrated}: ${JSON.stringify(errors)}`)
  487. assert.equal(slides[1].elements.some(el => el.type === 'image'), illustrated)
  488. const text = slides[1].elements.map(elementText).join(' ')
  489. assert(['它在做什么?', ...target.studentContent.points].every(value => text.includes(value)), '新构图不能遗漏任务和正文')
  490. modelDesignChecks++
  491. }
  492. }
  493. }
  494. }
  495. assert.equal(getAiPptImageAspectRatios(template.id).model_design_v1, 1)
  496. }
  497. const positioned = structuredClone(modelFixture)
  498. positioned.pages[1].visualPlan.composition = 'image_left'
  499. const leftImage = render(positioned)[1].elements.find(el => el.type === 'image')
  500. positioned.pages[1].visualPlan.composition = 'image_right'
  501. const rightImage = render(positioned)[1].elements.find(el => el.type === 'image')
  502. assert(rightImage.left > leftImage.left, '模型选择图右必须改变图片实际位置')
  503. const ratioPage = { title: '观察', visualPlan: { composition: 'image_left', visualMode: 'generated_image', visualShare: .63 } }
  504. const ratioSlot = compilePageDesign(ratioPage, 1, getAiCoursewareTemplateMaster('blank'), false, true, false).slots.find(slot => slot.kind === 'image')
  505. assert.equal(ratioSlot.width / ratioSlot.height, (1440 - 36) * .65 / 640, '前后端主视觉宽高比约定保持一致')
  506. const missingPlannedImage = structuredClone(modelFixture)
  507. missingPlannedImage.pages[1].image = undefined
  508. missingPlannedImage.pages[1].imageStatus = 'budget_exhausted'
  509. assert(inspectAiPptQuality(missingPlannedImage, render(missingPlannedImage)).issues.some(issue => issue.pageNo === 2 && issue.severity === 'error' && issue.message.includes('打开草稿补图')))
  510. const repeatedSlides = structuredClone(modelSlides)
  511. repeatedSlides[2] = structuredClone(modelSlides[1])
  512. repeatedSlides[3] = structuredClone(modelSlides[1])
  513. assert(inspectAiPptQuality(modelFixture, repeatedSlides).issues.some(issue => issue.code === 'rendered_layout_repeat'), '版式名不同但坐标相同也应被检测到')
  514. const semantic = semanticDesignFixture(scoreImage)
  515. const semanticSlides = render(semantic)
  516. const semanticErrors = inspectAiPptQuality(semantic, semanticSlides).errors.filter(issue => ['out_of_bounds', 'text_overlap', 'text_overflow', 'rendered_text_geometry', 'missing_content', 'small_font'].includes(issue.code))
  517. assert.deepEqual(semanticErrors, [], JSON.stringify(semanticErrors))
  518. for (const template of AI_COURSEWARE_TEMPLATES) {
  519. const check = structuredClone(semantic)
  520. check.templateId = template.id
  521. const errors = inspectAiPptQuality(check, render(check)).errors.filter(issue => ['out_of_bounds', 'text_overlap', 'text_overflow', 'rendered_text_geometry', 'missing_content', 'small_font'].includes(issue.code))
  522. assert.deepEqual(errors, [], `${template.id}: ${JSON.stringify(errors)}`)
  523. }
  524. const illustratedGroups = structuredClone(semantic)
  525. Object.assign(illustratedGroups.pages[4], { image: { url: scoreImage, width: 960, height: 720 } })
  526. illustratedGroups.pages[4].visualPlan.visualMode = 'generated_image'
  527. illustratedGroups.pages[4].studentContent.comparisonGroups[0].points.push('用手势表现变化')
  528. illustratedGroups.pages[4].studentContent.comparisonGroups[1].points.push('试着模仿它的声音')
  529. const illustratedErrors = inspectAiPptQuality(illustratedGroups, render(illustratedGroups)).errors.filter(issue => issue.pageNo === 5 && ['out_of_bounds', 'text_overlap', 'text_overflow', 'rendered_text_geometry', 'missing_content', 'small_font'].includes(issue.code))
  530. assert.deepEqual(illustratedErrors, [], '图文对比必须为组名、正文和公共提示预留高度')
  531. const textElement = (i, text) => semanticSlides[i].elements.find(el => elementText(el).includes(text))
  532. assert(textElement(3, '风的声音').left < textElement(3, '老虎的声音').left, '两种对比对象必须各在一侧')
  533. assert.equal(semanticSlides[5].elements.filter(el => /^[1-9]$/.test(elementText(el))).length, 4, '四个真实任务不能混入引导标题变成五步')
  534. assert.equal(semanticSlides[7].elements.filter(el => /^[1-9]$/.test(elementText(el))).length, 2, '结构化步骤只编号两步')
  535. assert(!semanticSlides[7].elements.map(elementText).join(' ').includes('旧正文'), '结构化内容不回退到旧bullets')
  536. assert(semanticSlides[6].elements.filter(el => ['X', 'XX', '-'].includes(elementText(el))).length >= 8, '已有节奏必须产生可编辑符号')
  537. assert(semanticSlides[5].elements.filter(el => el.type === 'shape' && el.width > 200).every(el => el.height < 230), '短任务卡片不应占据整页高度')
  538. assert(textElement(0, '凤鸣山谷的故事').top >= 200, '缺主图封面重新组织标题位置')
  539. const { modelPageContent } = await vite.ssrLoadModule('/src/utils/aiPptContent.ts')
  540. const invalidGroups = structuredClone(semantic.pages[4])
  541. invalidGroups.studentContent.comparisonGroups.push({ label: '第三组', points: ['第三组的完整内容'] })
  542. invalidGroups.studentContent.comparisonGroups.push({ label: '第四组', points: ['第四组的完整内容'] }, { label: '第五组', points: ['第五组的完整内容'] })
  543. assert(modelPageContent(invalidGroups).points.includes('第三组的完整内容'), '异常分组不能丢失正文')
  544. const density = densityRegressionFixture(scoreImage)
  545. const densitySlides = render(density)
  546. assert.deepEqual(inspectAiPptQuality(density, densitySlides).errors.filter(issue => ['out_of_bounds', 'text_overlap', 'text_overflow', 'rendered_text_geometry', 'missing_content', 'small_font'].includes(issue.code)), [])
  547. assert.equal(densitySlides[8].elements.filter(el => /^[1-9]$/.test(elementText(el))).length, 3, '新语义字段不得丢失为单个引导标题')
  548. const brokenDensity = structuredClone(densitySlides)
  549. brokenDensity[4].elements.filter(el => el.type === 'text' && density.pages[4].studentContent.points.some(point => elementText(el).includes(point))).forEach((el, i) => { el.top = 783.915 + i * 55.6875; el.height = 41.1075 })
  550. assert(inspectAiPptQuality(density, brokenDensity).errors.some(issue => issue.pageNo === 5 && issue.code === 'rendered_text_geometry'), '必须拦截之前坐标检查漏掉的真实高度溢出')
  551. const heroImages = heroImageRegressionFixture(scoreImage)
  552. const heroSlides = render(heroImages)
  553. for (const index of [0, 1, 11]) {
  554. const image = heroSlides[index].elements.find(el => el.type === 'image' && el.src === scoreImage)
  555. assert(image.width * image.height / (1920 * 1080) >= .24, `第 ${index + 1} 页主图不能退化成小横幅`)
  556. assert(Math.abs(image.width / image.height - 1584 / 560) < .001 && !image.clip, '主图保留完整比例,不裁切角色')
  557. const captions = heroImages.pages[index].studentContent.points.map(text => heroSlides[index].elements.find(el => elementText(el) === text))
  558. assert(captions.every(el => el && el.top === captions[0].top && el.top >= image.top + image.height), '短提示完整横排在主图下方')
  559. }
  560. assert.deepEqual(inspectAiPptQuality(heroImages, heroSlides).errors.filter(issue => ['out_of_bounds', 'text_overlap', 'text_overflow', 'rendered_text_geometry', 'missing_content', 'small_font'].includes(issue.code)), [])
  561. const longCaption = structuredClone(heroImages)
  562. longCaption.pages[1].studentContent.points[0] = '请仔细观察山谷里的溪水、树木与远山,再和身边的小伙伴一起讨论不同声音的变化以及自己最喜欢的声音,并用动作向大家展示你的发现。'
  563. const longCaptionSlides = render(longCaption)
  564. const longText = longCaptionSlides[1].elements.find(el => elementText(el) === longCaption.pages[1].studentContent.points[0])
  565. assert(longText.width > 1000, '长内容保留整行,不硬塞进三列短提示')
  566. const { aiPptEntryChanged } = await vite.ssrLoadModule('/src/utils/aiPptClientVersion.ts')
  567. const html = '<script type="module" crossorigin src="./assets/index-new.js"></script>'
  568. assert(aiPptEntryChanged(html, 'https://example.com/classroom-ppt/', './assets/index-old.js'))
  569. assert(!aiPptEntryChanged(html, 'https://example.com/classroom-ppt/', 'https://example.com/classroom-ppt/assets/index-new.js'))
  570. assert(!aiPptEntryChanged('<html>login</html>', 'https://example.com/', './assets/index-old.js'))
  571. console.log(JSON.stringify({ passed: failures.length === 0, scenarioCount: results.length, contractChecks: 13, imageTemplateChecks: AI_COURSEWARE_TEMPLATES.length, modelDesignChecks, semanticDesignChecks: 10, regressionChecks: 17,
  572. scenarios: courseVariants.map(item => item.id), ...(process.argv.includes("--verbose") ? { results } : {}), failures }, null, 2))
  573. if (failures.length) process.exitCode = 1
  574. } finally {
  575. await vite.close()
  576. }