|
|
@@ -0,0 +1,264 @@
|
|
|
+/**
|
|
|
+ * 契约级单测(TDD-RED)——能力键 family.signature.consigneeEditor
|
|
|
+ *
|
|
|
+ * 溯源:
|
|
|
+ * requirement: docs/superpowers/specs/2026-11-07-signature-page-landscape.md
|
|
|
+ * §3 AC-R3(签署回传:base64 回传原页面并显示预览;水机未签名仍可保存)、
|
|
|
+ * §5 WI-4(水机购买签名改造:收件人信息段 + 手签名段)
|
|
|
+ * + 台账 B-135-1(前端 16 红:consignee-edit.vue 未交付 → 预期 RED)
|
|
|
+ * design: cfc-frontend/pages/signature/components/consignee-edit.vue
|
|
|
+ * (新建子组件:收件人编辑 + 手签名段;收件人初始值经 props 注入,
|
|
|
+ * 手签名经 signature-bus 预填 + 跳转独立签名页 + events.signatureConfirm 回传写回)
|
|
|
+ *
|
|
|
+ * 说明(既有 Jest 体系约束):
|
|
|
+ * cfc-frontend 的 jest.config.js 仅 transform .js(babel-jest),moduleFileExtensions 不含 .vue,
|
|
|
+ * 且未安装 vue-jest/jsdom 组件环境 —— 既有约定为「抽取被测逻辑断言」而非挂载 .vue
|
|
|
+ * (见 __tests__/family-join/join-form.required-info.test.js 与已合入的
|
|
|
+ * __tests__/pages/signature/signature.consignee.test.js,能力键 family.signature.consignee)。
|
|
|
+ * 本文件沿用该约定,但专测本键(family.signature.consigneeEditor)的组件契约:
|
|
|
+ * - <script>:把 import 语句替换为注入桩后求值,得到真实组件的 data()/methods,做行为级断言;
|
|
|
+ * - <template>:读取模板文本,做结构契约断言(收件人字段、手签名段「去签名」按钮 + 预览、确认回调)。
|
|
|
+ * 组件文件缺失 → 预期 RED;GREEN 阶段落地组件后本套件转绿。
|
|
|
+ * 本文件不修改 pages/、utils/ 任何业务代码。
|
|
|
+ */
|
|
|
+
|
|
|
+const fs = require('fs')
|
|
|
+const path = require('path')
|
|
|
+
|
|
|
+const CONSIGNEE_EDIT_PATH = path.join(__dirname, '../../../pages/signature/components/consignee-edit.vue')
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// 工具:加载真实 SFC 的 <script> 逻辑(行为级)与 <template> 结构(结构级)
|
|
|
+// 风格对齐 __tests__/family-join/join-form.required-info.test.js 与 signature.consignee.test.js
|
|
|
+// (独立 global 键 __consigneeEditorStubs,避免与已合入套件同键竞争)
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+/** 把 ES import 语句替换为从 globalThis.__consigneeEditorStubs 取桩的声明(不解析模块,避免污染业务实现) */
|
|
|
+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.__consigneeEditorStubs[' + JSON.stringify(id) + '] || {})[' + JSON.stringify(n) + '];'
|
|
|
+ }).join('\n')
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+/** 加载真实 SFC:返回 { component, template }(component = 真实 <script> 的 export default 对象);文件缺失时抛出明确错误 */
|
|
|
+function loadConsigneeEditor() {
|
|
|
+ if (!fs.existsSync(CONSIGNEE_EDIT_PATH)) {
|
|
|
+ throw new Error('契约未落地:pages/signature/components/consignee-edit.vue 不存在(GREEN 阶段需新建该组件)')
|
|
|
+ }
|
|
|
+ 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.__consigneeEditorStubs || {})
|
|
|
+ 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.navigateTo.mockReset()
|
|
|
+ global.uni.request.mockReset()
|
|
|
+ stubs = {
|
|
|
+ setSignaturePrefill: jest.fn(),
|
|
|
+ consumeSignaturePrefill: jest.fn(function () { return '' }),
|
|
|
+ getConsigneeData: jest.fn(function () {
|
|
|
+ return { name: '张三', phone: '13800001111', address: '北京市朝阳区' }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ globalThis.__consigneeEditorStubs = {
|
|
|
+ '@/utils/signature-bus.js': {
|
|
|
+ setSignaturePrefill: stubs.setSignaturePrefill,
|
|
|
+ consumeSignaturePrefill: stubs.consumeSignaturePrefill
|
|
|
+ },
|
|
|
+ '@/utils/api.js': { getConsigneeData: stubs.getConsigneeData }
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+afterEach(function () {
|
|
|
+ delete globalThis.__consigneeEditorStubs
|
|
|
+})
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// 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 组件导出契约:Option API + props 注入初始收件人值 + 收件人编辑字段 data()
|
|
|
+// 设计锚点:新建子组件「收件人编辑 + 手签名段」,收件人初始值经 props 注入(子组件不各自请求)
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+describe('AC-R1 <script> 契约:子组件导出 + 收件人编辑字段', function () {
|
|
|
+ var component
|
|
|
+
|
|
|
+ beforeEach(function () {
|
|
|
+ // 文件缺失时 loadConsigneeEditor 抛错 → 每条用例红(明确的 RED 信号);GREEN 后加载真实 SFC 断言
|
|
|
+ component = loadConsigneeEditor().component
|
|
|
+ })
|
|
|
+
|
|
|
+ it('组件通过 export default 导出(Option API)', function () {
|
|
|
+ expect(component).toBeDefined()
|
|
|
+ expect(typeof component.data).toBe('function')
|
|
|
+ expect(component.methods).toBeDefined()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('组件定义 props(子组件契约:收件人初始值经 props 注入,不自行重复请求接口)', function () {
|
|
|
+ expect(component.props).toBeDefined()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('data 定义收件人编辑字段:consigneeName / consigneePhone / consigneeAddress', function () {
|
|
|
+ var vm = instantiate(component)
|
|
|
+ expect(vm.consigneeName).toBeDefined()
|
|
|
+ expect(vm.consigneePhone).toBeDefined()
|
|
|
+ expect(vm.consigneeAddress).toBeDefined()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('data 定义手签名段字段 handSignature(初始为空 = 选填,符合 AC4.3)', function () {
|
|
|
+ var vm = instantiate(component)
|
|
|
+ expect(vm.handSignature).toBeDefined()
|
|
|
+ expect(vm.handSignature).toBe('')
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// AC-R2 手签名段行为契约:goSignature → setSignaturePrefill + navigateTo 独立签名页
|
|
|
+// + events.signatureConfirm 回传写回 handSignature(§4.2 规范跳转片段;AC4.2 返回后赋值预览)
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+describe('AC-R2 手签名段跳转与回传写回(AC4.2)', function () {
|
|
|
+ var component
|
|
|
+ var vm
|
|
|
+
|
|
|
+ beforeEach(function () {
|
|
|
+ component = loadConsigneeEditor().component
|
|
|
+ vm = instantiate(component)
|
|
|
+ vm.handSignature = 'data:image/png;base64,AAA'
|
|
|
+ })
|
|
|
+
|
|
|
+ it('goSignature 跳转前经 setSignaturePrefill 写入当前手签名(编辑场景预填)', function () {
|
|
|
+ vm.goSignature()
|
|
|
+ expect(stubs.setSignaturePrefill).toHaveBeenCalledWith('data:image/png;base64,AAA')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('goSignature 以 uni.navigateTo 打开 /pages/signature/signature(requireAgree=1 + agreeText)', function () {
|
|
|
+ vm.goSignature()
|
|
|
+ expect(global.uni.navigateTo).toHaveBeenCalledTimes(1)
|
|
|
+ var opts = global.uni.navigateTo.mock.calls[0][0]
|
|
|
+ expect(opts.url).toMatch(/\/pages\/signature\/signature/)
|
|
|
+ expect(opts.url).toMatch(/requireAgree=1/)
|
|
|
+ expect(opts.url).toMatch(/agreeText=/)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('签名页回传 events.signatureConfirm(dataUrl) 写回 handSignature(返回后预览显示)', function () {
|
|
|
+ vm.goSignature()
|
|
|
+ var opts = global.uni.navigateTo.mock.calls[0][0]
|
|
|
+ expect(typeof opts.events.signatureConfirm).toBe('function')
|
|
|
+ opts.events.signatureConfirm('data:image/png;base64,BBB')
|
|
|
+ expect(vm.handSignature).toBe('data:image/png;base64,BBB')
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// AC-R3 选填语义(AC4.3):未签名仍可完成/保存 —— 确认回调不因 handSignature 为空被拦截
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+describe('AC-R3 未签名不拦截(AC4.3 选填语义)', function () {
|
|
|
+ it('handSignature 为空时,onConfirm 确认路径不抛错、不提示「请先签名」类拦截', function () {
|
|
|
+ var component = loadConsigneeEditor().component
|
|
|
+ var vm = instantiate(component)
|
|
|
+ vm.handSignature = ''
|
|
|
+ expect(function () {
|
|
|
+ component.methods.onConfirm.call(vm, {})
|
|
|
+ }).not.toThrow()
|
|
|
+ expect(global.uni.showToast).not.toHaveBeenCalledWith(expect.objectContaining({ title: expect.stringMatching(/签名/) }))
|
|
|
+ })
|
|
|
+
|
|
|
+ it('模板手签名段含「选填」提示与「去签名」入口(未签名仍可保存的可见语义)', function () {
|
|
|
+ var template = loadConsigneeEditor().template
|
|
|
+ expect(template).toMatch(/选填/)
|
|
|
+ expect(template).toMatch(/去签名/)
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// AC-R4 <template> 结构契约:收件人字段 + 手签名段(按钮/预览)+ 确认回调
|
|
|
+// (与已合入 signature.consignee.test.js 的组件契约保持一致)
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+describe('AC-R4 <template> 结构契约:收件人编辑 + 手签名段', function () {
|
|
|
+ var template
|
|
|
+
|
|
|
+ beforeEach(function () {
|
|
|
+ template = loadConsigneeEditor().template
|
|
|
+ })
|
|
|
+
|
|
|
+ it('模板包含收件人姓名/电话/地址输入绑定', function () {
|
|
|
+ expect(template).toMatch(/consigneeName/)
|
|
|
+ expect(template).toMatch(/consigneePhone/)
|
|
|
+ expect(template).toMatch(/consigneeAddress/)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('模板包含手签名段:已签预览 image 绑定 handSignature(返回后显示预览)', function () {
|
|
|
+ expect(template).toMatch(/<\s*image[^>]*handSignature/)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('模板将确认事件绑定到确认回调(@confirm="onConfirm")', function () {
|
|
|
+ expect(template).toMatch(/@confirm\s*=\s*["']onConfirm["']/)
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// 权限流:收件人信息编辑属于已登录水机购买流程,无角色门禁分支(不适用项,显式记录)
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+describe.skip('权限流:收件人信息编辑无角色门禁,不适用', function () {
|
|
|
+ it('占位:该能力面向已登录购买流程,无 role 分支(详见汇报)', function () {})
|
|
|
+})
|