Просмотр исходного кода

test(第69轮): 全量实跑 286类/2375例 95.16%(干净基线)+ WIP代入实测96.93% | 新增B-39 skipTests假绿/B-40 CI软门禁 | 度量脚本入库(闭B-33)

E2E Test Bot 5 дней назад
Родитель
Сommit
2aa575e26d

+ 408 - 0
docs/tdd/test/automation-run-2026-11-07-1054.md

@@ -0,0 +1,408 @@
+# 测试负责人工作汇报 · 第 69 轮(2026-11-07 10:54 · 全量实跑)
+
+> **汇报人**:测试负责人(软件测试专家团 · 定时自动化 6255181e)
+> **执行档**:review(离线白名单)——`mvn test` / jest / pytest 均在本机离线执行,不联网、不装依赖、不启停服务、**不改被审源码**
+> **角色边界**:测试负责人不代写生产代码、不代提交研发 WIP;只写测试侧产物(报告 / 台账 / 度量脚本)
+> **本轮目标(用户下达)**:① 所有测试用例都通过 ② 所有测试用例覆盖全部 SRS 与用户场景(含异常流)
+
+---
+
+## 0. 历史审查记录(记忆召回 · 只作对照,不作本次结论依据)
+
+| 项目 | 内容 |
+|---|---|
+| 召回方式 | 自动化记忆库 `automations/6255181e/memory.md`(63 轮累计)+ `docs/tdd/test/` 历史报告 + `blockers.md`(B-1~B-40) |
+| 上一轮(第 67 轮 09:30)结论 | 后端 2219 例 / 96.71%(含主工作区 WIP);26 红类 / 69 红例;结论「不通过(有条件)」 |
+| 本轮是否首次审查 | 否(同一对象第 63 次连续审查,非首次) |
+| 历史遗留缺口(带入本轮) | ① B-18/B-38 A 类测试装置漂移 ② B-13 环境红 8 例 ③ B-23/B-29 测试资产长期 WIP 未入库 ④ B-30 场景/TC 无锚点 → 覆盖不可量化 ⑤ B-36 根级黑盒套件全 skip ⑥ B-33 度量脚本曾被 `.gitignore` 吞掉 |
+| 本轮已改善 | B-33 **已闭环**(`fb5412e92` 放行 `docs/tdd/tools/*.py`,本轮度量脚本首次成功入库);B-34/B-35/B-37 复验通过(pytest 51 passed / 0 failed);B-21 契约 17/17 稳定 |
+| 本轮新发现 | **B-39(P0)**:`pom.xml` 默认 `skipTests=true` → 标准 `mvn test` 零执行且 BUILD SUCCESS(假绿);**B-40(P1)**:CI 4 个测试 job 为软门禁且排除 `integration` 包 |
+
+---
+
+## 1. 项目画像
+
+| 维度 | 实测事实 |
+|---|---|
+| 业务定位 | 家庭教育 SaaS(浠艾福 XAF):家长端小程序 + 规划师端 + 管理端 + AI 服务;五维能量、任务/心愿、商城支付、健康报告解析、测评订单 |
+| 技术栈 | `cfc-backend` Spring Boot 2.7.18 + MyBatis-Plus + Java 8(9082)/`cfc-frontend` uni-app 小程序 Vue2/`cfc-web` Vue2+Element(8082)/`cfc-langgraph` FastAPI+LangGraph(9000) |
+| 后端规模 | `*Controller.java` **359** 个、`@PostMapping` **1935**、`@GetMapping` **8 处 / 3 文件**(违规,见 P-9) |
+| 测试资产 | 后端测试源 **290** 个(`@Test` **2366**)、契约类 **62**、`integration` 包 **59** 类;小程序 jest 16 suites;管理端 12 suites;Playwright **90** spec / **550** `test()`;pytest 63 例 |
+| 关键路径 | 登录鉴权(JwtInterceptor)→ 家庭/成员 → 任务&心愿 → 五维能量 → 支付/佣金 → 健康报告上传解析 → 规划师订单与认证 |
+| 质量目标(用户) | 用例全绿 + SRS/场景(含异常流)全覆盖 |
+| 本轮基线 | 隔离工作树 `D:\_wt_r1054` @ **`9359de39a`**(= `origin/cfclub`),跑前跑后 SHA 一致;主工作区 52 项 WIP **零侵入**(只读复制 11 个测试文件做对照,见 §1.3) |
+| 风险点 | ① 测试默认不执行(B-39)② 集成层在 CI 视野外(B-40)③ 21 个测试校准资产长期未入库(B-29 遗留)④ 场景→资产无锚点(B-30) |
+
+---
+
+## 2. 二十类测试覆盖矩阵(三态 + 证据)
+
+> 三态口径:**已发现** = 扫描命中可用资产;**未发现** = 扫描未命中(≠ 项目没做);**待核实** = 材料不足或本轮未实测。
+
+| # | 测试类型 | 状态 | 证据 |
+|---|---|---|---|
+| 1 | 单元测试 | 已发现 | 后端 290 源 / 2366 `@Test`;本轮实跑 2375 例 |
+| 2 | 组件测试 | 已发现 | 控制器直调 + MockMvc standalone(`integration/controller/*`) |
+| 3 | 集成测试 | 已发现 | `com.etotem.cfc.integration` **59** 类(含 3 个 `@SpringBootTest` 真实数据源类) |
+| 4 | 契约测试 | 已发现 | `*Contract*Test.java` **62** 类;本轮契约 15 类 155 例 → 152 绿 / 3 红 |
+| 5 | 接口 API 测试 | 已发现(部分降级) | MockMvc 断言为主;根级黑盒 `tests/integration/*.spec.js` 因缺凭据 **23 例全 skip**(B-36) |
+| 6 | 系统 / 端到端 | 已发现(未实测) | Playwright 90 spec / 550 `test()`;需联网 + `globalSetup` 会 reset 共享库 → 未授权未执行 |
+| 7 | 验收测试 UAT | 未发现 | 无 UAT 用例/签署记录 |
+| 8 | BDD 规格 | 未发现 | 无 `.feature` / Given-When-Then 资产 |
+| 9 | 冒烟测试 | 已发现 | `.gitlab-ci.yml:207` `ci-smoke.spec.js`(headless chromium)+ `backend-g0-compile` |
+| 10 | 回归测试 | 已发现 | CI `backend-contract-junit` 分层回归;本轮全量 clean 复跑即回归证据 |
+| 11 | 探索性测试 | 未发现 | 无 charter / 会话记录 |
+| 12 | 性能测试 | 未发现 | 无压测脚本与基线(单类耗时仅作观察,见 §1.6) |
+| 13 | 可靠性测试 | 未发现 | 无故障注入 / 恢复演练 |
+| 14 | 安全测试 | 部分 | 静态扫描人工登记(B-31 明文凭据);**无 SAST/DAST/SCA 工具链**(未实测) |
+| 15 | 兼容性测试 | 部分 | 小程序仅 jest 逻辑镜像(无真机/SFC 渲染);无浏览器矩阵 |
+| 16 | 可用性测试 | 未发现 | 无真实用户可用性证据 |
+| 17 | 无障碍测试 | 未发现 | 无 WCAG / a11y 断言 |
+| 18 | 覆盖率度量 | 未发现 | `pom.xml` 无 JaCoCo(`grep jacoco` = 0)→ 仅有 surefire 通过率,**不得宣称行覆盖达标** |
+| 19 | 变异测试 | 未发现 | 无 PIT / mutmut |
+| 20 | 质量度量与流程 | 已发现 | `docs/tdd/tools/test_report_metrics.py`(本轮首次入库)+ `test_attr.py` + 本台账 40 条阻塞 |
+
+**矩阵结论**:20 类中 **9 类已发现(含 2 类部分降级)**、**8 类未发现**、**3 类待核实/未实测**。用户目标「覆盖全部测试类型」不成立——这是**类型层缺口**,与用例通过率是两条独立战线。
+
+---
+
+## 3. 测试方案与执行档
+
+| 项 | 内容 |
+|---|---|
+| 范围 | 四套件全量:cfc-backend(Maven surefire)、cfc-frontend(jest)、cfc-web(jest)、根级 tests(jest)、cfc-langgraph(pytest) |
+| 执行档 | **review(离线)**:不联网、不装依赖、不启停服务。E2E 浏览器级、压测、SAST/SCA、JaCoCo 均**未授权/无工具 → 未实测** |
+| 隔离策略 | 后端在 `D:\_wt_r1054` 独立工作树跑,避免受主工作区 52 项 WIP 污染;证据备份 `D:\_r1054_surefire`(仓库外) |
+| 核心命令 | `mvn -o -B test -DskipTests=false -Dmaven.test.failure.ignore=true`(**注意:不加 `-DskipTests=false` 会零执行,见 B-39**) |
+| 判定标准 | 门禁 G1~G14(见 §七) |
+
+---
+
+# 一、本轮工作数据汇总
+
+## 1.1 测试用例建设
+
+| 指标 | 数量 | 说明 |
+|---|---|---|
+| **本轮新增测试用例数*** | **0 个** | 测试负责人本轮不新增用例(未发现 SRS/场景文档变更:`tests/user-scenarios`、`tests/test-cases` 自 11-07 02:10 起零变更);用例**资产**侧同期由研发侧合入 9 个契约类 +156 例(`7f9b978c2` EVD-8R,见 §1.3 对账) |
+| **本轮验证执行测试数*** | **2829 个**(收集 2864) | 后端 2375 + 小程序 313 + 管理端 90 + pytest 51 实跑 + 根级 **0**(23 全 skip) |
+| **用例通过率** | **95.93%** | 通过 2714 / 执行 2829;后端单独 **95.16%**(剔环境红 95.49%) |
+| 全绿套件 | **2 / 5** | 小程序 313/313 ✅、管理端 90/90 ✅;后端 ❌、pytest ✅(51/51 无红但有 12 skip)、根级 ❌(0 执行) |
+
+## 1.2 会话与任务分发
+
+| 指标 | 数量 | 说明 |
+|---|---|---|
+| **分派任务数*** | **0 个** | 单 agent 定时巡检,无多会话编排派发 |
+| **恢复智能体会话数*** | **0 个** | 无 |
+| **当前在用会话数** | **0 个** | 无活跃智能体会话(本机并行跑测进程 4 个,非会话) |
+
+## 1.2b 各会话当前工作状态列表
+
+| 会话标签 | 会话 ID | 当前状态 | 当前任务 | 静默/空闲时长 | 工作目录 | 备注 |
+|---|---|---|---|---|---|---|
+| 测试负责人(本会话) | `auto-6255181e` | `running` | 本轮全量跑测 + 汇报 | 进行中 | 干净(仅测试侧产物待提交) | 无空闲会话;研发侧 WIP 52 项不属本会话 |
+| 研发负责人(错峰) | `dev-lead-*` | `unknown` | EVD 系列 SC 契约补建 | 0.5h | **不干净**(52 项 WIP) | 21 个测试校准文件仍未入库(B-29/B-23 遗留) |
+
+> R2「不留空闲」:本巡检无空闲会话可派;**但研发侧 52 项 WIP 是当前最大质量瓶颈**(见 P-8)。
+
+## 1.3 问题处置
+
+| 指标 | 数量 | 说明 |
+|---|---|---|
+| **新测试发现问题数*** | **3 个** | ① **B-39** `skipTests=true` 假绿(P0)② **B-40** CI 软门禁 + 排除 integration(P1)③ 度量口径落差:09:30 记 2219 例、本轮 2375 例,已定位为「EVD-8R 9 契约类未进入上轮编译窗口」(+156 例,全绿),非回归 |
+| **已解决测试相关问题数*** | **4 个** | ① B-33 `.gitignore` 吞 Python 脚本 → 已放行且本轮脚本成功入库 ② B-34 问卷 `prompt_template`(pytest 51/51 实证)③ B-35 DAN A1/A2/B4(38/38)④ B-37 blank-key(10/10) |
+| **待解决测试相关问题数** | **111 例 / 34 类**(+ 40 条台账项) | 分层:A 类测试侧 87 · B 类待研发确认 13 · 契约待裁决 3 · 环境 8(明细见附录 C) |
+
+### 1.3.1 与上轮逐类对账(同源可比部分)
+
+| 口径 | 类 | 例 | 通过 | F | E | S | 通过率 | 红类 | 红例 |
+|---|---|---|---|---|---|---|---|---|---|
+| 第 67 轮 09:30(HEAD **+ 主工作区 WIP**) | 277 | 2219 | 2146 | 40 | 29 | 4 | 96.71% | 26 | 69 |
+| **本轮 10:54(干净 HEAD `9359de39a`)** | **286** | **2375** | **2260** | **65** | **46** | **4** | **95.16%** | **34** | **111** |
+| **本轮 + WIP 代入(11 类定向实测)** | 286 | 2375 | **2302** | 23 | 42 | 4 | **96.93%** | **29** | **69** |
+
+**逐项对账(可加总验证)**:
+
+1. **+9 类 / +156 例(全绿)**:`AssessmentQuotaAppointmentContractTest`、`PlanLifecycleParentContractTest`、`TianpanGuideContractTest` 等 9 个契约类由 `7f9b978c2`(11-07 08:15)引入,经 `7ecb11d02`(09:50 合并)进入主线;上轮 09:30 开跑时**尚未进入编译窗口** → 非回归,纯分母增长。
+2. **+42 红例**:本轮为**干净 HEAD**,不带上主工作区 11 个 WIP 测试校准文件;上轮带 WIP。
+   **已由定向实测闭环**:把 11 个 WIP 文件复制进隔离树复跑,**170 例 / 48 红 → 170 例 / 6 红(−42)**,分母不变(170 例),
+   即 `111 − 42 = 69` 与上轮 69 红例**逐位一致**;按此推算全量 = `2375 / 2302 通过 / 96.93%`(见上表第 3 行)。
+3. 校验:`2260 + 42 = 2302`,`2302 / 2375 = 96.93%`;`2219 + 156 = 2375`。**两轮口径落差已 100% 解释,无新增回归**。
+
+**WIP 定向复跑明细(11 类)**:`ArticleControllerTest` 11/11 ✅、`AuthControllerTest` 39/39 ✅、`FamilyControllerTest` 8/8 ✅、`GuideOrderControllerTest` 8/8 ✅、`GuideTeamControllerTest` 9/9 ✅、`PaymentControllerTest` 12/12 ✅、`ProductControllerTest` 16/16 ✅、`ProductOrderControllerTest` 20/20 ✅;**仍红 6 例**:`GuideFamilyTaskControllerTest` 3(overview 返回 null、pendingReview 计数 0、batchReview 计数 0)、`GuideRolePermissionTest` 2(reviewTask/batchReview NPE、计数 0)、`StatsControllerTest` 1(文案「请先创建或加入家庭」)。
+
+## 1.4 本轮 git 提交与推送证据
+
+| 提交 sha | 关联任务/能力键 | 阶段 | 触及文件类型 | 目标分支 | 已推送远端 | 备注 |
+|---|---|---|---|---|---|---|
+| 见文末「本轮提交」 | 巡检报告 + 台账 + 度量脚本 | 巡检 | 测试文档/工具 | `cfclub` | 是 | 仅测试侧产物;**不触碰研发 WIP** |
+
+> 基线 `HEAD=9359de39a` = `origin/cfclub`,跑前跑后一致,窗口内零源码提交 → B-14(并发漂移)未复发。
+
+## 1.5 状态机阶段分布(全局进度)
+
+| 阶段 | 键数 | 备注 |
+|---|---|---|
+| UNTRACKED | 0 | — |
+| RED | 0 | — |
+| YELLOW | 0 | — |
+| GREEN | 5 | `.tdd/state.json`(`updated_at` 仍为 2026-11-06 16:58) |
+| DONE | 0 | — |
+
+> ⚠️ **B-17 未闭环**:状态机仅 5 键,与研发负责人报告的口径(22 键 = 12 DONE + 9 GREEN + 1 YELLOW)脱节;本机 `.tdd/` 为本地私有,权威状态在各角色工作区,**本条只作审计提示**。
+
+## 1.6 数据源健康度
+
+| 数据源 | 读取数 | 是否正常 | 备注 |
+|---|---|---|---|
+| 需求侧场景(`tests/user-scenarios`) | **215** 场景 | 正常 | 11 文件(`02-parent` 85 条最多) |
+| 测试用例(`tests/test-cases`) | **784** TC(异常流 **474 = 60.5%**) | 正常 | 与上轮 784/474 一致,口径稳定 |
+| 场景→TC 映射 | **155 / 215 = 72.1%** | ⚠️ 异常偏低 | 60 场景无 TC 引用(其中 40 个零资产) |
+| TC→自动化资产锚点 | **0 / 784 = 0%** | ❌ 断链 | 测试资产中 TC 编号出现 0 次(B-30) |
+| 场景→spec 字面引用 | **32 / 215 = 14.9%** | ⚠️ 改善但低 | 上轮 6(2.8%)→ 本轮 32(新增 EVD 系列 spec 带编号) |
+| 执行结果回填 | surefire 286 XML / 0 沉默 / 0 dump | 正常 | 完整性 286/286;恒等式 `2366 − 20 + 3 + 26 = 2375` 误差 **0** |
+
+---
+
+# 二、向研发提出的改进建议
+
+| 序号 | 建议标题 | 问题描述 | 根因分析 | 建议方案 | 优先级 | 状态 |
+|---|---|---|---|---|---|---|
+| 1 | 去掉默认 `skipTests=true` + CI 加「0 XML 即失败」守卫 | 标准 `mvn -o -B clean test` 在干净树跑出 **9:49 BUILD SUCCESS 且 `Tests are skipped.`,surefire-reports 0 个 XML** | `cfc-backend/pom.xml:28 <skipTests>true</skipTests>`,`:232` surefire 引用该属性 | 删默认值(或改 false),"跳过"交由命令行 `-DskipTests` 显式控制;CI 断言 `TEST-*.xml` 数量 > 0 | **P0** | 待评估(B-39) |
+| 2 | 测试资产立即入库(21 文件) | 11 个后端测试 + 8 个 pytest + 前端 spec 长期滞留工作区;`git clean` 即静默丢失(B-23 已发生过) | 测试侧不代提交研发 WIP,研发侧未收口 | 按 B-29 已验收的写法(`when(articleService.recordRead(...))`)提交 11 文件;`--clean-check` 复核 | **P0** | 待评估(B-29 遗留) |
+| 3 | CI 从软门禁升级为硬门禁 + 补 integration 层 | 4 个测试 job `allow_failure: true`;`-Dtest='!**/integration/**'` 排除 59 类,而 34 个红类中 **14 个在 integration 包** | 历史红例多,选择"先放行" | 分 `test-blocking` / `test-observed` 两级;integration 按类白名单逐批转 blocking | **P0** | 待评估(B-40) |
+| 4 | 修复 3 处规划师认证门禁引发的整类失败 | `GuideOrderControllerTest`(8)、`GuideRolePermissionTest`(5)、`PlannerBindInviteFlowTest`(5) 全部返回 `403 规划师未完成【planner】培训认证`,掩盖了「订单不存在 / 无权查看 / 缺少订单ID」等原断言 | 生产新增认证前置校验,测试夹具未注入已认证上下文 | 测试夹具补 `plannerCertified=true` 桩;同时**确认该门禁是否符合产品预期**(若不符属生产缺陷) | **P0** | 待评估 |
+| 5 | 统一测试数据源指向 | 3 类 8 例恒红 `Failed to load ApplicationContext`(`AdminFamilyOverviewControllerTest`/`FamilyAdminOverviewServiceTest`/`UserServiceFamilyNameLinkTest`) | 硬编码 `127.0.0.1:3306`,本机实例握手失败 | `@ActiveProfiles("test")` + `application-test.yml` 指向 192.168.16.251;或 Testcontainers | P1 | 待评估(B-13,跨 14 轮未修) |
+| 6 | 核验 `platform_fee` 列定义 | `GuideModuleIntegrationTest.testGuidePackageCreationFlow` 报 `MysqlDataTruncation: Out of range value for column 'platform_fee'` | schema 迁移与代码类型不一致候选 | 核对迁移脚本列精度(DECIMAL 位宽)与写入值量级 | P1 | 待评估(B-38 ④) |
+| 7 | 建立「场景/TC → 资产」锚点约定 | TC 编号在 348+ 测试资产中出现 **0 次**,无法回答"哪些 SRS 场景没有自动化" | 台账与资产无编号契约 | 每个自动化用例首行注明 `@TC-xxx`;反向核对脚本进 `docs/tdd/tools/` | P1 | 待评估(B-30) |
+| 8 | 根级黑盒套件恢复可执行 | 23 例全 skip(`CFC_API_E2E==='1' && CFC_API_TOKEN` 门控),接口层验收从「假红」变「真空」 | 缺 seed 账号与 token 注入 | `tests/.env.test` + `globalSetup` 自动登录取 token;CI 注入 `API_BASE_URL` | P1 | 待评估(B-36) |
+| 9 | 清理 8 处 `@GetMapping` | `AdminSystemPromptController`、`DimensionRegistryController`、`CfBalanceController` 违背 AGENTS.md「统一 `@PostMapping`」 | 规范落伍未回收 | 改 `@PostMapping` 或更新 AGENTS.md 例外清单 | P2 | 待评估 |
+| 10 | 补 JaCoCo + 变异测试 | 无行/分支覆盖率、无变异测试 → 无法量化"测试是否真的有效" | 工具链缺失 | `jacoco-maven-plugin` 入 pom,report 进 CI;核心域先跑 PIT | P2 | 待评估 |
+
+---
+
+# 三、本轮阻塞与风险
+
+| 阻塞项 | 影响范围 | 阻塞类型 | 处置进展 | 需要协调 |
+|---|---|---|---|---|
+| B-39 `skipTests=true` 默认假绿 | 全部后端验证入口 | 研发 | 本轮新登记,已在报告中量化 | 研发负责人:改 pom + CI 守卫 |
+| B-40 CI 软门禁 + 排除 integration | 交付门禁可信度 | 研发/流程 | 本轮新登记 | 主理人:裁决 blocking / observed 分界 |
+| B-29 遗留:21 个测试资产未入库 | 42 例转绿能力悬空 | 研发/流程 | 已实测确认(见 §1.3) | 研发负责人:提交 OR 主理人裁决代提交 |
+| B-13 环境红 8 例 | 后端通过率天花板 | 研发/环境 | 跨 14 轮未修 | 研发:测试数据源统一 |
+| B-30 场景/TC 零锚点 | 用户目标②不可量化 | 需求/测试 | 度量脚本已入库,锚点仍 0 | 需求侧:定锚点规范 |
+| B-36 根级黑盒全 skip | 接口层验收真空 | 测试/环境 | 待 seed 账号 | 研发:提供测试账号 |
+| E2E 90 spec / 550 例未实测 | 系统级覆盖盲区 | 测试 | **未授权联网 + globalSetup 会 reset 共享库** | 主理人:批准独立 E2E 环境 |
+
+---
+
+# 四、下轮工作计划
+
+| 计划项 | 预计完成时间 | 前置依赖 | 备注 |
+|---|---|---|---|
+| 复跑 11 个 WIP 测试类,把「预计转绿 42 例」变实测 | 2026-11-07 12:30 | 无 | 本轮已启动定向复跑(见 §附录 D) |
+| B-39 修复后复验「默认 `mvn test` 有 XML」 | 2026-11-08 | 研发改 pom | 门禁 G13 转绿条件 |
+| 按 A 类清单逐类修 87 例 | 2026-11-08~11-09 | 研发不阻断测试目录 | 目标后端 ≥98.5% |
+| 补 40 个零资产场景的 TC | 2026-11-09 | 需求侧确认场景清单 | 优先异常流 |
+| E2E 独立环境试跑(需授权) | 2026-11-10 | 主理人授权 + 独立库 | 90 spec / 550 例 |
+
+---
+
+# 五、覆盖缺口清单(对应用户目标②)
+
+| 缺口类型 | 现状 | 风险 | 建议测试设计 | 优先级 | 闭环方式 |
+|---|---|---|---|---|---|
+| G-1 零资产场景 | **40** 个场景无任何测试资产引用:`ED-1~ED-17`(五维能量 17)、`A-8~A-15`(管理端 8)、`B-01/02/05/10`、`C-9`、`H-03~H-07`、`S-4~S-8`、`T-9` | 五维能量与管理端是核心域,全部"设计有、执行无" | 每场景 ≥1 正常流 + 1 异常流 JUnit 契约;五维能量补「余额不足/超发/并发扣减」负向 | **P0** | TC 落 `tests/test-cases` + JUnit 类带 `@TC-` 锚点;复跑转绿 |
+| G-2 有资产无台账 | **20** 个场景(`G-6~G-9`、`P-14~P-17`、`T-10~T-12`、`B-04/06/07/09/11/12`、`A-12`、`H-01/02`)资产已引用场景编号,但 TC 表未登记 | 覆盖被低估,评审时"看起来没测" | 反向补 TC 行,挂到对应场景 | P1 | 台账对齐(脚本可生成) |
+| G-3 异常流不可证 | 异常流 TC **474/784 = 60.5%**(设计层达标),但 TC→资产锚点 **0/784**,无法证明被执行 | 无法回答"异常流覆盖多少" | 锚点化 + 反向核对脚本产出「场景→TC→资产→结果」四列表 | **P0** | 锚点 100% 后由 `test_report_metrics.py --scene` 出数 |
+| G-4 测试类型空洞 | 8 类未发现(UAT/BDD/探索性/性能/可靠性/可用性/无障碍/变异)+ 覆盖率无 JaCoCo | 非功能质量无证据 | 先补性能基线(关键 10 接口)+ JaCoCo 行覆盖 | P1 | 报告出基线数字 |
+| G-5 集成层在 CI 外 | 59 个 integration 类不参与门禁,14 个红类位于其中 | CI 绿灯 ≠ 可发布 | integration 分批转 blocking | **P0** | CI 红即拦 |
+| G-6 E2E 盲区 | 90 spec / 550 例,本轮 0 执行 | 系统级链路无回归网 | 独立环境 + 只读数据集 | P1 | 出首份 E2E 通过率 |
+
+---
+
+# 六、五要素问题清单
+
+> 每条严格按 ① 问题描述 ② 测试 bug 点 ③ 最优修改方案 ④ 修改目的 ⑤ 闭环方式(复测 + 通过标准)
+
+### P-1(P0·研发)默认 `skipTests=true` → 零执行假绿
+1. **问题描述**:干净树 `mvn -o -B clean test` 9:49 BUILD SUCCESS,日志 `Tests are skipped.`,`surefire-reports` 0 XML。
+2. **测试 bug 点**:`cfc-backend/pom.xml:28` `<skipTests>true</skipTests>`(`:232` 引用);证据日志 `D:\_r1054_backend.log`。
+3. **最优修改方案**:删除 `:28` 默认值或改 `false`;CI 增加守卫:mvn 后断言 `target/surefire-reports/TEST-*.xml` 数量 > 0。
+4. **修改目的**:让"构建成功"重新等价于"测试通过",消除系统性假绿。
+5. **闭环**:默认 `mvn test` 产出 XML 且 CI 守卫可拦截 0 XML;通过标准:`ls TEST-*.xml | wc -l ≥ 280`。
+
+### P-2(P0·测试)A 类测试装置漂移 87 例
+1. **问题描述**:111 红例中 87 例可在测试侧修复:Mockito 误用 22、测试内 NPE 26、断言基线/文案/状态码漂移 39(明细附录 C)。
+2. **测试 bug 点**:`ArticleControllerTest.recordRead_NoDuration`(`doNothing()` 用于非 void)、`ProductControllerTest.shelve_PublishSuccess`(matcher 混用)、`ProductOrderControllerTest.confirm_Success`(NPE,堆栈仅测试帧)、`ArticleWorkflowTest.reDraft_notRejected_*`(文案 `仅已驳回` vs `仅已驳回或已撤回`)、`WishControllerTest.getWishes_MissingCondition`(`childId`→`memberId`)、`StatsControllerTest.familyCompletion_NoFamily`(500 vs 5001)、`MindFortuneControllerTest.testGetTodayFortuneWithoutAuth`(401 vs 400)、`AuthControllerTest.*`(接口已 410 Gone)。
+3. **最优修改方案**:按 5 个模式批量修——① `when(mock.method(...)).thenReturn` 替代 `doNothing().when(...)`;② matcher 数量与实参个数对齐;③ 补 `@Mock` 并 `ReflectionTestUtils.setField` 注入新依赖;④ 断言改为 `contains`/新文案;⑤ 废弃接口断言改 `410`。
+4. **修改目的**:把"测试追不上生产"的损耗清零,让红灯只剩真实问题(当前 111 红中 78% 是自噪声)。
+5. **闭环**:`mvn test -DskipTests=false` 后 A 类清单 87 例全绿;通过标准:后端通过率 `(2260+87)/2375 = 98.82%`,红例 ≤ 24。
+
+### P-3(P0·研发)规划师认证门禁导致 18 例订单断言失效
+1. **问题描述**:`GuideOrderControllerTest`(8)/`GuideRolePermissionTest`(5)/`PlannerBindInviteFlowTest`(5) 全部返回 `403 规划师未完成【planner】培训认证,暂不可提供该类服务`,掩盖原断言(订单不存在/无权查看/缺少订单ID/缺省参数)。
+2. **测试 bug 点**:`GuideOrderControllerTest.getOrderDetail_Success`、`GuideRolePermissionTest.orderDetail_OwnerCanView`、`PlannerBindInviteFlowTest.parentConfirmsBind_Success`。
+3. **最优修改方案**:① 研发确认该门禁是否为预期产品行为;② 测试夹具补"已认证规划师"上下文(stub 认证查询/写 `planner_certified` 状态);③ 保留 1 例"未认证 → 403"作为门禁自身契约。
+4. **修改目的**:让订单域断言重新可判真伪;避免"一个门禁吃掉整类断言"的假红放大。
+5. **闭环**:18 例中 16 例转绿 + 2 例门禁契约绿;通过标准:三类 `(tests, F, E)` 中 F+E = 0,且 `orderDetail_未认证_403` 新用例绿。
+
+### P-4(P0·流程)21 个测试校准资产未入库
+1. **问题描述**:主工作区 52 项 WIP 中 11 个后端测试文件可让 42 例转绿(本轮已实测确认差值 42),但 `git ls-files` 零命中;`git clean -fd` 即丢失(B-23 已发生过一次)。
+2. **测试 bug 点**:`cfc-backend/src/test/java/com/etotem/cfc/integration/controller/{Article,Auth,Family,GuideFamilyTask,GuideOrder,GuideRolePermission,GuideTeam,Payment,Product,ProductOrder,Stats}*.java`。
+3. **最优修改方案**:按 B-29 已验收写法 `when(articleService.recordRead(eq(1L), isNull(), isNull(), eq(30))).thenReturn(new HashMap<>())` 收口,`git add` 11 文件 + commit + push;`--clean-check` 复核。
+4. **修改目的**:把"可用能力"变成"可复现能力",防止分支切换/清理静默丢失。
+5. **闭环**:`git ls-files` 命中 11 文件;干净 HEAD 全量跑出 **红例 69 / 96.93%**(本轮定向实测:11 类 170 例 48 红 → 6 红);通过标准:`git status --porcelain` 中测试资产 0 项。
+
+### P-5(P0·研发)CI 软门禁 + 排除集成层
+1. **问题描述**:`.gitlab-ci.yml` 7 job 中 4 个 `allow_failure`;`backend-contract-junit:142` 用 `-Dtest='!**/integration/**'` 排除 59 类,而 34 红类中 14 类在其中。
+2. **测试 bug 点**:`.gitlab-ci.yml:134 / :165 / :225 / :142`。
+3. **最优修改方案**:拆 `test-blocking`(当前全绿子集:小程序 313 + 管理端 90 + 后端非 integration 全绿类 + pytest)与 `test-observed`(已知红,允许失败但留趋势);integration 按类白名单逐批转 blocking。
+4. **修改目的**:让 CI 红 = 不可发布;同时不因存量红阻断全部交付。
+5. **闭环**:任一 blocking job 红 → 流水线红;通过标准:blocking 覆盖例数 ≥ 全量 80%,observed 清单 ≤ 111 例且逐轮下降。
+
+### P-6(P1·研发/环境)3 类 8 例环境红跨 14 轮未修
+1. **问题描述**:`AdminFamilyOverviewControllerTest`(3)、`FamilyAdminOverviewServiceTest`(3)、`UserServiceFamilyNameLinkTest`(2) 恒报 `Failed to load ApplicationContext`(`Communications link failure`)。
+2. **测试 bug 点**:三类的 `@SpringBootTest(properties={"spring.datasource.url=jdbc:mysql://127.0.0.1:3306/..."})` 硬编码本机库。
+3. **最优修改方案**:`@ActiveProfiles("test")` + `src/test/resources/application-test.yml` 指向测试库(池上限 4);或引入 Testcontainers。
+4. **修改目的**:剔除 8 例永久噪声,让通过率真实反映代码质量(当前 95.16% → 95.49%)。
+5. **闭环**:三类 8 例转绿或明确 `@Disabled`+原因;通过标准:`Failed to load ApplicationContext` 出现 0 次。
+
+### P-7(P1·研发)`platform_fee` 数据截断(真实问题候选)
+1. **问题描述**:`GuideModuleIntegrationTest.testGuidePackageCreationFlow` 报 `MysqlDataTruncation: Out of range value for column 'platform_fee'`。
+2. **测试 bug 点**:`GuideModuleIntegrationTest#testGuidePackageCreationFlow`(唯一 1 例 other 类异常)。
+3. **最优修改方案**:核对 `platform_fee` 列定义(DECIMAL 位宽/符号)与写入值量级;若列过窄,补 DDL 迁移(按 AGENTS.md 迁移流程 + 同步 `schema.sql`)。
+4. **修改目的**:这是 111 红例中**唯一指向 schema 与代码不一致**的候选,可能是真实生产缺陷(套餐创建含大额手续费时失败)。
+5. **闭环**:该例转绿 + 人工核验迁移脚本;通过标准:`Out of range value` 0 次,且生产/测试库列定义一致。
+
+### P-8(P1·需求/测试)场景与 TC 零锚点 → 目标②不可量化
+1. **问题描述**:215 场景中 155(72.1%)有 TC 映射,60 无映射(40 个零资产);TC 784 条中在自动化资产出现编号 **0** 次。
+2. **测试 bug 点**:`tests/test-cases/*.md` 的「对应 e2e」列与真实文件名脱节(含 `../` 非法路径);资产侧无 `@TC-` 约定。
+3. **最优修改方案**:① 约定每个自动化用例首行 `// @TC-xxx`;② 反向核对脚本产出「场景→TC→资产→结果」四列清单并入库;③ 按角色 4 批补锚点,优先 `-N` 负向用例。
+4. **修改目的**:把"覆盖了多少需求"从主观判断变成可复算指标(当前只能报"用例数")。
+5. **闭环**:`test_report_metrics.py --scene` 输出 `tc_with_asset_anchor = 784`;通过标准:场景映射率 ≥ 95%、锚点率 100%。
+
+### P-9(P2·研发)8 处 `@GetMapping` 违背项目规范
+1. **问题描述**:`AdminSystemPromptController`、`DimensionRegistryController`、`CfBalanceController` 共 8 处 `@GetMapping`,与 AGENTS.md「统一 `@PostMapping`」冲突。
+2. **测试 bug 点**:三个控制器类(`grep -rl @GetMapping` 命中)。
+3. **最优修改方案**:改 `@PostMapping`(需同步前端调用),或在 AGENTS.md 明确例外并加注释。
+4. **修改目的**:消除规范漂移导致的测试/前端契约歧义。
+5. **闭环**:`grep -rh @GetMapping | wc -l = 0`(或规范例外登记)。
+
+### P-10(P1·测试)测试类型空洞 8 类 + 无覆盖率工具
+1. **问题描述**:UAT/BDD/探索性/性能/可靠性/可用性/无障碍/变异 8 类零资产;`pom.xml` 无 JaCoCo,无法报行覆盖。
+2. **测试 bug 点**:`cfc-backend/pom.xml`(无 jacoco 插件,`grep -c jacoco = 0`);仓库无 `*.feature`、无压测脚本。
+3. **最优修改方案**:① 引入 `jacoco-maven-plugin` 出 HTML/XML 报告并进 CI;② 关键 10 接口压测基线(k6/JMeter 脚本入库);③ 核心域(支付/能量/订单)先跑 PIT 变异。
+4. **修改目的**:从"用例通过率"升级到"测试有效性"——通过率高但断言弱时,变异分数会暴露。
+5. **闭环**:报告出 JaCoCo 行/分支覆盖 + 1 份压测基线 + 核心域变异分数;通过标准:行覆盖 ≥ 60%、核心域变异杀灭率 ≥ 50%。
+
+---
+
+# 七、门禁结论
+
+| 门禁 | 判定条件 | 本轮实测 | 结论 |
+|---|---|---|---|
+| G1 | 后端用例通过率 ≥ 95% | **95.16%**(剔环境 95.49%) | ✅ |
+| G2 | 后端红例 ≤ 20 | **111** | ❌ |
+| G3 | 前端 313/313 + 管理端 90/90 | 313 ✅ / 90 ✅ | ✅ |
+| G4 | testCompile 0 错 | 290 源,0 错 | ✅ |
+| G5 | 类完整性 100% / 沉默 0 | 286 类有 XML,`silent_missing = 0` | ✅ |
+| G6 | CI 为硬门禁且覆盖集成层 | 4 job `allow_failure`,integration 被排除 | ❌ |
+| G7 | 关键契约键 GREEN | 5 键 16/16、17/17 全绿 | ✅ |
+| G8 | 契约红 ≤ 1 | 3(`DimensionRegistryMapContractTest`) | ❌ |
+| G9 | pytest 实跑通过率 ≥ 90% | 51/51 = 100%(0 失败,12 skip) | ✅ |
+| G10 | 场景→资产可量化 ≥ 80% | TC 锚点 **0%**,场景 spec 引用 14.9% | ❌ |
+| G11 | 主工作区 WIP ≤ 0 项 | **52 项**(含 21 测试资产) | ❌ |
+| G12 | 测试资产全部入库 | 11 后端 + 8 pytest 未入库 | ❌ |
+| **G13(新)** | 默认 `mvn test` 能执行用例 | `skipTests=true` → 0 执行 | ❌ |
+| **G14(新)** | 场景台账映射率 ≥ 95% | 155/215 = 72.1% | ❌ |
+
+## 结论:**不通过(有条件通过方向明确)**
+
+**依据**:14 条门禁中 6 条达标、8 条未达标;用户目标①(全部用例通过)达成度 **95.93%**(后端 95.16%),目标②(覆盖全部 SRS/场景含异常流)测得 **场景映射 72.1% / TC 锚点 0% / 8 类测试类型空洞** → **均未达成**。
+
+**但方向已收敛,且可量化**:
+- 距目标①仅差 **111 例**:A 类 87(测试侧可修)+ B 类 13(研发确认)+ 契约 3 + 环境 8;
+- **A 类中 42 例已由工作区 WIP 修好并本轮实测确认**(11 类 170 例 48 红 → 6 红,分母不变):**WIP 入库即 96.93%**;
+- 修完 A 类全部 87 例即达 **98.82%**;叠加环境 8 例转绿可达 **99.16%**;
+- 目标②的卡点是**度量断链**(锚点 0),不是"没测"——60 个未映射场景中 20 个已有资产。
+
+**复评条件**:P-1/P-2/P-4 闭环 + G13 转绿 → 后端 ≥ 98.8%、红例 ≤ 24、默认命令不再假绿。
+
+---
+
+# 附录 A:本轮新增测试用例明细
+
+**0 个**。本轮未新增用例(SRS/场景文档零变更)。同期主线新增的 9 个契约类(+156 例)由研发侧 `7f9b978c2` 引入,非本会话产出,已计入本轮执行分母。
+
+# 附录 B:任务分派与会话恢复明细
+
+**不适用**(单 agent 定时巡检,无派发)。会话恢复 0 次。
+
+# 附录 C:红例归因明细(111 例 / 34 类)
+
+## C.1 分层汇总
+
+| 层 | 例数 | 构成 | 处置方 |
+|---|---|---|---|
+| **A 测试装置/断言漂移** | **87** | Mockito 误用 22、测试内 NPE 26、断言基线漂移 39 | 测试侧 |
+| **B 生产侧待确认** | **13** | `EnergySystemFlowTest` 6(500)、`Orchestration*` 2、`MembershipPaySwitchTest` 1、`PaymentServiceORDRoutingTest` 1、`TaskPlanServiceTest` 1、`GrowthRecordSyncFlowTest` 1、`GuideModuleIntegrationTest` 1(DataTruncation) | 研发确认 |
+| **契约待裁决** | **3** | `DimensionRegistryMapContractTest`(`$.data` null 语义 + 400 vs 200) | 研发/需求裁决 |
+| **C 环境红** | **8** | 3 类 `Failed to load ApplicationContext` | 研发/环境 |
+| 合计 | **111** | — | — |
+
+## C.2 红类 top(按红例降序)
+
+| 类 | F | E | 归层 | 主因 |
+|---|---|---|---|---|
+| `ArticleControllerTest` | 4 | 5 | A(WIP 已修) | `doNothing()` 非 void、NPE、断言基线 |
+| `GuideOrderControllerTest` | 6 | 2 | A(WIP 已修) | 规划师认证 403 掩盖原断言 |
+| `CfCommissionServiceSettleTest` | 8 | 0 | A | `WantedButNotInvoked: octopusEffectRecordMapper.insert` |
+| `ProductOrderControllerTest` | 0 | 6 | A(WIP 已修) | NPE(手工注入缺依赖) |
+| `EnergySystemFlowTest` | 6 | 0 | B | 集成流 500,需研发确认依赖 |
+| `ProductOrderServiceFulfillmentMultiModeTest` | 0 | 6 | A | matcher 误用 + NPE |
+| `GuideFamilyTaskControllerTest` | 3 | 2 | A(WIP 已修) | NPE + 计数断言 |
+| `GuideRolePermissionTest` | 4 | 1 | A(WIP 已修) | 认证 403 + NPE |
+| `PlannerBindInviteFlowTest` | 4 | 1 | A | 认证 403 + `doNothing` |
+| `AuthControllerTest` | 4 | 0 | A(WIP 已修) | 接口已 410 Gone,断言仍 500/200 |
+| `PaymentControllerTest` | 4 | 0 | A(WIP 已修) | 回调桩返回 false/null |
+| `FamilyJoinRequestServiceReferrerBindTest` | 0 | 4 | A | NPE(桩返回 null) |
+| `ProductControllerTest` | 0 | 3 | A(WIP 已修) | `InvalidUseOfMatchers` |
+| `DimensionRegistryMapContractTest` | 3 | 0 | 契约 | null 语义/状态码口径 |
+| `ArticleWorkflowTest` | 3 | 0 | A | 文案 `仅已驳回或已撤回` |
+| `AdminFamilyOverviewControllerTest` / `FamilyAdminOverviewServiceTest` / `UserServiceFamilyNameLinkTest` | 0 | 3/3/2 | C 环境 | 数据源握手失败 |
+
+> 其余 17 类各 1~3 例,明细见 `D:\_r1054_attr.json`(逐例:class/case/type/msg/首帧/生产帧数/桶)。
+
+# 附录 D:未实测项(不得视为通过)
+
+| 项 | 规模 | 原因 |
+|---|---|---|
+| Playwright E2E(浏览器级) | **90 spec / 550 `test()`** | 需联网 + `globalSetup` 会 `reset-db.sh` 重置共享库 → 未授权 |
+| JaCoCo 行/分支覆盖率 | — | pom 无插件(`grep jacoco` = 0) |
+| 变异测试 | — | 无 PIT/mutmut |
+| 性能 / 压测 | — | 无脚本、需联网压目标环境 |
+| SAST / DAST / 依赖漏洞扫描 | — | 无工具链、需联网 |
+| 根级黑盒 API 套件 | 23 例 | 全部 skip(缺 `CFC_API_TOKEN`/seed 账号,B-36) |
+| 类级 `@Disabled` | 20 例(3 类) | `DataMigrationServiceTest` 11、`TaskServiceMinigameTest` 5、`TeacherLoginTest` 4 |
+| pytest skip | 12 例 | 依赖外部模型/容器路径 |
+| 小程序真机 / SFC 渲染 | — | jest 为逻辑镜像,无 vue-jest SFC 渲染 |
+
+# 附录 E:四套件实测总览
+
+| 套件 | 类/套件 | 用例 | 通过 | 失败 | 错误 | 跳过 | 通过率 | 耗时 |
+|---|---|---|---|---|---|---|---|---|
+| cfc-backend(干净 HEAD) | 286 | 2375 | 2260 | 65 | 46 | 4 | **95.16%** | 49:18 |
+| cfc-frontend(小程序 jest) | 16 | 313 | 313 | 0 | 0 | 0 | **100%** | 16.9s |
+| cfc-web(管理端 jest) | 12 | 90 | 90 | 0 | 0 | 0 | **100%** | 24.6s |
+| cfc-langgraph(pytest) | 11 文件 | 63 | 51 | 0 | 0 | 12 | **100%(实跑)** | 476.9s |
+| 根级 tests(黑盒) | 2 | 23 | 0 | 0 | 0 | 23 | **未执行** | 19.1s |
+| **后端 + WIP 代入(11 类定向复核)** | 11 | 170 | **164** | 4 | 2 | 0 | **96.47%**(该类集) | 18:50 |
+| **合计** | — | **2864** | **2714** | **65** | **46** | **39** | **95.93%(执行口径)** | ≈52 min |
+
+> `mvn` 单类耗时总和与 Top5 长尾:本机并行 4 套件 + IDE 负载,Maven 总时长 49:18(上轮 42:55,+15%),属环境时延(B-24 族),与用例结果零相关。
+
+---
+
+**证据清单(仓库外,供复核)**:`D:\_r1054_backend2.log`(全量)、`D:\_r1054_surefire\`(286 XML 备份)、`D:\_r1054_agg.json`、`D:\_r1054_attr.json`、`D:\_r1054_scene.json`、`D:\_r1054_unmapped.json`、`D:\_r1054_gap2.json`、`D:\_r1054_wip.log`(WIP 定向复跑)、`D:\_r1054_frontend.log`、`D:\_r1054_cfcweb.log`、`D:\_r1054_root_tests.log`、`D:\_r1054_pytest.log`
+
+**度量脚本(已入库)**:`docs/tdd/tools/test_report_metrics.py`(聚合/场景/恒等式)、`docs/tdd/tools/test_attr.py`(逐例归因 7 桶)——两者组合可在任意机器一键复现本文全部数字(B-15/B-32 口径漂移至此闭环)。

+ 2 - 0
docs/tdd/test/blockers.md

@@ -43,3 +43,5 @@
 | B-36 | 根级黑盒 API 套件由「12 恒红」转为「默认 skip」,接口层覆盖归零 | 测试/环境 | WIP 将 `tests/integration/mini-games-api.spec.js:6-11`、`plan-chain.spec.js:21-22` 改为 `CFC_API_E2E==='1' && CFC_API_TOKEN` 门控(原写死 `Bearer test-token` 打真服务 → 恒 401/500)。方向正确(消除 12 个伪红),但 65 轮实测 **23 例全部 skip、0 执行**(`Tests: 23 skipped`)→ 接口层验收从「假红」变「真空」,且 `plan-chain` 仍依赖空的 `ADMIN_PHONE/ADMIN_PASSWORD`。建议:新增 `tests/.env.test` + seed 账号,`globalSetup` 自动登录取 token 写入 `process.env.CFC_API_TOKEN`,CI 注入 `API_BASE_URL=http://127.0.0.1:9082`。闭环:本地 9082 在听时 `CFC_API_E2E=1 npm test` 23 例实跑且红例有归因;CI 出绿灯记录 | 测试(65 轮实测登记) | 2026-11-07 02:15 | 待处理 |
 | B-37 | sleep.config.tags blank-key 实现缺口(契约测试 1 红,阻断该键转 GREEN) | 研发 | 复验轮(TR-6 派发 10 键复验)`SleepConfigTagsMechanismContractTest` **9/10 绿 + 1 红**:`publicValue_blankKey_returnsError:99` 期望 blank key(`"   "`)→ 500 但实得 200。实现 `PublicConfigController.getValue`(L21-28)只查 `key==null||isEmpty()`,未 reject 全空白串 → blank key 被当作正常查询放行。测试由 `f11062b42`(提交信息注明「21绿/1红 blank-key 缺陷」)引入,属**已知实现缺口、非测试误报**。`HealthSleepControllerContractTest` 12/12 绿不受影响。该键 `--mark-green` 已**暂缓**、状态机保持 YELLOW。修复:`getValue` 校验改为 `key==null || key.trim().isEmpty()`(或判 `matches("\\s*")`),并确认无调用方依赖空白 key 语义。闭环:`SleepConfigTagsMechanismContractTest` 10/10 绿后再 `--mark-green sleep.config.tags` | 测试(复验派发登记) | 2026-11-07 05:50 | **已解除(67 轮实测复验,2026-11-07 09:30)**:`SleepConfigTagsMechanismContractTest` 全量 **10/10 GREEN**,blank-key 红清零 → 修复生效;`--mark-green sleep.config.tags` 键不在本工作区账本,由研发负责人侧执行 |
 | B-38 | 本轮 26 红类/69 红例归因(含 GUIDE integration 真实候选) | 研发/测试 | 67 轮全量实测 2219 例 96.71%:26 红类 40F+29E=69 例,归因分层:① **B-13 环境红 3 类 8 例**(primaryDataSource→Hikari→`Communications link failure`);② **A 类断言/装置漂移 ~15 类 47 例**(B-18 同族:doNothing/matcher 误用、NPE、桩缺失、文案断言过期——ArticleWorkflow「仅已驳回」vs「仅已驳回或已撤回」、StatsController「未关联家庭」vs「请先创建或加入家庭」、WishController childId→memberId、MindFortune 401 vs 400);③ **契约断言与实现不一致 6 类 13 例**(DimensionRegistryMapContractTest 3F、EnergySystemFlowTest 6F expected 200 but 500、MembershipPaySwitch/PaymentServiceORDRouting/OrchestrationEngine/OrchestrationIntegration 各 1F);④ **真实问题候选 1 类 1 例**:`GuideModuleIntegrationTest.testGuidePackageCreationFlow` → `MysqlDataTruncation: Out of range value for column 'platform_fee'`(schema 与代码不一致,需研发核验迁移脚本)。建议:① 修 B-13 数据源指向;② 测试侧下轮同步 3 处文案断言 + 修 A 类装置(B-18 清单);③ 研发核验 platform_fee 列定义与 DimensionRegistryMap 契约口径;④ EnergySystemFlowTest 500 需研发确认集成流依赖 | 测试(67 轮实测登记) | 2026-11-07 10:20 | 待处理 |
