2026-04-29-exam-sprint-report-filename.md 17 KB

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:

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

Expect:

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

Update processLogsSuccessfulGenerationStages(...) to expect:

.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:

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

Add a focused fallback test:

@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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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():

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

Step 2: Add missing-name fallback service test

Add a sync test for outlook payload missing studentName:

@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:

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:

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:

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:

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:

mvn -pl abilities/exam-sprint/application test

Expected: PASS.

Step 2: Run full test suite

Run:

mvn test

Expected: BUILD SUCCESS.

Step 3: Review diff

Run:

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:

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:

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.