recommendation.viewKeyAndDelete.test.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. /**
  2. * 契约级单测 —— 能力键 diet.recommendation.viewKeyAndDelete
  3. *
  4. * 溯源:
  5. * PRD docs/需求分析/2026-11-10-食材推荐食谱需求变更.md(38 条 AC,产品规则单一来源)
  6. * 设计 docs/需求分析/2026-11-10-食材推荐食谱需求变更-影响面与任务分解.md
  7. * §1.1(K1~K6)/ §3(接口契约)/ §8.2(AC→断言映射)
  8. *
  9. * 覆盖:buildViewKey 各视角、视角切换四处联动、生成载荷 view_key、模板契约(替换→删除)、
  10. * deleteDish 成功/失败/防重复、D2 只读菜数状态不补菜、空/加载/失败三态、
  11. * parseMenu 透传 dish.type、allMealsEmpty 整份空态。
  12. *
  13. * 约定(沿用 uiRework / participantSelect):加载真实 recommendation.vue 的 <script>/<template>,
  14. * import 替换为注入桩后求值,驱动真实 data()/methods/computed 做行为级断言;不挂载 .vue。
  15. * 本文件不修改任何业务代码。
  16. */
  17. import fs from 'fs'
  18. import path from 'path'
  19. const RECOMMENDATION_PATH = path.join(__dirname, '../../../pages/diet/recommendation.vue')
  20. function stubImports(script, stubMap) {
  21. return script.replace(/import\s+([^;]+?)\s+from\s+['"]([^'"]+)['"];?/g, function (m, clause, id) {
  22. var names = []
  23. var c = clause.trim()
  24. var braceIdx = c.indexOf('{')
  25. if (braceIdx >= 0) {
  26. var def = c.slice(0, braceIdx).replace(/,\s*$/, '').trim()
  27. if (def) names.push(def)
  28. var named = c.slice(braceIdx + 1, c.lastIndexOf('}'))
  29. named.split(',').forEach(function (part) {
  30. var p = part.trim()
  31. if (!p) return
  32. var parts = p.split(/\s+as\s+/)
  33. var local = parts.length > 1 ? parts[1].trim() : parts[0].trim()
  34. if (local) names.push(local)
  35. })
  36. } else if (c.indexOf('*') === 0) {
  37. var nsMatch = c.match(/\*\s+as\s+(\w+)/)
  38. if (nsMatch) names.push(nsMatch[1])
  39. } else {
  40. names.push(c)
  41. }
  42. return names.map(function (n) {
  43. return 'var ' + n + ' = (globalThis.__recommendationStubs[' + JSON.stringify(id) + '] || {})[' + JSON.stringify(n) + '];'
  44. }).join('\n')
  45. })
  46. }
  47. function loadRecommendation() {
  48. var src = fs.readFileSync(RECOMMENDATION_PATH, 'utf8')
  49. var tmplMatch = src.match(/<template>([\s\S]*?)<\/template>/)
  50. var scriptMatch = src.match(/<script>([\s\S]*?)<\/script>/)
  51. if (!scriptMatch) throw new Error('无法在 recommendation.vue 中定位 <script> 块')
  52. var script = stubImports(scriptMatch[1], globalThis.__recommendationStubs || {})
  53. script = script.replace(/export default\s*\{/, 'module.exports = {')
  54. var mod = { exports: {} }
  55. // eslint-disable-next-line no-new-func
  56. var fn = new Function('module', 'exports', script)
  57. fn(mod, mod.exports)
  58. return { component: mod.exports, template: tmplMatch ? tmplMatch[1] : '' }
  59. }
  60. /** 合并 data()/computed(getter)/methods 到 vm 实例(模拟 Vue Options 合并) */
  61. function instantiate(component) {
  62. var vm = {}
  63. var data = typeof component.data === 'function' ? component.data.call(vm) : (component.data || {})
  64. Object.keys(data).forEach(function (k) { vm[k] = data[k] })
  65. if (component.computed) {
  66. Object.keys(component.computed).forEach(function (k) {
  67. Object.defineProperty(vm, k, { get: component.computed[k].bind(vm), enumerable: true, configurable: true })
  68. })
  69. }
  70. var methods = component.methods || {}
  71. Object.keys(methods).forEach(function (k) { vm[k] = methods[k] })
  72. return vm
  73. }
  74. var stubs
  75. beforeEach(function () {
  76. jest.useFakeTimers()
  77. global.uni._storage = {}
  78. global.uni.showToast.mockReset()
  79. global.uni.showLoading.mockReset()
  80. global.uni.hideLoading.mockReset()
  81. global.uni.request.mockReset()
  82. stubs = {
  83. getDietRecommendation: jest.fn(function () {
  84. return Promise.resolve({ code: 200, data: { id: 11, date: '2026-11-10', menu: '{"meals":[]}', nutritionSummary: {}, status: 'pending' } })
  85. }),
  86. generateDietRecommendation: jest.fn(function () { return Promise.resolve({ data: { id: 11 } }) }),
  87. updateDietRecommendation: jest.fn(function () { return Promise.resolve({ code: 200, message: 'success', data: null }) }),
  88. completeDietRecommendation: jest.fn(function () { return Promise.resolve({}) }),
  89. regenerateDietRecommendation: jest.fn(function () { return Promise.resolve({ data: { id: 11 } }) }),
  90. memberIdOf: jest.fn(function (m) { return m && (m.familyMemberId != null ? m.familyMemberId : (m.id != null ? m.id : null)) }),
  91. config: { default: { API_BASE_URL: '/api' } },
  92. MemberSelector: { default: {} },
  93. fontScaleMixin: { default: { data: function () { return { fontScale: 1 } }, onShow: function () {} } }
  94. }
  95. globalThis.__recommendationStubs = {
  96. '@/utils/api.js': {
  97. getDietRecommendation: stubs.getDietRecommendation,
  98. generateDietRecommendation: stubs.generateDietRecommendation,
  99. updateDietRecommendation: stubs.updateDietRecommendation,
  100. completeDietRecommendation: stubs.completeDietRecommendation,
  101. regenerateDietRecommendation: stubs.regenerateDietRecommendation
  102. },
  103. '@/config.js': stubs.config,
  104. '@/components/MemberSelector.vue': stubs.MemberSelector,
  105. '@/utils/memberNormalize.js': { memberIdOf: stubs.memberIdOf },
  106. '@/mixins/font-scale.js': stubs.fontScaleMixin
  107. }
  108. })
  109. afterEach(function () {
  110. jest.clearAllTimers()
  111. jest.useRealTimers()
  112. delete globalThis.__recommendationStubs
  113. })
  114. /** 构造一份 4 菜 1 汤的午餐,便于删除 / 状态断言 */
  115. function lunchWithDishes() {
  116. return {
  117. name: '午餐', type: 'lunch', icon: '☀️', participants: 3,
  118. dishes: [
  119. { name: '红烧肉', type: 'dish', calories: 300, ingredients: [{ name: '肉', amount: 100 }], method: '炖' },
  120. { name: '清炒时蔬', type: 'dish', calories: 90, ingredients: [], method: '炒' },
  121. { name: '蒸鱼', type: 'dish', calories: 180, ingredients: [], method: '蒸' },
  122. { name: '凉拌黄瓜', type: 'dish', calories: 40, ingredients: [], method: '拌' },
  123. { name: '番茄蛋汤', type: 'soup', calories: 60, ingredients: [], method: '煮' }
  124. ]
  125. }
  126. }
  127. function dinnerWithDishes() {
  128. var meal = lunchWithDishes()
  129. meal.name = '晚餐'
  130. meal.type = 'dinner'
  131. meal.icon = '🌙'
  132. return meal
  133. }
  134. // ---------------------------------------------------------------------------
  135. // 1. buildViewKey:K1 视角键(AC-01/03/10)
  136. // ---------------------------------------------------------------------------
  137. describe('buildViewKey 视角键生成(K1,后端视为不透明字符串)', function () {
  138. var vm
  139. beforeEach(function () {
  140. vm = instantiate(loadRecommendation().component)
  141. })
  142. it('家庭总览固定返回 family', function () {
  143. vm.currentView = 'family'
  144. expect(vm.buildViewKey()).toBe('family')
  145. })
  146. it('个人/成员视角返回 member:<id>', function () {
  147. vm.currentView = 'personal'
  148. vm.currentMemberId = 42
  149. expect(vm.buildViewKey()).toBe('member:42')
  150. })
  151. it('loadMembers 未完成(personal 且无 currentMemberId、memberList 空)返回 null', function () {
  152. vm.currentView = 'personal'
  153. vm.currentMemberId = null
  154. vm.memberList = []
  155. expect(vm.buildViewKey()).toBeNull()
  156. })
  157. it('视角未就绪时不发 /today 请求,且清空残留防串数据', function () {
  158. vm.currentView = 'personal'
  159. vm.currentMemberId = null
  160. vm.memberList = []
  161. vm.meals = [lunchWithDishes()]
  162. vm.recommendation = { id: 99 }
  163. vm.loadRecommendation()
  164. expect(stubs.getDietRecommendation).not.toHaveBeenCalled()
  165. expect(vm.meals).toEqual([])
  166. expect(vm.recommendation).toBeNull()
  167. expect(vm.recipeLoading).toBe(false)
  168. })
  169. })
  170. // ---------------------------------------------------------------------------
  171. // 2. loadRecommendation 载荷 + 局部加载态(AC-02/04/09/36)
  172. // ---------------------------------------------------------------------------
  173. describe('loadRecommendation 按视角加载', function () {
  174. var vm
  175. beforeEach(function () {
  176. vm = instantiate(loadRecommendation().component)
  177. })
  178. it('请求携带 {date, view_key}', function () {
  179. vm.currentView = 'family'
  180. vm.loadRecommendation()
  181. expect(stubs.getDietRecommendation).toHaveBeenCalledWith(
  182. expect.objectContaining({ date: expect.any(String), view_key: 'family' })
  183. )
  184. })
  185. it('加载期间显示局部 loading 且清空上一视角的 meals(不串数据)', function () {
  186. vm.currentView = 'family'
  187. vm.currentMemberId = 7
  188. vm.meals = [lunchWithDishes()]
  189. vm.recommendation = { id: 99 }
  190. vm.loadRecommendation()
  191. expect(vm.recipeLoading).toBe(true)
  192. expect(vm.meals).toEqual([])
  193. expect(vm.recommendation).toBeNull()
  194. })
  195. it('请求成功后关闭 loading 并写入该视角食谱', async function () {
  196. vm.currentView = 'family'
  197. await vm.loadRecommendation()
  198. expect(vm.recipeLoading).toBe(false)
  199. expect(vm.recommendation.id).toBe(11)
  200. })
  201. it('请求失败进入失败态,保留重试入口所需数据位', async function () {
  202. stubs.getDietRecommendation.mockImplementationOnce(function () {
  203. return Promise.reject({ message: '网络异常' })
  204. })
  205. vm.currentView = 'family'
  206. await vm.loadRecommendation()
  207. expect(vm.recipeLoading).toBe(false)
  208. expect(vm.recipeError).toContain('网络异常')
  209. expect(typeof vm.retryRecommendation).toBe('function')
  210. })
  211. })
  212. // ---------------------------------------------------------------------------
  213. // 3. 视角切换四处联动(AC-01/02/03/09)
  214. // ---------------------------------------------------------------------------
  215. describe('视角切换联动今日食谱', function () {
  216. var vm
  217. beforeEach(function () {
  218. vm = instantiate(loadRecommendation().component)
  219. })
  220. it('switchMember 触发 /today 且 view_key=member:<id>,仍保留 visibleCount=20', function () {
  221. vm.visibleCount = 60
  222. vm.switchMember({ familyMemberId: 42 })
  223. expect(vm.currentView).toBe('personal')
  224. expect(vm.currentMemberId).toBe(42)
  225. expect(vm.visibleCount).toBe(20)
  226. var payload = stubs.getDietRecommendation.mock.calls[0][0]
  227. expect(payload.view_key).toBe('member:42')
  228. })
  229. it('switchView("family") 触发 /today 且 view_key=family', function () {
  230. vm.currentMemberId = 42
  231. vm.switchView('family')
  232. expect(vm.currentMemberId).toBeNull()
  233. var payload = stubs.getDietRecommendation.mock.calls[0][0]
  234. expect(payload.view_key).toBe('family')
  235. })
  236. it('onRefresh 按当前视角重载 /today', function () {
  237. vm.currentView = 'family'
  238. vm.onRefresh()
  239. expect(stubs.getDietRecommendation).toHaveBeenCalledWith(
  240. expect.objectContaining({ view_key: 'family' })
  241. )
  242. })
  243. it('switchTab("recipe") 按当前视角重载 /today;其它 Tab 不重载', function () {
  244. vm.currentView = 'family'
  245. vm.switchTab('recipe')
  246. expect(stubs.getDietRecommendation).toHaveBeenCalledTimes(1)
  247. expect(stubs.getDietRecommendation.mock.calls[0][0].view_key).toBe('family')
  248. stubs.getDietRecommendation.mockClear()
  249. vm.switchTab('suitable')
  250. expect(stubs.getDietRecommendation).not.toHaveBeenCalled()
  251. })
  252. it('loadMembers 成功后补发一次 /today(onLoad 时序坑)', function () {
  253. vm.currentView = 'personal'
  254. vm.currentMemberId = null
  255. global.uni.setStorageSync('token', 'test-token')
  256. global.uni.request.mockImplementation(function (opts) {
  257. opts.success({ data: { code: 200, data: [{ familyMemberId: 8, nickname: '我', isSelf: true }] } })
  258. })
  259. vm.loadMembers()
  260. expect(stubs.getDietRecommendation).toHaveBeenCalledWith(
  261. expect.objectContaining({ view_key: 'member:8' })
  262. )
  263. })
  264. })
  265. // ---------------------------------------------------------------------------
  266. // 4. 生成载荷(AC-05/06/07/08)
  267. // ---------------------------------------------------------------------------
  268. describe('生成食谱载荷带 view_key 且不破坏共餐人多选', function () {
  269. var vm
  270. beforeEach(function () {
  271. vm = instantiate(loadRecommendation().component)
  272. })
  273. it('个人视角 generateRecipe 带 view_key=member:<id> 与 participant_member_ids=[id]', async function () {
  274. vm.currentView = 'personal'
  275. vm.currentMemberId = 42
  276. await vm.generateRecipe()
  277. var payload = stubs.generateDietRecommendation.mock.calls[0][0]
  278. expect(payload.view_key).toBe('member:42')
  279. expect(JSON.parse(payload.participant_member_ids)).toEqual([42])
  280. })
  281. it('家庭总览 confirmParticipants 带 view_key=family 与所选共餐人', async function () {
  282. vm.currentView = 'family'
  283. vm.memberList = [{ familyMemberId: 1, nickname: '成员1' }, { familyMemberId: 2, nickname: '成员2' }]
  284. vm.generateRecipe()
  285. await Promise.resolve()
  286. await Promise.resolve()
  287. // 先弹层,不直接生成
  288. expect(stubs.generateDietRecommendation).not.toHaveBeenCalled()
  289. await vm.confirmParticipants()
  290. var payload = stubs.generateDietRecommendation.mock.calls[0][0]
  291. expect(payload.view_key).toBe('family')
  292. expect(JSON.parse(payload.participant_member_ids)).toEqual([1, 2])
  293. })
  294. })
  295. // ---------------------------------------------------------------------------
  296. // 5. 模板契约(D1① / AC-12/19/33/34/35/38)
  297. // ---------------------------------------------------------------------------
  298. describe('模板契约:替换→删除 + 三态', function () {
  299. var template
  300. beforeAll(function () {
  301. globalThis.__recommendationStubs = {}
  302. template = loadRecommendation().template
  303. })
  304. it('模板不再出现「替换」/「好吃」/「跳过」', function () {
  305. expect(template).not.toContain('替换')
  306. expect(template).not.toContain('好吃')
  307. expect(template).not.toContain('跳过')
  308. })
  309. it('模板含单一「🗑 删除」且由 dishActions 数据位驱动', function () {
  310. expect(template).toContain('🗑 删除')
  311. expect(template).toContain("dishActions === 'delete-only'")
  312. expect(template).toContain('deleteDish(meal.name, dIndex)')
  313. })
  314. it('餐次头部含只读菜数状态、餐次级空态、整份空态、局部 loading、失败态', function () {
  315. expect(template).toContain('dishStandard(meal)')
  316. expect(template).toContain('本餐暂无菜品')
  317. expect(template).toContain('今天的菜品都删完啦')
  318. expect(template).toContain('正在加载今日食谱')
  319. expect(template).toContain('今日食谱加载失败')
  320. })
  321. })
  322. // ---------------------------------------------------------------------------
  323. // 6. 删除行为(AC-13/14/15/16/18/37)
  324. // ---------------------------------------------------------------------------
  325. describe('deleteDish 复用 /update 整份覆盖', function () {
  326. var vm
  327. beforeEach(function () {
  328. vm = instantiate(loadRecommendation().component)
  329. vm.currentView = 'family'
  330. vm.recommendation = { id: 77, status: 'pending' }
  331. vm.meals = [lunchWithDishes(), dinnerWithDishes()]
  332. stubs.updateDietRecommendation.mockClear()
  333. })
  334. it('成功:移除该菜 + toast「已删除」+ 提交 {id, menu_json},不影响其它餐次', async function () {
  335. await vm.deleteDish('午餐', 0)
  336. expect(stubs.updateDietRecommendation).toHaveBeenCalledTimes(1)
  337. var payload = stubs.updateDietRecommendation.mock.calls[0][0]
  338. expect(payload.id).toBe(77)
  339. var menu = JSON.parse(payload.menu_json)
  340. expect(menu.meals[0].dishes.length).toBe(4)
  341. expect(menu.meals[0].dishes[0].type).toBe('dish')
  342. expect(menu.meals[0].dishes[3].type).toBe('soup')
  343. expect(menu.meals[1].dishes.length).toBe(5) // 晚餐不受影响
  344. expect(vm.meals[0].dishes.length).toBe(4) // 列表即时更新
  345. expect(vm.meals[1].dishes.length).toBe(5)
  346. expect(global.uni.showToast).toHaveBeenCalledWith(expect.objectContaining({ title: '已删除' }))
  347. })
  348. it('menu_json 保留 dish.type 且不混入 icon 展示字段', async function () {
  349. vm.meals[0].icon = '☀️'
  350. await vm.deleteDish('午餐', 0)
  351. var text = stubs.updateDietRecommendation.mock.calls[0][0].menu_json
  352. expect(text).not.toContain('"icon"')
  353. expect(text).toContain('"type":"soup"')
  354. })
  355. it('失败:原菜品保留、deleting 复位可重试、toast 失败提示', async function () {
  356. stubs.updateDietRecommendation.mockImplementationOnce(function () {
  357. return Promise.reject({ message: '网络异常' })
  358. })
  359. await vm.deleteDish('午餐', 0)
  360. expect(vm.meals[0].dishes.length).toBe(5)
  361. expect(vm.deleting).toBe(false)
  362. expect(global.uni.showToast).toHaveBeenCalledWith(expect.objectContaining({ icon: 'none' }))
  363. })
  364. it('D1 可回退:dishActions 默认 delete-only,replaceDish 与 deleteDish 为独立方法', function () {
  365. expect(vm.dishActions).toBe('delete-only')
  366. expect(typeof vm.replaceDish).toBe('function')
  367. expect(typeof vm.deleteDish).toBe('function')
  368. })
  369. it('防重复点击:deleting 期间不重复提交', async function () {
  370. var release
  371. stubs.updateDietRecommendation.mockImplementationOnce(function () {
  372. return new Promise(function (resolve) { release = resolve })
  373. })
  374. vm.deleteDish('午餐', 0)
  375. expect(vm.isDeleting('午餐', 0)).toBe(true)
  376. vm.deleteDish('午餐', 1)
  377. expect(stubs.updateDietRecommendation).toHaveBeenCalledTimes(1)
  378. release({ code: 200, message: 'success', data: null })
  379. await Promise.resolve()
  380. await Promise.resolve()
  381. expect(vm.deleting).toBe(false)
  382. })
  383. })
  384. // ---------------------------------------------------------------------------
  385. // 7. D2 只读菜数状态(AC-17/25):不静默补菜
  386. // ---------------------------------------------------------------------------
  387. describe('dishStandard 只读状态(D2① 删除与补菜解耦)', function () {
  388. var vm
  389. beforeEach(function () {
  390. vm = instantiate(loadRecommendation().component)
  391. })
  392. it('4 菜 1 汤(N=3)显示达标', function () {
  393. expect(vm.dishStandard(lunchWithDishes())).toContain('达标')
  394. })
  395. it('删掉 1 道菜后显示「当前 3 菜 1 汤 / 未达标准」', function () {
  396. var meal = lunchWithDishes()
  397. meal.dishes.splice(0, 1)
  398. var text = vm.dishStandard(meal)
  399. expect(text).toContain('当前 3 菜 1 汤')
  400. expect(text).toContain('未达标准')
  401. })
  402. it('N=4 时 D(N)=5,5 菜 1 汤才达标', function () {
  403. var meal = lunchWithDishes()
  404. meal.participants = 4
  405. expect(vm.dishStandard(meal)).toContain('未达标准')
  406. meal.dishes.splice(0, 0, { name: '加菜', type: 'dish', calories: 0, ingredients: [], method: '' })
  407. expect(vm.dishStandard(meal)).toContain('达标')
  408. })
  409. it('早餐不显示菜数状态;计算过程不触发任何补菜/重新生成请求', function () {
  410. var breakfast = { type: 'breakfast', participants: 3, dishes: [{ name: '牛奶', type: 'dish' }] }
  411. expect(vm.dishStandard(breakfast)).toBe('')
  412. vm.dishStandard(lunchWithDishes())
  413. expect(stubs.generateDietRecommendation).not.toHaveBeenCalled()
  414. expect(stubs.regenerateDietRecommendation).not.toHaveBeenCalled()
  415. expect(stubs.updateDietRecommendation).not.toHaveBeenCalled()
  416. })
  417. })
  418. // ---------------------------------------------------------------------------
  419. // 8. parseMenu 透传 dish.type / allMealsEmpty 整份空态(AC-25/35)
  420. // ---------------------------------------------------------------------------
  421. describe('parseMenu 与整份空态', function () {
  422. var vm
  423. beforeEach(function () {
  424. vm = instantiate(loadRecommendation().component)
  425. })
  426. it('透传 dish.type(soup 可识别,存量无 type 默认 dish)', function () {
  427. vm.parseMenu('{"meals":[{"type":"lunch","name":"午餐","participants":3,"dishes":[{"name":"番茄汤","type":"soup","calories":50},{"name":"青菜"}]}]}')
  428. expect(vm.meals[0].dishes[0].type).toBe('soup')
  429. expect(vm.meals[0].dishes[1].type).toBe('dish')
  430. })
  431. it('所有餐次菜数为 0 时 allMealsEmpty=true,可显示整份空态', function () {
  432. vm.meals = [{ name: '午餐', type: 'lunch', dishes: [] }, { name: '晚餐', type: 'dinner', dishes: [] }]
  433. expect(vm.allMealsEmpty).toBe(true)
  434. vm.meals[0].dishes.push({ name: '红烧肉', type: 'dish' })
  435. expect(vm.allMealsEmpty).toBe(false)
  436. })
  437. })