+| B-39 | `cfc-backend/pom.xml` 默认 `skipTests=true`(测试零执行假绿) | 研发 | **P0 流程黑洞**。`cfc-backend/pom.xml:28` 声明 `<skipTests>true</skipTests>`,`:232` 的 surefire 配置引用 `${skipTests}`。本轮(2026-11-07 10:59)实测:在干净隔离树 `D:\_wt_r1054`@`9359de39a` 执行文档标准命令 `mvn -o -B clean test` → **9:49 BUILD SUCCESS,日志明确 `[INFO] Tests are skipped.`,surefire-reports 下 0 个 XML**——即“构建成功但零用例执行”。影响:① 任何按标准 Maven 命令接入的本地验证/CI 都会得到**假绿**,覆盖率与通过率无从谈起;② 本台账历史上多轮“全量通过”结论必须显式带 `-DskipTests=false` 才成立;③ 新入职/新流水线极易踩坑。`.gitlab-ci.yml:16` 注释已记录该陷阱并以 `-DskipTests=false` 绕过,但**根因未修** → 每个使用方都要自己记得加参数。建议:去掉 `:28` 的默认 `<skipTests>true</skipTests>`(或改为 `<skipTests>false</skipTests>`,把“跳过”交给命令行 `-DskipTests` 显式控制);同时在 CI 增加一条守卫:`mvn -o -B test -DskipTests=false` 后断言 `surefire-reports/*.xml` 数量 > 0,否则 job 失败。闭环:默认 `mvn test` 能跑出用例;或 CI 守卫在“0 XML”时报红 | 测试(本轮实测登记) | 2026-11-07 11:10 | 待处理 |
+| B-40 | CI 4 个测试 job 为软门禁 + 集成层被排除 | 研发/流程 | `.gitlab-ci.yml` 现有 7 个 job,其中 **4 个 `allow_failure` 语义**:`backend-contract-junit:134`、`langgraph-pytest:165`、`tests-jest:225`(e2e-playwright 亦按注释兜底),即**测试失败不阻断流水线**;且 `backend-contract-junit:142` 使用 `-Dtest='!**/integration/**'` 排除整个 `integration` 包(1794/2219 例)——而本轮 26 个红类中 **14 个位于 `com.etotem.cfc.integration.*`**(含 `EnergySystemFlowTest` 6F、`DimensionRegistryMapContractTest` 3F、`PlannerBindInviteFlowTest` 4F 等),**恰是 CI 视野之外的部分**。后果:CI 绿灯 ≠ 可发布,红灯也不拦人。建议:① 分两级门禁——`test-blocking`(全绿子集,必须过)与 `test-observed`(已知红,允许失败但产出趋势);② 把 `integration` 包按类白名单逐批转 blocking;③ 报告 G6 口径从“有无 CI”升级为“blocking job 覆盖例数占比”。闭环:任一 blocking job 红 → 流水线红;且 blocking 覆盖 ≥ 当前全量 80% | 测试(本轮实测登记) | 2026-11-07 11:10 | 待处理 |

