|
|
@@ -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 () {})
|
|
|
+})
|