فهرست منبع

test(S-cfc-35): family.invite.joinRequiredInfoFrontend RED 契约级单测(join-form 出生日期/性别必填)

Sisyphus 5 روز پیش
والد
کامیت
076cfa8796
1فایلهای تغییر یافته به همراه316 افزوده شده و 0 حذف شده
  1. 316 0
      cfc-frontend/__tests__/family-join/join-form.required-info.test.js

+ 316 - 0
cfc-frontend/__tests__/family-join/join-form.required-info.test.js

@@ -0,0 +1,316 @@
+/**
+ * 契约级单测(TDD-RED)——能力键 family.invite.joinRequiredInfoFrontend
+ *
+ * 溯源:
+ *   requirement: docs/superpowers/specs/2026-11-07-family-join-required-info.md
+ *                §3 AC-R1/AC-R3(前端可见且必填、校验规则)、§4.2(前端表单)、§5 WI-1(前端表单补充出生日期与性别必填项)
+ *   design:      cfc-frontend/pages/invite/join-form.vue + cfc-frontend/utils/api.js `updateUserInfo`
+ *
+ * 说明(既有 Jest 体系约束):
+ *   cfc-frontend 的 jest.config.js 仅 transform `.js`(babel-jest),moduleFileExtensions 不含 `vue`,
+ *   且未安装 vue-jest/jsdom 组件环境 —— 既有约定为「抽取被测逻辑断言」而非挂载 .vue
+ *   (见 __tests__/components/FamilyRelationGraph.test.js,导入 shallowMount 但实际只测抽取函数)。
+ *   本文件沿用该约定,但比「测试内复刻逻辑」更强:直接加载真实 SFC 的 <script>/<template> 源码——
+ *   - <script>:把 import 语句替换为注入桩后求值,得到真实组件的 data()/methods,做行为级断言;
+ *   - <template>:读取模板文本,做结构契约断言(必填标记、picker 绑定、change 处理器)。
+ *   若实现方在后续 GREEN 阶段对 join-form.vue 补足契约,本文件断言即翻绿;当前实现缺失 → 预期 RED。
+ *   本文件不修改 pages/、utils/ 任何业务代码。
+ */
+
+import fs from 'fs'
+import path from 'path'
+import { updateUserInfo as apiUpdateUserInfo, clearApiDedup } from '@/utils/api'
+
+const JOIN_FORM_PATH = path.join(__dirname, '../../pages/invite/join-form.vue')
+
+// ---------------------------------------------------------------------------
+// 工具:加载真实 join-form.vue 的 <script> 逻辑(行为级)与 <template> 结构(结构级)
+// ---------------------------------------------------------------------------
+
+/** 把 ES import 语句替换为从 globalThis.__joinFormStubs 取桩的声明(不解析模块,避免污染业务实现) */
+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.__joinFormStubs[' + JSON.stringify(id) + '] || {})[' + JSON.stringify(n) + '];'
+    }).join('\n')
+  })
+}
+
+/** 加载真实 SFC:返回 { component, template }(component = 真实 <script> 的 export default 对象) */
+function loadJoinForm() {
+  var src = fs.readFileSync(JOIN_FORM_PATH, 'utf8')
+  var tmplMatch = src.match(/<template>([\s\S]*?)<\/template>/)
+  var scriptMatch = src.match(/<script>([\s\S]*?)<\/script>/)
+  if (!scriptMatch) throw new Error('无法在 join-form.vue 中定位 <script> 块')
+  var script = stubImports(scriptMatch[1], globalThis.__joinFormStubs || {})
+  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
+}
+
+// ---------------------------------------------------------------------------
+// 测试夹具
+// ---------------------------------------------------------------------------
+
+var stubs
+
+beforeEach(function () {
+  global.uni._storage = {}
+  global.uni.showToast.mockReset()
+  global.uni.reLaunch.mockReset()
+  global.uni.request.mockReset()
+  stubs = {
+    updateUserInfo: jest.fn(function () { return Promise.resolve({ code: 0, message: 'ok', data: null }) }),
+    requestJoinByCode: jest.fn(function () { return Promise.resolve({ code: 0, message: 'ok', data: null }) }),
+    pickImage: jest.fn(),
+    fontScaleMixin: { data: function () { return { fontScale: 1 } }, onShow: function () {} }
+  }
+  globalThis.__joinFormStubs = {
+    '@/utils/api.js': { updateUserInfo: stubs.updateUserInfo, requestJoinByCode: stubs.requestJoinByCode },
+    '@/utils/image-picker.js': { pickImage: stubs.pickImage },
+    '@/mixins/font-scale.js': { fontScaleMixin: stubs.fontScaleMixin }
+  }
+  jest.useFakeTimers()
+})
+
+afterEach(function () {
+  jest.useRealTimers()
+  delete globalThis.__joinFormStubs
+})
+
+// ---------------------------------------------------------------------------
+// AC1.1 表单结构:真实姓名 + 出生日期 + 性别三项,出生日期/性别带 * 必填标记
+// ---------------------------------------------------------------------------
+
+describe('AC1.1 表单结构:出生日期/性别必填项可见', function () {
+  var template
+  beforeAll(function () {
+    // beforeAll 先于 beforeEach 执行:仅做模板结构断言,无需桩函数,置空即可(loadJoinForm 对桩做可选访问)
+    globalThis.__joinFormStubs = {}
+    template = loadJoinForm().template
+  })
+
+  it('真实姓名字段保留(无回归)', function () {
+    expect(template).toMatch(/真实姓名/)
+    expect(template).toMatch(/form\.realName/)
+  })
+
+  it('出生日期为必填(label 带 * 标记)', function () {
+    expect(template).toMatch(/出生日期[\s\S]{0,120}<text class="required">\*<\/text>/)
+  })
+
+  it('性别为必填(label 带 * 标记)', function () {
+    expect(template).toMatch(/性别[\s\S]{0,120}<text class="required">\*<\/text>/)
+  })
+
+  it('出生日期使用 uni date picker 绑定 form.birthday', function () {
+    expect(template).toMatch(/<picker[^>]*mode="date"[^>]*form\.birthday/)
+    expect(template).toMatch(/onBirthdayChange/)
+  })
+
+  it('性别使用 picker(男/女 两档)绑定 form.gender,change 映射 male/female', function () {
+    expect(template).toMatch(/onGenderChange/)
+    expect(template).toMatch(/男/)
+    expect(template).toMatch(/女/)
+  })
+})
+
+// ---------------------------------------------------------------------------
+// AC1.4 进入页面时预填:userInfo 已有 realName/birthday/gender 则带出
+// ---------------------------------------------------------------------------
+
+describe('AC1.4 prefill 预填已存资料', function () {
+  var component
+
+  beforeEach(function () {
+    component = loadJoinForm().component
+  })
+
+  it('预填真实姓名(既有行为,无回归)', function () {
+    global.uni.setStorageSync('userInfo', { realName: '张三' })
+    var vm = instantiate(component)
+    vm.prefill()
+    expect(vm.form.realName).toBe('张三')
+  })
+
+  it('预填出生日期与性别(userInfo.birthday/gender 带出)', function () {
+    global.uni.setStorageSync('userInfo', { realName: '张三', birthday: '1990-05-20', gender: 'male' })
+    var vm = instantiate(component)
+    vm.prefill()
+    expect(vm.form.birthday).toBe('1990-05-20')
+    expect(vm.form.gender).toBe('male')
+  })
+
+  it('userInfo 缺失三项时不写入(保持空串)', function () {
+    global.uni.setStorageSync('userInfo', {})
+    var vm = instantiate(component)
+    vm.prefill()
+    expect(vm.form.birthday).toBe('')
+    expect(vm.form.gender).toBe('')
+  })
+})
+
+// ---------------------------------------------------------------------------
+// AC1.2 提交拦截:出生日期/性别/姓名任一缺失或格式非法 → toast + 不调用 requestJoinByCode
+// ---------------------------------------------------------------------------
+
+describe('AC1.2 提交拦截(正常/边界)', function () {
+  var vm
+
+  beforeEach(function () {
+    var component = loadJoinForm().component
+    vm = instantiate(component)
+    vm.familyInviteCode = 'CODE-123'
+    vm.form.realName = '张三'
+    vm.form.birthday = '1990-05-20'
+    vm.form.gender = 'male'
+  })
+
+  it('真实姓名为空 → 拦截,不调用 requestJoinByCode(既有行为)', async function () {
+    vm.form.realName = '   '
+    await vm.submit()
+    expect(global.uni.showToast).toHaveBeenCalled()
+    expect(stubs.requestJoinByCode).not.toHaveBeenCalled()
+  })
+
+  it('出生日期未选 → 拦截并 toast 提示,不调用 requestJoinByCode', async function () {
+    vm.form.birthday = ''
+    await vm.submit()
+    expect(stubs.requestJoinByCode).not.toHaveBeenCalled()
+    expect(global.uni.showToast).toHaveBeenCalled()
+  })
+
+  it('性别未选 → 拦截并 toast 提示,不调用 requestJoinByCode', async function () {
+    vm.form.gender = ''
+    await vm.submit()
+    expect(stubs.requestJoinByCode).not.toHaveBeenCalled()
+    expect(global.uni.showToast).toHaveBeenCalled()
+  })
+
+  it('出生日期格式非法(非 yyyy-MM-dd)→ 拦截,不调用 requestJoinByCode', async function () {
+    vm.form.birthday = '1990/05/20'
+    await vm.submit()
+    expect(stubs.requestJoinByCode).not.toHaveBeenCalled()
+    expect(global.uni.showToast).toHaveBeenCalled()
+  })
+
+  it('缺少家庭邀请码 → 拦截(既有行为)', async function () {
+    vm.familyInviteCode = ''
+    await vm.submit()
+    expect(stubs.requestJoinByCode).not.toHaveBeenCalled()
+    expect(global.uni.showToast).toHaveBeenCalled()
+  })
+})
+
+// ---------------------------------------------------------------------------
+// AC1.3 提交载荷:birthday 以 yyyy-MM-dd 字符串、gender 为 male/female 写入 updateUserInfo
+// ---------------------------------------------------------------------------
+
+describe('AC1.3 提交载荷与 picker 映射', function () {
+  var vm
+
+  beforeEach(function () {
+    var component = loadJoinForm().component
+    vm = instantiate(component)
+    vm.familyInviteCode = 'CODE-123'
+    vm.form.realName = '张三'
+    vm.form.birthday = '1990-05-20'
+    vm.form.gender = 'male'
+    vm.form.nickname = ''
+    vm.form.avatar = ''
+  })
+
+  it('出生日期 change 处理器以 yyyy-MM-dd 写入 form.birthday', function () {
+    expect(typeof vm.onBirthdayChange).toBe('function')
+    vm.onBirthdayChange({ detail: { value: '2010-01-15' } })
+    expect(vm.form.birthday).toBe('2010-01-15')
+  })
+
+  it('性别 picker 索引 0 → male、索引 1 → female', function () {
+    expect(typeof vm.onGenderChange).toBe('function')
+    vm.onGenderChange({ detail: { value: 0 } })
+    expect(vm.form.gender).toBe('male')
+    vm.onGenderChange({ detail: { value: 1 } })
+    expect(vm.form.gender).toBe('female')
+  })
+
+  it('合法提交:updateUserInfo 收到 realName(trim)/birthday(yyyy-MM-dd)/gender(male|female)', async function () {
+    await vm.submit()
+    expect(stubs.updateUserInfo).toHaveBeenCalledWith(expect.objectContaining({
+      realName: '张三',
+      birthday: '1990-05-20',
+      gender: 'male'
+    }))
+  })
+
+  it('合法提交:随后调用 requestJoinByCode(inviteCode) 正常进入待审批', async function () {
+    await vm.submit()
+    expect(stubs.requestJoinByCode).toHaveBeenCalledWith('CODE-123')
+  })
+})
+
+// ---------------------------------------------------------------------------
+// 设计锚点:utils/api.js updateUserInfo 需支持 realName/gender/birthday 透传
+// (规格 §2:接口封装无改动,锚定「已支持透传」契约)
+// ---------------------------------------------------------------------------
+
+describe('设计锚点:api.js updateUserInfo 透传 realName/gender/birthday', function () {
+  it('POST /api/user/update 请求体含 realName/gender/birthday 三字段', async function () {
+    var captured = null
+    global.uni.request.mockImplementation(function (opts) {
+      captured = opts
+      process.nextTick(function () { opts.success({ data: { code: 200, message: 'ok', data: null } }) })
+    })
+    clearApiDedup()
+    await apiUpdateUserInfo({ realName: '张三', gender: 'male', birthday: '1990-05-20' })
+    expect(captured.url).toContain('/api/user/update')
+    expect(captured.method).toBe('POST')
+    expect(captured.data).toEqual(expect.objectContaining({
+      realName: '张三',
+      gender: 'male',
+      birthday: '1990-05-20'
+    }))
+  })
+})
+
+// ---------------------------------------------------------------------------
+// 权限流:本能力为「被邀请人加入申请表单」,不区分角色(邀请人/被邀请人均不触发角色门禁),
+// 属《编排派发》中「权限(适用时)」的不适用项,显式记录。
+// ---------------------------------------------------------------------------
+
+describe.skip('权限流:加入表单无角色门禁,不适用', function () {
+  it('占位:该能力面向被邀请人,无 role 分支(详见汇报)', function () {})
+})