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

merge(S-cfc-80): RED contract tests for diet.recommendation.participantSelect (co-diner multi-select default-all, personal currentMemberId payload)

Sisyphus Agent 4 дней назад
Родитель
Сommit
999aaeae7a

+ 326 - 0
cfc-frontend/__tests__/pages/diet/recommendation.participantSelect.test.js

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