|
|
@@ -7,12 +7,15 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
|
|
|
import org.junit.jupiter.api.Test;
|
|
|
|
|
|
import java.time.Instant;
|
|
|
+import java.util.regex.Matcher;
|
|
|
+import java.util.regex.Pattern;
|
|
|
|
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
|
|
|
|
|
class ClasspathAchievementExamSprintReportRendererTest {
|
|
|
|
|
|
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
|
|
+ private static final Pattern SELF_CLOSING_RECT_PATTERN = Pattern.compile("<rect\\b[^>]*/>");
|
|
|
|
|
|
@Test
|
|
|
void renderBuildsAchievementHtmlAlignedWithDesignDraft() throws Exception {
|
|
|
@@ -20,6 +23,37 @@ class ClasspathAchievementExamSprintReportRendererTest {
|
|
|
|
|
|
String html = renderer.render(samplePayload(), Instant.parse("2026-04-25T08:00:00Z"));
|
|
|
|
|
|
+ assertSectionClassForTitle(html, "模块一:词汇量对比", "section comparison-section");
|
|
|
+ assertSectionClassForTitle(html, "模块二:试卷熟词量对比", "section comparison-section");
|
|
|
+ assertSectionClassForTitle(html, "模块三:实考生词命中状况", "section");
|
|
|
+
|
|
|
+ assertModuleUsesVerticalCard(
|
|
|
+ html,
|
|
|
+ "<h2 class=\"section-title\">模块一:词汇量对比</h2>",
|
|
|
+ "vocabulary-growth-chart",
|
|
|
+ "训练前词汇量:<span class=\"highlight\">2328 词</span><br/>",
|
|
|
+ "训练后词汇量:<span class=\"highlight\">2347 词</span><br/>",
|
|
|
+ "本次提升:<span class=\"highlight\">+19 词</span>");
|
|
|
+ assertModuleUsesVerticalCard(
|
|
|
+ html,
|
|
|
+ "<h2 class=\"section-title\">模块二:试卷熟词量对比</h2>",
|
|
|
+ "paper-known-words-chart",
|
|
|
+ "训练前熟词量:<span class=\"highlight\">650 个</span><br/>",
|
|
|
+ "训练后熟词量:<span class=\"highlight\">654 个</span><br/>",
|
|
|
+ "本次提升:<span class=\"highlight\">+4 个</span>");
|
|
|
+ assertBarFill(extractChartSvg(html, "vocabulary-growth-chart"), "chart-bar chart-bar-before", "#448aff");
|
|
|
+ assertBarFill(extractChartSvg(html, "vocabulary-growth-chart"), "chart-bar chart-bar-after", "#448aff");
|
|
|
+ assertBarFill(extractChartSvg(html, "paper-known-words-chart"), "chart-bar chart-bar-before", "#34a853");
|
|
|
+ assertBarFill(extractChartSvg(html, "paper-known-words-chart"), "chart-bar chart-bar-after", "#34a853");
|
|
|
+
|
|
|
+ assertCssRuleContains(html, ".section", "margin-top: 24px;", "page-break-inside: avoid;");
|
|
|
+ assertCssRuleContains(html, ".section-title", "margin: 0 0 14px;");
|
|
|
+ assertCssRuleContains(html, ".comparison-section", "margin-top: 0;", "margin-bottom: 50px;");
|
|
|
+ assertCssRuleContains(html, ".comparison-section .section-title", "margin: 0 0 25px;");
|
|
|
+ assertCssRuleContains(html, ".card", "background: #fafbfc;", "padding: 25px;");
|
|
|
+ assertCssRuleContains(html, ".chart-box", "height: 260px;");
|
|
|
+ assertCssRuleContains(html, ".data-text", "line-height: 1.8;");
|
|
|
+
|
|
|
assertThat(html)
|
|
|
.contains("高考英语临考突击学习成果报告")
|
|
|
.contains("2024真题 · 两周专项训练 · 真实提分效果")
|
|
|
@@ -46,19 +80,15 @@ class ClasspathAchievementExamSprintReportRendererTest {
|
|
|
.contains(">0</text>")
|
|
|
.contains(">2347</text>")
|
|
|
.contains(">654</text>")
|
|
|
- .contains("<rect class='chart-bar chart-bar-after' x='222' y='36' width='58' height='144' rx='10' ry='10' fill='#ff7d00'/>")
|
|
|
- .contains("<rect class='chart-bar chart-bar-after' x='222' y='36' width='58' height='144' rx='10' ry='10' fill='#3f8cff'/>")
|
|
|
- .contains("训练前词汇量:<span class=\"highlight\">2328 词</span>")
|
|
|
- .contains("训练后词汇量:<span class=\"highlight\">2347 词</span>")
|
|
|
- .contains("本次提升:<span class=\"highlight\">+19 词</span>")
|
|
|
- .contains("训练前熟词量:<span class=\"highlight\">650 个</span>")
|
|
|
- .contains("训练后熟词量:<span class=\"highlight\">654 个</span>")
|
|
|
.contains("成功减少生词:<span class=\"highlight\">4 个</span>")
|
|
|
.contains("class=\"word-list\"")
|
|
|
.contains("class=\"word-item\">number</div>")
|
|
|
.doesNotContain("cdn.jsdelivr.net")
|
|
|
.doesNotContain("echarts")
|
|
|
- .doesNotContain("<script");
|
|
|
+ .doesNotContain("<script")
|
|
|
+ .doesNotContain("comparison-table")
|
|
|
+ .doesNotContain("chart-cell")
|
|
|
+ .doesNotContain("detail-cell");
|
|
|
|
|
|
assertThat(countOccurrences(html, "class='chart-grid-line'")).isEqualTo(6);
|
|
|
assertThat(countOccurrences(html, "class='chart-tick-label'")).isEqualTo(6);
|
|
|
@@ -187,6 +217,176 @@ class ClasspathAchievementExamSprintReportRendererTest {
|
|
|
""");
|
|
|
}
|
|
|
|
|
|
+ private void assertModuleUsesVerticalCard(
|
|
|
+ String html,
|
|
|
+ String sectionTitle,
|
|
|
+ String chartClass,
|
|
|
+ String beforeLine,
|
|
|
+ String afterLine,
|
|
|
+ String growthLine) {
|
|
|
+ int sectionStart = html.indexOf(sectionTitle);
|
|
|
+ assertThat(sectionStart)
|
|
|
+ .as("module section should exist: %s", sectionTitle)
|
|
|
+ .isGreaterThanOrEqualTo(0);
|
|
|
+
|
|
|
+ int nextSectionStart = html.indexOf("<h2 class=\"section-title\">", sectionStart + sectionTitle.length());
|
|
|
+ String sectionHtml = nextSectionStart >= 0
|
|
|
+ ? html.substring(sectionStart, nextSectionStart)
|
|
|
+ : html.substring(sectionStart);
|
|
|
+ String chartBox = "<div class=\"chart-box\">";
|
|
|
+ String svgClass = "class='achievement-bar-chart " + chartClass + "'";
|
|
|
+
|
|
|
+ assertThat(sectionHtml)
|
|
|
+ .as("%s should render as card -> chart-box -> data-text", sectionTitle)
|
|
|
+ .containsSubsequence(
|
|
|
+ sectionTitle,
|
|
|
+ "<div class=\"card\">",
|
|
|
+ chartBox,
|
|
|
+ svgClass,
|
|
|
+ "</div>",
|
|
|
+ "<div class=\"data-text\">",
|
|
|
+ beforeLine,
|
|
|
+ afterLine,
|
|
|
+ growthLine);
|
|
|
+
|
|
|
+ int cardIndex = sectionHtml.indexOf("<div class=\"card\">");
|
|
|
+ int chartBoxIndex = sectionHtml.indexOf(chartBox, Math.max(cardIndex, 0));
|
|
|
+ int svgClassIndex = sectionHtml.indexOf(svgClass, Math.max(chartBoxIndex, 0));
|
|
|
+ int chartBoxCloseIndex = sectionHtml.indexOf("</div>", Math.max(svgClassIndex, 0));
|
|
|
+ int dataTextIndex = sectionHtml.indexOf("<div class=\"data-text\">", Math.max(chartBoxCloseIndex, 0));
|
|
|
+ assertThat(cardIndex)
|
|
|
+ .as("%s should contain card", sectionTitle)
|
|
|
+ .isGreaterThanOrEqualTo(0);
|
|
|
+ assertThat(chartBoxIndex)
|
|
|
+ .as("%s should contain chart-box inside card", sectionTitle)
|
|
|
+ .isGreaterThan(cardIndex);
|
|
|
+ assertThat(svgClassIndex)
|
|
|
+ .as("%s should contain the expected chart inside chart-box", sectionTitle)
|
|
|
+ .isGreaterThan(chartBoxIndex);
|
|
|
+ assertThat(chartBoxCloseIndex)
|
|
|
+ .as("%s should close chart-box before data-text", sectionTitle)
|
|
|
+ .isGreaterThan(svgClassIndex);
|
|
|
+ assertThat(dataTextIndex)
|
|
|
+ .as("%s should place data-text after chart-box", sectionTitle)
|
|
|
+ .isGreaterThan(chartBoxCloseIndex);
|
|
|
+ }
|
|
|
+
|
|
|
+ private String extractChartSvg(String html, String chartClass) {
|
|
|
+ int chartClassIndex = html.indexOf(chartClass);
|
|
|
+ assertThat(chartClassIndex)
|
|
|
+ .as("chart should exist: %s", chartClass)
|
|
|
+ .isGreaterThanOrEqualTo(0);
|
|
|
+
|
|
|
+ int svgStart = html.lastIndexOf("<svg", chartClassIndex);
|
|
|
+ int svgEnd = html.indexOf("</svg>", chartClassIndex);
|
|
|
+ assertThat(svgStart)
|
|
|
+ .as("chart should start with svg: %s", chartClass)
|
|
|
+ .isGreaterThanOrEqualTo(0);
|
|
|
+ assertThat(svgEnd)
|
|
|
+ .as("chart should close svg: %s", chartClass)
|
|
|
+ .isGreaterThan(svgStart);
|
|
|
+ return html.substring(svgStart, svgEnd + "</svg>".length());
|
|
|
+ }
|
|
|
+
|
|
|
+ private void assertSectionClassForTitle(String html, String title, String expectedClass) {
|
|
|
+ String sectionTitle = "<h2 class=\"section-title\">" + title + "</h2>";
|
|
|
+ int titleIndex = html.indexOf(sectionTitle);
|
|
|
+ assertThat(titleIndex)
|
|
|
+ .as("section title should exist: %s", title)
|
|
|
+ .isGreaterThanOrEqualTo(0);
|
|
|
+
|
|
|
+ int sectionStart = html.lastIndexOf("<div class=\"", titleIndex);
|
|
|
+ int classEnd = html.indexOf("\">", sectionStart);
|
|
|
+ assertThat(sectionStart)
|
|
|
+ .as("section wrapper should exist before title: %s", title)
|
|
|
+ .isGreaterThanOrEqualTo(0);
|
|
|
+ assertThat(classEnd)
|
|
|
+ .as("section wrapper should close before title: %s", title)
|
|
|
+ .isGreaterThan(sectionStart);
|
|
|
+ assertThat(html.substring(sectionStart, classEnd + "\">".length()))
|
|
|
+ .as("section wrapper class for %s", title)
|
|
|
+ .isEqualTo("<div class=\"" + expectedClass + "\">");
|
|
|
+ }
|
|
|
+
|
|
|
+ private void assertCssRuleContains(String html, String selector, String... declarations) {
|
|
|
+ String rule = extractCssRule(html, selector);
|
|
|
+ assertThat(rule)
|
|
|
+ .as("CSS rule for %s", selector)
|
|
|
+ .contains(declarations);
|
|
|
+ }
|
|
|
+
|
|
|
+ private String extractCssRule(String html, String selector) {
|
|
|
+ String css = extractCssText(html);
|
|
|
+ int searchFrom = 0;
|
|
|
+ while (searchFrom < css.length()) {
|
|
|
+ int selectorIndex = css.indexOf(selector, searchFrom);
|
|
|
+ if (selectorIndex < 0) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ int braceStart = css.indexOf('{', selectorIndex + selector.length());
|
|
|
+ if (braceStart < 0) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ boolean startsRule = isRuleStart(css, selectorIndex);
|
|
|
+ boolean selectorMatchesExactly = css.substring(selectorIndex + selector.length(), braceStart).trim().isEmpty();
|
|
|
+ if (startsRule && selectorMatchesExactly) {
|
|
|
+ int braceEnd = css.indexOf('}', braceStart);
|
|
|
+ assertThat(braceEnd)
|
|
|
+ .as("CSS rule for %s should close", selector)
|
|
|
+ .isGreaterThan(braceStart);
|
|
|
+ return css.substring(selectorIndex, braceEnd + 1);
|
|
|
+ }
|
|
|
+
|
|
|
+ searchFrom = selectorIndex + selector.length();
|
|
|
+ }
|
|
|
+
|
|
|
+ throw new AssertionError("CSS rule should exist for selector '" + selector + "' in style block:\n" + css);
|
|
|
+ }
|
|
|
+
|
|
|
+ private String extractCssText(String html) {
|
|
|
+ int styleStart = html.indexOf("<style>");
|
|
|
+ assertThat(styleStart)
|
|
|
+ .as("HTML should contain style block")
|
|
|
+ .isGreaterThanOrEqualTo(0);
|
|
|
+
|
|
|
+ int styleContentStart = styleStart + "<style>".length();
|
|
|
+ int styleEnd = html.indexOf("</style>", styleContentStart);
|
|
|
+ assertThat(styleEnd)
|
|
|
+ .as("HTML style block should close")
|
|
|
+ .isGreaterThan(styleContentStart);
|
|
|
+ return html.substring(styleContentStart, styleEnd);
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean isRuleStart(String css, int selectorIndex) {
|
|
|
+ int previous = selectorIndex - 1;
|
|
|
+ while (previous >= 0 && Character.isWhitespace(css.charAt(previous))) {
|
|
|
+ previous--;
|
|
|
+ }
|
|
|
+ return previous < 0 || css.charAt(previous) == '}';
|
|
|
+ }
|
|
|
+
|
|
|
+ private void assertBarFill(String svg, String rectClass, String expectedFill) {
|
|
|
+ String rect = extractRect(svg, rectClass);
|
|
|
+ assertThat(rect)
|
|
|
+ .as("%s should use %s", rectClass, expectedFill)
|
|
|
+ .contains("fill='" + expectedFill + "'");
|
|
|
+ }
|
|
|
+
|
|
|
+ private String extractRect(String svgOrHtml, String rectClass) {
|
|
|
+ Matcher rectMatcher = SELF_CLOSING_RECT_PATTERN.matcher(svgOrHtml);
|
|
|
+ String singleQuotedClass = "class='" + rectClass + "'";
|
|
|
+ String doubleQuotedClass = "class=\"" + rectClass + "\"";
|
|
|
+ while (rectMatcher.find()) {
|
|
|
+ String rect = rectMatcher.group();
|
|
|
+ if (rect.contains(singleQuotedClass) || rect.contains(doubleQuotedClass)) {
|
|
|
+ return rect;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ throw new AssertionError("rect should exist for class '" + rectClass + "' in SVG/HTML fragment:\n" + svgOrHtml);
|
|
|
+ }
|
|
|
+
|
|
|
private int countOccurrences(String value, String substring) {
|
|
|
int count = 0;
|
|
|
int index = 0;
|