|
|
@@ -0,0 +1,229 @@
|
|
|
+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.AfterAll;
|
|
|
+import org.junit.jupiter.api.BeforeAll;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.junit.jupiter.api.TestInstance;
|
|
|
+
|
|
|
+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.assertThatCode;
|
|
|
+
|
|
|
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
|
|
+class PlaywrightExamSprintReportPdfGeneratorTest {
|
|
|
+
|
|
|
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
|
|
+
|
|
|
+ private PlaywrightExamSprintReportPdfGenerator pdfGenerator;
|
|
|
+
|
|
|
+ @BeforeAll
|
|
|
+ void createPdfGenerator() {
|
|
|
+ pdfGenerator = new PlaywrightExamSprintReportPdfGenerator();
|
|
|
+ }
|
|
|
+
|
|
|
+ @AfterAll
|
|
|
+ void closePdfGenerator() {
|
|
|
+ if (pdfGenerator != null) {
|
|
|
+ pdfGenerator.close();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void constructorDoesNotLaunchChromiumBeforeFirstGenerate() {
|
|
|
+ assertThatCode(() -> {
|
|
|
+ try (PlaywrightExamSprintReportPdfGenerator generator = new PlaywrightExamSprintReportPdfGenerator(
|
|
|
+ BundledOutlookReportFonts::load,
|
|
|
+ 1,
|
|
|
+ 1)) {
|
|
|
+ // Constructor should not require a browser launch; Chromium is initialized lazily by generate().
|
|
|
+ }
|
|
|
+ }).doesNotThrowAnyException();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void generateCreatesPdfWithExtractableChineseText() throws Exception {
|
|
|
+ byte[] pdfBytes = pdfGenerator.generate("""
|
|
|
+ <html>
|
|
|
+ <head>
|
|
|
+ <meta charset=\"UTF-8\"/>
|
|
|
+ <style>
|
|
|
+ body { font-family: MiSans, ReportFont, sans-serif; }
|
|
|
+ </style>
|
|
|
+ </head>
|
|
|
+ <body>
|
|
|
+ <p>Playwright 测试:临考冲刺 +19分</p>
|
|
|
+ </body>
|
|
|
+ </html>
|
|
|
+ """);
|
|
|
+
|
|
|
+ assertPdfHeader(pdfBytes);
|
|
|
+ try (PDDocument document = PDDocument.load(pdfBytes)) {
|
|
|
+ assertThat(document.getNumberOfPages()).isGreaterThanOrEqualTo(1);
|
|
|
+ assertThat(normalizePdfText(new PDFTextStripper().getText(document)))
|
|
|
+ .contains("Playwright测试:临考冲刺+19分");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void generateRendersInlineSvgChineseText() throws Exception {
|
|
|
+ byte[] pdfBytes = pdfGenerator.generate("""
|
|
|
+ <html>
|
|
|
+ <head>
|
|
|
+ <meta charset=\"UTF-8\"/>
|
|
|
+ </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>
|
|
|
+ """);
|
|
|
+
|
|
|
+ assertPdfHeader(pdfBytes);
|
|
|
+ try (PDDocument document = PDDocument.load(pdfBytes)) {
|
|
|
+ assertThat(document.getNumberOfPages()).isEqualTo(1);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void generateCreatesReadablePdfForOutlookReportTemplate() throws Exception {
|
|
|
+ ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
|
|
|
+ String html = renderer.render(
|
|
|
+ new UnmodeledReportContent(ReportType.OUTLOOK, samplePayloadWithComplex(false)),
|
|
|
+ Instant.parse("2026-01-03T08:00:00Z"));
|
|
|
+
|
|
|
+ byte[] pdfBytes = pdfGenerator.generate(html);
|
|
|
+
|
|
|
+ assertPdfHeader(pdfBytes);
|
|
|
+ Path previewPdfPath = Path.of(System.getProperty("user.dir"), "target", "playwright-outlook-report-demo.pdf");
|
|
|
+ Files.deleteIfExists(previewPdfPath);
|
|
|
+ writePreviewPdf(previewPdfPath, pdfBytes);
|
|
|
+ assertThat(previewPdfPath).exists().isRegularFile();
|
|
|
+ try (PDDocument document = PDDocument.load(pdfBytes)) {
|
|
|
+ assertThat(document.getNumberOfPages()).isGreaterThanOrEqualTo(1);
|
|
|
+ assertThat(normalizePdfText(new PDFTextStripper().getText(document)))
|
|
|
+ .contains("高考英语临考词汇突击潜力展望报告")
|
|
|
+ .contains("模块一:个人学情分析")
|
|
|
+ .contains("模块二:科学备考建议");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void generateCreatesReadablePdfForAchievementReportTemplate() throws Exception {
|
|
|
+ ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
|
|
|
+ String html = renderer.render(sampleAchievementContent(), Instant.parse("2026-04-25T08:00:00Z"));
|
|
|
+
|
|
|
+ byte[] pdfBytes = pdfGenerator.generate(html);
|
|
|
+
|
|
|
+ assertPdfHeader(pdfBytes);
|
|
|
+ Path previewPdfPath = Path.of(System.getProperty("user.dir"), "target", "playwright-achievement-report-demo.pdf");
|
|
|
+ Files.deleteIfExists(previewPdfPath);
|
|
|
+ writePreviewPdf(previewPdfPath, pdfBytes);
|
|
|
+ assertThat(previewPdfPath).exists().isRegularFile();
|
|
|
+ try (PDDocument document = PDDocument.load(pdfBytes)) {
|
|
|
+ assertThat(document.getNumberOfPages()).isGreaterThanOrEqualTo(1);
|
|
|
+ assertThat(normalizePdfText(new PDFTextStripper().getText(document)))
|
|
|
+ .contains("高考英语临考突击学习成果报告")
|
|
|
+ .contains("模块一:词汇量对比")
|
|
|
+ .contains("模块二:试卷熟词量对比")
|
|
|
+ .contains("模块三:实考生词命中状况");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void assertPdfHeader(byte[] pdfBytes) {
|
|
|
+ assertThat(pdfBytes).isNotEmpty();
|
|
|
+ assertThat(new String(pdfBytes, 0, 4, StandardCharsets.ISO_8859_1)).isEqualTo("%PDF");
|
|
|
+ }
|
|
|
+
|
|
|
+ private void writePreviewPdf(Path previewPdfPath, byte[] pdfBytes) throws Exception {
|
|
|
+ Files.createDirectories(previewPdfPath.getParent());
|
|
|
+ Files.write(previewPdfPath, pdfBytes);
|
|
|
+ }
|
|
|
+
|
|
|
+ private String normalizePdfText(String text) {
|
|
|
+ return Normalizer.normalize(text, Normalizer.Form.NFKC).replaceAll("\\s+", "");
|
|
|
+ }
|
|
|
+
|
|
|
+ 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;
|
|
|
+ }
|
|
|
+}
|