# Exam Sprint Report Filename Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Generate 临考词汇突击 PDF filenames as `{StudentNameOrReportId}-临考词汇突击成果报告/潜力展望报告-{AsiaShanghaiTimestamp}.pdf`.

**Architecture:** Keep filename generation centralized in `ExamSprintReportGenerationPipeline`, because it already builds the upload filename and has access to `ExamSprintReport`, `ReportType`, content, and the injected `Clock`. Propagate optional lowercase `studentName` through the achievement contract/domain mapper, add lowercase alias validation support for outlook, and stop default in-memory storage from parsing report id out of localized filenames. Tests should lock worker-level generation, service-level sync behavior, and in-memory storage URL generation so persisted `storageObjectKey`, persisted `fileName`, and download behavior remain consistent.

**Tech Stack:** Java 17, Maven, JUnit 5, AssertJ, Jackson `JsonNode`, Spring Boot test utilities.

---

### Task 1: Worker-level filename tests

**Files:**
- Modify: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java`

**Step 1: Write failing tests and update existing expectations**

Update `processMarksReportSuccessAfterUpload()` to use an outlook payload containing root `studentName`:

```java
repository.save(ExamSprintReport.pending(
        "report-success",
        ReportType.OUTLOOK,
        unmodeledOutlookContentWithStudentName("冯亿豪"),
        FIXED_CLOCK.instant(),
        FIXED_CLOCK.instant().plusSeconds(86400)));
```

Expect:

```java
assertThat(report.storageObjectKey()).isEqualTo("冯亿豪-临考词汇突击潜力展望报告-20260101080000.pdf");
assertThat(report.fileName()).isEqualTo("冯亿豪-临考词汇突击潜力展望报告-20260101080000.pdf");
```

Update `processLogsSuccessfulGenerationStages(...)` to expect:

```java
.contains("storageObjectKey=report-log-success-临考词汇突击潜力展望报告-20260101080000.pdf")
```

because its helper can keep an empty payload to exercise fallback behavior.

Update `processCreatesAchievementFileNameAndStorageKeyAfterUpload()` to use achievement content with `studentName = "吴泓妤"`. Expect:

```java
assertThat(report.fileName()).isEqualTo("吴泓妤-临考词汇突击成果报告-20260101080000.pdf");
assertThat(report.storageObjectKey()).isEqualTo("吴泓妤-临考词汇突击成果报告-20260101080000.pdf");
```

Add a focused fallback test:

```java
@Test
void processFallsBackToReportIdWhenStudentNameIsBlank() {
    TestRepository repository = new TestRepository();
    repository.save(ExamSprintReport.pending(
            "report-blank-student",
            ReportType.OUTLOOK,
            unmodeledOutlookContentWithStudentName("   "),
            FIXED_CLOCK.instant(),
            FIXED_CLOCK.instant().plusSeconds(86400)));
    TestStorage storage = new TestStorage();
    ExamSprintReportGenerationWorker worker = createWorker(repository, List.of(new TestRenderer()), storage);

    worker.process("report-blank-student");

    ExamSprintReport report = repository.findById("report-blank-student").orElseThrow();
    assertThat(report.fileName()).isEqualTo("report-blank-student-临考词汇突击潜力展望报告-20260101080000.pdf");
    assertThat(report.storageObjectKey()).isEqualTo(report.fileName());
}
```

Add helper:

```java
private ReportContent unmodeledOutlookContentWithStudentName(String studentName) {
    return new UnmodeledReportContent(
            ReportType.OUTLOOK,
            OBJECT_MAPPER.createObjectNode().put("studentName", studentName));
}
```

**Step 2: Run worker tests to verify failure**

Run:

```bash
mvn -pl abilities/exam-sprint/application -Dtest=ExamSprintReportGenerationWorkerTest test
```

Expected: FAIL with assertions showing old filenames like `exam-sprint-outlook-report-report-success.pdf`.

### Task 2: Propagate achievement `studentName`

**Files:**
- Modify: `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/AchievementExamSprintReportPayload.java`
- Modify: `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContent.java`
- Modify: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapper.java`
- Modify tests that instantiate `AchievementExamSprintReportPayload` or `AchievementReportContent` directly.

**Step 1: Add optional contract field**

Add lowercase `studentName` as the first record component without validation annotations:

```java
public record AchievementExamSprintReportPayload(
        String studentName,
        @NotBlank String reportTitle,
        ...)
```

Because Java records use positional constructors, update existing `new AchievementExamSprintReportPayload(...)` calls by adding a first argument such as `"吴泓妤"` in happy-path tests or `null` where the name is irrelevant.

**Step 2: Add optional domain field**

Add `String studentName` as the first `AchievementReportContent` component:

```java
public record AchievementReportContent(
        String studentName,
        String reportTitle,
        ...)
```

Do not add `Objects.requireNonNull(studentName, ...)`; missing names must be valid.

**Step 3: Map the field**

