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

docs(tdd): 测试负责人 R95 执行汇报 2026-11-09 0320 — langgraph 251 实派 pytest 44/36/8(B-77 解除/B-78 解除/B-56 派发根因修正) + 签名 v2 契约 B-81(9 RED 预期) + 后端 95.68%(+66 红=Guhe 11 类预期 RED 非回归) + 前端 41 红(diet24+participant8+canvas2d9)

E2E Test Bot 4 дней назад
Родитель
Сommit
4480e2b144

+ 91 - 0
_dispatch_251.cjs

@@ -0,0 +1,91 @@
+// 189 派发客户端(修正版)—— 修复 B-56 客户端侧缺陷,使 189 通道在凭证就绪后即可用
+//
+// 根因之一(客户端):旧脚本(_dispatch_r91.cjs 等)在 POST /api/session 时把 prompt 嵌进 body
+//   并假设 opencode 建会话后自动运行 agent。实测 189 的 opencode 不会自动运行(session 创建后
+//   outcome 停在初始态,无 assistant 产出)。正确触发流程是:
+//     ① POST /api/session            建会话(返回 data.id)
+//     ② POST /api/session/{id}/prompt 经 {text:"..."} 触发 agent 运行(注意字段是 text,不是 prompt)
+//     ③ 轮询 GET /api/session/{id}   取 outcome(success / failed)
+//
+// 用法:
+//   OPENCODE_SERVER_PASSWORD='IwinTrue@123' node _dispatch_189.cjs "在此写要执行的命令/任务"
+//
+// 注意:189 当前(R93 实测)opencode 所有模型调用均 outcome=failed,根因是 189 节点
+//   C:\Users\Administrator\.config\opencode\opencode.json 中 agnes/sensenova/axon 均无 apiKey,
+//   且 opencode-free 模型需账号授权 —— 即无任一可用 LLM 后端。该问题需运维在 189 本机
+//   配置 API Key 并重启 opencode 后方可解决(SSH 22 当前 refused,无法远程处置)。
+//   本客户端只负责把「触发流程」修对;凭证就绪后本文件即可直接派发 admin 浏览器套件。
+
+const BASE = process.env.OPENCODE_251_BASE || 'http://192.168.16.251:4096';
+const AUTH = 'Basic ' + Buffer.from('opencode:' + (process.env.OPENCODE_SERVER_PASSWORD || '')).toString('base64');
+const MODEL = {
+  id: process.env.OPENCODE_MODEL || 'deepseek3.2',
+  providerID: process.env.OPENCODE_PROVIDER || 'axon',
+};
+const PROJECT_ID = '527b7036bd8d16188784528603a9b79a1d95c9fb';
+
+async function dispatch(promptText, timeoutMs = 20 * 60 * 1000) {
+  // ① 建会话
+  const cr = await fetch(BASE + '/api/session', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json', 'Authorization': AUTH },
+    body: JSON.stringify({ title: 'dispatch-' + Date.now(), model: MODEL, projectID: PROJECT_ID }),
+  });
+  const cj = await cr.json().catch(() => ({}));
+  const sid = (cj.data && cj.data.id) || cj.id;
+  if (!sid) throw new Error('create session failed: ' + JSON.stringify(cj).slice(0, 200));
+  console.log('SESSION_ID', sid);
+
+  // ② 正确触发 agent:POST /prompt,body 字段是 text
+  const pr = await fetch(BASE + '/api/session/' + sid + '/prompt', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json', 'Authorization': AUTH },
+    body: JSON.stringify({ text: promptText }),
+  });
+  console.log('PROMPT', pr.status, (await pr.text()).slice(0, 200));
+
+  // ③ 轮询 outcome
+  const start = Date.now();
+  let last = '?';
+  while (Date.now() - start < timeoutMs) {
+    await new Promise(r => setTimeout(r, 5000));
+    const q = await fetch(BASE + '/api/session/' + sid, { headers: { 'Authorization': AUTH } });
+    const j = await q.json().catch(() => null);
+    const outcome = (j && (j.data && j.data.outcome || j.outcome)) || '?';
+    if (outcome !== last) { console.log('OUTCOME', outcome); last = outcome; }
+    if (outcome === 'success' || outcome === 'succeeded') {
+      // 拉取会话消息,取最后一条 assistant 文本作为结果
+      let resultText = '';
+      try {
+        const m = await fetch(BASE + '/api/session/' + sid + '/message', { headers: { 'Authorization': AUTH } });
+        const mj = await m.json().catch(() => null);
+        const list = (mj && (mj.data || mj)) || [];
+        const asst = list.filter(x => x.type === 'assistant');
+        const lastAsst = asst[asst.length - 1];
+        if (lastAsst) {
+          const parts = lastAsst.content || [];
+          resultText = parts.map(p => p.text || p.content || '').filter(Boolean).join('\n');
+        }
+      } catch (e) { resultText = 'msg-fetch-err:' + e.message; }
+      return { sid, outcome: 'succeeded', resultText };
+    }
+    if (outcome === 'failed') return { sid, outcome: 'failed' };
+  }
+  return { sid, outcome: 'timeout' };
+}
+
+(async () => {
+  const prompt = process.argv[2] || '请只执行命令 `node -v` 并把 stdout 原样返回。';
+  if (!process.env.OPENCODE_SERVER_PASSWORD) {
+    console.error('ERROR: 需设置环境变量 OPENCODE_SERVER_PASSWORD');
+    process.exit(2);
+  }
+  try {
+    const r = await dispatch(prompt);
+    console.log('RESULT', JSON.stringify(r));
+    if (r && r.resultText) console.log('RESULT_TEXT_START\n' + r.resultText + '\nRESULT_TEXT_END');
+  } catch (e) {
+    console.error('DISPATCH_ERROR', e.message);
+    process.exit(1);
+  }
+})();

+ 1174 - 0
_r95_agg.json

