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

test(cfc-frontend): family.signature.openPage 契约级单测 S-cfc-50

Sisyphus Agent 2 дней назад
Родитель
Сommit
c373b0611b
1 измененных файлов с 242 добавлено и 0 удалено
  1. 242 0
      cfc-frontend/__tests__/pages/signature/signature.openPage.test.js

+ 242 - 0
cfc-frontend/__tests__/pages/signature/signature.openPage.test.js

@@ -0,0 +1,242 @@
+/**
+ * 契约级单测(TDD-RED)——能力键 family.signature.openPage
+ *
+ * 溯源:
+ *   requirement: docs/superpowers/specs/2026-11-07-signature-page-landscape.md
+ *                §3 AC-R1(独立页跳转 navigateTo 打开签名页)、§5 WI-1(新建独立横屏签名页)
+ *                关联 AC5.1(pages.json 路由注册正确)
+ *   design:      cfc-frontend/pages/signature/signature.vue(签名页)
+ *                + 入口页面 health-plan-summary.vue / consignee-edit.vue / checkout.vue
+ *                  (goSignature 经 uni.navigateTo 打开 pages/signature/signature)
+ *
+ * 说明(既有 Jest 体系约束):
+ *   cfc-frontend 的 jest.config.js moduleFileExtensions 不含 `vue`,testEnvironment 为 node,
+ *   既有约定为「抽取被测逻辑断言」而非挂载 .vue(见 __tests__/pages/signature/signature.canvas2d.test.js)。
+ *   本文件沿用该约定:直接加载真实 SFC 的 <script>/<template> 源码做结构契约断言,
+ *   并对 onConfirm/onLoad/onUnload 做行为级断言(真实 export default 求值 + 方法驱动)。
+ *   若实现方后续改动签名页契约,本文件断言即暴露;本文件不修改 pages/、utils/ 任何业务代码。
+ */
+
+const fs = require('fs')
+const path = require('path')
+
+const PAGE = path.join(__dirname, '../../../pages/signature/signature.vue')
+const PAGES_JSON = path.join(__dirname, '../../../pages.json')
+
+const ENTRY_FILES = {
+  healthPlanSummary: path.join(__dirname, '../../../pages/health/health-plan-summary.vue'),
+  consigneeEdit: path.join(__dirname, '../../../pages/shop/consignee-edit/consignee-edit.vue'),
+  checkout: path.join(__dirname, '../../../pages/shop/checkout/checkout.vue')
+}
+
+// ---------------------------------------------------------------------------
+// 工具:加载真实 signature.vue 的 <script>(行为级)与 <template>(结构级)
+// ---------------------------------------------------------------------------
+
+/** 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(' ')
+  })
+}
+
+/** 求值 <script> 得到真实 export default 对象(可注入 uni/wx/window/模块桩) */
+function loadOptions(sandbox) {
+  let code = stubImports(readScript(PAGE)).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 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]
+}
+
+function readAll(file) {
+  return fs.readFileSync(file, 'utf8')
+}
+
+/** 构造可驱动页面实例:合并 data() 与 methods,注入 $refs,并暴露顶层生命周期 opts */
+function makePage(sandbox) {
+  const base = {
+    uni: {
+      getWindowInfo: function () { return { windowHeight: 390, windowWidth: 844 } },
+      navigateBack: function () {},
+      showToast: function () {}
+    },
+    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(merged)
+  const data = typeof opts.data === 'function' ? opts.data() : opts.data
+  const vm = Object.assign({}, data, opts.methods)
+  vm.$refs = {}
+  // 暴露顶层生命周期(onLoad/onUnload 不在 methods 内)
+  vm.__opts__ = opts
+  return vm
+}
+
+// ---------------------------------------------------------------------------
+// AC1.1 / AC5.1 页面存在 + pages.json 路由注册
+// ---------------------------------------------------------------------------
+
+describe('AC1.1/AC5.1 签名页存在并注册为可打开页面', function () {
+  test('signature.vue 存在且为可打开页面(有 template + script + export default 组件)', function () {
+    expect(fs.existsSync(PAGE)).toBe(true)
+    const tpl = readTemplate(PAGE)
+    const script = readScript(PAGE)
+    // 可打开页面:模板有根节点 + 脚本 export default 组件定义
+    expect(tpl).toMatch(/<view[^>]*class="signature-page"/)
+    expect(script).toMatch(/export\s+default\s*\{/)
+    // 复用 HandSignature 组件
+    expect(script).toMatch(/components\s*:\s*\{\s*HandSignature\s*\}/)
+  })
+
+  test('pages.json 注册 pages/signature/signature 路由(无重复 path)', function () {
+    const json = readAll(PAGES_JSON)
+    // 分包 root + path + 真全屏 navigationStyle
+    expect(json).toMatch(/"root"\s*:\s*"pages\/signature"/)
+    expect(json).toMatch(/"path"\s*:\s*"signature"/)
+    expect(json).toMatch(/"navigationBarTitleText"\s*:\s*"签名"/)
+    expect(json).toMatch(/"navigationStyle"\s*:\s*"custom"/)
+    // 路径唯一(无重复注册)
+    const pathMatches = json.match(/"path"\s*:\s*"signature"/g) || []
+    expect(pathMatches.length).toBe(1)
+  })
+})
+
+// ---------------------------------------------------------------------------
+// AC-R1 存在入口通过 uni.navigateTo 打开 pages/signature/signature
+// ---------------------------------------------------------------------------
+
+describe('AC-R1 入口页面经 uni.navigateTo 打开签名页', function () {
+  const ENTRY_PATH_RE = /['"]\/pages\/signature\/signature\?requireAgree=1&agreeText=['"]/
+  const NAVIGATE_RE = /uni\.navigateTo\s*\(\s*\{[\s\S]{0,200}url\s*:\s*['"]\/pages\/signature\/signature\?requireAgree=1/
+
+  test('health-plan-summary.vue(问卷完成签名)经 navigateTo 打开签名页', function () {
+    const src = readAll(ENTRY_FILES.healthPlanSummary)
+    expect(src).toMatch(NAVIGATE_RE)
+    expect(src).toMatch(ENTRY_PATH_RE)
+  })
+
+  test('consignee-edit.vue(水机购买收货人签名)经 navigateTo 打开签名页', function () {
+    const src = readAll(ENTRY_FILES.consigneeEdit)
+    expect(src).toMatch(NAVIGATE_RE)
+    expect(src).toMatch(ENTRY_PATH_RE)
+  })
+
+  test('checkout.vue(订单确认签署)经 navigateTo 打开签名页', function () {
+    const src = readAll(ENTRY_FILES.checkout)
+    expect(src).toMatch(NAVIGATE_RE)
+    expect(src).toMatch(ENTRY_PATH_RE)
+  })
+
+  test('入口跳转前经 signature-bus 写入初始值(setSignaturePrefill)', function () {
+    const src = readAll(ENTRY_FILES.healthPlanSummary)
+    expect(src).toMatch(/setSignaturePrefill\s*\(/)
+  })
+})
+
+// ---------------------------------------------------------------------------
+// AC1.4 行为级:点「确认」经 eventChannel 回传 base64 + navigateBack;「返回」不回传直接退出
+// ---------------------------------------------------------------------------
+
+describe('AC1.4 签名页回传/退出行为', function () {
+  test('onConfirm 经 getOpenerEventChannel().emit(signatureConfirm, dataUrl) 回传后 doBack', function () {
+    const emitted = []
+    const navigated = []
+    const vm = makePage({
+      uni: { navigateBack: function () { navigated.push('navigateBack') } }
+    })
+    vm.getOpenerEventChannel = function () {
+      return { emit: function (name, payload) { emitted.push([name, payload]) } }
+    }
+    const dataUrl = 'data:image/png;base64,QUJD'
+    vm.onConfirm(dataUrl)
+    expect(emitted.length).toBe(1)
+    expect(emitted[0][0]).toBe('signatureConfirm')
+    expect(emitted[0][1]).toBe(dataUrl)
+    expect(navigated).toEqual(['navigateBack'])
+  })
+
+  test('onBack 不回传直接 navigateBack(恢复竖屏)', function () {
+    const emitted = []
+    const seq = []
+    const vm = makePage({
+      wx: { setPageOrientation: function (cfg) { seq.push('orient:' + cfg.orientation) } },
+      uni: { navigateBack: function () { seq.push('navigateBack') } }
+    })
+    vm.getOpenerEventChannel = function () {
+      return { emit: function (name, payload) { emitted.push([name, payload]) } }
+    }
+    vm.onBack()
+    expect(emitted.length).toBe(0)
+    expect(seq).toEqual(['orient:portrait', 'navigateBack'])
+  })
+})
+
+// ---------------------------------------------------------------------------
+// AC1.5 行为级:onLoad 读取 requireAgree/agreeText 与 prefill 回显
+// ---------------------------------------------------------------------------
+
+describe('AC1.5 onLoad 入参与 prefill 回显', function () {
+  test('onLoad 从 signature-bus 读取 prefill;requireAgree=1 置真、agreeText 解码', function () {
+    const script = readScript(PAGE)
+    // 结构契约:onLoad 经 signature-bus 读取初始值(读后清空,避免残留串扰)
+    expect(script).toMatch(/consumeSignaturePrefill\s*\(\)/)
+    expect(script).toMatch(/this\.prefill\s*=\s*consumeSignaturePrefill\s*\(\)/)
+    // 行为级:驱动真实 onLoad(consumeSignaturePrefill 已 stub 为空函数,不抛错)
+    const vm = makePage({ wx: undefined, window: undefined })
+    vm.__opts__.onLoad.call(vm, { requireAgree: '1', agreeText: encodeURIComponent('我已阅读并确认') })
+    expect(vm.requireAgree).toBe(true)
+    expect(vm.agreeText).toBe('我已阅读并确认')
+  })
+
+  test('onLoad: requireAgree 未传则 false;agreeText 未传保留默认文案', function () {
+    const vm = makePage({ wx: undefined, window: undefined })
+    vm.__opts__.onLoad.call(vm, {})
+    expect(vm.requireAgree).toBe(false)
+    expect(vm.agreeText).toBe('我已阅读并确认以上内容真实、准确')
+  })
+})
+
+// ---------------------------------------------------------------------------
+// 权限流:签名页为通用签署页,不区分角色(无 role 门禁),显式记录。
+// ---------------------------------------------------------------------------
+
+describe.skip('权限流:签名页无角色门禁,不适用', function () {
+  it('占位:该能力为通用签署页,无 role 分支(详见汇报)', function () {})
+})