+ 127 - 0
docs/tdd/tools/test_attr.py

@@ -0,0 +1,127 @@
+# -*- coding: utf-8 -*-
+"""CFC 测试失败逐例归因(纯只读)。
+
+用途:把 surefire XML 的红例展开为逐例明细,并按「异常类型 + 是否含生产栈帧」预分桶,
+避免跨轮归因边界漂移(B-26/B-28/B-32 教训)。
+
+用法:
+  python test_attr.py --reports <cfc-backend>/target/surefire-reports --out-json attr.json
+
+输出:
+  - cases[]:class / case / kind(F|E) / type / msg / prod_frames / env / bucket
+  - buckets:{bucket: 例数}
+  - classes:{class: {F, E, env, buckets}}
+
+预分桶口径(写死,跨轮一致):
+  env          环境/上下文加载失败(数据源连接类关键字)
+  mockito      Mockito 误用(InvalidUseOfMatchers / UnnecessaryStubbing / WantedButNotInvoked 等)
+  npe_test     NullPointer 且栈内无生产帧 → 测试装置(桩返回 null / 手工注入缺依赖)
+  npe_prod     NullPointer 且栈内含生产帧 → 需研发判定
+  assert       断言失败(AssertionFailedError / ComparisonFailure)→ 需人工判「文案漂移 or 契约不一致」
+  other        其余(异常类型非上述)
+"""
+import argparse
+import glob
+import io
+import json
+import os
+import re
+import xml.etree.ElementTree as ET
+
+ENV_PAT = re.compile(
+    r"Failed to load ApplicationContext|Communications link failure|Connection refused|"
+    r"Could not create connection|Access denied for user|Unknown database|"
+    r"CannotGetJdbcConnection|Unable to acquire JDBC Connection|"
+    r"Failed to instantiate.*ApplicationContext",
+    re.S,
+)
+MOCK_TYPES = {
+    "MockitoException", "InvalidUseOfMatchersException", "WantedButNotInvoked",
+    "ArgumentsAreDifferent", "UnnecessaryStubbingException", "MissingMethodInvocationException",
+    "PotentialStubbingProblem", "MockitoAssertionError",
+}
+ASSERT_TYPES = {"AssertionFailedError", "ComparisonFailure", "AssertionError"}
+PROD_FRAME = re.compile(r"at (com\.etotem\.cfc\.[\w.]+)\.(\w+)\(")
+
+
+def read(p):
+    with io.open(p, encoding="utf-8", errors="ignore") as f:
+        return f.read()
+
+
+def simple(name):
+    return (name or "").split(".")[-1]
+
+
+def classify(typ, body, msg, env):
+    if env:
+        return "env"
+    if typ in MOCK_TYPES:
+        return "mockito"
+    if typ in ("NullPointerException",):
+        prod = 0
+        for line in body.split("\n"):
+            line = line.strip()
+            if not line.startswith("at "):
+                continue
+            m = PROD_FRAME.search(line)
+            if m and not m.group(1).split(".")[-1].endswith(("Test", "Tests", "IT")):
+                prod += 1
+        return "npe_prod" if prod else "npe_test"
+    if typ in ASSERT_TYPES:
+        return "assert"
+    return "other"
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument("--reports", required=True)
+    ap.add_argument("--out-json", required=True)
+    a = ap.parse_args()
+
+    cases, classes = [], {}
+    for p in sorted(glob.glob(os.path.join(a.reports, "TEST-*.xml"))):
+        txt = read(p)
+        try:
+            root = ET.parse(p).getroot()
+        except Exception:
+            continue
+        cls = simple(root.get("name"))
+        for tc in root.iter("testcase"):
+            nodes = list(tc.findall("failure")) + list(tc.findall("error"))
+            if not nodes:
+                continue
+            n = nodes[0]
+            body = n.text or ""
+            typ = (n.get("type") or "").split(".")[-1]
+            msg = (n.get("message") or "").strip() or (body.split("\n")[0] if body else "")
+            frames = [l.strip() for l in body.split("\n") if l.strip().startswith("at ")]
+            prod = 0
+            for f in frames:
+                m = PROD_FRAME.search(f)
+                if m and not m.group(1).split(".")[-1].endswith(("Test", "Tests", "IT")):
+                    prod += 1
+            first_frame = frames[0] if frames else ""
+            # 环境红判定:类级 XML 命中环境关键字(整类上下文加载失败)
+            env = bool(ENV_PAT.search(body))
+            rec = {"class": cls, "case": tc.get("name"),
+                   "kind": "F" if n.tag == "failure" else "E", "type": typ,
+                   "msg": msg[:220], "prod_frames": prod, "first_frame": first_frame[:140],
+                   "env": env, "bucket": classify(typ, body, msg, env)}
+            cases.append(rec)
+            c = classes.setdefault(cls, {"F": 0, "E": 0, "env": False, "cases": 0})
+            c["F" if rec["kind"] == "F" else "E"] += 1
+            c["env"] = c["env"] or env
+            c["cases"] += 1
+
+    buckets = {}
+    for c in cases:
+        buckets[c["bucket"]] = buckets.get(c["bucket"], 0) + 1
+    out = {"total_red": len(cases), "buckets": buckets, "classes": classes, "cases": cases}
+    with io.open(a.out_json, "w", encoding="utf-8") as f:
+        f.write(json.dumps(out, ensure_ascii=False, indent=1))
+    print("red_cases=%d buckets=%s classes=%d" % (len(cases), buckets, len(classes)))
+
+
+if __name__ == "__main__":
+    main()