@@ -0,0 +1,1174 @@
+{
+ "backend": {
+  "classes": 328,
+  "tests": 2753,
+  "failures": 104,
+  "errors": 11,
+  "skipped": 4,
+  "time": 3084.024000000004,
+  "passed": 2634,
+  "pass_rate": 95.68,
+  "pass_rate_ex_env": 95.68,
+  "red_classes": 31,
+  "red_cases": 115,
+  "env_red_cases": 0,
+  "nonenv_red_cases": 115,
+  "time_sum_s": 3084.0,
+  "reds": [
+   {
+    "class": "GuheEvidenceIsolateContractTest",
+    "F": 11,
+    "E": 0,
+    "env": false,
+    "tests": 12
+   },
+   {
+    "class": "GuheNoPathMapContractTest",
+    "F": 10,
+    "E": 0,
+    "env": false,
+    "tests": 10
+   },
+   {
+    "class": "GuheKbVersionSenseContractTest",
+    "F": 9,
+    "E": 0,
+    "env": false,
+    "tests": 11
+   },
+   {
+    "class": "CfCommissionServiceSettleTest",
+    "F": 8,
+    "E": 0,
+    "env": false,
+    "tests": 12
+   },
+   {
+    "class": "EnergySystemFlowTest",
+    "F": 6,
+    "E": 0,
+    "env": false,
+    "tests": 7
+   },
+   {
+    "class": "GuheV2RouteContractTest",
+    "F": 6,
+    "E": 0,
+    "env": false,
+    "tests": 8
+   },
+   {
+    "class": "PlannerBindInviteFlowTest",
+    "F": 4,
+    "E": 1,
+    "env": false,
+    "tests": 9
+   },
+   {
+    "class": "GuheGateStrainBlockContractTest",
+    "F": 5,
+    "E": 0,
+    "env": false,
+    "tests": 6
+   },
+   {
+    "class": "GuheLowSideAggregateContractTest",
+    "F": 5,
+    "E": 0,
+    "env": false,
+    "tests": 6
+   },
+   {
+    "class": "GuheShellGuardContractTest",
+    "F": 5,
+    "E": 0,
+    "env": false,
+    "tests": 7
+   },
+   {
+    "class": "DimensionScoreServiceImplTest",
+    "F": 0,
+    "E": 4,
+    "env": false,
+    "tests": 6
+   },
+   {
+    "class": "ArticleAutoTagServiceTest",
+    "F": 4,
+    "E": 0,
+    "env": false,
+    "tests": 8
+   },
+   {
+    "class": "GuheLowSideNoSupplementContractTest",
+    "F": 4,
+    "E": 0,
+    "env": false,
+    "tests": 6
+   },
+   {
+    "class": "GuhePageTailCleanContractTest",
+    "F": 4,
+    "E": 0,
+    "env": false,
+    "tests": 6
+   },
+   {
+    "class": "GuheVersionSplitContractTest",
+    "F": 4,
+    "E": 0,
+    "env": false,
+    "tests": 5
+   },
+   {
+    "class": "DimensionRegistryMapContractTest",
+    "F": 3,
+    "E": 0,
+    "env": false,
+    "tests": 7
+   },
+   {
+    "class": "GrowthRecordSyncFlowTest",
+    "F": 1,
+    "E": 2,
+    "env": false,
+    "tests": 10
+   },
+   {
+    "class": "ArticleWorkflowTest",
+    "F": 3,
+    "E": 0,
+    "env": false,
+    "tests": 14
+   },
+   {
+    "class": "GuheKbFieldExposeContractTest",
+    "F": 3,
+    "E": 0,
+    "env": false,
+    "tests": 6
+   },
+   {
+    "class": "ServiceRoleApproveTest",
+    "F": 0,
+    "E": 2,
+    "env": false,
+    "tests": 6
+   },
+   {
+    "class": "MindFortuneControllerTest",
+    "F": 1,
+    "E": 0,
+    "env": false,
+    "tests": 1
+   },
+   {
+    "class": "DailyCheckinStreakFlowTest",
+    "F": 0,
+    "E": 1,
+    "env": false,
+    "tests": 6
+   },
+   {
+    "class": "GuideModuleIntegrationTest",
+    "F": 0,
+    "E": 1,
+    "env": false,
+    "tests": 4
+   },
+   {
+    "class": "OrchestrationEngineTest",
+    "F": 1,
+    "E": 0,
+    "env": false,
+    "tests": 8
+   },
+   {
+    "class": "OrchestrationIntegrationTest",
+    "F": 1,
+    "E": 0,
+    "env": false,
+    "tests": 7
+   },
+   {
+    "class": "MembershipPaySwitchTest",
+    "F": 1,
+    "E": 0,
+    "env": false,
+    "tests": 6
+   },
+   {
+    "class": "PaymentServiceORDRoutingTest",
+    "F": 1,
+    "E": 0,
+    "env": false,
+    "tests": 3
+   },
+   {
+    "class": "UserServiceWechatLoginContractTest",
+    "F": 1,
+    "E": 0,
+    "env": false,
+    "tests": 6
+   },
+   {
+    "class": "UserServiceWechatLoginTest",
+    "F": 1,
+    "E": 0,
+    "env": false,
+    "tests": 5
+   },
+   {
+    "class": "TaskPlanServiceTest",
+    "F": 1,
+    "E": 0,
+    "env": false,
+    "tests": 4
+   },
+   {
+    "class": "TeamProfitCronContractTest",
+    "F": 1,
+    "E": 0,
+    "env": false,
+    "tests": 22
+   }
+  ]
+ },
+ "attr": [
+  {
+   "class": "MindFortuneControllerTest",
+   "case": "testGetTodayFortuneWithoutAuth",
+   "kind": "F",
+   "type": "AssertionError",
+   "msg": "JSON path \"$.code\" expected:<401> but was:<400>",
+   "prod_frames": 0
+  },
+  {
+   "class": "DimensionRegistryMapContractTest",
+   "case": "map_unknownLegacy_returnsNullData",
+   "kind": "F",
+   "type": "AssertionError",
+   "msg": "JSON path \"$.data\"\nExpected: null\n     but: was \"unknown_legacy_xyz\"",
+   "prod_frames": 0
+  },
+  {
+   "class": "DimensionRegistryMapContractTest",
+   "case": "map_emptyLegacy_returnsNullData",
+   "kind": "F",
+   "type": "AssertionError",
+   "msg": "JSON path \"$.data\"\nExpected: null\n     but: was \"\"",
+   "prod_frames": 0
+  },
+  {
+   "class": "DimensionRegistryMapContractTest",
+   "case": "map_missingLegacy_returnsBadRequest",
+   "kind": "F",
+   "type": "AssertionError",
+   "msg": "Status expected:<400> but was:<200>",
+   "prod_frames": 0
+  },
+  {
+   "class": "DailyCheckinStreakFlowTest",
+   "case": "childDeletesCheckin_Success",
+   "kind": "E",
+   "type": "MockitoException",
+   "msg": "Only void methods can doNothing()!\nExample of correct use of doNothing():\n    doNothing().\n    doThrow(new RuntimeException())\n    .when(mock).someVoidMethod();\nAbove means:\nsomeVoidMethod() does noth",
+   "prod_frames": 0
+  },
+  {
+   "class": "EnergySystemFlowTest",
+   "case": "parentQueriesEnergyLogs_Success",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "expected: <200> but was: <500>",
+   "prod_frames": 0
+  },
+  {
+   "class": "EnergySystemFlowTest",
+   "case": "childExchangesWishWithEnergy_Success",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "expected: <200> but was: <500>",
+   "prod_frames": 0
+  },
+  {
+   "class": "EnergySystemFlowTest",
+   "case": "childExchangesWithInsufficientEnergy_Fails",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "expected: <true> but was: <false>",
+   "prod_frames": 0
+  },
+  {
+   "class": "EnergySystemFlowTest",
+   "case": "userWithoutFamily_Fails",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "expected: <500> but was: <5001>",
+   "prod_frames": 0
+  },
+  {
+   "class": "EnergySystemFlowTest",
+   "case": "parentGetsEnergyOverview_Success",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "expected: <200> but was: <500>",
+   "prod_frames": 0
+  },
+  {
+   "class": "EnergySystemFlowTest",
+   "case": "taskCompletionGrantsEnergy_Success",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "expected: <200> but was: <500>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GrowthRecordSyncFlowTest",
+   "case": "parentUploadsHealthReport_Success",
+   "kind": "E",
+   "type": "NullPointerException",
+   "msg": "java.lang.NullPointerException",
+   "prod_frames": 0
+  },
+  {
+   "class": "GrowthRecordSyncFlowTest",
+   "case": "parentCreatesRecordWithoutChild_Fails",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "expected: <true> but was: <false>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GrowthRecordSyncFlowTest",
+   "case": "parentCreatesRecordWithOrder_Success",
+   "kind": "E",
+   "type": "NullPointerException",
+   "msg": "java.lang.NullPointerException",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuideModuleIntegrationTest",
+   "case": "testGuidePackageCreationFlow",
+   "kind": "E",
+   "type": "DataIntegrityViolationException",
+   "msg": "### Error updating database.  Cause: com.mysql.cj.jdbc.exceptions.MysqlDataTruncation: Data truncation: Out of range value for column 'platform_fee' at row 1\n### The error may exist in com/etotem/cfc/",
+   "prod_frames": 0
+  },
+  {
+   "class": "PlannerBindInviteFlowTest",
+   "case": "parentConfirmsBind_Success",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "expected: <200> but was: <403>",
+   "prod_frames": 0
+  },
+  {
+   "class": "PlannerBindInviteFlowTest",
+   "case": "guideViewsBoundFamilies_Success",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "expected: not <null>",
+   "prod_frames": 0
+  },
+  {
+   "class": "PlannerBindInviteFlowTest",
+   "case": "parentBindsSameGuideTwice_Fails",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "expected: <400> but was: <403>",
+   "prod_frames": 0
+  },
+  {
+   "class": "PlannerBindInviteFlowTest",
+   "case": "parentUnbindsGuide_Success",
+   "kind": "E",
+   "type": "MockitoException",
+   "msg": "Only void methods can doNothing()!\nExample of correct use of doNothing():\n    doNothing().\n    doThrow(new RuntimeException())\n    .when(mock).someVoidMethod();\nAbove means:\nsomeVoidMethod() does noth",
+   "prod_frames": 0
+  },
+  {
+   "class": "PlannerBindInviteFlowTest",
+   "case": "completeInviteBindFlow_Success",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "expected: <200> but was: <403>",
+   "prod_frames": 0
+  },
+  {
+   "class": "OrchestrationEngineTest",
+   "case": "testOnTaskFailed_FoundNode_ThenMarkFailedAndEvaluateFlow",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "expected: \"manual_fail\"\n but was: null",
+   "prod_frames": 0
+  },
+  {
+   "class": "OrchestrationIntegrationTest",
+   "case": "testEvaluateFlow_AndNode_AllCompleted_Triggered",
+   "kind": "F",
+   "type": "AssertionError",
+   "msg": "Expecting actual:\n  \"failed\"\nto be in:\n  [\"in_progress\", \"pending\"]",
+   "prod_frames": 0
+  },
+  {
+   "class": "ArticleWorkflowTest",
+   "case": "reDraft_notRejected_pending",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "expected: <仅已驳回的文章可重新编辑> but was: <仅已驳回或已撤回的文章可重新编辑>",
+   "prod_frames": 0
+  },
+  {
+   "class": "ArticleWorkflowTest",
+   "case": "reDraft_notRejected_draft",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "expected: <仅已驳回的文章可重新编辑> but was: <仅已驳回或已撤回的文章可重新编辑>",
+   "prod_frames": 0
+  },
+  {
+   "class": "ArticleWorkflowTest",
+   "case": "reDraft_notRejected_published",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "expected: <仅已驳回的文章可重新编辑> but was: <仅已驳回或已撤回的文章可重新编辑>",
+   "prod_frames": 0
+  },
+  {
+   "class": "CfCommissionServiceSettleTest",
+   "case": "settle_F03a发放_自动创建感恩记录",
+   "kind": "F",
+   "type": "WantedButNotInvoked",
+   "msg": "Wanted but not invoked:\noctopusEffectRecordMapper.insert(<any>);\n-> at com.etotem.cfc.service.CfCommissionServiceSettleTest.settle_F03a发放_自动创建感恩记录(CfCommissionServiceSettleTest.java:419)\nActually, the",
+   "prod_frames": 0
+  },
+  {
+   "class": "CfCommissionServiceSettleTest",
+   "case": "settle_同家庭引荐人跳过_顺延到下一级拿F03a",
+   "kind": "F",
+   "type": "WantedButNotInvoked",
+   "msg": "Wanted but not invoked:\ncfBalanceService.earnByRule(\n    1020L,\n    \"F03a\",\n    1L,\n    <any>\n);\n-> at com.etotem.cfc.service.CfCommissionServiceSettleTest.settle_同家庭引荐人跳过_顺延到下一级拿F03a(CfCommissionServ",
+   "prod_frames": 0
+  },
+  {
+   "class": "CfCommissionServiceSettleTest",
+   "case": "settle_商品订单实付与分润基数分离",
+   "kind": "F",
+   "type": "WantedButNotInvoked",
+   "msg": "Wanted but not invoked:\ncfBalanceService.earnByRule(\n    1010L,\n    \"F03a\",\n    1L,\n    <Capturing argument>\n);\n-> at com.etotem.cfc.service.CfCommissionServiceSettleTest.settle_商品订单实付与分润基数分离(CfCommis",
+   "prod_frames": 0
+  },
+  {
+   "class": "CfCommissionServiceSettleTest",
+   "case": "settle_两级销售奖_F03a按团队档位_F03b按自身档位",
+   "kind": "F",
+   "type": "WantedButNotInvoked",
+   "msg": "Wanted but not invoked:\ncfBalanceService.earnByRule(\n    1010L,\n    \"F03a\",\n    1L,\n    <Capturing argument>\n);\n-> at com.etotem.cfc.service.CfCommissionServiceSettleTest.settle_两级销售奖_F03a按团队档位_F03b按自",
+   "prod_frames": 0
+  },
+  {
+   "class": "CfCommissionServiceSettleTest",
+   "case": "settle_档位比例超规则上限_按上限封顶",
+   "kind": "F",
+   "type": "WantedButNotInvoked",
+   "msg": "Wanted but not invoked:\ncfBalanceService.earnByRule(\n    1010L,\n    \"F03a\",\n    1L,\n    <Capturing argument>\n);\n-> at com.etotem.cfc.service.CfCommissionServiceSettleTest.settle_档位比例超规则上限_按上限封顶(CfComm",
+   "prod_frames": 0
+  },
+  {
+   "class": "CfCommissionServiceSettleTest",
+   "case": "settle_落P点消费日志_基数与订单对应",
+   "kind": "F",
+   "type": "WantedButNotInvoked",
+   "msg": "Wanted but not invoked:\nppointConsumptionLogMapper.insert(\n    <Capturing argument>\n);\n-> at com.etotem.cfc.service.CfCommissionServiceSettleTest.settle_落P点消费日志_基数与订单对应(CfCommissionServiceSettleTest.j",
+   "prod_frames": 0
+  },
+  {
+   "class": "CfCommissionServiceSettleTest",
+   "case": "settle_最多两级_第三级引荐人不发放",
+   "kind": "F",
+   "type": "WantedButNotInvoked",
+   "msg": "Wanted but not invoked:\ncfBalanceService.earnByRule(\n    1010L,\n    \"F03a\",\n    1L,\n    <any>\n);\n-> at com.etotem.cfc.service.CfCommissionServiceSettleTest.settle_最多两级_第三级引荐人不发放(CfCommissionServiceSet",
+   "prod_frames": 0
+  },
+  {
+   "class": "CfCommissionServiceSettleTest",
+   "case": "settle_推荐人分润只走earnByRule_不再写旧分佣流水",
+   "kind": "F",
+   "type": "WantedButNotInvoked",
+   "msg": "Wanted but not invoked:\ncfBalanceService.earnByRule(\n    <any long>,\n    <any string>,\n    <any long>,\n    <any>\n);\n-> at com.etotem.cfc.service.CfCommissionServiceSettleTest.settle_推荐人分润只走earnByRule_",
+   "prod_frames": 0
+  },
+  {
+   "class": "DimensionScoreServiceImplTest",
+   "case": "getHealthDimension_trendUp_whenScoreIncreasedBy5OrMore",
+   "kind": "E",
+   "type": "NoSuchElementException",
+   "msg": "No value present",
+   "prod_frames": 0
+  },
+  {
+   "class": "DimensionScoreServiceImplTest",
+   "case": "getHealthDimension_trendStable_whenNoHistory",
+   "kind": "E",
+   "type": "NoSuchElementException",
+   "msg": "No value present",
+   "prod_frames": 0
+  },
+  {
+   "class": "DimensionScoreServiceImplTest",
+   "case": "getHealthDimension_trendDown_whenScoreDecreasedBy5OrMore",
+   "kind": "E",
+   "type": "NoSuchElementException",
+   "msg": "No value present",
+   "prod_frames": 0
+  },
+  {
+   "class": "DimensionScoreServiceImplTest",
+   "case": "getHealthDimension_trendStable_whenScoreChangeLessThan5",
+   "kind": "E",
+   "type": "NoSuchElementException",
+   "msg": "No value present",
+   "prod_frames": 0
+  },
+  {
+   "class": "MembershipPaySwitchTest",
+   "case": "createOrderWithPayType_virtualEnabled_configMissing_returnsNull",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "expected: <null> but was: <{levelCode=FAMILY, amount=131400, orderNo=ORD17941626330060059610, payType=wechat, wechatPayParams={}, groupNo=null, totalPeriods=1, status=pending, periodNo=1}>",
+   "prod_frames": 0
+  },
+  {
+   "class": "PaymentServiceORDRoutingTest",
+   "case": "unknownPrefix_routesToHandlePaymentCallback",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "expected: <SUCCESS> but was: <FAIL>",
+   "prod_frames": 0
+  },
+  {
+   "class": "UserServiceWechatLoginContractTest",
+   "case": "wechatLogin_NewOpenid_CreatesParentUserWithFamily",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "expected: <jwt-token-1> but was: <null>",
+   "prod_frames": 0
+  },
+  {
+   "class": "UserServiceWechatLoginTest",
+   "case": "首次微信登录_自动建档默认家长并创建家庭与成员",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "expected: not <null>",
+   "prod_frames": 0
+  },
+  {
+   "class": "ArticleAutoTagServiceTest",
+   "case": "toggleStatus_标签非空_不调度自动打标",
+   "kind": "F",
+   "type": "NeverWantedButInvoked",
+   "msg": "threadPoolTaskExecutor.execute(\n    <any java.lang.Runnable>\n);\nNever wanted here:\n-> at com.etotem.cfc.unit.ArticleAutoTagServiceTest.toggleStatus_标签非空_不调度自动打标(ArticleAutoTagServiceTest.java:74)\nBut ",
+   "prod_frames": 0
+  },
+  {
+   "class": "ArticleAutoTagServiceTest",
+   "case": "toggleStatus_发布且标签为空_调度自动打标",
+   "kind": "F",
+   "type": "TooManyActualInvocations",
+   "msg": "threadPoolTaskExecutor.execute(\n    <any java.lang.Runnable>\n);\nWanted 1 time:\n-> at com.etotem.cfc.unit.ArticleAutoTagServiceTest.toggleStatus_发布且标签为空_调度自动打标(ArticleAutoTagServiceTest.java:65)\nBut wa",
+   "prod_frames": 0
+  },
+  {
+   "class": "ArticleAutoTagServiceTest",
+   "case": "auditArticle_审核通过且标签为空_调度自动打标",
+   "kind": "F",
+   "type": "TooManyActualInvocations",
+   "msg": "threadPoolTaskExecutor.execute(\n    <any java.lang.Runnable>\n);\nWanted 1 time:\n-> at com.etotem.cfc.unit.ArticleAutoTagServiceTest.auditArticle_审核通过且标签为空_调度自动打标(ArticleAutoTagServiceTest.java:103)\nBut",
+   "prod_frames": 0
+  },
+  {
+   "class": "ArticleAutoTagServiceTest",
+   "case": "publishByUser_家庭可见直接发布且标签为空_调度自动打标",
+   "kind": "F",
+   "type": "TooManyActualInvocations",
+   "msg": "threadPoolTaskExecutor.execute(\n    <any java.lang.Runnable>\n);\nWanted 1 time:\n-> at com.etotem.cfc.unit.ArticleAutoTagServiceTest.publishByUser_家庭可见直接发布且标签为空_调度自动打标(ArticleAutoTagServiceTest.java:138",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheEvidenceIsolateContractTest",
+   "case": "lowSideRules_exposesRecommendationBasisEvidenceCodes",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.gate.evidenceIsolate] AttributionLowSideRules 缺少 EVIDENCE_PUB 常量:T11(G-3)要求证据码隔离常量(可进推荐依据的 PUB/KB、存在性记录 COM、「未核实」标注),当前未落地。 ==> expected: not <null>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheEvidenceIsolateContractTest",
+   "case": "tagEvidence_pubKb_unchanged",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.gate.evidenceIsolate] 契约方法 AttributionComplianceGate#tagEvidence 未实现(G-3 证据码隔离未落地,T11)",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheEvidenceIsolateContractTest",
+   "case": "canEnterRecommendationBasis_unverified_false",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.gate.evidenceIsolate] 契约方法 AttributionComplianceGate#canEnterRecommendationBasis 未实现(G-3 证据码隔离未落地,T11)",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheEvidenceIsolateContractTest",
+   "case": "tagEvidence_unknownOrBlank_markedUnverified",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.gate.evidenceIsolate] 契约方法 AttributionComplianceGate#tagEvidence 未实现(G-3 证据码隔离未落地,T11)",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheEvidenceIsolateContractTest",
+   "case": "isExistenceRecordOnly_com_true_pubFalse",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.gate.evidenceIsolate] 契约方法 AttributionComplianceGate#isExistenceRecordOnly 未实现(G-3 证据码隔离未落地,T11)",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheEvidenceIsolateContractTest",
+   "case": "canEnterRecommendationBasis_com_false",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.gate.evidenceIsolate] 契约方法 AttributionComplianceGate#canEnterRecommendationBasis 未实现(G-3 证据码隔离未落地,T11)",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheEvidenceIsolateContractTest",
+   "case": "lowSideRules_exposesComAndUnverifiedMarkers",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.gate.evidenceIsolate] AttributionLowSideRules 缺少 EVIDENCE_COM 常量:T11(G-3)要求证据码隔离常量(可进推荐依据的 PUB/KB、存在性记录 COM、「未核实」标注),当前未落地。 ==> expected: not <null>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheEvidenceIsolateContractTest",
+   "case": "canEnterRecommendationBasis_pubKb_true",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.gate.evidenceIsolate] 契约方法 AttributionComplianceGate#canEnterRecommendationBasis 未实现(G-3 证据码隔离未落地,T11)",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheEvidenceIsolateContractTest",
+   "case": "lowSideRules_exposesEvidenceIsolateRuleCode",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.gate.evidenceIsolate] AttributionLowSideRules 缺少 RULE_EVIDENCE_ISOLATE 规则码:T11(G-3)要求独立「证据码隔离」合规规则——低侧机制证据标 PUB/KB;COM 只作存在性记录;未核实编号标「未核实」。当前仅落地 G-1 三规则(association_only / no_causal_bridge / no_",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheEvidenceIsolateContractTest",
+   "case": "gate_exposesTagEvidence",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.gate.evidenceIsolate] AttributionComplianceGate 缺少 tagEvidence(1 参) 方法:T11(G-3)证据码隔离契约要求门禁暴露该确定性方法,当前未落地(仅 G-1 的 enforceLowSide / scan / merge 已实现)。 ==> expected: <true> but was: <false>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheEvidenceIsolateContractTest",
+   "case": "isolateForRecommendation_filtersComAndUnverified",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.gate.evidenceIsolate] 契约方法 AttributionComplianceGate#isolateForRecommendation 未实现(G-3 证据码隔离未落地,T11)",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheGateStrainBlockContractTest",
+   "case": "strainBlockRules_exposesStrainBlockRuleCode",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.gate.strainBlock] 规则层(AttributionLowSideRules / AttributionComplianceGate)缺少 \"no_strain_recommendation\" 规则码常量:T6(G-2)要求独立「16S 粒度拦截 / 菌株级推荐阻断」规则码,区别于 T5 的 association_only/no_causal_bridge/no_pre",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheGateStrainBlockContractTest",
+   "case": "attributionModel_exposesGranularityDimension",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.gate.strainBlock] 归因模型(IndicatorReading/MemberAttribution)未暴露粒度(granularity)维度:T6(G-2)按「granularity != strain」拦菌株号推荐,需在读数/归因结论上区分「菌株级(strain)」与「菌属级/16S(非 strain,默认)」粒度。当前模型无 granularity 字段、无 str",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheGateStrainBlockContractTest",
+   "case": "scan_strainNumberProductRecommendation_flagsStrainBlockViolation",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.gate.strainBlock] L5 scan 未将「含菌株号的商品推荐」回扫为 no_strain_recommendation 违规:T6(G-2)要求 granularity != strain 时禁菌株号推荐,scan 应识别「长双歧杆菌 BB536」等菌株号。当前 scan 仅回扫 G-1 的「缺X桥接 + 处方化」,未落地菌株号识别(实际命中=0 条)。 ==> exp",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheGateStrainBlockContractTest",
+   "case": "complianceGate_exposesEnforceStrainBlockEntry",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.gate.strainBlock] AttributionComplianceGate 缺少 enforceStrainBlock(...) 门禁入口:T6(G-2)要求在 L4 出结论前按粒度拦「含菌株号的商品推荐」,与已落地的 enforceLowSide(G-1)同构。当前门禁仅 enforceLowSide + scan,未落地 T6 菌株级拦截入口。 ==> expected",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheGateStrainBlockContractTest",
+   "case": "enforceStrainBlock_16SGranularityWithStrainNumberProduct_flagsViolation",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.gate.strainBlock] 16S 粒度下含菌株号的商品推荐未被拦(AttributionComplianceGate 缺少 enforceStrainBlock 入口):T6(G-2)要求 granularity != strain 时禁生成含菌株号的商品推荐,「推荐含长双歧杆菌 BB536 的产品」应在门禁被识别为 G-2 菌株级拦截违规。当前未落地。",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheKbFieldExposeContractTest",
+   "case": "speciesInfo_escherichia_lowSideImpact_exposed",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.kb.fieldExpose] 菌种综合信息「埃希氏菌属」偏低侧影响 应为非空(契约 §2.2,T10 L2-3 入 L2 查询返回);当前=null ==> expected: <true> but was: <false>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheKbFieldExposeContractTest",
+   "case": "aidefansiEcoli_colonizationNote_exposed",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.kb.fieldExpose] 平台知识库缺少「艾德范思」来源知识(契约 §2.2 艾德范思大肠杆菌 健康定植说明);艾德范思品牌未落地。 ==> expected: <true> but was: <false>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheKbFieldExposeContractTest",
+   "case": "bacteriaGenus_highLowImpact_exposed",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.kb.fieldExpose] 菌属功能库[埃希氏菌属] 偏高影响 应为非空(契约 §2.2 既有键补齐,T10 L2-3 入 L2 查询返回);当前= ==> expected: <true> but was: <false>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheKbVersionSenseContractTest",
+   "case": "extensionModule_hasElevenSubkeys",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.kb.versionSense] getSection(谷禾2026新版扩展知识) 为空,无法验证 11 子键(T9)。 ==> expected: not <null>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheKbVersionSenseContractTest",
+   "case": "extensionModule_containsLowSidePatchSubkey",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.kb.versionSense] getSection(谷禾2026新版扩展知识) 为空,无法验证低侧补丁块(§2.2)。 ==> expected: not <null>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheKbVersionSenseContractTest",
+   "case": "upgradeScript_repeatRun_idempotentNoAccumulation",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.kb.versionSense] 升级脚本(importFromClasspath)运行后 getSection(谷禾2026新版扩展知识) 应非空。T9 ③:重复运行只覆盖不累积。 ==> expected: not <null>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheKbVersionSenseContractTest",
+   "case": "unifiedKbService_getVersion_containsGuhe2026_2",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.kb.versionSense] UnifiedKnowledgeBaseService.getVersion() 应含 +guhe2026.2(当前=2.0)。T9:KB 服务读取并暴露版本感知标记。 ==> expected: <true> but was: <false>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheKbVersionSenseContractTest",
+   "case": "lowSidePatch_containsJudgmentConcepts",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.kb.versionSense] getSection(谷禾2026新版扩展知识) 为空,无法验证低侧补丁块内容(§2.2)。 ==> expected: not <null>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheKbVersionSenseContractTest",
+   "case": "unifiedKbService_moduleKeys_registerExtensionModule",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.kb.versionSense] UnifiedKnowledgeBaseService.MODULE_KEYS 应注册「谷禾2026新版扩展知识」(当前 12 个模块均不含)。T9:注册后 KB 服务加载时才会读取并暴露。 ==> expected: <true> but was: <false>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheKbVersionSenseContractTest",
+   "case": "seedJson_metaVersion_containsGuhe2026_2",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.kb.versionSense] 统一知识库.json 版本应含 +guhe2026.2(当前=2.0)。契约:3.0+guhe2026.2(主)/ 3.1+guhe2026.2(专家),T9 L2-1 ①。 ==> expected: <true> but was: <false>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheKbVersionSenseContractTest",
+   "case": "seedJson_containsExtensionModule",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.kb.versionSense] 统一知识库.json 顶层应含「谷禾2026新版扩展知识」(当前只有 12 个模块)。T9:KB 读取并暴露扩展知识(§2.2)。 ==> expected: <true> but was: <false>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheKbVersionSenseContractTest",
+   "case": "unifiedKbService_getSection_extensionModule_notNull",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.kb.versionSense] UnifiedKnowledgeBaseService.getSection(谷禾2026新版扩展知识) 应非空。T9:KB 服务读取并暴露扩展块(L2-1 ②)。 ==> expected: not <null>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheLowSideAggregateContractTest",
+   "case": "schemaSql_hasCrossCaseAggregateTable",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.p3.lowSideAggregate] schema.sql 缺少跨案例聚合承载表(表名应含 attribution_case,如 attribution_case_strategies),用于沉淀跨案例「同条件致病菌低侧」应对方案。T15:低侧口径并入既有 P3 聚合口径。当前聚合承载表未落地。 ==> expected: <true> but was: <false>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheLowSideAggregateContractTest",
+   "case": "aggregateService_hasAggregateEntryMethod",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.p3.lowSideAggregate] 跨案例聚合服务类不存在,无法验证聚合入口方法(T15)。当前实现未落地。",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheLowSideAggregateContractTest",
+   "case": "aggregateResult_supportsSameReadingSameStrategy",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.p3.lowSideAggregate] 跨案例聚合服务类不存在,无法验证同策略聚类语义(T15)。当前实现未落地。",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheLowSideAggregateContractTest",
+   "case": "aggregateService_classExists",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.p3.lowSideAggregate] service/attribution 等候选包无跨案例聚合服务类(类名应含 CaseAggregate/CrossCase/CaseStrategy/LowSideAggregate 任一)。T15:低侧口径并入既有 P3 聚合口径,可检索「同条件致病菌低侧」应对方案。当前跨案例聚合实现未落地。 ==> expected: <true> bu",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheLowSideAggregateContractTest",
+   "case": "constantPackage_exposesLowSideReadingKey",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.p3.lowSideAggregate] constant 等候选包应暴露「条件致病菌低侧判读补充(人工)」判读口径常量(§2.2 KB 新增子键),作为跨案例聚合口径的事实锚点。T15:低侧口径并入既有 P3 聚合口径。当前判读口径常量未落地。 ==> expected: <true> but was: <false>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheLowSideNoSupplementContractTest",
+   "case": "lowSideNode_exposesNoSupplementFlag",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.lowSide.noSupplement] AttributionComplianceGate.LowSideNode 缺少 isNoSupplement() 节点级禁补菌标记:T4 要求「偏低」归因节点在源头即标注不推荐补菌,而非仅门禁事后回扫。 ==> expected: <true> but was: <false>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheLowSideNoSupplementContractTest",
+   "case": "interpret_lowSideEColi_meaningCarriesNoSupplementRedline",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.lowSide.noSupplement] 偏低大肠杆菌节点干预含义应携带「偏低不推荐补菌」口径(T4 L4-3,无成熟证据支持缺X→补X),实际为:「大肠杆菌 偏离 → 与肠道环境/营养缺口相关,先医后补」。当前仅产通用「先医后补」,未落地 T4 源头禁补菌。 ==> expected: <true> but was: <false>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheLowSideNoSupplementContractTest",
+   "case": "lowSideRules_exposesNoSupplementRuleCode",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.lowSide.noSupplement] AttributionLowSideRules 缺少 RULE_NO_SUPPLEMENT 规则码:T4(L4-3)要求独立「低位补菌红线」——无成熟证据支持「缺大肠杆菌→补大肠杆菌」。当前仅落地 T5 的 no_causal_bridge,未区分「引擎不产出补菌建议」与「文本回扫」。 ==> expected: not <null>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheLowSideNoSupplementContractTest",
+   "case": "lowSideRules_exposesNoSupplementHintWording",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.lowSide.noSupplement] AttributionLowSideRules 缺少 NO_SUPPLEMENT_HINT 口径常量:T4 要求「偏低不推荐补菌」显式口径(无成熟证据支持缺X→补X),供 L4 引擎与报告侧引用,而非仅通用「先医后补」。 ==> expected: not <null>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheNoPathMapContractTest",
+   "case": "mediationChain_gutFloraWisdom_ladderLockedWording",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "RED[guhe.l4.noPathMap] 契约方法 com.etotem.cfc.service.attribution.FiveDimAttributionEngine#mediationChain 未实现(T14 五维 no_path 映射未落地)",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheNoPathMapContractTest",
+   "case": "attribute_gutFloraReading_wealthNoPenalty_red",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "菌群观测对「富」维 no_path:异常菌群读数不应使富维扣分(保持基数 78.0),实际 66.8 ==> expected: <78.0> but was: <66.8>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheNoPathMapContractTest",
+   "case": "isNoPath_gutFloraMind_false",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "RED[guhe.l4.noPathMap] 契约方法 com.etotem.cfc.service.attribution.FiveDimAttributionEngine#isNoPath 未实现(T14 五维 no_path 映射未落地)",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheNoPathMapContractTest",
+   "case": "isNoPath_gutFloraWealth_true",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "RED[guhe.l4.noPathMap] 契约方法 com.etotem.cfc.service.attribution.FiveDimAttributionEngine#isNoPath 未实现(T14 五维 no_path 映射未落地)",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheNoPathMapContractTest",
+   "case": "isNoPath_gutFloraAction_true",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "RED[guhe.l4.noPathMap] 契约方法 com.etotem.cfc.service.attribution.FiveDimAttributionEngine#isNoPath 未实现(T14 五维 no_path 映射未落地)",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheNoPathMapContractTest",
+   "case": "isNoPath_gutFloraBody_false",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "RED[guhe.l4.noPathMap] 契约方法 com.etotem.cfc.service.attribution.FiveDimAttributionEngine#isNoPath 未实现(T14 五维 no_path 映射未落地)",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheNoPathMapContractTest",
+   "case": "mediationChain_gutFloraMind_ladderLockedWording",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "RED[guhe.l4.noPathMap] 契约方法 com.etotem.cfc.service.attribution.FiveDimAttributionEngine#mediationChain 未实现(T14 五维 no_path 映射未落地)",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheNoPathMapContractTest",
+   "case": "noPathMap_gutFlora_wealthAndActionMarked",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "RED[guhe.l4.noPathMap] 契约方法 com.etotem.cfc.service.attribution.FiveDimAttributionEngine#noPathMap 未实现(T14 五维 no_path 映射未落地)",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheNoPathMapContractTest",
+   "case": "attribute_gutFloraReading_actionNoPenalty_red",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "菌群观测对「行」维 no_path:异常菌群读数不应使行维扣分(保持基数 70.0),实际 58.8 ==> expected: <70.0> but was: <58.8>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheNoPathMapContractTest",
+   "case": "attribute_lowSideGutFlora_weakExcludesWealthAndAction_red",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "非身维遮挡:菌群低侧读数不应使「行」成弱维(no_path),weak=[身, 行] ==> expected: <false> but was: <true>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuhePageTailCleanContractTest",
+   "case": "l1Layer_declaresTailAssertRegex",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.l1.pageTailClean] L1 接入归一化层应声明残段断言正则常量(静态 String 常量,值含 /16\\d 即 \\d{1,3}/16\\d 模式,用于校验层断言物种库条目不含 \\d{1,3}/16\\d,T12/L1-2)。当前无残段断言正则,无法对清洗结果做「残段 0 命中」的程序化校验。 ==> expected: <true> but was: <false>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuhePageTailCleanContractTest",
+   "case": "l1Layer_providesTailCleanEntry",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.l1.pageTailClean] L1 接入归一化层(PdfParseService / ReportParserDispatchService / ReportFingerprintService / ParsedReportResult)应提供分页残段清洗执行入口(方法名含 TableTailClean / PageTail / StripTail,如 stripTableTai",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuhePageTailCleanContractTest",
+   "case": "l1Layer_providesTailCleanAssertEntry",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.l1.pageTailClean] L1 接入归一化层应提供残段清洗校验断言入口(方法名含 AssertTailClean / ValidateTail / AssertClean),断言清洗后产物「残段 0 命中」(§2.1 清洗后 76 条、残段 0 命中;T12/L1-2 校验层断言)。当前无残段断言步骤,无法验证清洗效果。 ==> expected: <true> but wa",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuhePageTailCleanContractTest",
+   "case": "l1Layer_declaresPageTailMarker",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.l1.pageTailClean] L1 接入归一化层应声明 _PAGE_TAIL 残段标记常量(静态 String 常量,字段名/值含 PAGE_TAIL / 分页 / 残段,值含残段样例 34/167),作为分页表拼接残段的识别依据(§2.1 / T12)。当前无残段标记常量,残段无法被识别与清洗。 ==> expected: <true> but was: <false>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheShellGuardContractTest",
+   "case": "l1Layer_recognizesEmptyShell26",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.l1.shellGuard] L1 层应识别空壳签名 26 键(静态 int 常量 26,曾误用艾德范思提取器仅产 26 键),判为空壳后换正确提取器(T13)。当前无空壳签名检测,26 键空壳将不被识别。 ==> expected: <true> but was: <false>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheShellGuardContractTest",
+   "case": "l1Layer_declaresGuheV2KeyBoundary32",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.l1.shellGuard] L1 层应声明 guhe_v2 总键数 32(静态 int 常量 32,§2.1 旧 21 键 + 11 增量键),以区分完整 guhe_v2 报告(32 键)与空壳(不足 32 键)。当前无 32 键边界(T13/L1-4)。 ==> expected: <true> but was: <false>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheShellGuardContractTest",
+   "case": "l1Layer_declaresIncrementalKeys11",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.l1.shellGuard] L1 层应声明增量 11 键基准(静态 String 键集合常量 size=11 且字段名含 KEY/KEYS/INCREMENTAL/增量,或静态 int 常量 11),作为 guhe_v2 解析后校验『增量 11 键齐全』的基准(§2.1 旧 21 键 + 11 增量键 = 32 键)。当前无增量键基准,无法判定空壳(T13/L1-4)。 ==> ex",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheShellGuardContractTest",
+   "case": "dispatchService_providesShellGuardEntry",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.l1.shellGuard] ReportParserDispatchService 应提供空壳守卫入口(方法名含 Shell / KeyComplete / ValidateKeys / AssertKeys,如 guardShell),在 guhe_v2 解析后校验增量 11 键齐全(T13/L1-4)。当前 parseReport 仅做 指纹→专用解析器/LLM 兜底,无空壳检测",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheShellGuardContractTest",
+   "case": "l1Layer_addressesCorrectExtractor",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.l1.shellGuard] L1 层(ReportParserDispatchService / ReportFingerprintService / PdfParseService)应寻址正确提取器(静态 String 常量值含 \"extract_guhe_v2\"),在空壳(26 键)检测到时切换用正确提取器(T13『空壳→换正确提取器』)。当前无 extract_guhe_v2 ",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheV2RouteContractTest",
+   "case": "routeLayer_registersTriggerWord_firmicutesBacteroidetesRatio",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.l1.guheV2Route] L1 指纹路由层应可寻址 guhe_v2 触发词锚点「厚壁菌比拟杆菌比值」(静态 String 常量值含之,T7 指纹识别特征,§2.1)。当前 src/main 无此触发词,32 键 guhe_v2 新版报告无法被指纹识别路由。 ==> expected: <true> but was: <false>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheV2RouteContractTest",
+   "case": "routeLayer_providesGuheV2KeyCountSelfCheck",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.l1.guheV2Route] L1 路由层应提供 guhe_v2 <b>32 键自检</b>入口(方法名含 keyCount/selfCheck,或静态 int 常量名含 GUHE_V2 且值为 32)。契约:旧 21 键打底 + 11 增量键 = 32 键,路由/解析后须自检键数齐全(T7 键数自检 32 键,§2.1)。当前无自检入口,32 键报告键数完整性无法保证。 ==> e",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheV2RouteContractTest",
+   "case": "routeLayer_declaresRouteChainOrder",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.l1.guheV2Route] L1 指纹路由层应声明判定链顺序常量(值含 aidefansi→beijing→guhe_v2→standard 完整链),保证 guhe_v2 在 beijing 之后、standard 之前被判定(T7/L1-1,§2.1)。当前未声明,32 键新版报告可能被误判为标准格式而丢失增量 11 键。 ==> expected: <true> but wa",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheV2RouteContractTest",
+   "case": "routeLayer_registersTriggerWord_diseaseSimilarAssessment",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.l1.guheV2Route] L1 指纹路由层应可寻址 guhe_v2 触发词锚点「疾病相似评估」(静态 String 常量值含之,T7 指纹识别特征,§2.1)。当前 src/main 无此触发词,32 键 guhe_v2 新版报告无法被指纹识别路由。 ==> expected: <true> but was: <false>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheV2RouteContractTest",
+   "case": "routeLayer_registersTriggerWord_subhealthAndDietUnreasonable",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.l1.guheV2Route] L1 指纹路由层应可寻址 guhe_v2 触发词锚点「亚健康及营养饮食不合理」(静态 String 常量值含之,T7 指纹识别特征,§2.1)。当前 src/main 无此触发词,32 键 guhe_v2 新版报告无法被指纹识别路由。 ==> expected: <true> but was: <false>",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheV2RouteContractTest",
+   "case": "routeLayer_registersGuheV2Stage",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.l1.guheV2Route] L1 指纹路由层(PdfParseService / ReportFingerprintService / ReportParserDispatchService)应注册判定阶段「guhe_v2」(静态 String 常量值含之),使判定链 aidefansi→beijing→guhe_v2→standard 的阶段可寻址(T7/L1-1)。当前 src",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheVersionSplitContractTest",
+   "case": "l1Layer_addressesGuheV2DedicatedParser",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.l1.versionSplit] L1 层(ReportParserDispatchService / ReportFingerprintService)应寻址 guhe_v2 专用解析器(字符串常量值含 \"extract_guhe_v2\"),使 32 键 guhe_v2 报告进入专用解析分支,**不落入** unknown→LLM 兜底(T8 禁把 32 键误判成路由错误)。当前 g",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheVersionSplitContractTest",
+   "case": "l1Layer_declaresLegacy21KeyBoundary",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.l1.versionSplit] L1 层应声明 21/32 键数边界(静态 int 常量 21 与 32 并存),以区分旧 21 键与原 v5 流程、32 键走 guhe_v2(§2.1 旧 21 键 + 11 增量 = 32 键)。当前无键数边界标记,旧 21 键无法与 guhe_v2 隔离,存在被强套 guhe_v2 风险(T8 禁把旧 21 键强套 guhe_v2)。 ==> ",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheVersionSplitContractTest",
+   "case": "parsedReportResult_exposesGuheV2FormatMarker",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.l1.versionSplit] ParsedReportResult 应暴露 guhe_v2 格式版本标记(静态字符串常量值为 \"guhe_v2\"),使新版 32 键格式与旧 beijing/guhe 可区分(§2.1 判定链 aidefansi→beijing→guhe_v2→standard)。当前实现仅 beijing/guhe,格式版本分流未落地。 ==> expected:",
+   "prod_frames": 0
+  },
+  {
+   "class": "GuheVersionSplitContractTest",
+   "case": "dispatchService_providesFormatVersionSplitEntry",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "[guhe.l1.versionSplit] ReportParserDispatchService 应提供按报告格式版本分流入口(方法名含 FormatVersion / VersionSplit,如 routeByFormatVersion),按『报告格式版本』分流:32 键→guhe_v2 提取器、旧 21 键→原 v5 流程(T8/L1-3)。当前 parseReport 仅做 指纹→专用",
+   "prod_frames": 0
+  },
+  {
+   "class": "ServiceRoleApproveTest",
+   "case": "planner_已有同手机号admin则更新不重复插入",
+   "kind": "E",
+   "type": "NullPointerException",
+   "msg": "java.lang.NullPointerException",
+   "prod_frames": 0
+  },
+  {
+   "class": "ServiceRoleApproveTest",
+   "case": "planner_通过后写teacher_status并创建admins记录",
+   "kind": "E",
+   "type": "NullPointerException",
+   "msg": "java.lang.NullPointerException",
+   "prod_frames": 0
+  },
+  {
+   "class": "TaskPlanServiceTest",
+   "case": "applyAndActivate_无active实例时新建并置active",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "expected: <100> but was: <null>",
+   "prod_frames": 0
+  },
+  {
+   "class": "TeamProfitCronContractTest",
+   "case": "refundWithinCooling_cancelAllRecordsFundsNotPaid",
+   "kind": "F",
+   "type": "AssertionFailedError",
+   "msg": "冷静期内订单退款:对应 team_share_record / team_award_record 全部 cancelled、资金不发放(§8.2),cancelled=0 ==> expected: <true> but was: <false>",
+   "prod_frames": 0
+  }
+ ],
+ "identity": {
+  "at_test": 2741,
+  "parameterized_methods": 1,
+  "disabled_classes": [
+   "DataMigrationServiceTest",
+   "TaskServiceMinigameTest",
+   "TeacherLoginTest"
+  ],
+  "disabled_inner": 20,
+  "placeholders": 3,
+  "base_executed": 2724,
+  "actual_executed": 2753,
+  "param_expansion_net": 29
+ },
+ "silent_missing": []
+}

