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

test(cfc-frontend): 合入 S-cfc-51/52/53/54 RED 契约测试

Sisyphus Agent 2 дней назад
Родитель
Сommit
d91095be5a

+ 186 - 0
cfc-frontend/__tests__/pages/signature/signature.consignee.test.js

@@ -0,0 +1,186 @@
+/**
+ * 契约级单测(TDD-RED)——能力键 family.signature.consignee
+ *
+ * 溯源:
+ *   requirement: docs/superpowers/specs/2026-11-07-signature-page-landscape.md
+ *                §3 AC-R3 / §5 WI-4(水机购买签名改造:收件人信息段 + 手签名段)
+ *   design:      cfc-frontend/pages/signature/components/consignee-edit.vue(签收人信息 + 手签名段组件)
+ *
+ * 说明(既有 Jest 体系约束):
+ *   cfc-frontend 的 jest.config.js 仅 transform .js(babel-jest),moduleFileExtensions 不含 .vue,
+ *   且未安装 vue-jest/jsdom 组件环境 —— 既有约定为「抽取被测逻辑断言」而非挂载 .vue
+ *   (见 __tests__/components/FamilyRelationGraph.test.js)。
+ *   本文件沿用 join-form.required-info.test.js 的风格:直接加载真实 SFC 的 <script>/<template> 源码——
+ *   - <script>:把 import 语句替换为注入桩后求值,得到真实组件的 data()/methods,做行为级断言;
+ *   - <template>:读取模板文本,做结构契约断言(收件人姓名/电话/地址字段、手签名确认段、确认回调)。
+ *
+ *   当前状态:组件 pages/signature/components/consignee-edit.vue 尚未落地(实现未交付)→ 断言文件存在性 → 预期 RED。
+ *   实现方在 GREEN 阶段按本文件所锚定的契约补足组件后,断言应可翻绿。
+ *   本文件不修改 src/main、不修改任何既有测试。
+ */
+
+import fs from 'fs'
+import path from 'path'
+
+// 测试位于 __tests__/pages/signature/,组件位于 pages/signature/components/ → 需三级回退到 cfc-frontend
+const CONSIGNEE_EDIT_PATH = path.join(__dirname, '../../../pages/signature/components/consignee-edit.vue')
+
+// ---------------------------------------------------------------------------
+// 工具:加载真实 SFC 的 <script> 逻辑(行为级)与 <template> 结构(结构级)
+// 风格对齐 cfc-frontend/__tests__/family-join/join-form.required-info.test.js
+// ---------------------------------------------------------------------------
+
+/** 把 ES import 语句替换为从 globalThis.__consigneeEditStubs 取桩的声明(不解析模块,避免污染业务实现) */
+function stubImports(script, stubMap) {
+  return script.replace(/import\s+([^;]+?)\s+from\s+['"]([^'"]+)['"];?/g, function (m, clause, id) {
+    var names = []
+    var c = clause.trim()
+    var braceIdx = c.indexOf('{')
+    if (braceIdx >= 0) {
+      var def = c.slice(0, braceIdx).replace(/,\s*$/, '').trim()
+      if (def) names.push(def)
+      var named = c.slice(braceIdx + 1, c.lastIndexOf('}'))
+      named.split(',').forEach(function (part) {
+        var p = part.trim()
+        if (!p) return
+        var parts = p.split(/\s+as\s+/)
+        var local = parts.length > 1 ? parts[1].trim() : parts[0].trim()
+        if (local) names.push(local)
+      })
+    } else if (c.indexOf('*') === 0) {
+      var nsMatch = c.match(/\*\s+as\s+(\w+)/)
+      if (nsMatch) names.push(nsMatch[1])
+    } else {
+      names.push(c)
+    }
+    return names.map(function (n) {
+      return 'var ' + n + ' = (globalThis.__consigneeEditStubs[' + JSON.stringify(id) + '] || {})[' + JSON.stringify(n) + '];'
+    }).join('\n')
+  })
+}
+
+/** 加载真实 SFC:返回 { component, template }(component = 真实 <script> 的 export default 对象);文件缺失时抛出明确错误 */
+function loadConsigneeEdit() {
+  if (!fs.existsSync(CONSIGNEE_EDIT_PATH)) {
+    throw new Error('组件文件尚未实现(GREEN 未交付):' + CONSIGNEE_EDIT_PATH)
+  }
+  var src = fs.readFileSync(CONSIGNEE_EDIT_PATH, 'utf8')
+  var tmplMatch = src.match(/<template>([\s\S]*?)<\/template>/)
+  var scriptMatch = src.match(/<script>([\s\S]*?)<\/script>/)
+  if (!scriptMatch) throw new Error('无法在 consignee-edit.vue 中定位 <script> 块')
+  var script = stubImports(scriptMatch[1], globalThis.__consigneeEditStubs || {})
+  script = script.replace(/export default\s*\{/, 'module.exports = {')
+  var mod = { exports: {} }
+  // eslint-disable-next-line no-new-func
+  var fn = new Function('module', 'exports', script)
+  fn(mod, mod.exports)
+  return { component: mod.exports, template: tmplMatch ? tmplMatch[1] : '' }
+}
+
+/** 依据真实组件的 data()/methods 构造可驱动上下文(模拟 Vue 把 data 与 methods 合并到实例) */
+function instantiate(component) {
+  var vm = {}
+  var data = typeof component.data === 'function' ? component.data.call(vm) : (component.data || {})
+  Object.keys(data).forEach(function (k) { vm[k] = data[k] })
+  var methods = component.methods || {}
+  Object.keys(methods).forEach(function (k) { vm[k] = methods[k] })
+  return vm
+}
+
+// ---------------------------------------------------------------------------
+// 测试夹具(桩表:为 GREEN 阶段组件可能存在的 import 预留,缺失时按可选访问处理)
+// ---------------------------------------------------------------------------
+
+var stubs
+
+beforeEach(function () {
+  global.uni.showToast.mockReset()
+  global.uni.navigateBack.mockReset()
+  global.uni.request.mockReset()
+  stubs = {
+    getConsigneeData: jest.fn(function () {
+      return Promise.resolve({ name: '张三', phone: '13800001111', address: '北京市朝阳区' })
+    }),
+    onConfirm: jest.fn(function (dataUrl) { return dataUrl }),
+    onAgreeChange: jest.fn(function (e) { return e })
+  }
+  globalThis.__consigneeEditStubs = {
+    '@/utils/api.js': { getConsigneeData: stubs.getConsigneeData }
+  }
+  jest.useFakeTimers()
+})
+
+afterEach(function () {
+  jest.useRealTimers()
+  delete globalThis.__consigneeEditStubs
+})
+
+// ---------------------------------------------------------------------------
+// AC-R0 组件文件存在性契约(本能力 TDD-RED:实现未落地 → 文件应缺失 → 本组断言红)
+// ---------------------------------------------------------------------------
+
+describe('AC-R0 组件文件存在性契约', function () {
+  it('pages/signature/components/consignee-edit.vue 必须存在', function () {
+    expect(fs.existsSync(CONSIGNEE_EDIT_PATH)).toBe(true)
+  })
+})
+
+// ---------------------------------------------------------------------------
+// AC-R1 <script> 契约:组件导出收件人信息编辑的 data()/methods(GREEN 后强制加载真实源码断言)
+// ---------------------------------------------------------------------------
+
+describe('AC-R1 <script> 契约:收件人信息 + 手签名确认回调', function () {
+  var component
+
+  beforeEach(function () {
+    // 文件缺失时 loadConsigneeEdit 抛错 → 每条用例红(明确的 RED 信号);GREEN 后加载真实 SFC 断言
+    component = loadConsigneeEdit().component
+  })
+
+  it('组件必须定义收件人信息字段(姓名/电话/地址)', function () {
+    var vm = instantiate(component)
+    expect(vm.consigneeName).toBeDefined()
+    expect(vm.consigneePhone).toBeDefined()
+    expect(vm.consigneeAddress).toBeDefined()
+  })
+
+  it('组件必须暴露确认回调方法(手签名确认后写回收件人信息)', function () {
+    expect(typeof component.methods.onConfirm).toBe('function')
+    component.methods.onConfirm.call(instantiate(component), 'data:image/png;base64,xxxx')
+    expect(stubs.onConfirm).toHaveBeenCalled()
+  })
+})
+
+// ---------------------------------------------------------------------------
+// AC-R2 <template> 契约:签收人信息输入段 + 手签名确认段 + 确认回调绑定
+// ---------------------------------------------------------------------------
+
+describe('AC-R2 <template> 契约:签收人信息段 + 手签名确认段', function () {
+  var template
+
+  beforeEach(function () {
+    template = loadConsigneeEdit().template
+  })
+
+  it('模板包含收件人姓名/电话/地址输入绑定', function () {
+    expect(template).toMatch(/consigneeName/)
+    expect(template).toMatch(/consigneePhone/)
+    expect(template).toMatch(/consigneeAddress/)
+  })
+
+  it('模板包含手签名确认段(内嵌 HandSignature 组件)', function () {
+    expect(template).toMatch(/<hand-signature/)
+  })
+
+  it('模板将确认事件绑定到确认回调(@confirm="onConfirm")', function () {
+    expect(template).toMatch(/@confirm\s*=\s*["']onConfirm["']/)
+  })
+})
+
+// ---------------------------------------------------------------------------
+// 权限流:水机购买收件人信息编辑属于已登录购物流程,无角色门禁分支(不适用项,显式记录)
+// ---------------------------------------------------------------------------
+
+describe.skip('权限流:收件人信息编辑无角色门禁,不适用', function () {
+  it('占位:该能力面向已登录购买流程,无 role 分支(详见汇报)', function () {})
+})

+ 134 - 0
cfc-frontend/__tests__/pages/signature/signature.healthPlan.test.js

@@ -0,0 +1,134 @@
+/**
+ * 契约级单测(TDD-RED)——能力键 family.signature.healthPlan
+ *
+ * 溯源:
+ *   requirement: docs/superpowers/specs/2026-11-07-signature-page-landscape.md
+ *                §3 AC-R3(签署回传)、§5 WI-3(问卷完成签名改造:移除内嵌画布 → 「去签名」按钮 + 已签预览;
+ *                原「确认签名并生成方案」按钮逻辑不变,仍依 signatureDataUrl 判空禁用)
+ *   design:      cfc-frontend/pages/signature/components/health-plan-summary.vue
+ *                —— Step 3 健康计划摘要组件(编排侧 WI-3 设计锚点:展示计划名/周期/项目/参与人;提供确认回调)
+ *
+ * TDD 现状(预期 RED):
+ *   pages/signature/components/ 下当前仅有 hand-signature/,health-plan-summary.vue 尚未落地 →
+ *   - 存在性断言失败(主红信号);
+ *   - 契约断言经 loadTemplate/loadScript 加载组件时抛出「契约未落地」错误(溯源清晰的次红信号)。
+ * 实现方在后续 GREEN 阶段落地该组件并导出健康计划摘要契约后,本文件断言即翻绿。
+ *
+ * 说明(既有 Jest 体系约束):与 __tests__/pages/signature/* 同款——只读 .vue 源码做结构/行为契约断言,
+ * 不求值 import(本文件仅文本断言,不加载模块),不修改 pages/、components/、utils/ 任何业务代码。
+ */
+
+const fs = require('fs')
+const path = require('path')
+
+const HEALTH_PLAN_SUMMARY = path.join(
+  __dirname,
+  '../../../pages/signature/components/health-plan-summary.vue'
+)
+
+// ---------------------------------------------------------------------------
+// 工具:加载真实 SFC 的 <template>/<script> 文本(组件落地后生效;缺失时抛「契约未落地」)
+// ---------------------------------------------------------------------------
+
+function ensureExists() {
+  if (!fs.existsSync(HEALTH_PLAN_SUMMARY)) {
+    throw new Error('契约未落地:pages/signature/components/health-plan-summary.vue 不存在(GREEN 阶段需新建该组件)')
+  }
+}
+
+function loadTemplate() {
+  ensureExists()
+  const src = fs.readFileSync(HEALTH_PLAN_SUMMARY, 'utf8')
+  const m = src.match(/<template>([\s\S]*?)<\/template>/)
+  if (!m) throw new Error('health-plan-summary.vue 缺少 <template> 块')
+  return m[1]
+}
+
+function loadScript() {
+  ensureExists()
+  const src = fs.readFileSync(HEALTH_PLAN_SUMMARY, 'utf8')
+  const m = src.match(/<script>([\s\S]*?)<\/script>/)
+  if (!m) throw new Error('health-plan-summary.vue 缺少 <script> 块')
+  return m[1]
+}
+
+// ---------------------------------------------------------------------------
+// AC1 组件存在性(TDD-RED 主红信号)
+// ---------------------------------------------------------------------------
+
+describe('AC1 契约:health-plan-summary.vue 存在', function () {
+  test('组件文件 pages/signature/components/health-plan-summary.vue 存在', function () {
+    expect(fs.existsSync(HEALTH_PLAN_SUMMARY)).toBe(true)
+  })
+})
+
+// ---------------------------------------------------------------------------
+// AC2 Step 3 展示健康计划摘要:计划名 / 周期 / 项目 / 参与人
+// ---------------------------------------------------------------------------
+
+describe('AC2 契约:Step 3 展示健康计划摘要(计划名/周期/项目/参与人)', function () {
+  test('展示「计划名」及数据绑定', function () {
+    const tpl = loadTemplate()
+    expect(tpl).toMatch(/计划名/)
+    expect(tpl).toMatch(/planName|plan\.name|plan_name/)
+  })
+
+  test('展示「周期」及数据绑定', function () {
+    const tpl = loadTemplate()
+    expect(tpl).toMatch(/周期/)
+    expect(tpl).toMatch(/cycle|cycleText|周期/)
+  })
+
+  test('展示「项目」列表(label + 列表渲染)', function () {
+    const tpl = loadTemplate()
+    expect(tpl).toMatch(/项目/)
+    expect(tpl).toMatch(/items|projects|planItems/)
+    expect(tpl).toMatch(/v-for/)
+  })
+
+  test('展示「参与人」列表(label + 列表渲染)', function () {
+    const tpl = loadTemplate()
+    expect(tpl).toMatch(/参与人/)
+    expect(tpl).toMatch(/participants|participant|members/)
+    expect(tpl).toMatch(/v-for/)
+  })
+})
+
+// ---------------------------------------------------------------------------
+// AC3 提供确认回调
+// ---------------------------------------------------------------------------
+
+describe('AC3 契约:提供确认回调', function () {
+  test('模板将确认按钮绑定到 onConfirm(含「确认」文案)', function () {
+    const tpl = loadTemplate()
+    expect(tpl).toMatch(/@(?:click|tap)="onConfirm"/)
+    expect(tpl).toMatch(/确认/)
+  })
+
+  test('script 定义 onConfirm 方法并 emit confirm 事件', function () {
+    const script = loadScript()
+    expect(script).toMatch(/onConfirm\s*:/)
+    expect(script).toMatch(/emit\(['"]confirm/)
+  })
+})
+
+// ---------------------------------------------------------------------------
+// AC4 Vue 组件导出
+// ---------------------------------------------------------------------------
+
+describe('AC4 契约:Vue 组件导出', function () {
+  test('script 通过 export default 导出组件(Option API)', function () {
+    const script = loadScript()
+    expect(script).toMatch(/export\s+default/)
+  })
+})
+
+// ---------------------------------------------------------------------------
+// 权限流:健康计划签署页 Step 3 摘要组件面向「家长端问卷完成签署」,
+// 无角色门禁分支(家长/成长规划师均可用,规格未定义角色差异),
+// 属《编排派发》中「权限(适用时)」的不适用项,显式记录。
+// ---------------------------------------------------------------------------
+
+describe.skip('权限流:健康计划签署无角色门禁,不适用', function () {
+  test('占位:该能力面向问卷完成签署组件,无 role 分支(详见汇报)', function () {})
+})

+ 177 - 0
cfc-frontend/__tests__/pages/signature/signature.landscape.contract.test.js

@@ -0,0 +1,177 @@
+/**
+ * 契约级单测 —— 签名页横屏适配(能力键 family.signature.landscape)
+ *
+ * 溯源:
+ *   SRS:  docs/superpowers/specs/2026-11-07-signature-page-landscape.md
+ *         §3 AC-R2(签名页为横屏全屏;加载时切横屏、离开时恢复竖屏,H5 不支持时优雅降级)
+ *         §5 WI-2(进入/退出横屏适配:微信 wx.setPageOrientation 切横屏;H5 降级不切横屏)
+ *   设计锚点: pages/signature/signature.vue 的 enterLandscape / restorePortrait 接线
+ *            (onLoad / onUnload / doBack)+ wx.setPageOrientation('landscape' / 'portrait')
+ *   台账: tests/test-cases/signature.md(TC-LANDSCAPE-01~08)
+ *
+ * 覆盖(独立契约,与 signature.canvas2d.test.js / signature.landscape.superseded.js 不重名):
+ *   TC-LANDSCAPE-01  enterLandscape / restorePortrait 方法存在
+ *   TC-LANDSCAPE-02  onLoad 进入即切横屏(调 enterLandscape)
+ *   TC-LANDSCAPE-03  onUnload 退出恢复竖屏(调 restorePortrait)
+ *   TC-LANDSCAPE-04  doBack 在 navigateBack 前恢复竖屏
+ *   TC-LANDSCAPE-05  enterLandscape 调 wx.setPageOrientation({ orientation: 'landscape' })
+ *   TC-LANDSCAPE-06  restorePortrait 调 wx.setPageOrientation({ orientation: 'portrait' })
+ *   TC-LANDSCAPE-07  H5(无 wx)降级不切横屏且不抛错
+ *   TC-LANDSCAPE-08  doBack 防重复返回(_backed 标志),onUnload 恢复竖屏
+ *
+ * 本文件只读源码并求值 script 段,不修改任何业务代码。
+ */
+
+const fs = require('fs')
+const path = require('path')
+
+const PAGE = path.join(__dirname, '../../../pages/signature/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]
+}
+
+// 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]
+    })
+  )
+}
+
+// 行为级测试用页面实例构造器:可注入 wx / window / uni 桩
+function makePageWith(sandbox) {
+  const base = {
+    uni: { getWindowInfo: function () { return { windowHeight: 390, windowWidth: 844 } } },
+    wx: undefined,
+    window: undefined,
+  }
+  const merged = Object.assign({}, base, sandbox || {})
+  if (sandbox && sandbox.uni) {
+    merged.uni = Object.assign({}, base.uni, sandbox.uni)
+  }
+  const opts = loadOptions(PAGE, merged)
+  const data = typeof opts.data === 'function' ? opts.data() : opts.data
+  const vm = Object.assign({}, data, opts.methods)
+  vm.$refs = {}
+  return vm
+}
+
+describe('签名页横屏适配契约 · family.signature.landscape', function () {
+  const pageScript = readScript(PAGE)
+
+  test('TC-LANDSCAPE-01 enterLandscape / restorePortrait 方法存在', function () {
+    expect(pageScript).toMatch(/enterLandscape\s*:/)
+    expect(pageScript).toMatch(/restorePortrait\s*:/)
+    // 两方法须为真实函数(行为可调)
+    const vm = makePageWith({ wx: undefined })
+    expect(typeof vm.enterLandscape).toBe('function')
+    expect(typeof vm.restorePortrait).toBe('function')
+  })
+
+  test('TC-LANDSCAPE-02 onLoad 进入即切横屏(调 enterLandscape)', function () {
+    expect(pageScript).toMatch(/onLoad\s*:\s*function[\s\S]*?enterLandscape\(\)/)
+  })
+
+  test('TC-LANDSCAPE-03 onUnload 退出恢复竖屏(调 restorePortrait)', function () {
+    expect(pageScript).toMatch(/onUnload\s*:\s*function[\s\S]*?restorePortrait\(\)/)
+  })
+
+  test('TC-LANDSCAPE-04 doBack 在 navigateBack 前恢复竖屏', function () {
+    expect(pageScript).toMatch(/doBack\s*:\s*function[\s\S]*?restorePortrait\(\)[\s\S]*?navigateBack\(\)/)
+  })
+
+  test('TC-LANDSCAPE-05 enterLandscape 调 wx.setPageOrientation landscape', function () {
+    const calls = []
+    const vm = makePageWith({
+      wx: { setPageOrientation: function (cfg) { calls.push(cfg) } }
+    })
+    vm.enterLandscape()
+    expect(calls.length).toBe(1)
+    expect(calls[0].orientation).toBe('landscape')
+    // 旋转后重算画布高度(complete 回调)
+    expect(typeof calls[0].complete).toBe('function')
+  })
+
+  test('TC-LANDSCAPE-06 restorePortrait 调 wx.setPageOrientation portrait', function () {
+    const calls = []
+    const vm = makePageWith({
+      wx: { setPageOrientation: function (cfg) { calls.push(cfg) } }
+    })
+    vm.restorePortrait()
+    expect(calls.length).toBe(1)
+    expect(calls[0].orientation).toBe('portrait')
+  })
+
+  test('TC-LANDSCAPE-07 H5(无 wx)降级不切横屏且不抛错', function () {
+    let setCalled = false
+    const vm = makePageWith({
+      wx: {
+        setPageOrientation: function () { setCalled = true }
+      }
+    })
+    // H5:wx 为 undefined
+    const h5 = makePageWith({ wx: undefined })
+    expect(function () { h5.enterLandscape() }).not.toThrow()
+    expect(function () { h5.restorePortrait() }).not.toThrow()
+    // 降级路径不得调用 setPageOrientation(不切横屏)
+    expect(setCalled).toBe(false)
+  })
+
+  test('TC-LANDSCAPE-08 doBack 防重复返回 + onUnload 恢复竖屏', function () {
+    const seq = []
+    const vm = makePageWith({
+      wx: { setPageOrientation: function (cfg) { seq.push('orient:' + cfg.orientation) } },
+      uni: {
+        getWindowInfo: function () { return { windowHeight: 390 } },
+        navigateBack: function () { seq.push('navigateBack') }
+      }
+    })
+    vm.doBack()
+    expect(seq).toEqual(['orient:portrait', 'navigateBack'])
+    // 防重复返回
+    vm.doBack()
+    expect(seq).toEqual(['orient:portrait', 'navigateBack'])
+    // onUnload 独立恢复竖屏(onUnload 走 opts.onUnload,单独构造)
+    const seq2 = []
+    const opts = loadOptions(PAGE, {
+      wx: { setPageOrientation: function (cfg) { seq2.push('orient:' + cfg.orientation) } },
+      uni: { getWindowInfo: function () { return { windowHeight: 390 } } }
+    })
+    const data = typeof opts.data === 'function' ? opts.data() : opts.data
+    const vm2 = Object.assign({}, data, opts.methods)
+    vm2.$refs = {}
+    opts.onUnload.call(vm2)
+    expect(seq2).toEqual(['orient:portrait'])
+  })
+})

+ 320 - 0
cfc-frontend/__tests__/pages/signature/signature.routeIntegrate.test.js

@@ -0,0 +1,320 @@
+/**
+ * 契约级单测(TDD)——能力键 family.signature.routeIntegrate
+ *
+ * 溯源:
+ *   requirement: docs/superpowers/specs/2026-11-07-signature-page-landscape.md
+ *                §3 AC-R5(无回归:pages.json 路由注册正确、无遗留内嵌 hand-signature)
+ *                §5 WI-5(路由注册与联调验证)
+ *   design:      cfc-frontend/pages.json 路由注册(root=pages/signature + path=signature)
+ *                + pages/signature/signature.vue(回退/恢复逻辑 onUnload/doBack)
+ *
+ * 覆盖(对应 WI-5 验收条件):
+ *   AC5.1 pages.json 正确注册 pages/signature/signature 路由(subPackages root=pages/signature
+ *          + path=signature),全量路由唯一无重复,navigationStyle: custom 真全屏
+ *   AC5.2 页面文件 pages/signature/signature.vue 存在,其编译依赖(hand-signature 组件、
+ *         utils/signature-bus.js)存在,模板引用组件契约不缺失
+ *   AC5.3 两处入口(health-plan-summary / consignee-edit)均 navigateTo 新签名页;
+ *         全局业务页无内嵌 <hand-signature>(仅签名页内复用)
+ *   AC5.4 回退契约:onUnload 注销尺寸监听并恢复竖屏;doBack 在 navigateBack 前恢复竖屏、
+ *         _backed 防重复返回;onBack / onConfirm 均收敛到 doBack(回传后退出)
+ *
+ * 方法:只读 pages.json 与 signature.vue 源码并求值 <script> 段(同
+ *       __tests__/pages/signature/signature.canvas2d.test.js 约定),
+ *       不挂载组件、不修改任何业务代码。实现疑似已存在 → 预期意外全绿。
+ */
+
+const fs = require('fs')
+const path = require('path')
+
+const FRONT_ROOT = path.join(__dirname, '../../..')
+const PAGES_JSON = path.join(FRONT_ROOT, 'pages.json')
+const PAGES_DIR = path.join(FRONT_ROOT, 'pages')
+const PAGE = path.join(FRONT_ROOT, 'pages/signature/signature.vue')
+const PAD = path.join(FRONT_ROOT, 'pages/signature/components/hand-signature/hand-signature.vue')
+const SIG_BUS = path.join(FRONT_ROOT, 'utils/signature-bus.js')
+const ENTRY_HEALTH = path.join(FRONT_ROOT, 'pages/health/health-plan-summary.vue')
+const ENTRY_CONSIGNEE = path.join(FRONT_ROOT, 'pages/shop/consignee-edit/consignee-edit.vue')
+
+// ---------------------------------------------------------------------------
+// 工具
+// ---------------------------------------------------------------------------
+
+function readRaw(file) {
+  return fs.readFileSync(file, 'utf8')
+}
+
+function readScript(file) {
+  const src = readRaw(file)
+  const m = src.match(/<script>([\s\S]*?)<\/script>/)
+  if (!m) throw new Error('无法在 ' + file + ' 中定位 <script> 块(路由契约依赖页面脚本存在)')
+  return m[1]
+}
+
+function readTemplate(file) {
+  const src = readRaw(file)
+  const m = src.match(/<template>([\s\S]*?)<\/template>/)
+  return m ? m[1] : ''
+}
+
+/** 剥离 HTML 注释与 JS 块注释,用于「标签是否真实出现在代码中」的判断 */
+function stripComments(src) {
+  return src
+    .replace(/<!--[\s\S]*?-->/g, '')
+    .replace(/\/\*[\s\S]*?\*\//g, '')
+}
+
+function collectVueFiles(dir, out) {
+  out = out || []
+  fs.readdirSync(dir, { withFileTypes: true }).forEach(function (ent) {
+    if (ent.name === 'node_modules') return
+    const full = path.join(dir, ent.name)
+    if (ent.isDirectory()) collectVueFiles(full, out)
+    else if (ent.name.endsWith('.vue')) out.push(full)
+  })
+  return out
+}
+
+/** 解析 pages.json → { json, routes },routes 为全部页面的完整路由(分包展开为 root/path) */
+function loadRoutes() {
+  const json = JSON.parse(readRaw(PAGES_JSON))
+  const routes = []
+  ;(json.pages || []).forEach(function (p) { routes.push(p.path) })
+  ;(json.subPackages || []).forEach(function (sub) {
+    ;(sub.pages || []).forEach(function (p) { routes.push(sub.root + '/' + p.path) })
+  })
+  return { json: json, routes: routes }
+}
+
+/** 把 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(' ')
+  })
+}
+
+/** 求值 SFC <script> 的 export default 选项对象(uni/wx/window 经 sandbox 注入) */
+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]
+    })
+  )
+}
+
+/** 构造行为级 vm:data() + methods 合并(模拟 Vue 实例),$refs 置空 */
+function makeVm(sandbox) {
+  const opts = loadOptions(PAGE, sandbox)
+  const data = typeof opts.data === 'function' ? opts.data() : opts.data
+  const vm = Object.assign({}, data, opts.methods)
+  vm.$refs = {}
+  return { vm: vm, opts: opts }
+}
+
+// ---------------------------------------------------------------------------
+// AC5.1 路由注册:pages.json 已注册 pages/signature/signature,且唯一、样式正确
+// ---------------------------------------------------------------------------
+
+describe('AC5.1 pages.json 路由注册(root=pages/signature + path=signature)', function () {
+  let routes
+  let sub
+  let page
+
+  beforeAll(function () {
+    const loaded = loadRoutes()
+    routes = loaded.routes
+    sub = loaded.json.subPackages.filter(function (s) { return s.root === 'pages/signature' })
+    page = sub.length
+      ? sub[0].pages.filter(function (p) { return p.path === 'signature' })
+      : []
+  })
+
+  it('subPackages 注册 root=pages/signature 分包,且仅注册一次', function () {
+    expect(sub.length).toBe(1)
+  })
+
+  it('分包 pages 内注册 path=signature(组合路由 pages/signature/signature)', function () {
+    expect(page.length).toBe(1)
+  })
+
+  it('全量路由(主包 pages + subPackages)中 pages/signature/signature 唯一无重复', function () {
+    const hits = routes.filter(function (r) { return r === 'pages/signature/signature' })
+    expect(hits.length).toBe(1)
+  })
+
+  it('签名页 style 为 navigationStyle: custom(真全屏)+ 标题「签名」', function () {
+    expect(page[0].style).toMatchObject({ navigationStyle: 'custom', navigationBarTitleText: '签名' })
+  })
+})
+
+// ---------------------------------------------------------------------------
+// AC5.2 页面文件与编译依赖存在:signature.vue 及其引用的组件/工具不缺失
+// ---------------------------------------------------------------------------
+
+describe('AC5.2 页面文件与编译依赖(编译不再缺失文件即证明路由可编译)', function () {
+  it('页面文件 pages/signature/signature.vue 存在', function () {
+    expect(fs.existsSync(PAGE)).toBe(true)
+  })
+
+  it('signature.vue 含 <template>/<script>/<style> 三段(uni 编译输入完整)', function () {
+    const raw = readRaw(PAGE)
+    expect(raw).toMatch(/<template>/)
+    expect(raw).toMatch(/<script>/)
+    expect(raw).toMatch(/<style/)
+    const exportCnt = (raw.match(/export\s+default/g) || []).length
+    expect(exportCnt).toBe(1)
+  })
+
+  it('模板内嵌复用 hand-signature 组件(components 注册 + 模板标签均存在)', function () {
+    expect(readTemplate(PAGE)).toMatch(/<hand-signature/)
+    expect(readScript(PAGE)).toMatch(/components\s*:\s*\{\s*HandSignature/)
+  })
+
+  it('编译依赖存在:hand-signature.vue 组件与 utils/signature-bus.js', function () {
+    expect(fs.existsSync(PAD)).toBe(true)
+    expect(fs.existsSync(SIG_BUS)).toBe(true)
+  })
+})
+
+// ---------------------------------------------------------------------------
+// AC5.3 无回归:两处入口 navigateTo 新签名页;全局无内嵌 <hand-signature>(仅签名页)
+// ---------------------------------------------------------------------------
+
+describe('AC5.3 入口接入新页面(无回归)', function () {
+  it('问卷签名入口 health-plan-summary.vue navigateTo /pages/signature/signature', function () {
+    expect(fs.existsSync(ENTRY_HEALTH)).toBe(true)
+    const src = stripComments(readRaw(ENTRY_HEALTH))
+    expect(src).toMatch(/navigateTo\s*\(\s*\{[\s\S]*?url\s*:\s*['"]\/pages\/signature\/signature/)
+    expect(src).not.toMatch(/<hand-signature/)
+  })
+
+  it('水机签名入口 consignee-edit.vue navigateTo /pages/signature/signature', function () {
+    expect(fs.existsSync(ENTRY_CONSIGNEE)).toBe(true)
+    const src = stripComments(readRaw(ENTRY_CONSIGNEE))
+    expect(src).toMatch(/navigateTo\s*\(\s*\{[\s\S]*?url\s*:\s*['"]\/pages\/signature\/signature/)
+    expect(src).not.toMatch(/<hand-signature/)
+  })
+
+  it('全局业务页无内嵌 <hand-signature>:仅签名页 signature.vue 真实使用', function () {
+    const offenders = collectVueFiles(PAGES_DIR).filter(function (file) {
+      const clean = stripComments(readRaw(file))
+      return clean.indexOf('<hand-signature') >= 0
+    })
+    const rel = offenders.map(function (f) { return path.relative(FRONT_ROOT, f).replace(/\\/g, '/') })
+    expect(rel.sort()).toEqual(['pages/signature/signature.vue'])
+  })
+})
+
+// ---------------------------------------------------------------------------
+// AC5.4 回退契约:onUnload / doBack / onBack / onConfirm 的竖屏恢复与导航还原
+// ---------------------------------------------------------------------------
+
+describe('AC5.4 回退逻辑契约(onUnload/doBack 恢复)', function () {
+  const pageScript = readScript(PAGE)
+
+  it('源码接线:onUnload 注销尺寸监听并恢复竖屏', function () {
+    expect(pageScript).toMatch(/onUnload\s*:\s*function[\s\S]*?unbindResizeListener\(\)/)
+    expect(pageScript).toMatch(/onUnload\s*:\s*function[\s\S]*?restorePortrait\(\)/)
+  })
+
+  it('源码接线:doBack 在 navigateBack 前恢复竖屏,_backed 防重复返回', function () {
+    expect(pageScript).toMatch(/doBack\s*:\s*function[\s\S]*?restorePortrait\(\)[\s\S]*?navigateBack\(\)/)
+    expect(pageScript).toMatch(/_backed/)
+  })
+
+  it('行为:doBack 先 setPageOrientation(portrait) 再 navigateBack,二次调用不再重复返回', function () {
+    const seq = []
+    const { vm } = makeVm({
+      uni: {
+        getWindowInfo: function () { return { windowHeight: 390 } },
+        navigateBack: function () { seq.push('navigateBack') }
+      },
+      wx: { setPageOrientation: function (cfg) { seq.push('orient:' + cfg.orientation) } },
+      window: undefined
+    })
+    vm.doBack()
+    expect(seq).toEqual(['orient:portrait', 'navigateBack'])
+    vm.doBack()
+    expect(seq).toEqual(['orient:portrait', 'navigateBack'])
+  })
+
+  it('行为:onBack(返回按钮)收敛到 doBack,不回传直接退出', function () {
+    const seq = []
+    const { vm } = makeVm({
+      uni: {
+        getWindowInfo: function () { return { windowHeight: 390 } },
+        navigateBack: function () { seq.push('navigateBack') }
+      },
+      wx: { setPageOrientation: function (cfg) { seq.push('orient:' + cfg.orientation) } },
+      window: undefined
+    })
+    vm.onBack()
+    expect(seq).toEqual(['orient:portrait', 'navigateBack'])
+  })
+
+  it('行为:onUnload 兜底恢复(注销监听 + 恢复竖屏),异常退出也不污染原页面', function () {
+    const seq = []
+    const sandbox = {
+      uni: { getWindowInfo: function () { return { windowHeight: 390 } } },
+      wx: {
+        onWindowResize: function () {},
+        offWindowResize: function () { seq.push('offResize') },
+        setPageOrientation: function (cfg) { seq.push('orient:' + cfg.orientation) }
+      },
+      window: undefined
+    }
+    const { vm, opts } = makeVm(sandbox)
+    vm.bindResizeListener()
+    opts.onUnload.call(vm)
+    expect(seq).toEqual(['offResize', 'orient:portrait'])
+  })
+
+  it('行为:onConfirm 先 emit signatureConfirm 再收敛到 doBack(确认后回传并退出)', function () {
+    const emitted = []
+    const seq = []
+    const { vm } = makeVm({
+      uni: {
+        getWindowInfo: function () { return { windowHeight: 390 } },
+        navigateBack: function () { seq.push('navigateBack') }
+      },
+      wx: { setPageOrientation: function (cfg) { seq.push('orient:' + cfg.orientation) } },
+      window: undefined
+    })
+    vm.getOpenerEventChannel = function () {
+      return {
+        emit: function (name, payload) { emitted.push([name, payload]) }
+      }
+    }
+    vm.onConfirm('data:image/png;base64,ABC')
+    expect(emitted).toEqual([['signatureConfirm', 'data:image/png;base64,ABC']])
+    expect(seq).toEqual(['orient:portrait', 'navigateBack'])
+  })
+})
+
+// ---------------------------------------------------------------------------
+// 权限流:签名页为通用独立页面,不区分角色,无 role 门禁 —— 适用性为「不适用」,
+// 显式记录(同 join-form.required-info.test.js 约定)。
+// ---------------------------------------------------------------------------
+
+describe.skip('权限流:签名页无角色门禁,不适用', function () {
+  it('占位:该能力面向通用签名交互,无 role 分支(详见汇报)', function () {})
+})