Explorar o código

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

Sisyphus Agent hai 2 días
pai
achega
584e0a0b2d

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