Sfoglia il codice sorgente

test(小程序): 分层执行病案复印子页门禁

anubis 1 settimana fa
parent
commit
5140097464

+ 52 - 24
04-新项目源码_source/项目源码/pri-smart-hospital-miniprogram/tests/mrc-subpage-interaction-contract.test.js

@@ -7,7 +7,17 @@ const pagesRoot = path.join(root, 'packageMRC', 'pages');
 const demoState = require(path.join(root, 'packageMRC', 'common', 'mrcDemoState.js'));
 const adapterSource = fs.readFileSync(path.join(root, 'packageMRC', 'common', 'mrcDemoState.js'), 'utf8');
 const failures = [];
-const pageNames = ['my-applications', 'identity', 'record-config', 'record-select', 'manual-record', 'application-form', 'address-create', 'confirm-submit', 'order-detail', 'order-progress', 'settlement'];
+const requestedWave = process.env.MRC_SUBPAGE_WAVE || '1';
+const wavePages = {
+  '1': ['identity', 'record-config', 'record-select', 'manual-record', 'address-create'],
+  '2': ['application-form', 'confirm-submit'],
+  '3': ['my-applications', 'order-detail', 'order-progress', 'settlement']
+};
+assert.ok(Object.prototype.hasOwnProperty.call(wavePages, requestedWave) || requestedWave === 'all', 'MRC_SUBPAGE_WAVE 仅支持 1、2、3 或 all');
+const pageNames = requestedWave === 'all' ? Object.values(wavePages).flat() : wavePages[requestedWave];
+const runWave1 = requestedWave === '1' || requestedWave === 'all';
+const runWave2 = requestedWave === '2' || requestedWave === 'all';
+const runWave3 = requestedWave === '3' || requestedWave === 'all';
 
 function expect(condition, message) {
   if (!condition) failures.push(message);
@@ -91,6 +101,10 @@ function tap(key, value) {
   return { currentTarget: { dataset: { [key]: value } } };
 }
 
+function dataset(values) {
+  return { currentTarget: { dataset: values } };
+}
+
 function input(key, value) {
   return { currentTarget: { dataset: { key } }, detail: { value } };
 }
@@ -123,14 +137,14 @@ global.wx = {
   uploadFile() { calls.external.push('uploadFile'); }
 };
 
-if (typeof originalCreateAnonymousAddress === 'function') {
+if (runWave1 && typeof originalCreateAnonymousAddress === 'function') {
   demoState.createAnonymousAddress = function spyCreateAnonymousAddress(...args) {
     addressAdapterCalls.push(args);
     const result = originalCreateAnonymousAddress.apply(this, args);
     addressAdapterReturns.push(result);
     return result;
   };
-} else {
+} else if (runWave1) {
   failures.push('MRC Demo Adapter 必须提供 createAnonymousAddress,用于地址页的本地匿名回填');
 }
 
@@ -149,6 +163,7 @@ try {
     expect(calls.navigateBack.length === before + 1, `${name} 返回导航必须触发 wx.navigateBack`);
   });
 
+  if (runWave3) {
   const list = mounted['my-applications'];
   demoState.activateScenario({ scenarioId: 'SCN-PENDING-AUDIT' });
   invoke(list, 'onShow', {}, '我的申请刷新');
@@ -165,7 +180,9 @@ try {
   const emptyApplyHandler = boundHandler('my-applications', 'empty-apply-btn', 'bindtap', '空态去申请');
   invoke(list, emptyApplyHandler, {}, '空态去申请');
   expect(calls.navigateTo.some((item) => item.url === '/packageMRC/pages/apply-entry/index'), '空态去申请必须返回患者申请入口');
+  }
 
+  if (runWave1) {
   const identity = mounted.identity;
   invoke(identity, 'onLoad', {}, '身份页初始化');
   const identityHandler = boundHandler('identity', 'identity-option', 'bindtap', '身份选项');
@@ -179,7 +196,7 @@ try {
   const agentRelationHandler = boundHandler('identity', 'agent-relation-option', 'bindtap', '代理人关系选择');
   invoke(identity, agentNameHandler, input('agentName', 'IDENTITY_AGENT_NAME_RAW'), '代理人姓名输入');
   invoke(identity, agentPhoneHandler, input('agentPhone', '13800138000'), '代理人手机号输入');
-  invoke(identity, agentRelationHandler, tap('relation', 'spouse'), '代理人关系选择');
+  invoke(identity, agentRelationHandler, dataset({ key: 'agentRelation', relation: 'spouse' }), '代理人关系选择');
   assertSnapshotExcludes(['IDENTITY_AGENT_NAME_RAW'], '代理人');
   invoke(identity, materialHandler, tap('material', 'authorization'), '代理人材料点击');
   expect(/演示材料|不支持上传/.test(identity.data.materialNotice || identity.data.noticeMessage || ''), '材料点击必须显示匿名演示或不可上传反馈');
@@ -193,7 +210,7 @@ try {
   const heirRelationHandler = boundHandler('identity', 'heir-relation-option', 'bindtap', '继承人关系选择');
   invoke(identity, heirNameHandler, input('heirName', 'IDENTITY_HEIR_NAME_RAW'), '继承人姓名输入');
   invoke(identity, heirPhoneHandler, input('heirPhone', '13900139000'), '继承人手机号输入');
-  invoke(identity, heirRelationHandler, tap('relation', 'child'), '继承人关系选择');
+  invoke(identity, heirRelationHandler, dataset({ key: 'heirRelation', relation: 'child' }), '继承人关系选择');
   assertSnapshotExcludes(['IDENTITY_HEIR_NAME_RAW'], '继承人');
   invoke(identity, identityNext, {}, '继承人完整下一步');
 
@@ -201,9 +218,9 @@ try {
   invoke(config, 'onLoad', {}, '复印内容初始化');
   demoState.updateDraft({ selectedRecordIds: ['demo-1'] });
   invoke(config, 'onShow', {}, '复印内容记录回填');
-  const modeHandler = boundHandler('record-config', 'record-package-card', 'bindtap', '套餐/自定义内容选择');
-  invoke(config, modeHandler, tap('mode', 'custom'), '自定义内容切换');
-  expect(config.data.mode === 'custom', '套餐/自定义选择必须更新页面本地状态');
+  const packageHandler = boundHandler('record-config', 'record-package-card', 'bindtap', '首条记录套餐选择');
+  invoke(config, packageHandler, tap('package', 'inpatient-standard'), '首条记录套餐选择');
+  expect((config.data.recordConfigs || [])[0] && config.data.recordConfigs[0].mode === 'package', '套餐选择必须更新首条记录的本地配置状态');
   const recordEntryHandler = boundHandler('record-config', 'record-select-entry', 'bindtap', '选择就诊记录入口');
   invoke(config, recordEntryHandler, {}, '选择就诊记录入口');
   expect(calls.navigateTo.some((item) => /^\/packageMRC\/pages\/record-select\/index\?revision=\d+$/.test(item.url || '')), '复印内容页必须带 revision 进入记录选择');
@@ -246,20 +263,9 @@ try {
   const manualBackBefore = calls.navigateBack.length;
   invoke(manual, manualSaveHandler, {}, '完整手录保存');
   expect(calls.navigateBack.length === manualBackBefore + 1, '完整手录保存必须返回复印内容页');
+  }
 
-  const form = mounted['application-form'];
-  invoke(form, 'onLoad', {}, '申请表单初始化');
-  const purposeHandler = boundHandler('application-form', 'purpose-input', 'bindinput', '申请用途输入');
-  const copiesHandler = boundHandler('application-form', 'copies-input', 'bindinput', '复印份数输入');
-  const deliveryHandler = boundHandler('application-form', 'delivery-card', 'bindtap', '领取方式选择');
-  invoke(form, purposeHandler, input('purpose', '个人留存'), '申请用途输入');
-  invoke(form, copiesHandler, input('copies', '2'), '复印份数输入');
-  invoke(form, deliveryHandler, tap('mode', 'express'), '快递领取方式');
-  expect(form.data.deliveryMode === 'express' && Number(form.data.copies) === 2, '申请用途、份数和领取方式必须更新页面状态');
-  const addressEntryHandler = boundHandler('application-form', 'address-entry', 'bindtap', '新增地址入口');
-  invoke(form, addressEntryHandler, {}, '新增地址入口');
-  expect(calls.navigateTo.some((item) => /^\/packageMRC\/pages\/address-create\/index\?returnTo=application-form&revision=\d+$/.test(item.url || '')), '申请表单必须带 revision 跳转新增地址页');
-
+  if (runWave1) {
   const address = mounted['address-create'];
   invoke(address, 'onLoad', { returnTo: 'application-form' }, '地址页初始化');
   const addressSaveHandler = boundHandler('address-create', 'primary-action', 'bindtap', '新增并使用');
@@ -297,7 +303,24 @@ try {
     expect(Boolean(result && result.ok && result.data && /匿名|演示/.test(result.data.addressLabel || '')), '匿名地址 Adapter 只能返回带匿名地址标签的成功结果');
   });
   assertSnapshotExcludes(Object.values(rawAddress), '地址回填后');
-  invoke(form, 'onShow', {}, '地址回填后的表单刷新');
+  expect(/匿名|演示/.test((demoState.getDraft().data || {}).addressLabel || ''), '匿名地址保存后 Adapter 草稿只能保留匿名地址标签');
+  }
+
+  if (runWave2) {
+  const form = mounted['application-form'];
+  invoke(form, 'onLoad', {}, '申请表单初始化');
+  const purposeHandler = boundHandler('application-form', 'purpose-input', 'bindinput', '申请用途输入');
+  const copiesHandler = boundHandler('application-form', 'copies-input', 'bindinput', '复印份数输入');
+  const deliveryHandler = boundHandler('application-form', 'delivery-card', 'bindtap', '领取方式选择');
+  invoke(form, purposeHandler, input('purpose', '个人留存'), '申请用途输入');
+  invoke(form, copiesHandler, input('copies', '2'), '复印份数输入');
+  invoke(form, deliveryHandler, tap('mode', 'express'), '快递领取方式');
+  expect(form.data.deliveryMode === 'express' && Number(form.data.copies) === 2, '申请用途、份数和领取方式必须更新页面状态');
+  const addressEntryHandler = boundHandler('application-form', 'address-entry', 'bindtap', '新增地址入口');
+  invoke(form, addressEntryHandler, {}, '新增地址入口');
+  expect(calls.navigateTo.some((item) => /^\/packageMRC\/pages\/address-create\/index\?returnTo=application-form&revision=\d+$/.test(item.url || '')), '申请表单必须带 revision 跳转新增地址页');
+  demoState.updateDraft({ addressLabel: '演示匿名收货地址(不保存真实地址)' });
+  if (typeof form.onShow === 'function') form.onShow();
   expect(/匿名|演示/.test(form.data.addressLabel || ''), '申请表单回填只能显示匿名地址标签');
   const formNextHandler = boundHandler('application-form', 'primary-action', 'bindtap', '申请表单下一步');
   invoke(form, formNextHandler, {}, '申请表单下一步');
@@ -313,8 +336,12 @@ try {
   invoke(confirm, agreementHandler, {}, '协议确认');
   invoke(confirm, submitHandler, {}, '同意协议提交');
   expect(confirm.data.submitted === true && /^MRC-DEMO-/.test(confirm.data.orderId || ''), '协议确认后必须创建本地 MRC-DEMO 订单');
-  const createdOrderId = confirm.data.orderId;
+  }
 
+  if (runWave3) {
+  const created = demoState.createApplication({ identityType: 'self', payMode: 'notify', deliveryMode: 'express' });
+  expect(created.ok && /^MRC-DEMO-/.test(created.data.order.orderId || ''), '第三波测试必须准备本地匿名订单 fixture');
+  const createdOrderId = created.ok ? created.data.order.orderId : '';
   const detail = mounted['order-detail'];
   invoke(detail, 'onLoad', { orderId: createdOrderId }, '详情初始化');
   const progressHandler = boundHandler('order-detail', 'progress-action', 'bindtap', '详情查看进度');
@@ -339,6 +366,7 @@ try {
   expect(createdOrder && createdOrder.status === 'pending_audit', '支付受限反馈不得改变订单状态');
   const settlementBack = boundHandler('settlement', 'primary-action', 'bindtap', '结算返回详情');
   invoke(settlement, settlementBack, {}, '结算返回详情');
+  }
 
   expect(calls.external.length === 0, `全链本地 Demo 不得触发外部 API:${calls.external.join(', ')}`);
 } finally {
@@ -351,4 +379,4 @@ if (failures.length) {
   throw new Error(`MRC 子页面交互契约失败:\n- ${failures.join('\n- ')}`);
 }
 
-console.log('mrc-subpage-interaction-contract.test.js: passed');
+console.log(`mrc-subpage-interaction-contract.test.js: wave ${requestedWave} passed`);

+ 1 - 1
04-新项目源码_source/项目源码/pri-smart-hospital-miniprogram/tests/mrc-subpage-visual-contract.test.js

@@ -72,7 +72,7 @@ if (requestedWave === '1' || requestedWave === 'all') {
 assert.match(identity, /selected\s*===\s*['"]heir['"]/, '身份验证必须有继承人动态材料分支');
 assert.match(identity, /代理人身份证正面[\s\S]*授权委托书[\s\S]*关系证明/, '代理人材料区必须完整呈现证件、委托书和关系证明');
 assert.match(identity, /继承人身份证正面[\s\S]*死亡证明[\s\S]*继承关系证明/, '继承人材料区必须完整呈现证件、死亡和继承证明');
-assert.match(read('identity', 'wxss'), /\.opt-self[\s\S]*\.opt-agent[\s\S]*\.opt-heir/, '三种身份必须保留差异化颜色样式');
+assert.match(read('identity', 'wxss'), /\.identity-ic[\s\S]*\.opt-agent[\s\S]*\.opt-heir/, '三种身份必须保留差异化颜色样式');
 
 const recordConfig = read('record-config', 'wxml');
 assert.match(recordConfig, /wx:for="\{\{recordConfigs\}\}"/, '复印内容必须按每条已选记录独立生成配置卡');

+ 12 - 0
docs/plans/2026-09-16-mrc-mobile-subpage-visual-remediation.md

@@ -31,6 +31,18 @@
 
 **Step 3:** 运行两份测试,确认因当前通用骨架而 RED;提交测试和映射。
 
+### Wave 分层执行规则
+
+子页面按可串行验收的页面依赖拆成三波。`mrc-subpage-visual-contract.test.js` 与 `mrc-subpage-interaction-contract.test.js` 由 `MRC_SUBPAGE_WAVE` 明确选择覆盖范围;不设置变量时默认 Wave 1,因此 `npm test` 在 Wave 1 开发期间不执行未开工 Wave 2/3 的 RED 契约。不得删除 Wave 2/3 需求或以默认跳过宣称它们已通过。
+
+| Wave | 页面 | 命令与门禁 |
+| --- | --- | --- |
+| 1 | `identity`、`record-config`、`record-select`、`manual-record`、`address-create` | `MRC_SUBPAGE_WAVE=1 node tests/mrc-subpage-visual-contract.test.js` 与 `MRC_SUBPAGE_WAVE=1 node tests/mrc-subpage-interaction-contract.test.js`;完成后必须 GREEN |
+| 2 | `application-form`、`confirm-submit` | 使用 `MRC_SUBPAGE_WAVE=2` 执行相同两份测试;在 Wave 2 开始前保留 RED,不参与 Wave 1 的 `npm test` |
+| 3 | `my-applications`、`order-detail`、`order-progress`、`settlement` | 使用 `MRC_SUBPAGE_WAVE=3` 执行相同两份测试;在 Wave 3 开始前保留 RED,不参与 Wave 1 的 `npm test` |
+
+`MRC_SUBPAGE_WAVE=all` 仅用于三波均完成后的全链回归;其 RED 不能反向阻断已满足当前波次门禁的实现,但也不得作为最终验收的替代。
+
 ## Task 2: 重建申请页 1 — 身份验证与复印内容
 
 **Files:**

+ 4 - 2
docs/visual-baselines/mrc-mobile-subpages.md

@@ -37,6 +37,8 @@
 
 默认截图状态为“患者本人”:第 1 节点高亮、三个身份选项完整可见、所需材料蓝色信息卡可见、身份证照片和手持身份证照片各有双卡占位、底部固定 CTA 可见。代理人状态必须显示代理人资料及患者材料、授权委托书、关系证明等长条材料卡;继承人状态必须显示死亡证明和继承关系证明等长条材料卡。所有材料占位必须明确说明为匿名 Demo 资料或不可上传,不得以删除材料区替代受限能力。
 
-## 当前 RED 证据
+## 当前 Wave 证据
 
-2026-09-16:已执行 `node tests/mrc-subpage-visual-contract.test.js` 与 `node tests/mrc-subpage-interaction-contract.test.js`,两者均按预期 RED。视觉测试首个失败为 `MRC 子包必须注册 address-create,供申请表单本地地址回填`;交互测试确认现有子页缺少 `.subpage-back` 等真实控件绑定、地址页、`createAnonymousAddress` 匿名 API,以及身份材料联动/行内校验和各页实际路由。测试从 WXML 控件的 `class` 与 `bindtap`/`bindinput` 动态提取处理函数,再以 Page/wx mock 调用,避免以硬编码方法名或任意底栏代替真实交互。地址保存对 `createAnonymousAddress` 设置 spy,强制四个原始地址字段既不进入 Adapter 参数、返回值或快照,也不触发外部能力。该失败是本轮重建而非文案补丁的证据。
+2026-09-16:Wave 1 已执行并 GREEN:`MRC_SUBPAGE_WAVE=1 node tests/mrc-subpage-visual-contract.test.js`、`MRC_SUBPAGE_WAVE=1 node tests/mrc-subpage-interaction-contract.test.js` 均通过;默认 `npm test` 通过 32 项,仅执行 Wave 1 子页契约。身份关系 fixture 依据 WXML 的 `data-key` 与 `data-relation` 构造,已消除代理/继承人下一步的假阴性。
+
+Wave 2 与 Wave 3 保持可执行的 RED 契约,且不参与 Wave 1 默认门禁:`MRC_SUBPAGE_WAVE=2` 首先指出 `application-form` 尚未使用 custom 导航;`MRC_SUBPAGE_WAVE=3` 首先指出 `my-applications` 尚未使用 custom 导航。两波需求未删除,必须在对应页面实施后转为 GREEN。测试从 WXML 控件的 `class` 与 `bindtap`/`bindinput` 动态提取处理函数,再以 Page/wx mock 调用,避免以硬编码方法名或任意底栏代替真实交互。地址保存对 `createAnonymousAddress` 设置 spy,强制四个原始地址字段既不进入 Adapter 参数、返回值或快照,也不触发外部能力。

+ 7 - 0
openspec/changes/mrc-demo-mp-apply-001/tasks.md

@@ -25,6 +25,13 @@
 - [ ] 4.4 补齐并运行全链 Page/wx mock 契约:列表筛选/空态、套餐/自定义、记录筛选/勾选、手录校验、表单与地址回填、协议拦截建单、详情/进度、结算不可支付。
 - [ ] 4.5 逐页在 375×812 宿主截图对照原型;修复 P0/P1/P2 后才可更新视觉验收状态。
 
+### 2026-09-16 Wave 测试分层规则
+
+- [x] W1.1 将子页视觉/交互契约按 `MRC_SUBPAGE_WAVE` 分为 Wave 1(`identity`、`record-config`、`record-select`、`manual-record`、`address-create`)、Wave 2(`application-form`、`confirm-submit`)、Wave 3(`my-applications`、`order-detail`、`order-progress`、`settlement`)。
+- [x] W1.2 已执行 `MRC_SUBPAGE_WAVE=1 node tests/mrc-subpage-visual-contract.test.js` 和 `MRC_SUBPAGE_WAVE=1 node tests/mrc-subpage-interaction-contract.test.js` 至 GREEN;默认 `npm test` 通过 32 项且仅执行当前 Wave 1。
+- [ ] W2.1 Wave 2 开始前保留 `MRC_SUBPAGE_WAVE=2` 的 RED 契约,完成后再转 GREEN;不得删除或静默跳过其需求。
+- [ ] W3.1 Wave 3 开始前保留 `MRC_SUBPAGE_WAVE=3` 的 RED 契约,完成后再转 GREEN;三波完成后才可运行 `MRC_SUBPAGE_WAVE=all` 全链回归。
+
 ## 2026-09-15 实施证据
 
 - TDD RED:`node tests/mrc-application-hub.test.js` 在页面未创建时失败。