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

test(tdd): S-cfc-133 family.signature.consigneeEditor RED 契约测试 —— consignee-edit.vue 收件人编辑+手签名段未落地预期13红

Sisyphus Agent 1 день назад
Родитель
Сommit
8e8f9c2914

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

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