| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494 |
- /**
- * 契约级单测 —— 能力键 diet.recommendation.viewKeyAndDelete
- *
- * 溯源:
- * PRD docs/需求分析/2026-11-10-食材推荐食谱需求变更.md(38 条 AC,产品规则单一来源)
- * 设计 docs/需求分析/2026-11-10-食材推荐食谱需求变更-影响面与任务分解.md
- * §1.1(K1~K6)/ §3(接口契约)/ §8.2(AC→断言映射)
- *
- * 覆盖:buildViewKey 各视角、视角切换四处联动、生成载荷 view_key、模板契约(替换→删除)、
- * deleteDish 成功/失败/防重复、D2 只读菜数状态不补菜、空/加载/失败三态、
- * parseMenu 透传 dish.type、allMealsEmpty 整份空态。
- *
- * 约定(沿用 uiRework / participantSelect):加载真实 recommendation.vue 的 <script>/<template>,
- * import 替换为注入桩后求值,驱动真实 data()/methods/computed 做行为级断言;不挂载 .vue。
- * 本文件不修改任何业务代码。
- */
- import fs from 'fs'
- import path from 'path'
- const RECOMMENDATION_PATH = path.join(__dirname, '../../../pages/diet/recommendation.vue')
- 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')
- })
- }
- 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()/computed(getter)/methods 到 vm 实例(模拟 Vue Options 合并) */
- 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
- }
- var stubs
- beforeEach(function () {
- jest.useFakeTimers()
- global.uni._storage = {}
- global.uni.showToast.mockReset()
- global.uni.showLoading.mockReset()
- global.uni.hideLoading.mockReset()
- global.uni.request.mockReset()
- stubs = {
- getDietRecommendation: jest.fn(function () {
- return Promise.resolve({ code: 200, data: { id: 11, date: '2026-11-10', menu: '{"meals":[]}', nutritionSummary: {}, status: 'pending' } })
- }),
- generateDietRecommendation: jest.fn(function () { return Promise.resolve({ data: { id: 11 } }) }),
- updateDietRecommendation: jest.fn(function () { return Promise.resolve({ code: 200, message: 'success', data: null }) }),
- completeDietRecommendation: jest.fn(function () { return Promise.resolve({}) }),
- regenerateDietRecommendation: jest.fn(function () { return Promise.resolve({ data: { id: 11 } }) }),
- 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,
- updateDietRecommendation: stubs.updateDietRecommendation,
- 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 () {
- jest.clearAllTimers()
- jest.useRealTimers()
- delete globalThis.__recommendationStubs
- })
- /** 构造一份 4 菜 1 汤的午餐,便于删除 / 状态断言 */
- function lunchWithDishes() {
- return {
- name: '午餐', type: 'lunch', icon: '☀️', participants: 3,
- dishes: [
- { name: '红烧肉', type: 'dish', calories: 300, ingredients: [{ name: '肉', amount: 100 }], method: '炖' },
- { name: '清炒时蔬', type: 'dish', calories: 90, ingredients: [], method: '炒' },
- { name: '蒸鱼', type: 'dish', calories: 180, ingredients: [], method: '蒸' },
- { name: '凉拌黄瓜', type: 'dish', calories: 40, ingredients: [], method: '拌' },
- { name: '番茄蛋汤', type: 'soup', calories: 60, ingredients: [], method: '煮' }
- ]
- }
- }
- function dinnerWithDishes() {
- var meal = lunchWithDishes()
- meal.name = '晚餐'
- meal.type = 'dinner'
- meal.icon = '🌙'
- return meal
- }
- // ---------------------------------------------------------------------------
- // 1. buildViewKey:K1 视角键(AC-01/03/10)
- // ---------------------------------------------------------------------------
- describe('buildViewKey 视角键生成(K1,后端视为不透明字符串)', function () {
- var vm
- beforeEach(function () {
- vm = instantiate(loadRecommendation().component)
- })
- it('家庭总览固定返回 family', function () {
- vm.currentView = 'family'
- expect(vm.buildViewKey()).toBe('family')
- })
- it('个人/成员视角返回 member:<id>', function () {
- vm.currentView = 'personal'
- vm.currentMemberId = 42
- expect(vm.buildViewKey()).toBe('member:42')
- })
- it('loadMembers 未完成(personal 且无 currentMemberId、memberList 空)返回 null', function () {
- vm.currentView = 'personal'
- vm.currentMemberId = null
- vm.memberList = []
- expect(vm.buildViewKey()).toBeNull()
- })
- it('视角未就绪时不发 /today 请求,且清空残留防串数据', function () {
- vm.currentView = 'personal'
- vm.currentMemberId = null
- vm.memberList = []
- vm.meals = [lunchWithDishes()]
- vm.recommendation = { id: 99 }
- vm.loadRecommendation()
- expect(stubs.getDietRecommendation).not.toHaveBeenCalled()
- expect(vm.meals).toEqual([])
- expect(vm.recommendation).toBeNull()
- expect(vm.recipeLoading).toBe(false)
- })
- })
- // ---------------------------------------------------------------------------
- // 2. loadRecommendation 载荷 + 局部加载态(AC-02/04/09/36)
- // ---------------------------------------------------------------------------
- describe('loadRecommendation 按视角加载', function () {
- var vm
- beforeEach(function () {
- vm = instantiate(loadRecommendation().component)
- })
- it('请求携带 {date, view_key}', function () {
- vm.currentView = 'family'
- vm.loadRecommendation()
- expect(stubs.getDietRecommendation).toHaveBeenCalledWith(
- expect.objectContaining({ date: expect.any(String), view_key: 'family' })
- )
- })
- it('加载期间显示局部 loading 且清空上一视角的 meals(不串数据)', function () {
- vm.currentView = 'family'
- vm.currentMemberId = 7
- vm.meals = [lunchWithDishes()]
- vm.recommendation = { id: 99 }
- vm.loadRecommendation()
- expect(vm.recipeLoading).toBe(true)
- expect(vm.meals).toEqual([])
- expect(vm.recommendation).toBeNull()
- })
- it('请求成功后关闭 loading 并写入该视角食谱', async function () {
- vm.currentView = 'family'
- await vm.loadRecommendation()
- expect(vm.recipeLoading).toBe(false)
- expect(vm.recommendation.id).toBe(11)
- })
- it('请求失败进入失败态,保留重试入口所需数据位', async function () {
- stubs.getDietRecommendation.mockImplementationOnce(function () {
- return Promise.reject({ message: '网络异常' })
- })
- vm.currentView = 'family'
- await vm.loadRecommendation()
- expect(vm.recipeLoading).toBe(false)
- expect(vm.recipeError).toContain('网络异常')
- expect(typeof vm.retryRecommendation).toBe('function')
- })
- })
- // ---------------------------------------------------------------------------
- // 3. 视角切换四处联动(AC-01/02/03/09)
- // ---------------------------------------------------------------------------
- describe('视角切换联动今日食谱', function () {
- var vm
- beforeEach(function () {
- vm = instantiate(loadRecommendation().component)
- })
- it('switchMember 触发 /today 且 view_key=member:<id>,仍保留 visibleCount=20', function () {
- vm.visibleCount = 60
- vm.switchMember({ familyMemberId: 42 })
- expect(vm.currentView).toBe('personal')
- expect(vm.currentMemberId).toBe(42)
- expect(vm.visibleCount).toBe(20)
- var payload = stubs.getDietRecommendation.mock.calls[0][0]
- expect(payload.view_key).toBe('member:42')
- })
- it('switchView("family") 触发 /today 且 view_key=family', function () {
- vm.currentMemberId = 42
- vm.switchView('family')
- expect(vm.currentMemberId).toBeNull()
- var payload = stubs.getDietRecommendation.mock.calls[0][0]
- expect(payload.view_key).toBe('family')
- })
- it('onRefresh 按当前视角重载 /today', function () {
- vm.currentView = 'family'
- vm.onRefresh()
- expect(stubs.getDietRecommendation).toHaveBeenCalledWith(
- expect.objectContaining({ view_key: 'family' })
- )
- })
- it('switchTab("recipe") 按当前视角重载 /today;其它 Tab 不重载', function () {
- vm.currentView = 'family'
- vm.switchTab('recipe')
- expect(stubs.getDietRecommendation).toHaveBeenCalledTimes(1)
- expect(stubs.getDietRecommendation.mock.calls[0][0].view_key).toBe('family')
- stubs.getDietRecommendation.mockClear()
- vm.switchTab('suitable')
- expect(stubs.getDietRecommendation).not.toHaveBeenCalled()
- })
- it('loadMembers 成功后补发一次 /today(onLoad 时序坑)', function () {
- vm.currentView = 'personal'
- vm.currentMemberId = null
- global.uni.setStorageSync('token', 'test-token')
- global.uni.request.mockImplementation(function (opts) {
- opts.success({ data: { code: 200, data: [{ familyMemberId: 8, nickname: '我', isSelf: true }] } })
- })
- vm.loadMembers()
- expect(stubs.getDietRecommendation).toHaveBeenCalledWith(
- expect.objectContaining({ view_key: 'member:8' })
- )
- })
- })
- // ---------------------------------------------------------------------------
- // 4. 生成载荷(AC-05/06/07/08)
- // ---------------------------------------------------------------------------
- describe('生成食谱载荷带 view_key 且不破坏共餐人多选', function () {
- var vm
- beforeEach(function () {
- vm = instantiate(loadRecommendation().component)
- })
- it('个人视角 generateRecipe 带 view_key=member:<id> 与 participant_member_ids=[id]', async function () {
- vm.currentView = 'personal'
- vm.currentMemberId = 42
- await vm.generateRecipe()
- var payload = stubs.generateDietRecommendation.mock.calls[0][0]
- expect(payload.view_key).toBe('member:42')
- expect(JSON.parse(payload.participant_member_ids)).toEqual([42])
- })
- it('家庭总览 confirmParticipants 带 view_key=family 与所选共餐人', async function () {
- vm.currentView = 'family'
- vm.memberList = [{ familyMemberId: 1, nickname: '成员1' }, { familyMemberId: 2, nickname: '成员2' }]
- vm.generateRecipe()
- await Promise.resolve()
- await Promise.resolve()
- // 先弹层,不直接生成
- expect(stubs.generateDietRecommendation).not.toHaveBeenCalled()
- await vm.confirmParticipants()
- var payload = stubs.generateDietRecommendation.mock.calls[0][0]
- expect(payload.view_key).toBe('family')
- expect(JSON.parse(payload.participant_member_ids)).toEqual([1, 2])
- })
- })
- // ---------------------------------------------------------------------------
- // 5. 模板契约(D1① / AC-12/19/33/34/35/38)
- // ---------------------------------------------------------------------------
- describe('模板契约:替换→删除 + 三态', function () {
- var template
- beforeAll(function () {
- globalThis.__recommendationStubs = {}
- template = loadRecommendation().template
- })
- it('模板不再出现「替换」/「好吃」/「跳过」', function () {
- expect(template).not.toContain('替换')
- expect(template).not.toContain('好吃')
- expect(template).not.toContain('跳过')
- })
- it('模板含单一「🗑 删除」且由 dishActions 数据位驱动', function () {
- expect(template).toContain('🗑 删除')
- expect(template).toContain("dishActions === 'delete-only'")
- expect(template).toContain('deleteDish(meal.name, dIndex)')
- })
- it('餐次头部含只读菜数状态、餐次级空态、整份空态、局部 loading、失败态', function () {
- expect(template).toContain('dishStandard(meal)')
- expect(template).toContain('本餐暂无菜品')
- expect(template).toContain('今天的菜品都删完啦')
- expect(template).toContain('正在加载今日食谱')
- expect(template).toContain('今日食谱加载失败')
- })
- })
- // ---------------------------------------------------------------------------
- // 6. 删除行为(AC-13/14/15/16/18/37)
- // ---------------------------------------------------------------------------
- describe('deleteDish 复用 /update 整份覆盖', function () {
- var vm
- beforeEach(function () {
- vm = instantiate(loadRecommendation().component)
- vm.currentView = 'family'
- vm.recommendation = { id: 77, status: 'pending' }
- vm.meals = [lunchWithDishes(), dinnerWithDishes()]
- stubs.updateDietRecommendation.mockClear()
- })
- it('成功:移除该菜 + toast「已删除」+ 提交 {id, menu_json},不影响其它餐次', async function () {
- await vm.deleteDish('午餐', 0)
- expect(stubs.updateDietRecommendation).toHaveBeenCalledTimes(1)
- var payload = stubs.updateDietRecommendation.mock.calls[0][0]
- expect(payload.id).toBe(77)
- var menu = JSON.parse(payload.menu_json)
- expect(menu.meals[0].dishes.length).toBe(4)
- expect(menu.meals[0].dishes[0].type).toBe('dish')
- expect(menu.meals[0].dishes[3].type).toBe('soup')
- expect(menu.meals[1].dishes.length).toBe(5) // 晚餐不受影响
- expect(vm.meals[0].dishes.length).toBe(4) // 列表即时更新
- expect(vm.meals[1].dishes.length).toBe(5)
- expect(global.uni.showToast).toHaveBeenCalledWith(expect.objectContaining({ title: '已删除' }))
- })
- it('menu_json 保留 dish.type 且不混入 icon 展示字段', async function () {
- vm.meals[0].icon = '☀️'
- await vm.deleteDish('午餐', 0)
- var text = stubs.updateDietRecommendation.mock.calls[0][0].menu_json
- expect(text).not.toContain('"icon"')
- expect(text).toContain('"type":"soup"')
- })
- it('失败:原菜品保留、deleting 复位可重试、toast 失败提示', async function () {
- stubs.updateDietRecommendation.mockImplementationOnce(function () {
- return Promise.reject({ message: '网络异常' })
- })
- await vm.deleteDish('午餐', 0)
- expect(vm.meals[0].dishes.length).toBe(5)
- expect(vm.deleting).toBe(false)
- expect(global.uni.showToast).toHaveBeenCalledWith(expect.objectContaining({ icon: 'none' }))
- })
- it('D1 可回退:dishActions 默认 delete-only,replaceDish 与 deleteDish 为独立方法', function () {
- expect(vm.dishActions).toBe('delete-only')
- expect(typeof vm.replaceDish).toBe('function')
- expect(typeof vm.deleteDish).toBe('function')
- })
- it('防重复点击:deleting 期间不重复提交', async function () {
- var release
- stubs.updateDietRecommendation.mockImplementationOnce(function () {
- return new Promise(function (resolve) { release = resolve })
- })
- vm.deleteDish('午餐', 0)
- expect(vm.isDeleting('午餐', 0)).toBe(true)
- vm.deleteDish('午餐', 1)
- expect(stubs.updateDietRecommendation).toHaveBeenCalledTimes(1)
- release({ code: 200, message: 'success', data: null })
- await Promise.resolve()
- await Promise.resolve()
- expect(vm.deleting).toBe(false)
- })
- })
- // ---------------------------------------------------------------------------
- // 7. D2 只读菜数状态(AC-17/25):不静默补菜
- // ---------------------------------------------------------------------------
- describe('dishStandard 只读状态(D2① 删除与补菜解耦)', function () {
- var vm
- beforeEach(function () {
- vm = instantiate(loadRecommendation().component)
- })
- it('4 菜 1 汤(N=3)显示达标', function () {
- expect(vm.dishStandard(lunchWithDishes())).toContain('达标')
- })
- it('删掉 1 道菜后显示「当前 3 菜 1 汤 / 未达标准」', function () {
- var meal = lunchWithDishes()
- meal.dishes.splice(0, 1)
- var text = vm.dishStandard(meal)
- expect(text).toContain('当前 3 菜 1 汤')
- expect(text).toContain('未达标准')
- })
- it('N=4 时 D(N)=5,5 菜 1 汤才达标', function () {
- var meal = lunchWithDishes()
- meal.participants = 4
- expect(vm.dishStandard(meal)).toContain('未达标准')
- meal.dishes.splice(0, 0, { name: '加菜', type: 'dish', calories: 0, ingredients: [], method: '' })
- expect(vm.dishStandard(meal)).toContain('达标')
- })
- it('早餐不显示菜数状态;计算过程不触发任何补菜/重新生成请求', function () {
- var breakfast = { type: 'breakfast', participants: 3, dishes: [{ name: '牛奶', type: 'dish' }] }
- expect(vm.dishStandard(breakfast)).toBe('')
- vm.dishStandard(lunchWithDishes())
- expect(stubs.generateDietRecommendation).not.toHaveBeenCalled()
- expect(stubs.regenerateDietRecommendation).not.toHaveBeenCalled()
- expect(stubs.updateDietRecommendation).not.toHaveBeenCalled()
- })
- })
- // ---------------------------------------------------------------------------
- // 8. parseMenu 透传 dish.type / allMealsEmpty 整份空态(AC-25/35)
- // ---------------------------------------------------------------------------
- describe('parseMenu 与整份空态', function () {
- var vm
- beforeEach(function () {
- vm = instantiate(loadRecommendation().component)
- })
- it('透传 dish.type(soup 可识别,存量无 type 默认 dish)', function () {
- vm.parseMenu('{"meals":[{"type":"lunch","name":"午餐","participants":3,"dishes":[{"name":"番茄汤","type":"soup","calories":50},{"name":"青菜"}]}]}')
- expect(vm.meals[0].dishes[0].type).toBe('soup')
- expect(vm.meals[0].dishes[1].type).toBe('dish')
- })
- it('所有餐次菜数为 0 时 allMealsEmpty=true,可显示整份空态', function () {
- vm.meals = [{ name: '午餐', type: 'lunch', dishes: [] }, { name: '晚餐', type: 'dinner', dishes: [] }]
- expect(vm.allMealsEmpty).toBe(true)
- vm.meals[0].dishes.push({ name: '红烧肉', type: 'dish' })
- expect(vm.allMealsEmpty).toBe(false)
- })
- })
|