Bläddra i källkod

merge(S-cfc-79): add RED contract tests for diet.recommendation.uiRework (missing top title, 4Tab layout, pagedFoods slice, visibleCount reset, selfLabel=我, generateRecipe loading)

Sisyphus Agent 4 dagar sedan
förälder
incheckning
2438bb283
1 ändrade filer med 462 tillägg och 0 borttagningar
  1. 462 0
      cfc-frontend/__tests__/pages/diet/recommendation.uiRework.test.js

+ 462 - 0
cfc-frontend/__tests__/pages/diet/recommendation.uiRework.test.js

@@ -0,0 +1,462 @@
+/**
+ * 契约级单测(TDD-RED)——能力键 diet.recommendation.uiRework
+ *
+ * 溯源:
+ *   requirement: docs/superpowers/specs/2026-11-08-diet-recommendation-polish.md
+ *                §2 需求 1-6(页面标题、互斥选中、4Tab、分页、「我」、生成 loading)
+ *   design:      同文档 §2 各条落点;cfc-frontend/pages/diet/recommendation.vue
+ *                (L6-9 删标题、L65-75 改4Tab、L103-123 列表分页、L285-334 filteredFoods、
+ *                 L407-425 生成提示、L658-674 互斥);
+ *                components/MemberSelector.vue minimal 变体 selfLabel/overviewActive
+ *
+ * 说明:
+ *   沿用 __tests__/family-join/join-form.required-info.test.js 约定:直接加载真实
+ *   recommendation.vue SFC 的 <script>(import 替换为注入桩后求值)拿到 data()/methods,
+ *   做行为级断言;读取 <template> 做结构契约断言。当前实现未落地(REQ1-6 均未写),
+ *   预期全部 RED;GREEN 阶段在实现不修改本文件的前提下翻绿。
+ *   本文件不修改 pages/ / components/ / utils/ 任何业务代码。
+ */
+
+import fs from 'fs'
+import path from 'path'
+
+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
+}
+
+/** 安全求值 pagedFoods:computed 缺失时返回 null(让断言以 toBeDefined 干净失败) */
+function pagedOf(vm, component) {
+  var c = component.computed && component.computed.pagedFoods
+  if (!c) return null
+  return c.call(vm)
+}
+
+// ---------------------------------------------------------------------------
+// 测试夹具
+// ---------------------------------------------------------------------------
+
+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
+})
+
+// ---------------------------------------------------------------------------
+// 测试辅助
+// ---------------------------------------------------------------------------
+
+function buildFoods(n) {
+  var out = []
+  for (var i = 1; i <= n; i++) {
+    out.push({ id: i, name: '食材' + i, category: i % 3 === 0 ? '蔬菜' : (i % 3 === 1 ? '水果' : '肉禽'), score: i % 2 === 0 ? 5 : -3 })
+  }
+  return out
+}
+
+// ---------------------------------------------------------------------------
+// REQ1 页面顶部:去掉「推荐食材」标题 + 副标题;MemberSelector 保留为首个元素
+// ---------------------------------------------------------------------------
+
+describe('REQ1 模板结构:去掉顶部「推荐食材」标题区', function () {
+  var template
+  beforeAll(function () {
+    globalThis.__recommendationStubs = {}
+    template = loadRecommendation().template
+  })
+
+  it('删除包含「查看家庭成员适合与戒备的食材」副标题的顶部 section-title-bar 块', function () {
+    expect(template).not.toContain('查看家庭成员适合与戒备的食材')
+  })
+
+  it('MemberSelector 仍然作为页面首个元素(minimal 变体),未被整体移除', function () {
+    expect(template).toContain('<MemberSelector')
+    expect(template).toContain(':show-overview="true"')
+  })
+})
+
+// ---------------------------------------------------------------------------
+// REQ2 家庭总览 ↔ 个人/成员互斥:switchView('family') 清 currentMemberId,loadMembers 不再默认选
+// ---------------------------------------------------------------------------
+
+describe('REQ2 数据行为:switchView/family 互斥', function () {
+  var vm
+
+  beforeEach(function () {
+    var component = loadRecommendation().component
+    vm = instantiate(component)
+  })
+
+  it('switchView("family") 置 currentView=family 且清 currentMemberId=null(个人/成员 chip 不高亮)', function () {
+    vm.currentMemberId = 7
+    vm.currentView = 'personal'
+    vm.switchView('family')
+    expect(vm.currentView).toBe('family')
+    expect(vm.currentMemberId).toBeNull()
+  })
+
+  it('switchMember(member) 保留已有选中:currentView=personal + currentMemberId 为 member 的 id', function () {
+    vm.switchMember({ familyMemberId: 5 })
+    expect(vm.currentView).toBe('personal')
+    expect(vm.currentMemberId).toBe(5)
+  })
+
+  it('switchView("family") 后再调 loadMembers:不得再默认选中个人(loadMembers 默认选中仅限首次 onLoad)', function () {
+    vm.currentMemberId = 7
+    vm.switchView('family')
+    expect(vm.currentMemberId).toBeNull()
+    global.uni.setStorageSync('token', 'test-token')
+    global.uni.request.mockImplementation(function (opts) {
+      opts.success({ data: { code: 200, data: [{ familyMemberId: 1, nickname: '我', isSelf: true }] } })
+    })
+    vm.loadMembers()
+    return Promise.resolve().then(function () {
+      expect(vm.currentMemberId).toBeNull()
+      expect(vm.memberList).toEqual([{ familyMemberId: 1, nickname: '我', isSelf: true }])
+    })
+  })
+})
+
+// ---------------------------------------------------------------------------
+// REQ3 4Tab 行(今日食谱 | 适合 | 戒备 | 全部):data 新增 currentTab='recipe',模板切 Tab
+// ---------------------------------------------------------------------------
+
+describe('REQ3 数据契约:新增 currentTab 字段(初始 recipe)', function () {
+  var component
+  beforeEach(function () {
+    component = loadRecommendation().component
+  })
+  it('data().currentTab 初值为 "recipe"(进页默认显示今日食谱)', function () {
+    expect(component.data().currentTab).toBe('recipe')
+  })
+})
+
+describe('REQ3 模板契约:4Tab 切换行 + 按 currentTab 渲染内容区', function () {
+  var template
+  beforeAll(function () {
+    globalThis.__recommendationStubs = {}
+    template = loadRecommendation().template
+  })
+
+  it('模板出现 switchTab("recipe") / switchTab("suitable") / switchTab("unsuitable") / switchTab("all") 绑定', function () {
+    expect(template).toMatch(/switchTab\(['"]recipe['"]\)/)
+    expect(template).toMatch(/switchTab\(['"]suitable['"]\)/)
+    expect(template).toMatch(/switchTab\(['"]unsuitable['"]\)/)
+    expect(template).toMatch(/switchTab\(['"]all['"]\)/)
+  })
+
+  it('今日食谱内容区按 currentTab === \'recipe\' 条件渲染', function () {
+    expect(template).toMatch(/currentTab\s*===\s*['"]recipe['"]/)
+  })
+
+  it('食材区出现 currentTab 引用(过滤栏区域按 currentTab 区分内容)', function () {
+    expect(template).toMatch(/currentTab/)
+  })
+})
+
+describe('REQ3 行为契约:switchTab 更新 currentTab', function () {
+  var vm
+  beforeEach(function () {
+    var component = loadRecommendation().component
+    vm = instantiate(component)
+  })
+  it('switchTab("suitable") 置 currentTab 并重置分页 visibleCount', function () {
+    expect(typeof vm.switchTab).toBe('function')
+    if (typeof vm.switchTab !== 'function') return
+    vm.visibleCount = 60
+    vm.switchTab('suitable')
+    expect(vm.currentTab).toBe('suitable')
+    expect(vm.visibleCount).toBe(20)
+  })
+})
+
+// ---------------------------------------------------------------------------
+// REQ4 推荐食材列表分页(前端 slice + scroll-view 触底加载更多)
+// ---------------------------------------------------------------------------
+
+describe('REQ4 数据契约:visibleCount / PAGE_SIZE 初值', function () {
+  var component
+  beforeEach(function () {
+    component = loadRecommendation().component
+  })
+  it('data().visibleCount 初值为 20', function () {
+    expect(component.data().visibleCount).toBe(20)
+  })
+})
+
+describe('REQ4 计算属性 pagedFoods(分片)', function () {
+  var vm, component
+  beforeEach(function () {
+    component = loadRecommendation().component
+    vm = instantiate(component)
+    vm.currentView = 'personal'
+    vm.filterType = 'all'
+    vm.activeCategory = 'all'
+    vm.searchQuery = ''
+    vm.allFoods = buildFoods(45)
+  })
+
+  it('computed.pagedFoods 存在', function () {
+    expect(typeof component.computed.pagedFoods).toBe('function')
+  })
+
+  it('首批只返回前 visibleCount(20)条', function () {
+    expect(typeof component.computed.pagedFoods).toBe('function')
+    var page = pagedOf(vm, component)
+    if (page === null) return
+    expect(page.length).toBe(20)
+  })
+
+  it('visibleCount 增加时 pagedFoods 随之增长且不超全量', function () {
+    expect(typeof component.computed.pagedFoods).toBe('function')
+    var page = pagedOf(vm, component)
+    if (page === null) return
+    vm.visibleCount = 40
+    expect(pagedOf(vm, component).length).toBe(40)
+    vm.visibleCount = 100
+    expect(pagedOf(vm, component).length).toBe(45)
+  })
+})
+
+describe('REQ4 loadMore() 触底加载行为', function () {
+  var vm, component
+  beforeEach(function () {
+    component = loadRecommendation().component
+    vm = instantiate(component)
+    vm.currentView = 'personal'
+    vm.filterType = 'all'
+    vm.activeCategory = 'all'
+    vm.searchQuery = ''
+    vm.allFoods = buildFoods(45)
+    vm.visibleCount = 20
+  })
+
+  it('loadMore 是函数', function () {
+    expect(typeof vm.loadMore).toBe('function')
+  })
+
+  it('触底一次:visibleCount += PAGE_SIZE(20 → 40),pagedFoods 同步增至 40', function () {
+    expect(typeof vm.loadMore).toBe('function')
+    if (typeof vm.loadMore !== 'function') return
+    vm.loadMore()
+    expect(vm.visibleCount).toBe(40)
+    expect(pagedOf(vm, component).length).toBe(40)
+  })
+
+  it('触底两次后未超全量并自动停止(20 → 40 → 60;第 3 次不变化,末尾停)', function () {
+    expect(typeof vm.loadMore).toBe('function')
+    if (typeof vm.loadMore !== 'function') return
+    vm.loadMore()
+    vm.loadMore()
+    expect(vm.visibleCount).toBe(60)
+    expect(pagedOf(vm, component).length).toBe(45)
+    vm.loadMore()
+    expect(vm.visibleCount).toBe(60)
+  })
+
+  it('切换筛选:setFilter -> reset visibleCount = 20', function () {
+    vm.visibleCount = 60
+    vm.setFilter('unsuitable')
+    expect(vm.visibleCount).toBe(20)
+  })
+
+  it('切换分类:switchCategory -> reset visibleCount = 20', function () {
+    vm.visibleCount = 60
+    vm.switchCategory('all')
+    expect(vm.visibleCount).toBe(20)
+  })
+
+  it('切换成员:switchMember -> reset visibleCount = 20 且更新 currentView/currentMemberId', function () {
+    vm.visibleCount = 60
+    vm.switchMember({ familyMemberId: 9 })
+    expect(vm.currentView).toBe('personal')
+    expect(vm.currentMemberId).toBe(9)
+    expect(vm.visibleCount).toBe(20)
+  })
+})
+
+describe('REQ4 模板契约:v-for 改用 pagedFoods;scroll-view 监听 @scrolltolower="loadMore"', function () {
+  var template
+  beforeAll(function () {
+    globalThis.__recommendationStubs = {}
+    template = loadRecommendation().template
+  })
+  it('食材列表 v-for 引用 pagedFoods(非 filteredFoods)', function () {
+    expect(template).toContain('pagedFoods')
+  })
+  it('外层 scroll-view 绑定 @scrolltolower="loadMore"', function () {
+    expect(template).toContain('@scrolltolower="loadMore"')
+  })
+})
+
+// ---------------------------------------------------------------------------
+// REQ5 「个人」改为「我」:recommendation.vue 给 MemberSelector 传 self-label="'我'"
+// ---------------------------------------------------------------------------
+
+describe('REQ5 模板契约:MemberSelector 传 self-label="我"', function () {
+  var template
+  beforeAll(function () {
+    globalThis.__recommendationStubs = {}
+    template = loadRecommendation().template
+  })
+  it('推荐食材页的 MemberSelector 显式传入 self-label="我"(不依赖组件默认 👤 个人)', function () {
+    expect(template).toMatch(/self-label\s*=\s*['"]我['"]/)
+  })
+})
+
+// ---------------------------------------------------------------------------
+// REQ6 生成食谱 loading 提示
+// ---------------------------------------------------------------------------
+
+describe('REQ6 generateRecipe 正在生成 loading', function () {
+  var vm, component
+  beforeEach(function () {
+    component = loadRecommendation().component
+    vm = instantiate(component)
+    stubs.generateDietRecommendation.mockClear()
+    stubs.generateDietRecommendation.mockImplementationOnce(function () {
+      return Promise.resolve({ data: { id: 100, date: '2026-11-08' } })
+    })
+  })
+
+  it('发起生成时 uni.showLoading({ title 含 "正在生成" })', async function () {
+    vm.generateRecipe()
+    await Promise.resolve()
+    await Promise.resolve()
+    expect(global.uni.showLoading).toHaveBeenCalledWith(expect.objectContaining({ title: expect.stringContaining('正在生成') }))
+  })
+
+  it('成功后 uni.hideLoading() 并 toast 成功提示', async function () {
+    await vm.generateRecipe()
+    expect(global.uni.hideLoading).toHaveBeenCalled()
+    expect(global.uni.showToast).toHaveBeenCalledWith(expect.objectContaining({ title: expect.stringContaining('成功'), icon: 'success' }))
+  })
+
+  it('失败后 uni.hideLoading() 并 toast 失败', async function () {
+    stubs.generateDietRecommendation.mockImplementationOnce(function () { return Promise.reject(new Error('net')) })
+    await vm.generateRecipe()
+    expect(global.uni.hideLoading).toHaveBeenCalled()
+    expect(global.uni.showToast).toHaveBeenCalledWith(expect.objectContaining({ icon: 'none' }))
+  })
+})
+
+// ---------------------------------------------------------------------------
+// 回归防护:既有能力不因 uiRework 破坏
+// ---------------------------------------------------------------------------
+
+describe('回归防护:既有核心方法仍可用', function () {
+  var vm
+  beforeEach(function () {
+    var component = loadRecommendation().component
+    vm = instantiate(component)
+  })
+
+  it('filteredFoods computed 在 personal + allFoods 非空场景下有输出(无回归)', function () {
+    vm.currentView = 'personal'
+    vm.allFoods = [{ id: 1, name: 'Apple', score: 3, status: 'suitable', statusText: '适合' }]
+    vm.filterType = 'suitable'
+    vm.activeCategory = 'all'
+    vm.searchQuery = ''
+    var result = vm.filteredFoods
+    expect(Array.isArray(result)).toBe(true)
+    expect(result.length).toBe(1)
+    expect(result[0].name).toBe('Apple')
+  })
+
+  it('switchMember 触发 loadFoods(即使无 token 提前返回无副作用)', function () {
+    // setup.js 的 global.uni.getStorageSync 非 jest mock;未设 token → loadFoods 提前 return
+    vm.switchMember({ familyMemberId: 2 })
+    expect(vm.currentMemberId).toBe(2)
+    expect(vm.currentView).toBe('personal')
+  })
+})