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

test: 完善病案复印交互门禁

anubis 6 дней назад
Родитель
Сommit
121bdd9bd6

+ 149 - 68
04-新项目源码_source/项目源码/pri-smart-hospital-miniprogram/tests/mrc-apply-entry-interaction.test.js

@@ -3,9 +3,9 @@ const path = require('path');
 
 const root = path.join(__dirname, '..');
 const applyEntryDir = path.join(root, 'packageMRC', 'pages', 'apply-entry');
+const patientCreateDir = path.join(root, 'packageMRC', 'pages', 'patient-create');
 const applyEntryScriptPath = path.join(applyEntryDir, 'index.js');
 const applyEntryTemplatePath = path.join(applyEntryDir, 'index.wxml');
-const patientCreateDir = path.join(root, 'packageMRC', 'pages', 'patient-create');
 const patientCreateScriptPath = path.join(patientCreateDir, 'index.js');
 const patientCreateTemplatePath = path.join(patientCreateDir, 'index.wxml');
 const adapterPath = path.join(root, 'packageMRC', 'common', 'mrcDemoState.js');
@@ -21,32 +21,69 @@ function readOptional(filePath) {
 }
 
 function assertNoForbiddenCapability(source, scope) {
-  expect(!/\bwx\.(?:request|upload\w*|choose(?:Image|Media|MessageFile)|getStorage(?:Sync)?|setStorage(?:Sync)?|removeStorage(?:Sync)?|clearStorage(?:Sync)?)\b/.test(source), `${scope}不得使用 wx.request、上传能力或 Storage`);
+  expect(!/\bwx\.\w*Storage\w*\b/i.test(source), `${scope}不得使用任意 wx.*Storage*(包括 getStorageInfo)能力`);
+  expect(!/\bwx\.(?:upload\w*|choose(?:Image|Video|Media|MessageFile)|openDocument|saveImageToPhotosAlbum)\b/.test(source), `${scope}不得使用上传、选择图片/视频/文件等能力`);
+  expect(!/\bconsole\.\w+\b/.test(source), `${scope}不得写入 console,避免泄漏本地输入`);
   expect(!/(?:utils\/(?:patientMaintenance|patientSelector|homePatient)|pages\/mine\/patientEdit)/.test(source), `${scope}不得引用既有就诊人服务或页面`);
 }
 