In `AchievementReportContentMapper.toDomainContent(...)`, pass `payload.studentName()` into the new first domain constructor argument.

**Step 4: Run focused compile/test**

Run:

```bash
mvn -pl abilities/exam-sprint/application -Dtest=AchievementReportContentMapperTest,ExamSprintReportGenerationWorkerTest test
```

Expected: compilation succeeds after all constructor call sites are updated. Worker filename assertions may still fail until Task 3 implements the pipeline filename rule.

### Task 3: Implement pipeline filename generation

**Files:**
- Modify: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.java`

**Step 1: Add imports**

Add:

```java
import cn.yunzhixue.ability.center.examsprint.domain.report.AchievementReportContent;
import cn.yunzhixue.ability.center.examsprint.domain.report.ReportContent;
import cn.yunzhixue.ability.center.examsprint.domain.report.UnmodeledReportContent;
import com.fasterxml.jackson.databind.JsonNode;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
```

`ReportContent` and `UnmodeledReportContent` may already be absent from this file; only add what is needed.

**Step 2: Add constants**

Near the logger:

```java
private static final ZoneId REPORT_FILENAME_ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter REPORT_FILENAME_TIMESTAMP_FORMATTER =
        DateTimeFormatter.ofPattern("yyyyMMddHHmmss").withZone(REPORT_FILENAME_ZONE);
```

**Step 3: Replace `fileNameOf(...)`**

Replace the existing method with:

```java
private String fileNameOf(ExamSprintReport report) {
    return studentNameOrReportId(report) + "-" + reportTitle(report.reportType()) + "-"
            + REPORT_FILENAME_TIMESTAMP_FORMATTER.format(clock.instant()) + ".pdf";
}
```

**Step 4: Add helper methods**

Add:

```java
private String studentNameOrReportId(ExamSprintReport report) {
    return studentNameFromContent(report.content())
            .filter(name -> !name.isBlank())
            .map(String::trim)
            .orElse(report.reportId());
}

private Optional<String> studentNameFromContent(ReportContent content) {
    if (content instanceof UnmodeledReportContent unmodeledReportContent
            && unmodeledReportContent.source() instanceof JsonNode payload) {
        JsonNode studentName = payload.get("studentName");
        if (studentName != null && studentName.isTextual()) {
            return Optional.of(studentName.asText());
        }
    }
    if (content instanceof AchievementReportContent achievementReportContent) {
        return Optional.ofNullable(achievementReportContent.studentName());
    }
    return Optional.empty();
}

private String reportTitle(ReportType reportType) {
    return switch (reportType) {
        case ACHIEVEMENT -> "临考词汇突击成果报告";
        case OUTLOOK -> "临考词汇突击潜力展望报告";
    };
}
```

**Step 5: Run worker tests**

Run:

```bash
mvn -pl abilities/exam-sprint/application -Dtest=ExamSprintReportGenerationWorkerTest test
```

Expected: PASS.

### Task 4: Service-level sync tests

**Files:**
- Modify: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java`

**Step 1: Update sync filename expectations**

Update `createOutlookReportSyncGeneratesUploadAndReturnsDownloadUrl()` to add lowercase `studentName` to the existing valid outlook payload and expect the Shanghai timestamp. Because `FIXED_CLOCK = 2026-01-02T00:00:00Z`, expected timestamp is `20260102080000`:

```java
ObjectNode payload = validOutlookPayload();
payload.put("studentName", "冯亿豪");

var response = service.createOutlookReportSync(payload);

String expectedFileName = "冯亿豪-临考词汇突击潜力展望报告-20260102080000.pdf";
assertThat(saved.storageObjectKey()).isEqualTo(expectedFileName);
assertThat(saved.fileName()).isEqualTo(expectedFileName);
assertThat(storage.generatedKeys).containsExactly(expectedFileName);
```

Update `createOutlookReportSyncAcceptsCallerVocabularyPayloadAndReturnsDownloadUrl()` similarly if it should assert the filename, otherwise keep its existing renderer-focused assertions and add a separate filename test.

Update `createAchievementReportSyncGeneratesUploadAndReturnsDownloadUrl()` using the `studentName` from `validAchievementPayload()`:

```java
String expectedFileName = "吴泓妤-临考词汇突击成果报告-20260102080000.pdf";
```

**Step 2: Add missing-name fallback service test**

Add a sync test for outlook payload missing `studentName`:

```java
@Test
void createOutlookReportSyncFallsBackToReportIdWhenStudentNameIsMissing() {
    TestRepository repository = new TestRepository();
    TestStorage storage = new TestStorage();
    DefaultExamSprintReportApplicationService service = service(
            repository,
            reportId -> {
                throw new IllegalStateException("sync create must not dispatch async generation");
            },
            storage);
    ObjectNode payload = validOutlookPayload().deepCopy();
    payload.remove("studentName");

    var response = service.createOutlookReportSync(payload);

    String expectedFileName = response.reportId() + "-临考词汇突击潜力展望报告-20260102080000.pdf";
    ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
    assertThat(saved.storageObjectKey()).isEqualTo(expectedFileName);
    assertThat(saved.fileName()).isEqualTo(expectedFileName);
}
```