+ 266 - 0
docs/tdd/tools/test_report_metrics.py

@@ -0,0 +1,266 @@
+# -*- coding: utf-8 -*-
+"""CFC 测试度量工具(纯只读)—— 聚合 surefire + 逐例归因 + 场景/用例覆盖。
+
+用途:让每轮测试报告的数字可复现(B-15 度量脚本丢失、B-32 口径漂移)。
+
+用法:
+  # 1) 聚合 + 归因(后端 surefire 报告目录)
+  python test_report_metrics.py --reports <cfc-backend>/target/surefire-reports --out-json agg.json
+
+  # 2) 场景/用例覆盖(仓库根)
+  python test_report_metrics.py --repo-root <repo> --scene
+
+口径(写死在代码里,跨轮必须一致):
+  - 场景 = user-scenarios/*.md 中 `##`/`### 场景 <ID>:` 标题
+  - TC   = test-cases/*.md 中 `| TC-xxx |` 表格行
+  - 异常流 TC = 编号含 `-N<数字>` 或 类型列含 异常/边界/负向/权限
+  - 场景绑定 = 场景 ID 或其映射 TC 编号出现在测试资产中
+  - 环境红 = 堆栈/消息命中 ApplicationContext/JDBC 连接类关键字
+  - 恒等式 = @Test − 类级@Disabled 内用例 + surefire 占位 + Σ(参数化展开−1) = 执行数
+"""
+import argparse
+import glob
+import io
+import json
+import os
+import re
+import xml.etree.ElementTree as ET
+
+ENV_PAT = re.compile(
+    r"Failed to load ApplicationContext|Communications link failure|Connection refused|"
+    r"Could not create connection|Access denied for user|Unknown database|"
+    r"CannotGetJdbcConnection|Unable to acquire JDBC Connection|Failed to instantiate.*ApplicationContext",
+    re.S,
+)
+MOCK_TYPES = {"MockitoException", "InvalidUseOfMatchersException", "WantedButNotInvoked",
+              "ArgumentsAreDifferent", "UnnecessaryStubbingException"}
+
+
+def read(p):
+    with io.open(p, encoding="utf-8", errors="ignore") as f:
+        return f.read()
+
+
+def xml_flag(txt, key):
+    m = re.search(key + r'="(\d+)"', txt)
+    return int(m.group(1)) if m else 0
+
+
+def simple(name):
+    return (name or "").split(".")[-1]
+
+
+def collect_surefire(reports_dir):
+    """XML 为主、TXT 兜底;返回 {类简单名: stats}。"""
+    stats = {}
+    for p in glob.glob(os.path.join(reports_dir, "TEST-*.xml")):
+        txt = read(p)
+        try:
+            root = ET.parse(p).getroot()
+        except Exception:
+            continue
+        cls = simple(root.get("name") or os.path.basename(p)[5:-4])
+        rec = {"tests": xml_flag(txt, "tests"), "failures": xml_flag(txt, "failures"),
+               "errors": xml_flag(txt, "errors"), "skipped": xml_flag(txt, "skipped"),
+               "env": bool(ENV_PAT.search(txt)), "time": 0.0}
+        m = re.search(r'time="([0-9.]+)"', txt)
+        if m:
+            rec["time"] = float(m.group(1))
+        prev = stats.get(cls)
+        if prev is None or rec["tests"] > prev["tests"]:
+            stats[cls] = rec
+        else:
+            prev["env"] = prev["env"] or rec["env"]
+    for p in glob.glob(os.path.join(reports_dir, "*.txt")):
+        cls = simple(os.path.basename(p)[:-4])
+        if cls in stats:
+            continue
+        txt = read(p)
+        m = re.search(r"Tests run:\s*(\d+),\s*Failures:\s*(\d+),\s*Errors:\s*(\d+),\s*Skipped:\s*(\d+)", txt)
+        if not m:
+            continue
+        stats[cls] = {"tests": int(m.group(1)), "failures": int(m.group(2)),
+                      "errors": int(m.group(3)), "skipped": int(m.group(4)),
+                      "env": bool(ENV_PAT.search(txt)), "time": 0.0}
+    return stats
+
+
+def source_classes(backend_root):
+    out = set()
+    for p in glob.glob(os.path.join(backend_root, "src", "test", "java", "**", "*.java"), recursive=True):
+        fn = os.path.basename(p)
+        if fn.endswith(("Test.java", "Tests.java", "IT.java")):
+            out.add(fn[:-5])
+    return out
+
+
+def identity(backend_root, executed):
+    """恒等式校验:base = @Test − 类级@Disabled 内用例 + 占位;delta 应为参数化净增。
+
+    注意:@Disabled 必须紧贴类声明(注解块内)才算「类级」——方法级 @Disabled
+    (如 EnergySystemFlowTest:69)会自带 1 条 skipped,不参与 base 扣减。
+    """
+    test_cnt = 0
+    param_cnt = 0
+    disabled_inner = 0
+    placeholders = 0
+    disabled_classes = []
+    for p in glob.glob(os.path.join(backend_root, "src", "test", "java", "**", "*.java"), recursive=True):
+        txt = read(p)
+        n_test = len(re.findall(r"^\s*@Test\b", txt, re.M))
+        test_cnt += n_test
+        param_cnt += len(re.findall(r"^\s*@ParameterizedTest\b", txt, re.M))
+        m = re.search(r"^\s*(?:public\s+|final\s+|abstract\s+)*class\s+(\w+)", txt, re.M)
+        if m:
+            head = txt[:m.start()]
+            # 类声明前最后一个非注解语句之后的注解块
+            block = "\n".join(head.split("\n")[-40:])
+            if re.search(r"^\s*@Disabled\b", block, re.M):
+                disabled_inner += n_test
+                placeholders += 1
+                disabled_classes.append(m.group(1))
+    base = test_cnt - disabled_inner + placeholders
+    return {"at_test": test_cnt, "parameterized_methods": param_cnt,
+            "disabled_classes": disabled_classes,
+            "disabled_inner": disabled_inner, "placeholders": placeholders,
+            "base_executed": base, "actual_executed": executed,
+            "param_expansion_net": executed - base}
+
+
+def attribute(reports_dir):
+    rows = []
+    T = re.compile(r"at (com\.etotem\.cfc\.[\w.]+)\.(\w+)\(")
+    for p in glob.glob(os.path.join(reports_dir, "TEST-*.xml")):
+        try:
+            root = ET.parse(p).getroot()
+        except Exception:
+            continue
+        cls = simple(root.get("name"))
+        for tc in root.iter("testcase"):
+            nodes = list(tc.findall("failure")) + list(tc.findall("error"))
+            if not nodes:
+                continue
+            n = nodes[0]
+            body = n.text or ""
+            frames = [l.strip() for l in body.split("\n") if l.strip().startswith("at ")]
+            prod = []
+            for f in frames:
+                m = T.search(f)
+                if m and not m.group(1).split(".")[-1].endswith(("Test", "Tests", "IT")):
+                    prod.append(f)
+            typ = (n.get("type") or "").split(".")[-1]
+            rows.append({"class": cls, "case": tc.get("name"),
+                         "kind": "F" if n.tag == "failure" else "E", "type": typ,
+                         "msg": ((n.get("message") or "").strip() or (body.split("\n")[0] if body else ""))[:200],
+                         "prod_frames": len(prod)})
+    return rows
+
+
+def scene_metrics(repo_root):
+    scenes = []
+    scene_re = re.compile(r"^#{2,3}\s*场景\s*([A-Z]{1,3}-\d+)", re.M)
+    per = {}
+    for p in sorted(glob.glob(os.path.join(repo_root, "tests", "user-scenarios", "*.md"))):
+        ids = scene_re.findall(read(p))
+        per[os.path.basename(p)] = len(ids)
+        scenes += ids
+    tcs = []
+    for p in sorted(glob.glob(os.path.join(repo_root, "tests", "test-cases", "*.md"))):
+        for line in read(p).splitlines():
+            m = re.match(r"^\|\s*(TC-[A-Z]{1,4}-[0-9A-Za-z\-]+?)\s*\|", line)
+            if m:
+                cols = [c.strip() for c in line.strip().strip("|").split("|")]
+                tcs.append({"id": m.group(1), "scene": cols[1] if len(cols) > 1 else "",
+                            "type": cols[5] if len(cols) > 5 else ""})
+    def is_exc(t):
+        return bool(re.search(r"-N\d+", t["id"])) or any(k in t["type"] for k in ("异常", "边界", "负向", "权限"))
+    mapped = set()
+    for t in tcs:
+        for sid in re.findall(r"[A-Z]{1,3}-\d+", t["scene"]):
+            mapped.add(sid)
+    # 测试资产编号索引
+    toks = set()
+    globs = [repo_root + r"\cfc-backend\src\test\java\**\*.java",
+             repo_root + r"\cfc-frontend\__tests__\**\*.js",
+             repo_root + r"\cfc-web\src\**\*.spec.js", repo_root + r"\cfc-web\src\**\*.test.js",
+             repo_root + r"\tests\**\*.spec.js", repo_root + r"\cfc-langgraph\tests\**\*.py"]
+    assets = 0
+    for g in globs:
+        for p in glob.glob(g, recursive=True):
+            if "node_modules" in p:
+                continue
+            assets += 1
+            for m in re.finditer(r"(?<![A-Za-z0-9])([A-Z]{1,3}-\d{1,3}(?:-[A-Za-z0-9]+)*)(?![0-9A-Za-z])", read(p)):
+                toks.add(m.group(1))
+    spec_txt = "\n".join(read(p) for p in glob.glob(repo_root + r"\tests\**\*.spec.js", recursive=True))
+    lit = [s for s in scenes
+           if re.search(r"(?<![A-Za-z0-9])" + re.escape(s) + r"(?![0-9A-Za-z])", spec_txt)]
+    n = len(scenes)
+    exc = sum(1 for t in tcs if is_exc(t))
+    return {
+        "scenes": n, "per_scenario_file": per,
+        "scenes_mapped_to_tc": len([s for s in scenes if s in mapped]),
+        "scene_mapped_ratio": round(len([s for s in scenes if s in mapped]) * 100.0 / n, 1) if n else 0,
+        "scenes_literal_in_spec": len(lit),
+        "scene_spec_ratio": round(len(lit) * 100.0 / n, 1) if n else 0,
+        "tc_total": len(tcs), "tc_exception": exc,
+        "tc_exception_ratio": round(exc * 100.0 / len(tcs), 1) if tcs else 0,
+        "tc_with_asset_anchor": len([t for t in tcs if t["id"] in toks]),
+        "assets_scanned": assets,
+    }
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument("--reports", help="surefire-reports 目录")
+    ap.add_argument("--repo-root", help="仓库根(用于恒等式与场景度量)")
+    ap.add_argument("--scene", action="store_true", help="输出场景/用例覆盖")
+    ap.add_argument("--out-json")
+    a = ap.parse_args()
+
+    out = {}
+    if a.reports:
+        st = collect_surefire(a.reports)
+        tot = {"classes": len(st), "tests": 0, "failures": 0, "errors": 0, "skipped": 0, "time": 0.0}
+        reds = []
+        for cls, s in st.items():
+            for k in ("tests", "failures", "errors", "skipped", "time"):
+                tot[k] += s[k]
+            if s["failures"] + s["errors"]:
+                reds.append({"class": cls, "F": s["failures"], "E": s["errors"], "env": s["env"], "tests": s["tests"]})
+        env_red = sum(r["F"] + r["E"] for r in reds if r["env"])
+        passed = tot["tests"] - tot["failures"] - tot["errors"] - tot["skipped"]
+        out["backend"] = dict(tot, passed=passed,
+                              pass_rate=round(passed * 100.0 / tot["tests"], 2) if tot["tests"] else 0,
+                              pass_rate_ex_env=round((passed + env_red) * 100.0 / tot["tests"], 2) if tot["tests"] else 0,
+                              red_classes=len(reds), red_cases=tot["failures"] + tot["errors"],
+                              env_red_cases=env_red, nonenv_red_cases=tot["failures"] + tot["errors"] - env_red,
+                              time_sum_s=round(tot["time"], 1),
+                              reds=sorted(reds, key=lambda x: -(x["F"] + x["E"])))
+        out["attr"] = attribute(a.reports)
+        if a.repo_root:
+            b = os.path.join(a.repo_root, "cfc-backend")
+            out["identity"] = identity(b, tot["tests"])
+            out["silent_missing"] = sorted(source_classes(b) - set(st))
+    if a.scene and a.repo_root:
+        out["scene"] = scene_metrics(a.repo_root)
+
+    txt = json.dumps(out, ensure_ascii=False, indent=1)
+    if a.out_json:
+        with io.open(a.out_json, "w", encoding="utf-8") as f:
+            f.write(txt)
+    b = out.get("backend")
+    if b:
+        print("classes=%d tests=%d pass=%d F=%d E=%d S=%d rate=%.2f%% (ex-env %.2f%%) red_classes=%d red_cases=%d"
+              % (b["classes"], b["tests"], b["passed"], b["failures"], b["errors"], b["skipped"],
+                 b["pass_rate"], b["pass_rate_ex_env"], b["red_classes"], b["red_cases"]))
+    if "identity" in out:
+        print("identity:", out["identity"])
+    if "silent_missing" in out:
+        print("silent_missing=%d %s" % (len(out["silent_missing"]), out["silent_missing"][:5]))
+    if "scene" in out:
+        print("scene:", {k: v for k, v in out["scene"].items() if k != "per_scenario_file"})
+
+
+if __name__ == "__main__":
+    main()