Procházet zdrojové kódy

test(diet.food.healthAdjustUi): S-cfc-97 add RED contract tests for 推荐食材健康问卷调整提示条与分组 (B1-B3)

Sisyphus Agent před 4 dny
rodič
revize
eea38d6ec

+ 340 - 0
cfc-frontend/__tests__/diet/recommendation.healthAdjustUi.test.js

@@ -0,0 +1,340 @@
+/**
+ * 契约级单测(TDD-RED)——能力键 diet.food.healthAdjustUi
+ *
+ * 溯源:
+ *   requirement: docs/superpowers/specs/2026-11-09-diet-recommendation-health-questionnaire-adjust.md
+ *                §2 调整分级语义(blocked/warning/boost/normal)、§4 验收判据 V/C-1/6
+ *   design:      同上 §3 前端 B1-B3 + cfc-frontend/pages/diet/recommendation.vue L104-123/L285-334/L491-523
+ *
+ * B1. loadFoods(L491-523):/api/health/foods 响应食材透传 adjustment / adjustmentReason(旁置现有 scoreClass/statusText/status 映射)
+ * B2. 食物卡片(L104-123):按 item.adjustment 渲染提示条——
+ *     blocked → 红条「⚠ 健康问卷不建议:{adjustmentReason}」
+ *     warning → 黄条「注意:{adjustmentReason}」
+ *     boost   → 绿条「✓ 符合饮食习惯偏好」
+ *     normal/空 → 不渲染
+ * B3. filteredFoods(L285-334):suitable Tab 中 adjustment==='blocked' 项排到列表末尾
+ *     (保持可见但不置顶);unsuitable / all 位置不变、仅带提示条;排序逻辑不变,仅 stable 分组。
+ *
+ * 说明(沿用既有的 Jest 体系约束,见 __tests__/family-join/join-form.required-info.test.js):
+ *   jest.config.js 仅 transform .js;约定为「加载真实 SFC 的 <script>/<template> 源码断言」,不挂载 .vue。
+ *   本文件直接读取推荐食材页 recommendation.vue 的源码——
+ *   - <script>:import 语句替换为注入桩后求值,驱动真实 data()/methods/computed 做行为级断言;
+ *   - <template>:读取模板文本做结构契约断言(三类提示条 + adjustment 分支条件 + adjustmentReason 插值)。
+ *   GREEN 阶段实现方对 recommendation.vue 补足 B1-B3 后本文件即翻绿;当前实现缺失 → 预期 RED。
+ *   本文件不修改 pages/ 任何业务代码。
+ */
+
+import fs from 'fs'
+import path from 'path'
+
+const RECOMMENDATION_PATH = path.join(__dirname, '../../pages/diet/recommendation.vue')
+
+// ---------------------------------------------------------------------------
+// 工具:加载真实 recommendation.vue 的 <script> 行为(data/methods/computed)与 <template> 结构
+// ---------------------------------------------------------------------------
+
+/** 把 ES import 语句替换为从 globalThis.__recommendationStubs 取桩的声明(不解析模块,避免污染业务实现) */
+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 }(component = 真实 <script> 的 export default 对象) */
+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] : '' }
+}
+
+/** 依据真实组件的 data()/methods 构造可驱动上下文(模拟 Vue 合并,computed 由用例对 component.computed 调用) */
+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
+}
+
+/** 驱动真实 computed.filteredFoods(computed 为函数属性,以 vm 为 this 求值) */
+function evalFilteredFoods(component, vm) {
+  var computed = component.computed || {}
+  if (typeof computed.filteredFoods !== 'function') throw new Error('computed.filteredFoods 缺失')
+  return computed.filteredFoods.call(vm)
+}
+
+function ids(list) {
+  return list.map(function (f) { return f.id })
+}
+
+// ---------------------------------------------------------------------------
+// 测试夹具
+// ---------------------------------------------------------------------------
+
+var stubs
+
+beforeEach(function () {
+  global.uni._storage = {}
+  global.uni.request.mockReset()
+  global.uni.showToast.mockReset()
+  stubs = {
+    getDietRecommendation: jest.fn(function () { return Promise.resolve({ code: 0, message: 'ok', data: null }) }),
+    generateDietRecommendation: jest.fn(),
+    completeDietRecommendation: jest.fn(),
+    regenerateDietRecommendation: jest.fn(),
+    config: { API_BASE_URL: 'http://mock' },
+    MemberSelector: {},
+    memberIdOf: function (m) { return m && (m.familyMemberId || m.memberId || m.id) },
+    fontScaleMixin: { 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': { config: stubs.config },
+    '@/components/MemberSelector.vue': { MemberSelector: stubs.MemberSelector },
+    '@/utils/memberNormalize.js': { memberIdOf: stubs.memberIdOf },
+    '@/mixins/font-scale.js': { fontScaleMixin: stubs.fontScaleMixin }
+  }
+})
+
+afterEach(function () {
+  delete globalThis.__recommendationStubs
+})
+
+// ---------------------------------------------------------------------------
+// B1. loadFoods 透传 adjustment / adjustmentReason(旁置现有 scoreClass/statusText/status 映射)
+//     契约:/api/health/foods 响应食材的 adjustment / adjustmentReason 必须原样进入 allFoods,
+//     不得被 map 丢弃或改写(GREEN 实现须保持显式透传;当前借 object 复用保留,属既有增强面)
+// ---------------------------------------------------------------------------
+
+describe('B1 loadFoods 透传 adjustment / adjustmentReason', function () {
+  var component
+
+  beforeEach(function () {
+    component = loadRecommendation().component
+    global.uni._storage.token = 'mock-token'
+  })
+
+  it('后端返回 adjustment=blocked 食材 → allFoods 保留 blocked 与 adjustmentReason 原文', function () {
+    var foods = [{ id: 1, name: '花生', category: '谷物', score: 8, adjustment: 'blocked', adjustmentReason: '过敏原花生命中,建议不食' }]
+    global.uni.request.mockImplementation(function (opts) {
+      opts.success({ data: { code: 200, data: foods } })
+    })
+    var vm = instantiate(component)
+    vm.loadFoods()
+    expect(vm.allFoods.length).toBe(1)
+    expect(vm.allFoods[0].adjustment).toBe('blocked')
+    expect(vm.allFoods[0].adjustmentReason).toBe('过敏原花生命中,建议不食')
+  })
+
+  it('后端返回 adjustment=warning 食材 → allFoods 保留 warning 与 interval 提示文案', function () {
+    var foods = [{ id: 2, name: '西柚', category: '水果', score: 3, adjustment: 'warning', adjustmentReason: '药物二甲双胍与西柚需间隔 30 分钟' }]
+    global.uni.request.mockImplementation(function (opts) {
+      opts.success({ data: { code: 200, data: foods } })
+    })
+    var vm = instantiate(component)
+    vm.loadFoods()
+    expect(vm.allFoods[0].adjustment).toBe('warning')
+    expect(vm.allFoods[0].adjustmentReason).toBe('药物二甲双胍与西柚需间隔 30 分钟')
+  })
+
+  it('后端未返回 adjustment(报告快照分支/灰度未下发)→ allFoods 不伪造 adjustment 值', function () {
+    var foods = [{ id: 3, name: '苹果', category: '水果', score: 6 }]
+    global.uni.request.mockImplementation(function (opts) {
+      opts.success({ data: { code: 200, data: foods } })
+    })
+    var vm = instantiate(component)
+    vm.loadFoods()
+    // normal/空 → 不渲染分支的输入前提:缺失时保持 undefined,不得擅自赋值 blocked/warning/boost
+    expect(vm.allFoods[0].adjustment).toBeUndefined()
+    expect(vm.allFoods[0].adjustmentReason).toBeUndefined()
+  })
+
+  it('既有 scoreClass/statusText/status 映射无回归', function () {
+    var foods = [{ id: 4, name: '鸡蛋', category: '蛋奶', score: 10, adjustment: 'boost' }]
+    global.uni.request.mockImplementation(function (opts) {
+      opts.success({ data: { code: 200, data: foods } })
+    })
+    var vm = instantiate(component)
+    vm.loadFoods()
+    expect(vm.allFoods[0].scoreClass).toBe('good')
+    expect(vm.allFoods[0].statusText).toBe('适合')
+    expect(vm.allFoods[0].status).toBe('suitable')
+  })
+})
+
+// ---------------------------------------------------------------------------
+// B2. 食物卡片按 item.adjustment 渲染三类提示条(template 结构契约,当前实现缺失 → 预期 RED)
+//     blocked → 红条「⚠ 健康问卷不建议:{adjustmentReason}」
+//     warning → 黄条「注意:{adjustmentReason}」
+//     boost   → 绿条「✓ 符合饮食习惯偏好」
+//     normal/空 → 不渲染(无独立分支,仅 v-if 缺席判定)
+// ---------------------------------------------------------------------------
+
+describe('B2 食物卡片 adjustment 提示条(template 结构契约)', function () {
+  var template
+  beforeAll(function () {
+    // beforeAll 先于 beforeEach:仅做模板结构断言,无需桩函数,置空即可(loadRecommendation 对桩做可选访问)
+    globalThis.__recommendationStubs = {}
+    template = loadRecommendation().template
+  })
+
+  it('blocked:红条「健康问卷不建议」以 item.adjustment === blocked 为条件', function () {
+    expect(template).toMatch(/健康问卷不建议/)
+    expect(template).toMatch(/item\.adjustment\s*===?\s*'blocked'/)
+  })
+
+  it('blocked 红条插值渲染 adjustmentReason(原因为空则不渲染该文案)', function () {
+    expect(template).toMatch(/item\.adjustmentReason/)
+  })
+
+  it('warning:黄条「注意」以 item.adjustment === warning 为条件', function () {
+    expect(template).toMatch(/注意/)
+    expect(template).toMatch(/item\.adjustment\s*===?\s*'warning'/)
+  })
+
+  it('boost:绿条「符合饮食习惯偏好」以 item.adjustment === boost 为条件', function () {
+    expect(template).toMatch(/符合饮食习惯偏好/)
+    expect(template).toMatch(/item\.adjustment\s*===?\s*'boost'/)
+  })
+
+  it('提示条仅挂在 adjustment 判定之下(normal/空不渲染:模板中不得出现无条件提示文案)', function () {
+    // 三个提示文案若缺 v-if 条件(如直接常显),将同时命中「无条件常显」:此处抽样断言文案与条件在卡片域内共存。
+    var cardRegion = template.split('<view class="food-card"')[1] || ''
+    expect(cardRegion).toMatch(/item\.adjustment/)
+  })
+})
+
+// ---------------------------------------------------------------------------
+// B3. filteredFoods 分组:suitable Tab 中 blocked 项排到列表末尾(当前仅按 score 排序 → 预期 RED)
+//     unsuitable / all:位置不变,仅带提示条(既有 score 排序无回归锁定)
+// ---------------------------------------------------------------------------
+
+describe('B3 filteredFoods 分组(suitable blocked 排末尾)', function () {
+  function makeVm(component) {
+    var vm = instantiate(component)
+    vm.currentView = 'personal'
+    vm.activeCategory = 'all'
+    vm.searchQuery = ''
+    return vm
+  }
+
+  it('suitable + desc:非 blocked 按分值在前,blocked 高值项落列表末尾(不置顶)', function () {
+    var component = loadRecommendation().component
+    var vm = makeVm(component)
+    vm.filterType = 'suitable'
+    vm.sortType = 'desc'
+    vm.allFoods = [
+      { id: 1, name: '鸡蛋', category: '蛋奶', score: 3, adjustment: 'boost' },
+      { id: 2, name: '花生', category: '谷物', score: 10, adjustment: 'blocked', adjustmentReason: '过敏原花生命中,建议不食' },
+      { id: 3, name: '牛奶', category: '蛋奶', score: 5, adjustment: 'normal' }
+    ]
+    var result = evalFilteredFoods(component, vm)
+    // 契约:花生(blocked) 虽分值最高,仍排末尾 → [牛奶, 鸡蛋, 花生]
+    expect(ids(result)).toEqual([3, 1, 2])
+  })
+
+  it('suitable Tab 首项不得为 blocked(存在非 blocked 可排时)', function () {
+    var component = loadRecommendation().component
+    var vm = makeVm(component)
+    vm.filterType = 'suitable'
+    vm.sortType = 'desc'
+    vm.allFoods = [
+      { id: 1, name: '鸡蛋', category: '蛋奶', score: 3, adjustment: 'boost' },
+      { id: 2, name: '花生', category: '谷物', score: 10, adjustment: 'blocked', adjustmentReason: '过敏原花生命中,建议不食' },
+      { id: 3, name: '牛奶', category: '蛋奶', score: 5, adjustment: 'normal' }
+    ]
+    var result = evalFilteredFoods(component, vm)
+    expect(result[0].adjustment).not.toBe('blocked')
+  })
+
+  it('suitable + desc:多个 blocked 项整体落末尾(stable 分组,组内保持分值序)', function () {
+    var component = loadRecommendation().component
+    var vm = makeVm(component)
+    vm.filterType = 'suitable'
+    vm.sortType = 'desc'
+    vm.allFoods = [
+      { id: 1, name: '鸡蛋', category: '蛋奶', score: 9, adjustment: 'boost' },
+      { id: 2, name: '花生', category: '谷物', score: 8, adjustment: 'blocked', adjustmentReason: '过敏原花生命中,建议不食' },
+      { id: 3, name: '核桃', category: '坚果', score: 7, adjustment: 'blocked', adjustmentReason: '疾病痛风禁忌食用' },
+      { id: 4, name: '苹果', category: '水果', score: 6, adjustment: undefined }
+    ]
+    var result = evalFilteredFoods(component, vm)
+    // 非 blocked 在前(鸡蛋9 > 苹果6),blocked 组整体落末尾(花生8 > 核桃7)
+    expect(ids(result)).toEqual([1, 4, 2, 3])
+  })
+
+  it('unsuitable Tab:blocked 不强制移动,仅按既有分值排序(位置不变、仅带提示条)', function () {
+    var component = loadRecommendation().component
+    var vm = makeVm(component)
+    vm.filterType = 'unsuitable'
+    vm.sortType = 'asc'
+    vm.allFoods = [
+      { id: 1, name: '动物内脏', category: '肉禽', score: -5, adjustment: 'blocked', adjustmentReason: '疾病痛风禁忌食用' },
+      { id: 2, name: '西柚', category: '水果', score: -8, adjustment: 'warning', adjustmentReason: '药物间隔离提示' },
+      { id: 3, name: '腌制菜', category: '蔬菜', score: -3, adjustment: 'normal' }
+    ]
+    var result = evalFilteredFoods(component, vm)
+    // 升序:西柚(-8) < 动物内脏(-5) < 腌制菜(-3),blocked 不置末
+    expect(ids(result)).toEqual([2, 1, 3])
+  })
+
+  it('all Tab:位置不变、仅带提示条(既有 score 排序无回归)', function () {
+    var component = loadRecommendation().component
+    var vm = makeVm(component)
+    vm.filterType = 'all'
+    vm.sortType = 'desc'
+    vm.allFoods = [
+      { id: 1, name: '鸡蛋', category: '蛋奶', score: 9, adjustment: 'boost' },
+      { id: 2, name: '花生', category: '谷物', score: 8, adjustment: 'blocked', adjustmentReason: '过敏原花生命中,建议不食' },
+      { id: 4, name: '苹果', category: '水果', score: 6, adjustment: undefined }
+    ]
+    var result = evalFilteredFoods(component, vm)
+    expect(ids(result)).toEqual([1, 2, 4])
+  })
+})
+
+// ---------------------------------------------------------------------------
+// 权限流:本能力为「当前推荐」个人食材视图的展示层(recommendation.vue),
+// 页面沿既有 JWT/memberId 鉴权(controller 侧 userId/familyId 属性鉴权,接口不新增越权面),
+// 前端无独立角色分支 → 《编排派发》中「权限(适用时)」的不适用项,显式记录。
+// ---------------------------------------------------------------------------
+
+describe.skip('权限流:健康问卷调整展示无独立角色门禁,不适用', function () {
+  it('占位:该能力沿既有 memberId/userId 鉴权,无新增 role 分支(详见汇报)', function () {})
+})