taskHistory.view.test.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. /**
  2. * 契约级单测(TDD-RED/意外全绿)——能力键 task.history.view
  3. *
  4. * 溯源:
  5. * requirement: tests/user-scenarios/应用场景总览.md SC-P-94(家长·成就「完成任务」→ 历史完成任务页面)
  6. * + docs/需求分析/2026-11-06-成就成长打卡需求变更.md 变更 A(完成任务 → 历史完成任务页面)
  7. * design: TaskController.getTaskHistory(POST /api/tasks/history,已合入 origin/cfclub)
  8. * + cfc-frontend/utils/api.js `getTaskHistory`(支持 memberId/page/size/category 分页)
  9. * + cfc-frontend/pages/tasks/history.vue(已合入 origin/cfclub,标题「历史完成任务」)
  10. * + cfc-frontend/pages.json 注册「pages/tasks/history」
  11. * + cfc-frontend/pages/profile-main/profile.vue「完成任务」卡 → goToTaskHistory
  12. *
  13. * 契约口径(key = unit.domain.action,「view」含页面装配,须按真实源码断言):
  14. * 1) api 契约:api.js 导出 getTaskHistory(memberId, page, size, category),
  15. * 以 POST 请求 /api/tasks/history,body = { memberId, page, size[, category] },
  16. * 默认 page=1、size=10;category 缺省/空串时不携带。
  17. * 2) view 装配:pages/tasks/history.vue 存在、在 pages.json 注册(标题「历史完成任务」)、
  18. * 导入 getTaskHistory 并按 (memberId, page, pageSize, category||null) 分页调用、
  19. * 含空态「暂无已完成任务」;loadHistory 行为级校验分页参数与响应装载。
  20. * 3) 入口装配:profile.vue「完成任务」成就卡 goToTaskHistory → uni.navigateTo('/pages/tasks/history')。
  21. *
  22. * 本文件只读真实源码(utils/api.js / pages/tasks/history.vue / pages.json / profile.vue),
  23. * 不改任何业务代码。若上述契约已由 origin/cfclub 实现满足,本文件为「意外全绿」。
  24. */
  25. import fs from 'fs'
  26. import path from 'path'
  27. import * as api from '@/utils/api'
  28. const HISTORY_PAGE_PATH = path.join(__dirname, '../../../pages/tasks/history.vue')
  29. const PAGES_JSON_PATH = path.join(__dirname, '../../../pages.json')
  30. const PROFILE_PATH = path.join(__dirname, '../../../pages/profile-main/profile.vue')
  31. // ---------------------------------------------------------------------------
  32. // API 契约工具(沿用 api.test.js 的 uni.request 打桩方式)
  33. // ---------------------------------------------------------------------------
  34. /** Helper: make uni.request call success(data) on next tick */
  35. function mockSuccess(data) {
  36. global.uni.request.mockImplementation((opts) => {
  37. process.nextTick(() => {
  38. if (opts.success) opts.success({ data })
  39. })
  40. })
  41. }
  42. /** Capture the last uni.request call options */
  43. function lastRequest() {
  44. const calls = global.uni.request.mock.calls
  45. return calls[calls.length - 1][0]
  46. }
  47. // ---------------------------------------------------------------------------
  48. // SFC 加载工具(沿用 join-form.required-info.test.js 的「抽取 <script> 求值」约定)
  49. // ---------------------------------------------------------------------------
  50. /** 把 ES import 语句替换为从 globalThis.__historyStubs 取桩的声明(不解析模块,避免污染业务实现) */
  51. function stubImports(script, stubMap) {
  52. return script.replace(/import\s+([^;]+?)\s+from\s+['"]([^'"]+)['"];?/g, function (m, clause, id) {
  53. var names = []
  54. var c = clause.trim()
  55. var braceIdx = c.indexOf('{')
  56. if (braceIdx >= 0) {
  57. var def = c.slice(0, braceIdx).replace(/,\s*$/, '').trim()
  58. if (def) names.push(def)
  59. var named = c.slice(braceIdx + 1, c.lastIndexOf('}'))
  60. named.split(',').forEach(function (part) {
  61. var p = part.trim()
  62. if (!p) return
  63. var parts = p.split(/\s+as\s+/)
  64. var local = parts.length > 1 ? parts[1].trim() : parts[0].trim()
  65. if (local) names.push(local)
  66. })
  67. } else if (c.indexOf('*') === 0) {
  68. var nsMatch = c.match(/\*\s+as\s+(\w+)/)
  69. if (nsMatch) names.push(nsMatch[1])
  70. } else {
  71. names.push(c)
  72. }
  73. return names.map(function (n) {
  74. return 'var ' + n + ' = (globalThis.__historyStubs[' + JSON.stringify(id) + '] || {})[' + JSON.stringify(n) + '];'
  75. }).join('\n')
  76. })
  77. }
  78. /** 加载真实 history.vue:返回 { component, template } */
  79. function loadHistoryPage() {
  80. var src = fs.readFileSync(HISTORY_PAGE_PATH, 'utf8')
  81. var tmplMatch = src.match(/<template>([\s\S]*?)<\/template>/)
  82. var scriptMatch = src.match(/<script>([\s\S]*?)<\/script>/)
  83. if (!scriptMatch) throw new Error('无法在 history.vue 中定位 <script> 块')
  84. var script = stubImports(scriptMatch[1], globalThis.__historyStubs || {})
  85. script = script.replace(/export default\s*\{/, 'module.exports = {')
  86. var mod = { exports: {} }
  87. // eslint-disable-next-line no-new-func
  88. var fn = new Function('module', 'exports', script)
  89. fn(mod, mod.exports)
  90. return { component: mod.exports, template: tmplMatch ? tmplMatch[1] : '' }
  91. }
  92. /** 合并 data()/methods 到 vm 实例(模拟 Vue Options 合并) */
  93. function instantiate(component) {
  94. var vm = {}
  95. var data = typeof component.data === 'function' ? component.data.call(vm) : (component.data || {})
  96. Object.keys(data).forEach(function (k) { vm[k] = data[k] })
  97. var methods = component.methods || {}
  98. Object.keys(methods).forEach(function (k) { vm[k] = methods[k] })
  99. return vm
  100. }
  101. /** 让已 resolve 的 Promise 链(.then 微任务)跑完 */
  102. function flush() {
  103. return new Promise(function (resolve) { setImmediate(resolve) })
  104. }
  105. // ---------------------------------------------------------------------------
  106. // 测试夹具
  107. // ---------------------------------------------------------------------------
  108. beforeEach(function () {
  109. global.uni._storage = {}
  110. global.uni.request.mockReset()
  111. global.uni.showToast.mockReset()
  112. global.uni.reLaunch.mockReset()
  113. global.uni.navigateTo.mockReset()
  114. global.uni.getAccountInfoSync.mockReset()
  115. global.uni.getAccountInfoSync.mockReturnValue({ miniProgram: { envVersion: 'develop' } })
  116. // api.js 的 3 秒请求去重缓存是模块级单例,跨用例残留会让后续用例拿到上一次缓存的 Promise
  117. api.clearApiDedup()
  118. globalThis.__historyStubs = {
  119. '@/components/MemberSelector.vue': { MemberSelector: { name: 'MemberSelector' } },
  120. '../../utils/api.js': {
  121. getFamilyMemberList: jest.fn(function () { return Promise.resolve({ code: 200, data: [] }) }),
  122. getTaskHistory: jest.fn(function () { return Promise.resolve({ code: 200, data: { records: [] } }) })
  123. },
  124. '../../utils/format.js': { formatDateTime: jest.fn(function () { return '' }) },
  125. '@/mixins/font-scale.js': { fontScaleMixin: { data: function () { return { fontScale: 1 } }, onShow: function () {} } }
  126. }
  127. })
  128. afterEach(function () {
  129. delete globalThis.__historyStubs
  130. })
  131. // ---------------------------------------------------------------------------
  132. // 1) API 契约:getTaskHistory(memberId, page, size, category) → POST /api/tasks/history
  133. // ---------------------------------------------------------------------------
  134. describe('api 契约:getTaskHistory → POST /api/tasks/history', function () {
  135. test('导出 getTaskHistory 为函数', function () {
  136. expect(typeof api.getTaskHistory).toBe('function')
  137. })
  138. test('以 POST 请求 /api/tasks/history 并携带 memberId/page/size', async function () {
  139. mockSuccess({ code: 200 })
  140. await api.getTaskHistory(5, 1, 20)
  141. var opts = lastRequest()
  142. expect(opts.method).toBe('POST')
  143. expect(opts.url).toMatch(/\/api\/tasks\/history$/)
  144. expect(opts.data.memberId).toBe(5)
  145. expect(opts.data.page).toBe(1)
  146. expect(opts.data.size).toBe(20)
  147. })
  148. test('缺省 page=1、size=10(分页契约默认值)', async function () {
  149. mockSuccess({ code: 200 })
  150. await api.getTaskHistory(5)
  151. var opts = lastRequest()
  152. expect(opts.data.memberId).toBe(5)
  153. expect(opts.data.page).toBe(1)
  154. expect(opts.data.size).toBe(10)
  155. })
  156. test('传入 category 时透传给 body', async function () {
  157. mockSuccess({ code: 200 })
  158. await api.getTaskHistory(5, 1, 20, '学习类')
  159. var opts = lastRequest()
  160. expect(opts.data.memberId).toBe(5)
  161. expect(opts.data.page).toBe(1)
  162. expect(opts.data.size).toBe(20)
  163. expect(opts.data.category).toBe('学习类')
  164. })
  165. test('category 为空串时不携带(契约:仅传非空筛选)', async function () {
  166. mockSuccess({ code: 200 })
  167. await api.getTaskHistory(5, 1, 20, '')
  168. var opts = lastRequest()
  169. expect(opts.data.category).toBeUndefined()
  170. })
  171. })
  172. // ---------------------------------------------------------------------------
  173. // 2) view 装配(静态结构):history.vue 存在、注册、导入与分页调用、空态
  174. // ---------------------------------------------------------------------------
  175. describe('view 装配(静态):pages/tasks/history.vue', function () {
  176. var src
  177. beforeAll(function () {
  178. src = fs.readFileSync(HISTORY_PAGE_PATH, 'utf8')
  179. })
  180. test('history.vue 存在且包含 <template> 与 <script> 块', function () {
  181. expect(fs.existsSync(HISTORY_PAGE_PATH)).toBe(true)
  182. expect(src).toMatch(/<template>/)
  183. expect(src).toMatch(/<script>/)
  184. })
  185. test('导入 getTaskHistory 与 getFamilyMemberList(数据源装配)', function () {
  186. expect(src).toMatch(/import\s*\{[^}]*getTaskHistory[^}]*\}\s*from\s*['"][^'"]*utils\/api\.js['"]/)
  187. expect(src).toMatch(/import\s*\{[^}]*getFamilyMemberList[^}]*\}\s*from\s*['"][^'"]*utils\/api\.js['"]/)
  188. })
  189. test('导入 MemberSelector 组件(成员选择装配)', function () {
  190. expect(src).toMatch(/import\s+MemberSelector\s+from\s*['"][^'"]*MemberSelector\.vue['"]/)
  191. })
  192. test('分页调用 getTaskHistory(memberId, page, pageSize, category||null)', function () {
  193. // loadHistory 内以「4 参」调用,且第 4 参为 currentCategory || null(契约:无筛选传 null)
  194. var call = src.match(/getTaskHistory\(\s*memberId\s*,\s*this\.page\s*,\s*this\.pageSize\s*,\s*this\.currentCategory\s*\|\|\s*null\s*\)/)
  195. expect(call).not.toBeNull()
  196. })
  197. test('模板含空态「暂无已完成任务」与加载更多', function () {
  198. var tmpl = (src.match(/<template>([\s\S]*?)<\/template>/) || [])[1] || ''
  199. expect(tmpl).toContain('暂无已完成任务')
  200. expect(tmpl).toContain('点击加载更多')
  201. })
  202. })
  203. describe('view 装配(静态):pages.json 注册', function () {
  204. var pagesJson
  205. beforeAll(function () {
  206. pagesJson = JSON.parse(fs.readFileSync(PAGES_JSON_PATH, 'utf8'))
  207. })
  208. test('pages/tasks 分包注册 history 页面且标题为「历史完成任务」', function () {
  209. var sub = (pagesJson.subPackages || []).find(function (p) { return p.root === 'pages/tasks' })
  210. expect(sub).toBeTruthy()
  211. var page = (sub.pages || []).find(function (p) { return p.path === 'history' })
  212. expect(page).toBeTruthy()
  213. expect(page.style.navigationBarTitleText).toBe('历史完成任务')
  214. })
  215. })
  216. // ---------------------------------------------------------------------------
  217. // 3) 入口装配(静态):profile.vue「完成任务」卡 → goToTaskHistory → /pages/tasks/history
  218. // ---------------------------------------------------------------------------
  219. describe('入口装配(静态):profile.vue「完成任务」卡', function () {
  220. var src
  221. beforeAll(function () {
  222. src = fs.readFileSync(PROFILE_PATH, 'utf8')
  223. })
  224. test('成就卡「完成任务」绑定 goToTaskHistory', function () {
  225. expect(src).toMatch(/@click="goToTaskHistory"/)
  226. // 「完成任务」文案须与 goToTaskHistory 处于同一 ach-item 卡块内:
  227. // 绑定出现后紧邻的「完成任务」label(同一卡内,间隔仅 ach-icon-wrap/ach-num)
  228. var clickIdx = src.indexOf('@click="goToTaskHistory"')
  229. var labelIdx = src.indexOf('完成任务', clickIdx)
  230. expect(labelIdx).toBeGreaterThan(clickIdx)
  231. expect(labelIdx - clickIdx).toBeLessThan(400)
  232. })
  233. test('goToTaskHistory 跳转 /pages/tasks/history', function () {
  234. expect(src).toMatch(/goToTaskHistory\s*:\s*function\s*\([^)]*\)\s*\{[^}]*navigateTo\(\{\s*url:\s*['"]\/pages\/tasks\/history['"]\s*\}/)
  235. })
  236. })
  237. // ---------------------------------------------------------------------------
  238. // 4) view 装配(行为级):loadHistory 以分页参数调用 getTaskHistory 并装载响应
  239. // ---------------------------------------------------------------------------
  240. describe('view 装配(行为级):history.vue loadHistory', function () {
  241. test('loadHistory(true) 以 (memberId, page, pageSize, category||null) 调用 getTaskHistory', async function () {
  242. var getTaskHistorySpy = jest.fn(function () {
  243. return Promise.resolve({ code: 200, data: { records: [{ id: 1, title: '任务A', category: '学习类' }] } })
  244. })
  245. globalThis.__historyStubs['../../utils/api.js'].getTaskHistory = getTaskHistorySpy
  246. var page = loadHistoryPage()
  247. var vm = instantiate(page.component)
  248. vm.currentMemberId = 5
  249. vm.page = 1
  250. vm.pageSize = 20
  251. vm.currentCategory = '学习类'
  252. vm.loadHistory(true)
  253. await flush()
  254. expect(getTaskHistorySpy).toHaveBeenCalledWith(5, 1, 20, '学习类')
  255. })
  256. test('无筛选时第 4 参为 null(契约:不携带 category)', async function () {
  257. var getTaskHistorySpy = jest.fn(function () {
  258. return Promise.resolve({ code: 200, data: { records: [] } })
  259. })
  260. globalThis.__historyStubs['../../utils/api.js'].getTaskHistory = getTaskHistorySpy
  261. var page = loadHistoryPage()
  262. var vm = instantiate(page.component)
  263. vm.currentMemberId = 5
  264. vm.currentCategory = ''
  265. vm.loadHistory(true)
  266. await flush()
  267. expect(getTaskHistorySpy).toHaveBeenCalledWith(5, 1, 20, null)
  268. })
  269. test('响应 records 装载进 taskList,hasMore 由 records 长度与 pageSize 决定', async function () {
  270. var records = []
  271. for (var i = 0; i < 20; i++) records.push({ id: i + 1, title: '任务' + (i + 1) })
  272. globalThis.__historyStubs['../../utils/api.js'].getTaskHistory = jest.fn(function () {
  273. return Promise.resolve({ code: 200, data: { records: records } })
  274. })
  275. var page = loadHistoryPage()
  276. var vm = instantiate(page.component)
  277. vm.currentMemberId = 5
  278. vm.loadHistory(true)
  279. await flush()
  280. expect(vm.taskList.length).toBe(20)
  281. // 20 条 == pageSize → hasMore 为 true 且 page 递增到 2
  282. expect(vm.hasMore).toBe(true)
  283. expect(vm.page).toBe(2)
  284. expect(vm.loading).toBe(false)
  285. })
  286. test('无 memberId 时不发请求且 hasMore 置 false(空态前提)', async function () {
  287. var getTaskHistorySpy = jest.fn()
  288. globalThis.__historyStubs['../../utils/api.js'].getTaskHistory = getTaskHistorySpy
  289. var page = loadHistoryPage()
  290. var vm = instantiate(page.component)
  291. vm.currentMemberId = null
  292. global.uni._storage.userId = ''
  293. vm.loadHistory(true)
  294. await flush()
  295. expect(getTaskHistorySpy).not.toHaveBeenCalled()
  296. expect(vm.hasMore).toBe(false)
  297. expect(vm.loading).toBe(false)
  298. })
  299. })