|
|
@@ -0,0 +1,326 @@
|
|
|
+/**
|
|
|
+ * 契约级单测(TDD-RED)——能力键 diet.recommendation.participantSelect
|
|
|
+ *
|
|
|
+ * 溯源:
|
|
|
+ * requirement: docs/superpowers/specs/2026-11-08-diet-recommendation-polish.md
|
|
|
+ * §2 需求 7(共餐人选择:家庭总览先弹共餐人多选、个人/成员直传 currentMemberId)
|
|
|
+ * design: 同文档 §2 需求 7
|
|
|
+ * - 前端:cfc-frontend/pages/diet/recommendation.vue generateRecipe(L407-425),
|
|
|
+ * 家庭总览(currentView==='family')点「生成食谱」→ 弹共餐人多选
|
|
|
+ * (候选=memberList,含 familyMemberId+nickname+isSelf,默认勾选全部),
|
|
|
+ * 确认后调 generate 并传 participant_member_ids([familyMemberId,...] JSON 字符串);
|
|
|
+ * 个人/成员(currentView==='personal')→ 不弹选择,传 participant_member_ids:[currentMemberId]
|
|
|
+ * - api 透传:cfc-frontend/utils/api.js generateDietRecommendation(L3125)通用透传,无需改动
|
|
|
+ * - 后端:DietRecommendationService.generateRecommendation(L67-140)应读
|
|
|
+ * request.get("participant_member_ids") 覆盖 queryParticipantIds 兜底;
|
|
|
+ * 解析复用 parseMemberIds(L257-267)——本文件(前端契约)锚定「前端发送该字段」,
|
|
|
+ * 后端覆盖行为由 GREEN 阶段后端侧验证
|
|
|
+ *
|
|
|
+ * 说明:
|
|
|
+ * 沿用 __tests__/pages/diet/recommendation.uiRework.test.js 约定:直接加载真实
|
|
|
+ * recommendation.vue SFC 的 <script>(import 替换为注入桩后求值)拿到 data()/methods,
|
|
|
+ * 做行为级断言。当前实现未落地(generateRecipe 仅传 {date, meal_type},无 participant_member_ids),
|
|
|
+ * 预期全部 RED;GREEN 阶段在实现不修改本文件的前提下翻绿。
|
|
|
+ * 注:家庭总览「确认共餐人」的确认入口设计未限定方法名,本文件以 confirmParticipants 作为
|
|
|
+ * GREEN 预期表面,并在断言中聚焦「载荷契约(participant_member_ids 内容)」,若 GREEN 采用
|
|
|
+ * 其他命名需与编排侧确认同步。
|
|
|
+ * 本文件不修改 pages/ / components/ / utils/ 任何业务代码。
|
|
|
+ */
|
|
|
+
|
|
|
+import fs from 'fs'
|
|
|
+import path from 'path'
|
|
|
+import { generateDietRecommendation as apiGenerateDietRecommendation, clearApiDedup } from '@/utils/api'
|
|
|
+
|
|
|
+const RECOMMENDATION_PATH = path.join(__dirname, '../../../pages/diet/recommendation.vue')
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// 工具:把 ES import 替换成从 globalThis.__recommendationStubs 取桩的 var 声明
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+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.__recommendationStubs[' + JSON.stringify(id) + '] || {})[' + JSON.stringify(n) + '];'
|
|
|
+ }).join('\n')
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+/** 加载真实 SFC:返回 { component, template } */
|
|
|
+function loadRecommendation() {
|
|
|
+ var src = fs.readFileSync(RECOMMENDATION_PATH, 'utf8')
|
|
|
+ var tmplMatch = src.match(/<template>([\s\S]*?)<\/template>/)
|
|
|
+ var scriptMatch = src.match(/<script>([\s\S]*?)<\/script>/)
|
|
|
+ if (!scriptMatch) throw new Error('无法在 recommendation.vue 中定位 <script> 块')
|
|
|
+ var script = stubImports(scriptMatch[1], globalThis.__recommendationStubs || {})
|
|
|
+ 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] : '' }
|
|
|
+}
|
|
|
+
|
|
|
+/** 把 component 的 data()/computed()/methods 合并到 vm 实例(computed 定义为 getter) */
|
|
|
+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] })
|
|
|
+ if (component.computed) {
|
|
|
+ Object.keys(component.computed).forEach(function (k) {
|
|
|
+ Object.defineProperty(vm, k, { get: component.computed[k].bind(vm), enumerable: true, configurable: true })
|
|
|
+ })
|
|
|
+ }
|
|
|
+ var methods = component.methods || {}
|
|
|
+ Object.keys(methods).forEach(function (k) { vm[k] = methods[k] })
|
|
|
+ return vm
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 从 generate 载荷中提取共餐人 id 数组。
|
|
|
+ * 契约要点:participant_member_ids 存在即返回其内容(兼容 JSON 字符串 / 原生数组两种实现形态,
|
|
|
+ * 值统一规整为 number 数组),不存在/为空则返回与之对应的哨兵值,令断言干净失败(RED)。
|
|
|
+ */
|
|
|
+function participantIdsOf(payload) {
|
|
|
+ if (!payload || !Object.prototype.hasOwnProperty.call(payload, 'participant_member_ids')) return undefined
|
|
|
+ var v = payload.participant_member_ids
|
|
|
+ if (v === undefined || v === null || v === '') return []
|
|
|
+ var out
|
|
|
+ if (Array.isArray(v)) {
|
|
|
+ out = v
|
|
|
+ } else if (typeof v === 'string') {
|
|
|
+ try { out = JSON.parse(v) } catch (e) { return v }
|
|
|
+ } else if (typeof v === 'number') {
|
|
|
+ out = [v]
|
|
|
+ } else {
|
|
|
+ return v
|
|
|
+ }
|
|
|
+ return out.map(function (n) { return Number(n) })
|
|
|
+}
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// 测试夹具
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+var stubs
|
|
|
+
|
|
|
+beforeEach(function () {
|
|
|
+ global.uni._storage = {}
|
|
|
+ global.uni.showToast.mockReset()
|
|
|
+ global.uni.showLoading.mockReset()
|
|
|
+ global.uni.hideLoading.mockReset()
|
|
|
+ global.uni.request.mockReset()
|
|
|
+
|
|
|
+ stubs = {
|
|
|
+ generateDietRecommendation: jest.fn(function () { return Promise.resolve({}) }),
|
|
|
+ getDietRecommendation: jest.fn(function () { return Promise.resolve({}) }),
|
|
|
+ completeDietRecommendation: jest.fn(function () { return Promise.resolve({}) }),
|
|
|
+ regenerateDietRecommendation: jest.fn(function () { return Promise.resolve({}) }),
|
|
|
+ memberIdOf: jest.fn(function (m) { return m && (m.familyMemberId != null ? m.familyMemberId : (m.id != null ? m.id : null)) }),
|
|
|
+ config: { default: { API_BASE_URL: '/api' } },
|
|
|
+ MemberSelector: { default: {} },
|
|
|
+ fontScaleMixin: { default: { data: function () { return { fontScale: 1 } }, onShow: function () {} } }
|
|
|
+ }
|
|
|
+ globalThis.__recommendationStubs = {
|
|
|
+ '@/utils/api.js': {
|
|
|
+ getDietRecommendation: stubs.getDietRecommendation,
|
|
|
+ generateDietRecommendation: stubs.generateDietRecommendation,
|
|
|
+ completeDietRecommendation: stubs.completeDietRecommendation,
|
|
|
+ regenerateDietRecommendation: stubs.regenerateDietRecommendation
|
|
|
+ },
|
|
|
+ '@/config.js': stubs.config,
|
|
|
+ '@/components/MemberSelector.vue': stubs.MemberSelector,
|
|
|
+ '@/utils/memberNormalize.js': { memberIdOf: stubs.memberIdOf },
|
|
|
+ '@/mixins/font-scale.js': stubs.fontScaleMixin
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+afterEach(function () {
|
|
|
+ delete globalThis.__recommendationStubs
|
|
|
+})
|
|
|
+
|
|
|
+/** 家庭总览场景的成员列表(/api/food/recommendation/members 返回含 familyMemberId+nickname+isSelf) */
|
|
|
+function familyOf(memberIds) {
|
|
|
+ return memberIds.map(function (id) {
|
|
|
+ return { familyMemberId: id, nickname: '成员' + id, isSelf: id === 1 }
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// REQ7-前端 个人/成员视角:generateRecipe 不弹选择,直传 participant_member_ids:[currentMemberId]
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+describe('REQ7 personal 视角:generateRecipe 载荷直传当前成员为共餐人', function () {
|
|
|
+ var vm
|
|
|
+
|
|
|
+ beforeEach(function () {
|
|
|
+ var component = loadRecommendation().component
|
|
|
+ vm = instantiate(component)
|
|
|
+ vm.currentView = 'personal'
|
|
|
+ vm.currentMemberId = 42
|
|
|
+ stubs.generateDietRecommendation.mockClear()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('generate 载荷存在 participant_member_ids 键', async function () {
|
|
|
+ vm.generateRecipe()
|
|
|
+ await Promise.resolve()
|
|
|
+ await Promise.resolve()
|
|
|
+ expect(stubs.generateDietRecommendation).toHaveBeenCalled()
|
|
|
+ var payload = stubs.generateDietRecommendation.mock.calls[0][0]
|
|
|
+ expect(payload).toHaveProperty('participant_member_ids')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('participant_member_ids 解析为 [currentMemberId](只当前选中人,不做多选)', async function () {
|
|
|
+ vm.generateRecipe()
|
|
|
+ await Promise.resolve()
|
|
|
+ await Promise.resolve()
|
|
|
+ expect(stubs.generateDietRecommendation).toHaveBeenCalled()
|
|
|
+ var payload = stubs.generateDietRecommendation.mock.calls[0][0]
|
|
|
+ expect(participantIdsOf(payload)).toEqual([42])
|
|
|
+ })
|
|
|
+
|
|
|
+ it('成员视角(选中其他成员)同样直传该成员 id', async function () {
|
|
|
+ vm.currentMemberId = 88
|
|
|
+ vm.generateRecipe()
|
|
|
+ await Promise.resolve()
|
|
|
+ await Promise.resolve()
|
|
|
+ expect(stubs.generateDietRecommendation).toHaveBeenCalled()
|
|
|
+ var payload = stubs.generateDietRecommendation.mock.calls[0][0]
|
|
|
+ expect(participantIdsOf(payload)).toEqual([88])
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// REQ7-前端 家庭总览视角:点「生成食谱」先弹共餐人选择,不立即生成
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+describe('REQ7 family 视角:generateRecipe 先弹共餐人选择,不直接调 generate', function () {
|
|
|
+ var vm
|
|
|
+
|
|
|
+ beforeEach(function () {
|
|
|
+ var component = loadRecommendation().component
|
|
|
+ vm = instantiate(component)
|
|
|
+ vm.currentView = 'family'
|
|
|
+ vm.memberList = familyOf([1, 2, 3])
|
|
|
+ stubs.generateDietRecommendation.mockClear()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('family 视角 generateRecipe 不立即调用 generateDietRecommendation(等待共餐人确认)', async function () {
|
|
|
+ vm.generateRecipe()
|
|
|
+ await Promise.resolve()
|
|
|
+ await Promise.resolve()
|
|
|
+ await Promise.resolve()
|
|
|
+ expect(stubs.generateDietRecommendation).not.toHaveBeenCalled()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('family 视角即使成员列表为单人也先弹选择(不跳过共餐人确认)', async function () {
|
|
|
+ vm.memberList = familyOf([1])
|
|
|
+ vm.generateRecipe()
|
|
|
+ await Promise.resolve()
|
|
|
+ await Promise.resolve()
|
|
|
+ await Promise.resolve()
|
|
|
+ expect(stubs.generateDietRecommendation).not.toHaveBeenCalled()
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// REQ7-前端 家庭总览视角:确认共餐人后生成,participant_member_ids 默认覆盖全部成员
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+describe('REQ7 family 视角:确认共餐人后生成的载荷契约', function () {
|
|
|
+ var vm
|
|
|
+
|
|
|
+ beforeEach(function () {
|
|
|
+ var component = loadRecommendation().component
|
|
|
+ vm = instantiate(component)
|
|
|
+ vm.currentView = 'family'
|
|
|
+ vm.memberList = familyOf([1, 2, 3])
|
|
|
+ stubs.generateDietRecommendation.mockClear()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('家庭总览存在「确认共餐人」入口(confirmParticipants 方法)', async function () {
|
|
|
+ vm.generateRecipe()
|
|
|
+ await Promise.resolve()
|
|
|
+ await Promise.resolve()
|
|
|
+ expect(typeof vm.confirmParticipants).toBe('function')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('未显式勾选时确认 → participant_member_ids 默认覆盖全部成员(默认勾选全部)', async function () {
|
|
|
+ vm.generateRecipe()
|
|
|
+ await Promise.resolve()
|
|
|
+ await Promise.resolve()
|
|
|
+ expect(typeof vm.confirmParticipants).toBe('function')
|
|
|
+ if (typeof vm.confirmParticipants === 'function') {
|
|
|
+ await vm.confirmParticipants()
|
|
|
+ await Promise.resolve()
|
|
|
+ await Promise.resolve()
|
|
|
+ expect(stubs.generateDietRecommendation).toHaveBeenCalled()
|
|
|
+ var payload = stubs.generateDietRecommendation.mock.calls[0][0]
|
|
|
+ expect(participantIdsOf(payload)).toEqual([1, 2, 3])
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it('confirmParticipants 生成的载荷仍含 participant_member_ids 键', async function () {
|
|
|
+ vm.generateRecipe()
|
|
|
+ await Promise.resolve()
|
|
|
+ await Promise.resolve()
|
|
|
+ expect(typeof vm.confirmParticipants).toBe('function')
|
|
|
+ if (typeof vm.confirmParticipants === 'function') {
|
|
|
+ await vm.confirmParticipants()
|
|
|
+ await Promise.resolve()
|
|
|
+ await Promise.resolve()
|
|
|
+ expect(stubs.generateDietRecommendation).toHaveBeenCalled()
|
|
|
+ var payload = stubs.generateDietRecommendation.mock.calls[0][0]
|
|
|
+ expect(payload).toHaveProperty('participant_member_ids')
|
|
|
+ }
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// 设计锚点:utils/api.js generateDietRecommendation 为通用透传,无需改动
|
|
|
+// (规格 §2 需求 7:generateDietRecommendation 通用透传 request,GREEN 不动 api.js)
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+describe('设计锚点:api.js generateDietRecommendation 透传 participant_member_ids', function () {
|
|
|
+ it('POST /api/diet/recommendation/generate 载荷含 participant_member_ids 原样透传', async function () {
|
|
|
+ var captured = null
|
|
|
+ global.uni.request.mockImplementation(function (opts) {
|
|
|
+ captured = opts
|
|
|
+ process.nextTick(function () { opts.success({ data: { code: 200, message: 'ok', data: { id: 1 } } }) })
|
|
|
+ })
|
|
|
+ clearApiDedup()
|
|
|
+ await apiGenerateDietRecommendation({ date: '2026-11-08', meal_type: 'all', participant_member_ids: '[1,2,3]' })
|
|
|
+ expect(captured.url).toContain('/api/diet/recommendation/generate')
|
|
|
+ expect(captured.method).toBe('POST')
|
|
|
+ expect(captured.data).toEqual(expect.objectContaining({ participant_member_ids: '[1,2,3]' }))
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// 权限流:本能力为「生成食谱时的共餐人选择」,家长/孩子进入均可能触发
|
|
|
+// (家庭总览为家长视角典型入口;成员视角任何角色均可),无额外角色门禁,
|
|
|
+// 属《编排派发》中「权限(适用时)」的不适用项,显式记录。
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+describe.skip('权限流:共餐人选择无角色门禁,不适用', function () {
|
|
|
+ it('占位:生成食谱在家长端/成员端共用 generateRecipe,无 role 分支(详见汇报)', function () {})
|
|
|
+})
|