Преглед изворни кода

fix(exam-sprint): 修正成果报告掌握度命中率展示

金逸霄 пре 2 недеља
родитељ
комит
586e6e9d5d

+ 14 - 2
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapper.java

@@ -5,6 +5,7 @@ import cn.yunzhixue.ability.center.examsprint.domain.report.AchievementReportCon
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.JsonNode;
 
 
 import java.math.BigDecimal;
 import java.math.BigDecimal;
+import java.math.RoundingMode;
 import java.util.List;
 import java.util.List;
 import java.util.Objects;
 import java.util.Objects;
 
 
@@ -15,6 +16,7 @@ final class AchievementReportContentMapper {
 
 
     static AchievementReportContent toDomainContent(AchievementExamSprintReportPayload payload) {
     static AchievementReportContent toDomainContent(AchievementExamSprintReportPayload payload) {
         Objects.requireNonNull(payload, "payload");
         Objects.requireNonNull(payload, "payload");
+        String masteryHitRateText = masteryHitRateText(payload.testPaperImprovedWordCount(), payload.studentImproveWordCount());
         return new AchievementReportContent(
         return new AchievementReportContent(
                 payload.studentName(),
                 payload.studentName(),
                 reportTitle(payload.stageName()),
                 reportTitle(payload.stageName()),
@@ -24,7 +26,7 @@ final class AchievementReportContentMapper {
                 new AchievementReportContent.SummaryMetrics(
                 new AchievementReportContent.SummaryMetrics(
                         signed(payload.studentImproveWordCount()),
                         signed(payload.studentImproveWordCount()),
                         signed(payload.testPaperImprovedWordCount()),
                         signed(payload.testPaperImprovedWordCount()),
-                        format(payload.paperMasteryHitRate()) + "%",
+                        masteryHitRateText,
                         format(payload.improveStudyEfficiency())),
                         format(payload.improveStudyEfficiency())),
                 new AchievementReportContent.Comparison(
                 new AchievementReportContent.Comparison(
                         payload.studentVocabularyBefore().doubleValue(),
                         payload.studentVocabularyBefore().doubleValue(),
@@ -53,7 +55,7 @@ final class AchievementReportContentMapper {
                         format(payload.testPaperLatestMasteryRate()),
                         format(payload.testPaperLatestMasteryRate()),
                         signed(payload.testPaperImproveRate())),
                         signed(payload.testPaperImproveRate())),
                 new AchievementReportContent.ExamUnknownWordsHitStatus(
                 new AchievementReportContent.ExamUnknownWordsHitStatus(
-                        format(payload.paperMasteryHitRate()) + "%",
+                        masteryHitRateText,
                         format(payload.improveStudyEfficiency()),
                         format(payload.improveStudyEfficiency()),
                         format(payload.testPaperBeforUnMastery()),
                         format(payload.testPaperBeforUnMastery()),
                         format(payload.testPaperAfterUnMastery()),
                         format(payload.testPaperAfterUnMastery()),
@@ -78,6 +80,16 @@ final class AchievementReportContentMapper {
         return value.stripTrailingZeros().toPlainString();
         return value.stripTrailingZeros().toPlainString();
     }
     }
 
 
+    private static String masteryHitRateText(BigDecimal improvedWordCount, BigDecimal studentImproveWordCount) {
+        if (studentImproveWordCount.signum() == 0) {
+            return "0%";
+        }
+        BigDecimal derivedRate = improvedWordCount
+                .multiply(BigDecimal.valueOf(100))
+                .divide(studentImproveWordCount, 1, RoundingMode.HALF_UP);
+        return derivedRate.toPlainString() + "%";
+    }
+
     private static List<String> hitWords(List<JsonNode> improvedWords) {
     private static List<String> hitWords(List<JsonNode> improvedWords) {
         return improvedWords.stream()
         return improvedWords.stream()
                 .map(AchievementReportContentMapper::hitWord)
                 .map(AchievementReportContentMapper::hitWord)

+ 26 - 2
abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapperTest.java

@@ -24,7 +24,7 @@ class AchievementReportContentMapperTest {
         assertThat(content.completionSubtitle()).isEqualTo("基于2024真题 · 真实学习效果分析");
         assertThat(content.completionSubtitle()).isEqualTo("基于2024真题 · 真实学习效果分析");
         assertThat(content.summaryMetrics().vocabularyGrowthText()).isEqualTo("+19");
         assertThat(content.summaryMetrics().vocabularyGrowthText()).isEqualTo("+19");
         assertThat(content.summaryMetrics().paperKnownWordsGrowthText()).isEqualTo("+4");
         assertThat(content.summaryMetrics().paperKnownWordsGrowthText()).isEqualTo("+4");
-        assertThat(content.summaryMetrics().unknownWordHitRateText()).isEqualTo("1.93%");
+        assertThat(content.summaryMetrics().unknownWordHitRateText()).isEqualTo("21.1%");
         assertThat(content.summaryMetrics().learningEfficiencyText()).isEqualTo("0.48");
         assertThat(content.summaryMetrics().learningEfficiencyText()).isEqualTo("0.48");
         assertThat(content.vocabularyComparison().beforeValue()).isEqualTo(2328.0);
         assertThat(content.vocabularyComparison().beforeValue()).isEqualTo(2328.0);
         assertThat(content.vocabularyComparison().afterText()).isEqualTo("2347");
         assertThat(content.vocabularyComparison().afterText()).isEqualTo("2347");
@@ -44,7 +44,7 @@ class AchievementReportContentMapperTest {
         assertThat(content.testPaperVocabularySummary().masteryRateBeforeText()).isEqualTo("75.49");
         assertThat(content.testPaperVocabularySummary().masteryRateBeforeText()).isEqualTo("75.49");
         assertThat(content.testPaperVocabularySummary().masteryRateAfterText()).isEqualTo("75.96");
         assertThat(content.testPaperVocabularySummary().masteryRateAfterText()).isEqualTo("75.96");
         assertThat(content.testPaperVocabularySummary().masteryRateImprovementText()).isEqualTo("+0.62");
         assertThat(content.testPaperVocabularySummary().masteryRateImprovementText()).isEqualTo("+0.62");
-        assertThat(content.examUnknownWordsHitStatus().unknownWordHitRateText()).isEqualTo("1.93%");
+        assertThat(content.examUnknownWordsHitStatus().unknownWordHitRateText()).isEqualTo("21.1%");
         assertThat(content.examUnknownWordsHitStatus().learningEfficiencyText()).isEqualTo("0.48");
         assertThat(content.examUnknownWordsHitStatus().learningEfficiencyText()).isEqualTo("0.48");
         assertThat(content.examUnknownWordsHitStatus().unknownWordsBeforeText()).isEqualTo("207");
         assertThat(content.examUnknownWordsHitStatus().unknownWordsBeforeText()).isEqualTo("207");
         assertThat(content.examUnknownWordsHitStatus().unknownWordsAfterText()).isEqualTo("203");
         assertThat(content.examUnknownWordsHitStatus().unknownWordsAfterText()).isEqualTo("203");
@@ -53,6 +53,30 @@ class AchievementReportContentMapperTest {
                 .containsExactly("number", "bear", "popular", "importance");
                 .containsExactly("number", "bear", "popular", "importance");
     }
     }
 
 
+    @Test
+    void mapsZeroMasteryHitRateWhenStudentImproveWordCountIsZero() {
+        ObjectNode payload = pascalPayload();
+        payload.put("StudentImproveWordCount", 0);
+        payload.put("PaperMasteryHitRate", 88.88);
+
+        AchievementReportContent content = AchievementReportContentMapper.toDomainContent(convert(payload));
+
+        assertThat(content.summaryMetrics().unknownWordHitRateText()).isEqualTo("0%");
+        assertThat(content.examUnknownWordsHitStatus().unknownWordHitRateText()).isEqualTo("0%");
+    }
+
+    @Test
+    void mapsWholeNumberMasteryHitRateWithOneDecimalPlace() {
+        ObjectNode payload = pascalPayload();
+        payload.put("TestPaperImprovedWordCount", 1);
+        payload.put("StudentImproveWordCount", 5);
+
+        AchievementReportContent content = AchievementReportContentMapper.toDomainContent(convert(payload));
+
+        assertThat(content.summaryMetrics().unknownWordHitRateText()).isEqualTo("20.0%");
+        assertThat(content.examUnknownWordsHitStatus().unknownWordHitRateText()).isEqualTo("20.0%");
+    }
+
     @Test
     @Test
     void mapsNegativeSignedGrowthAndDoesNotDuplicateEnglishInReportTitle() {
     void mapsNegativeSignedGrowthAndDoesNotDuplicateEnglishInReportTitle() {
         ObjectNode payload = pascalPayload();
         ObjectNode payload = pascalPayload();

+ 17 - 1
abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java

@@ -300,18 +300,34 @@ class ExamSprintReportApplicationServiceTest {
         assertThat(content.reportSubtitle()).isEqualTo("2024真题 · 临考突击 · 真实提分效果");
         assertThat(content.reportSubtitle()).isEqualTo("2024真题 · 临考突击 · 真实提分效果");
         assertThat(content.summaryMetrics().vocabularyGrowthText()).isEqualTo("+19");
         assertThat(content.summaryMetrics().vocabularyGrowthText()).isEqualTo("+19");
         assertThat(content.summaryMetrics().paperKnownWordsGrowthText()).isEqualTo("+4");
         assertThat(content.summaryMetrics().paperKnownWordsGrowthText()).isEqualTo("+4");
-        assertThat(content.summaryMetrics().unknownWordHitRateText()).isEqualTo("1.93%");
+        assertThat(content.summaryMetrics().unknownWordHitRateText()).isEqualTo("21.1%");
         assertThat(content.stageVocabularySummary().stageName()).isEqualTo("高考");
         assertThat(content.stageVocabularySummary().stageName()).isEqualTo("高考");
         assertThat(content.stageVocabularySummary().stageVocabularyText()).isEqualTo("3500");
         assertThat(content.stageVocabularySummary().stageVocabularyText()).isEqualTo("3500");
         assertThat(content.stageVocabularySummary().masteryImprovementText()).isEqualTo("+0.55");
         assertThat(content.stageVocabularySummary().masteryImprovementText()).isEqualTo("+0.55");
         assertThat(content.testPaperVocabularySummary().testPaperTitle()).isEqualTo("2024真题");
         assertThat(content.testPaperVocabularySummary().testPaperTitle()).isEqualTo("2024真题");
         assertThat(content.testPaperVocabularySummary().testPaperWordCountText()).isEqualTo("861");
         assertThat(content.testPaperVocabularySummary().testPaperWordCountText()).isEqualTo("861");
         assertThat(content.testPaperVocabularySummary().masteryRateImprovementText()).isEqualTo("+0.62");
         assertThat(content.testPaperVocabularySummary().masteryRateImprovementText()).isEqualTo("+0.62");
+        assertThat(content.examUnknownWordsHitStatus().unknownWordHitRateText()).isEqualTo("21.1%");
         assertThat(content.examUnknownWordsHitStatus().learningEfficiencyText()).isEqualTo("0.48");
         assertThat(content.examUnknownWordsHitStatus().learningEfficiencyText()).isEqualTo("0.48");
         assertThat(content.examUnknownWordsHitStatus().hitWords())
         assertThat(content.examUnknownWordsHitStatus().hitWords())
                 .containsExactly("number", "bear", "popular", "importance");
                 .containsExactly("number", "bear", "popular", "importance");
     }
     }
 
 
+    @Test
+    void createAchievementReportStoresZeroPercentWhenStudentImproveWordCountIsZero() {
+        TestRepository repository = new TestRepository();
+        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());
+        ObjectNode payload = validAchievementPayload().deepCopy();
+        payload.put("StudentImproveWordCount", 0);
+        payload.put("PaperMasteryHitRate", 77.77);
+
+        var response = service.createAchievementReport(payload);
+
+        AchievementReportContent content = (AchievementReportContent) repository.findById(response.reportId()).orElseThrow().content();
+        assertThat(content.summaryMetrics().unknownWordHitRateText()).isEqualTo("0%");
+        assertThat(content.examUnknownWordsHitStatus().unknownWordHitRateText()).isEqualTo("0%");
+    }
+
     /** 覆盖成果报告调用方 PascalCase 类型边界场景,当核心数字字段为 string 时,应在保存前校验失败。 */
     /** 覆盖成果报告调用方 PascalCase 类型边界场景,当核心数字字段为 string 时,应在保存前校验失败。 */
     @Test
     @Test
     void createAchievementReportRejectsCallerPayloadWithWrongPascalCaseTypesBeforeSaving() {
     void createAchievementReportRejectsCallerPayloadWithWrongPascalCaseTypesBeforeSaving() {

+ 4 - 4
abilities/exam-sprint/infrastructure/src/main/resources/templates/achievement-exam-sprint-report-template.html

@@ -265,7 +265,7 @@
             </td>
             </td>
             <td class="result-card">
             <td class="result-card">
                 <div class="result-value">{{unknownWordHitRateText}}</div>
                 <div class="result-value">{{unknownWordHitRateText}}</div>
-                <div class="result-label">真题生词命中率</div>
+                <div class="result-label">试卷掌握度命中率</div>
             </td>
             </td>
             <td class="result-card">
             <td class="result-card">
                 <div class="result-value">{{learningEfficiencyText}}</div>
                 <div class="result-value">{{learningEfficiencyText}}</div>
@@ -297,8 +297,8 @@
                 试卷标题:<span class="highlight">{{testPaperTitle}}</span><br/>
                 试卷标题:<span class="highlight">{{testPaperTitle}}</span><br/>
                 试卷总词量:<span class="highlight">{{testPaperWordCountText}}</span><br/>
                 试卷总词量:<span class="highlight">{{testPaperWordCountText}}</span><br/>
                 训练前/后生词:<span class="highlight">{{testPaperUnknownWordsBeforeText}} -&gt; {{testPaperUnknownWordsAfterText}}</span><br/>
                 训练前/后生词:<span class="highlight">{{testPaperUnknownWordsBeforeText}} -&gt; {{testPaperUnknownWordsAfterText}}</span><br/>
-                试卷词率:<span class="highlight">{{testPaperMasteryRateBeforeText}} -&gt; {{testPaperMasteryRateAfterText}}</span><br/>
-                试卷词率提升:<span class="highlight">{{testPaperMasteryRateImprovementText}}</span></div>
+                试卷词率:<span class="highlight">{{testPaperMasteryRateBeforeText}} -&gt; {{testPaperMasteryRateAfterText}}</span><br/>
+                试卷词率提升:<span class="highlight">{{testPaperMasteryRateImprovementText}}</span></div>
         </div>
         </div>
     </div>
     </div>
 
 
@@ -309,7 +309,7 @@
                 <tr>
                 <tr>
                     <td class="hit-stat">
                     <td class="hit-stat">
                         <div class="hit-stat-value">{{hitStatusUnknownWordHitRateText}}</div>
                         <div class="hit-stat-value">{{hitStatusUnknownWordHitRateText}}</div>
-                        <div class="hit-stat-label">真题生词命中率</div>
+                        <div class="hit-stat-label">试卷掌握度命中率</div>
                     </td>
                     </td>
                     <td class="hit-stat">
                     <td class="hit-stat">
                         <div class="hit-stat-value">{{hitStatusLearningEfficiencyText}}</div>
                         <div class="hit-stat-value">{{hitStatusLearningEfficiencyText}}</div>

+ 7 - 5
abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGeneratorTest.java

@@ -93,8 +93,8 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
                     .contains("模块二:试卷熟词量对比")
                     .contains("模块二:试卷熟词量对比")
                     .contains("模块三:实考生词命中状况")
                     .contains("模块三:实考生词命中状况")
                     .contains("词汇量提升(个)")
                     .contains("词汇量提升(个)")
-                    .contains("真题生词命中率")
-                    .contains("1.93%")
+                    .contains("试卷掌握度命中率")
+                    .contains("21.1%")
                     .contains("0.48倍")
                     .contains("0.48倍")
                     .contains("高考词汇量")
                     .contains("高考词汇量")
                     .contains("3500词")
                     .contains("3500词")
@@ -109,7 +109,9 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
                     .contains("207个")
                     .contains("207个")
                     .contains("203个")
                     .contains("203个")
                     .contains("4个")
                     .contains("4个")
-                    .containsAnyOf("number", "bear", "popular", "importance");
+                    .containsAnyOf("number", "bear", "popular", "importance")
+                    .doesNotContain("真题生词命中率")
+                    .doesNotContain("1.93%");
         }
         }
     }
     }
 
 
@@ -246,14 +248,14 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
                 new AchievementReportContent.SummaryMetrics(
                 new AchievementReportContent.SummaryMetrics(
                         "+19",
                         "+19",
                         "+4",
                         "+4",
-                        "0.0193",
+                        "21.1%",
                         "0.48"),
                         "0.48"),
                 new AchievementReportContent.Comparison(2328.0, 2347.0, "2328", "2347", "+19"),
                 new AchievementReportContent.Comparison(2328.0, 2347.0, "2328", "2347", "+19"),
                 new AchievementReportContent.Comparison(650.0, 654.0, "650", "654", "+4"),
                 new AchievementReportContent.Comparison(650.0, 654.0, "650", "654", "+4"),
                 new AchievementReportContent.StageVocabularySummary("高考", "3500", "66.51", "67.06", "+0.55"),
                 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.TestPaperVocabularySummary("2024真题", "861", "207", "203", "75.49", "75.96", "+0.62"),
                 new AchievementReportContent.ExamUnknownWordsHitStatus(
                 new AchievementReportContent.ExamUnknownWordsHitStatus(
-                        "0.0193",
+                        "21.1%",
                         "0.48",
                         "0.48",
                         "207",
                         "207",
                         "203",
                         "203",

+ 16 - 15
abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRendererTest.java

@@ -45,8 +45,8 @@ class ClasspathAchievementExamSprintReportRendererTest {
                 "试卷标题:<span class=\"highlight\">2024真题</span><br/>",
                 "试卷标题:<span class=\"highlight\">2024真题</span><br/>",
                 "试卷总词量:<span class=\"highlight\">861 词</span><br/>",
                 "试卷总词量:<span class=\"highlight\">861 词</span><br/>",
                 "训练前/后生词:<span class=\"highlight\">207 个 -&gt; 203 个</span><br/>",
                 "训练前/后生词:<span class=\"highlight\">207 个 -&gt; 203 个</span><br/>",
-                "试卷词率:<span class=\"highlight\">75.49% -&gt; 75.96%</span><br/>",
-                "试卷词率提升:<span class=\"highlight\">+0.62%</span>");
+                "试卷词率:<span class=\"highlight\">75.49% -&gt; 75.96%</span><br/>",
+                "试卷词率提升:<span class=\"highlight\">+0.62%</span>");
         assertBarFill(extractChartSvg(html, "vocabulary-growth-chart"), "chart-bar chart-bar-before", "#448aff");
         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, "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-before", "#34a853");
@@ -69,14 +69,14 @@ class ClasspathAchievementExamSprintReportRendererTest {
                 .contains("class=\"result-grid\"")
                 .contains("class=\"result-grid\"")
                 .contains("词汇量提升(个)")
                 .contains("词汇量提升(个)")
                 .contains("试卷熟词提升(个)")
                 .contains("试卷熟词提升(个)")
-                .contains("真题生词命中率")
+                .contains("试卷掌握度命中率")
                 .contains("学习效率提升")
                 .contains("学习效率提升")
                 .contains("模块一:词汇量对比")
                 .contains("模块一:词汇量对比")
                 .contains("模块二:试卷熟词量对比")
                 .contains("模块二:试卷熟词量对比")
                 .contains("模块三:实考生词命中状况")
                 .contains("模块三:实考生词命中状况")
-                .contains("class=\"result-value\">1.93%</div>")
+                .contains("class=\"result-value\">21.1%</div>")
                 .contains("class=\"result-value\">0.48倍</div>")
                 .contains("class=\"result-value\">0.48倍</div>")
-                .contains("class=\"hit-stat-value\">1.93%</div>")
+                .contains("class=\"hit-stat-value\">21.1%</div>")
                 .contains("class=\"hit-stat-value\">0.48倍</div>")
                 .contains("class=\"hit-stat-value\">0.48倍</div>")
                 .contains("class=\"hit-stat-value\">207 个</div>")
                 .contains("class=\"hit-stat-value\">207 个</div>")
                 .contains("class=\"hit-stat-value\">203 个</div>")
                 .contains("class=\"hit-stat-value\">203 个</div>")
@@ -96,7 +96,8 @@ class ClasspathAchievementExamSprintReportRendererTest {
                 .contains("class=\"word-list\"")
                 .contains("class=\"word-list\"")
                 .contains("class=\"word-item\">number</div>")
                 .contains("class=\"word-item\">number</div>")
                 .doesNotContain("193%")
                 .doesNotContain("193%")
-                .doesNotContain("1.93%%")
+                .doesNotContain("真题生词命中率")
+                .doesNotContain("21.1%%")
                 .doesNotContain("0.48倍倍")
                 .doesNotContain("0.48倍倍")
                 .doesNotContain("cdn.jsdelivr.net")
                 .doesNotContain("cdn.jsdelivr.net")
                 .doesNotContain("echarts")
                 .doesNotContain("echarts")
@@ -253,7 +254,7 @@ class ClasspathAchievementExamSprintReportRendererTest {
                 new AchievementReportContent.SummaryMetrics(
                 new AchievementReportContent.SummaryMetrics(
                         content.summaryMetrics().vocabularyGrowthText(),
                         content.summaryMetrics().vocabularyGrowthText(),
                         content.summaryMetrics().paperKnownWordsGrowthText(),
                         content.summaryMetrics().paperKnownWordsGrowthText(),
-                        "1.93%",
+                        "21.1%",
                         "0.48倍"),
                         "0.48倍"),
                 new AchievementReportContent.Comparison(
                 new AchievementReportContent.Comparison(
                         content.vocabularyComparison().beforeValue(),
                         content.vocabularyComparison().beforeValue(),
@@ -282,7 +283,7 @@ class ClasspathAchievementExamSprintReportRendererTest {
                         "75.96%",
                         "75.96%",
                         "+0.62%"),
                         "+0.62%"),
                 new AchievementReportContent.ExamUnknownWordsHitStatus(
                 new AchievementReportContent.ExamUnknownWordsHitStatus(
-                        "1.93%",
+                        "21.1%",
                         "0.48倍",
                         "0.48倍",
                         "207 个",
                         "207 个",
                         "203个",
                         "203个",
@@ -292,7 +293,7 @@ class ClasspathAchievementExamSprintReportRendererTest {
         String html = renderer.render(mutated, Instant.parse("2026-04-25T08:00:00Z"));
         String html = renderer.render(mutated, Instant.parse("2026-04-25T08:00:00Z"));
 
 
         assertThat(html)
         assertThat(html)
-                .contains("class=\"result-value\">1.93%</div>")
+                .contains("class=\"result-value\">21.1%</div>")
                 .contains("class=\"result-value\">0.48倍</div>")
                 .contains("class=\"result-value\">0.48倍</div>")
                 .contains("训练前词汇量:<span class=\"highlight\">2328 词</span><br/>")
                 .contains("训练前词汇量:<span class=\"highlight\">2328 词</span><br/>")
                 .contains("训练后词汇量:<span class=\"highlight\">2347词</span><br/>")
                 .contains("训练后词汇量:<span class=\"highlight\">2347词</span><br/>")
@@ -305,14 +306,14 @@ class ClasspathAchievementExamSprintReportRendererTest {
                 .contains("掌握率提升:<span class=\"highlight\">+0.55%</span>")
                 .contains("掌握率提升:<span class=\"highlight\">+0.55%</span>")
                 .contains("试卷总词量:<span class=\"highlight\">861 词</span><br/>")
                 .contains("试卷总词量:<span class=\"highlight\">861 词</span><br/>")
                 .contains("训练前/后生词:<span class=\"highlight\">207 个 -&gt; 203个</span><br/>")
                 .contains("训练前/后生词:<span class=\"highlight\">207 个 -&gt; 203个</span><br/>")
-                .contains("试卷词率:<span class=\"highlight\">75.49% -&gt; 75.96%</span><br/>")
-                .contains("试卷词率提升:<span class=\"highlight\">+0.62%</span>")
-                .contains("class=\"hit-stat-value\">1.93%</div>")
+                .contains("试卷词率:<span class=\"highlight\">75.49% -&gt; 75.96%</span><br/>")
+                .contains("试卷词率提升:<span class=\"highlight\">+0.62%</span>")
+                .contains("class=\"hit-stat-value\">21.1%</div>")
                 .contains("class=\"hit-stat-value\">0.48倍</div>")
                 .contains("class=\"hit-stat-value\">0.48倍</div>")
                 .contains("class=\"hit-stat-value\">207 个</div>")
                 .contains("class=\"hit-stat-value\">207 个</div>")
                 .contains("class=\"hit-stat-value\">203个</div>")
                 .contains("class=\"hit-stat-value\">203个</div>")
                 .contains("成功减少生词:<span class=\"highlight\">4 个</span>")
                 .contains("成功减少生词:<span class=\"highlight\">4 个</span>")
-                .doesNotContain("1.93%%")
+                .doesNotContain("21.1%%")
                 .doesNotContain("0.48倍倍")
                 .doesNotContain("0.48倍倍")
                 .doesNotContain("2328 词 词")
                 .doesNotContain("2328 词 词")
                 .doesNotContain("2347词 词")
                 .doesNotContain("2347词 词")
@@ -372,14 +373,14 @@ class ClasspathAchievementExamSprintReportRendererTest {
                 new AchievementReportContent.SummaryMetrics(
                 new AchievementReportContent.SummaryMetrics(
                         "+19",
                         "+19",
                         "+4",
                         "+4",
-                        "0.0193",
+                        "21.1%",
                         "0.48"),
                         "0.48"),
                 new AchievementReportContent.Comparison(2328.0, 2347.0, "2328", "2347", "+19"),
                 new AchievementReportContent.Comparison(2328.0, 2347.0, "2328", "2347", "+19"),
                 new AchievementReportContent.Comparison(650.0, 654.0, "650", "654", "+4"),
                 new AchievementReportContent.Comparison(650.0, 654.0, "650", "654", "+4"),
                 new AchievementReportContent.StageVocabularySummary("高考", "3500", "66.51", "67.06", "+0.55"),
                 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.TestPaperVocabularySummary("2024真题", "861", "207", "203", "75.49", "75.96", "+0.62"),
                 new AchievementReportContent.ExamUnknownWordsHitStatus(
                 new AchievementReportContent.ExamUnknownWordsHitStatus(
-                        "0.0193",
+                        "21.1%",
                         "0.48",
                         "0.48",
                         "207",
                         "207",
                         "203",
                         "203",

+ 130 - 0
docs/plans/2026-04-30-achievement-report-mastery-hit-rate.md

@@ -0,0 +1,130 @@
+# Achievement Report Mastery Hit Rate Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Rename the achievement-report metric from `真题生词命中率` to `试卷掌握度命中率`, and derive its displayed value from `试卷熟词提升 ÷ 词汇量提升`, rounded to 1 decimal place as a percentage, with `0%` when `词汇量提升 = 0`.
+
+**Architecture:** Keep the existing report DTO/placeholder shape intact and change only the application-layer mapping logic plus presentation text. Compute the percentage inside `AchievementReportContentMapper` from the existing delta fields so infrastructure rendering continues to consume the same `unknownWordHitRateText` placeholders without contract changes.
+
+**Tech Stack:** Java, Maven, JUnit 5, AssertJ, HTML template rendering.
+
+---
+
+### Task 1: Replace payload-driven metric with derived mastery hit rate
+
+**Files:**
+- Modify: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapper.java`
+- Test: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapperTest.java`
+- Test: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java`
+
+**Step 1: Write the failing tests**
+
+- Update mapper/service expectations so the metric is derived from `TestPaperImprovedWordCount ÷ StudentImproveWordCount` instead of `PaperMasteryHitRate`.
+- Use the existing sample payload to expect `4 ÷ 19 = 21.1%`.
+- Add a zero-denominator case expecting `0%` when `StudentImproveWordCount = 0`.
+- Make at least one test keep `PaperMasteryHitRate` at a deliberately mismatched value so RED proves the old field is no longer the source.
+
+**Step 2: Run tests to verify they fail**
+
+Run:
+
+```bash
+mvn -pl abilities/exam-sprint/application -am -Dtest=AchievementReportContentMapperTest,ExamSprintReportApplicationServiceTest test
+```
+
+Expected: FAIL because the mapper still uses `PaperMasteryHitRate` and formats `1.93%` instead of `21.1%` / `0%`.
+
+**Step 3: Write the minimal implementation**
+
+- Add a small helper in `AchievementReportContentMapper` that:
+  - reads `payload.testPaperImprovedWordCount()` as numerator
+  - reads `payload.studentImproveWordCount()` as denominator
+  - returns `"0%"` when denominator is zero
+  - otherwise computes `(numerator / denominator) * 100`
+  - rounds half up to 1 decimal place
+  - formats non-zero denominator results with exactly 1 decimal place so `20%` becomes `20.0%` while `21.1%` stays `21.1%`
+- Reuse the computed string for both `summaryMetrics.unknownWordHitRateText` and `examUnknownWordsHitStatus.unknownWordHitRateText`.
+
+**Step 4: Run tests to verify they pass**
+
+Run:
+
+```bash
+mvn -pl abilities/exam-sprint/application -am -Dtest=AchievementReportContentMapperTest,ExamSprintReportApplicationServiceTest test
+```
+
+Expected: PASS.
+
+**Step 5: Do not commit**
+
+- Leave changes uncommitted unless the user explicitly asks for a commit.
+
+### Task 2: Rename the displayed metric label everywhere in achievement report outputs
+
+**Files:**
+- Modify: `abilities/exam-sprint/infrastructure/src/main/resources/templates/achievement-exam-sprint-report-template.html`
+- Test: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRendererTest.java`
+- Test: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGeneratorTest.java`
+
+**Step 1: Write the failing tests**
+
+- Update assertions from `真题生词命中率` to `试卷掌握度命中率`.
+- Update rendered value expectations from `1.93%` to `21.1%` based on the shared sample payload.
+
+**Step 2: Run tests to verify they fail**
+
+Run:
+
+```bash
+mvn -pl abilities/exam-sprint/infrastructure -am -Dtest=ClasspathAchievementExamSprintReportRendererTest,OpenHtmlToPdfExamSprintReportPdfGeneratorTest test
+```
+
+Expected: FAIL because the template still shows the old label and existing fixture-derived values still render `1.93%`.
+
+**Step 3: Write the minimal implementation**
+
+- Change the two achievement-template labels from `真题生词命中率` to `试卷掌握度命中率`.
+- Keep placeholder names unchanged.
+
+**Step 4: Run tests to verify they pass**
+
+Run:
+
+```bash
+mvn -pl abilities/exam-sprint/infrastructure -am -Dtest=ClasspathAchievementExamSprintReportRendererTest,OpenHtmlToPdfExamSprintReportPdfGeneratorTest test
+```
+
+Expected: PASS.
+
+**Step 5: Do not commit**
+
+- Leave changes uncommitted unless the user explicitly asks for a commit.
+
+### Task 3: End-to-end verification of the final achievement report behavior
+
+**Files:**
+- Verify only: no new files expected
+
+**Step 1: Run the combined verification suite**
+
+Run:
+
+```bash
+mvn -pl abilities/exam-sprint/infrastructure,abilities/exam-sprint/application -am -Dtest=AchievementReportContentMapperTest,ExamSprintReportApplicationServiceTest,ClasspathAchievementExamSprintReportRendererTest,OpenHtmlToPdfExamSprintReportPdfGeneratorTest test
+```
+
+Expected: PASS with zero failures.
+
+**Step 2: Inspect the final diff**
+
+Run:
+
+```bash
+git diff -- abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapper.java abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapperTest.java abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java abilities/exam-sprint/infrastructure/src/main/resources/templates/achievement-exam-sprint-report-template.html abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRendererTest.java abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGeneratorTest.java
+```
+
+Expected: Only the derived-metric logic, renamed labels, and directly related test updates appear.
+
+**Step 3: Do not commit**
+
+- Stop after reporting verification evidence unless the user explicitly asks for a commit.