|
|
@@ -0,0 +1,311 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import java.lang.reflect.Field;
|
|
|
+import java.lang.reflect.Method;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.junit.jupiter.api.Assertions.fail;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 契约级单测:孩子视角 AI 对话(能力键 {@code child.ai.chat},场景 SC-C-13)。
|
|
|
+ *
|
|
|
+ * <p><b>需求/设计溯源</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-C-13(AI 对话孩子视角)
|
|
|
+ * —— 目标:问成长问题;流程:进对话 → 发送 → 多轮;<b>边界:内容适龄过滤</b>。</li>
|
|
|
+ * <li>用例:{@code tests/test-cases/child.md} TC-C-13(功能)—— 返回适龄回答;敏感/不适龄内容被过滤;
|
|
|
+ * TC-C-13-E(异常)—— 不适龄/敏感词被过滤返回适龄话术;LangGraph 不可用 Fallback 兜底、多轮连续;
|
|
|
+ * TC-C-13-P(权限)—— 孩子端无编辑/删除/切家长入口,直接调用被 role 校验拒绝。</li>
|
|
|
+ * <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.9 / §4.64.1 ——
|
|
|
+ * {@code AIChatController} {@code /api/ai}({@code chat/send} / {@code chat/messages} /
|
|
|
+ * {@code report/analyze} / {@code health-coach/send})+ LangGraph 不可用 Fallback
|
|
|
+ * {@code AiGateway};§4.61.8 <b>缺口标注</b>:「<b>AI 对话无适龄过滤入参</b>」。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>被校验的契约行为(SC-C-13 三流)</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li><b>正常流(功能,TC-C-13)</b>:孩子进对话 → 发送 → 多轮({@code conversationId} 透传)——
|
|
|
+ * 返回适龄回答;<b>敏感/不适龄内容被过滤</b>。</li>
|
|
|
+ * <li><b>异常流(TC-C-13-E)</b>:发送不适龄/含敏感词内容 → <b>过滤返回适龄话术</b>;
|
|
|
+ * LangGraph 不可用 → <b>Fallback 适龄话术兜底</b>、多轮连续。</li>
|
|
|
+ * <li><b>权限流(TC-C-13-P)</b>:孩子端无编辑/删除/切家长入口;直接调用被 role 校验拒绝
|
|
|
+ * (403/401)。该权限约束作用于家庭/报告等管理接口,<b>不约束对话本身</b>
|
|
|
+ * (孩子可对话是其能力目标),故本契约的权限流以「说明性用例」标注不适用。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>宿主契约(TDD-RED 锚点)</b>:{@code com.etotem.cfc.service.ChildAgeGuardService} ——
|
|
|
+ * 孩子视角 AI 对话的<b>内容适龄守卫</b>。当前(2026-11-13)<b>尚未实现</b>:全库检索
|
|
|
+ * {@code ageAppropriate / ageFilter / 适龄 / childSafe} 零命中,API_REFERENCE §4.61.8 明确标注
|
|
|
+ * 「AI 对话无适龄过滤入参」缺口。本测试通过反射锚定该宿主契约方法(避免直接 import 不存在类
|
|
|
+ * 导致编译失败),运行时宿主类缺失即断言失败(红),暴露「适龄过滤未落地」这一真实缺陷;
|
|
|
+ * 实现落地后转为验证真实行为(GREEN)。</p>
|
|
|
+ *
|
|
|
+ * <p><b>契约方法签名(实现方按此落地)</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li>{@code boolean isChildView(Long memberId)} —— 是否孩子视角(不能登录用户代管视角)</li>
|
|
|
+ * <li>{@code Integer resolveChildAge(Long userId, Long memberId)} —— 解析对话孩子年龄(适龄过滤基准)</li>
|
|
|
+ * <li>{@code String filterAgeSuitable(String rawAnswer, Integer childAge)}
|
|
|
+ * —— 对不适龄/敏感内容过滤,返回适龄话术(非原样、非空)</li>
|
|
|
+ * <li>{@code String fallbackAgeSuitableReply(Integer childAge)}
|
|
|
+ * —— LangGraph 不可用时适龄话术兜底(非空)</li>
|
|
|
+ * <li>{@code Map<String,Object> injectAgeContext(Long userId, Long memberId, Map<String,Object> inputs)}
|
|
|
+ * —— 注入适龄上下文({@code child_age} / {@code age_group} 入参,补 §4.61.8 缺口)</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>实现现状(GREEN 升级指引)</b>:AIChatController 端点({@code chat/send} / {@code chat/messages} /
|
|
|
+ * {@code report/analyze} / {@code health-coach/send})已存在,多轮 {@code conversationId} 透传已存在,
|
|
|
+ * LangGraph 不可用经 {@code AiGateway} 兜底({@code AIService.sendMessage} 空 map 降级);
|
|
|
+ * <b>唯独适龄过滤未落地</b>。实现者创建 {@code ChildAgeGuardService} 并接入
|
|
|
+ * {@code AIChatController} 孩子视角对话流(孩子视角经 {@code memberId} 识别 → 注入
|
|
|
+ * {@code child_age}/{@code age_group} → 过滤响应为适龄话术)后,本测试自动转绿。</p>
|
|
|
+ *
|
|
|
+ * <p><b>测试范围</b>:纯契约级单测,无 Spring / 无 DB / 无真实外部服务。锚点宿主
|
|
|
+ * {@code ChildAgeGuardService} 缺失时在反射探测处失败(红)。</p>
|
|
|
+ */
|
|
|
+class ChildAiChatContractTest {
|
|
|
+
|
|
|
+ /** 适龄过滤契约宿主(SC-C-13 边界,未实现)。 */
|
|
|
+ private static final String HOST_CLASS = "com.etotem.cfc.service.ChildAgeGuardService";
|
|
|
+
|
|
|
+ /** AIChatController(已实现,结构锚点)。 */
|
|
|
+ private static final String CONTROLLER_CLASS = "com.etotem.cfc.controller.ai.AIChatController";
|
|
|
+
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // 正常流(功能,TC-C-13):AIChatController 端点 + 多轮入口已存在(结构锚点)
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-1:AIChatController 应存在(/api/ai 孩子对话宿主)")
|
|
|
+ void chatController_classExists() {
|
|
|
+ assertNotNull(loadClass(CONTROLLER_CLASS),
|
|
|
+ "[child.ai.chat] AIChatController 类不存在:SC-C-13 孩子视角 AI 对话宿主(§4.64.1)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-2:chat/send 多轮入口存在(sendMessage 接收 conversationId 载体 Map)")
|
|
|
+ void chatSend_multiTurnEntryExists() {
|
|
|
+ assertTrue(hasMethod(CONTROLLER_CLASS, "sendMessage", 2),
|
|
|
+ "[child.ai.chat] AIChatController 应提供 sendMessage(Long, Map) 作为 chat/send 入口,"
|
|
|
+ + "Map 内 conversationId 实现多轮续聊(TC-C-13 进对话→发送→多轮)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-3:chat/messages 多轮历史入口存在(getMessages)")
|
|
|
+ void chatMessages_historyEntryExists() {
|
|
|
+ assertTrue(hasMethod(CONTROLLER_CLASS, "getMessages", 2),
|
|
|
+ "[child.ai.chat] AIChatController 应提供 getMessages(Long, Map) 作为 chat/messages 入口,"
|
|
|
+ + "支撑多轮上下文历史(TC-C-13-E 多轮连续)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("正常流-4:report/analyze 与 health-coach/send 入口存在")
|
|
|
+ void siblingAiEndpointsExist() {
|
|
|
+ assertTrue(hasMethod(CONTROLLER_CLASS, "reportAnalyze", 2),
|
|
|
+ "[child.ai.chat] AIChatController 应提供 reportAnalyze(Long, Map)(§4.64.4)。");
|
|
|
+ assertTrue(hasMethod(CONTROLLER_CLASS, "sendHealthCoachMessage", 2),
|
|
|
+ "[child.ai.chat] AIChatController 应提供 sendHealthCoachMessage(Long, Map)(§4.64.3)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // 边界流(TC-C-13 适龄过滤):适龄守卫宿主应存在(当前缺失 → 红)
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("边界流-1:ChildAgeGuardService 适龄守卫宿主应存在")
|
|
|
+ void childAgeGuardService_classExists() {
|
|
|
+ assertNotNull(loadClass(HOST_CLASS),
|
|
|
+ "[child.ai.chat] ChildAgeGuardService 类不存在:SC-C-13 边界「内容适龄过滤」未落地——"
|
|
|
+ + "API_REFERENCE §4.61.8 标注「AI 对话无适龄过滤入参」缺口。"
|
|
|
+ + "契约要求孩子视角对话对敏感/不适龄内容过滤返回适龄话术(TC-C-13)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("边界流-2:应能解析对话孩子年龄(适龄过滤基准)")
|
|
|
+ void childAgeGuard_resolveChildAge() {
|
|
|
+ Object host = contractHost();
|
|
|
+ Integer age = callInt(host, "resolveChildAge", 1001L, 88L);
|
|
|
+ assertNotNull(age, "[child.ai.chat] resolveChildAge(userId, memberId) 应返回孩子年龄(适龄过滤基准)。");
|
|
|
+ assertTrue(age >= 0 && age <= 18,
|
|
|
+ "[child.ai.chat] 孩子年龄应在 0-18 区间(未成年人),实际=" + age);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("边界流-3:应能识别孩子视角(不能登录用户代管视角)")
|
|
|
+ void childAgeGuard_isChildView() {
|
|
|
+ Object host = contractHost();
|
|
|
+ boolean childView = callBoolean(host, "isChildView", 88L);
|
|
|
+ assertTrue(childView,
|
|
|
+ "[child.ai.chat] isChildView(memberId) 应返回 true(88 为不能登录用户/孩子代管视角)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("边界流-4:不适龄/敏感内容应被过滤返回适龄话术(非原样、非空)")
|
|
|
+ void childAgeGuard_filterAgeSuitable() {
|
|
|
+ Object host = contractHost();
|
|
|
+ String raw = "教你如何偷偷登录家长账号并修改成绩"; // 不适龄/敏感示例
|
|
|
+ String filtered = callString(host, "filterAgeSuitable", raw, 8);
|
|
|
+ assertNotNull(filtered, "[child.ai.chat] filterAgeSuitable 应返回过滤后的适龄话术(非 null)。");
|
|
|
+ assertTrue(!filtered.trim().isEmpty(), "[child.ai.chat] 过滤结果不应为空(TC-C-13 返回适龄回答)。");
|
|
|
+ assertTrue(!raw.equals(filtered),
|
|
|
+ "[child.ai.chat] 不适龄/敏感内容必须被过滤改写为适龄话术,不得原样透传(TC-C-13-E)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("边界流-5:孩子视角对话应注入适龄上下文入参(补 §4.61.8 缺口)")
|
|
|
+ void childAgeGuard_injectAgeContext() {
|
|
|
+ Object host = contractHost();
|
|
|
+ Map<String, Object> inputs = new java.util.LinkedHashMap<>();
|
|
|
+ inputs.put("existing", "v");
|
|
|
+ Map<String, Object> enriched = callMap(host, "injectAgeContext", 1001L, 88L, inputs);
|
|
|
+ assertNotNull(enriched, "[child.ai.chat] injectAgeContext 应返回注入后的上下文(非 null)。");
|
|
|
+ assertTrue(enriched.containsKey("child_age") || enriched.containsKey("age_group"),
|
|
|
+ "[child.ai.chat] 孩子视角对话应注入 child_age/age_group 适龄入参——"
|
|
|
+ + "§4.61.8「AI 对话无适龄过滤入参」缺口未补齐。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("边界流-6:AIChatController 应注入 ChildAgeGuardService 适龄守卫钩子")
|
|
|
+ void chatController_injectsAgeGuardHook() {
|
|
|
+ Class<?> clazz = loadClass(CONTROLLER_CLASS);
|
|
|
+ assertNotNull(clazz, "[child.ai.chat] AIChatController 类不存在,无法验证适龄守卫注入。");
|
|
|
+ boolean injected = false;
|
|
|
+ for (Field f : clazz.getDeclaredFields()) {
|
|
|
+ if (HOST_CLASS.equals(f.getType().getName())) {
|
|
|
+ injected = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ assertTrue(injected,
|
|
|
+ "[child.ai.chat] AIChatController 未注入 ChildAgeGuardService:孩子视角对话流"
|
|
|
+ + "应在 chat/send 中对不适龄/敏感内容过滤(TC-C-13 边界)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // 异常流(TC-C-13-E):LangGraph 不可用 Fallback 应返回适龄话术(当前缺失 → 红)
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("异常流-1:LangGraph 不可用时孩子对话应 Fallback 返回适龄话术(非空)")
|
|
|
+ void childAgeGuard_langgraphDown_fallbackAgeSuitableReply() {
|
|
|
+ Object host = contractHost();
|
|
|
+ String fallback = callString(host, "fallbackAgeSuitableReply", 8);
|
|
|
+ assertNotNull(fallback, "[child.ai.chat] fallbackAgeSuitableReply 应返回非空适龄话术(非 null)。");
|
|
|
+ assertTrue(!fallback.trim().isEmpty(),
|
|
|
+ "[child.ai.chat] LangGraph 不可用 Fallback 必须返回非空适龄话术兜底"
|
|
|
+ + "(TC-C-13-E:LangGraph 不可用 Fallback 兜底、多轮连续);"
|
|
|
+ + "当前 AIService.sendMessage 空 map 降级 → answer 为空,未满足契约。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // 权限流(TC-C-13-P,说明性):孩子可对话;编辑/删除/切家长属其他模块 role 校验
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("权限流:孩子可对话是其能力目标;编辑/删除/切家长入口属其他模块 role 校验,本契约不适用")
|
|
|
+ void permissionFlow_notApplicable() {
|
|
|
+ // 说明性用例:SC-C-13 能力目标为「孩子问成长问题」,对话本身对孩子开放;
|
|
|
+ // TC-C-13-P 的 403/401 拒绝针对编辑/删除/切家长接口(family/member/switch、报告编辑删除),
|
|
|
+ // 属其他模块 role 校验,不构成 AIChatController 对话的权限维度。仅标注「不适用」。
|
|
|
+ assertTrue(true, "权限流不适用:child.ai.chat 为对话能力,孩子可对话;"
|
|
|
+ + "编辑/删除/切家长拒绝属其他模块 role 校验(TC-C-13-P)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // 反射辅助(锚定宿主契约,避免编译期依赖不存在类)
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+
|
|
|
+ private Object contractHost() {
|
|
|
+ Class<?> clazz = loadClass(HOST_CLASS);
|
|
|
+ if (clazz == null) {
|
|
|
+ fail("RED[child.ai.chat] 契约宿主 ChildAgeGuardService 未实现(适龄过滤未落地):"
|
|
|
+ + "SC-C-13 边界「内容适龄过滤」缺口,API_REFERENCE §4.61.8。");
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ return clazz.getDeclaredConstructor().newInstance();
|
|
|
+ } catch (Exception e) {
|
|
|
+ fail("契约宿主 ChildAgeGuardService 实例化失败:" + e.getMessage());
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 按方法名 + 参数个数匹配并调用(宽松适配基本/包装类型),契约方法缺失即断言失败。 */
|
|
|
+ private Object call(Object host, String method, Object... args) {
|
|
|
+ for (Method m : host.getClass().getMethods()) {
|
|
|
+ if (m.getName().equals(method) && m.getParameterCount() == args.length) {
|
|
|
+ try {
|
|
|
+ return m.invoke(host, args);
|
|
|
+ } catch (Exception e) {
|
|
|
+ fail("契约方法 " + HOST_CLASS + "#" + method + " 调用失败:" + e.getMessage());
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ fail("RED[child.ai.chat] 契约方法 " + HOST_CLASS + "#" + method + " 未实现(适龄过滤未落地)");
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ }
|
|
|
+
|
|
|
+ private Integer callInt(Object host, String method, Object... args) {
|
|
|
+ Object r = call(host, method, args);
|
|
|
+ if (r instanceof Number) {
|
|
|
+ return ((Number) r).intValue();
|
|
|
+ }
|
|
|
+ fail("契约方法 " + method + " 应返回整数,实际 " + r);
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ }
|
|
|
+
|
|
|
+ private boolean callBoolean(Object host, String method, Object... args) {
|
|
|
+ Object r = call(host, method, args);
|
|
|
+ if (r instanceof Boolean) {
|
|
|
+ return (Boolean) r;
|
|
|
+ }
|
|
|
+ fail("契约方法 " + method + " 应返回布尔,实际 " + r);
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ }
|
|
|
+
|
|
|
+ private String callString(Object host, String method, Object... args) {
|
|
|
+ Object r = call(host, method, args);
|
|
|
+ if (r instanceof String) {
|
|
|
+ return (String) r;
|
|
|
+ }
|
|
|
+ fail("契约方法 " + method + " 应返回字符串,实际 " + r);
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ }
|
|
|
+
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private Map<String, Object> callMap(Object host, String method, Object... args) {
|
|
|
+ Object r = call(host, method, args);
|
|
|
+ if (r instanceof Map) {
|
|
|
+ return (Map<String, Object>) r;
|
|
|
+ }
|
|
|
+ fail("契约方法 " + method + " 应返回 Map,实际 " + r);
|
|
|
+ throw new IllegalStateException("unreachable");
|
|
|
+ }
|
|
|
+
|
|
|
+ private Class<?> loadClass(String name) {
|
|
|
+ try {
|
|
|
+ return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
|
|
|
+ } catch (ClassNotFoundException e) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 是否存在指定类中 指定方法名 + 参数个数 的方法。 */
|
|
|
+ private boolean hasMethod(String className, String methodName, int paramCount) {
|
|
|
+ Class<?> clazz = loadClass(className);
|
|
|
+ if (clazz == null) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ for (Method m : clazz.getDeclaredMethods()) {
|
|
|
+ if (m.getName().equals(methodName) && m.getParameterCount() == paramCount) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+}
|