If `validOutlookPayload()` uses `StudentName` instead of lowercase `studentName`, add lowercase `studentName` to the test payload explicitly for the new naming contract, and keep renderer-specific `StudentName` assertions unchanged.

**Step 3: Run application service tests**

Run:

```bash
mvn -pl abilities/exam-sprint/application -Dtest=ExamSprintReportApplicationServiceTest test
```

Expected: PASS.

### Task 5: Outlook validation alias and trim review fixes

**Files:**
- Modify: `abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload.java`
- Modify: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java`
- Modify: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.java`
- Modify: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java`

**Step 1: Add outlook lowercase alias without global ObjectMapper relaxation**

Add `@JsonAlias("studentName")` to the existing `@JsonProperty("StudentName")` component in `OutlookExamSprintReportPayload`. Do not disable `FAIL_ON_UNKNOWN_PROPERTIES` globally in tests or production.

**Step 2: Keep validation side effects local**

When both `StudentName` and lowercase `studentName` exist, validate a copied payload that removes lowercase `studentName`, while preserving the original raw payload for persistence and filename generation.

**Step 3: Trim filename student names**

In `ExamSprintReportGenerationPipeline`, trim `studentName` before deciding whether to use it or fall back to `reportId`.

**Step 4: Add trim coverage**

Add a worker-level test where `studentName = "  冯亿豪  "` yields `冯亿豪-临考词汇突击潜力展望报告-20260101080000.pdf`.

**Step 5: Verify**

Run:

```bash
mvn -pl abilities/exam-sprint/application -Dtest=ExamSprintReportGenerationWorkerTest,ExamSprintReportApplicationServiceTest test
mvn -pl abilities/exam-sprint/application test
```

Expected: PASS.

### Task 6: In-memory storage localized filename compatibility

**Files:**
- Modify: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/InMemoryExamSprintReportStorage.java`
- Create: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/InMemoryExamSprintReportStorageTest.java`

**Step 1: Write failing storage test**

Create a test that uploads `fileName = "冯亿豪-临考词汇突击潜力展望报告-20260102080000.pdf"` with `reportId = "report-001"`, then expects `generateDownloadUrl(...)` to return `/api/exam-sprint/reports/report-001/download`.

Also cover `download(...)` returning the original localized filename, `application/pdf`, and defensive byte copies.

**Step 2: Verify RED**

Run:

```bash
mvn -pl abilities/exam-sprint/infrastructure -Dtest=InMemoryExamSprintReportStorageTest test
```

Expected: FAIL because old in-memory storage parses report id from the legacy filename prefix.

**Step 3: Implement storage fix**

Store the `reportId` supplied to `upload(...)` in the in-memory stored record and have `generateDownloadUrl(...)` use that stored value instead of parsing `storageObjectKey`.

**Step 4: Verify GREEN**

Run:

```bash
mvn -pl abilities/exam-sprint/infrastructure -Dtest=InMemoryExamSprintReportStorageTest test
mvn -pl abilities/exam-sprint/infrastructure test
```

Expected: PASS.

### Task 7: Regression verification

**Files:**
- No code changes expected.

**Step 1: Run application module tests**

Run:

```bash
mvn -pl abilities/exam-sprint/application test
```

Expected: PASS.

**Step 2: Run full test suite**

Run:

```bash
mvn test
```

Expected: BUILD SUCCESS.

**Step 3: Review diff**

Run:

```bash
git diff -- abilities/exam-sprint/application docs/superpowers/specs/2026-04-29-exam-sprint-report-filename-design.md docs/plans/2026-04-29-exam-sprint-report-filename.md
```

Expected: Diff only contains filename tests, pipeline filename generation, and the docs for this change.

### Task 8: Commit if requested

**Files:**
- Commit all modified files from this plan only.

**Step 1: Check status**

Run:

```bash
git status --short
```

Expected: Only the planned docs, test, and pipeline files are modified or untracked.

**Step 2: Commit only if the user explicitly asks**

Run only after explicit commit approval:

```bash
git add docs/superpowers/specs/2026-04-29-exam-sprint-report-filename-design.md \
  docs/plans/2026-04-29-exam-sprint-report-filename.md \
  abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.java \
  abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapper.java \
  abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java \
  abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/AchievementExamSprintReportPayload.java \
  abilities/exam-sprint/contracts/src/main/java/cn/yunzhixue/ability/center/examsprint/contracts/report/OutlookExamSprintReportPayload.java \
  abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContent.java \
  abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/InMemoryExamSprintReportStorage.java \
  abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/InMemoryExamSprintReportStorageTest.java \
  abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java \
  abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java
git commit -m "fix(exam-sprint): 调整报告 PDF 文件名"
```

Expected: Commit succeeds without bypassing hooks.
