|
|
@@ -1,336 +0,0 @@
|
|
|
-package cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf;
|
|
|
-
|
|
|
-import cn.yunzhixue.ability.center.examsprint.domain.report.AchievementReportContent;
|
|
|
-import cn.yunzhixue.ability.center.examsprint.domain.report.ReportType;
|
|
|
-import cn.yunzhixue.ability.center.examsprint.domain.report.UnmodeledReportContent;
|
|
|
-import cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.achievement.ClasspathAchievementExamSprintReportRenderer;
|
|
|
-import cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.outlook.ClasspathOutlookExamSprintReportRenderer;
|
|
|
-import com.fasterxml.jackson.databind.JsonNode;
|
|
|
-import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
-import com.fasterxml.jackson.databind.node.ObjectNode;
|
|
|
-import org.apache.pdfbox.pdmodel.PDDocument;
|
|
|
-import org.apache.pdfbox.text.PDFTextStripper;
|
|
|
-import org.junit.jupiter.api.Test;
|
|
|
-
|
|
|
-import java.awt.Font;
|
|
|
-import java.nio.charset.StandardCharsets;
|
|
|
-import java.nio.file.Files;
|
|
|
-import java.nio.file.Path;
|
|
|
-import java.text.Normalizer;
|
|
|
-import java.time.Instant;
|
|
|
-import java.util.List;
|
|
|
-
|
|
|
-import static org.assertj.core.api.Assertions.assertThat;
|
|
|
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
|
-
|
|
|
-class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
|
|
|
-
|
|
|
- private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
|
|
-
|
|
|
- /**
|
|
|
- * 覆盖官方上游词汇 payload 渲染后的 Outlook HTML 生成 PDF 时,应产出可解析 PDF 并包含关键静态与计算文本。
|
|
|
- */
|
|
|
- @Test
|
|
|
- void generateCreatesPdfSmokeWithExtractableOutlookKeyText() throws Exception {
|
|
|
- ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
|
|
|
- OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator();
|
|
|
-
|
|
|
- String html = renderer.render(unmodeledOutlookContent(samplePayloadWithComplex(true)), Instant.parse("2026-01-03T08:00:00Z"));
|
|
|
- byte[] pdfBytes = pdfGenerator.generate(html);
|
|
|
-
|
|
|
- assertThat(pdfBytes).isNotEmpty();
|
|
|
- assertThat(new String(pdfBytes, 0, 4, StandardCharsets.ISO_8859_1)).isEqualTo("%PDF");
|
|
|
-
|
|
|
- Path previewPdfPath = Path.of(System.getProperty("user.dir"), "target", "outlook-report-demo.pdf");
|
|
|
- Files.createDirectories(previewPdfPath.getParent());
|
|
|
- Files.write(previewPdfPath, pdfBytes);
|
|
|
- assertThat(previewPdfPath).exists().isRegularFile();
|
|
|
-
|
|
|
- try (PDDocument document = PDDocument.load(pdfBytes)) {
|
|
|
- assertThat(document.getNumberOfPages()).isGreaterThanOrEqualTo(1);
|
|
|
- String normalizedText = new PDFTextStripper().getText(document).replaceAll("\\s+", "");
|
|
|
- assertThat(normalizedText)
|
|
|
- .contains("高考英语临考词汇突击潜力展望报告")
|
|
|
- .contains("模块一:个人学情分析")
|
|
|
- .contains("模块二:科学备考建议")
|
|
|
- .contains("模块三:上届学员提分案例")
|
|
|
- .contains("词频区间掌握度")
|
|
|
- .containsAnyOf("练习学案频率与提分规划", "建议策略")
|
|
|
- .contains("王雷宇")
|
|
|
- .contains("705词")
|
|
|
- .contains("237词")
|
|
|
- .contains("+19分")
|
|
|
- .containsAnyOf("高频词:56.7%(优先学习)", "高频词56.7%优先学习")
|
|
|
- .containsAnyOf("中频词:47.5%(重点突破)", "中频词47.5%重点突破")
|
|
|
- .containsAnyOf("低频词:30.0%(酌情学习)", "低频词30.0%酌情学习");
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 覆盖官方上游词汇 payload 在 Complex=false 时生成 Outlook PDF,应隐藏模块三案例文本。
|
|
|
- */
|
|
|
- @Test
|
|
|
- void generateCreatesPdfSmokeWithoutOutlookModuleThreeWhenComplexIsFalse() throws Exception {
|
|
|
- ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
|
|
|
- OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator();
|
|
|
-
|
|
|
- String html = renderer.render(unmodeledOutlookContent(samplePayloadWithComplex(false)), Instant.parse("2026-01-03T08:00:00Z"));
|
|
|
- byte[] pdfBytes = pdfGenerator.generate(html);
|
|
|
-
|
|
|
- assertThat(pdfBytes).isNotEmpty();
|
|
|
- assertThat(new String(pdfBytes, 0, 4, StandardCharsets.ISO_8859_1)).isEqualTo("%PDF");
|
|
|
-
|
|
|
- try (PDDocument document = PDDocument.load(pdfBytes)) {
|
|
|
- assertThat(document.getNumberOfPages()).isGreaterThanOrEqualTo(1);
|
|
|
- String normalizedText = new PDFTextStripper().getText(document).replaceAll("\\s+", "");
|
|
|
- assertThat(normalizedText)
|
|
|
- .contains("高考英语临考词汇突击潜力展望报告")
|
|
|
- .contains("模块一:个人学情分析")
|
|
|
- .contains("模块二:科学备考建议")
|
|
|
- .contains("词频区间掌握度")
|
|
|
- .doesNotContain("模块三:上届学员提分案例")
|
|
|
- .doesNotContain("王雷宇")
|
|
|
- .doesNotContain("+19分");
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 覆盖学习成果报告 HTML 生成 PDF 时,应产出可解析 PDF 并包含成果报告关键文本。
|
|
|
- */
|
|
|
- @Test
|
|
|
- void generateCreatesPdfSmokeWithExtractableAchievementKeyText() throws Exception {
|
|
|
- ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
|
|
|
- OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator();
|
|
|
-
|
|
|
- String html = renderer.render(sampleAchievementContent(), Instant.parse("2026-04-25T08:00:00Z"));
|
|
|
- byte[] pdfBytes = pdfGenerator.generate(html);
|
|
|
-
|
|
|
- assertThat(pdfBytes).isNotEmpty();
|
|
|
- assertThat(new String(pdfBytes, 0, 4, StandardCharsets.ISO_8859_1)).isEqualTo("%PDF");
|
|
|
-
|
|
|
- Path previewPdfPath = Path.of(System.getProperty("user.dir"), "target", "achievement-report-demo.pdf");
|
|
|
- Files.createDirectories(previewPdfPath.getParent());
|
|
|
- Files.write(previewPdfPath, pdfBytes);
|
|
|
- assertThat(previewPdfPath).exists().isRegularFile();
|
|
|
-
|
|
|
- try (PDDocument document = PDDocument.load(pdfBytes)) {
|
|
|
- assertThat(document.getNumberOfPages()).isGreaterThanOrEqualTo(1);
|
|
|
- String normalizedText = normalizePdfText(new PDFTextStripper().getText(document));
|
|
|
- assertThat(normalizedText)
|
|
|
- .contains("高考英语临考突击学习成果报告")
|
|
|
- .contains("模块一:词汇量对比")
|
|
|
- .contains("模块二:试卷熟词量对比")
|
|
|
- .contains("模块三:实考生词命中状况")
|
|
|
- .contains("词汇量提升(个)")
|
|
|
- .contains("试卷掌握度命中率")
|
|
|
- .contains("21.1%")
|
|
|
- .contains("0.48倍")
|
|
|
- .contains("高考词汇量")
|
|
|
- .contains("3500词")
|
|
|
- .contains("掌握率")
|
|
|
- .contains("66.51%")
|
|
|
- .contains("67.06%")
|
|
|
- .contains("试卷标题")
|
|
|
- .contains("2024真题")
|
|
|
- .contains("861词")
|
|
|
- .contains("75.49%")
|
|
|
- .contains("75.96%")
|
|
|
- .contains("207个")
|
|
|
- .contains("203个")
|
|
|
- .contains("4个")
|
|
|
- .containsAnyOf("number", "bear", "popular", "importance")
|
|
|
- .doesNotContain("真题生词命中率")
|
|
|
- .doesNotContain("1.93%");
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 覆盖系统字体候选为空时,PDF 生成器应使用随包 MiSans 字体输出可抽取中文文本。
|
|
|
- */
|
|
|
- @Test
|
|
|
- void generateUsesBundledMiSansWhenSystemFontCandidatesAreEmpty() throws Exception {
|
|
|
- OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator(
|
|
|
- BundledOutlookReportFonts::load,
|
|
|
- List.of());
|
|
|
-
|
|
|
- byte[] pdfBytes = pdfGenerator.generate("""
|
|
|
- <html>
|
|
|
- <head>
|
|
|
- <meta charset=\"UTF-8\"/>
|
|
|
- <style>
|
|
|
- body { font-family: MiSans; }
|
|
|
- </style>
|
|
|
- </head>
|
|
|
- <body>
|
|
|
- <p>MiSans 测试:临考冲刺 +19分</p>
|
|
|
- </body>
|
|
|
- </html>
|
|
|
- """);
|
|
|
-
|
|
|
- assertThat(pdfBytes).isNotEmpty();
|
|
|
- assertThat(new String(pdfBytes, 0, 4, StandardCharsets.ISO_8859_1)).isEqualTo("%PDF");
|
|
|
-
|
|
|
- try (PDDocument document = PDDocument.load(pdfBytes)) {
|
|
|
- assertThat(document.getNumberOfPages()).isGreaterThanOrEqualTo(1);
|
|
|
- assertThat(normalizePdfText(new PDFTextStripper().getText(document)))
|
|
|
- .contains("MiSans测试:临考冲刺+19分");
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 覆盖系统字体候选为空且 HTML 包含内联 SVG 中文时,AWT 注册的随包 MiSans 应支持 SVG 文本渲染。
|
|
|
- */
|
|
|
- @Test
|
|
|
- void generateRendersInlineSvgChineseTextWithAwtRegisteredMiSansWhenSystemFontCandidatesAreEmpty() throws Exception {
|
|
|
- OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator(
|
|
|
- BundledOutlookReportFonts::load,
|
|
|
- List.of());
|
|
|
-
|
|
|
- BundledOutlookReportFonts.load();
|
|
|
- assertThat(new Font("MiSans VF", Font.PLAIN, 24).canDisplayUpTo("掌握率")).isEqualTo(-1);
|
|
|
-
|
|
|
- byte[] pdfBytes = pdfGenerator.generate("""
|
|
|
- <html>
|
|
|
- <head>
|
|
|
- <meta charset=\"UTF-8\"/>
|
|
|
- <style>
|
|
|
- body { font-family: sans-serif; }
|
|
|
- </style>
|
|
|
- </head>
|
|
|
- <body>
|
|
|
- <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 220 80' width='220' height='80'
|
|
|
- font-family=\"'MiSans VF', MiSans, ReportFont, sans-serif\">
|
|
|
- <text x='20' y='48' font-size='24'>掌握率</text>
|
|
|
- </svg>
|
|
|
- </body>
|
|
|
- </html>
|
|
|
- """);
|
|
|
-
|
|
|
- assertThat(pdfBytes).isNotEmpty();
|
|
|
- try (PDDocument document = PDDocument.load(pdfBytes)) {
|
|
|
- assertThat(document.getNumberOfPages()).isEqualTo(1);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 覆盖随包字体 supplier 抛出非预期异常时,PDF 生成器应保留根因并向调用方报告生成失败。
|
|
|
- */
|
|
|
- @Test
|
|
|
- void generateDoesNotHideUnexpectedBundledFontSupplierFailures() {
|
|
|
- OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator(
|
|
|
- () -> { throw new IllegalArgumentException("broken supplier contract"); },
|
|
|
- List.of("/System/Library/Fonts/Supplemental/Arial Unicode.ttf"));
|
|
|
-
|
|
|
- assertThatThrownBy(() -> pdfGenerator.generate("<html><body>测试</body></html>"))
|
|
|
- .isInstanceOf(IllegalStateException.class)
|
|
|
- .hasMessageContaining("Failed to generate PDF")
|
|
|
- .hasRootCauseInstanceOf(IllegalArgumentException.class)
|
|
|
- .hasRootCauseMessage("broken supplier contract");
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 覆盖随包字体资源缺失时,PDF 生成器应回退系统字体并仍能生成 ASCII PDF。
|
|
|
- */
|
|
|
- @Test
|
|
|
- void generateFallsBackToSystemFontsWhenBundledFontResourceIsMissing() {
|
|
|
- OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator(
|
|
|
- () -> { throw new BundledOutlookReportFonts.BundledFontMissingException("missing bundled font"); },
|
|
|
- List.of());
|
|
|
-
|
|
|
- byte[] pdfBytes = pdfGenerator.generate("<html><body>ASCII fallback smoke</body></html>");
|
|
|
-
|
|
|
- assertThat(pdfBytes).isNotEmpty();
|
|
|
- assertThat(new String(pdfBytes, 0, 4, StandardCharsets.ISO_8859_1)).isEqualTo("%PDF");
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * 覆盖随包字体注册失败时,PDF 生成器不应静默回退而应报告生成失败。
|
|
|
- */
|
|
|
- @Test
|
|
|
- void generateDoesNotFallbackWhenBundledFontRegistrationFails() {
|
|
|
- OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator(
|
|
|
- () -> { throw new BundledOutlookReportFonts.BundledFontUnavailableException("invalid bundled font"); },
|
|
|
- List.of());
|
|
|
-
|
|
|
- assertThatThrownBy(() -> pdfGenerator.generate("<html><body>测试</body></html>"))
|
|
|
- .isInstanceOf(IllegalStateException.class)
|
|
|
- .hasMessageContaining("Failed to generate PDF")
|
|
|
- .hasRootCauseInstanceOf(BundledOutlookReportFonts.BundledFontUnavailableException.class)
|
|
|
- .hasRootCauseMessage("invalid bundled font");
|
|
|
- }
|
|
|
-
|
|
|
- private String normalizePdfText(String text) {
|
|
|
- return Normalizer.normalize(text, Normalizer.Form.NFKC).replaceAll("\\s+", "");
|
|
|
- }
|
|
|
-
|
|
|
- private UnmodeledReportContent unmodeledOutlookContent(JsonNode payload) {
|
|
|
- return new UnmodeledReportContent(ReportType.OUTLOOK, payload);
|
|
|
- }
|
|
|
-
|
|
|
- private AchievementReportContent sampleAchievementContent() {
|
|
|
- return new AchievementReportContent(
|
|
|
- "吴泓妤",
|
|
|
- "高考英语临考突击学习成果报告",
|
|
|
- "2024真题 · 两周专项训练 · 真实提分效果",
|
|
|
- "恭喜完成两周考前突击专项训练",
|
|
|
- "基于2024英语真题试卷 · 真实学习效果分析",
|
|
|
- new AchievementReportContent.SummaryMetrics(
|
|
|
- "+19",
|
|
|
- "+4",
|
|
|
- "21.1%",
|
|
|
- "0.48"),
|
|
|
- new AchievementReportContent.Comparison(2328.0, 2347.0, "2328", "2347", "+19"),
|
|
|
- new AchievementReportContent.Comparison(650.0, 654.0, "650", "654", "+4"),
|
|
|
- new AchievementReportContent.StageVocabularySummary("高考", "3500", "66.51", "67.06", "+0.55"),
|
|
|
- new AchievementReportContent.TestPaperVocabularySummary("2024真题", "861", "207", "203", "75.49", "75.96", "+0.62"),
|
|
|
- new AchievementReportContent.ExamUnknownWordsHitStatus(
|
|
|
- "21.1%",
|
|
|
- "0.48",
|
|
|
- "207",
|
|
|
- "203",
|
|
|
- "4",
|
|
|
- List.of("number", "bear", "popular", "importance")));
|
|
|
- }
|
|
|
-
|
|
|
- private JsonNode samplePayload() throws Exception {
|
|
|
- return OBJECT_MAPPER.readTree("""
|
|
|
- {
|
|
|
- "StudentName": "20260318测试",
|
|
|
- "StudentStage": 2,
|
|
|
- "StageName": "初中",
|
|
|
- "StageVocabulary": 10,
|
|
|
- "StudentVocabulary": 4,
|
|
|
- "StageExaminName": "中考",
|
|
|
- "StageImportant": 3,
|
|
|
- "StudentWordsLatest": [
|
|
|
- {"WordId": 1, "MeanId": 1, "WordSpell": "w1", "WordFrequency": 1, "Mastery": 1.0, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
|
|
|
- {"WordId": 2, "MeanId": 1, "WordSpell": "w2", "WordFrequency": 2, "Mastery": 0.5, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
|
|
|
- {"WordId": 3, "MeanId": 1, "WordSpell": "w3", "WordFrequency": 3, "Mastery": 0.2, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
|
|
|
- {"WordId": 4, "MeanId": 1, "WordSpell": "w4", "WordFrequency": 4, "Mastery": 0.4, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
|
|
|
- {"WordId": 5, "MeanId": 1, "WordSpell": "w5", "WordFrequency": 5, "Mastery": 0.6, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
|
|
|
- {"WordId": 6, "MeanId": 1, "WordSpell": "w6", "WordFrequency": 6, "Mastery": 0.8, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
|
|
|
- {"WordId": 7, "MeanId": 1, "WordSpell": "w7", "WordFrequency": 7, "Mastery": 0.1, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
|
|
|
- {"WordId": 8, "MeanId": 1, "WordSpell": "w8", "WordFrequency": 8, "Mastery": 0.2, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
|
|
|
- {"WordId": 9, "MeanId": 1, "WordSpell": "w9", "WordFrequency": 9, "Mastery": 0.3, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
|
|
|
- {"WordId": 10, "MeanId": 1, "WordSpell": "w10", "WordFrequency": 10, "Mastery": 0.4, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"}
|
|
|
- ],
|
|
|
- "MastedWordCount": 4,
|
|
|
- "UnMastedWordCount": 6,
|
|
|
- "ExamineStrangeWordCount": 3,
|
|
|
- "TestPaperWordIdArray": [1, 2, 3, 4, 5],
|
|
|
- "TestPaperTitle": "文章2.jpg",
|
|
|
- "TestPaperUnMasterWordCount": 3,
|
|
|
- "TestPaperMastedWordCount": 2,
|
|
|
- "TestPaperWordCount": 5,
|
|
|
- "Complex": false
|
|
|
- }
|
|
|
- """);
|
|
|
- }
|
|
|
-
|
|
|
- private JsonNode samplePayloadWithComplex(boolean complex) throws Exception {
|
|
|
- ObjectNode payload = (ObjectNode) samplePayload();
|
|
|
- payload.put("Complex", complex);
|
|
|
- return payload;
|
|
|
- }
|
|
|
-
|
|
|
-}
|