+ 205 - 0
cfc-frontend/__tests__/pages/signature/signature.canvas2d.test.js

@@ -0,0 +1,205 @@
+/**
+ * 契约级单测 —— 手签名实现方式重写(竖屏直签 + canvas 2d,能力键 signature.canvas2d.rewrite)
+ *
+ * 溯源:
+ *   SRS:  docs/superpowers/specs/2026-11-09-signature-canvas2d-rewrite.md(DOC-2026-11-09-signature-canvas2d-rewrite,待开发)
+ *   取代: docs/superpowers/specs/2026-11-08-signature-landscape-fix.md(整份作废,勿按旧实现断言)
+ *   台账: tests/test-cases/signature.md(TC-SIG2D-01~08)
+ *
+ * 覆盖(v2 任务书 §三):
+ *   S4-1 竖屏直签(signature.vue)
+ *     TC-SIG2D-01  删除 enterLandscape()/restorePortrait() 方法
+ *     TC-SIG2D-01b onLoad/onUnload 不再调用横屏方法;模板删 h5DegradeHint/.rotate-hint
+ *     TC-SIG2D-02  computeCanvasHeight 保留竖屏计算(174/214 两档 + 160 下限)
+ *   S4-2 canvas 2d(hand-signature.vue)
+ *     TC-SIG2D-03  模板 :id + type="2d"(非 canvas-id)
+ *     TC-SIG2D-03b data 含 canvasId='hsCanvas_'+(_uid||0) 与 _canvasNode:null
+ *     TC-SIG2D-04  mounted 走 createSelectorQuery().in(this).select().fields({node:true,...})
+ *     TC-SIG2D-05  触摸坐标优先 t.x/t.y,兜底 clientX-rectLeft(不依赖旧 getPos 缓存 rect)
+ *     TC-SIG2D-06  onTouchMove 同步 ctx.stroke() 绘制(无 ctx.draw)
+ *     TC-SIG2D-07  导出 canvasToTempFilePath({canvas:_canvasNode});清空 ctx.clearRect
+ *   S4-3 不回归
+ *     TC-SIG2D-08  组件对外契约 props/emit 不变(value/strokeColor/strokeWidth/height/requireAgree/agreeText + confirm/clear/agree-change)
+ *
+ * 本文件只读源码并求值 script 段,不修改任何业务代码。
+ * 当前实现 = v2「待开发」,本文件内新契约断言应为 RED(除 02/08 保留不变的既有契约)。
+ */
+
+const fs = require('fs')
+const path = require('path')
+
+const PAGE = path.join(__dirname, '../../../pages/signature/signature.vue')
+const PAD = path.join(__dirname, '../../../components/hand-signature/hand-signature.vue')
+
+function readScript(file) {
+  const src = fs.readFileSync(file, 'utf8')
+  const m = src.match(/<script>([\s\S]*?)<\/script>/)
+  if (!m) throw new Error('no <script> in ' + file)
+  return m[1]
+}
+
+function readTemplate(file) {
+  const src = fs.readFileSync(file, 'utf8')
+  const m = src.match(/<template>([\s\S]*?)<\/template>/)
+  if (!m) return ''
+  return m[1]
+}
+
+// import 语句 -> 同名空桩变量(保留脚本内引用,避免 ReferenceError)
+function stubImports(code) {
+  return code.replace(/import\s+([\s\S]*?)\s+from\s+['"][^'"]+['"];?/g, function (_all, clause) {
+    const ids = String(clause)
+      .replace(/[{}]/g, ' ')
+      .split(',')
+      .map(function (x) {
+        const parts = x.trim().split(/\s+as\s+/)
+        return parts[parts.length - 1].trim()
+      })
+      .filter(function (x) {
+        return /^[A-Za-z_$][\w$]*$/.test(x)
+      })
+    return ids
+      .map(function (id) {
+        return 'var ' + id + ' = function () {};'
+      })
+      .join(' ')
+  })
+}
+
+function loadOptions(file, sandbox) {
+  let code = stubImports(readScript(file)).replace(/export\s+default/, 'var __opts__ =')
+  code = code + '\nreturn __opts__'
+  const names = Object.keys(sandbox)
+  // eslint-disable-next-line no-new-func
+  const fn = new Function(names.join(','), code)
+  return fn.apply(
+    null,
+    names.map(function (n) {
+      return sandbox[n]
+    })
+  )
+}
+
+function makePage(windowHeight, requireAgree) {
+  const opts = loadOptions(PAGE, {
+    uni: {
+      getWindowInfo: function () {
+        return { windowHeight: windowHeight }
+      },
+    },
+    wx: undefined,
+    window: undefined,
+  })
+  const data = typeof opts.data === 'function' ? opts.data() : opts.data
+  const vm = Object.assign({}, data, opts.methods)
+  vm.requireAgree = !!requireAgree
+  vm.$refs = {}
+  return vm
+}
+
+function styleBlock(selector) {
+  const src = fs.readFileSync(PAGE, 'utf8')
+  const i = src.indexOf(selector + ' {')
+  if (i < 0) return ''
+  const open = src.indexOf('{', i)
+  return src.slice(open + 1, src.indexOf('}', open))
+}
+
+describe('签名实现重写 v2(竖屏直签 + canvas 2d)· signature.canvas2d.rewrite', function () {
+  const pageScript = readScript(PAGE)
+  const pageTpl = readTemplate(PAGE)
+  const padScript = readScript(PAD)
+  const padTpl = readTemplate(PAD)
+
+  // ---------- S4-1 竖屏直签(signature.vue) ----------
+
+  test('TC-SIG2D-01 删除横屏方法 enterLandscape/restorePortrait', function () {
+    expect(pageScript).not.toMatch(/enterLandscape\s*:/)
+    expect(pageScript).not.toMatch(/restorePortrait\s*:/)
+    // 全文件不应再引用该方法名(含 onLoad/onUnload 调用点与注释外的 toString)
+    expect(pageScript).not.toMatch(/enterLandscape\(/)
+    expect(pageScript).not.toMatch(/restorePortrait\(/)
+  })
+
+  test('TC-SIG2D-01b onLoad/onUnload 不再调用横屏依赖;模板删除 h5DegradeHint/.rotate-hint', function () {
+    // 页面模板不再渲染 H5 降级提示块
+    expect(pageTpl).not.toMatch(/h5DegradeHint/)
+    expect(pageTpl).not.toMatch(/rotate-hint/)
+    // 样式块中无 .rotate-hint
+    expect(styleBlock('.rotate-hint')).toBe('')
+  })
+
+  test('TC-SIG2D-01c 不再调用 wx.setPageOrientation / screen.orientation.lock', function () {
+    expect(pageScript).not.toMatch(/setPageOrientation/)
+    expect(pageScript).not.toMatch(/orientation\.lock/)
+  })
+
+  test('TC-SIG2D-02 computeCanvasHeight 保留竖屏计算(174/214 两档 + 160 下限)', function () {
+    expect(makePage(390, false).computeCanvasHeight()).toBe(390 - 174)
+    expect(makePage(390, true).computeCanvasHeight()).toBe(390 - 214)
+    expect(makePage(200, true).computeCanvasHeight()).toBe(160)
+  })
+
+  // ---------- S4-2 canvas 2d(hand-signature.vue) ----------
+
+  test('TC-SIG2D-03 模板 canvas 改 :id + type="2d"(不再 canvas-id)', function () {
+    expect(padTpl).toMatch(/<canvas[^>]*type="2d"/)
+    expect(padTpl).toMatch(/<canvas[^>]*:id="/)
+    expect(padTpl).not.toMatch(/canvas-id="/)
+  })
+
+  test('TC-SIG2D-03b data 含 canvasId 唯一化与 _canvasNode', function () {
+    const opts = loadOptions(PAD, { uni: {}, wx: undefined, window: undefined })
+    const data = typeof opts.data === 'function' ? opts.data() : opts.data
+    expect(data).toHaveProperty('_canvasNode', null)
+    // canvasId 赋值应含 "(this._uid || 0)" 保证多实例唯一
+    expect(String(data.canvasId)).toMatch(/hsCanvas_.*_uid/)
+  })
+
+  test('TC-SIG2D-04 mounted 用 createSelectorQuery().in(this).select("#canvasId").fields({node:true,size:true,rect:true})', function () {
+    // 参照 RadarChart:createSelectorQuery().in(this).select('#'+canvasId).fields({node:true,...})
+    expect(padScript).toMatch(/createSelectorQuery\(\s*\)\s*\.in\(this\)/)
+    expect(padScript).not.toMatch(/\.select\(['"]\.signature-canvas['"]\)/)
+    expect(padScript).toMatch(/select\(\s*['"]#['"]\s*\+/)
+    expect(padScript).toMatch(/fields\(\s*\{\s*node\s*:\s*true/)
+  })
+
+  test('TC-SIG2D-05 触摸坐标优先 t.x/t.y(2d 相对画布),兜底 clientX-_rectLeft', function () {
+    // v2:var t = e.touches && e.touches[0]; var x = (t && t.x) || 0; y 同理
+    // 兜底:t.x === undefined 时用 t.clientX - _rectLeft(fields info.left 存储)
+    expect(padScript).toMatch(/\bt\.x\b/)
+    expect(padScript).toMatch(/\bt\.y\b/)
+    expect(padScript).toMatch(/_rectLeft|_rectTop/)
+  })
+
+  test('TC-SIG2D-06 onTouchMove 同步绘制 ctx.stroke(),无异步 ctx.draw()', function () {
+    const opts = loadOptions(PAD, { uni: {}, wx: undefined, window: undefined })
+    const jit = opts.methods.onTouchMove.toString()
+    expect(jit).toMatch(/\.stroke\(\)/)
+    expect(jit).not.toMatch(/\.draw\(/)
+  })
+
+  test('TC-SIG2D-07 导出传 canvas 节点;清空用 ctx.clearRect(同步)', function () {
+    expect(padScript).toMatch(/canvasToTempFilePath\(\s*\{[^}]*canvas\s*:\s*this\._canvasNode/m)
+    // 清空:ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
+    expect(padScript).toMatch(/clearRect\(/)
+    expect(padScript).not.toMatch(/clearRect[\s\S]{0,80}ctx\.draw\(\)/)
+  })
+
+  // ---------- S4-3 不回归 ----------
+
+  test('TC-SIG2D-08 组件对外契约不变(props/emit 保持,三调用方无感)', function () {
+    const opts = loadOptions(PAD, { uni: {}, wx: undefined, window: undefined })
+    const props = opts.props
+    expect(props).toHaveProperty('value')
+    expect(props).toHaveProperty('strokeColor')
+    expect(props).toHaveProperty('strokeWidth')
+    expect(props).toHaveProperty('height')
+    expect(props).toHaveProperty('requireAgree')
+    expect(props).toHaveProperty('agreeText')
+    const src = padScript
+    expect(src).toMatch(/\$emit\(['"]confirm['"]/)
+    expect(src).toMatch(/\$emit\(['"]clear['"]/)
+    expect(src).toMatch(/\$emit\(['"]agree-change['"]/)
+  })
+})

+ 0 - 0
cfc-frontend/__tests__/pages/signature/signature.landscape.test.js → cfc-frontend/__tests__/pages/signature/signature.landscape.superseded.js


+ 79 - 0
docs/tdd/orchestration/report-test-2026-11-09-0320.md

@@ -0,0 +1,79 @@
+# 测试负责人汇报 · R95(2026-11-09 03:20)
+
+> 执行明细见 `docs/tdd/test/automation-run-2026-11-09-R95.md`;本文件面向研发/运维负责人,汇报执行结果与催办项。
+
+## 一、执行总览
+
+| 维度 | R95 结果 | R94 基线 | 变化 |
+|---|---|---|---|
+| 后端全量 | **2753 / 2634 过 / 115 红 / 4 skip = 95.68%** | 2664 / 2611 / 49 / 4 = 98.01% | +89 例 / +66 红 / **-2.33pp** |
+| cfc-frontend jest | **408 / 365 / 41 / 2 skip** | 394 / 369 / 24 / 1 | +14 例 / +17 红 |
+| cfc-web jest | **116 / 116 / 0 / 0** | 116 / 116 / 0 / 0 | 零漂移 |
+| tests/ 根级 jest | **23 / 7 / 16 / 0** | 23 / 7 / 16 / 0 | 零漂移(B-36) |
+| 浏览器 live(admin+frontend+shop) | **15/15 全绿** | 15/15 | 零漂移 |
+| 浏览器 health-contract | **13/13 全绿** | 13/13 | 零漂移 |
+| 浏览器 D/X 桥接 | **2 skipped**(404 未部署) | 2 skipped | 零漂移 |
+| langgraph 本机 pytest | 44 / 32 / 12 | 44 / 32 / 12 | 零漂移 |
+| **langgraph 251 实派 pytest** | **44 / 36 / 8** | — | **指令首次实质落地** |
+| **251 `chat` live 端到端** | **200 完整问答 JSON** | 挂起(B-77) | **B-77 解除** |
+| 251 `cfc-langgraph` 源码 | **存在(HEAD b6145f156)** | 不存在(B-78) | **B-78 解除** |
+
+## 二、后端 +66 红归因(关键)
+
+**11 个 Guhe 契约类(66 红)全部来自研发 HEAD `a80a6115a`「19 RED 测试侧契约批次在途」**,最近提交 `d5c656cf3`(11-09 01:19 merge S-cfc-81)入库——属**预期 RED**,与生产回归无关。
+
+非 Guhe 部分 = **20 类 / 38F+11E = 49 红**,与 R94 的 49 红(38F+11E)**数值完全一致**:既有 A 类测试装置缺陷、B-69 产品缺陷、B-13 环境红、D 类待裁决项均未变化。
+
+## 三、本轮闭环 / 解除的关键项
+
+### B-77 解除(251 chat 挂起)
+R94 两次探测 30s/60s 挂起 → R95 经 251 派发逐级探测:422(参数缺)→ 422(user_id 类型)→ **200 完整问答 JSON**(儿童成长家庭助手应答)。live 端到端可用。
+
+### B-78 解除(251 无 langgraph 源码)
+R94 记「`/sc-data/cfc/cfc-langgraph` 不存在」→ R95 实探确认 **git repo 就位(HEAD b6145f156,02:10 更新,含 tests/.venv/bin/pytest,Python 3.11.13)**,并**实派 pytest:44/36/8/71.7s**——langgraph 派 251 指令首次实质落地。
+
+### B-56 根因定位(251 opencode 派发通道)
+上轮派发 `outcome=failed/tokens=0`(模型未被调用即失败)根因=本地脚本默认 provider=`axon` 而 251 opencode 配置实际含 agnes/etotem/etotem-llm/zcode,无 axon。改用 `etotem/deepseek` 后 OUTCOME succeeded、tokens 正常(input 2094/output 1434)。**脚本已修正 provider + 识别 succeeded 双态 + 拉 assistant 文本**,本轮后续派发(探活、存在性直探、pytest 实派、chat live 三层探测)全部成功回传。
+
+### B-75 作废(旧横屏契约)
+v2 SRS `DOC-2026-11-09-signature-canvas2d-rewrite` 整份取代 11-08 横屏方案。测试侧按 SRS 变更更新用例:
+- 新增 `signature.canvas2d.test.js`(11 例:9 RED 新契约 + 2 GREEN 保留契约)
+- 旧横屏 `signature.landscape.test.js` 改名 `.superseded.js` 归档(移除 3 例验证已作废 S1/S2/S3 的假红)
+- `tests/test-cases/signature.md` 更新为 v2 契约台账
+- **新登记 B-81:签名 v2 契约 RED 9 例(预期 RED,待研发实现 S4-1/S4-2)**
+
+## 四、未闭环 P0/P1 催办(研发)
+
+| 优先级 | 编号 | 问题 | 状态 |
+|---|---|---|---|
+| **P0** | B-65 | `/api/v1/analyze` 恒 500(checkpointer 缺 thread_id) | **连续第 5 轮**(251 pytest 复现) |
+| **P0** | B-69 | `/api/mind/` 匿名仍非 401(`LOGIN_REQUIRED_PATHS` 缺 `/api/mind/`) | **连续第 5 轮**(后端 2753 例复现) |
+| P1 | B-73 | S-cfc-79 diet 24 例 RED(`recommendation.vue` 零实现) | 待实现 |
+| P1 | — | S-cfc-80 participantSelect 8 例 RED(`recommendation.vue` 未实现) | 待实现 |
+| P1 | **B-81(新)** | 签名 v2 契约 9 例 RED(S4-1/S4-2 未实施) | 待实现 |
+| P1 | B-54 | langgraph 本机 chromadb DLL 缺失 + LLM 凭据无效 | 环境,251 已证明非生产缺陷 |
+
+## 五、测试侧自处置
+
+- B-56:本地派发脚本 provider 修正 + 结果解析修复(识别 succeeded + 拉 assistant 文本)。
+- B-79:DX 桥接鉴权 200 一律失败、404 记 skip 哨兵修正已在 R94 完成,本轮维持。
+- B-75 系列接替:旧横屏契约移除 + v2 契约落地 + 台账重写。
+- B-82 新登记:intent 4 例 251 绿/本机红(API key 401)环境差异记录,权威口径以 251 为准。
+
+## 六、下轮计划
+
+1. 研发若修复 B-65/B-69(后端源码级),测试侧 251+本机双通道定向复跑 `test_analyze` 2 例 + `JwtInterceptorLoginRequiredPathTest` + `/api/mind/` 匿名契约。
+2. 签名 v2 实现(S4-1/S4-2)落地后,`signature.canvas2d.test.js` 9 RED 定向复跑。
+3. S-cfc-79 diet 24 例 + S-cfc-80 participantSelect 8 例随 `recommendation.vue` 实现复跑。
+4. 251 派发通道已打通,下轮将 langgraph pytest 与 chat live 探测纳入常规执行项,作为生产实例权威口径。
+5. DX 桥接 404(2 skip)待部署验证。
+
+## 七、证据清单
+
+- 后端:`_r95_backend.log`、`_r95_agg.json`
+- 本机 langgraph:`_r95_pytest.log`
+- 251 派发会话:`ses_e439748c6ffeFGAb34m2J5w9Mp`(pytest 44/36/8)、`ses_e439319d1ffejz7626dCYXFa85`(chat 200)
+- 前端:`/tmp/fe_jest2.log`
+- 签名 v2:`cfc-frontend/__tests__/pages/signature/signature.canvas2d.test.js`
+- 台账:`docs/tdd/test/blockers.md`(B-77/B-78-Close、B-56/B-75-Update、B-81/B-82 新增)
+- 执行明细:`docs/tdd/test/automation-run-2026-11-09-R95.md`

+ 89 - 0
docs/tdd/test/automation-run-2026-11-09-R95.md

@@ -0,0 +1,89 @@
+# 自动化测试执行记录 · R95(2026-11-09 01:00 ~ 03:20)
+
+> 汇报版见 `docs/tdd/orchestration/report-test-2026-11-09-0320.md`;本文件只记可复现的执行事实与证据路径。
+
+## 0. 基线与环境
+
+| 项 | 值 |
+|---|---|
+| 仓库/分支 | `d:\cfc` · `cfclub` |
+| 起始 SHA | `a80a6115a`(= `origin/cfclub`,同步后一致) |
+| 工作区 | 同步时保护 WIP(stash)→ checkout cfclub → pull --rebase;源码级 WIP 0 |
+| JDK / Maven | `D:\jdk1.8.0_25` / IDEA 内置 Maven |
+| 节点分工 | 本机(204):浏览器串行 + 后端全量 + 前端 jest + 根级 jest + langgraph 兜底 pytest;251:opencode 派发 langgraph pytest + chat live 复核 |
+
+## 1. 后端全量(`mvn -o -B clean test`)
+
+```
+Tests run: 2753, Failures: 104, Errors: 11, Skipped: 4      → passed 2634 = 95.68%
+classes=328 red_classes=31 red_cases=115
+BUILD FAILURE(仅测试失败,MVN_EXIT=1)
+证据:_r95_backend.log、_r95_agg.json(工具 docs/tdd/tools/test_report_metrics.py 聚合)
+```
+
+- 相比 R94(2664 例 / 49 红 / 20 红类 / 98.01%):**+89 例 / +66 红 / +11 红类 / -2.33pp**。
+- 新增 66 红全部来自 **11 个 Guhe 契约类**(`GuheEvidenceIsolateContractTest` 11F、`GuheNoPathMapContractTest` 10F、`GuheKbVersionSenseContractTest` 9F、`GuheV2RouteContractTest` 6F、`GuheShellGuardContractTest` 5F、`GuheGateStrainBlockContractTest` 5F、`GuheLowSideAggregateContractTest` 5F、`GuheKbFieldExposeContractTest` 3F、`GuheVersionSplitContractTest` 4F、`GuheLowSideNoSupplementContractTest` 4F、`GuhePageTailCleanContractTest` 4F),最近提交 `d5c656cf3`(11-09 01:19 merge S-cfc-81)→ **属研发 HEAD a80a6115a 汇报「19 RED 测试侧契约批次在途」,预期 RED,非回归**。
+- 非 Guhe 部分 = 20 类 / 38F+11E = 49 红,与 R94 的 49 红(38F+11E)**数值完全一致**,无生产回归信号。
+- **B-69 连续第 5 轮未修**:`MindFortuneControllerTest` 期望 401 实得 400(`/api/mind/` 未入 `LOGIN_REQUIRED_PATHS`)。
+
+## 2. 前端 / 管理端 / 根级
+
+| 套件 | 命令 | 结果 | 与 R94 对比 |
+|---|---|---|---|
+| cfc-frontend | `npx jest --no-cache` | **408 例 / 365 过 / 41 红 / 2 skip** | 394→408(+14);24→41 红 |
+| cfc-web | 同上 | 116/116 全绿 | 零漂移 |
+| tests/ 根级 | `cd tests && npx jest` | **23 例 / 7 过 / 16 红** | 零漂移(B-36 黑盒环境依赖) |
+
+前端 41 红构成(逐套件实跑确认):
+- `recommendation.uiRework.test.js`(S-cfc-79 diet)**24 红**,与 R94 一致,预期 RED(B-73)。
+- `recommendation.participantSelect.test.js`(S-cfc-80 共餐人选择)**8 红 / 1 skip / 1 占位绿**(commit `3b0ec73b2` 新入库契约,预期 RED)。
+- `signature.canvas2d.test.js`(本轮新增 v2 契约)**9 红 / 2 绿**(预期 RED,S4-1/S4-2 未实施;B-81)。
+- 旧横屏 `signature.landscape.superseded.js`(7 例,含 3 例验证已作废 S1/S2/S3 的假红)已从 jest 执行范围移除。
+
+## 3. 浏览器 E2E(本机单节点串行)
+
+| 配置 | 用例 | 结果 | 耗时 |
+|---|---|---|---|
+| `playwright.config.live.js` | admin 5 + frontend 6 + shop 4 | **15/15 passed** | 3.7m |
+| `playwright.config.health.js` | health-contract 13 | **13/13 passed**(B-61 闭环) | 19.4s |
+| `playwright.config.dxbridge.js`(临时配置 `_r95_dx_cfg.js`,跑完删除) | DX 桥接 2 | **2 skipped**(404 记 skip) | 9.8s |
+
+注:`playwright.config.dxbridge.js` 不在版本库(临时生成,跑后删),主 `playwright.config.js` 仍受 B-80 阻断(`reset-db.sh` 凭据 1045)。
+
+## 4. langgraph(本机兜底 + 251 实派)
+
+| 维度 | 结果 |
+|---|---|
+| 本机 venv pytest(`--ignore=tests/qna`) | **44 / 32 / 12**(238.4s),与 R94 逐例一致。12 红 = analyze 2(B-65)+ auto_tag 5 + chat 1(本机 chromadb DLL 500)+ intent 4(API key 401) |
+| **251 实派 pytest(opencode bash,`.venv/bin/python -m pytest tests/`)** | **44 collected / 36 passed / 8 failed / 71.7s**(首次实质落地「langgraph 派 251」指令) |
+| 251 8 红构成 | analyze 2(B-65 checkpointer thread_id,连续第 5 轮)+ auto_tag 5 + chat 1(装置 ASGITransport 不触发 startup 致 chromadb/记忆未初始化;live chat 200 证明非生产缺陷) |
+| 251 intent 4 例 | **251 绿(模型可用)/ 本机红(key 401)** → 环境差异,权威口径以 251 为准(B-82) |
+| 251 `:9000/api/v1/chat` | **B-77 解除**:422(参数缺)→ 422(int_parsing)→ **200 完整问答 JSON**(儿童成长家庭助手),live 端到端可用 |
+| 251 opencode 派发通道 | **B-56 根因定位**:本地脚本默认 provider=axon 不存在 → 改 etotem/deepseek 后 OUTCOME succeeded;脚本已修(识别 succeeded + 拉 assistant 文本) |
+
+## 5. SRS 变更 → 用例更新(签名 v2)
+
+| 动作 | 结果 |
+|---|---|
+| 读 v2 SRS `DOC-2026-11-09-signature-canvas2d-rewrite`(取代 11-08 横屏,整份作废) | 确认 S4-1 竖屏直签 / S4-2 canvas 2d 契约 |
+| 核验 `signature.vue` / `hand-signature.vue` 现状 | 均为旧实现(旧横屏 + `uni.createCanvasContext`)→ v2「待开发」 |
+| 新增 `signature.canvas2d.test.js` | 11 例:9 RED 新契约 + 2 GREEN 保留契约(computeCanvasHeight 保留、组件 props/emit 不变) |
+| 归档 `signature.landscape.superseded.js` | 旧横屏 7 例(3 例假红)不再参与 jest 匹配 |
+| 更新 `tests/test-cases/signature.md` | v2 契约台账(TC-SIG2D-01~08 + -E×2 + -N×2) |
+
+## 6. 节点分派纪律遵守
+
+- 251:**langgraph pytest 派发指令实质落地**(上轮 B-78 阻断已解除),单节点单浏览器任务(opencode 非浏览器)。
+- 204(本机):浏览器 15+13+2 单节点串行(`workers=1`),后端全量 + 前端 jest 串行执行,未并发同节点多浏览器。
+- 浏览器套件用隔离配置(live/health/dxbridge),未触碰主配置(B-80 阻断未变)。
+
+## 7. 证据清单
+
+- `_r95_backend.log`(后端全量日志)、`_r95_agg.json`(聚合)
+- `_r95_pytest.log`(本机 langgraph pytest)
+- `_r95_dx_cfg.js`(DX 临时配置,已删除)
+- `_dispatch_251.cjs`(251 派发脚本,本轮修正 provider/结果解析)
+- 251 派发会话:`ses_e43af17beffeG46teMrzft7srI`(首次成功派发)、`ses_e439748c6ffeFGAb34m2J5w9Mp`(pytest 实派)、`ses_e439319d1ffejz7626dCYXFa85`(chat 200 验证)
+- 签名 v2 契约:`cfc-frontend/__tests__/pages/signature/signature.canvas2d.test.js`(新增)、`signature.landscape.superseded.js`(归档)
+- 台账:`tests/test-cases/signature.md`(v2 重写)、`docs/tdd/test/blockers.md`(B-77/B-78/B-75 系列更新 + B-81/B-82 新增)
+- R94 基线:`docs/tdd/test/automation-run-2026-11-08-R94.md`

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

@@ -117,3 +117,9 @@
 | B-76-Update(第 94 轮 · 本轮未复现) | admin-desktop 非确定 flaky | 测试(观察) | **本轮未复现(2026-11-08 23:00)**。`playwright.config.live.js` 串行 `workers=1` 下 admin 5/5 全绿(3.7m,含 TC-A-U04 真实登录+滑块)。按 B-67 纪律单轮绿不足以判定消除,维持「观察」状态,需连续 2 轮绿后关闭。 |
 | B-73-Update(第 94 轮 · 源码级复核) | S-cfc-79 diet 24 例 RED | 研发 | **仍为预期 RED(第 94 轮复核,2026-11-08 23:00)**。`grep -c "currentTab\|pagedFoods\|loadMore" cfc-frontend/pages/diet/recommendation.vue` = **0** → REQ1-7 均未落地,24 例红属 TDD RED 而非测试缺陷;与 R92/R93 逐例零漂移。 |
 | B-55-Update(第 94 轮 · 服务在线但 chat 挂起) | 251 langgraph 服务维度在线、能力维度退化 | 运维 | **第 94 轮(2026-11-08 23:20)**。`:9000/health` ok 维持(B-55 服务维度仍解除),但 `chat` 挂起另立 **B-77**。 |
+| B-77-Close(第 95 轮 · 第 3 轮确认后闭环) | 251 langgraph `POST /api/v1/chat` 挂起 | 研发/运维(已修复) | **已解除(2026-11-09 03:1x,251 端点派发实测)**。本轮经 251 opencode 通道带 50s/90s/120s 超时逐级探测:① 错误参数 → **422 快速返回**(缺 query/user_id,6ms);② user_id 传字符串 → **422 int_parsing**(5ms);③ 正确参数 `{query,user_id:995,enable_chat_visual:false}` → **200 完整问答 JSON 返回**(儿童成长家庭助手),`/health` 200 `{"status":"ok"}`。结论:**服务已恢复响应与推理能力,不再挂起**,B-77 解除。对比 R94(30s/60s 两次 0 字节挂起)为确定性改善,非短时抖动 | 测试负责人(第 95 轮派发实测闭环) | 2026-11-09 03:15 | **已解除** |
+| B-78-Close(第 95 轮 · 源码就位 + pytest 实派) | 251 无 `cfc-langgraph` 源码 → pytest 无法派发 | 运维(已补齐) | **已解除(第 95 轮,2026-11-09 02:50 前后)**。251 上 `/sc-data/cfc/cfc-langgraph` **已存在**(git repo,HEAD `b6145f156`,02:10 更新,含 `tests/`、`tests_contract/`、`.venv/bin/pytest`、Python 3.11.13)→ 上轮「目录不存在」结论已过期。**251 实派 pytest(经 opencode bash 工具):44 collected / 36 passed / 8 failed / 71.7s**——8 红(analyze 2 = B-65 checkpointer、auto_tag 5 + chat 1 = 装置 ASGITransport 不触发 startup 致 chromadb/记忆依赖未初始化;live chat 200 证明非生产缺陷)。**intent 4 例在 251 转绿**(模型可用),本机 4 红为 `****ocal` key 401 环境差异。langgraph 测试派发 251 指令已实质落地 | 测试负责人(第 95 轮实测) | 2026-11-09 02:55 | **已解除** |
+| B-56-Update(第 95 轮 · 251 派发通道根因定位) | 251 opencode 派发会话 failed / tokens=0 | 测试(自处置) | **根因定位并修复(第 95 轮,2026-11-09 03:00)**。上轮 251 派发 `outcome=failed, tokens=0`(模型未被调用即失败)根因=**本地派发脚本默认 model=deepseek3.2 + provider=axon,而 251 opencode 配置中不存在 `axon` provider**(`/api/config` 实际含 agnes/etotem/etotem-llm/zcode;etotem 有 deepseek/minimax-m2/glm-5/gpt-5)。改用 `OPENCODE_PROVIDER=etotem OPENCODE_MODEL=deepseek` → **OUTCOME succeeded、tokens 正常**(input 2094/output 1434)。脚本已修:识别 `succeeded`/`success` 双态 + 从 `/api/session/{id}/message` 拉取 assistant 文本。**后续派发实证**:环境探活 5 组命令、cfc-langgraph 存在性直探(A/B/C/D 布尔+ls+git log)、pytest 全量派发、chat live 三层探测全部成功回传 | 测试负责人(第 95 轮实测) | 2026-11-09 03:00 | 已处置(通道可用) |
+| B-81(第 95 轮新增) | 签名 v2 重写(S4-1 竖屏直签 + S4-2 canvas 2d)契约 RED 9 例 | 研发 | **P1 预期 RED(第 95 轮,2026-11-09 02:1x 实测)**。SRS 变更 `DOC-2026-11-09-signature-canvas2d-rewrite`(**取代 11-08 横屏方案,整份作废**)已入库:S4-1 弃横屏改竖屏直签(删 enterLandscape/restorePortrait/h5DegradeHint/.rotate-hint)、S4-2 canvas 重写 `type="2d"`(`createSelectorQuery().in(this).select().fields({node:true})` + `ctx.stroke()` 同步绘制 + `canvasToTempFilePath({canvas})` + `clearRect`)。测试侧已按 SRS 变更更新用例:新增 `signature.canvas2d.test.js` **11 例(9 RED 新契约 + 2 GREEN 保留契约**:computeCanvasHeight 保留、组件 props/emit 不变),旧横屏 `signature.landscape.test.js` 改名 `.superseded.js` 归档(验证已作废 S1/S2/S3 的 3 例假红移除,不参与 jest 匹配)。当前实现仍为旧横屏+旧 canvas → 9 RED 均归因「S4-1/S4-2 未实施」,待研发实现后转绿。闭环:`signature.canvas2d.test.js` 9 RED 转绿 + `tests/test-cases/signature.md` 状态置已实现 | 测试负责人(第 95 轮实测登记) | 2026-11-09 02:20 | 待处理(P1,预期 RED,待实现) |
+| B-82(第 95 轮 · 环境差异) | langgraph intent 分类 4 例:251 绿 / 本机红(API key 401) | 环境/运维 | **环境差异记录(第 95 轮,2026-11-09 02:50)**。`test_intent.py` 4 例(classify analysis/recommend/mind/health):251 实跑 **4/4 绿**(模型可用);本机 4 红(`Your api key: ****ocal is invalid` → 意图分类失败默认 CHAT,B-54 同源)。非生产缺陷、非回归——同 HEAD 同源码,差异仅模型凭据可用性。建议:本机 venv 注入有效 LLM 凭据后 intent 4 例预期转绿;**权威口径以 251 派发结果为准(36 过/8 红)** | 测试负责人(第 95 轮实测) | 2026-11-09 02:55 | 待处理(环境,低优先) |
+| B-75-Update(第 95 轮 · 作废) | signature.landscape.layout 被 v2 重写取代 | 测试(登记) | **作废(第 95 轮,2026-11-09 02:05)**。旧 SRS `DOC-2026-11-08-signature-landscape-fix` 已被 `DOC-2026-11-09-signature-canvas2d-rewrite` **整份取代(含 B-75 的 S1/S2/S3 实现 `adb032a61` 作废,不再按旧任务书实施)**。能力键由 `signature.landscape.layout` 迁移为 `signature.canvas2d.rewrite`(B-81)。旧契约 7 例(TC-SIG-01~04b/-03E)中 3 例(02/04/04b)验证已作废需求 → 已从执行范围移除(归档 `.superseded.js`),其余 4 例能力(computeCanvasHeight 竖屏计算、getPos 兜底)并入 v2 契约保持守护 | 测试负责人(第 95 轮登记) | 2026-11-09 02:05 | 已作废(由 B-81 接替) |

+ 25 - 17
tests/test-cases/signature.md

@@ -1,25 +1,31 @@
-# 测试用例 · 手签名横屏适配(Signature Landscape)
+# 测试用例 · 手签名实现方式重写(竖屏直签 + canvas 2d)
 
-> 关联 SRS:`docs/superpowers/specs/2026-11-08-signature-landscape-fix.md`(需求编号 `DOC-2026-11-08-signature-landscape-fix`,状态:待开发)。
-> 能力键:`signature.landscape.layout`(前端 UI 纵切,无独立后端接口)。
-> 涉改文件:`cfc-frontend/pages/signature/signature.vue`、`cfc-frontend/components/hand-signature/hand-signature.vue`。
+> 关联 SRS:`docs/superpowers/specs/2026-11-09-signature-canvas2d-rewrite.md`(需求编号 `DOC-2026-11-09-signature-canvas2d-rewrite`,状态:待开发)。
+> **取代**:`docs/superpowers/specs/2026-11-08-signature-landscape-fix.md`(整份作废,见 §历史)与旧台账(横屏 TC-SIG-01~05 系列已作废归档)。
+> 能力键:`signature.canvas2d.rewrite`(前端 UI 纵切,无独立后端接口)。
+> 涉改文件:`cfc-frontend/pages/signature/signature.vue`(S4-1 竖屏直签)、`cfc-frontend/components/hand-signature/hand-signature.vue`(S4-2 canvas 2d)。
 > 派生自 `user-scenarios/` 的家长/孩子签名回传场景(health-plan-summary / checkout / consignee-edit 跳转进入)。
 
 | TC 编号 | 所属场景 | 模块 | 角色 | 优先级 | 类型 | 前置条件 | 测试步骤 | 预期结果 | 关联需求/设计 | 对应 e2e |
 |---|---|---|---|---|---|---|---|---|---|---|
-| TC-SIG-01 | SIG-1 | 签名·横屏布局 | 家长/孩子 | P0 | 兼容 | 进入签名页;设备/工具支持横屏 | 1. 进入签名页 2. 切横屏(wx.setPageOrientation / H5 resize) | 画布高度 = 横屏短边 − 固定栏(标题66+底部92+同意40+留白16),可视区无裁剪(R1) | S1 canvasHeight 随旋转刷新 | — |
-| TC-SIG-02 | SIG-2 | 签名·横屏字号 | 家长/孩子 | P0 | 兼容 | 横屏态 | 1. 横屏下查看标题/副标题/按钮/旋转提示文字 2. 对比竖屏观感 | 文字与按钮尺寸与竖屏一致(rpx→px 化),横屏不再放大 ~2.2 倍(R2) | S2 rpx 改 px | — |
-| TC-SIG-03 | SIG-3 | 签名·笔迹可见 | 家长/孩子 | P0 | 功能 | 横屏态 | 1. 横屏下书写签名 2. 导出 dataUrl | 笔迹实时可见、不被 `overflow:hidden` 裁掉;导出 dataUrl 完整(R3) | S3 refreshRect() 修复 rect 错位 | — |
-| TC-SIG-04 | SIG-4 | 签名·旋转兜底 | 家长/孩子 | P1 | 功能 | 已横屏 | 1. 横屏→切回竖屏→再横屏 2. 检查画布高度与 rect | 每次旋转后高度与 rect 均正确刷新(onWindowResize + setPageOrientation 回调双兜底) | S1/S3 兜底 | — |
-| TC-SIG-05 | SIG-5 | 签名·竖屏回归 | 家长/孩子 | P1 | 回归 | 未横屏 | 1. 竖屏进入签名页 2. 从 health-plan-summary / checkout / consignee-edit 跳转进入 3. 签名后回传 | 竖屏布局正常;三处跳转回传链路不回归 | 不修改竖屏链路 | — |
+| TC-SIG2D-01 | SIG-1 | 签名·去横屏化 | 家长/孩子 | P0 | 功能 | 进入签名页(任意机型) | 1. 检查 signature.vue script | 不再存在 `enterLandscape()`/`restorePortrait()` 方法及其 onLoad/onUnload 调用(S4-1) | S4-1 删除横屏方法 | — |
+| TC-SIG2D-01b | SIG-1 | 签名·去横屏化 | 家长/孩子 | P0 | 功能 | 进入签名页 | 1. 检查模板与样式 | 无 `h5DegradeHint` / `.rotate-hint` 模板块与样式(S4-1 删除 H5 降级提示) | S4-1 删除降级提示 | — |
+| TC-SIG2D-01c | SIG-1 | 签名·去横屏化 | 家长/孩子 | P0 | 功能 | 进入签名页 | 1. 检查源码 | 不再调用 `wx.setPageOrientation` / `screen.orientation.lock`(不再依赖不可靠横屏能力) | S4-1 去横屏依赖 | — |
+| TC-SIG2D-02 | SIG-1 | 签名·竖屏布局 | 家长/孩子 | P0 | 兼容 | 竖屏进入签名页 | 1. 竖屏下查看画布高度 | `computeCanvasHeight()` 保留竖屏计算:windowHeight − reserved(requireAgree 214 / 否则 174),且不低于 160 下限 | S4-1 保留 computeCanvasHeight | — |
+| TC-SIG2D-03 | SIG-2 | 签名·canvas 2d | 家长/孩子 | P0 | 功能 | 渲染签名组件 | 1. 检查组件模板 | `<canvas :id="canvasId" type="2d">`,不再使用 `canvas-id` 旧式绑定 | S4-2 模板 2d 化 | — |
+| TC-SIG2D-03b | SIG-2 | 签名·canvas 2d | 家长/孩子 | P0 | 功能 | 渲染签名组件 | 1. 检查组件 data | `canvasId: 'hsCanvas_'+(this._uid||0)` 多实例唯一;`_canvasNode: null`(导出用 2d 节点) | S4-2 data 新增 | — |
+| TC-SIG2D-04 | SIG-2 | 签名·canvas 2d | 家长/孩子 | P0 | 功能 | mounted 初始化 | 1. 检查 mounted 逻辑 | 走 `createSelectorQuery().in(this).select('#'+canvasId).fields({node:true,size:true,rect:true})`,不再用 `.select('.signature-canvas').boundingClientRect` 缓存 rect | S4-2 初始化 | — |
+| TC-SIG2D-05 | SIG-2 | 签名·坐标修复 | 家长/孩子 | P0 | 功能 | 触摸书写 | 1. 书写签名 | 触摸坐标优先 `t.x/t.y`(type=2d 相对画布,不再依赖缓存 rect);兜底 `t.clientX − _rectLeft/_rectTop` | S4-2 触摸坐标 | — |
+| TC-SIG2D-06 | SIG-2 | 签名·笔迹可见 | 家长/孩子 | P0 | 功能 | 书写签名 | 1. 书写时观察笔迹 | `onTouchMove` 用 `ctx.stroke()` 同步绘制,无异步 `ctx.draw()`(笔迹实时可见,根因 R2 修复) | S4-2 同步绘制 | — |
+| TC-SIG2D-07 | SIG-2 | 签名·导出/清空 | 家长/孩子 | P0 | 功能 | 已绘制 | 1. 确认导出 2. 重写清空 | 导出 `canvasToTempFilePath({canvas:this._canvasNode,...})`(2d 节点,非 canvasId);清空 `ctx.clearRect` 同步,无 `ctx.draw()` | S4-2 导出/清空 | — |
+| TC-SIG2D-08 | SIG-3 | 签名·契约不回归 | 家长/孩子 | P1 | 回归 | 三调用方跳转 | 1. 从 health-plan-summary / checkout / consignee-edit 进入 2. 签名回传 | 组件对外契约不变:props(value/strokeColor/strokeWidth/height/requireAgree/agreeText)+ emit(confirm/clear/agree-change)保持,调用方无感 | S4-3 不回归 | — |
 
 ### 异常流用例(派生自正常流)
 
 | TC 编号 | 所属场景 | 模块 | 角色 | 优先级 | 类型 | 前置条件 | 测试步骤 | 预期结果 | 关联需求/设计 | 对应 e2e |
 |---|---|---|---|---|---|---|---|---|---|---|
-| TC-SIG-01-E | SIG-1 | 签名·rect 防御 | 家长/孩子 | P2 | 异常 | rect 未刷新(旧值/ null) | 1. 旋转后 rect 仍为竖屏旧值或 null 时书写 | `getPos()` 在 rect 为 null 时沿用 raw clientX/Y,不抛错、不崩溃(R3 防御) | S3 getPos 防御 | — |
-| TC-SIG-02-E | SIG-2 | 签名·安卓闪烁 | 家长/孩子 | P2 | 异常 | 安卓即时旋转 canvas 重建 | 1. 安卓机型即时旋转后 canvas 闪烁重建 2. refreshRect 成功后书写 | 笔迹仍可见(`hasDrawn` 不变,不加重绘逻辑) | S3 风险说明 | — |
-| TC-SIG-03-E | SIG-3 | 签名·监听注销 | 家长/孩子 | P2 | 异常 | 多次进出签名页 | 1. onUnload 后再次进入签名页 2. 检查 resize 监听 | 监听在 onUnload 注销,无重复绑定/内存泄漏 | S1 onUnload 注销 | — |
+| TC-SIG2D-01-E | SIG-2 | 签名·2d 初始化失败 | 家长/孩子 | P2 | 异常 | fields size/rect 均无效 | 1. 初始化失败路径 | 失败时 console.log + 定时器重试一次;`_destroyed` 时停止(参照 RadarChart,不静默) | S4-2 初始化防御 | — |
+| TC-SIG2D-02-E | SIG-2 | 签名·导出降级 | 家长/孩子 | P2 | 异常 | H5 / 无文件系统 | 1. H5 端确认导出 | H5 直接传 dataURL;小程序 fs.readFile 转 base64 既有链路保持不变 | S4-2 导出保持既有链路 | — |
 
 ### 权限流用例(派生自正常流)
 
@@ -29,11 +35,13 @@
 
 | TC 编号 | 所属场景 | 模块 | 角色 | 优先级 | 类型 | 前置条件 | 测试步骤 | 预期结果 | 关联需求/设计 | 对应 e2e |
 |---|---|---|---|---|---|---|---|---|---|---|
-| TC-SIG-N01 | SIG-2 | 签名·多端兼容 | 家长/孩子 | P1 | 兼容 | iOS / 安卓真机或等效工具 | 1. 两平台横屏进入签名页 | 文字/按钮观感一致,无放大溢出 | S2 px 化 | — |
+| TC-SIG2D-N01 | SIG-2 | 签名·多端兼容 | 家长/孩子 | P1 | 兼容 | iOS / 安卓真机或等效工具 | 1. 两平台(含无法自动横屏的机型)竖屏进入签名页 2. 书写 | 任意机型竖屏可签,无横屏依赖;笔迹实时可见 | S4-1/S4-2 | — |
+| TC-SIG2D-N02 | SIG-2 | 签名·既有 2d 组件不回归 | 家长/孩子 | P1 | 回归 | 雷达图/沙盘/趋势图/mbti 页面 | 1. 抽查项目内既有 2d 组件 | 未触碰(radar/trend/family/wuxing/mbti/ability 等 10+ 处不受影响) | S4-3 不回归 | — |
 
 ### 状态
 
-- 上述用例随 SRS 变更 `a7edf5698`(2026-11-08)新增,覆盖 R1/R2/R3 三处根因 + 旋转兜底 + 竖屏回归 + 异常防御。
-- 功能状态:**已实现(GREEN)**——`adb032a61`(2026-11-08 22:51)落地 S1 高度重算 / S2 px 化 / S3 `refreshRect()`。
-- 可执行化:**已完成(第 94 轮)**——`cfc-frontend/__tests__/pages/signature/signature.landscape.test.js` 覆盖 TC-SIG-01(含 01b/01c 两档与 160 下限)、TC-SIG-02(px 化)、TC-SIG-04(旋转重算 + 组件 rect 刷新 + refs 缺失容错)、TC-SIG-03-E(rect 缺失沿用 clientX/Y),**7/7 全绿**。
-- 仍未覆盖:TC-SIG-03(真机笔迹可见性)、TC-SIG-05(三处跳转回传回归)、TC-SIG-N01(iOS/安卓横屏观感)——需真机/多端,本轮未实测。
+- 上述用例随 SRS 变更 `DOC-2026-11-09-signature-canvas2d-rewrite`(2026-11-09)重写,取代 11-08 横屏方案(R2 根因:旧式 `uni.createCanvasContext` 触摸坐标依赖缓存 rect → 坐标漂移字迹不显示;R1 根因:`setPageOrientation` 部分机型不可靠)。
+- 功能状态:**待开发(RED)**——v2 任务书未实施,`signature.vue` 仍含 `enterLandscape/restorePortrait/h5DegradeHint`,`hand-signature.vue` 仍为旧式 `canvas-id + ctx.draw()`。
+- 可执行化:**已完成(第 95 轮)**——`cfc-frontend/__tests__/pages/signature/signature.canvas2d.test.js` 覆盖全部 S4-1/S4-2 契约(11 例:9 RED 契约 + 2 保留 GREEN 基线),**实测 9 红 / 2 绿**。
+- 归档:旧横屏契约 `cfc-frontend/__tests__/pages/signature/signature.landscape.superseded.js`(7 例验证已作废 S1/S2/S3,改名不参与 jest 匹配,保留审计)。
+- 仍未覆盖:TC-SIG2D-01-E(2d 初始化失败重试)、TC-SIG2D-02-E(导出降级)、TC-SIG2D-N01/N02(真机/多端)——需真机与运行时环境,本轮未实测。