-function handlerForClass(template, className, interactionName) {
-  const tag = new RegExp(`<[^>]*class=["'][^"']*\\b${className}\\b[^"']*["'][^>]*>`, 'g').exec(template);
+function classNames(attributes) {
+  const match = attributes.match(/\bclass\s*=\s*(["'])(.*?)\1/);
+  return match ? match[2].trim().split(/\s+/).filter(Boolean) : [];
+}
+
+function findViewViolations(wxml, childClass, requiredAncestorClass) {
+  const stack = [];
+  const violations = [];
+  const tokens = /<\/?view\b[^>]*>/g;
+  let token;
+  while ((token = tokens.exec(wxml))) {
+    const raw = token[0];
+    if (/^<\//.test(raw)) {
+      if (stack.length) stack.pop();
+      continue;
+    }
+    const classes = classNames(raw);
+    if (classes.includes(childClass) && !stack.some((item) => item.includes(requiredAncestorClass))) {
+      violations.push(token.index);
+    }
+    if (!/\/\s*>$/.test(raw)) stack.push(classes);
+  }
+  return violations;
+}
+
+function tagBindingForClass(template, className, interactionName) {
+  const tag = (template.match(/<[^>]+>/g) || []).find((raw) => classNames(raw).includes(className));
   expect(Boolean(tag), `${interactionName}必须保留 .${className} 可点击节点`);
   if (!tag) return '';
-  const binding = /bindtap=["']([\w$]+)["']/.exec(tag[0]);
+  const binding = /bindtap=["']([\w$]+)["']/.exec(tag);
   expect(Boolean(binding), `${interactionName}必须绑定 tap 处理函数`);
   return binding ? binding[1] : '';
 }
 
-function loadApplyEntryPage() {
+function inputBindings(template) {
+  const bindings = [];
+  const tags = template.match(/<input\b[^>]*>/g) || [];
+  tags.forEach((tag, index) => {
+    const binding = /bindinput=["']([\w$]+)["']/.exec(tag);
+    if (binding) bindings.push({ handler: binding[1], index });
+  });
+  return bindings;
+}
+
+function loadPage(scriptPath, scope) {
   let definition;
   const originalPage = global.Page;
   global.Page = (config) => { definition = config; };
-  delete require.cache[require.resolve(applyEntryScriptPath)];
+  delete require.cache[require.resolve(scriptPath)];
   try {
-    require(applyEntryScriptPath);
+    require(scriptPath);
   } catch (error) {
-    failures.push(`申请入口 JS 必须可由 Page mock 加载:${error.message}`);
+    failures.push(`${scope} JS 必须可由 Page mock 加载:${error.message}`);
   } finally {
     global.Page = originalPage;
   }
-  expect(Boolean(definition), '申请入口必须通过 Page 注册,供真实小程序事件分发');
+  expect(Boolean(definition), `${scope}必须通过 Page 注册,供真实小程序事件分发`);
   return definition;
 }
 
@@ -63,7 +100,7 @@ function mountPage(definition) {
 
 function invoke(page, handlerName, event, interactionName) {
   if (!handlerName || typeof page[handlerName] !== 'function') {
-    failures.push(`${interactionName}的 ${handlerName || 'tap'} 处理函数必须在申请入口 Page 中实现`);
+    failures.push(`${interactionName}的 ${handlerName || 'tap'} 处理函数必须在 Page 中实现`);
     return undefined;
   }
   try {
@@ -75,87 +112,131 @@ function invoke(page, handlerName, event, interactionName) {
 }
 
 const applyEntryTemplate = readOptional(applyEntryTemplatePath);
+const patientCreateTemplate = readOptional(patientCreateTemplatePath);
+const patientCreateScript = readOptional(patientCreateScriptPath);
+const adapterSource = readOptional(adapterPath);
 expect(Boolean(applyEntryTemplate), '申请入口 WXML 必须存在');
-const serviceHandler = handlerForClass(applyEntryTemplate, 'service-btn', '服务 CTA');
-const applicationHandler = handlerForClass(applyEntryTemplate, 'my-application-card', '我的申请');
-const patientSelectorHandler = handlerForClass(applyEntryTemplate, 'patient-selector', '就诊人选择');
-const patientSheetHandler = handlerForClass(applyEntryTemplate, 'sheet-item', '就诊人抽屉选项');
-const demoEntryHandler = handlerForClass(applyEntryTemplate, 'demo-mode-entry', 'Demo 模式入口');
-const demoCloseHandler = handlerForClass(applyEntryTemplate, 'demo-mode-close', 'Demo 模式关闭');
-const patientAddHandler = handlerForClass(applyEntryTemplate, 'patient-add-card', '无就诊人添加');
+expect(Boolean(patientCreateTemplate), '本地 Mock 添加就诊人页必须提供可操作表单');
+expect(Boolean(patientCreateScript), '本地 Mock 添加就诊人页必须存在,当前交互契约应保持 RED 直至该页实现');
+
+const serviceHandler = tagBindingForClass(applyEntryTemplate, 'service-btn', '服务 CTA');
+const applicationHandler = tagBindingForClass(applyEntryTemplate, 'my-application-card', '我的申请');
+const patientSelectorHandler = tagBindingForClass(applyEntryTemplate, 'patient-selector', '就诊人选择');
+const patientSheetHandler = tagBindingForClass(applyEntryTemplate, 'sheet-item', '就诊人抽屉选项');
+const demoEntryHandler = tagBindingForClass(applyEntryTemplate, 'demo-mode-entry', 'Demo 模式入口');
+const demoCloseHandler = tagBindingForClass(applyEntryTemplate, 'demo-mode-close', 'Demo 模式关闭');
+const patientAddHandler = tagBindingForClass(applyEntryTemplate, 'patient-add-card', '无就诊人添加');
+const scenarioHandler = tagBindingForClass(applyEntryTemplate, 'chip', 'Demo 场景芯片');
+const sheetViolations = findViewViolations(applyEntryTemplate, 'sheet-item', 'patient-sheet');
+expect(sheetViolations.length === 0, `每个 .sheet-item 必须位于 .patient-sheet 内,违规位置:${sheetViolations.join(', ')}`);
+expect(/bindtap=["']goHome["']/.test(applyEntryTemplate) && /首页/.test(applyEntryTemplate), '底部“首页”必须绑定 goHome 返回宿主首页');
+
+const patientInputBindings = inputBindings(patientCreateTemplate);
+expect(patientInputBindings.length >= 4, '添加就诊人页必须至少保留姓名、证件号、手机号、验证码四项 bindinput 真实表单交互');
+const patientSubmitHandler = tagBindingForClass(patientCreateTemplate, 'next-btn', '添加就诊人提交');
+const patientAgreementHandler = tagBindingForClass(patientCreateTemplate, 'agree', '隐私协议确认');
+
+assertNoForbiddenCapability(readOptional(applyEntryScriptPath), '申请入口本地 Demo');
+assertNoForbiddenCapability(patientCreateScript, '本地 Mock 添加就诊人页');
+assertNoForbiddenCapability(adapterSource, 'MRC Demo Adapter');
 
 const originalWx = global.wx;
 const navigations = [];
+const switches = [];
+const backs = [];
+const toasts = [];
 global.wx = {
   navigateTo(options) { navigations.push(options || {}); },
-  showToast() {}
+  switchTab(options) { switches.push(options || {}); },
+  navigateBack(options) { backs.push(options || {}); },
+  showToast(options) { toasts.push(options || {}); }
 };
 
 try {
-  const definition = loadApplyEntryPage();
-  if (definition) {
-    const page = mountPage(definition);
-    invoke(page, patientAddHandler, {}, '无就诊人添加');
+  expect(typeof demoState.setPatientsForTest === 'function', 'Demo Adapter 必须提供仅测试可用的 setPatientsForTest,以隔离无就诊人与多患者场景');
+  const applyDefinition = loadPage(applyEntryScriptPath, '申请入口');
+  const applyPage = applyDefinition && mountPage(applyDefinition);
+  if (applyPage && typeof demoState.setPatientsForTest === 'function') {
+    demoState.setPatientsForTest({ patients: [], currentPatientId: '' });
+    invoke(applyPage, 'onShow', {}, '无就诊人入口刷新');
+    invoke(applyPage, serviceHandler, {}, '无就诊人服务 CTA');
+    expect(applyPage.data.showNoPatientGuide === true, '无就诊人点击“我要申请复印”必须展示本地添加引导/弹层');
+    invoke(applyPage, patientAddHandler, {}, '无就诊人添加');
     expect(navigations.some((item) => item.url === '/packageMRC/pages/patient-create/index'), '无就诊人添加必须实际导航到本地 patient-create 页面');
 
-    invoke(page, demoEntryHandler, {}, 'Demo 模式入口');
-    expect(page.data.showDemoMode === true, '点击 Demo 模式入口必须打开辅助面板');
-    invoke(page, demoCloseHandler, {}, 'Demo 模式关闭');
-    expect(page.data.showDemoMode === false, '关闭 Demo 模式必须恢复患者首屏');
-
-    if (typeof demoState.createAnonymousPatient === 'function') {
-      const created = demoState.createAnonymousPatient({
-        name: 'PATIENT_INPUT_NAME_MUST_NOT_PERSIST',
-        idCard: 'PATIENT_INPUT_ID_MUST_NOT_PERSIST',
-        mobile: 'PATIENT_INPUT_MOBILE_MUST_NOT_PERSIST'
-      });
-      expect(created && created.ok, 'Demo Adapter 必须能生成匿名演示就诊人');
-      invoke(page, 'onShow', {}, 'patient-create 返回后的入口刷新');
-      expect(page.data.currentPatient && /演示就诊人/.test(page.data.currentPatient.displayName || ''), 'patient-create 返回后 onShow 必须回填当前匿名就诊人');
-
-      invoke(page, patientSelectorHandler, {}, '就诊人选择');
-      expect(page.data.showPatientSheet === true, '点击当前就诊人必须打开底部选择抽屉');
-      const candidate = (page.data.patients || []).find((patient) => patient.id !== (page.data.currentPatient || {}).id);
-      expect(Boolean(candidate), '本地演示必须提供至少两名匿名就诊人供抽屉选择');
-      if (candidate) {
-        invoke(page, patientSheetHandler, { currentTarget: { dataset: { id: candidate.id } } }, '就诊人抽屉选项');
-        expect(page.data.showPatientSheet === false, '选择就诊人后必须关闭底部抽屉');
-        expect((page.data.currentPatient || {}).id === candidate.id, '选择就诊人后必须回填当前就诊人卡片');
-      }
-
-      invoke(page, serviceHandler, {}, '服务 CTA');
-      expect(navigations.some((item) => /^\/packageMRC\/pages\/identity\/index(?:\?|$)/.test(item.url || '')), '有匿名就诊人时“我要申请复印”必须进入身份验证页');
+    demoState.setPatientsForTest({
+      patients: [
+        { id: 'test-patient-a', displayName: '演示就诊人 A', tag: '默认' },
+        { id: 'test-patient-b', displayName: '演示就诊人 B', tag: '本人' }
+      ],
+      currentPatientId: 'test-patient-a'
+    });
+    invoke(applyPage, 'onShow', {}, '有就诊人入口刷新');
+    expect((applyPage.data.currentPatient || {}).id === 'test-patient-a', '入口 onShow 必须从本地 Adapter 回填当前就诊人');
+
+    invoke(applyPage, patientSelectorHandler, {}, '就诊人选择');
+    expect(applyPage.data.showPatientSheet === true, '点击当前就诊人必须打开底部选择抽屉');
+    invoke(applyPage, patientSheetHandler, { currentTarget: { dataset: { id: 'test-patient-b' } } }, '就诊人抽屉选项');
+    expect(applyPage.data.showPatientSheet === false, '选择就诊人后必须关闭底部抽屉');
+    expect((applyPage.data.currentPatient || {}).id === 'test-patient-b', '选择就诊人后必须回填当前就诊人卡片');
+
+    invoke(applyPage, demoEntryHandler, {}, 'Demo 模式入口');
+    expect(applyPage.data.showDemoMode === true, '点击 Demo 模式入口必须打开辅助面板');
+    invoke(applyPage, scenarioHandler, { currentTarget: { dataset: { id: 'SCN-PENDING-AUDIT' } } }, 'Demo 场景芯片');
+    expect((applyPage.data.snapshot || {}).activeScenarioId === 'SCN-PENDING-AUDIT', '点击场景芯片后 activeScenarioId 必须切换');
+    expect((applyPage.data.applicationSummary || {}).total === 1, '切换到待审核场景后我的申请摘要必须更新为 1 条');
+    invoke(applyPage, demoCloseHandler, {}, 'Demo 模式关闭');
+    expect(applyPage.data.showDemoMode === false, '关闭 Demo 模式必须恢复患者首屏');
+
+    invoke(applyPage, serviceHandler, {}, '有就诊人服务 CTA');
+    expect(navigations.some((item) => /^\/packageMRC\/pages\/identity\/index(?:\?|$)/.test(item.url || '')), '有匿名就诊人时“我要申请复印”必须进入身份验证页');
+    invoke(applyPage, applicationHandler, {}, '我的申请');
+    expect(navigations.some((item) => item.url === '/packageMRC/pages/my-applications/index'), '点击“我的申请”必须进入申请列表页');
+    invoke(applyPage, 'goHome', {}, '底部首页');
+    expect(switches.some((item) => item.url === '/pages/tabBar/index/index'), '底部“首页”必须通过 switchTab 返回宿主首页');
+
+    const originalCreate = demoState.createAnonymousPatient;
+    let createCalls = [];
+    if (typeof originalCreate === 'function') {
+      demoState.createAnonymousPatient = (input) => {
+        createCalls.push(input);
+        return originalCreate(input);
+      };
     } else {
       failures.push('Demo Adapter 必须提供 createAnonymousPatient,且不能写入原始表单值');
     }
-
-    invoke(page, applicationHandler, {}, '我的申请');
-    expect(navigations.some((item) => item.url === '/packageMRC/pages/my-applications/index'), '点击“我的申请”必须进入申请列表页');
+    try {
+      const patientDefinition = loadPage(patientCreateScriptPath, '添加就诊人');
+      if (patientDefinition) {
+        const patientPage = mountPage(patientDefinition);
+        invoke(patientPage, patientSubmitHandler, {}, '空表单提交');
+        expect(toasts.some((item) => /请|填写|同意/.test(item.title || '')) || Boolean(patientPage.data.errorMessage), '空表单提交必须显示行内校验或明确提示');
+
+        patientInputBindings.slice(0, 4).forEach((binding, index) => {
+          invoke(patientPage, binding.handler, { detail: { value: ['测试姓名', '110101199001011234', '13800138000', '123456'][index] }, currentTarget: { dataset: { field: ['name', 'idCard', 'mobile', 'verificationCode'][index] } } }, `添加就诊人第 ${index + 1} 项输入`);
+        });
+        invoke(patientPage, patientAgreementHandler, {}, '隐私协议确认');
+        invoke(patientPage, patientSubmitHandler, {}, '有效表单提交');
+        expect(createCalls.length === 1, '有效表单提交必须恰好调用一次 createAnonymousPatient');
+        expect(backs.length > 0, '匿名就诊人创建成功后必须 navigateBack 返回申请入口');
+        invoke(applyPage, 'onShow', {}, 'patient-create 返回后的入口刷新');
+        expect(applyPage.data.currentPatient && /演示就诊人/.test(applyPage.data.currentPatient.displayName || ''), 'patient-create 返回后入口 onShow 必须回填匿名就诊人');
+      }
+    } finally {
+      if (typeof originalCreate === 'function') demoState.createAnonymousPatient = originalCreate;
+    }
   }
 } finally {
   global.wx = originalWx;
 }
 
-const patientCreateScript = readOptional(patientCreateScriptPath);
-const patientCreateTemplate = readOptional(patientCreateTemplatePath);
-const adapterSource = readOptional(adapterPath);
-expect(Boolean(patientCreateScript), '本地 Mock 添加就诊人页必须存在,当前交互契约应保持 RED 直至该页实现');
-expect(Boolean(patientCreateTemplate), '本地 Mock 添加就诊人页必须提供可操作表单');
-expect(/bindinput=/.test(patientCreateTemplate), '添加就诊人页必须保留原型表单的真实输入交互');
-expect(/createAnonymousPatient/.test(patientCreateScript), '添加就诊人页必须仅通过 Demo Adapter 创建匿名患者');
-expect(typeof demoState.createAnonymousPatient === 'function', 'Demo Adapter 必须提供 createAnonymousPatient,且不能写入原始表单值');
-
-assertNoForbiddenCapability(patientCreateScript, '本地 Mock 添加就诊人页');
-assertNoForbiddenCapability(adapterSource, 'MRC Demo Adapter');
-
-if (typeof demoState.createAnonymousPatient === 'function') {
+if (typeof demoState.getSnapshot === 'function') {
   const snapshotResult = demoState.getSnapshot();
   expect(snapshotResult && snapshotResult.ok, '匿名患者创建后必须能读取 Demo Adapter 快照');
   const snapshotText = JSON.stringify(snapshotResult && snapshotResult.data);
-  ['PATIENT_INPUT_NAME_MUST_NOT_PERSIST', 'PATIENT_INPUT_ID_MUST_NOT_PERSIST', 'PATIENT_INPUT_MOBILE_MUST_NOT_PERSIST'].forEach((input) => {
+  ['测试姓名', '110101199001011234', '13800138000'].forEach((input) => {
     expect(!snapshotText.includes(input), `Adapter 快照不得包含 patient-create 的原始输入:${input}`);
   });
-  expect(/演示就诊人/.test(snapshotText), 'Adapter 快照只能暴露生成后的匿名演示就诊人');
 }
 
 if (failures.length) {

+ 1 - 1
docs/plans/2026-09-16-mrc-mobile-visual-remediation.md

@@ -90,7 +90,7 @@ Expected: PASS。
 
 **Step 1: Write failing interaction tests**
 
-覆盖:默认患者状态渲染;点击“我要申请复印”进入身份验证;点击“我的申请”进入列表;点击患者打开/关闭本地底部面板并切换匿名患者;点击演示模式打开/关闭场景面板、切换场景后摘要更新;无患者时按 `09-添加就诊人页.html` 进入本地 Mock 添加页、完成行内校验、生成匿名患者并返回入口回填。新增 `mrc-apply-entry-interaction.test.js` 必须以最小 `Page`/`wx` mock 加载申请入口 JS,按 WXML 绑定实际调用服务 CTA、我的申请、Demo 面板、就诊人抽屉及无患者添加动作,并断言状态变化与导航目标;不得以静态字符串检查替代行为断言。测试还必须将唯一原始姓名/证件号/手机号输入传给匿名患者创建流程,并断言 Adapter 快照不含任一原始输入,仅含生成后的“演示就诊人”;同时静态拒绝 `patient-create` 页面和 Adapter 使用 `wx.request`、上传/选文件、Storage、既有就诊人服务或 `pages/mine/patientEdit`。
+覆盖:默认患者状态渲染;点击“我要申请复印”进入身份验证;点击“我的申请”进入列表;点击患者打开/关闭本地底部面板并切换匿名患者;点击演示模式打开/关闭场景面板、切换场景后摘要更新;无患者时按 `09-添加就诊人页.html` 进入本地 Mock 添加页、完成行内校验、生成匿名患者并返回入口回填。新增 `mrc-apply-entry-interaction.test.js` 必须以最小 `Page`/`wx` mock 加载申请入口和 `patient-create` JS,按 WXML 绑定实际调用服务 CTA、我的申请、Demo 面板/场景芯片、就诊人抽屉、无患者添加、表单输入/校验/提交和底部首页动作,并断言状态变化与导航目标;不得以静态字符串检查替代行为断言。Adapter 必须提供仅测试可用的受控患者状态辅助方法,以隔离无患者与多患者场景且不影响用户可见 Demo 行为。测试还必须将唯一原始姓名/证件号/手机号输入传给匿名患者创建流程,并断言 Adapter 快照不含任一原始输入,仅含生成后的“演示就诊人”;同时静态拒绝申请入口、`patient-create` 页面和 Adapter 使用任何 `wx.*Storage*`(含 `getStorageInfo`)、上传、选择图片/视频/文件、`console.*`、既有就诊人服务或 `pages/mine/patientEdit`。
 
 **Step 2: Run tests to verify RED**
 

+ 1 - 1
openspec/changes/mrc-demo-mp-apply-001/proposal.md

@@ -22,6 +22,6 @@
 
 ## Impact
 
-- Affected code: 新增 `packageMRC/` 内的申请入口、我的申请、本地匿名添加就诊人页面和局部展示组件;不修改既有小程序页面或公共组件。
+- Affected code: 新增或调整 `packageMRC/` 内的申请入口、我的申请、本地匿名添加就诊人页面和局部展示组件;既有宿主代码仅允许受控修改 `utils/homeMenu.js` 与 `utils/homeRoute.js`,用于既有“病案复印(Demo)”单一菜单路由修正及同菜单重复项归一,不修改其他宿主页面、菜单或公共组件。
 - Dependencies: `mrc-demo-mp-base-001`、`mrc-demo-shared-state-001`、`demo-state-v1.md`、已评审移动端原型 01/01-1/09。
 - Exclusions: 真实接口、生产订单状态、真实患者资料与所有写入后端。