Parcourir la source

Merge branch 'feature/exam-sprint-outlook-report-adjustments' of jyx/dcjxb.microservice into master

金逸霄 il y a 2 semaines
Parent
commit
1577160709

+ 34 - 19
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java

@@ -90,14 +90,14 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
                 new PastPaperVocabularyChart(
                         payload.testPaperWordCount(),
                         payload.testPaperUnMasterWords().size(),
-                        payload.testPaperUnMasterWords().size(),
+                        sprintUnknownWordCount(payload.testPaperUnMasterWords().size()),
                         "预计提分5-15分",
-                        "先压降真题生词占比。"),
+                        null),
                 new HighFrequencyVocabularyChart(
                         roundedMasteryPercent(words, 0, basicUpper),
                         roundedMasteryPercent(words, basicUpper, frequentUpper),
                         roundedMasteryPercent(words, frequentUpper, stageVocabulary),
-                        "拉分词是提分核心突破项"),
+                        null),
                 new VocabularyFrequencyBandChart(
                         List.of(
                                 new VocabularyFrequencyBar(
@@ -117,6 +117,10 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
                 .toList();
     }
 
+    private int sprintUnknownWordCount(int unknownWordCountBeforeSprint) {
+        return (int) Math.round(unknownWordCountBeforeSprint * 0.75d);
+    }
+
     private int roundedMasteryPercent(List<OutlookExamSprintReportPayload.StudentWordLatest> words,
                                       int lowerExclusive,
                                       int upperInclusive) {
@@ -141,7 +145,6 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
     private FrequencyPlan staticFrequencyPlan() {
         return new FrequencyPlan(
                 List.of(
-                        new FrequencyPlanCard(1, "+5分", 38, false, "稳健", "①"),
                         new FrequencyPlanCard(2, "+10分", 55, false, "均衡", "②"),
                         new FrequencyPlanCard(3, "+15分", 72, true, "推荐", "③"),
                         new FrequencyPlanCard(5, "+20分", 88, false, "冲刺", "④")),
@@ -250,7 +253,8 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
         double unmasteredPercent = percentage(chart.unmasteredWordCount(), chart.totalWordCount());
         double endAngle = -90 + (Math.max(0d, Math.min(100d, masteredPercent)) * 3.6);
 
-        return new StringBuilder()
+        StringBuilder builder = new StringBuilder();
+        builder
                 .append("<div class='card'>")
                 .append("<h3 class='card-title'>考纲词汇掌握情况</h3>")
                 .append("<div class='chart-box'>")
@@ -273,25 +277,27 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
                 .append("未掌握:<span class='highlight'>").append(chart.unmasteredWordCount())
                 .append("词(").append(formatTwoDecimals(unmasteredPercent)).append("%)</span>")
                 .append("</div>")
-                .append("</div>")
-                .toString();
+                .append("</div>");
+        return builder.toString();
     }
 
     private String renderPastPaperVocabularyChart(PastPaperVocabularyChart chart) {
-        int axisMax = roundUpToStep(Math.max(chart.totalWordCount(), chart.unknownWordCountBeforeSprint()), 250);
+        int axisMax = chart.totalWordCount();
+        int yAxisTickStep = axisMax <= 0 ? 1 : axisMax;
         int totalHeight = barHeight(chart.totalWordCount(), axisMax);
         int unknownHeight = barHeight(chart.unknownWordCountBeforeSprint(), axisMax);
         double beforePercent = percentage(chart.unknownWordCountBeforeSprint(), chart.totalWordCount());
         double afterPercent = percentage(chart.unknownWordCountAfterSprint(), chart.totalWordCount());
 
-        return new StringBuilder()
+        StringBuilder builder = new StringBuilder();
+        builder
                 .append("<div class='card'>")
                 .append("<h3 class='card-title'>真题试卷词汇掌握情况</h3>")
                 .append("<div class='chart-box'>")
                 .append("<svg class='past-paper-column-chart'").append(SVG_CJK_FONT_FAMILY)
                 .append(" xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 220' role='img' aria-label='真题试卷词汇掌握情况'>")
                 .append(renderChartAxes(320))
-                .append(renderYAxisTicks(axisMax, 250))
+                .append(renderYAxisTicks(axisMax, yAxisTickStep))
                 .append(renderXAxisTickMarks(112, 208))
                 .append("<rect class='chart-column total-column' x='84' y='").append(CHART_AXIS_BOTTOM - totalHeight)
                 .append("' width='56' height='").append(totalHeight).append("' rx='8' ry='8' fill='#448aff'/>")
@@ -308,9 +314,13 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
                 .append("冲刺后生词:").append(chart.unknownWordCountAfterSprint())
                 .append("词,生词占比降至").append(formatTwoDecimals(afterPercent)).append("%,")
                 .append(escape(chart.projectedScoreGainLabel()))
-                .append("</div>")
-                .append("<p class='data-text'>").append(escape(chart.recommendation())).append("</p>")
-                .append("</div>")
+                .append("</div>");
+
+        if (chart.recommendation() != null && !chart.recommendation().isBlank()) {
+            builder.append("<p class='data-text'>").append(escape(chart.recommendation())).append("</p>");
+        }
+
+        return builder.append("</div>")
                 .toString();
     }
 
@@ -320,7 +330,8 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
         int frequentHeight = barHeight(chart.frequentCorePercent(), axisMax);
         int advancedHeight = barHeight(chart.advancedScorePercent(), axisMax);
 
-        return new StringBuilder()
+        StringBuilder builder = new StringBuilder();
+        builder
                 .append("<div class='card'>")
                 .append("<h3 class='card-title'>常考词汇掌握情况</h3>")
                 .append("<div class='chart-box'>")
@@ -342,11 +353,15 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
                 .append("</div>")
                 .append("<div class='data-text'>基础必会词:综合掌握率").append(chart.basicCorePercent())
                 .append("%<br/>核心常考词:综合掌握率").append(chart.frequentCorePercent())
-                .append("%<br/>拔高拉分词:综合掌握率").append(chart.advancedScorePercent()).append("%</div>")
-                .append("<p class='data-text'><span class='highlight'>")
-                .append(escape(chart.highlightLabel()))
-                .append("</span></p>")
-                .append("</div>")
+                .append("%<br/>拔高拉分词:综合掌握率").append(chart.advancedScorePercent()).append("%</div>");
+
+        if (chart.highlightLabel() != null && !chart.highlightLabel().isBlank()) {
+            builder.append("<p class='data-text'><span class='highlight'>")
+                    .append(escape(chart.highlightLabel()))
+                    .append("</span></p>");
+        }
+
+        return builder.append("</div>")
                 .toString();
     }
 

+ 11 - 6
abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java

@@ -47,13 +47,14 @@ class ClasspathOutlookExamSprintReportRendererTest {
                 .contains("<td class='frequency-cell frequency-cell-1'>")
                 .contains("<td class='frequency-cell frequency-cell-2'>")
                 .contains("<td class='frequency-cell frequency-cell-3'>")
-                .contains("<td class='frequency-cell frequency-cell-4'>")
-                .contains("class='freq-card card-3 active'")
+                .doesNotContain("<td class='frequency-cell frequency-cell-4'>")
+                .contains("class='freq-card card-2 active'")
+                .contains("class='freq-header'>2套/周")
                 .contains("class='freq-header'>3套/周 <span class='badge'>推荐</span>")
                 .contains("class='freq-header'>5套/周 <span class='crown'>★</span>")
                 .contains("class='chart-track' cx='110' cy='110' r='76' fill='none' stroke='#e8eef7' stroke-width='18'")
                 .doesNotContain("class='freq-title'>")
-                .doesNotContain("1套/周 <span class='badge'>")
+                .doesNotContain("class='freq-header'>1套/周")
                 .doesNotContain("2套/周 <span class='badge'>")
                 .doesNotContain("5套/周 <span class='badge'>")
                 .doesNotContain("④")
@@ -75,7 +76,9 @@ class ClasspathOutlookExamSprintReportRendererTest {
                 .contains("已掌握:<span class='highlight'>4词(40.00%)</span>")
                 .contains("未掌握:<span class='highlight'>6词(60.00%)</span>")
                 .contains("真题总词:5词 | 生词量:3词(60.00%)")
-                .contains("生词占比降至60.00%")
+                .contains("冲刺后生词:2词,生词占比降至40.00%")
+                .doesNotContain("先压降真题生词占比。")
+                .doesNotContain("拉分词是提分核心突破项")
                 .contains("基础必会词:综合掌握率75%")
                 .contains("核心常考词:综合掌握率50%")
                 .contains("拔高拉分词:综合掌握率25%")
@@ -98,7 +101,9 @@ class ClasspathOutlookExamSprintReportRendererTest {
                 .contains("已掌握:<span class='highlight'>4词(40.00%)</span>")
                 .contains("未掌握:<span class='highlight'>6词(60.00%)</span>")
                 .contains("真题总词:5词 | 生词量:3词(60.00%)")
-                .contains("冲刺后生词:3词,生词占比降至60.00%")
+                .contains("冲刺后生词:2词,生词占比降至40.00%")
+                .doesNotContain("先压降真题生词占比。")
+                .doesNotContain("拉分词是提分核心突破项")
                 .contains("基础必会词:综合掌握率75%")
                 .contains("核心常考词:综合掌握率50%")
                 .contains("拔高拉分词:综合掌握率25%")
@@ -140,7 +145,7 @@ class ClasspathOutlookExamSprintReportRendererTest {
                 .contains("class='chart-axis-tick chart-axis-tick-y'")
                 .contains("class='chart-axis-tick chart-axis-tick-x'")
                 .containsPattern("<text class='chart-axis-tick-label chart-axis-tick-label-y' x='26' y='54' text-anchor='end' fill='#7f8b97' font-size='11'>100</text>")
-                .containsPattern("<text class='chart-axis-tick-label chart-axis-tick-label-y' x='26' y='54' text-anchor='end' fill='#7f8b97' font-size='11'>250</text>");
+                .containsPattern("<text class='chart-axis-tick-label chart-axis-tick-label-y' x='26' y='54' text-anchor='end' fill='#7f8b97' font-size='11'>5</text>");
     }
 
     /**

+ 153 - 0
docs/superpowers/specs/2026-04-29-exam-sprint-outlook-report-adjustments-design.md

@@ -0,0 +1,153 @@
+# Exam Sprint Outlook Report Adjustments Design
+
+## Goal
+
+Adjust the exam-sprint outlook report so the rendered content follows the new reporting rules for projected strange-word reduction, removes hard-coded personalized hints, removes the `1套/周` planning card, and aligns the past-paper vocabulary chart y-axis ceiling with the total test-paper word count.
+
+## Scope
+
+This change is limited to the outlook report rendering path under `abilities/exam-sprint`:
+
+- payload adaptation in `ClasspathOutlookExamSprintReportRenderer`
+- HTML fragment rendering for the past-paper and high-frequency cards
+- static study-plan card configuration
+- renderer integration tests that lock the new visible output
+
+This change does not alter the request contract, application service flow, PDF generation pipeline, or the overall report template structure unless a minimal layout adjustment is required to keep the existing output stable.
+
+## Recommended Approach
+
+Use the existing template-plus-renderer structure and make the smallest possible behavior changes inside `ClasspathOutlookExamSprintReportRenderer`.
+
+The renderer already owns the output assembly for the affected cards, so the safest approach is to:
+
+1. recalculate the past-paper projected strange-word count during payload adaptation
+2. make optional hint paragraphs conditional instead of unconditional
+3. shrink the static frequency-plan card set from four cards to three cards
+4. derive the past-paper chart axis maximum directly from `testPaperWordCount`
+
+This keeps the report layout stable while changing only the business rules the user requested.
+
+## Business Rules
+
+### Past-paper projected strange-word count
+
+- `unknownWordCountBeforeSprint` continues to come from the incoming past-paper unknown-word count.
+- `unknownWordCountAfterSprint` must be recalculated as:
+
+  `round(unknownWordCountBeforeSprint * 0.75)`
+
+- The rendered projected post-sprint strange-word percentage must use the recalculated `unknownWordCountAfterSprint` and the existing `totalWordCount`.
+- The percentage display format remains unchanged.
+
+### Removal of hard-coded personalized hints
+
+The following hard-coded hints must no longer appear in the report:
+
+- `先压降真题生词占比。`
+- `拉分词是提分核心突破项`
+
+These hints should be removed completely rather than replaced with neutral fallback copy.
+
+### Study-plan frequency cards
+
+The `练习学案频率与提分规划` section must remove the `1套/周` card.
+
+The remaining visible cards should continue to be:
+
+- `2套/周`
+- `3套/周`
+- `5套/周`
+
+The existing recommendation styling should remain on the current recommended card unless implementation reveals a presentation issue.
+
+### Past-paper chart y-axis rule
+
+The `真题试卷词汇掌握情况` chart y-axis maximum must be aligned directly to `testPaperWordCount`.
+
+- Do not round up to a fixed step such as `250`.
+- The axis ceiling should equal the exact total test-paper word count.
+- The bar heights should continue to use the existing scaling helper logic, now against the exact total count ceiling.
+
+## Components
+
+### `ClasspathOutlookExamSprintReportRenderer`
+
+#### `adaptPayload(...)`
+
+Update the view-model mapping so that:
+
+- `PastPaperVocabularyChart.unknownWordCountAfterSprint` is computed with `round(before * 0.75)`
+- the past-paper recommendation hint is absent
+- the high-frequency highlight hint is absent
+
+The implementation should preserve existing DTO usage and avoid changing upstream payload semantics.
+
+#### `renderPastPaperVocabularyChart(...)`
+
+Update rendering so that:
+
+- the chart axis max is derived from `chart.totalWordCount()`
+- the rendered `冲刺后生词` count reflects the new rounded value
+- the rendered projected percentage reflects the recalculated count
+- the recommendation paragraph is only rendered when non-empty
+
+The chart should continue to render as inline SVG and preserve the existing label set and card structure.
+
+#### `renderHighFrequencyVocabularyChart(...)`
+
+Update rendering so that the highlight paragraph is only rendered when non-empty.
+
+No other chart semantics need to change in this task.
+
+#### `staticFrequencyPlan()`
+
+Update the static card list to remove the `1套/周` entry and keep the rest of the recommendation content unchanged unless the reduced card count requires a minimal presentation adjustment.
+
+## Data Flow
+
+1. The renderer deserializes `OutlookExamSprintReportPayload` from the unmodeled report content.
+2. `adaptPayload(...)` maps the payload into the internal report view model.
+3. During adaptation, the past-paper strange-word projection is recalculated with the new `0.75` multiplier and rounded to the nearest integer.
+4. The renderer builds HTML fragments for each report card.
+5. Optional hint paragraphs are omitted entirely when their values are blank or absent.
+6. The final HTML is injected into `outlook-exam-sprint-report-template.html` and returned for downstream PDF generation.
+
+## Error Handling
+
+The change should preserve the current renderer exception behavior.
+
+- Invalid payloads should still fail through the current deserialization or rendering path.
+- Optional hint removal should not introduce null-driven HTML artifacts such as literal `null` output or empty wrapper paragraphs.
+- If `testPaperWordCount` is zero, the existing percentage and bar-scaling guards must continue to prevent divide-by-zero or invalid SVG output.
+
+## Testing Strategy
+
+Follow TDD by updating the renderer integration tests first, verifying failure, then implementing the minimal code changes.
+
+### Required test coverage
+
+Update `ClasspathOutlookExamSprintReportRendererTest` to lock the new visible behavior:
+
+- projected strange-word output changes from the old same-count behavior to the new rounded `75%` behavior
+- projected percentage is recalculated from the new projected strange-word count
+- `先压降真题生词占比。` is absent
+- `拉分词是提分核心突破项` is absent
+- `1套/周` is absent
+- `2套/周` / `3套/周` / `5套/周` remain present
+- the past-paper chart axis no longer uses the rounded-up `250` ceiling for the sample payload and instead aligns to the exact test-paper total
+
+### Regression expectations
+
+- Existing SVG font-family assertions should continue to pass.
+- Existing PDF-generation smoke coverage should continue to pass if it already depends on the outlook HTML output.
+- No tests should rely on removed hint paragraphs being present.
+
+## Acceptance Criteria
+
+- The report shows `冲刺后生词` as `round(冲刺前生词 * 0.75)`.
+- The displayed post-sprint strange-word percentage matches the recalculated projected count.
+- The two hard-coded personalized hint sentences are not rendered anywhere in the outlook report.
+- The `练习学案频率与提分规划` section no longer shows the `1套/周` card.
+- The `真题试卷词汇掌握情况` y-axis ceiling matches `TestPaperWordCount` exactly.
+- Existing report rendering remains structurally stable outside these requested behavior changes.