Forráskód Böngészése

refactor(exam-sprint): 收敛成果报告防御式兜底

金逸霄 2 hete
szülő
commit
0b4b962304

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

@@ -62,7 +62,7 @@ final class AchievementReportContentMapper {
     }
 
     private static String reportTitle(String stageName) {
-        String normalizedStageName = stageName == null ? "" : stageName.trim();
+        String normalizedStageName = Objects.requireNonNull(stageName, "stageName").trim();
         if (normalizedStageName.contains("英语")) {
             return normalizedStageName + "临考突击学习成果报告";
         }

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

@@ -91,6 +91,42 @@ class AchievementReportContentMapperTest {
                 .hasMessageContaining("payload");
     }
 
+    @Test
+    void rejectsNullStageNameInsteadOfSilentlyFallingBack() {
+        AchievementExamSprintReportPayload payload = payload();
+        AchievementExamSprintReportPayload payloadWithoutStageName = new AchievementExamSprintReportPayload(
+                payload.studentName(),
+                payload.studentStage(),
+                null,
+                payload.stageVocabulary(),
+                payload.studentVocabulary(),
+                payload.studentVocabularyBefore(),
+                payload.studentUnMastedWordCount(),
+                payload.studentImproveWordCount(),
+                payload.testPaperTitle(),
+                payload.testPaperWordCount(),
+                payload.testPaperBeforUnMastery(),
+                payload.testPaperBeforMastery(),
+                payload.testPaperLatestMastery(),
+                payload.testPaperAfterUnMastery(),
+                payload.testPaperImprovedWords(),
+                payload.testPaperImprovedWordCount(),
+                payload.testPaperImproveRate(),
+                payload.paperMasteryHitRate(),
+                payload.improveStudyEfficiency(),
+                payload.studentInitialVocabMastery(),
+                payload.studentCurrentVocabMastery(),
+                payload.studentVocabMasteryImprovement(),
+                payload.testPaperBeforMasteryRate(),
+                payload.testPaperLatestMasteryRate(),
+                payload.shouldDisplaySigningGuarantee(),
+                payload.signingGuarantee());
+
+        assertThatThrownBy(() -> AchievementReportContentMapper.toDomainContent(payloadWithoutStageName))
+                .isInstanceOf(NullPointerException.class)
+                .hasMessageContaining("stageName");
+    }
+
     private AchievementExamSprintReportPayload payload() {
         return convert(pascalPayload());
     }

+ 1 - 1
abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContent.java

@@ -72,7 +72,7 @@ public record AchievementReportContent(
             List<String> hitWords) {
 
         public ExamUnknownWordsHitStatus {
-            hitWords = hitWords == null ? null : List.copyOf(hitWords);
+            hitWords = hitWords == null ? List.of() : List.copyOf(hitWords);
         }
     }
 }

+ 9 - 0
abilities/exam-sprint/domain/src/test/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContentTest.java

@@ -39,6 +39,15 @@ class AchievementReportContentTest {
                 .hasMessageContaining("summaryMetrics");
     }
 
