|
|
@@ -0,0 +1,243 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import com.etotem.cfc.controller.DanAssessmentController;
|
|
|
+import com.etotem.cfc.entity.AssessmentMaterial;
|
|
|
+import com.etotem.cfc.service.AssessmentService;
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import java.io.InputStream;
|
|
|
+import java.lang.reflect.Method;
|
|
|
+import java.lang.reflect.Field;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.HashSet;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Scanner;
|
|
|
+import java.util.Set;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.junit.jupiter.api.Assertions.fail;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 契约级单测:测评类型浏览(能力键 {@code assessment.type.browse},任务 S-cfc-273 / SC-P-31)。
|
|
|
+ *
|
|
|
+ * <p>契约来源:
|
|
|
+ * <ul>
|
|
|
+ * <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-P-31 —— 浏览测评类型(DAN 8 类),
|
|
|
+ * 测评页列 DAN 8 类 <b>mind/wisdom/cognition/learning/behavior/career/campus/adolescent</b>,
|
|
|
+ * <b>类目数据来自后端字典</b>;模块 {@code DanAssessmentController}。</li>
|
|
|
+ * <li>用例:{@code tests/test-cases/parent.md} TC-P-31 —— 进入测评页 → 列完整 8 类;断言「类目来自后端字典」。</li>
|
|
|
+ * <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.63.4 —— `DanAssessmentController`
|
|
|
+ * {@code /api/dan-assessment/*}(/material/active、/material/list 为「测评材料=类型载体」,/points-config、/family/config);</li>
|
|
|
+ * <li>设计:{@code docs/superpowers/specs/2026-11-07-parent-rest-contract.md} §3 —— 建议能力键
|
|
|
+ * {@code dan.assessment.typeList} 映射 {@code DanAssessmentController /material/list、/material/active、/points-config}。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>实现现状(本分支 src/main 核实)</b>:DAN 8 类字典常量仅散见 {@code DanReportUpload}(DIM_*)与上传校验
|
|
|
+ * ({@code DanReportUploadService} 的 dimension 白名单),但:
|
|
|
+ * <ol>
|
|
|
+ * <li>{@code AssessmentService} 无「枚举 8 类类型」的浏览入口方法(现仅有 getActiveMaterial / getMaterialList 返回材料实体);</li>
|
|
|
+ * <li>{@code AssessmentMaterial} 实体及 {@code assessment_materials} 表<b>无 {@code dimension/type} 类型码字段</b>,
|
|
|
+ * 无法承载「材料=类型载体」的设计,前端拿不到后端字典中的类型码;</li>
|
|
|
+ * <li>{@code DanAssessmentController} 无返回类型字典/类型列表的 POST 端点(/material/list 仅返回材料,不映射 8 类字典)。</li>
|
|
|
+ * </ol>
|
|
|
+ * 因此「测评页列出完整 8 类、类目来自后端字典」的契约未落地。
|
|
|
+ *
|
|
|
+ * <p><b>RED 预期</b>:本批为 TDD-RED 阶段,断言契约所需能力缺失——四个用例应<b>失败(红)</b>;
|
|
|
+ * GREEN 阶段按失败断言补实现(service 类型浏览方法 + 实体/表类型码字段 + 控制器 POST 端点)后转绿。</p>
|
|
|
+ *
|
|
|
+ * <p>测试隔离:纯反射 / classpath 资源断言,无 Spring / 无 DB / 无外部服务(对齐 TeamProfitHookContractTest 等既有 RED 契约风格)。</p>
|
|
|
+ */
|
|
|
+class DanAssessmentTypeBrowseContractTest {
|
|
|
+
|
|
|
+ /** SC-P-31 / TC-P-31 契约要求的完整 8 类(取自 DanAssessmentResultDTO dimension 注释:mind(心-A2)/wisdom(智-B4)/.../adolescent(B5)) */
|
|
|
+ private static final List<String> DAN_TYPES = Arrays.asList(
|
|
|
+ "mind", "wisdom", "cognition", "learning", "behavior", "career", "campus", "adolescent"
|
|
|
+ );
|
|
|
+
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // 正常流:后端应提供「枚举 DAN 8 类」的浏览能力(类目来自后端字典)
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-1:AssessmentService 应提供类型浏览入口(列出 DAN 8 类)")
|
|
|
+ void service_hasTypeBrowseEntry() {
|
|
|
+ Class<?> serviceClass = AssessmentService.class;
|
|
|
+ Method browse = null;
|
|
|
+ for (Method m : serviceClass.getDeclaredMethods()) {
|
|
|
+ String name = m.getName().toLowerCase();
|
|
|
+ // 契约入口命名候选:listTypes / listAssessmentTypes / getAssessmentTypes(返回类型键集合)
|
|
|
+ if ((name.contains("listtypes") || name.contains("assessmenttypes") || name.contains("typelist"))
|
|
|
+ && m.getParameterCount() == 0) {
|
|
|
+ browse = m;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ assertNotNull(browse,
|
|
|
+ "[assessment.type.browse] AssessmentService 缺少类型浏览入口(listTypes/listAssessmentTypes/getAssessmentTypes):"
|
|
|
+ + "契约要求后端字典可枚举 DAN 8 类(mind/wisdom/cognition/learning/behavior/career/campus/adolescent)供测评页展示(TC-P-31)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-2:类型浏览入口应返回完整 8 类(含 mind/adolescent 等全部字典键)")
|
|
|
+ void typeBrowseEntry_coversAllEightTypes() throws Exception {
|
|
|
+ Method browse = null;
|
|
|
+ boolean[] found = {false};
|
|
|
+ for (Method m : AssessmentService.class.getDeclaredMethods()) {
|
|
|
+ String name = m.getName().toLowerCase();
|
|
|
+ if ((name.contains("listtypes") || name.contains("assessmenttypes") || name.contains("typelist"))
|
|
|
+ && m.getParameterCount() == 0) {
|
|
|
+ browse = m;
|
|
|
+ found[0] = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!found[0]) {
|
|
|
+ fail("[assessment.type.browse] 类型浏览入口缺失,无法验证 8 类完整性(契约要求:完整 8 类且类目来自后端字典)。");
|
|
|
+ }
|
|
|
+ // 反射探测返回值类型应为集合/数组(承载 8 类字典键)
|
|
|
+ Class<?> ret = browse.getReturnType();
|
|
|
+ boolean isCollectionish = java.util.Collection.class.isAssignableFrom(ret)
|
|
|
+ || ret.isArray() || java.util.Map.class.isAssignableFrom(ret) || ret.isEnum();
|
|
|
+ assertTrue(isCollectionish,
|
|
|
+ "[assessment.type.browse] 类型浏览入口应返回集合/数组(8 类键),当前返回类型为 " + ret.getName());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // 正常流:材料应作为类型载体(设计:/material/list、/material/active = 类型载体)
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-3:AssessmentMaterial 应承载类型码字段(dimension 或 typeCode)")
|
|
|
+ void materialEntity_carriesTypeCode() {
|
|
|
+ boolean hasTypeField = false;
|
|
|
+ for (Field f : AssessmentMaterial.class.getDeclaredFields()) {
|
|
|
+ String n = f.getName().toLowerCase();
|
|
|
+ if (n.equals("dimension") || n.equals("typecode") || n.equals("type")) {
|
|
|
+ hasTypeField = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ assertTrue(hasTypeField,
|
|
|
+ "[assessment.type.browse] AssessmentMaterial 实体缺少类型码字段(dimension/typeCode/type):"
|
|
|
+ + "设计规定 /material/list 为「类型载体」,材料无类型码则前端无法经后端字典渲染 8 类(SC-P-31)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-4:assessment_materials 表应含类型码列(schema.sql 同步)")
|
|
|
+ void schemaSql_materialTableHasTypeColumn() {
|
|
|
+ String schema = readSchemaSql();
|
|
|
+ assertTrue(schema.contains("CREATE TABLE IF NOT EXISTS assessment_materials"),
|
|
|
+ "[assessment.type.browse] schema.sql 缺少 assessment_materials 建表。");
|
|
|
+ // 提取 assessment_materials 建表段检查类型列
|
|
|
+ int start = schema.indexOf("assessment_materials");
|
|
|
+ int end = schema.indexOf("ENGINE=InnoDB", start);
|
|
|
+ String ddl = end > start ? schema.substring(start, end) : schema.substring(start);
|
|
|
+ boolean hasDimensionCol = ddl.contains("dimension");
|
|
|
+ boolean hasTypeCodeCol = ddl.contains("type_code") || ddl.contains("typeCode");
|
|
|
+ assertTrue(hasDimensionCol || hasTypeCodeCol,
|
|
|
+ "[assessment.type.browse] assessment_materials 建表缺类型码列(dimension/type_code):"
|
|
|
+ + "材料作为类型载体(API_REFERENCE §4.63.4)的表承载缺失,类目无法落库/查询(SC-P-31)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // 正常流:控制器应暴露类型浏览 POST 端点(统一 @PostMapping)
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-5:DanAssessmentController 应暴露类型浏览 POST 端点")
|
|
|
+ void controller_exposesTypeBrowseEndpoint() {
|
|
|
+ boolean hasEndpoint = false;
|
|
|
+ String found = "";
|
|
|
+ for (Method m : DanAssessmentController.class.getDeclaredMethods()) {
|
|
|
+ String name = m.getName().toLowerCase();
|
|
|
+ if ((name.contains("typelist") || name.contains("listtypes") || name.contains("assessmenttypes")
|
|
|
+ || name.contains("materialtypes") || name.contains("gettypes"))
|
|
|
+ && m.isAnnotationPresent(org.springframework.web.bind.annotation.PostMapping.class)) {
|
|
|
+ hasEndpoint = true;
|
|
|
+ found = m.getName();
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ assertTrue(hasEndpoint,
|
|
|
+ "[assessment.type.browse] DanAssessmentController 缺少类型浏览 POST 端点(如 /material/types、/types,@PostMapping):"
|
|
|
+ + "测评页需在此拉取后端字典 8 类(TC-P-31 流程:测评页 → 列 DAN 8 类)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // 边界流:字典键应恰为 8 类全量集合
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("边界流-1:后端字典常量应覆盖契约 8 类全量")
|
|
|
+ void typeDictionary_coversAllEightTypes() {
|
|
|
+ // 字典半成品:DanReportUpload.DIM_* 常量(后端字典基线,GREEN 部分)
|
|
|
+ Class<?> uploadClass = loadClass("com.etotem.cfc.entity.DanReportUpload");
|
|
|
+ if (uploadClass == null) {
|
|
|
+ fail("[assessment.type.browse] DanReportUpload 不存在(类型字典基线缺失)。");
|
|
|
+ }
|
|
|
+ Set<String> actual = new HashSet<>();
|
|
|
+ for (Field f : uploadClass.getDeclaredFields()) {
|
|
|
+ if (f.getName().startsWith("DIM_") && f.getType() == String.class) {
|
|
|
+ try {
|
|
|
+ f.setAccessible(true);
|
|
|
+ actual.add((String) f.get(null));
|
|
|
+ } catch (IllegalAccessException ignored) {
|
|
|
+ // 忽略
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 字典应与契约 8 类一致(当前实现覆盖 8 类,此断言期望通过;缺口在「浏览入口/承载」而非字典本身)
|
|
|
+ assertTrue(actual.containsAll(DAN_TYPES),
|
|
|
+ "[assessment.type.browse] 后端类型字典(DanReportUpload.DIM_*)未覆盖契约 8 类全量,缺:" + diff(DAN_TYPES, actual));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // 权限流:类型浏览为公开目录能力(无需家庭级权限)——说明性用例
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("权限流:类型浏览为公开目录能力,无家庭级权限维度(说明性)")
|
|
|
+ void permissionFlow_notApplicable() {
|
|
|
+ // SC-P-31 边界仅规定「类目数据来自后端字典」,无家庭/角色数据隔离要求;
|
|
|
+ // 类型目录为全站公开字典,权限流不适用。不产生失败断言。
|
|
|
+ assertTrue(true, "权限流不适用:assessment.type.browse 为公开类型目录浏览,无家庭级权限维度。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // 辅助
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+
|
|
|
+ private static Class<?> loadClass(String name) {
|
|
|
+ try {
|
|
|
+ return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
|
|
|
+ } catch (ClassNotFoundException e) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String diff(List<String> expected, Set<String> actual) {
|
|
|
+ List<String> missing = new java.util.ArrayList<>();
|
|
|
+ for (String t : expected) {
|
|
|
+ if (!actual.contains(t)) missing.add(t);
|
|
|
+ }
|
|
|
+ return missing.toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String readSchemaSql() {
|
|
|
+ try (InputStream is = DanAssessmentTypeBrowseContractTest.class.getClassLoader()
|
|
|
+ .getResourceAsStream("schema.sql")) {
|
|
|
+ if (is == null) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
|
|
|
+ scanner.useDelimiter("\\A");
|
|
|
+ return scanner.hasNext() ? scanner.next() : "";
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|