|
@@ -4,9 +4,11 @@ import cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.ou
|
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
import org.apache.pdfbox.pdmodel.PDDocument;
|
|
import org.apache.pdfbox.pdmodel.PDDocument;
|
|
|
|
|
+import org.apache.pdfbox.rendering.PDFRenderer;
|
|
|
import org.apache.pdfbox.text.PDFTextStripper;
|
|
import org.apache.pdfbox.text.PDFTextStripper;
|
|
|
import org.junit.jupiter.api.Test;
|
|
import org.junit.jupiter.api.Test;
|
|
|
|
|
|
|
|
|
|
+import java.awt.image.BufferedImage;
|
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.nio.charset.StandardCharsets;
|
|
|
import java.time.Instant;
|
|
import java.time.Instant;
|
|
|
|
|
|
|
@@ -15,6 +17,20 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|
|
class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
|
|
class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
|
|
|
|
|
|
|
|
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
|
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
|
|
|
|
+ private static final int MODULE_ONE_PAGE_INDEX = 0;
|
|
|
|
|
+ private static final double MODULE_ONE_FIRST_ROW_HEIGHT_RATIO = 0.33;
|
|
|
|
|
+ private static final long FIRST_ROW_CHART_DIFFERENCE_THRESHOLD = 15_000L;
|
|
|
|
|
+ private static final double MODULE_ONE_LOWER_HALF_START_RATIO = 0.20;
|
|
|
|
|
+ private static final double MODULE_ONE_LOWER_HALF_END_RATIO = 0.70;
|
|
|
|
|
+ private static final long LOWER_HALF_CHART_DIFFERENCE_THRESHOLD = 2_000L;
|
|
|
|
|
+ private static final double SECOND_CARD_AREA_START_X_RATIO = 0.50;
|
|
|
|
|
+ private static final double SECOND_CARD_AREA_END_X_RATIO = 0.90;
|
|
|
|
|
+ private static final long SECOND_CARD_COLUMN_DIFFERENCE_THRESHOLD = 3_500L;
|
|
|
|
|
+ private static final double MODULE_THREE_START_X_RATIO = 0.00;
|
|
|
|
|
+ private static final double MODULE_THREE_END_X_RATIO = 1.00;
|
|
|
|
|
+ private static final double MODULE_THREE_START_Y_RATIO = 0.00;
|
|
|
|
|
+ private static final double MODULE_THREE_END_Y_RATIO = 1.00;
|
|
|
|
|
+ private static final long MODULE_THREE_VISUAL_DIFFERENCE_THRESHOLD = 8_000L;
|
|
|
|
|
|
|
|
@Test
|
|
@Test
|
|
|
void generateCreatesPdfWithExtractableOutlookKeyText() throws Exception {
|
|
void generateCreatesPdfWithExtractableOutlookKeyText() throws Exception {
|
|
@@ -29,74 +45,419 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
|
|
|
|
|
|
|
|
try (PDDocument document = PDDocument.load(pdfBytes)) {
|
|
try (PDDocument document = PDDocument.load(pdfBytes)) {
|
|
|
String normalizedText = new PDFTextStripper().getText(document).replaceAll("\\s+", "");
|
|
String normalizedText = new PDFTextStripper().getText(document).replaceAll("\\s+", "");
|
|
|
- assertThat(normalizedText).contains("2026词汇展望报告");
|
|
|
|
|
|
|
+ assertThat(normalizedText).contains("高考英语临考词汇突击潜力展望报告");
|
|
|
|
|
+ assertThat(normalizedText).contains("模块一:个人学情分析");
|
|
|
|
|
+ assertThat(normalizedText).contains("模块二:科学备考建议");
|
|
|
|
|
+ assertThat(normalizedText).contains("模块三:上届学员提分案例");
|
|
|
|
|
+ assertThat(normalizedText).contains("学生:李同学");
|
|
|
|
|
+ assertThat(normalizedText).contains("目标考试:春季高考英语");
|
|
|
|
|
+ assertThat(normalizedText).contains("冲刺周期:30天考前冲刺");
|
|
|
|
|
+ assertThat(normalizedText).contains("基础较稳,具备短期冲刺提分空间。");
|
|
|
|
|
+ assertThat(normalizedText).contains("核心观察:高频与常考词群是提分关键。");
|
|
|
assertThat(normalizedText).contains("常考词汇掌握情况");
|
|
assertThat(normalizedText).contains("常考词汇掌握情况");
|
|
|
- assertThat(normalizedText).contains("7天提分冲刺");
|
|
|
|
|
|
|
+ assertThat(normalizedText).contains("1套/周");
|
|
|
|
|
+ assertThat(normalizedText).contains("3套/周");
|
|
|
|
|
+ assertThat(normalizedText).containsAnyOf("💡建议策略", "建议策略");
|
|
|
|
|
+ assertThat(normalizedText).containsAnyOf("考前半个月核心突击期", "考前半个月·核心突击期");
|
|
|
|
|
+ assertThat(normalizedText).containsAnyOf("考前半小时临阵巩固期", "考前半小时·临阵巩固期");
|
|
|
|
|
+ assertThat(normalizedText).containsAnyOf("拉分词是提分核心突破项", "预计提分5-15分");
|
|
|
|
|
+ assertThat(normalizedText).containsAnyOf("真实提分·效果可复制", "真实提分效果可复制");
|
|
|
|
|
+ assertThat(normalizedText).contains("记忆词汇:705词");
|
|
|
|
|
+ assertThat(normalizedText).contains("高考命中:237词");
|
|
|
|
|
+ assertThat(normalizedText).containsAnyOf("提升分数:+19分", "提升分数:19分", "+19分");
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void generatePlacesModuleOneTitleAndFirstRowOnTheFirstPage() throws Exception {
|
|
|
|
|
+ ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
|
|
|
|
|
+ OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator();
|
|
|
|
|
+
|
|
|
|
|
+ String html = renderer.render(samplePayload(), Instant.parse("2026-01-03T08:00:00Z"));
|
|
|
|
|
+ byte[] pdfBytes = pdfGenerator.generate(html);
|
|
|
|
|
+
|
|
|
|
|
+ try (PDDocument document = PDDocument.load(pdfBytes)) {
|
|
|
|
|
+ PDFTextStripper textStripper = new PDFTextStripper();
|
|
|
|
|
+ textStripper.setStartPage(1);
|
|
|
|
|
+ textStripper.setEndPage(1);
|
|
|
|
|
+ String firstPageText = textStripper.getText(document).replaceAll("\\s+", "");
|
|
|
|
|
+
|
|
|
|
|
+ assertThat(firstPageText)
|
|
|
|
|
+ .as("module-one title and first-row content should appear on the first PDF page")
|
|
|
|
|
+ .contains("模块一:个人学情分析")
|
|
|
|
|
+ .contains("考纲词汇掌握情况")
|
|
|
|
|
+ .contains("真题试卷词汇掌握情况");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void generateKeepsModuleTwoTitleAndFirstCoreContentOnSamePage() throws Exception {
|
|
|
|
|
+ ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
|
|
|
|
|
+ OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator();
|
|
|
|
|
+
|
|
|
|
|
+ String html = renderer.render(samplePayload(), Instant.parse("2026-01-03T08:00:00Z"));
|
|
|
|
|
+ byte[] pdfBytes = pdfGenerator.generate(html);
|
|
|
|
|
+
|
|
|
|
|
+ try (PDDocument document = PDDocument.load(pdfBytes)) {
|
|
|
|
|
+ PDFTextStripper textStripper = new PDFTextStripper();
|
|
|
|
|
+ boolean foundSharedPage = false;
|
|
|
|
|
+
|
|
|
|
|
+ for (int pageIndex = 0; pageIndex < document.getNumberOfPages(); pageIndex++) {
|
|
|
|
|
+ textStripper.setStartPage(pageIndex + 1);
|
|
|
|
|
+ textStripper.setEndPage(pageIndex + 1);
|
|
|
|
|
+ String pageText = textStripper.getText(document).replaceAll("\\s+", "");
|
|
|
|
|
+ boolean hasModuleTwoFirstCoreContent = pageText.contains("💡建议策略")
|
|
|
|
|
+ || pageText.contains("建议策略")
|
|
|
|
|
+ || pageText.contains("1套/周");
|
|
|
|
|
+
|
|
|
|
|
+ if (pageText.contains("模块二:科学备考建议") && hasModuleTwoFirstCoreContent) {
|
|
|
|
|
+ foundSharedPage = true;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ assertThat(foundSharedPage)
|
|
|
|
|
+ .as("module-two title and first core content should appear on the same PDF page")
|
|
|
|
|
+ .isTrue();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void generateRendersFirstRowModuleOneChartsDifferentlyFromEmptySvgShells() throws Exception {
|
|
|
|
|
+ ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
|
|
|
|
|
+ OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator();
|
|
|
|
|
+
|
|
|
|
|
+ String html = renderer.render(samplePayload(), Instant.parse("2026-01-03T08:00:00Z"));
|
|
|
|
|
+ String emptyFirstRowHtml = stripFirstRowModuleOneDataShapes(html);
|
|
|
|
|
+ byte[] chartPdfBytes = pdfGenerator.generate(html);
|
|
|
|
|
+ byte[] emptyFirstRowPdfBytes = pdfGenerator.generate(emptyFirstRowHtml);
|
|
|
|
|
+
|
|
|
|
|
+ try (PDDocument chartDocument = PDDocument.load(chartPdfBytes);
|
|
|
|
|
+ PDDocument emptyDocument = PDDocument.load(emptyFirstRowPdfBytes)) {
|
|
|
|
|
+ BufferedImage chartPage = new PDFRenderer(chartDocument).renderImageWithDPI(MODULE_ONE_PAGE_INDEX, 144);
|
|
|
|
|
+ BufferedImage emptyPage = new PDFRenderer(emptyDocument).renderImageWithDPI(MODULE_ONE_PAGE_INDEX, 144);
|
|
|
|
|
+
|
|
|
|
|
+ long differentPixels = countDifferentPixelsInRectangle(chartPage, emptyPage, 0.0, 1.0, 0.0, 1.0);
|
|
|
|
|
+
|
|
|
|
|
+ assertThat(differentPixels)
|
|
|
|
|
+ .as("first-row module-one charts should visibly affect rendered PDF pixels")
|
|
|
|
|
+ .isGreaterThan(FIRST_ROW_CHART_DIFFERENCE_THRESHOLD);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void generateRendersPastPaperColumnsDifferentlyFromColumnlessScaffolding() throws Exception {
|
|
|
|
|
+ ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
|
|
|
|
|
+ OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator();
|
|
|
|
|
+
|
|
|
|
|
+ String html = renderer.render(samplePayload(), Instant.parse("2026-01-03T08:00:00Z"));
|
|
|
|
|
+ String withoutColumnShapesHtml = stripPastPaperColumnShapes(html);
|
|
|
|
|
+ byte[] chartPdfBytes = pdfGenerator.generate(html);
|
|
|
|
|
+ byte[] noColumnsPdfBytes = pdfGenerator.generate(withoutColumnShapesHtml);
|
|
|
|
|
+
|
|
|
|
|
+ try (PDDocument chartDocument = PDDocument.load(chartPdfBytes);
|
|
|
|
|
+ PDDocument noColumnsDocument = PDDocument.load(noColumnsPdfBytes)) {
|
|
|
|
|
+ BufferedImage chartPage = new PDFRenderer(chartDocument).renderImageWithDPI(MODULE_ONE_PAGE_INDEX, 144);
|
|
|
|
|
+ BufferedImage noColumnsPage = new PDFRenderer(noColumnsDocument).renderImageWithDPI(MODULE_ONE_PAGE_INDEX, 144);
|
|
|
|
|
+
|
|
|
|
|
+ long differentPixels = countDifferentPixelsInRectangle(chartPage, noColumnsPage, 0.0, 1.0, 0.0, 1.0);
|
|
|
|
|
+
|
|
|
|
|
+ assertThat(differentPixels)
|
|
|
|
|
+ .as("past-paper data columns should visibly affect rendered PDF pixels")
|
|
|
|
|
+ .isGreaterThan(SECOND_CARD_COLUMN_DIFFERENCE_THRESHOLD);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void generateRendersLowerHalfModuleOneChartsDifferentlyFromColumnlessScaffolding() throws Exception {
|
|
|
|
|
+ ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
|
|
|
|
|
+ OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator();
|
|
|
|
|
+
|
|
|
|
|
+ String html = renderer.render(samplePayload(), Instant.parse("2026-01-03T08:00:00Z"));
|
|
|
|
|
+ String strippedHtml = stripLowerHalfModuleOneColumnShapes(html);
|
|
|
|
|
+ byte[] chartPdfBytes = pdfGenerator.generate(html);
|
|
|
|
|
+ byte[] strippedPdfBytes = pdfGenerator.generate(strippedHtml);
|
|
|
|
|
+
|
|
|
|
|
+ try (PDDocument chartDocument = PDDocument.load(chartPdfBytes);
|
|
|
|
|
+ PDDocument strippedDocument = PDDocument.load(strippedPdfBytes)) {
|
|
|
|
|
+ int chartModuleOnePageIndex = findPageIndexContainingText(chartDocument, "词频区间掌握度");
|
|
|
|
|
+ int strippedModuleOnePageIndex = findPageIndexContainingText(strippedDocument, "词频区间掌握度");
|
|
|
|
|
+
|
|
|
|
|
+ assertThat(strippedModuleOnePageIndex)
|
|
|
|
|
+ .as("module-one lower-half content should stay on the same page after stripping column shapes")
|
|
|
|
|
+ .isEqualTo(chartModuleOnePageIndex);
|
|
|
|
|
+
|
|
|
|
|
+ BufferedImage chartPage = new PDFRenderer(chartDocument).renderImageWithDPI(chartModuleOnePageIndex, 144);
|
|
|
|
|
+ BufferedImage strippedPage = new PDFRenderer(strippedDocument).renderImageWithDPI(strippedModuleOnePageIndex, 144);
|
|
|
|
|
+
|
|
|
|
|
+ long differentPixels = countDifferentPixelsInRectangle(chartPage, strippedPage, 0.0, 1.0, 0.0, 1.0);
|
|
|
|
|
+
|
|
|
|
|
+ assertThat(differentPixels)
|
|
|
|
|
+ .as("lower-half module-one charts should visibly affect rendered PDF pixels")
|
|
|
|
|
+ .isGreaterThan(LOWER_HALF_CHART_DIFFERENCE_THRESHOLD);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void generateRendersModuleThreeVisualDifferentlyFromVisualFreeShell() throws Exception {
|
|
|
|
|
+ ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
|
|
|
|
|
+ OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator();
|
|
|
|
|
+
|
|
|
|
|
+ String html = renderer.render(samplePayload(), Instant.parse("2026-01-03T08:00:00Z"));
|
|
|
|
|
+ String strippedHtml = stripModuleThreeVisual(html);
|
|
|
|
|
+ byte[] visualPdfBytes = pdfGenerator.generate(html);
|
|
|
|
|
+ byte[] strippedPdfBytes = pdfGenerator.generate(strippedHtml);
|
|
|
|
|
+
|
|
|
|
|
+ try (PDDocument visualDocument = PDDocument.load(visualPdfBytes);
|
|
|
|
|
+ PDDocument strippedDocument = PDDocument.load(strippedPdfBytes)) {
|
|
|
|
|
+ int visualModuleThreePageIndex = findPageIndexContainingText(visualDocument, "模块三:上届学员提分案例");
|
|
|
|
|
+ int strippedModuleThreePageIndex = findPageIndexContainingText(strippedDocument, "模块三:上届学员提分案例");
|
|
|
|
|
+
|
|
|
|
|
+ assertThat(strippedModuleThreePageIndex)
|
|
|
|
|
+ .as("module-three title should stay on the same page after stripping visual shell")
|
|
|
|
|
+ .isEqualTo(visualModuleThreePageIndex);
|
|
|
|
|
+
|
|
|
|
|
+ BufferedImage visualPage = new PDFRenderer(visualDocument).renderImageWithDPI(visualModuleThreePageIndex, 144);
|
|
|
|
|
+ BufferedImage strippedPage = new PDFRenderer(strippedDocument).renderImageWithDPI(strippedModuleThreePageIndex, 144);
|
|
|
|
|
+
|
|
|
|
|
+ long differentPixels = countDifferentPixelsInRectangle(
|
|
|
|
|
+ visualPage,
|
|
|
|
|
+ strippedPage,
|
|
|
|
|
+ MODULE_THREE_START_X_RATIO,
|
|
|
|
|
+ MODULE_THREE_END_X_RATIO,
|
|
|
|
|
+ MODULE_THREE_START_Y_RATIO,
|
|
|
|
|
+ MODULE_THREE_END_Y_RATIO);
|
|
|
|
|
+
|
|
|
|
|
+ assertThat(differentPixels)
|
|
|
|
|
+ .as("module-three visual should visibly affect rendered PDF pixels")
|
|
|
|
|
+ .isGreaterThan(MODULE_THREE_VISUAL_DIFFERENCE_THRESHOLD);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private long countDifferentPixelsInTopArea(BufferedImage left, BufferedImage right, double heightRatio) {
|
|
|
|
|
+ assertThat(left.getWidth()).isEqualTo(right.getWidth());
|
|
|
|
|
+ assertThat(left.getHeight()).isEqualTo(right.getHeight());
|
|
|
|
|
+
|
|
|
|
|
+ int compareHeight = (int) Math.round(left.getHeight() * heightRatio);
|
|
|
|
|
+ long differentPixels = 0L;
|
|
|
|
|
+ for (int y = 0; y < compareHeight; y++) {
|
|
|
|
|
+ for (int x = 0; x < left.getWidth(); x++) {
|
|
|
|
|
+ if (left.getRGB(x, y) != right.getRGB(x, y)) {
|
|
|
|
|
+ differentPixels++;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return differentPixels;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private int findPageIndexContainingText(PDDocument document, String expectedText) throws Exception {
|
|
|
|
|
+ PDFTextStripper textStripper = new PDFTextStripper();
|
|
|
|
|
+ String normalizedExpectedText = expectedText.replaceAll("\\s+", "");
|
|
|
|
|
+
|
|
|
|
|
+ for (int pageIndex = 0; pageIndex < document.getNumberOfPages(); pageIndex++) {
|
|
|
|
|
+ textStripper.setStartPage(pageIndex + 1);
|
|
|
|
|
+ textStripper.setEndPage(pageIndex + 1);
|
|
|
|
|
+ String pageText = textStripper.getText(document).replaceAll("\\s+", "");
|
|
|
|
|
+ if (pageText.contains(normalizedExpectedText)) {
|
|
|
|
|
+ return pageIndex;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ throw new AssertionError("expected PDF page containing text: " + expectedText);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private long countDifferentPixelsInArea(BufferedImage left,
|
|
|
|
|
+ BufferedImage right,
|
|
|
|
|
+ double startXRatio,
|
|
|
|
|
+ double endXRatio,
|
|
|
|
|
+ double heightRatio) {
|
|
|
|
|
+ assertThat(left.getWidth()).isEqualTo(right.getWidth());
|
|
|
|
|
+ assertThat(left.getHeight()).isEqualTo(right.getHeight());
|
|
|
|
|
+
|
|
|
|
|
+ int startX = (int) Math.round(left.getWidth() * startXRatio);
|
|
|
|
|
+ int endX = (int) Math.round(left.getWidth() * endXRatio);
|
|
|
|
|
+ int compareHeight = (int) Math.round(left.getHeight() * heightRatio);
|
|
|
|
|
+ long differentPixels = 0L;
|
|
|
|
|
+ for (int y = 0; y < compareHeight; y++) {
|
|
|
|
|
+ for (int x = startX; x < endX; x++) {
|
|
|
|
|
+ if (left.getRGB(x, y) != right.getRGB(x, y)) {
|
|
|
|
|
+ differentPixels++;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return differentPixels;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private long countDifferentPixelsInVerticalBand(BufferedImage left,
|
|
|
|
|
+ BufferedImage right,
|
|
|
|
|
+ double startYRatio,
|
|
|
|
|
+ double endYRatio) {
|
|
|
|
|
+ assertThat(left.getWidth()).isEqualTo(right.getWidth());
|
|
|
|
|
+ assertThat(left.getHeight()).isEqualTo(right.getHeight());
|
|
|
|
|
+
|
|
|
|
|
+ int startY = (int) Math.round(left.getHeight() * startYRatio);
|
|
|
|
|
+ int endY = (int) Math.round(left.getHeight() * endYRatio);
|
|
|
|
|
+ long differentPixels = 0L;
|
|
|
|
|
+ for (int y = startY; y < endY; y++) {
|
|
|
|
|
+ for (int x = 0; x < left.getWidth(); x++) {
|
|
|
|
|
+ if (left.getRGB(x, y) != right.getRGB(x, y)) {
|
|
|
|
|
+ differentPixels++;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return differentPixels;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private long countDifferentPixelsInRectangle(BufferedImage left,
|
|
|
|
|
+ BufferedImage right,
|
|
|
|
|
+ double startXRatio,
|
|
|
|
|
+ double endXRatio,
|
|
|
|
|
+ double startYRatio,
|
|
|
|
|
+ double endYRatio) {
|
|
|
|
|
+ assertThat(left.getWidth()).isEqualTo(right.getWidth());
|
|
|
|
|
+ assertThat(left.getHeight()).isEqualTo(right.getHeight());
|
|
|
|
|
+
|
|
|
|
|
+ int startX = (int) Math.round(left.getWidth() * startXRatio);
|
|
|
|
|
+ int endX = (int) Math.round(left.getWidth() * endXRatio);
|
|
|
|
|
+ int startY = (int) Math.round(left.getHeight() * startYRatio);
|
|
|
|
|
+ int endY = (int) Math.round(left.getHeight() * endYRatio);
|
|
|
|
|
+ long differentPixels = 0L;
|
|
|
|
|
+ for (int y = startY; y < endY; y++) {
|
|
|
|
|
+ for (int x = startX; x < endX; x++) {
|
|
|
|
|
+ if (left.getRGB(x, y) != right.getRGB(x, y)) {
|
|
|
|
|
+ differentPixels++;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return differentPixels;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String stripFirstRowModuleOneDataShapes(String html) {
|
|
|
|
|
+ String stripped = stripRequiredFragment(html, "<path class='donut-mastered-arc'", "donut mastered arc");
|
|
|
|
|
+ stripped = stripRequiredFragment(stripped, "<path class='donut-unmastered-arc'", "donut unmastered arc");
|
|
|
|
|
+ stripped = stripRequiredFragment(stripped, "<rect class='chart-column total-column'", "total column");
|
|
|
|
|
+ return stripRequiredFragment(stripped, "<rect class='chart-column unknown-column'", "unknown column");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String stripPastPaperColumnShapes(String html) {
|
|
|
|
|
+ String stripped = stripRequiredFragment(html, "<rect class='chart-column total-column'", "total column");
|
|
|
|
|
+ return stripRequiredFragment(stripped, "<rect class='chart-column unknown-column'", "unknown column");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String stripLowerHalfModuleOneColumnShapes(String html) {
|
|
|
|
|
+ String stripped = stripRequiredFragment(html, "<rect class='chart-column basic-core-column'", "basic core column");
|
|
|
|
|
+ stripped = stripRequiredFragment(stripped, "<rect class='chart-column high-score-column'", "high score column");
|
|
|
|
|
+ stripped = stripRequiredFragment(stripped, "<rect class='chart-column high-band-column'", "high band column");
|
|
|
|
|
+ stripped = stripRequiredFragment(stripped, "<rect class='chart-column mid-band-column'", "mid band column");
|
|
|
|
|
+ return stripRequiredFragment(stripped, "<rect class='chart-column low-band-column'", "low band column");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String stripModuleThreeVisual(String html) {
|
|
|
|
|
+ String stripped = stripRequiredContainerFragment(html, "<svg class='case-study-visual-chart'", "</svg>", "module three visual chart");
|
|
|
|
|
+ return stripRequiredContainerFragment(stripped, "<div class='case-hit-rate-badge'", "</div>", "module three hit-rate badge");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String stripRequiredFragment(String html, String startToken, String description) {
|
|
|
|
|
+ assertThat(html)
|
|
|
|
|
+ .as("expected rendered HTML to include %s markup", description)
|
|
|
|
|
+ .contains(startToken);
|
|
|
|
|
+
|
|
|
|
|
+ String strippedHtml = html.replaceFirst(java.util.regex.Pattern.quote(startToken) + "[^>]*/>", "");
|
|
|
|
|
+
|
|
|
|
|
+ assertThat(strippedHtml)
|
|
|
|
|
+ .as("expected stripping %s markup to remove SVG fragment", description)
|
|
|
|
|
+ .isNotEqualTo(html);
|
|
|
|
|
+
|
|
|
|
|
+ return strippedHtml;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String stripRequiredContainerFragment(String html, String startToken, String endToken, String description) {
|
|
|
|
|
+ assertThat(html)
|
|
|
|
|
+ .as("expected rendered HTML to include %s markup", description)
|
|
|
|
|
+ .contains(startToken);
|
|
|
|
|
+
|
|
|
|
|
+ int startIndex = html.indexOf(startToken);
|
|
|
|
|
+ int endIndex = html.indexOf(endToken, startIndex);
|
|
|
|
|
+
|
|
|
|
|
+ assertThat(startIndex).as("expected start token for %s", description).isNotNegative();
|
|
|
|
|
+ assertThat(endIndex).as("expected end token for %s", description).isGreaterThan(startIndex);
|
|
|
|
|
+
|
|
|
|
|
+ String strippedHtml = html.substring(0, startIndex) + html.substring(endIndex + endToken.length());
|
|
|
|
|
+
|
|
|
|
|
+ assertThat(strippedHtml)
|
|
|
|
|
+ .as("expected stripping %s markup to remove HTML fragment", description)
|
|
|
|
|
+ .isNotEqualTo(html);
|
|
|
|
|
+
|
|
|
|
|
+ return strippedHtml;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private JsonNode samplePayload() throws Exception {
|
|
private JsonNode samplePayload() throws Exception {
|
|
|
return OBJECT_MAPPER.readTree("""
|
|
return OBJECT_MAPPER.readTree("""
|
|
|
{
|
|
{
|
|
|
"reportMetadata": {
|
|
"reportMetadata": {
|
|
|
- "reportVersionLabel": "2026 词汇展望报告",
|
|
|
|
|
"learnerName": "李同学",
|
|
"learnerName": "李同学",
|
|
|
"targetExamName": "春季高考英语",
|
|
"targetExamName": "春季高考英语",
|
|
|
- "sprintPeriodLabel": "30 天考前冲刺",
|
|
|
|
|
- "authorName": "Ability Bot"
|
|
|
|
|
|
|
+ "sprintPeriodLabel": "30 天考前冲刺"
|
|
|
},
|
|
},
|
|
|
"readinessOverview": {
|
|
"readinessOverview": {
|
|
|
"summary": "基础较稳,具备短期冲刺提分空间。",
|
|
"summary": "基础较稳,具备短期冲刺提分空间。",
|
|
|
- "currentStage": "冲刺提升期",
|
|
|
|
|
- "keyInsight": "高频与常考词群是提分关键。",
|
|
|
|
|
- "readinessScore": 72
|
|
|
|
|
|
|
+ "keyInsight": "核心观察:高频与常考词群是提分关键。"
|
|
|
},
|
|
},
|
|
|
- "syllabusMasteryProfile": {
|
|
|
|
|
- "masteryPercent": 78,
|
|
|
|
|
- "diagnosis": "考纲词覆盖较好。",
|
|
|
|
|
- "recommendation": "保持滚动复习。",
|
|
|
|
|
- "dimensionScores": [
|
|
|
|
|
- {"label": "识记", "score": 82},
|
|
|
|
|
- {"label": "应用", "score": 74}
|
|
|
|
|
- ]
|
|
|
|
|
|
|
+ "syllabusMasteryChart": {
|
|
|
|
|
+ "totalWordCount": 4200,
|
|
|
|
|
+ "masteredWordCount": 2701,
|
|
|
|
|
+ "unmasteredWordCount": 1499,
|
|
|
|
|
+ "masteryPercent": 64,
|
|
|
|
|
+ "recommendation": "优先补齐高考核心场景词。"
|
|
|
},
|
|
},
|
|
|
- "pastPaperVocabularyProfile": {
|
|
|
|
|
- "masteredWordCount": 420,
|
|
|
|
|
- "totalWordCount": 600,
|
|
|
|
|
- "masteryPercent": 70,
|
|
|
|
|
- "diagnosis": "真题词汇还需查漏补缺。",
|
|
|
|
|
- "recommendation": "优先扫清近三年高频词。",
|
|
|
|
|
- "sampleWords": ["abandon", "adapt", "assume"]
|
|
|
|
|
|
|
+ "pastPaperVocabularyChart": {
|
|
|
|
|
+ "totalWordCount": 961,
|
|
|
|
|
+ "unknownWordCountBeforeSprint": 847,
|
|
|
|
|
+ "unknownWordCountAfterSprint": 716,
|
|
|
|
|
+ "projectedScoreGainLabel": "预计提分5-15分",
|
|
|
|
|
+ "recommendation": "先压降真题生词占比。"
|
|
|
},
|
|
},
|
|
|
- "highFrequencyVocabularyProfile": {
|
|
|
|
|
- "masteredWordCount": 320,
|
|
|
|
|
- "totalWordCount": 400,
|
|
|
|
|
- "masteryPercent": 80,
|
|
|
|
|
- "diagnosis": "常考词汇掌握情况良好。",
|
|
|
|
|
- "recommendation": "继续稳固高频词群。",
|
|
|
|
|
- "sampleWords": ["benefit", "capacity", "decline"]
|
|
|
|
|
|
|
+ "highFrequencyVocabularyChart": {
|
|
|
|
|
+ "basicCorePercent": 62,
|
|
|
|
|
+ "highScorePercent": 41,
|
|
|
|
|
+ "highlightLabel": "拉分词是提分核心突破项"
|
|
|
},
|
|
},
|
|
|
- "vocabularyFrequencyBands": [
|
|
|
|
|
- {"bandLabel": "高频词", "masteryPercent": 80, "targetPercent": 90},
|
|
|
|
|
- {"bandLabel": "中频词", "masteryPercent": 68, "targetPercent": 80},
|
|
|
|
|
- {"bandLabel": "低频词", "masteryPercent": 45, "targetPercent": 60}
|
|
|
|
|
- ],
|
|
|
|
|
- "sprintPlanOptions": [
|
|
|
|
|
- {
|
|
|
|
|
- "planName": "7 天提分冲刺",
|
|
|
|
|
- "cadenceLabel": "7 天",
|
|
|
|
|
- "tagLabel": "推荐",
|
|
|
|
|
- "focus": "高频词与真题词回收",
|
|
|
|
|
- "actionItems": ["晨读高频词", "午间错词复现", "晚间真题套练"],
|
|
|
|
|
- "expectedOutcome": "稳定拿下基础词汇题"
|
|
|
|
|
|
|
+ "vocabularyFrequencyBandChart": {
|
|
|
|
|
+ "bars": [
|
|
|
|
|
+ {"bandLabel": "高频词", "currentValue": 188.6, "priorityLabel": "优先学习", "themeColor": "#448aff"},
|
|
|
|
|
+ {"bandLabel": "中频词", "currentValue": 154.5, "priorityLabel": "重点突破", "themeColor": "#4caf50"},
|
|
|
|
|
+ {"bandLabel": "低频词", "currentValue": 70.4, "priorityLabel": "酌情学习", "themeColor": "#ff9800"}
|
|
|
|
|
+ ]
|
|
|
|
|
+ },
|
|
|
|
|
+ "studySuggestionSection": {
|
|
|
|
|
+ "cadenceCards": [
|
|
|
|
|
+ {"cadencePerWeek": 1, "scoreGainLabel": "+5分", "winRatePercent": 38, "recommended": false},
|
|
|
|
|
+ {"cadencePerWeek": 2, "scoreGainLabel": "+10分", "winRatePercent": 55, "recommended": false},
|
|
|
|
|
+ {"cadencePerWeek": 3, "scoreGainLabel": "+10分", "winRatePercent": 72, "recommended": true},
|
|
|
|
|
+ {"cadencePerWeek": 5, "scoreGainLabel": "+20分", "winRatePercent": 88, "recommended": false}
|
|
|
|
|
+ ],
|
|
|
|
|
+ "strategyProjection": {
|
|
|
|
|
+ "recommendedCadenceLabel": "3套",
|
|
|
|
|
+ "projectedScoreGainLabel": "15+10分",
|
|
|
|
|
+ "overallWinRatePercent": 72
|
|
|
|
|
+ },
|
|
|
|
|
+ "halfMonthSprintAdvice": {
|
|
|
|
|
+ "description": "按词频优先级记忆,不浪费时间;只攻克高频/中频核心词,2周15小时速记500-800必考词,快速缩小生词缺口。"
|
|
|
|
|
+ },
|
|
|
|
|
+ "halfHourReviewAdvice": {
|
|
|
|
|
+ "description": "只复习已标记的核心词汇,不学新词;使用专属《压轴词》速记手册,保持记忆热度,考场直接见效。"
|
|
|
}
|
|
}
|
|
|
- ],
|
|
|
|
|
- "diagnosticCaseStudy": {
|
|
|
|
|
- "title": "上届学员案例",
|
|
|
|
|
- "context": "基础一般但执行力强。",
|
|
|
|
|
- "diagnosis": "高频词重复错误较多。",
|
|
|
|
|
- "strategy": "连续 10 天高频词闭环复习。",
|
|
|
|
|
- "keyTakeaway": "短周期高频复现可快速提分。"
|
|
|
|
|
|
|
+ },
|
|
|
|
|
+ "scoreImprovementCaseStudy": {
|
|
|
|
|
+ "headline": "真实提分 · 效果可复制",
|
|
|
|
|
+ "learnerName": "王雷宇",
|
|
|
|
|
+ "studyPeriodLabel": "考前3天短期突击",
|
|
|
|
|
+ "memorizedWordCount": 705,
|
|
|
|
|
+ "examHitWordCount": 237,
|
|
|
|
|
+ "hitRatePercent": 33.8,
|
|
|
|
|
+ "baselineScoreLabel": "70分以下",
|
|
|
|
|
+ "finalScore": 89,
|
|
|
|
|
+ "scoreGain": 19
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
""");
|
|
""");
|