+    @Test
+    void normalizesNullHitWordsToEmptyImmutableList() {
+        AchievementReportContent.ExamUnknownWordsHitStatus hitStatus = hitStatus(null);
+
+        assertThat(hitStatus.hitWords()).isEmpty();
+        assertThatThrownBy(() -> hitStatus.hitWords().add("mutated"))
+                .isInstanceOf(UnsupportedOperationException.class);
+    }
+
     private AchievementReportContent sampleContent(List<String> hitWords) {
         return new AchievementReportContent(
                 "吴泓妤",

+ 1 - 1
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRenderer.java

@@ -158,7 +158,7 @@ public class ClasspathAchievementExamSprintReportRenderer implements ExamSprintR
     }
 
     private String renderHitWords(AchievementReportContent.ExamUnknownWordsHitStatus hitStatus) {
-        if (hitStatus == null || hitStatus.hitWords() == null || hitStatus.hitWords().isEmpty()) {
+        if (hitStatus.hitWords().isEmpty()) {
             return "<div class=\"word-empty\">暂无命中单词</div>";
         }
 

+ 109 - 0
docs/plans/2026-04-30-defensive-programming-pass1.md

@@ -0,0 +1,109 @@
+# Defensive Programming Pass 1 Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** 以保守方式收敛两处明显的过度防御:把 `AchievementReportContent` 的 `hitWords` 归一化为非 null,不再让 renderer 对内部不变量重复判空;让 `AchievementReportContentMapper` 对不应出现的 `stageName == null` fail-fast,而不是静默兜底成空字符串。
+
+**Architecture:** 本轮只收敛“内部不变量已建立后仍重复兜底”的分支,不改 HTTP 契约、不改输入校验总策略。实现上通过领域对象归一化 `hitWords` 消除下游 null 防御,再通过 mapper 的 fail-fast 去掉 `stageName` 空串兜底。
+
+**Tech Stack:** Java 21, Maven, JUnit 5, AssertJ, Spring Boot
+
+---
+
+### Task 1: Normalize achievement hit words to an immutable non-null list
+
+**Files:**
+- Modify: `abilities/exam-sprint/domain/src/test/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContentTest.java`
+- Modify: `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContent.java`
+- Modify: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRenderer.java`
+
+**Step 1: Write the failing test**
+
+在 `AchievementReportContentTest` 增加一个测试,断言当 `ExamUnknownWordsHitStatus` 传入 `null` 的 `hitWords` 时:
+- `hitWords()` 返回空列表而不是 `null`
+- 返回值不可变
+
+测试名建议:`normalizesNullHitWordsToEmptyImmutableList`
+
+**Step 2: Run test to verify it fails**
+
+Run: `mvn -q -pl abilities/exam-sprint/domain -Dtest=AchievementReportContentTest#normalizesNullHitWordsToEmptyImmutableList test`
+
+Expected: FAIL,因为当前实现会保留 `null`
+
+**Step 3: Write minimal implementation**
+
+在 `AchievementReportContent.ExamUnknownWordsHitStatus` 构造器中,把 `hitWords` 归一化为 `List.of()` 或 `List.copyOf(...)` 的结果,保证字段永远非 null 且不可变。
+
+然后在 `ClasspathAchievementExamSprintReportRenderer.renderHitWords(...)` 中删掉对 `hitStatus == null` 和 `hitStatus.hitWords() == null` 的重复防御,只保留对空列表的空态渲染。
+
+**Step 4: Run test to verify it passes**
+
+Run: `mvn -q -pl abilities/exam-sprint/domain -Dtest=AchievementReportContentTest#normalizesNullHitWordsToEmptyImmutableList test`
+
+Expected: PASS
+
+**Step 5: Run focused regression tests**
+
+Run: `mvn -q -pl abilities/exam-sprint/infrastructure -am -Dtest=ClasspathAchievementExamSprintReportRendererTest test`
+
+Expected: PASS,尤其覆盖“hitWords 为空 / null 时渲染空态”的现有行为
+
+### Task 2: Fail fast on impossible null stage name in achievement mapper
+
+**Files:**
+- Modify: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapperTest.java`
+- Modify: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapper.java`
+
+**Step 1: Write the failing test**
+
+在 `AchievementReportContentMapperTest` 增加一个测试,构造 `AchievementExamSprintReportPayload`,其中 `stageName` 为 `null`,并断言 `toDomainContent(...)` 抛出 `NullPointerException` 且消息包含 `stageName`。
+
+测试名建议:`rejectsNullStageNameInsteadOfSilentlyFallingBack`
+
+**Step 2: Run test to verify it fails**
+
+Run: `mvn -q -pl abilities/exam-sprint/application -am -Dtest=AchievementReportContentMapperTest#rejectsNullStageNameInsteadOfSilentlyFallingBack test`
+
+Expected: FAIL,因为当前实现会把 `null` 兜底为 `""`
+
+**Step 3: Write minimal implementation**
+
+在 `AchievementReportContentMapper.reportTitle(...)` 中用 `Objects.requireNonNull(stageName, "stageName")` 明确内部不变量,再对非 null 字符串做 `trim()`。
+
+不要顺手调整其他 mapper/null 策略。
+
+**Step 4: Run test to verify it passes**
+
+Run: `mvn -q -pl abilities/exam-sprint/application -am -Dtest=AchievementReportContentMapperTest#rejectsNullStageNameInsteadOfSilentlyFallingBack test`
+
+Expected: PASS
+
+**Step 5: Run focused regression tests**
+
+Run: `mvn -q -pl abilities/exam-sprint/application -am -Dtest=AchievementReportContentMapperTest test`
+
+Expected: PASS
+
+### Task 3: Final verification for the conservative pass
+
+**Files:**
+- Verify only; no additional code changes expected
+
+**Step 1: Run the targeted module tests together**
+
+Run: `mvn -q -pl abilities/exam-sprint/domain,abilities/exam-sprint/application,abilities/exam-sprint/infrastructure -am -Dtest=AchievementReportContentTest,AchievementReportContentMapperTest,ClasspathAchievementExamSprintReportRendererTest test`
+
+Expected: PASS
+
+**Step 2: Run full repository tests**
+
+Run: `mvn test -q`
+
+Expected: PASS
+
+**Step 3: Review diff for scope control**
+
+Run: `git diff -- abilities/exam-sprint/domain abilities/exam-sprint/application abilities/exam-sprint/infrastructure docs/plans/2026-04-30-defensive-programming-pass1.md`
+
+Expected: 只包含计划内的保守收敛改动