|
|
@@ -0,0 +1,3116 @@
|
|
|
+# Vocabulary Survey Implementation Plan
|
|
|
+
|
|
|
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
+
|
|
|
+**Goal:** Build the mini-program vocabulary survey backend with grouped adaptive levels, MessagePack dictionary loading, in-memory single-node sessions, HTTP APIs, and tests.
|
|
|
+
|
|
|
+**Architecture:** Add a new `vocabulary-survey` ability that mirrors the existing Clean Architecture layout: `contracts`, `domain`, `application`, and `infrastructure`. Keep business rules in domain/application, keep MessagePack and in-memory technical implementations in infrastructure, and keep runtime limited to HTTP/controller/config wiring.
|
|
|
+
|
|
|
+**Tech Stack:** Java 17, Spring Boot 3.3.5, Maven multi-module, JUnit 5, AssertJ, MockMvc, ArchUnit, MessagePack Java (`org.msgpack:jackson-dataformat-msgpack` with `msgpack-core` fallback).
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Constraints for implementers
|
|
|
+
|
|
|
+- Do not commit unless the user explicitly authorizes commits. Use review checkpoints instead of `git commit` steps.
|
|
|
+- Do not print, copy into chat, or modify existing secret values. In particular, preserve existing `ability-center-runtime/src/main/resources/application-test.yml` secret-bearing lines without restating their values.
|
|
|
+- Copy only the authorized dictionary file from outside the workspace:
|
|
|
+ `F:\Codes\Projects\qingti.teaching.api\Qingti.Teaching.Dictionary\Resources\dict.data`.
|
|
|
+- Do not create database tables in this iteration.
|
|
|
+- Session recovery and idempotency only need to work inside the same service process.
|
|
|
+- Tests must not require real database credentials.
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## File structure to create or modify
|
|
|
+
|
|
|
+### Root and runtime
|
|
|
+
|
|
|
+- Modify: `pom.xml` — add four new `vocabulary-survey` modules.
|
|
|
+- Modify: `ability-center-runtime/pom.xml` — depend on the new contracts/application/domain/infrastructure modules.
|
|
|
+- Create: `ability-center-runtime/src/main/resources/application-dev.yml` — dev datasource with blank credentials and vocabulary config.
|
|
|
+- Modify: `ability-center-runtime/src/main/resources/application-test.yml` — append datasource with blank credentials and vocabulary config while preserving existing report config.
|
|
|
+- Modify: `ability-center-runtime/src/main/resources/application.yml` — add default `ability.vocabulary-survey.dictionary.location`.
|
|
|
+- Create: `ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/configuration/VocabularySurveyRuntimeConfiguration.java` — bind vocabulary survey properties.
|
|
|
+- Create: `ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/adapter/http/VocabularySurveyController.java` — mini-program routes.
|
|
|
+- Create: `ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/vocabularysurvey/adapter/http/VocabularySurveyControllerWebMvcTest.java` — web boundary tests.
|
|
|
+- Create: `ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/architecture/VocabularySurveyArchitectureTest.java` — architecture boundary tests.
|
|
|
+
|
|
|
+### Kernel
|
|
|
+
|
|
|
+- Modify: `ability-center-kernel/src/main/java/cn/yunzhixue/ability/center/kernel/ErrorCode.java` — add survey error codes.
|
|
|
+
|
|
|
+### Contracts module
|
|
|
+
|
|
|
+- Create: `abilities/vocabulary-survey/contracts/pom.xml`
|
|
|
+- Create: `abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/StartVocabularySurveyRequest.java`
|
|
|
+- Create: `abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/SubmitVocabularySurveyLevelRequest.java`
|
|
|
+- Create: `abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/VocabularySurveyStatus.java`
|
|
|
+- Create: `abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/VocabularySurveyWordResponse.java`
|
|
|
+- Create: `abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/VocabularySurveyLevelResponse.java`
|
|
|
+- Create: `abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/VocabularySurveyLearningRangeResponse.java`
|
|
|
+- Create: `abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/VocabularySurveyReportResponse.java`
|
|
|
+- Create: `abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/VocabularySurveySessionResponse.java`
|
|
|
+- Create: `abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/SubmitVocabularySurveyLevelResponse.java`
|
|
|
+
|
|
|
+### Domain module
|
|
|
+
|
|
|
+- Create: `abilities/vocabulary-survey/domain/pom.xml`
|
|
|
+- Create domain source files under `abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/`:
|
|
|
+ - `VocabularyWord.java`
|
|
|
+ - `VocabularyWordProvider.java`
|
|
|
+ - `VocabularySurveyStatus.java`
|
|
|
+ - `VocabularySurveyGradePolicy.java`
|
|
|
+ - `VocabularySurveyIds.java`
|
|
|
+ - `VocabularySurveyWord.java`
|
|
|
+ - `VocabularySurveyLevel.java`
|
|
|
+ - `VocabularySurveyReport.java`
|
|
|
+ - `VocabularySurveySession.java`
|
|
|
+ - `VocabularySurveySessionRepository.java`
|
|
|
+ - `VocabularySurveyLevelPlanner.java`
|
|
|
+ - `VocabularySurveyKEstimator.java`
|
|
|
+ - `VocabularySurveyReportGenerator.java`
|
|
|
+- Create tests under `abilities/vocabulary-survey/domain/src/test/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/`:
|
|
|
+ - `VocabularySurveyGradePolicyTest.java`
|
|
|
+ - `VocabularySurveyLevelPlannerTest.java`
|
|
|
+ - `VocabularySurveyKEstimatorTest.java`
|
|
|
+ - `VocabularySurveyReportGeneratorTest.java`
|
|
|
+ - `VocabularySurveySessionTest.java`
|
|
|
+
|
|
|
+### Application module
|
|
|
+
|
|
|
+- Create: `abilities/vocabulary-survey/application/pom.xml`
|
|
|
+- Create source files under `abilities/vocabulary-survey/application/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/application/`:
|
|
|
+ - `VocabularySurveyProperties.java`
|
|
|
+ - `VocabularySurveyApplicationService.java`
|
|
|
+ - `StartVocabularySurveyCommand.java`
|
|
|
+ - `SubmitVocabularySurveyLevelCommand.java`
|
|
|
+ - `DefaultVocabularySurveyApplicationService.java`
|
|
|
+ - `VocabularySurveyResultMapper.java`
|
|
|
+- Create test: `abilities/vocabulary-survey/application/src/test/java/cn/yunzhixue/ability/center/vocabularysurvey/application/DefaultVocabularySurveyApplicationServiceTest.java`
|
|
|
+
|
|
|
+### Infrastructure module
|
|
|
+
|
|
|
+- Create: `abilities/vocabulary-survey/infrastructure/pom.xml`
|
|
|
+- Copy: `F:\Codes\Projects\qingti.teaching.api\Qingti.Teaching.Dictionary\Resources\dict.data` to `abilities/vocabulary-survey/infrastructure/src/main/resources/data/dict.data`
|
|
|
+- Create source files under `abilities/vocabulary-survey/infrastructure/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/infrastructure/`:
|
|
|
+ - `dictionary/DictionaryObject.java`
|
|
|
+ - `dictionary/DictionaryWord.java`
|
|
|
+ - `dictionary/DictionaryMeaning.java`
|
|
|
+ - `dictionary/DictionaryExchange.java`
|
|
|
+ - `dictionary/MessagePackDictionaryLoader.java`
|
|
|
+ - `dictionary/MessagePackVocabularyWordProvider.java`
|
|
|
+ - `repository/InMemoryVocabularySurveySessionRepository.java`
|
|
|
+- Create tests under `abilities/vocabulary-survey/infrastructure/src/test/java/cn/yunzhixue/ability/center/vocabularysurvey/infrastructure/`:
|
|
|
+ - `dictionary/MessagePackVocabularyWordProviderTest.java`
|
|
|
+ - `repository/InMemoryVocabularySurveySessionRepositoryTest.java`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 1: Add Maven modules and safe runtime configuration
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `pom.xml`
|
|
|
+- Modify: `ability-center-runtime/pom.xml`
|
|
|
+- Modify: `ability-center-kernel/src/main/java/cn/yunzhixue/ability/center/kernel/ErrorCode.java`
|
|
|
+- Create: `abilities/vocabulary-survey/contracts/pom.xml`
|
|
|
+- Create: `abilities/vocabulary-survey/domain/pom.xml`
|
|
|
+- Create: `abilities/vocabulary-survey/application/pom.xml`
|
|
|
+- Create: `abilities/vocabulary-survey/infrastructure/pom.xml`
|
|
|
+- Modify: `ability-center-runtime/src/main/resources/application.yml`
|
|
|
+- Create: `ability-center-runtime/src/main/resources/application-dev.yml`
|
|
|
+- Modify: `ability-center-runtime/src/main/resources/application-test.yml`
|
|
|
+
|
|
|
+- [ ] **Step 1: Run a module-targeted command to record the current failure**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```powershell
|
|
|
+mvn -pl abilities/vocabulary-survey/domain test
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL because the module path does not exist yet.
|
|
|
+
|
|
|
+- [ ] **Step 2: Add modules to the root Maven build**
|
|
|
+
|
|
|
+Modify `pom.xml` so the `<modules>` block becomes:
|
|
|
+
|
|
|
+```xml
|
|
|
+<modules>
|
|
|
+ <module>ability-center-runtime</module>
|
|
|
+ <module>ability-center-kernel</module>
|
|
|
+ <module>abilities/exam-sprint/contracts</module>
|
|
|
+ <module>abilities/exam-sprint/application</module>
|
|
|
+ <module>abilities/exam-sprint/domain</module>
|
|
|
+ <module>abilities/exam-sprint/infrastructure</module>
|
|
|
+ <module>abilities/vocabulary-survey/contracts</module>
|
|
|
+ <module>abilities/vocabulary-survey/domain</module>
|
|
|
+ <module>abilities/vocabulary-survey/application</module>
|
|
|
+ <module>abilities/vocabulary-survey/infrastructure</module>
|
|
|
+</modules>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Create the contracts module POM**
|
|
|
+
|
|
|
+Create `abilities/vocabulary-survey/contracts/pom.xml`:
|
|
|
+
|
|
|
+```xml
|
|
|
+<project xmlns="http://maven.apache.org/POM/4.0.0"
|
|
|
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
|
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
|
|
+ <modelVersion>4.0.0</modelVersion>
|
|
|
+ <parent>
|
|
|
+ <groupId>cn.yunzhixue</groupId>
|
|
|
+ <artifactId>dcjxb.microservice</artifactId>
|
|
|
+ <version>0.0.1-SNAPSHOT</version>
|
|
|
+ <relativePath>../../../pom.xml</relativePath>
|
|
|
+ </parent>
|
|
|
+ <artifactId>vocabulary-survey-contracts</artifactId>
|
|
|
+ <name>vocabulary-survey-contracts</name>
|
|
|
+ <dependencies>
|
|
|
+ <dependency>
|
|
|
+ <groupId>jakarta.validation</groupId>
|
|
|
+ <artifactId>jakarta.validation-api</artifactId>
|
|
|
+ </dependency>
|
|
|
+ <dependency>
|
|
|
+ <groupId>com.fasterxml.jackson.core</groupId>
|
|
|
+ <artifactId>jackson-databind</artifactId>
|
|
|
+ </dependency>
|
|
|
+ </dependencies>
|
|
|
+</project>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Create the domain module POM**
|
|
|
+
|
|
|
+Create `abilities/vocabulary-survey/domain/pom.xml`:
|
|
|
+
|
|
|
+```xml
|
|
|
+<project xmlns="http://maven.apache.org/POM/4.0.0"
|
|
|
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
|
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
|
|
+ <modelVersion>4.0.0</modelVersion>
|
|
|
+ <parent>
|
|
|
+ <groupId>cn.yunzhixue</groupId>
|
|
|
+ <artifactId>dcjxb.microservice</artifactId>
|
|
|
+ <version>0.0.1-SNAPSHOT</version>
|
|
|
+ <relativePath>../../../pom.xml</relativePath>
|
|
|
+ </parent>
|
|
|
+ <artifactId>vocabulary-survey-domain</artifactId>
|
|
|
+ <name>vocabulary-survey-domain</name>
|
|
|
+ <dependencies>
|
|
|
+ <dependency>
|
|
|
+ <groupId>cn.yunzhixue</groupId>
|
|
|
+ <artifactId>ability-center-kernel</artifactId>
|
|
|
+ <version>${project.version}</version>
|
|
|
+ </dependency>
|
|
|
+ <dependency>
|
|
|
+ <groupId>org.springframework.boot</groupId>
|
|
|
+ <artifactId>spring-boot-starter-test</artifactId>
|
|
|
+ <scope>test</scope>
|
|
|
+ </dependency>
|
|
|
+ </dependencies>
|
|
|
+ <build>
|
|
|
+ <plugins>
|
|
|
+ <plugin>
|
|
|
+ <groupId>org.apache.maven.plugins</groupId>
|
|
|
+ <artifactId>maven-surefire-plugin</artifactId>
|
|
|
+ <configuration>
|
|
|
+ <failIfNoSpecifiedTests>false</failIfNoSpecifiedTests>
|
|
|
+ </configuration>
|
|
|
+ </plugin>
|
|
|
+ </plugins>
|
|
|
+ </build>
|
|
|
+</project>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Create the application module POM**
|
|
|
+
|
|
|
+Create `abilities/vocabulary-survey/application/pom.xml`:
|
|
|
+
|
|
|
+```xml
|
|
|
+<project xmlns="http://maven.apache.org/POM/4.0.0"
|
|
|
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
|
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
|
|
+ <modelVersion>4.0.0</modelVersion>
|
|
|
+ <parent>
|
|
|
+ <groupId>cn.yunzhixue</groupId>
|
|
|
+ <artifactId>dcjxb.microservice</artifactId>
|
|
|
+ <version>0.0.1-SNAPSHOT</version>
|
|
|
+ <relativePath>../../../pom.xml</relativePath>
|
|
|
+ </parent>
|
|
|
+ <artifactId>vocabulary-survey-application</artifactId>
|
|
|
+ <name>vocabulary-survey-application</name>
|
|
|
+ <dependencies>
|
|
|
+ <dependency>
|
|
|
+ <groupId>cn.yunzhixue</groupId>
|
|
|
+ <artifactId>ability-center-kernel</artifactId>
|
|
|
+ <version>${project.version}</version>
|
|
|
+ </dependency>
|
|
|
+ <dependency>
|
|
|
+ <groupId>cn.yunzhixue</groupId>
|
|
|
+ <artifactId>vocabulary-survey-contracts</artifactId>
|
|
|
+ <version>${project.version}</version>
|
|
|
+ </dependency>
|
|
|
+ <dependency>
|
|
|
+ <groupId>cn.yunzhixue</groupId>
|
|
|
+ <artifactId>vocabulary-survey-domain</artifactId>
|
|
|
+ <version>${project.version}</version>
|
|
|
+ </dependency>
|
|
|
+ <dependency>
|
|
|
+ <groupId>cn.yunzhixue</groupId>
|
|
|
+ <artifactId>vocabulary-survey-application</artifactId>
|
|
|
+ <version>${project.version}</version>
|
|
|
+ </dependency>
|
|
|
+ <dependency>
|
|
|
+ <groupId>org.springframework</groupId>
|
|
|
+ <artifactId>spring-context</artifactId>
|
|
|
+ </dependency>
|
|
|
+ <dependency>
|
|
|
+ <groupId>org.springframework.boot</groupId>
|
|
|
+ <artifactId>spring-boot-starter-validation</artifactId>
|
|
|
+ </dependency>
|
|
|
+ <dependency>
|
|
|
+ <groupId>org.springframework.boot</groupId>
|
|
|
+ <artifactId>spring-boot-starter-test</artifactId>
|
|
|
+ <scope>test</scope>
|
|
|
+ </dependency>
|
|
|
+ </dependencies>
|
|
|
+ <build>
|
|
|
+ <plugins>
|
|
|
+ <plugin>
|
|
|
+ <groupId>org.apache.maven.plugins</groupId>
|
|
|
+ <artifactId>maven-surefire-plugin</artifactId>
|
|
|
+ <configuration>
|
|
|
+ <failIfNoSpecifiedTests>false</failIfNoSpecifiedTests>
|
|
|
+ </configuration>
|
|
|
+ </plugin>
|
|
|
+ </plugins>
|
|
|
+ </build>
|
|
|
+</project>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: Create the infrastructure module POM**
|
|
|
+
|
|
|
+Create `abilities/vocabulary-survey/infrastructure/pom.xml`:
|
|
|
+
|
|
|
+```xml
|
|
|
+<project xmlns="http://maven.apache.org/POM/4.0.0"
|
|
|
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
|
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
|
|
+ <modelVersion>4.0.0</modelVersion>
|
|
|
+ <parent>
|
|
|
+ <groupId>cn.yunzhixue</groupId>
|
|
|
+ <artifactId>dcjxb.microservice</artifactId>
|
|
|
+ <version>0.0.1-SNAPSHOT</version>
|
|
|
+ <relativePath>../../../pom.xml</relativePath>
|
|
|
+ </parent>
|
|
|
+ <artifactId>vocabulary-survey-infrastructure</artifactId>
|
|
|
+ <name>vocabulary-survey-infrastructure</name>
|
|
|
+ <dependencies>
|
|
|
+ <dependency>
|
|
|
+ <groupId>cn.yunzhixue</groupId>
|
|
|
+ <artifactId>ability-center-kernel</artifactId>
|
|
|
+ <version>${project.version}</version>
|
|
|
+ </dependency>
|
|
|
+ <dependency>
|
|
|
+ <groupId>cn.yunzhixue</groupId>
|
|
|
+ <artifactId>vocabulary-survey-domain</artifactId>
|
|
|
+ <version>${project.version}</version>
|
|
|
+ </dependency>
|
|
|
+ <dependency>
|
|
|
+ <groupId>cn.yunzhixue</groupId>
|
|
|
+ <artifactId>vocabulary-survey-application</artifactId>
|
|
|
+ <version>${project.version}</version>
|
|
|
+ </dependency>
|
|
|
+ <dependency>
|
|
|
+ <groupId>org.springframework</groupId>
|
|
|
+ <artifactId>spring-context</artifactId>
|
|
|
+ </dependency>
|
|
|
+ <dependency>
|
|
|
+ <groupId>org.springframework.boot</groupId>
|
|
|
+ <artifactId>spring-boot-autoconfigure</artifactId>
|
|
|
+ </dependency>
|
|
|
+ <dependency>
|
|
|
+ <groupId>com.fasterxml.jackson.core</groupId>
|
|
|
+ <artifactId>jackson-databind</artifactId>
|
|
|
+ </dependency>
|
|
|
+ <dependency>
|
|
|
+ <groupId>org.msgpack</groupId>
|
|
|
+ <artifactId>jackson-dataformat-msgpack</artifactId>
|
|
|
+ <version>0.9.8</version>
|
|
|
+ </dependency>
|
|
|
+ <dependency>
|
|
|
+ <groupId>org.springframework.boot</groupId>
|
|
|
+ <artifactId>spring-boot-starter-test</artifactId>
|
|
|
+ <scope>test</scope>
|
|
|
+ </dependency>
|
|
|
+ </dependencies>
|
|
|
+ <build>
|
|
|
+ <plugins>
|
|
|
+ <plugin>
|
|
|
+ <groupId>org.apache.maven.plugins</groupId>
|
|
|
+ <artifactId>maven-surefire-plugin</artifactId>
|
|
|
+ <configuration>
|
|
|
+ <failIfNoSpecifiedTests>false</failIfNoSpecifiedTests>
|
|
|
+ </configuration>
|
|
|
+ </plugin>
|
|
|
+ </plugins>
|
|
|
+ </build>
|
|
|
+</project>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 7: Wire runtime dependencies**
|
|
|
+
|
|
|
+Add these dependencies to `ability-center-runtime/pom.xml` after the existing `exam-sprint` dependencies:
|
|
|
+
|
|
|
+```xml
|
|
|
+<dependency>
|
|
|
+ <groupId>cn.yunzhixue</groupId>
|
|
|
+ <artifactId>vocabulary-survey-contracts</artifactId>
|
|
|
+ <version>${project.version}</version>
|
|
|
+</dependency>
|
|
|
+<dependency>
|
|
|
+ <groupId>cn.yunzhixue</groupId>
|
|
|
+ <artifactId>vocabulary-survey-application</artifactId>
|
|
|
+ <version>${project.version}</version>
|
|
|
+</dependency>
|
|
|
+<dependency>
|
|
|
+ <groupId>cn.yunzhixue</groupId>
|
|
|
+ <artifactId>vocabulary-survey-domain</artifactId>
|
|
|
+ <version>${project.version}</version>
|
|
|
+</dependency>
|
|
|
+<dependency>
|
|
|
+ <groupId>cn.yunzhixue</groupId>
|
|
|
+ <artifactId>vocabulary-survey-infrastructure</artifactId>
|
|
|
+ <version>${project.version}</version>
|
|
|
+</dependency>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 7a: Add survey error codes before domain code depends on them**
|
|
|
+
|
|
|
+Modify `ability-center-kernel/src/main/java/cn/yunzhixue/ability/center/kernel/ErrorCode.java` by adding these enum entries before `INTERNAL_ERROR`:
|
|
|
+
|
|
|
+```java
|
|
|
+INVALID_GRADE("INVALID_GRADE", "invalid grade", 400),
|
|
|
+SURVEY_SESSION_NOT_FOUND("SURVEY_SESSION_NOT_FOUND", "survey session not found", 404),
|
|
|
+SURVEY_SESSION_EXPIRED("SURVEY_SESSION_EXPIRED", "survey session expired", 410),
|
|
|
+SURVEY_SESSION_FINISHED("SURVEY_SESSION_FINISHED", "survey session finished", 409),
|
|
|
+SURVEY_LEVEL_NOT_FOUND("SURVEY_LEVEL_NOT_FOUND", "survey level not found", 404),
|
|
|
+SURVEY_LEVEL_NOT_CURRENT("SURVEY_LEVEL_NOT_CURRENT", "survey level not current", 409),
|
|
|
+INVALID_SURVEY_WORD("INVALID_SURVEY_WORD", "invalid survey word", 400),
|
|
|
+SURVEY_WORD_GENERATION_FAILED("SURVEY_WORD_GENERATION_FAILED", "survey word generation failed", 500),
|
|
|
+SURVEY_REPORT_GENERATION_FAILED("SURVEY_REPORT_GENERATION_FAILED", "survey report generation failed", 500),
|
|
|
+```
|
|
|
+
|
|
|
+Ensure commas remain valid in the enum list. Domain tests in later tasks require `INVALID_GRADE` and `SURVEY_WORD_GENERATION_FAILED` to exist.
|
|
|
+
|
|
|
+- [ ] **Step 8: Add safe configuration**
|
|
|
+
|
|
|
+Append the vocabulary config to `ability-center-runtime/src/main/resources/application.yml` without removing the existing `ability.exam-sprint` section:
|
|
|
+
|
|
|
+```yaml
|
|
|
+ vocabulary-survey:
|
|
|
+ dictionary:
|
|
|
+ location: classpath:data/dict.data
|
|
|
+```
|
|
|
+
|
|
|
+Because `application.yml` already has a top-level `ability:` block, merge this snippet under that existing block instead of creating a second top-level `ability:` key.
|
|
|
+
|
|
|
+Create `ability-center-runtime/src/main/resources/application-dev.yml`:
|
|
|
+
|
|
|
+```yaml
|
|
|
+spring:
|
|
|
+ datasource:
|
|
|
+ url: jdbc:mysql://rm-uf6881jgyy065rdxdoo.rwlb.rds.aliyuncs.com:3306/qingti_db_dev?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
|
|
|
+ username:
|
|
|
+ password:
|
|
|
+ driver-class-name: com.mysql.cj.jdbc.Driver
|
|
|
+
|
|
|
+ability:
|
|
|
+ vocabulary-survey:
|
|
|
+ dictionary:
|
|
|
+ location: classpath:data/dict.data
|
|
|
+```
|
|
|
+
|
|
|
+Append this `spring.datasource` block and `ability.vocabulary-survey` block to `ability-center-runtime/src/main/resources/application-test.yml`; do not edit or echo existing secret-bearing values:
|
|
|
+
|
|
|
+```yaml
|
|
|
+spring:
|
|
|
+ datasource:
|
|
|
+ url: jdbc:mysql://rm-uf6881jgyy065rdxdoo.rwlb.rds.aliyuncs.com:3306/qingti_test?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
|
|
|
+ username:
|
|
|
+ password:
|
|
|
+ driver-class-name: com.mysql.cj.jdbc.Driver
|
|
|
+```
|
|
|
+
|
|
|
+Merge this under the existing top-level `ability:` block:
|
|
|
+
|
|
|
+```yaml
|
|
|
+ vocabulary-survey:
|
|
|
+ dictionary:
|
|
|
+ location: classpath:data/dict.data
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 9: Verify module skeleton compiles far enough**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```powershell
|
|
|
+mvn -pl abilities/vocabulary-survey/domain test
|
|
|
+```
|
|
|
+
|
|
|
+Expected after only POM creation: PASS with no tests, or FAIL only because source files referenced in later tasks do not exist. If Maven says the module path is unknown, fix the root module paths before continuing.
|
|
|
+
|
|
|
+- [ ] **Step 10: Review checkpoint**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```powershell
|
|
|
+git diff -- pom.xml ability-center-runtime/pom.xml ability-center-kernel/src/main/java/cn/yunzhixue/ability/center/kernel/ErrorCode.java ability-center-runtime/src/main/resources/application.yml ability-center-runtime/src/main/resources/application-dev.yml ability-center-runtime/src/main/resources/application-test.yml
|
|
|
+```
|
|
|
+
|
|
|
+Expected: diff contains module and config additions only. No secret value should be newly introduced.
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 2: Add external contract DTOs
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create all contract records listed in the file structure section.
|
|
|
+- Test through compile and runtime controller tests in later tasks.
|
|
|
+
|
|
|
+- [ ] **Step 1: Create request DTOs**
|
|
|
+
|
|
|
+Create `StartVocabularySurveyRequest.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.contracts;
|
|
|
+
|
|
|
+import jakarta.validation.constraints.Max;
|
|
|
+import jakarta.validation.constraints.Min;
|
|
|
+import jakarta.validation.constraints.NotNull;
|
|
|
+
|
|
|
+public record StartVocabularySurveyRequest(
|
|
|
+ Integer studentId,
|
|
|
+ @NotNull @Min(3) @Max(12) Integer grade,
|
|
|
+ Boolean restart) {
|
|
|
+
|
|
|
+ public boolean restartRequested() {
|
|
|
+ return Boolean.TRUE.equals(restart);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Create `SubmitVocabularySurveyLevelRequest.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.contracts;
|
|
|
+
|
|
|
+import jakarta.validation.constraints.NotBlank;
|
|
|
+
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+public record SubmitVocabularySurveyLevelRequest(
|
|
|
+ @NotBlank String sessionId,
|
|
|
+ List<String> unknownSurveyWordIds) {
|
|
|
+
|
|
|
+ public List<String> unknownSurveyWordIdsOrEmpty() {
|
|
|
+ return unknownSurveyWordIds == null ? List.of() : List.copyOf(unknownSurveyWordIds);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Create status enum**
|
|
|
+
|
|
|
+Create `VocabularySurveyStatus.java` in contracts:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.contracts;
|
|
|
+
|
|
|
+import com.fasterxml.jackson.annotation.JsonValue;
|
|
|
+
|
|
|
+public enum VocabularySurveyStatus {
|
|
|
+ IN_PROGRESS("inProgress"),
|
|
|
+ FINISHED("finished"),
|
|
|
+ EXPIRED("expired"),
|
|
|
+ CANCELLED("cancelled");
|
|
|
+
|
|
|
+ private final String value;
|
|
|
+
|
|
|
+ VocabularySurveyStatus(String value) {
|
|
|
+ this.value = value;
|
|
|
+ }
|
|
|
+
|
|
|
+ @JsonValue
|
|
|
+ public String value() {
|
|
|
+ return value;
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Create response records**
|
|
|
+
|
|
|
+Create `VocabularySurveyWordResponse.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.contracts;
|
|
|
+
|
|
|
+public record VocabularySurveyWordResponse(
|
|
|
+ String surveyWordId,
|
|
|
+ int wordId,
|
|
|
+ int meaningId,
|
|
|
+ String spell,
|
|
|
+ int wordFrequency) {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Create `VocabularySurveyLevelResponse.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.contracts;
|
|
|
+
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+public record VocabularySurveyLevelResponse(
|
|
|
+ String levelId,
|
|
|
+ int levelNo,
|
|
|
+ int wordCount,
|
|
|
+ String feedback,
|
|
|
+ List<VocabularySurveyWordResponse> words) {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Create `VocabularySurveyLearningRangeResponse.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.contracts;
|
|
|
+
|
|
|
+public record VocabularySurveyLearningRangeResponse(int min, int max) {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Create `VocabularySurveyReportResponse.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.contracts;
|
|
|
+
|
|
|
+public record VocabularySurveyReportResponse(
|
|
|
+ int vocabularySize,
|
|
|
+ int k,
|
|
|
+ int grade,
|
|
|
+ int gradeMaxFrequency,
|
|
|
+ String abilityLevel,
|
|
|
+ VocabularySurveyLearningRangeResponse learningRange,
|
|
|
+ int testedWordCount,
|
|
|
+ int unknownWordCount,
|
|
|
+ String summary) {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Create `VocabularySurveySessionResponse.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.contracts;
|
|
|
+
|
|
|
+public record VocabularySurveySessionResponse(
|
|
|
+ String sessionId,
|
|
|
+ VocabularySurveyStatus status,
|
|
|
+ int grade,
|
|
|
+ VocabularySurveyLevelResponse level,
|
|
|
+ VocabularySurveyLevelResponse currentLevel,
|
|
|
+ VocabularySurveyReportResponse report) {
|
|
|
+
|
|
|
+ public static VocabularySurveySessionResponse started(
|
|
|
+ String sessionId,
|
|
|
+ int grade,
|
|
|
+ VocabularySurveyLevelResponse level) {
|
|
|
+ return new VocabularySurveySessionResponse(
|
|
|
+ sessionId,
|
|
|
+ VocabularySurveyStatus.IN_PROGRESS,
|
|
|
+ grade,
|
|
|
+ level,
|
|
|
+ null,
|
|
|
+ null);
|
|
|
+ }
|
|
|
+
|
|
|
+ public static VocabularySurveySessionResponse current(
|
|
|
+ String sessionId,
|
|
|
+ int grade,
|
|
|
+ VocabularySurveyLevelResponse currentLevel) {
|
|
|
+ return new VocabularySurveySessionResponse(
|
|
|
+ sessionId,
|
|
|
+ VocabularySurveyStatus.IN_PROGRESS,
|
|
|
+ grade,
|
|
|
+ null,
|
|
|
+ currentLevel,
|
|
|
+ null);
|
|
|
+ }
|
|
|
+
|
|
|
+ public static VocabularySurveySessionResponse finished(
|
|
|
+ String sessionId,
|
|
|
+ int grade,
|
|
|
+ VocabularySurveyReportResponse report) {
|
|
|
+ return new VocabularySurveySessionResponse(
|
|
|
+ sessionId,
|
|
|
+ VocabularySurveyStatus.FINISHED,
|
|
|
+ grade,
|
|
|
+ null,
|
|
|
+ null,
|
|
|
+ report);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Create `SubmitVocabularySurveyLevelResponse.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.contracts;
|
|
|
+
|
|
|
+public record SubmitVocabularySurveyLevelResponse(
|
|
|
+ String sessionId,
|
|
|
+ VocabularySurveyStatus status,
|
|
|
+ int submittedLevelNo,
|
|
|
+ VocabularySurveyLevelResponse level,
|
|
|
+ VocabularySurveyReportResponse report) {
|
|
|
+
|
|
|
+ public static SubmitVocabularySurveyLevelResponse nextLevel(
|
|
|
+ String sessionId,
|
|
|
+ int submittedLevelNo,
|
|
|
+ VocabularySurveyLevelResponse level) {
|
|
|
+ return new SubmitVocabularySurveyLevelResponse(
|
|
|
+ sessionId,
|
|
|
+ VocabularySurveyStatus.IN_PROGRESS,
|
|
|
+ submittedLevelNo,
|
|
|
+ level,
|
|
|
+ null);
|
|
|
+ }
|
|
|
+
|
|
|
+ public static SubmitVocabularySurveyLevelResponse finished(
|
|
|
+ String sessionId,
|
|
|
+ int submittedLevelNo,
|
|
|
+ VocabularySurveyReportResponse report) {
|
|
|
+ return new SubmitVocabularySurveyLevelResponse(
|
|
|
+ sessionId,
|
|
|
+ VocabularySurveyStatus.FINISHED,
|
|
|
+ submittedLevelNo,
|
|
|
+ null,
|
|
|
+ report);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Compile contracts**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```powershell
|
|
|
+mvn -pl abilities/vocabulary-survey/contracts test
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS.
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 3: Add domain grade policy, value types, and level planning
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: domain source files for `VocabularyWord`, `VocabularyWordProvider`, `VocabularySurveyStatus`, `VocabularySurveyGradePolicy`, `VocabularySurveyIds`, `VocabularySurveyWord`, `VocabularySurveyLevel`, `VocabularySurveyLevelPlanner`.
|
|
|
+- Test: `VocabularySurveyGradePolicyTest.java`, `VocabularySurveyLevelPlannerTest.java`.
|
|
|
+
|
|
|
+- [ ] **Step 1: Write grade policy tests**
|
|
|
+
|
|
|
+Create `VocabularySurveyGradePolicyTest.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
|
|
|
+
|
|
|
+import cn.yunzhixue.ability.center.kernel.BusinessException;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import static org.assertj.core.api.Assertions.assertThat;
|
|
|
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
|
+
|
|
|
+class VocabularySurveyGradePolicyTest {
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void gradeMaxFrequencyReturnsConfiguredFrequency() {
|
|
|
+ assertThat(VocabularySurveyGradePolicy.gradeMaxFrequency(3)).isEqualTo(400);
|
|
|
+ assertThat(VocabularySurveyGradePolicy.gradeMaxFrequency(7)).isEqualTo(1600);
|
|
|
+ assertThat(VocabularySurveyGradePolicy.gradeMaxFrequency(12)).isEqualTo(4800);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void invalidGradeThrowsBusinessException() {
|
|
|
+ assertThatThrownBy(() -> VocabularySurveyGradePolicy.gradeMaxFrequency(2))
|
|
|
+ .isInstanceOf(BusinessException.class)
|
|
|
+ .hasMessageContaining("invalid grade");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void challengeMaxFrequencyUsesNextGradeAndDictionaryMaximum() {
|
|
|
+ assertThat(VocabularySurveyGradePolicy.challengeMaxFrequency(7, 5000)).isEqualTo(2000);
|
|
|
+ assertThat(VocabularySurveyGradePolicy.challengeMaxFrequency(12, 4200)).isEqualTo(4200);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Run grade policy tests and verify failure**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```powershell
|
|
|
+mvn -pl abilities/vocabulary-survey/domain -Dtest=VocabularySurveyGradePolicyTest test
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL because `VocabularySurveyGradePolicy` does not exist.
|
|
|
+
|
|
|
+- [ ] **Step 3: Implement grade policy and core value types**
|
|
|
+
|
|
|
+Create `VocabularyWord.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
|
|
|
+
|
|
|
+public record VocabularyWord(int wordId, int meaningId, String spell, int wordFrequency) {
|
|
|
+ public VocabularyWord {
|
|
|
+ if (wordId <= 0) {
|
|
|
+ throw new IllegalArgumentException("wordId must be positive");
|
|
|
+ }
|
|
|
+ if (meaningId <= 0) {
|
|
|
+ throw new IllegalArgumentException("meaningId must be positive");
|
|
|
+ }
|
|
|
+ if (spell == null || spell.isBlank()) {
|
|
|
+ throw new IllegalArgumentException("spell must not be blank");
|
|
|
+ }
|
|
|
+ if (wordFrequency <= 0) {
|
|
|
+ throw new IllegalArgumentException("wordFrequency must be positive");
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Create `VocabularyWordProvider.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
|
|
|
+
|
|
|
+import java.util.List;
|
|
|
+import java.util.Optional;
|
|
|
+import java.util.Set;
|
|
|
+
|
|
|
+public interface VocabularyWordProvider {
|
|
|
+
|
|
|
+ List<VocabularyWord> findWordsAroundFrequency(
|
|
|
+ int centerFrequency,
|
|
|
+ int width,
|
|
|
+ int count,
|
|
|
+ Set<Integer> excludedWordIds);
|
|
|
+
|
|
|
+ Optional<VocabularyWord> findByWordId(int wordId);
|
|
|
+
|
|
|
+ Optional<VocabularyWord> findByMeaningId(int meaningId);
|
|
|
+ int maxWordFrequency();
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Create `VocabularySurveyStatus.java` in domain:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
|
|
|
+
|
|
|
+public enum VocabularySurveyStatus {
|
|
|
+ IN_PROGRESS,
|
|
|
+ FINISHED,
|
|
|
+ EXPIRED,
|
|
|
+ CANCELLED
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Create `VocabularySurveyGradePolicy.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
|
|
|
+
|
|
|
+import cn.yunzhixue.ability.center.kernel.BusinessException;
|
|
|
+import cn.yunzhixue.ability.center.kernel.ErrorCode;
|
|
|
+
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+public final class VocabularySurveyGradePolicy {
|
|
|
+
|
|
|
+ private static final Map<Integer, Integer> GRADE_MAX_FREQUENCIES = Map.ofEntries(
|
|
|
+ Map.entry(3, 400),
|
|
|
+ Map.entry(4, 700),
|
|
|
+ Map.entry(5, 1000),
|
|
|
+ Map.entry(6, 1200),
|
|
|
+ Map.entry(7, 1600),
|
|
|
+ Map.entry(8, 2000),
|
|
|
+ Map.entry(9, 2400),
|
|
|
+ Map.entry(10, 3200),
|
|
|
+ Map.entry(11, 4000),
|
|
|
+ Map.entry(12, 4800));
|
|
|
+
|
|
|
+ private VocabularySurveyGradePolicy() {
|
|
|
+ }
|
|
|
+
|
|
|
+ public static int gradeMaxFrequency(int grade) {
|
|
|
+ Integer frequency = GRADE_MAX_FREQUENCIES.get(grade);
|
|
|
+ if (frequency == null) {
|
|
|
+ throw new BusinessException(ErrorCode.INVALID_GRADE, "invalid grade: " + grade);
|
|
|
+ }
|
|
|
+ return frequency;
|
|
|
+ }
|
|
|
+
|
|
|
+ public static int challengeMaxFrequency(int grade, int dictionaryMaxFrequency) {
|
|
|
+ int nextGrade = Math.min(12, grade + 1);
|
|
|
+ return Math.min(gradeMaxFrequency(nextGrade), dictionaryMaxFrequency);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Create `VocabularySurveyIds.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
|
|
|
+
|
|
|
+import java.util.UUID;
|
|
|
+
|
|
|
+public final class VocabularySurveyIds {
|
|
|
+
|
|
|
+ private VocabularySurveyIds() {
|
|
|
+ }
|
|
|
+
|
|
|
+ public static String sessionId() {
|
|
|
+ return "surv_" + compactUuid();
|
|
|
+ }
|
|
|
+
|
|
|
+ public static String levelId() {
|
|
|
+ return "lvl_" + compactUuid();
|
|
|
+ }
|
|
|
+
|
|
|
+ public static String surveyWordId() {
|
|
|
+ return "sw_" + compactUuid();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String compactUuid() {
|
|
|
+ return UUID.randomUUID().toString().replace("-", "");
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Run grade policy tests and verify pass**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```powershell
|
|
|
+mvn -pl abilities/vocabulary-survey/domain -Dtest=VocabularySurveyGradePolicyTest test
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS.
|
|
|
+
|
|
|
+- [ ] **Step 5: Write level planner tests**
|
|
|
+
|
|
|
+Create `VocabularySurveyLevelPlannerTest.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
|
|
|
+
|
|
|
+import cn.yunzhixue.ability.center.kernel.BusinessException;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.HashSet;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Optional;
|
|
|
+import java.util.Set;
|
|
|
+
|
|
|
+import static org.assertj.core.api.Assertions.assertThat;
|
|
|
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
|
+
|
|
|
+class VocabularySurveyLevelPlannerTest {
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void generatedLevelContainsTwelveWordsAndNoRepeatedWordId() {
|
|
|
+ VocabularySurveyLevelPlanner planner = new VocabularySurveyLevelPlanner(new FakeWordProvider(200));
|
|
|
+
|
|
|
+ VocabularySurveyLevel level = planner.generateLevel(1, 560, 1600, Set.of());
|
|
|
+
|
|
|
+ assertThat(level.words()).hasSize(12);
|
|
|
+ assertThat(level.words().stream().map(VocabularySurveyWord::wordId)).doesNotHaveDuplicates();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void generatedLevelExcludesAlreadyTestedWords() {
|
|
|
+ FakeWordProvider provider = new FakeWordProvider(200);
|
|
|
+ VocabularySurveyLevelPlanner planner = new VocabularySurveyLevelPlanner(provider);
|
|
|
+ Set<Integer> excluded = new HashSet<>();
|
|
|
+ for (int wordId = 1; wordId <= 20; wordId++) {
|
|
|
+ excluded.add(wordId);
|
|
|
+ }
|
|
|
+
|
|
|
+ VocabularySurveyLevel level = planner.generateLevel(1, 120, 200, excluded);
|
|
|
+
|
|
|
+ assertThat(level.words()).allMatch(word -> !excluded.contains(word.wordId()));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void generationFailureThrowsBusinessException() {
|
|
|
+ VocabularySurveyLevelPlanner planner = new VocabularySurveyLevelPlanner(new FakeWordProvider(8));
|
|
|
+
|
|
|
+ assertThatThrownBy(() -> planner.generateLevel(1, 100, 100, Set.of()))
|
|
|
+ .isInstanceOf(BusinessException.class)
|
|
|
+ .hasMessageContaining("failed to generate survey words");
|
|
|
+ }
|
|
|
+
|
|
|
+ private static final class FakeWordProvider implements VocabularyWordProvider {
|
|
|
+ private final List<VocabularyWord> words = new ArrayList<>();
|
|
|
+
|
|
|
+ private FakeWordProvider(int count) {
|
|
|
+ for (int index = 1; index <= count; index++) {
|
|
|
+ words.add(new VocabularyWord(index, 1000 + index, "word" + index, index * 10));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public List<VocabularyWord> findWordsAroundFrequency(
|
|
|
+ int centerFrequency,
|
|
|
+ int width,
|
|
|
+ int count,
|
|
|
+ Set<Integer> excludedWordIds) {
|
|
|
+ return words.stream()
|
|
|
+ .filter(word -> !excludedWordIds.contains(word.wordId()))
|
|
|
+ .sorted((left, right) -> Integer.compare(
|
|
|
+ Math.abs(left.wordFrequency() - centerFrequency),
|
|
|
+ Math.abs(right.wordFrequency() - centerFrequency)))
|
|
|
+ .limit(count)
|
|
|
+ .toList();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public Optional<VocabularyWord> findByWordId(int wordId) {
|
|
|
+ return words.stream().filter(word -> word.wordId() == wordId).findFirst();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public Optional<VocabularyWord> findByMeaningId(int meaningId) {
|
|
|
+ return words.stream().filter(word -> word.meaningId() == meaningId).findFirst();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public int maxWordFrequency() {
|
|
|
+ return words.stream().mapToInt(VocabularyWord::wordFrequency).max().orElse(0);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: Run level planner tests and verify failure**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```powershell
|
|
|
+mvn -pl abilities/vocabulary-survey/domain -Dtest=VocabularySurveyLevelPlannerTest test
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL because `VocabularySurveyLevelPlanner`, `VocabularySurveyLevel`, and `VocabularySurveyWord` do not exist.
|
|
|
+
|
|
|
+- [ ] **Step 7: Implement level and word models**
|
|
|
+
|
|
|
+Create `VocabularySurveyWord.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
|
|
|
+
|
|
|
+import java.time.Instant;
|
|
|
+
|
|
|
+public record VocabularySurveyWord(
|
|
|
+ String surveyWordId,
|
|
|
+ String sessionId,
|
|
|
+ String levelId,
|
|
|
+ int levelNo,
|
|
|
+ int wordId,
|
|
|
+ int meaningId,
|
|
|
+ String spell,
|
|
|
+ int wordFrequency,
|
|
|
+ Boolean unknown,
|
|
|
+ Instant createdAt,
|
|
|
+ Instant submittedAt) {
|
|
|
+
|
|
|
+ public static VocabularySurveyWord unsubmitted(
|
|
|
+ String sessionId,
|
|
|
+ String levelId,
|
|
|
+ int levelNo,
|
|
|
+ VocabularyWord word,
|
|
|
+ Instant createdAt) {
|
|
|
+ return new VocabularySurveyWord(
|
|
|
+ VocabularySurveyIds.surveyWordId(),
|
|
|
+ sessionId,
|
|
|
+ levelId,
|
|
|
+ levelNo,
|
|
|
+ word.wordId(),
|
|
|
+ word.meaningId(),
|
|
|
+ word.spell(),
|
|
|
+ word.wordFrequency(),
|
|
|
+ null,
|
|
|
+ createdAt,
|
|
|
+ null);
|
|
|
+ }
|
|
|
+
|
|
|
+ public VocabularySurveyWord submitted(boolean isUnknown, Instant submittedAt) {
|
|
|
+ return new VocabularySurveyWord(
|
|
|
+ surveyWordId,
|
|
|
+ sessionId,
|
|
|
+ levelId,
|
|
|
+ levelNo,
|
|
|
+ wordId,
|
|
|
+ meaningId,
|
|
|
+ spell,
|
|
|
+ wordFrequency,
|
|
|
+ isUnknown,
|
|
|
+ createdAt,
|
|
|
+ submittedAt);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Create `VocabularySurveyLevel.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
|
|
|
+
|
|
|
+import java.time.Instant;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Set;
|
|
|
+
|
|
|
+public record VocabularySurveyLevel(
|
|
|
+ String levelId,
|
|
|
+ String sessionId,
|
|
|
+ int levelNo,
|
|
|
+ int centerFrequency,
|
|
|
+ int wordWidth,
|
|
|
+ List<VocabularySurveyWord> words,
|
|
|
+ Double unknownRate,
|
|
|
+ String feedback,
|
|
|
+ Instant createdAt,
|
|
|
+ Instant submittedAt) {
|
|
|
+
|
|
|
+ public int wordCount() {
|
|
|
+ return words.size();
|
|
|
+ }
|
|
|
+
|
|
|
+ public boolean submitted() {
|
|
|
+ return submittedAt != null;
|
|
|
+ }
|
|
|
+
|
|
|
+ public boolean nearBoundary() {
|
|
|
+ return unknownRate != null && unknownRate > 0.4D && unknownRate < 0.6D;
|
|
|
+ }
|
|
|
+
|
|
|
+ public VocabularySurveyLevel submitted(Set<String> unknownSurveyWordIds, Instant submittedAt) {
|
|
|
+ List<VocabularySurveyWord> submittedWords = words.stream()
|
|
|
+ .map(word -> word.submitted(unknownSurveyWordIds.contains(word.surveyWordId()), submittedAt))
|
|
|
+ .toList();
|
|
|
+ long unknownCount = submittedWords.stream().filter(word -> Boolean.TRUE.equals(word.unknown())).count();
|
|
|
+ double submittedUnknownRate = submittedWords.isEmpty() ? 0.0D : (double) unknownCount / submittedWords.size();
|
|
|
+ return new VocabularySurveyLevel(
|
|
|
+ levelId,
|
|
|
+ sessionId,
|
|
|
+ levelNo,
|
|
|
+ centerFrequency,
|
|
|
+ wordWidth,
|
|
|
+ submittedWords,
|
|
|
+ submittedUnknownRate,
|
|
|
+ feedback,
|
|
|
+ createdAt,
|
|
|
+ submittedAt);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 8: Implement level planner**
|
|
|
+
|
|
|
+Create `VocabularySurveyLevelPlanner.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
|
|
|
+
|
|
|
+import cn.yunzhixue.ability.center.kernel.BusinessException;
|
|
|
+import cn.yunzhixue.ability.center.kernel.ErrorCode;
|
|
|
+
|
|
|
+import java.time.Instant;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.HashSet;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Set;
|
|
|
+
|
|
|
+public class VocabularySurveyLevelPlanner {
|
|
|
+
|
|
|
+ public static final int WORDS_PER_LEVEL = 12;
|
|
|
+ public static final int MIN_WORD_WIDTH = 100;
|
|
|
+
|
|
|
+ private final VocabularyWordProvider wordProvider;
|
|
|
+
|
|
|
+ public VocabularySurveyLevelPlanner(VocabularyWordProvider wordProvider) {
|
|
|
+ this.wordProvider = wordProvider;
|
|
|
+ }
|
|
|
+
|
|
|
+ public VocabularySurveyLevel generateLevel(
|
|
|
+ int levelNo,
|
|
|
+ int centerFrequency,
|
|
|
+ int wordWidth,
|
|
|
+ Set<Integer> testedWordIds) {
|
|
|
+ return generateLevel("surv_preview", levelNo, centerFrequency, wordWidth, testedWordIds, Instant.EPOCH);
|
|
|
+ }
|
|
|
+
|
|
|
+ public VocabularySurveyLevel generateLevel(
|
|
|
+ String sessionId,
|
|
|
+ int levelNo,
|
|
|
+ int centerFrequency,
|
|
|
+ int wordWidth,
|
|
|
+ Set<Integer> testedWordIds,
|
|
|
+ Instant now) {
|
|
|
+ String levelId = VocabularySurveyIds.levelId();
|
|
|
+ Set<Integer> excluded = new HashSet<>(testedWordIds);
|
|
|
+ List<VocabularySurveyWord> surveyWords = new ArrayList<>();
|
|
|
+ for (int targetFrequency : sampleFrequencies(centerFrequency, wordWidth, WORDS_PER_LEVEL)) {
|
|
|
+ List<VocabularyWord> candidates = wordProvider.findWordsAroundFrequency(
|
|
|
+ targetFrequency,
|
|
|
+ Math.max(wordWidth / WORDS_PER_LEVEL, MIN_WORD_WIDTH),
|
|
|
+ WORDS_PER_LEVEL,
|
|
|
+ excluded);
|
|
|
+ if (!candidates.isEmpty()) {
|
|
|
+ VocabularyWord selected = candidates.get(0);
|
|
|
+ excluded.add(selected.wordId());
|
|
|
+ surveyWords.add(VocabularySurveyWord.unsubmitted(sessionId, levelId, levelNo, selected, now));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (surveyWords.size() < WORDS_PER_LEVEL) {
|
|
|
+ List<VocabularyWord> backfill = wordProvider.findWordsAroundFrequency(
|
|
|
+ centerFrequency,
|
|
|
+ Math.max(wordWidth, MIN_WORD_WIDTH),
|
|
|
+ WORDS_PER_LEVEL - surveyWords.size(),
|
|
|
+ excluded);
|
|
|
+ for (VocabularyWord word : backfill) {
|
|
|
+ excluded.add(word.wordId());
|
|
|
+ surveyWords.add(VocabularySurveyWord.unsubmitted(sessionId, levelId, levelNo, word, now));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (surveyWords.size() != WORDS_PER_LEVEL) {
|
|
|
+ throw new BusinessException(
|
|
|
+ ErrorCode.SURVEY_WORD_GENERATION_FAILED,
|
|
|
+ "failed to generate survey words for level " + levelNo);
|
|
|
+ }
|
|
|
+ return new VocabularySurveyLevel(
|
|
|
+ levelId,
|
|
|
+ sessionId,
|
|
|
+ levelNo,
|
|
|
+ centerFrequency,
|
|
|
+ wordWidth,
|
|
|
+ List.copyOf(surveyWords),
|
|
|
+ null,
|
|
|
+ feedbackFor(levelNo),
|
|
|
+ now,
|
|
|
+ null);
|
|
|
+ }
|
|
|
+
|
|
|
+ public NextLevelState nextState(int currentCenterFrequency, int currentWordWidth, double unknownRate, int challengeMaxFrequency) {
|
|
|
+ int nextWordWidth = Math.max((int) Math.round(currentWordWidth * 0.75D), MIN_WORD_WIDTH);
|
|
|
+ int offset = Math.max(1, nextWordWidth / 6);
|
|
|
+ int nextCenter = currentCenterFrequency;
|
|
|
+ if (unknownRate <= 0.4D) {
|
|
|
+ nextCenter = currentCenterFrequency + offset;
|
|
|
+ } else if (unknownRate >= 0.6D) {
|
|
|
+ nextCenter = currentCenterFrequency - offset;
|
|
|
+ }
|
|
|
+ nextCenter = Math.max(100, Math.min(challengeMaxFrequency, nextCenter));
|
|
|
+ return new NextLevelState(nextCenter, nextWordWidth);
|
|
|
+ }
|
|
|
+
|
|
|
+ private List<Integer> sampleFrequencies(int centerFrequency, int wordWidth, int count) {
|
|
|
+ int left = Math.max(1, centerFrequency - wordWidth / 2);
|
|
|
+ int right = Math.max(left, centerFrequency + wordWidth / 2);
|
|
|
+ if (count == 1) {
|
|
|
+ return List.of(centerFrequency);
|
|
|
+ }
|
|
|
+ List<Integer> samples = new ArrayList<>();
|
|
|
+ double step = (double) (right - left) / (count - 1);
|
|
|
+ for (int index = 0; index < count; index++) {
|
|
|
+ samples.add((int) Math.round(left + step * index));
|
|
|
+ }
|
|
|
+ return samples;
|
|
|
+ }
|
|
|
+
|
|
|
+ private String feedbackFor(int levelNo) {
|
|
|
+ if (levelNo == 1) {
|
|
|
+ return "先来热个身,选出你不认识的单词吧";
|
|
|
+ }
|
|
|
+ return "继续挑战,选出你不认识的单词吧";
|
|
|
+ }
|
|
|
+
|
|
|
+ public record NextLevelState(int centerFrequency, int wordWidth) {
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 9: Run level planner tests and verify pass**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```powershell
|
|
|
+mvn -pl abilities/vocabulary-survey/domain -Dtest=VocabularySurveyLevelPlannerTest test
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS.
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 4: Add domain K estimation, report generation, and session aggregate
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `VocabularySurveyReport.java`, `VocabularySurveyKEstimator.java`, `VocabularySurveyReportGenerator.java`, `VocabularySurveySession.java`, `VocabularySurveySessionRepository.java`.
|
|
|
+- Test: `VocabularySurveyKEstimatorTest.java`, `VocabularySurveyReportGeneratorTest.java`, `VocabularySurveySessionTest.java`.
|
|
|
+
|
|
|
+- [ ] **Step 1: Write K estimator test**
|
|
|
+
|
|
|
+Create `VocabularySurveyKEstimatorTest.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
|
|
|
+
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import java.time.Instant;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Set;
|
|
|
+
|
|
|
+import static org.assertj.core.api.Assertions.assertThat;
|
|
|
+
|
|
|
+class VocabularySurveyKEstimatorTest {
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void estimateKMinimizesUnknownLeftAndKnownRightError() {
|
|
|
+ VocabularySurveyLevel level = submittedLevel(List.of(
|
|
|
+ word(1, 100, false),
|
|
|
+ word(2, 200, false),
|
|
|
+ word(3, 300, true),
|
|
|
+ word(4, 400, true)),
|
|
|
+ 250);
|
|
|
+
|
|
|
+ int k = new VocabularySurveyKEstimator().estimateK(List.of(level), 250);
|
|
|
+
|
|
|
+ assertThat(k).isEqualTo(200);
|
|
|
+ }
|
|
|
+
|
|
|
+ private VocabularySurveyLevel submittedLevel(List<VocabularySurveyWord> words, int centerFrequency) {
|
|
|
+ VocabularySurveyLevel level = new VocabularySurveyLevel(
|
|
|
+ "lvl_test",
|
|
|
+ "surv_test",
|
|
|
+ 1,
|
|
|
+ centerFrequency,
|
|
|
+ 400,
|
|
|
+ words,
|
|
|
+ null,
|
|
|
+ "feedback",
|
|
|
+ Instant.EPOCH,
|
|
|
+ null);
|
|
|
+ return level.submitted(Set.of("sw_3", "sw_4"), Instant.EPOCH.plusSeconds(1));
|
|
|
+ }
|
|
|
+
|
|
|
+ private VocabularySurveyWord word(int wordId, int frequency, boolean unknown) {
|
|
|
+ String surveyWordId = "sw_" + wordId;
|
|
|
+ return new VocabularySurveyWord(
|
|
|
+ surveyWordId,
|
|
|
+ "surv_test",
|
|
|
+ "lvl_test",
|
|
|
+ 1,
|
|
|
+ wordId,
|
|
|
+ 1000 + wordId,
|
|
|
+ "word" + wordId,
|
|
|
+ frequency,
|
|
|
+ unknown,
|
|
|
+ Instant.EPOCH,
|
|
|
+ Instant.EPOCH.plusSeconds(1));
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Implement K estimator**
|
|
|
+
|
|
|
+Create `VocabularySurveyKEstimator.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
|
|
|
+
|
|
|
+import java.util.Comparator;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+public class VocabularySurveyKEstimator {
|
|
|
+
|
|
|
+ public int estimateK(List<VocabularySurveyLevel> levels, int preferredFrequency) {
|
|
|
+ List<VocabularySurveyWord> testedWords = levels.stream()
|
|
|
+ .flatMap(level -> level.words().stream())
|
|
|
+ .filter(word -> word.unknown() != null)
|
|
|
+ .sorted(Comparator.comparingInt(VocabularySurveyWord::wordFrequency))
|
|
|
+ .toList();
|
|
|
+ if (testedWords.isEmpty()) {
|
|
|
+ return Math.max(1, preferredFrequency);
|
|
|
+ }
|
|
|
+ return testedWords.stream()
|
|
|
+ .map(VocabularySurveyWord::wordFrequency)
|
|
|
+ .distinct()
|
|
|
+ .min(Comparator
|
|
|
+ .comparingInt((Integer candidate) -> error(candidate, testedWords))
|
|
|
+ .thenComparingInt(candidate -> Math.abs(candidate - preferredFrequency)))
|
|
|
+ .orElse(preferredFrequency);
|
|
|
+ }
|
|
|
+
|
|
|
+ private int error(int candidateK, List<VocabularySurveyWord> testedWords) {
|
|
|
+ int error = 0;
|
|
|
+ for (VocabularySurveyWord word : testedWords) {
|
|
|
+ boolean unknown = Boolean.TRUE.equals(word.unknown());
|
|
|
+ if (word.wordFrequency() <= candidateK && unknown) {
|
|
|
+ error++;
|
|
|
+ }
|
|
|
+ if (word.wordFrequency() > candidateK && !unknown) {
|
|
|
+ error++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return error;
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Write report generator test**
|
|
|
+
|
|
|
+Create `VocabularySurveyReportGeneratorTest.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
|
|
|
+
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import java.time.Instant;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+import static org.assertj.core.api.Assertions.assertThat;
|
|
|
+
|
|
|
+class VocabularySurveyReportGeneratorTest {
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void reportUsesKAsVocabularySizeAndMapsAbilityLevel() {
|
|
|
+ VocabularySurveyReport report = new VocabularySurveyReportGenerator()
|
|
|
+ .generate(7, 1600, 2000, 1500, List.of(levelWithWords()));
|
|
|
+
|
|
|
+ assertThat(report.vocabularySize()).isEqualTo(1500);
|
|
|
+ assertThat(report.k()).isEqualTo(1500);
|
|
|
+ assertThat(report.abilityLevel()).isEqualTo("年级稳定");
|
|
|
+ assertThat(report.learningRange().min()).isEqualTo(1260);
|
|
|
+ assertThat(report.learningRange().max()).isEqualTo(1740);
|
|
|
+ assertThat(report.testedWordCount()).isEqualTo(2);
|
|
|
+ assertThat(report.unknownWordCount()).isEqualTo(1);
|
|
|
+ }
|
|
|
+
|
|
|
+ private VocabularySurveyLevel levelWithWords() {
|
|
|
+ return new VocabularySurveyLevel(
|
|
|
+ "lvl_test",
|
|
|
+ "surv_test",
|
|
|
+ 1,
|
|
|
+ 1500,
|
|
|
+ 100,
|
|
|
+ List.of(
|
|
|
+ submittedWord("sw_1", 1, false),
|
|
|
+ submittedWord("sw_2", 2, true)),
|
|
|
+ 0.5D,
|
|
|
+ "feedback",
|
|
|
+ Instant.EPOCH,
|
|
|
+ Instant.EPOCH.plusSeconds(1));
|
|
|
+ }
|
|
|
+
|
|
|
+ private VocabularySurveyWord submittedWord(String surveyWordId, int wordId, boolean unknown) {
|
|
|
+ return new VocabularySurveyWord(
|
|
|
+ surveyWordId,
|
|
|
+ "surv_test",
|
|
|
+ "lvl_test",
|
|
|
+ 1,
|
|
|
+ wordId,
|
|
|
+ 1000 + wordId,
|
|
|
+ "word" + wordId,
|
|
|
+ wordId * 100,
|
|
|
+ unknown,
|
|
|
+ Instant.EPOCH,
|
|
|
+ Instant.EPOCH.plusSeconds(1));
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Implement report model and generator**
|
|
|
+
|
|
|
+Create `VocabularySurveyReport.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
|
|
|
+
|
|
|
+public record VocabularySurveyReport(
|
|
|
+ int vocabularySize,
|
|
|
+ int k,
|
|
|
+ int grade,
|
|
|
+ int gradeMaxFrequency,
|
|
|
+ String abilityLevel,
|
|
|
+ LearningRange learningRange,
|
|
|
+ int testedWordCount,
|
|
|
+ int unknownWordCount,
|
|
|
+ String summary) {
|
|
|
+
|
|
|
+ public record LearningRange(int min, int max) {
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Create `VocabularySurveyReportGenerator.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
|
|
|
+
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+public class VocabularySurveyReportGenerator {
|
|
|
+
|
|
|
+ public VocabularySurveyReport generate(
|
|
|
+ int grade,
|
|
|
+ int gradeMaxFrequency,
|
|
|
+ int challengeMaxFrequency,
|
|
|
+ int k,
|
|
|
+ List<VocabularySurveyLevel> levels) {
|
|
|
+ int testedWordCount = (int) levels.stream()
|
|
|
+ .flatMap(level -> level.words().stream())
|
|
|
+ .filter(word -> word.unknown() != null)
|
|
|
+ .count();
|
|
|
+ int unknownWordCount = (int) levels.stream()
|
|
|
+ .flatMap(level -> level.words().stream())
|
|
|
+ .filter(word -> Boolean.TRUE.equals(word.unknown()))
|
|
|
+ .count();
|
|
|
+ String abilityLevel = abilityLevel(k, gradeMaxFrequency);
|
|
|
+ int rangeOffset = (int) Math.round(0.15D * gradeMaxFrequency);
|
|
|
+ int min = Math.max(1, k - rangeOffset);
|
|
|
+ int max = Math.min(challengeMaxFrequency, k + rangeOffset);
|
|
|
+ return new VocabularySurveyReport(
|
|
|
+ k,
|
|
|
+ k,
|
|
|
+ grade,
|
|
|
+ gradeMaxFrequency,
|
|
|
+ abilityLevel,
|
|
|
+ new VocabularySurveyReport.LearningRange(min, max),
|
|
|
+ testedWordCount,
|
|
|
+ unknownWordCount,
|
|
|
+ summary(abilityLevel));
|
|
|
+ }
|
|
|
+
|
|
|
+ private String abilityLevel(int k, int gradeMaxFrequency) {
|
|
|
+ double ratio = (double) k / gradeMaxFrequency;
|
|
|
+ if (ratio < 0.45D) {
|
|
|
+ return "基础待加强";
|
|
|
+ }
|
|
|
+ if (ratio < 0.75D) {
|
|
|
+ return "年级基础";
|
|
|
+ }
|
|
|
+ if (ratio < 1.05D) {
|
|
|
+ return "年级稳定";
|
|
|
+ }
|
|
|
+ if (ratio <= 1.25D) {
|
|
|
+ return "超前挑战";
|
|
|
+ }
|
|
|
+ return "明显超前";
|
|
|
+ }
|
|
|
+
|
|
|
+ private String summary(String abilityLevel) {
|
|
|
+ return switch (abilityLevel) {
|
|
|
+ case "基础待加强" -> "建议先巩固本年级核心高频词,再逐步挑战更高频段。";
|
|
|
+ case "年级基础" -> "你的词汇基础已具备雏形,建议继续巩固本年级常见词。";
|
|
|
+ case "年级稳定" -> "你的词汇基础比较稳定,适合继续挑战本年级中高阶词汇。";
|
|
|
+ case "超前挑战" -> "你的词汇能力已经超过当前年级基础要求,可以适当挑战更高频段词汇。";
|
|
|
+ default -> "你的词汇掌握明显超前,可以尝试挑战更高年级词汇。";
|
|
|
+ };
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4a: Run K estimator and report generator tests**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```powershell
|
|
|
+mvn -pl abilities/vocabulary-survey/domain -Dtest=VocabularySurveyKEstimatorTest,VocabularySurveyReportGeneratorTest test
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS.
|
|
|
+
|
|
|
+- [ ] **Step 5: Write session tests**
|
|
|
+
|
|
|
+Create `VocabularySurveySessionTest.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
|
|
|
+
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import java.time.Instant;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+import static org.assertj.core.api.Assertions.assertThat;
|
|
|
+
|
|
|
+class VocabularySurveySessionTest {
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void stopAfterFourLevelsWhenRecentTwoLevelsAreNearBoundary() {
|
|
|
+ VocabularySurveySession session = newSessionWithSubmittedRates(0.1D, 0.2D, 0.5D, 0.5D);
|
|
|
+
|
|
|
+ assertThat(session.shouldFinish()).isTrue();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void stopAfterDefaultFiveLevels() {
|
|
|
+ VocabularySurveySession session = newSessionWithSubmittedRates(0.1D, 0.2D, 0.1D, 0.2D, 0.1D);
|
|
|
+
|
|
|
+ assertThat(session.shouldFinish()).isTrue();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void continueBeforeFourLevels() {
|
|
|
+ VocabularySurveySession session = newSessionWithSubmittedRates(0.5D, 0.5D, 0.5D);
|
|
|
+
|
|
|
+ assertThat(session.shouldFinish()).isFalse();
|
|
|
+ }
|
|
|
+
|
|
|
+ private VocabularySurveySession newSessionWithSubmittedRates(double... rates) {
|
|
|
+ VocabularySurveySession session = VocabularySurveySession.start(
|
|
|
+ "surv_test",
|
|
|
+ 123,
|
|
|
+ 7,
|
|
|
+ 1600,
|
|
|
+ 2000,
|
|
|
+ 560,
|
|
|
+ 1600,
|
|
|
+ Instant.EPOCH,
|
|
|
+ Instant.EPOCH.plusSeconds(86400));
|
|
|
+ for (int index = 0; index < rates.length; index++) {
|
|
|
+ session.addLevel(level(index + 1, rates[index]));
|
|
|
+ }
|
|
|
+ return session;
|
|
|
+ }
|
|
|
+
|
|
|
+ private VocabularySurveyLevel level(int levelNo, double unknownRate) {
|
|
|
+ return new VocabularySurveyLevel(
|
|
|
+ "lvl_" + levelNo,
|
|
|
+ "surv_test",
|
|
|
+ levelNo,
|
|
|
+ 500,
|
|
|
+ 100,
|
|
|
+ List.of(),
|
|
|
+ unknownRate,
|
|
|
+ "feedback",
|
|
|
+ Instant.EPOCH,
|
|
|
+ Instant.EPOCH.plusSeconds(levelNo));
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: Implement session and repository port**
|
|
|
+
|
|
|
+Create `VocabularySurveySessionRepository.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
|
|
|
+
|
|
|
+import java.util.Optional;
|
|
|
+
|
|
|
+public interface VocabularySurveySessionRepository {
|
|
|
+
|
|
|
+ VocabularySurveySession save(VocabularySurveySession session);
|
|
|
+ Optional<VocabularySurveySession> findById(String sessionId);
|
|
|
+ Optional<VocabularySurveySession> findActiveByStudentIdAndGrade(Integer studentId, int grade);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Create `VocabularySurveySession.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
|
|
|
+
|
|
|
+import java.time.Instant;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.HashSet;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Optional;
|
|
|
+import java.util.Set;
|
|
|
+
|
|
|
+public class VocabularySurveySession {
|
|
|
+
|
|
|
+ private final String sessionId;
|
|
|
+ private final Integer studentId;
|
|
|
+ private final int grade;
|
|
|
+ private final int gradeMaxFrequency;
|
|
|
+ private final int challengeMaxFrequency;
|
|
|
+ private int currentCenterFrequency;
|
|
|
+ private int currentWordWidth;
|
|
|
+ private VocabularySurveyStatus status;
|
|
|
+ private final Instant createdAt;
|
|
|
+ private final Instant expiresAt;
|
|
|
+ private Instant finishedAt;
|
|
|
+ private final List<VocabularySurveyLevel> levels = new ArrayList<>();
|
|
|
+ private VocabularySurveyReport report;
|
|
|
+
|
|
|
+ private VocabularySurveySession(
|
|
|
+ String sessionId,
|
|
|
+ Integer studentId,
|
|
|
+ int grade,
|
|
|
+ int gradeMaxFrequency,
|
|
|
+ int challengeMaxFrequency,
|
|
|
+ int currentCenterFrequency,
|
|
|
+ int currentWordWidth,
|
|
|
+ VocabularySurveyStatus status,
|
|
|
+ Instant createdAt,
|
|
|
+ Instant expiresAt) {
|
|
|
+ this.sessionId = sessionId;
|
|
|
+ this.studentId = studentId;
|
|
|
+ this.grade = grade;
|
|
|
+ this.gradeMaxFrequency = gradeMaxFrequency;
|
|
|
+ this.challengeMaxFrequency = challengeMaxFrequency;
|
|
|
+ this.currentCenterFrequency = currentCenterFrequency;
|
|
|
+ this.currentWordWidth = currentWordWidth;
|
|
|
+ this.status = status;
|
|
|
+ this.createdAt = createdAt;
|
|
|
+ this.expiresAt = expiresAt;
|
|
|
+ }
|
|
|
+
|
|
|
+ public static VocabularySurveySession start(
|
|
|
+ String sessionId,
|
|
|
+ Integer studentId,
|
|
|
+ int grade,
|
|
|
+ int gradeMaxFrequency,
|
|
|
+ int challengeMaxFrequency,
|
|
|
+ int currentCenterFrequency,
|
|
|
+ int currentWordWidth,
|
|
|
+ Instant createdAt,
|
|
|
+ Instant expiresAt) {
|
|
|
+ return new VocabularySurveySession(
|
|
|
+ sessionId,
|
|
|
+ studentId,
|
|
|
+ grade,
|
|
|
+ gradeMaxFrequency,
|
|
|
+ challengeMaxFrequency,
|
|
|
+ currentCenterFrequency,
|
|
|
+ currentWordWidth,
|
|
|
+ VocabularySurveyStatus.IN_PROGRESS,
|
|
|
+ createdAt,
|
|
|
+ expiresAt);
|
|
|
+ }
|
|
|
+
|
|
|
+ public String sessionId() { return sessionId; }
|
|
|
+ public Integer studentId() { return studentId; }
|
|
|
+ public int grade() { return grade; }
|
|
|
+ public int gradeMaxFrequency() { return gradeMaxFrequency; }
|
|
|
+ public int challengeMaxFrequency() { return challengeMaxFrequency; }
|
|
|
+ public int currentCenterFrequency() { return currentCenterFrequency; }
|
|
|
+ public int currentWordWidth() { return currentWordWidth; }
|
|
|
+ public VocabularySurveyStatus status() { return status; }
|
|
|
+ public Instant createdAt() { return createdAt; }
|
|
|
+ public Instant expiresAt() { return expiresAt; }
|
|
|
+ public Instant finishedAt() { return finishedAt; }
|
|
|
+ public List<VocabularySurveyLevel> levels() { return List.copyOf(levels); }
|
|
|
+ public VocabularySurveyReport report() { return report; }
|
|
|
+
|
|
|
+ public boolean isExpiredAt(Instant instant) {
|
|
|
+ return !expiresAt.isAfter(instant);
|
|
|
+ }
|
|
|
+
|
|
|
+ public void expire() {
|
|
|
+ status = VocabularySurveyStatus.EXPIRED;
|
|
|
+ }
|
|
|
+
|
|
|
+ public void cancel() {
|
|
|
+ status = VocabularySurveyStatus.CANCELLED;
|
|
|
+ }
|
|
|
+
|
|
|
+ public void addLevel(VocabularySurveyLevel level) {
|
|
|
+ levels.add(level);
|
|
|
+ }
|
|
|
+
|
|
|
+ public Optional<VocabularySurveyLevel> currentLevel() {
|
|
|
+ if (levels.isEmpty()) {
|
|
|
+ return Optional.empty();
|
|
|
+ }
|
|
|
+ return Optional.of(levels.get(levels.size() - 1));
|
|
|
+ }
|
|
|
+
|
|
|
+ public Set<Integer> testedWordIds() {
|
|
|
+ Set<Integer> wordIds = new HashSet<>();
|
|
|
+ for (VocabularySurveyLevel level : levels) {
|
|
|
+ for (VocabularySurveyWord word : level.words()) {
|
|
|
+ wordIds.add(word.wordId());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return wordIds;
|
|
|
+ }
|
|
|
+
|
|
|
+ public void replaceCurrentLevel(VocabularySurveyLevel submittedLevel) {
|
|
|
+ if (levels.isEmpty()) {
|
|
|
+ throw new IllegalStateException("session has no levels");
|
|
|
+ }
|
|
|
+ levels.set(levels.size() - 1, submittedLevel);
|
|
|
+ }
|
|
|
+
|
|
|
+ public void updateNextState(VocabularySurveyLevelPlanner.NextLevelState nextState) {
|
|
|
+ currentCenterFrequency = nextState.centerFrequency();
|
|
|
+ currentWordWidth = nextState.wordWidth();
|
|
|
+ }
|
|
|
+
|
|
|
+ public boolean shouldFinish() {
|
|
|
+ int completedLevelCount = (int) levels.stream().filter(VocabularySurveyLevel::submitted).count();
|
|
|
+ if (completedLevelCount >= 6) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ if (completedLevelCount >= 4 && lastTwoSubmittedLevelsNearBoundary()) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ return completedLevelCount >= 5;
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean lastTwoSubmittedLevelsNearBoundary() {
|
|
|
+ List<VocabularySurveyLevel> submittedLevels = levels.stream()
|
|
|
+ .filter(VocabularySurveyLevel::submitted)
|
|
|
+ .toList();
|
|
|
+ if (submittedLevels.size() < 2) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ VocabularySurveyLevel previous = submittedLevels.get(submittedLevels.size() - 2);
|
|
|
+ VocabularySurveyLevel current = submittedLevels.get(submittedLevels.size() - 1);
|
|
|
+ return previous.nearBoundary() && current.nearBoundary();
|
|
|
+ }
|
|
|
+
|
|
|
+ public void finish(VocabularySurveyReport report, Instant finishedAt) {
|
|
|
+ this.report = report;
|
|
|
+ this.finishedAt = finishedAt;
|
|
|
+ this.status = VocabularySurveyStatus.FINISHED;
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 7: Run domain tests**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```powershell
|
|
|
+mvn -pl abilities/vocabulary-survey/domain test
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS.
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 5: Add in-memory repository and dictionary infrastructure
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Copy: `dict.data` to resources.
|
|
|
+- Create: infrastructure repository and dictionary classes.
|
|
|
+- Test: infrastructure repository and dictionary tests.
|
|
|
+
|
|
|
+- [ ] **Step 1: Copy the authorized dictionary file**
|
|
|
+
|
|
|
+Before copying, verify the parent destination exists or create it inside the workspace. Use PowerShell equivalent commands with quoted paths:
|
|
|
+
|
|
|
+```powershell
|
|
|
+Test-Path -LiteralPath "abilities\vocabulary-survey\infrastructure\src\main\resources"
|
|
|
+```
|
|
|
+
|
|
|
+If the path exists, create `data` under it and copy:
|
|
|
+
|
|
|
+```powershell
|
|
|
+New-Item -ItemType Directory -Force -Path "abilities\vocabulary-survey\infrastructure\src\main\resources\data"
|
|
|
+Copy-Item -LiteralPath "F:\Codes\Projects\qingti.teaching.api\Qingti.Teaching.Dictionary\Resources\dict.data" -Destination "abilities\vocabulary-survey\infrastructure\src\main\resources\data\dict.data"
|
|
|
+```
|
|
|
+
|
|
|
+Expected: `abilities/vocabulary-survey/infrastructure/src/main/resources/data/dict.data` exists. Do not print file contents.
|
|
|
+
|
|
|
+- [ ] **Step 2: Write in-memory repository test**
|
|
|
+
|
|
|
+Create `InMemoryVocabularySurveySessionRepositoryTest.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.repository;
|
|
|
+
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyGradePolicy;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyIds;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveySession;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import java.time.Instant;
|
|
|
+
|
|
|
+import static org.assertj.core.api.Assertions.assertThat;
|
|
|
+
|
|
|
+class InMemoryVocabularySurveySessionRepositoryTest {
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void saveAndFindByIdReturnsSession() {
|
|
|
+ InMemoryVocabularySurveySessionRepository repository = new InMemoryVocabularySurveySessionRepository();
|
|
|
+ VocabularySurveySession session = session(123, 7);
|
|
|
+
|
|
|
+ repository.save(session);
|
|
|
+
|
|
|
+ assertThat(repository.findById(session.sessionId())).containsSame(session);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void findActiveByStudentIdAndGradeSkipsCancelledSession() {
|
|
|
+ InMemoryVocabularySurveySessionRepository repository = new InMemoryVocabularySurveySessionRepository();
|
|
|
+ VocabularySurveySession session = session(123, 7);
|
|
|
+ session.cancel();
|
|
|
+ repository.save(session);
|
|
|
+
|
|
|
+ assertThat(repository.findActiveByStudentIdAndGrade(123, 7)).isEmpty();
|
|
|
+ }
|
|
|
+
|
|
|
+ private VocabularySurveySession session(Integer studentId, int grade) {
|
|
|
+ int gradeMax = VocabularySurveyGradePolicy.gradeMaxFrequency(grade);
|
|
|
+ return VocabularySurveySession.start(
|
|
|
+ VocabularySurveyIds.sessionId(),
|
|
|
+ studentId,
|
|
|
+ grade,
|
|
|
+ gradeMax,
|
|
|
+ VocabularySurveyGradePolicy.challengeMaxFrequency(grade, 5000),
|
|
|
+ (int) Math.round(0.35D * gradeMax),
|
|
|
+ gradeMax,
|
|
|
+ Instant.EPOCH,
|
|
|
+ Instant.EPOCH.plusSeconds(86400));
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Implement in-memory repository**
|
|
|
+
|
|
|
+Create `InMemoryVocabularySurveySessionRepository.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.repository;
|
|
|
+
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveySession;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveySessionRepository;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyStatus;
|
|
|
+import org.springframework.stereotype.Repository;
|
|
|
+
|
|
|
+import java.util.Optional;
|
|
|
+import java.util.concurrent.ConcurrentHashMap;
|
|
|
+import java.util.concurrent.ConcurrentMap;
|
|
|
+
|
|
|
+@Repository
|
|
|
+public class InMemoryVocabularySurveySessionRepository implements VocabularySurveySessionRepository {
|
|
|
+
|
|
|
+ private final ConcurrentMap<String, VocabularySurveySession> storage = new ConcurrentHashMap<>();
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public VocabularySurveySession save(VocabularySurveySession session) {
|
|
|
+ storage.put(session.sessionId(), session);
|
|
|
+ return session;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public Optional<VocabularySurveySession> findById(String sessionId) {
|
|
|
+ return Optional.ofNullable(storage.get(sessionId));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public Optional<VocabularySurveySession> findActiveByStudentIdAndGrade(Integer studentId, int grade) {
|
|
|
+ return storage.values().stream()
|
|
|
+ .filter(session -> studentId == null || studentId.equals(session.studentId()))
|
|
|
+ .filter(session -> session.grade() == grade)
|
|
|
+ .filter(session -> session.status() == VocabularySurveyStatus.IN_PROGRESS)
|
|
|
+ .findFirst();
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Write dictionary provider smoke test**
|
|
|
+
|
|
|
+Create `MessagePackVocabularyWordProviderTest.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.dictionary;
|
|
|
+
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.application.VocabularySurveyProperties;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularyWord;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.springframework.core.io.DefaultResourceLoader;
|
|
|
+
|
|
|
+import java.util.Set;
|
|
|
+
|
|
|
+import static org.assertj.core.api.Assertions.assertThat;
|
|
|
+
|
|
|
+class MessagePackVocabularyWordProviderTest {
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void loadsDictionaryAndFindsWordsAroundFrequency() {
|
|
|
+ VocabularySurveyProperties properties = new VocabularySurveyProperties();
|
|
|
+ properties.getDictionary().setLocation("classpath:data/dict.data");
|
|
|
+ MessagePackVocabularyWordProvider provider = new MessagePackVocabularyWordProvider(
|
|
|
+ new MessagePackDictionaryLoader(new DefaultResourceLoader()),
|
|
|
+ properties);
|
|
|
+
|
|
|
+ assertThat(provider.maxWordFrequency()).isPositive();
|
|
|
+ assertThat(provider.findWordsAroundFrequency(500, 200, 12, Set.of()))
|
|
|
+ .hasSize(12)
|
|
|
+ .allSatisfy(word -> {
|
|
|
+ assertThat(word.wordId()).isPositive();
|
|
|
+ assertThat(word.meaningId()).isPositive();
|
|
|
+ assertThat(word.spell()).isNotBlank();
|
|
|
+ assertThat(word.wordFrequency()).isPositive();
|
|
|
+ });
|
|
|
+ VocabularyWord first = provider.findWordsAroundFrequency(500, 200, 1, Set.of()).get(0);
|
|
|
+ assertThat(provider.findByWordId(first.wordId())).contains(first);
|
|
|
+ assertThat(provider.findByMeaningId(first.meaningId())).contains(first);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Create dictionary data records**
|
|
|
+
|
|
|
+Create `DictionaryObject.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.dictionary;
|
|
|
+
|
|
|
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
|
|
+
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+@JsonIgnoreProperties(ignoreUnknown = true)
|
|
|
+public record DictionaryObject(
|
|
|
+ List<DictionaryWord> words,
|
|
|
+ List<DictionaryExchange> exchanges,
|
|
|
+ List<DictionaryMeaning> meanings) {
|
|
|
+
|
|
|
+ public List<DictionaryWord> wordsOrEmpty() {
|
|
|
+ return words == null ? List.of() : words;
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Create `DictionaryWord.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.dictionary;
|
|
|
+
|
|
|
+import com.fasterxml.jackson.annotation.JsonAlias;
|
|
|
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
|
|
+
|
|
|
+@JsonIgnoreProperties(ignoreUnknown = true)
|
|
|
+public record DictionaryWord(
|
|
|
+ @JsonAlias({"wordId", "WordId"}) Integer wordId,
|
|
|
+ @JsonAlias({"spell", "Spell", "wordSpell", "WordSpell"}) String spell,
|
|
|
+ @JsonAlias({"wordFrequency", "WordFrequency"}) Integer wordFrequency,
|
|
|
+ @JsonAlias({"meaningId", "MeaningId", "meanId", "MeanId"}) Integer meaningId) {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Create `DictionaryMeaning.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.dictionary;
|
|
|
+
|
|
|
+import com.fasterxml.jackson.annotation.JsonAlias;
|
|
|
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
|
|
+
|
|
|
+@JsonIgnoreProperties(ignoreUnknown = true)
|
|
|
+public record DictionaryMeaning(
|
|
|
+ @JsonAlias({"meaningId", "MeaningId", "meanId", "MeanId"}) Integer meaningId,
|
|
|
+ @JsonAlias({"wordId", "WordId"}) Integer wordId) {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Create `DictionaryExchange.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.dictionary;
|
|
|
+
|
|
|
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
|
|
+
|
|
|
+@JsonIgnoreProperties(ignoreUnknown = true)
|
|
|
+public record DictionaryExchange() {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: Implement MessagePack loader and provider**
|
|
|
+
|
|
|
+Create `MessagePackDictionaryLoader.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.dictionary;
|
|
|
+
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import org.msgpack.jackson.dataformat.MessagePackFactory;
|
|
|
+import org.springframework.core.io.Resource;
|
|
|
+import org.springframework.core.io.ResourceLoader;
|
|
|
+import org.springframework.stereotype.Component;
|
|
|
+
|
|
|
+import java.io.IOException;
|
|
|
+import java.io.InputStream;
|
|
|
+
|
|
|
+@Component
|
|
|
+public class MessagePackDictionaryLoader {
|
|
|
+
|
|
|
+ private final ResourceLoader resourceLoader;
|
|
|
+ private final ObjectMapper objectMapper = new ObjectMapper(new MessagePackFactory());
|
|
|
+
|
|
|
+ public MessagePackDictionaryLoader(ResourceLoader resourceLoader) {
|
|
|
+ this.resourceLoader = resourceLoader;
|
|
|
+ }
|
|
|
+
|
|
|
+ public DictionaryObject load(String location) {
|
|
|
+ Resource resource = resourceLoader.getResource(location);
|
|
|
+ try (InputStream inputStream = resource.getInputStream()) {
|
|
|
+ DictionaryObject dictionaryObject = objectMapper.readValue(inputStream, DictionaryObject.class);
|
|
|
+ if (dictionaryObject == null || dictionaryObject.wordsOrEmpty().isEmpty()) {
|
|
|
+ throw new IllegalStateException("dictionary contains no words: " + location);
|
|
|
+ }
|
|
|
+ return dictionaryObject;
|
|
|
+ } catch (IOException exception) {
|
|
|
+ throw new IllegalStateException("failed to load vocabulary dictionary: " + location, exception);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Create `MessagePackVocabularyWordProvider.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.dictionary;
|
|
|
+
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.application.VocabularySurveyProperties;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularyWord;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularyWordProvider;
|
|
|
+import org.springframework.stereotype.Component;
|
|
|
+
|
|
|
+import java.util.Comparator;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.Optional;
|
|
|
+import java.util.Set;
|
|
|
+
|
|
|
+@Component
|
|
|
+public class MessagePackVocabularyWordProvider implements VocabularyWordProvider {
|
|
|
+
|
|
|
+ private final List<VocabularyWord> sortedWords;
|
|
|
+ private final Map<Integer, VocabularyWord> wordsByWordId;
|
|
|
+ private final Map<Integer, VocabularyWord> wordsByMeaningId;
|
|
|
+ private final int maxWordFrequency;
|
|
|
+
|
|
|
+ public MessagePackVocabularyWordProvider(
|
|
|
+ MessagePackDictionaryLoader loader,
|
|
|
+ VocabularySurveyProperties properties) {
|
|
|
+ DictionaryObject dictionaryObject = loader.load(properties.getDictionary().getLocation());
|
|
|
+ this.sortedWords = dictionaryObject.wordsOrEmpty().stream()
|
|
|
+ .map(this::toVocabularyWord)
|
|
|
+ .flatMap(Optional::stream)
|
|
|
+ .sorted(Comparator.comparingInt(VocabularyWord::wordFrequency))
|
|
|
+ .toList();
|
|
|
+ this.wordsByWordId = new HashMap<>();
|
|
|
+ this.wordsByMeaningId = new HashMap<>();
|
|
|
+ for (VocabularyWord word : sortedWords) {
|
|
|
+ wordsByWordId.putIfAbsent(word.wordId(), word);
|
|
|
+ wordsByMeaningId.putIfAbsent(word.meaningId(), word);
|
|
|
+ }
|
|
|
+ this.maxWordFrequency = sortedWords.stream().mapToInt(VocabularyWord::wordFrequency).max().orElse(0);
|
|
|
+ if (sortedWords.isEmpty()) {
|
|
|
+ throw new IllegalStateException("dictionary contains no usable vocabulary words");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public List<VocabularyWord> findWordsAroundFrequency(
|
|
|
+ int centerFrequency,
|
|
|
+ int width,
|
|
|
+ int count,
|
|
|
+ Set<Integer> excludedWordIds) {
|
|
|
+ int halfWidth = Math.max(1, width / 2);
|
|
|
+ return sortedWords.stream()
|
|
|
+ .filter(word -> !excludedWordIds.contains(word.wordId()))
|
|
|
+ .sorted(Comparator
|
|
|
+ .comparingInt((VocabularyWord word) -> Math.abs(word.wordFrequency() - centerFrequency))
|
|
|
+ .thenComparingInt(VocabularyWord::wordFrequency)
|
|
|
+ .thenComparingInt(VocabularyWord::wordId))
|
|
|
+ .filter(word -> Math.abs(word.wordFrequency() - centerFrequency) <= Math.max(halfWidth, 50)
|
|
|
+ || sortedWords.size() <= count)
|
|
|
+ .limit(count)
|
|
|
+ .toList();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public Optional<VocabularyWord> findByWordId(int wordId) {
|
|
|
+ return Optional.ofNullable(wordsByWordId.get(wordId));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public Optional<VocabularyWord> findByMeaningId(int meaningId) {
|
|
|
+ return Optional.ofNullable(wordsByMeaningId.get(meaningId));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public int maxWordFrequency() {
|
|
|
+ return maxWordFrequency;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Optional<VocabularyWord> toVocabularyWord(DictionaryWord word) {
|
|
|
+ if (word.wordId() == null || word.meaningId() == null || word.spell() == null || word.wordFrequency() == null) {
|
|
|
+ return Optional.empty();
|
|
|
+ }
|
|
|
+ if (word.wordId() <= 0 || word.meaningId() <= 0 || word.spell().isBlank() || word.wordFrequency() <= 0) {
|
|
|
+ return Optional.empty();
|
|
|
+ }
|
|
|
+ return Optional.of(new VocabularyWord(word.wordId(), word.meaningId(), word.spell(), word.wordFrequency()));
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+If this test fails because the C# MessagePack file uses array keys instead of map keys, stop and ask for authorization to inspect the C# dictionary model source files. Do not guess field order from the binary file.
|
|
|
+
|
|
|
+- [ ] **Step 7: Run infrastructure tests**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```powershell
|
|
|
+mvn -pl abilities/vocabulary-survey/infrastructure test
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS if MessagePack field names bind. If dictionary loading fails due MessagePack shape, report the exact exception type and request the C# model source mapping before editing parser code.
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 6: Add application service orchestration
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: application service, commands, mapper, properties.
|
|
|
+- Test: `DefaultVocabularySurveyApplicationServiceTest.java`.
|
|
|
+
|
|
|
+- [ ] **Step 1: Create application properties**
|
|
|
+
|
|
|
+Create `VocabularySurveyProperties.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.application;
|
|
|
+
|
|
|
+import java.time.Duration;
|
|
|
+
|
|
|
+public class VocabularySurveyProperties {
|
|
|
+
|
|
|
+ private Duration sessionTtl = Duration.ofHours(24);
|
|
|
+ private final Dictionary dictionary = new Dictionary();
|
|
|
+
|
|
|
+ public Duration getSessionTtl() {
|
|
|
+ return sessionTtl;
|
|
|
+ }
|
|
|
+
|
|
|
+ public void setSessionTtl(Duration sessionTtl) {
|
|
|
+ this.sessionTtl = sessionTtl;
|
|
|
+ }
|
|
|
+
|
|
|
+ public Dictionary getDictionary() {
|
|
|
+ return dictionary;
|
|
|
+ }
|
|
|
+
|
|
|
+ public static class Dictionary {
|
|
|
+ private String location = "classpath:data/dict.data";
|
|
|
+
|
|
|
+ public String getLocation() {
|
|
|
+ return location;
|
|
|
+ }
|
|
|
+
|
|
|
+ public void setLocation(String location) {
|
|
|
+ this.location = location;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Create commands and service interface**
|
|
|
+
|
|
|
+Create `StartVocabularySurveyCommand.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.application;
|
|
|
+
|
|
|
+public record StartVocabularySurveyCommand(Integer studentId, int grade, boolean restart) {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Create `SubmitVocabularySurveyLevelCommand.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.application;
|
|
|
+
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+public record SubmitVocabularySurveyLevelCommand(
|
|
|
+ String sessionId,
|
|
|
+ String levelId,
|
|
|
+ List<String> unknownSurveyWordIds) {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Create `VocabularySurveyApplicationService.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.application;
|
|
|
+
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.SubmitVocabularySurveyLevelResponse;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveySessionResponse;
|
|
|
+
|
|
|
+public interface VocabularySurveyApplicationService {
|
|
|
+
|
|
|
+ VocabularySurveySessionResponse start(StartVocabularySurveyCommand command);
|
|
|
+ SubmitVocabularySurveyLevelResponse submitLevel(SubmitVocabularySurveyLevelCommand command);
|
|
|
+ VocabularySurveySessionResponse getSession(String sessionId);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Write application service tests**
|
|
|
+
|
|
|
+Create `DefaultVocabularySurveyApplicationServiceTest.java` with fake provider and repository helpers:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.application;
|
|
|
+
|
|
|
+import cn.yunzhixue.ability.center.kernel.BusinessException;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.SubmitVocabularySurveyLevelResponse;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveySessionResponse;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveySession;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveySessionRepository;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularyWord;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularyWordProvider;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import java.time.Clock;
|
|
|
+import java.time.Instant;
|
|
|
+import java.time.ZoneOffset;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Optional;
|
|
|
+import java.util.Set;
|
|
|
+import java.util.concurrent.ConcurrentHashMap;
|
|
|
+import java.util.concurrent.ConcurrentMap;
|
|
|
+
|
|
|
+import static org.assertj.core.api.Assertions.assertThat;
|
|
|
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
|
+
|
|
|
+class DefaultVocabularySurveyApplicationServiceTest {
|
|
|
+
|
|
|
+ private final Clock clock = Clock.fixed(Instant.parse("2026-05-24T00:00:00Z"), ZoneOffset.UTC);
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void startCreatesFirstLevelWithTwelveWords() {
|
|
|
+ DefaultVocabularySurveyApplicationService service = service();
|
|
|
+
|
|
|
+ VocabularySurveySessionResponse response = service.start(new StartVocabularySurveyCommand(123, 7, false));
|
|
|
+
|
|
|
+ assertThat(response.sessionId()).startsWith("surv_");
|
|
|
+ assertThat(response.level().levelNo()).isEqualTo(1);
|
|
|
+ assertThat(response.level().words()).hasSize(12);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void startReturnsExistingActiveSessionUnlessRestartRequested() {
|
|
|
+ DefaultVocabularySurveyApplicationService service = service();
|
|
|
+ VocabularySurveySessionResponse first = service.start(new StartVocabularySurveyCommand(123, 7, false));
|
|
|
+
|
|
|
+ VocabularySurveySessionResponse second = service.start(new StartVocabularySurveyCommand(123, 7, false));
|
|
|
+
|
|
|
+ VocabularySurveySessionResponse restarted = service.start(new StartVocabularySurveyCommand(123, 7, true));
|
|
|
+
|
|
|
+ assertThat(second.sessionId()).isEqualTo(first.sessionId());
|
|
|
+ assertThat(restarted.sessionId()).isNotEqualTo(first.sessionId());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void submitUnknownWordsReturnsNextLevel() {
|
|
|
+ DefaultVocabularySurveyApplicationService service = service();
|
|
|
+ VocabularySurveySessionResponse started = service.start(new StartVocabularySurveyCommand(123, 7, false));
|
|
|
+
|
|
|
+ SubmitVocabularySurveyLevelResponse response = service.submitLevel(new SubmitVocabularySurveyLevelCommand(
|
|
|
+ started.sessionId(),
|
|
|
+ started.level().levelId(),
|
|
|
+ List.of(started.level().words().get(0).surveyWordId())));
|
|
|
+
|
|
|
+ assertThat(response.status().value()).isEqualTo("inProgress");
|
|
|
+ assertThat(response.submittedLevelNo()).isEqualTo(1);
|
|
|
+ assertThat(response.level().levelNo()).isEqualTo(2);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void submitRejectsSurveyWordFromOtherLevel() {
|
|
|
+ DefaultVocabularySurveyApplicationService service = service();
|
|
|
+ VocabularySurveySessionResponse started = service.start(new StartVocabularySurveyCommand(123, 7, false));
|
|
|
+
|
|
|
+ assertThatThrownBy(() -> service.submitLevel(new SubmitVocabularySurveyLevelCommand(
|
|
|
+ started.sessionId(),
|
|
|
+ started.level().levelId(),
|
|
|
+ List.of("sw_not_in_level"))))
|
|
|
+ .isInstanceOf(BusinessException.class)
|
|
|
+ .hasMessageContaining("invalid survey word");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void repeatedSubmitIsIdempotent() {
|
|
|
+ DefaultVocabularySurveyApplicationService service = service();
|
|
|
+ VocabularySurveySessionResponse started = service.start(new StartVocabularySurveyCommand(123, 7, false));
|
|
|
+ SubmitVocabularySurveyLevelCommand command = new SubmitVocabularySurveyLevelCommand(
|
|
|
+ started.sessionId(),
|
|
|
+ started.level().levelId(),
|
|
|
+ List.of());
|
|
|
+
|
|
|
+ SubmitVocabularySurveyLevelResponse first = service.submitLevel(command);
|
|
|
+ SubmitVocabularySurveyLevelResponse second = service.submitLevel(command);
|
|
|
+
|
|
|
+ assertThat(second.level().levelId()).isEqualTo(first.level().levelId());
|
|
|
+ }
|
|
|
+
|
|
|
+ private DefaultVocabularySurveyApplicationService service() {
|
|
|
+ VocabularySurveyProperties properties = new VocabularySurveyProperties();
|
|
|
+ return new DefaultVocabularySurveyApplicationService(
|
|
|
+ new TestRepository(),
|
|
|
+ new FakeProvider(),
|
|
|
+ properties,
|
|
|
+ clock);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static final class FakeProvider implements VocabularyWordProvider {
|
|
|
+ private final List<VocabularyWord> words = new ArrayList<>();
|
|
|
+
|
|
|
+ private FakeProvider() {
|
|
|
+ for (int index = 1; index <= 500; index++) {
|
|
|
+ words.add(new VocabularyWord(index, 1000 + index, "word" + index, index * 10));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public List<VocabularyWord> findWordsAroundFrequency(int centerFrequency, int width, int count, Set<Integer> excludedWordIds) {
|
|
|
+ return words.stream()
|
|
|
+ .filter(word -> !excludedWordIds.contains(word.wordId()))
|
|
|
+ .sorted((left, right) -> Integer.compare(
|
|
|
+ Math.abs(left.wordFrequency() - centerFrequency),
|
|
|
+ Math.abs(right.wordFrequency() - centerFrequency)))
|
|
|
+ .limit(count)
|
|
|
+ .toList();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public Optional<VocabularyWord> findByWordId(int wordId) {
|
|
|
+ return words.stream().filter(word -> word.wordId() == wordId).findFirst();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public Optional<VocabularyWord> findByMeaningId(int meaningId) {
|
|
|
+ return words.stream().filter(word -> word.meaningId() == meaningId).findFirst();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public int maxWordFrequency() {
|
|
|
+ return 5000;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static final class TestRepository implements VocabularySurveySessionRepository {
|
|
|
+ private final ConcurrentMap<String, VocabularySurveySession> storage = new ConcurrentHashMap<>();
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public VocabularySurveySession save(VocabularySurveySession session) {
|
|
|
+ storage.put(session.sessionId(), session);
|
|
|
+ return session;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public Optional<VocabularySurveySession> findById(String sessionId) {
|
|
|
+ return Optional.ofNullable(storage.get(sessionId));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public Optional<VocabularySurveySession> findActiveByStudentIdAndGrade(Integer studentId, int grade) {
|
|
|
+ return storage.values().stream()
|
|
|
+ .filter(session -> studentId == null || studentId.equals(session.studentId()))
|
|
|
+ .filter(session -> session.grade() == grade)
|
|
|
+ .filter(session -> session.status() == cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyStatus.IN_PROGRESS)
|
|
|
+ .findFirst();
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Implement result mapper**
|
|
|
+
|
|
|
+Create `VocabularySurveyResultMapper.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.application;
|
|
|
+
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveyLearningRangeResponse;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveyLevelResponse;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveyReportResponse;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveySessionResponse;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveyWordResponse;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyLevel;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyReport;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveySession;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyWord;
|
|
|
+
|
|
|
+public final class VocabularySurveyResultMapper {
|
|
|
+
|
|
|
+ private VocabularySurveyResultMapper() {
|
|
|
+ }
|
|
|
+
|
|
|
+ public static VocabularySurveySessionResponse startResponse(VocabularySurveySession session) {
|
|
|
+ return VocabularySurveySessionResponse.started(
|
|
|
+ session.sessionId(),
|
|
|
+ session.grade(),
|
|
|
+ toLevelResponse(session.currentLevel().orElseThrow()));
|
|
|
+ }
|
|
|
+
|
|
|
+ public static VocabularySurveySessionResponse sessionResponse(VocabularySurveySession session) {
|
|
|
+ if (session.report() != null) {
|
|
|
+ return VocabularySurveySessionResponse.finished(session.sessionId(), session.grade(), toReportResponse(session.report()));
|
|
|
+ }
|
|
|
+ return VocabularySurveySessionResponse.current(
|
|
|
+ session.sessionId(),
|
|
|
+ session.grade(),
|
|
|
+ toLevelResponse(session.currentLevel().orElseThrow()));
|
|
|
+ }
|
|
|
+
|
|
|
+ public static VocabularySurveyLevelResponse toLevelResponse(VocabularySurveyLevel level) {
|
|
|
+ return new VocabularySurveyLevelResponse(
|
|
|
+ level.levelId(),
|
|
|
+ level.levelNo(),
|
|
|
+ level.wordCount(),
|
|
|
+ level.feedback(),
|
|
|
+ level.words().stream().map(VocabularySurveyResultMapper::toWordResponse).toList());
|
|
|
+ }
|
|
|
+
|
|
|
+ public static VocabularySurveyReportResponse toReportResponse(VocabularySurveyReport report) {
|
|
|
+ return new VocabularySurveyReportResponse(
|
|
|
+ report.vocabularySize(),
|
|
|
+ report.k(),
|
|
|
+ report.grade(),
|
|
|
+ report.gradeMaxFrequency(),
|
|
|
+ report.abilityLevel(),
|
|
|
+ new VocabularySurveyLearningRangeResponse(report.learningRange().min(), report.learningRange().max()),
|
|
|
+ report.testedWordCount(),
|
|
|
+ report.unknownWordCount(),
|
|
|
+ report.summary());
|
|
|
+ }
|
|
|
+
|
|
|
+ private static VocabularySurveyWordResponse toWordResponse(VocabularySurveyWord word) {
|
|
|
+ return new VocabularySurveyWordResponse(
|
|
|
+ word.surveyWordId(),
|
|
|
+ word.wordId(),
|
|
|
+ word.meaningId(),
|
|
|
+ word.spell(),
|
|
|
+ word.wordFrequency());
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Implement application service**
|
|
|
+
|
|
|
+Create `DefaultVocabularySurveyApplicationService.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.application;
|
|
|
+
|
|
|
+import cn.yunzhixue.ability.center.kernel.BusinessException;
|
|
|
+import cn.yunzhixue.ability.center.kernel.ErrorCode;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.SubmitVocabularySurveyLevelResponse;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveySessionResponse;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyGradePolicy;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyIds;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyKEstimator;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyLevel;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyLevelPlanner;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyReport;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyReportGenerator;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveySession;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveySessionRepository;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyStatus;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularyWordProvider;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import java.time.Clock;
|
|
|
+import java.time.Instant;
|
|
|
+import java.util.HashSet;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Optional;
|
|
|
+import java.util.Set;
|
|
|
+
|
|
|
+@Service
|
|
|
+public class DefaultVocabularySurveyApplicationService implements VocabularySurveyApplicationService {
|
|
|
+
|
|
|
+ private final VocabularySurveySessionRepository repository;
|
|
|
+ private final VocabularyWordProvider wordProvider;
|
|
|
+ private final VocabularySurveyProperties properties;
|
|
|
+ private final Clock clock;
|
|
|
+ private final VocabularySurveyLevelPlanner levelPlanner;
|
|
|
+ private final VocabularySurveyKEstimator kEstimator = new VocabularySurveyKEstimator();
|
|
|
+ private final VocabularySurveyReportGenerator reportGenerator = new VocabularySurveyReportGenerator();
|
|
|
+
|
|
|
+ public DefaultVocabularySurveyApplicationService(
|
|
|
+ VocabularySurveySessionRepository repository,
|
|
|
+ VocabularyWordProvider wordProvider,
|
|
|
+ VocabularySurveyProperties properties,
|
|
|
+ Clock clock) {
|
|
|
+ this.repository = repository;
|
|
|
+ this.wordProvider = wordProvider;
|
|
|
+ this.properties = properties;
|
|
|
+ this.clock = clock;
|
|
|
+ this.levelPlanner = new VocabularySurveyLevelPlanner(wordProvider);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public VocabularySurveySessionResponse start(StartVocabularySurveyCommand command) {
|
|
|
+ Instant now = clock.instant();
|
|
|
+ VocabularySurveyGradePolicy.gradeMaxFrequency(command.grade());
|
|
|
+ Optional<VocabularySurveySession> existing = repository
|
|
|
+ .findActiveByStudentIdAndGrade(command.studentId(), command.grade())
|
|
|
+ .filter(session -> !session.isExpiredAt(now));
|
|
|
+ if (existing.isPresent() && !command.restart()) {
|
|
|
+ return VocabularySurveyResultMapper.sessionResponse(existing.orElseThrow());
|
|
|
+ }
|
|
|
+ existing.ifPresent(session -> {
|
|
|
+ session.cancel();
|
|
|
+ repository.save(session);
|
|
|
+ });
|
|
|
+ return createNewSession(command, now);
|
|
|
+ }
|
|
|
+
|
|
|
+ private VocabularySurveySessionResponse createNewSession(StartVocabularySurveyCommand command, Instant now) {
|
|
|
+ int gradeMaxFrequency = VocabularySurveyGradePolicy.gradeMaxFrequency(command.grade());
|
|
|
+ int challengeMaxFrequency = VocabularySurveyGradePolicy.challengeMaxFrequency(command.grade(), wordProvider.maxWordFrequency());
|
|
|
+ int centerFrequency = (int) Math.round(0.35D * gradeMaxFrequency);
|
|
|
+ VocabularySurveySession session = VocabularySurveySession.start(
|
|
|
+ VocabularySurveyIds.sessionId(),
|
|
|
+ command.studentId(),
|
|
|
+ command.grade(),
|
|
|
+ gradeMaxFrequency,
|
|
|
+ challengeMaxFrequency,
|
|
|
+ centerFrequency,
|
|
|
+ gradeMaxFrequency,
|
|
|
+ now,
|
|
|
+ now.plus(properties.getSessionTtl()));
|
|
|
+ VocabularySurveyLevel level = levelPlanner.generateLevel(
|
|
|
+ session.sessionId(),
|
|
|
+ 1,
|
|
|
+ session.currentCenterFrequency(),
|
|
|
+ session.currentWordWidth(),
|
|
|
+ Set.of(),
|
|
|
+ now);
|
|
|
+ session.addLevel(level);
|
|
|
+ repository.save(session);
|
|
|
+ return VocabularySurveyResultMapper.startResponse(session);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public SubmitVocabularySurveyLevelResponse submitLevel(SubmitVocabularySurveyLevelCommand command) {
|
|
|
+ Instant now = clock.instant();
|
|
|
+ VocabularySurveySession session = requireUsableSession(command.sessionId(), now);
|
|
|
+ VocabularySurveyLevel currentLevel = session.currentLevel()
|
|
|
+ .orElseThrow(() -> new BusinessException(ErrorCode.SURVEY_LEVEL_NOT_FOUND));
|
|
|
+ if (!currentLevel.levelId().equals(command.levelId())) {
|
|
|
+ throw new BusinessException(ErrorCode.SURVEY_LEVEL_NOT_CURRENT);
|
|
|
+ }
|
|
|
+ if (currentLevel.submitted()) {
|
|
|
+ return latestSubmitResponse(session, currentLevel.levelNo());
|
|
|
+ }
|
|
|
+ Set<String> levelWordIds = new HashSet<>();
|
|
|
+ currentLevel.words().forEach(word -> levelWordIds.add(word.surveyWordId()));
|
|
|
+ List<String> unknownIds = command.unknownSurveyWordIds() == null ? List.of() : command.unknownSurveyWordIds();
|
|
|
+ for (String unknownId : unknownIds) {
|
|
|
+ if (!levelWordIds.contains(unknownId)) {
|
|
|
+ throw new BusinessException(ErrorCode.INVALID_SURVEY_WORD, "invalid survey word: " + unknownId);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ VocabularySurveyLevel submittedLevel = currentLevel.submitted(Set.copyOf(unknownIds), now);
|
|
|
+ session.replaceCurrentLevel(submittedLevel);
|
|
|
+ VocabularySurveyLevelPlanner.NextLevelState nextState = levelPlanner.nextState(
|
|
|
+ session.currentCenterFrequency(),
|
|
|
+ session.currentWordWidth(),
|
|
|
+ submittedLevel.unknownRate(),
|
|
|
+ session.challengeMaxFrequency());
|
|
|
+ session.updateNextState(nextState);
|
|
|
+ if (session.shouldFinish()) {
|
|
|
+ int preferred = preferredFrequency(session);
|
|
|
+ int k = kEstimator.estimateK(session.levels(), preferred);
|
|
|
+ VocabularySurveyReport report = reportGenerator.generate(
|
|
|
+ session.grade(),
|
|
|
+ session.gradeMaxFrequency(),
|
|
|
+ session.challengeMaxFrequency(),
|
|
|
+ k,
|
|
|
+ session.levels());
|
|
|
+ session.finish(report, now);
|
|
|
+ repository.save(session);
|
|
|
+ return SubmitVocabularySurveyLevelResponse.finished(
|
|
|
+ session.sessionId(),
|
|
|
+ submittedLevel.levelNo(),
|
|
|
+ VocabularySurveyResultMapper.toReportResponse(report));
|
|
|
+ }
|
|
|
+ VocabularySurveyLevel nextLevel = levelPlanner.generateLevel(
|
|
|
+ session.sessionId(),
|
|
|
+ submittedLevel.levelNo() + 1,
|
|
|
+ session.currentCenterFrequency(),
|
|
|
+ session.currentWordWidth(),
|
|
|
+ session.testedWordIds(),
|
|
|
+ now);
|
|
|
+ session.addLevel(nextLevel);
|
|
|
+ repository.save(session);
|
|
|
+ return SubmitVocabularySurveyLevelResponse.nextLevel(
|
|
|
+ session.sessionId(),
|
|
|
+ submittedLevel.levelNo(),
|
|
|
+ VocabularySurveyResultMapper.toLevelResponse(nextLevel));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public VocabularySurveySessionResponse getSession(String sessionId) {
|
|
|
+ VocabularySurveySession session = requireExistingSession(sessionId);
|
|
|
+ if (session.isExpiredAt(clock.instant()) && session.status() == VocabularySurveyStatus.IN_PROGRESS) {
|
|
|
+ session.expire();
|
|
|
+ repository.save(session);
|
|
|
+ throw new BusinessException(ErrorCode.SURVEY_SESSION_EXPIRED);
|
|
|
+ }
|
|
|
+ return VocabularySurveyResultMapper.sessionResponse(session);
|
|
|
+ }
|
|
|
+
|
|
|
+ private VocabularySurveySession requireUsableSession(String sessionId, Instant now) {
|
|
|
+ VocabularySurveySession session = requireExistingSession(sessionId);
|
|
|
+ if (session.isExpiredAt(now)) {
|
|
|
+ session.expire();
|
|
|
+ repository.save(session);
|
|
|
+ throw new BusinessException(ErrorCode.SURVEY_SESSION_EXPIRED);
|
|
|
+ }
|
|
|
+ if (session.status() == VocabularySurveyStatus.FINISHED) {
|
|
|
+ throw new BusinessException(ErrorCode.SURVEY_SESSION_FINISHED);
|
|
|
+ }
|
|
|
+ return session;
|
|
|
+ }
|
|
|
+
|
|
|
+ private VocabularySurveySession requireExistingSession(String sessionId) {
|
|
|
+ return repository.findById(sessionId)
|
|
|
+ .orElseThrow(() -> new BusinessException(ErrorCode.SURVEY_SESSION_NOT_FOUND));
|
|
|
+ }
|
|
|
+
|
|
|
+ private SubmitVocabularySurveyLevelResponse latestSubmitResponse(VocabularySurveySession session, int submittedLevelNo) {
|
|
|
+ if (session.report() != null) {
|
|
|
+ return SubmitVocabularySurveyLevelResponse.finished(
|
|
|
+ session.sessionId(),
|
|
|
+ submittedLevelNo,
|
|
|
+ VocabularySurveyResultMapper.toReportResponse(session.report()));
|
|
|
+ }
|
|
|
+ return SubmitVocabularySurveyLevelResponse.nextLevel(
|
|
|
+ session.sessionId(),
|
|
|
+ submittedLevelNo,
|
|
|
+ VocabularySurveyResultMapper.toLevelResponse(session.currentLevel().orElseThrow()));
|
|
|
+ }
|
|
|
+
|
|
|
+ private int preferredFrequency(VocabularySurveySession session) {
|
|
|
+ return session.levels().stream()
|
|
|
+ .filter(VocabularySurveyLevel::nearBoundary)
|
|
|
+ .reduce((previous, current) -> current)
|
|
|
+ .map(VocabularySurveyLevel::centerFrequency)
|
|
|
+ .orElseGet(() -> session.currentLevel().map(VocabularySurveyLevel::centerFrequency).orElse(session.currentCenterFrequency()));
|
|
|
+ }
|
|
|
+
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: Run application tests**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```powershell
|
|
|
+mvn -pl abilities/vocabulary-survey/application test
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS.
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 7: Add runtime config and controller
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: runtime configuration and controller.
|
|
|
+- Test: WebMvc controller test.
|
|
|
+
|
|
|
+- [ ] **Step 1: Create runtime configuration**
|
|
|
+
|
|
|
+Create `VocabularySurveyRuntimeConfiguration.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.configuration;
|
|
|
+
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.application.VocabularySurveyProperties;
|
|
|
+import org.springframework.boot.context.properties.ConfigurationProperties;
|
|
|
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
|
|
+import org.springframework.context.annotation.Bean;
|
|
|
+import org.springframework.context.annotation.Configuration;
|
|
|
+
|
|
|
+@Configuration
|
|
|
+@EnableConfigurationProperties(VocabularySurveyRuntimeConfiguration.BoundVocabularySurveyProperties.class)
|
|
|
+public class VocabularySurveyRuntimeConfiguration {
|
|
|
+
|
|
|
+ @Bean
|
|
|
+ public VocabularySurveyProperties vocabularySurveyProperties(BoundVocabularySurveyProperties bound) {
|
|
|
+ VocabularySurveyProperties properties = new VocabularySurveyProperties();
|
|
|
+ properties.setSessionTtl(bound.getSessionTtl());
|
|
|
+ properties.getDictionary().setLocation(bound.getDictionary().getLocation());
|
|
|
+ return properties;
|
|
|
+ }
|
|
|
+
|
|
|
+ @ConfigurationProperties(prefix = "ability.vocabulary-survey")
|
|
|
+ public static class BoundVocabularySurveyProperties extends VocabularySurveyProperties {
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Do not define a second `Clock` bean because `ExamSprintReportRuntimeConfiguration` already exposes `systemClock()` in runtime.
|
|
|
+
|
|
|
+- [ ] **Step 2: Create controller**
|
|
|
+
|
|
|
+Create `VocabularySurveyController.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.adapter.http;
|
|
|
+
|
|
|
+import cn.yunzhixue.ability.center.kernel.BaseResponse;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.application.StartVocabularySurveyCommand;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.application.SubmitVocabularySurveyLevelCommand;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.application.VocabularySurveyApplicationService;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.StartVocabularySurveyRequest;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.SubmitVocabularySurveyLevelRequest;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.SubmitVocabularySurveyLevelResponse;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveySessionResponse;
|
|
|
+import jakarta.validation.Valid;
|
|
|
+import org.springframework.web.bind.annotation.GetMapping;
|
|
|
+import org.springframework.web.bind.annotation.PathVariable;
|
|
|
+import org.springframework.web.bind.annotation.PostMapping;
|
|
|
+import org.springframework.web.bind.annotation.RequestBody;
|
|
|
+import org.springframework.web.bind.annotation.RequestMapping;
|
|
|
+import org.springframework.web.bind.annotation.RestController;
|
|
|
+
|
|
|
+@RestController
|
|
|
+@RequestMapping("/mini-program/vocabulary-survey")
|
|
|
+public class VocabularySurveyController {
|
|
|
+
|
|
|
+ private final VocabularySurveyApplicationService applicationService;
|
|
|
+
|
|
|
+ public VocabularySurveyController(VocabularySurveyApplicationService applicationService) {
|
|
|
+ this.applicationService = applicationService;
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/start")
|
|
|
+ public BaseResponse<VocabularySurveySessionResponse> start(@Valid @RequestBody StartVocabularySurveyRequest request) {
|
|
|
+ return BaseResponse.success(applicationService.start(new StartVocabularySurveyCommand(
|
|
|
+ request.studentId(),
|
|
|
+ request.grade(),
|
|
|
+ request.restartRequested())));
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/levels/{levelId}/submit")
|
|
|
+ public BaseResponse<SubmitVocabularySurveyLevelResponse> submitLevel(
|
|
|
+ @PathVariable String levelId,
|
|
|
+ @Valid @RequestBody SubmitVocabularySurveyLevelRequest request) {
|
|
|
+ return BaseResponse.success(applicationService.submitLevel(new SubmitVocabularySurveyLevelCommand(
|
|
|
+ request.sessionId(),
|
|
|
+ levelId,
|
|
|
+ request.unknownSurveyWordIdsOrEmpty())));
|
|
|
+ }
|
|
|
+
|
|
|
+ @GetMapping("/sessions/{sessionId}")
|
|
|
+ public BaseResponse<VocabularySurveySessionResponse> getSession(@PathVariable String sessionId) {
|
|
|
+ return BaseResponse.success(applicationService.getSession(sessionId));
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Write WebMvc tests**
|
|
|
+
|
|
|
+Create `VocabularySurveyControllerWebMvcTest.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.vocabularysurvey.adapter.http;
|
|
|
+
|
|
|
+import cn.yunzhixue.ability.center.GlobalExceptionHandler;
|
|
|
+import cn.yunzhixue.ability.center.kernel.BusinessException;
|
|
|
+import cn.yunzhixue.ability.center.kernel.ErrorCode;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.application.VocabularySurveyApplicationService;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.SubmitVocabularySurveyLevelResponse;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveyLevelResponse;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveySessionResponse;
|
|
|
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveyWordResponse;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.springframework.beans.factory.annotation.Autowired;
|
|
|
+import org.springframework.boot.SpringBootConfiguration;
|
|
|
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
|
|
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
|
|
+import org.springframework.boot.test.mock.mockito.MockBean;
|
|
|
+import org.springframework.context.annotation.Import;
|
|
|
+import org.springframework.http.MediaType;
|
|
|
+import org.springframework.test.web.servlet.MockMvc;
|
|
|
+
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.ArgumentMatchers.eq;
|
|
|
+import static org.mockito.BDDMockito.given;
|
|
|
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
|
|
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
|
|
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
|
|
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
|
|
+
|
|
|
+@WebMvcTest(VocabularySurveyController.class)
|
|
|
+@Import({VocabularySurveyController.class, GlobalExceptionHandler.class})
|
|
|
+class VocabularySurveyControllerWebMvcTest {
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private MockMvc mockMvc;
|
|
|
+
|
|
|
+ @MockBean
|
|
|
+ private VocabularySurveyApplicationService applicationService;
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void startReturnsFirstLevel() throws Exception {
|
|
|
+ given(applicationService.start(any())).willReturn(VocabularySurveySessionResponse.started(
|
|
|
+ "surv_001",
|
|
|
+ 7,
|
|
|
+ level(1)));
|
|
|
+
|
|
|
+ mockMvc.perform(post("/mini-program/vocabulary-survey/start")
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
+ .content("{\"studentId\":123,\"grade\":7}"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.sessionId").value("surv_001"))
|
|
|
+ .andExpect(jsonPath("$.data.status").value("inProgress"))
|
|
|
+ .andExpect(jsonPath("$.data.level.words[0].spell").value("happy"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void submitReturnsNextLevel() throws Exception {
|
|
|
+ given(applicationService.submitLevel(any())).willReturn(SubmitVocabularySurveyLevelResponse.nextLevel(
|
|
|
+ "surv_001",
|
|
|
+ 1,
|
|
|
+ level(2)));
|
|
|
+
|
|
|
+ mockMvc.perform(post("/mini-program/vocabulary-survey/levels/{levelId}/submit", "lvl_001")
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
+ .content("{\"sessionId\":\"surv_001\",\"unknownSurveyWordIds\":[\"sw_001\"]}"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.data.submittedLevelNo").value(1))
|
|
|
+ .andExpect(jsonPath("$.data.level.levelNo").value(2));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void getSessionMapsBusinessError() throws Exception {
|
|
|
+ given(applicationService.getSession(eq("missing")))
|
|
|
+ .willThrow(new BusinessException(ErrorCode.SURVEY_SESSION_NOT_FOUND));
|
|
|
+
|
|
|
+ mockMvc.perform(get("/mini-program/vocabulary-survey/sessions/{sessionId}", "missing"))
|
|
|
+ .andExpect(status().isNotFound())
|
|
|
+ .andExpect(jsonPath("$.code").value("SURVEY_SESSION_NOT_FOUND"));
|
|
|
+ }
|
|
|
+
|
|
|
+ private VocabularySurveyLevelResponse level(int levelNo) {
|
|
|
+ return new VocabularySurveyLevelResponse(
|
|
|
+ "lvl_00" + levelNo,
|
|
|
+ levelNo,
|
|
|
+ 12,
|
|
|
+ "feedback",
|
|
|
+ List.of(new VocabularySurveyWordResponse("sw_001", 101, 2001, "happy", 403)));
|
|
|
+ }
|
|
|
+
|
|
|
+ @SpringBootConfiguration
|
|
|
+ @EnableAutoConfiguration
|
|
|
+ static class TestApplication {
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Run runtime WebMvc tests**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```powershell
|
|
|
+mvn -pl ability-center-runtime -Dtest=VocabularySurveyControllerWebMvcTest test
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS.
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 8: Add architecture tests
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/architecture/VocabularySurveyArchitectureTest.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: Write architecture rules**
|
|
|
+
|
|
|
+Create `VocabularySurveyArchitectureTest.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package cn.yunzhixue.ability.center.architecture;
|
|
|
+
|
|
|
+import com.tngtech.archunit.core.importer.ImportOption;
|
|
|
+import com.tngtech.archunit.junit.AnalyzeClasses;
|
|
|
+import com.tngtech.archunit.junit.ArchTest;
|
|
|
+import com.tngtech.archunit.lang.ArchRule;
|
|
|
+
|
|
|
+import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
|
|
|
+
|
|
|
+@AnalyzeClasses(
|
|
|
+ packages = "cn.yunzhixue.ability.center.vocabularysurvey",
|
|
|
+ importOptions = ImportOption.DoNotIncludeTests.class)
|
|
|
+class VocabularySurveyArchitectureTest {
|
|
|
+
|
|
|
+ @ArchTest
|
|
|
+ static final ArchRule contracts_should_not_depend_on_inner_layers = noClasses()
|
|
|
+ .that().resideInAPackage("..contracts..")
|
|
|
+ .should().dependOnClassesThat().resideInAnyPackage(
|
|
|
+ "..domain..",
|
|
|
+ "..application..",
|
|
|
+ "..infrastructure..",
|
|
|
+ "..adapter.."
|
|
|
+ );
|
|
|
+
|
|
|
+ @ArchTest
|
|
|
+ static final ArchRule domain_should_not_depend_on_contracts = noClasses()
|
|
|
+ .that().resideInAPackage("..domain..")
|
|
|
+ .should().dependOnClassesThat().resideInAPackage("..contracts..");
|
|
|
+
|
|
|
+ @ArchTest
|
|
|
+ static final ArchRule domain_should_not_depend_on_jackson_or_msgpack = noClasses()
|
|
|
+ .that().resideInAPackage("..domain..")
|
|
|
+ .should().dependOnClassesThat().resideInAnyPackage(
|
|
|
+ "com.fasterxml.jackson..",
|
|
|
+ "org.msgpack.."
|
|
|
+ );
|
|
|
+
|
|
|
+ @ArchTest
|
|
|
+ static final ArchRule infrastructure_should_not_depend_on_runtime_adapters = noClasses()
|
|
|
+ .that().resideInAPackage("..infrastructure..")
|
|
|
+ .should().dependOnClassesThat().resideInAnyPackage(
|
|
|
+ "..adapter..",
|
|
|
+ "..configuration.."
|
|
|
+ );
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Run architecture test**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```powershell
|
|
|
+mvn -pl ability-center-runtime -Dtest=VocabularySurveyArchitectureTest test
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS.
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 9: Run targeted and full verification
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- No new files.
|
|
|
+- Verify changed modules.
|
|
|
+
|
|
|
+- [ ] **Step 1: Run contracts tests**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```powershell
|
|
|
+mvn -pl abilities/vocabulary-survey/contracts test
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS.
|
|
|
+
|
|
|
+- [ ] **Step 2: Run domain tests**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```powershell
|
|
|
+mvn -pl abilities/vocabulary-survey/domain test
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS.
|
|
|
+
|
|
|
+- [ ] **Step 3: Run application tests**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```powershell
|
|
|
+mvn -pl abilities/vocabulary-survey/application test
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS.
|
|
|
+
|
|
|
+- [ ] **Step 4: Run infrastructure tests**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```powershell
|
|
|
+mvn -pl abilities/vocabulary-survey/infrastructure test
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS. If MessagePack binding fails, report the exact exception and do not claim infrastructure completion.
|
|
|
+
|
|
|
+- [ ] **Step 5: Run runtime targeted tests**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```powershell
|
|
|
+mvn -pl ability-center-runtime -Dtest=VocabularySurveyControllerWebMvcTest,VocabularySurveyArchitectureTest test
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS.
|
|
|
+
|
|
|
+- [ ] **Step 6: Run full test suite when targeted tests pass**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```powershell
|
|
|
+mvn test
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS. If existing Playwright/PDF tests fail for unrelated environment reasons, capture the failing command, failing test names, and first relevant error message.
|
|
|
+
|
|
|
+- [ ] **Step 7: Review diff and generated binary file**
|
|
|
+
|
|
|
+Run:
|
|
|
+
|
|
|
+```powershell
|
|
|
+git status --short
|
|
|
+git diff --stat
|
|
|
+git diff --check
|
|
|
+```
|
|
|
+
|
|
|
+Expected:
|
|
|
+
|
|
|
+- New vocabulary survey modules are listed.
|
|
|
+- `dict.data` is present under `abilities/vocabulary-survey/infrastructure/src/main/resources/data/dict.data`.
|
|
|
+- No whitespace errors from `git diff --check`.
|
|
|
+- No real database password or token is newly added by this implementation.
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Self-review checklist for the implementer
|
|
|
+
|
|
|
+- [ ] Spec coverage: start, submit, get session, grade mapping, adaptive levels, K estimation, report fields, in-memory idempotency, MessagePack dictionary, and tests all have implementation tasks above.
|
|
|
+- [ ] Boundary coverage: domain does not depend on contracts, Jackson, MessagePack, Spring, or runtime adapters.
|
|
|
+- [ ] Secret handling: existing secret-bearing configuration is preserved but not printed or duplicated; new datasource credentials remain blank.
|
|
|
+- [ ] Runtime behavior: start returns an existing active session unless `restart=true`; submit is idempotent for already submitted current levels.
|
|
|
+- [ ] Single-node limitation: session persistence remains in memory and is documented in design and final report.
|
|
|
+- [ ] Verification evidence: final response reports exact commands run and their outcomes.
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Handoff note
|
|
|
+
|
|
|
+This plan intentionally omits commit commands because the current session has no explicit user authorization to commit. If the user later authorizes commits, commit after coherent checkpoints with messages matching repository style, for example `feat(词汇摸底): 新增领域模型和算法测试`.
|