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