Просмотр исходного кода

test(cfc-frontend): task.history.view 契约级单测 S-cfc-29

Sisyphus Agent 2 дней назад
Родитель
Сommit
e45bca1c25
1 измененных файлов с 336 добавлено и 0 удалено
  1. 336 0
      cfc-frontend/__tests__/pages/tasks/taskHistory.view.test.js

+ 336 - 0
cfc-frontend/__tests__/pages/tasks/taskHistory.view.test.js

@@ -0,0 +1,336 @@
+/**
+ * 契约级单测(TDD-RED/意外全绿)——能力键 task.history.view
+ *
+ * 溯源:
+ *   requirement: tests/user-scenarios/应用场景总览.md SC-P-94(家长·成就「完成任务」→ 历史完成任务页面)
+ *                + docs/需求分析/2026-11-06-成就成长打卡需求变更.md 变更 A(完成任务 → 历史完成任务页面)
+ *   design:      TaskController.getTaskHistory(POST /api/tasks/history,已合入 origin/cfclub)
+ *                + cfc-frontend/utils/api.js `getTaskHistory`(支持 memberId/page/size/category 分页)
+ *                + cfc-frontend/pages/tasks/history.vue(已合入 origin/cfclub,标题「历史完成任务」)
+ *                + cfc-frontend/pages.json 注册「pages/tasks/history」
+ *                + cfc-frontend/pages/profile-main/profile.vue「完成任务」卡 → goToTaskHistory
+ *
+ * 契约口径(key = unit.domain.action,「view」含页面装配,须按真实源码断言):
+ *   1) api 契约:api.js 导出 getTaskHistory(memberId, page, size, category),
+ *      以 POST 请求 /api/tasks/history,body = { memberId, page, size[, category] },
+ *      默认 page=1、size=10;category 缺省/空串时不携带。
+ *   2) view 装配:pages/tasks/history.vue 存在、在 pages.json 注册(标题「历史完成任务」)、
+ *      导入 getTaskHistory 并按 (memberId, page, pageSize, category||null) 分页调用、
+ *      含空态「暂无已完成任务」;loadHistory 行为级校验分页参数与响应装载。
+ *   3) 入口装配:profile.vue「完成任务」成就卡 goToTaskHistory → uni.navigateTo('/pages/tasks/history')。
+ *
+ * 本文件只读真实源码(utils/api.js / pages/tasks/history.vue / pages.json / profile.vue),
+ * 不改任何业务代码。若上述契约已由 origin/cfclub 实现满足,本文件为「意外全绿」。
+ */
+
+import fs from 'fs'
+import path from 'path'
+import * as api from '@/utils/api'
+
+const HISTORY_PAGE_PATH = path.join(__dirname, '../../../pages/tasks/history.vue')
+const PAGES_JSON_PATH = path.join(__dirname, '../../../pages.json')
+const PROFILE_PATH = path.join(__dirname, '../../../pages/profile-main/profile.vue')
+
+// ---------------------------------------------------------------------------
+// API 契约工具(沿用 api.test.js 的 uni.request 打桩方式)
+// ---------------------------------------------------------------------------
+
+/** Helper: make uni.request call success(data) on next tick */
+function mockSuccess(data) {
+  global.uni.request.mockImplementation((opts) => {
+    process.nextTick(() => {
+      if (opts.success) opts.success({ data })
+    })
+  })
+}
+
+/** Capture the last uni.request call options */
+function lastRequest() {
+  const calls = global.uni.request.mock.calls
+  return calls[calls.length - 1][0]
+}
+
+// ---------------------------------------------------------------------------
+// SFC 加载工具(沿用 join-form.required-info.test.js 的「抽取 <script> 求值」约定)
+// ---------------------------------------------------------------------------
+
+/** 把 ES import 语句替换为从 globalThis.__historyStubs 取桩的声明(不解析模块,避免污染业务实现) */
+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.__historyStubs[' + JSON.stringify(id) + '] || {})[' + JSON.stringify(n) + '];'
+    }).join('\n')
+  })
+}
+
+/** 加载真实 history.vue:返回 { component, template } */
+function loadHistoryPage() {
+  var src = fs.readFileSync(HISTORY_PAGE_PATH, 'utf8')
+  var tmplMatch = src.match(/<template>([\s\S]*?)<\/template>/)
+  var scriptMatch = src.match(/<script>([\s\S]*?)<\/script>/)
+  if (!scriptMatch) throw new Error('无法在 history.vue 中定位 <script> 块')
+  var script = stubImports(scriptMatch[1], globalThis.__historyStubs || {})
+  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 到 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] })
+  var methods = component.methods || {}
+  Object.keys(methods).forEach(function (k) { vm[k] = methods[k] })
+  return vm
+}
+
+/** 让已 resolve 的 Promise 链(.then 微任务)跑完 */
+function flush() {
+  return new Promise(function (resolve) { setImmediate(resolve) })
+}
+
+// ---------------------------------------------------------------------------
+// 测试夹具
+// ---------------------------------------------------------------------------
+
+beforeEach(function () {
+  global.uni._storage = {}
+  global.uni.request.mockReset()
+  global.uni.showToast.mockReset()
+  global.uni.reLaunch.mockReset()
+  global.uni.navigateTo.mockReset()
+  global.uni.getAccountInfoSync.mockReset()
+  global.uni.getAccountInfoSync.mockReturnValue({ miniProgram: { envVersion: 'develop' } })
+  // api.js 的 3 秒请求去重缓存是模块级单例,跨用例残留会让后续用例拿到上一次缓存的 Promise
+  api.clearApiDedup()
+  globalThis.__historyStubs = {
+    '@/components/MemberSelector.vue': { MemberSelector: { name: 'MemberSelector' } },
+    '../../utils/api.js': {
+      getFamilyMemberList: jest.fn(function () { return Promise.resolve({ code: 200, data: [] }) }),
+      getTaskHistory: jest.fn(function () { return Promise.resolve({ code: 200, data: { records: [] } }) })
+    },
+    '../../utils/format.js': { formatDateTime: jest.fn(function () { return '' }) },
+    '@/mixins/font-scale.js': { fontScaleMixin: { data: function () { return { fontScale: 1 } }, onShow: function () {} } }
+  }
+})
+
+afterEach(function () {
+  delete globalThis.__historyStubs
+})
+
+// ---------------------------------------------------------------------------
+// 1) API 契约:getTaskHistory(memberId, page, size, category) → POST /api/tasks/history
+// ---------------------------------------------------------------------------
+
+describe('api 契约:getTaskHistory → POST /api/tasks/history', function () {
+  test('导出 getTaskHistory 为函数', function () {
+    expect(typeof api.getTaskHistory).toBe('function')
+  })
+
+  test('以 POST 请求 /api/tasks/history 并携带 memberId/page/size', async function () {
+    mockSuccess({ code: 200 })
+    await api.getTaskHistory(5, 1, 20)
+    var opts = lastRequest()
+    expect(opts.method).toBe('POST')
+    expect(opts.url).toMatch(/\/api\/tasks\/history$/)
+    expect(opts.data.memberId).toBe(5)
+    expect(opts.data.page).toBe(1)
+    expect(opts.data.size).toBe(20)
+  })
+
+  test('缺省 page=1、size=10(分页契约默认值)', async function () {
+    mockSuccess({ code: 200 })
+    await api.getTaskHistory(5)
+    var opts = lastRequest()
+    expect(opts.data.memberId).toBe(5)
+    expect(opts.data.page).toBe(1)
+    expect(opts.data.size).toBe(10)
+  })
+
+  test('传入 category 时透传给 body', async function () {
+    mockSuccess({ code: 200 })
+    await api.getTaskHistory(5, 1, 20, '学习类')
+    var opts = lastRequest()
+    expect(opts.data.memberId).toBe(5)
+    expect(opts.data.page).toBe(1)
+    expect(opts.data.size).toBe(20)
+    expect(opts.data.category).toBe('学习类')
+  })
+
+  test('category 为空串时不携带(契约:仅传非空筛选)', async function () {
+    mockSuccess({ code: 200 })
+    await api.getTaskHistory(5, 1, 20, '')
+    var opts = lastRequest()
+    expect(opts.data.category).toBeUndefined()
+  })
+})
+
+// ---------------------------------------------------------------------------
+// 2) view 装配(静态结构):history.vue 存在、注册、导入与分页调用、空态
+// ---------------------------------------------------------------------------
+
+describe('view 装配(静态):pages/tasks/history.vue', function () {
+  var src
+  beforeAll(function () {
+    src = fs.readFileSync(HISTORY_PAGE_PATH, 'utf8')
+  })
+
+  test('history.vue 存在且包含 <template> 与 <script> 块', function () {
+    expect(fs.existsSync(HISTORY_PAGE_PATH)).toBe(true)
+    expect(src).toMatch(/<template>/)
+    expect(src).toMatch(/<script>/)
+  })
+
+  test('导入 getTaskHistory 与 getFamilyMemberList(数据源装配)', function () {
+    expect(src).toMatch(/import\s*\{[^}]*getTaskHistory[^}]*\}\s*from\s*['"][^'"]*utils\/api\.js['"]/)
+    expect(src).toMatch(/import\s*\{[^}]*getFamilyMemberList[^}]*\}\s*from\s*['"][^'"]*utils\/api\.js['"]/)
+  })
+
+  test('导入 MemberSelector 组件(成员选择装配)', function () {
+    expect(src).toMatch(/import\s+MemberSelector\s+from\s*['"][^'"]*MemberSelector\.vue['"]/)
+  })
+
+  test('分页调用 getTaskHistory(memberId, page, pageSize, category||null)', function () {
+    // loadHistory 内以「4 参」调用,且第 4 参为 currentCategory || null(契约:无筛选传 null)
+    var call = src.match(/getTaskHistory\(\s*memberId\s*,\s*this\.page\s*,\s*this\.pageSize\s*,\s*this\.currentCategory\s*\|\|\s*null\s*\)/)
+    expect(call).not.toBeNull()
+  })
+
+  test('模板含空态「暂无已完成任务」与加载更多', function () {
+    var tmpl = (src.match(/<template>([\s\S]*?)<\/template>/) || [])[1] || ''
+    expect(tmpl).toContain('暂无已完成任务')
+    expect(tmpl).toContain('点击加载更多')
+  })
+})
+
+describe('view 装配(静态):pages.json 注册', function () {
+  var pagesJson
+  beforeAll(function () {
+    pagesJson = JSON.parse(fs.readFileSync(PAGES_JSON_PATH, 'utf8'))
+  })
+
+  test('pages/tasks 分包注册 history 页面且标题为「历史完成任务」', function () {
+    var sub = (pagesJson.subPackages || []).find(function (p) { return p.root === 'pages/tasks' })
+    expect(sub).toBeTruthy()
+    var page = (sub.pages || []).find(function (p) { return p.path === 'history' })
+    expect(page).toBeTruthy()
+    expect(page.style.navigationBarTitleText).toBe('历史完成任务')
+  })
+})
+
+// ---------------------------------------------------------------------------
+// 3) 入口装配(静态):profile.vue「完成任务」卡 → goToTaskHistory → /pages/tasks/history
+// ---------------------------------------------------------------------------
+
+describe('入口装配(静态):profile.vue「完成任务」卡', function () {
+  var src
+  beforeAll(function () {
+    src = fs.readFileSync(PROFILE_PATH, 'utf8')
+  })
+
+  test('成就卡「完成任务」绑定 goToTaskHistory', function () {
+    expect(src).toMatch(/@click="goToTaskHistory"/)
+    // 「完成任务」文案须与 goToTaskHistory 处于同一 ach-item 卡块内:
+    // 绑定出现后紧邻的「完成任务」label(同一卡内,间隔仅 ach-icon-wrap/ach-num)
+    var clickIdx = src.indexOf('@click="goToTaskHistory"')
+    var labelIdx = src.indexOf('完成任务', clickIdx)
+    expect(labelIdx).toBeGreaterThan(clickIdx)
+    expect(labelIdx - clickIdx).toBeLessThan(400)
+  })
+
+  test('goToTaskHistory 跳转 /pages/tasks/history', function () {
+    expect(src).toMatch(/goToTaskHistory\s*:\s*function\s*\([^)]*\)\s*\{[^}]*navigateTo\(\{\s*url:\s*['"]\/pages\/tasks\/history['"]\s*\}/)
+  })
+})
+
+// ---------------------------------------------------------------------------
+// 4) view 装配(行为级):loadHistory 以分页参数调用 getTaskHistory 并装载响应
+// ---------------------------------------------------------------------------
+
+describe('view 装配(行为级):history.vue loadHistory', function () {
+  test('loadHistory(true) 以 (memberId, page, pageSize, category||null) 调用 getTaskHistory', async function () {
+    var getTaskHistorySpy = jest.fn(function () {
+      return Promise.resolve({ code: 200, data: { records: [{ id: 1, title: '任务A', category: '学习类' }] } })
+    })
+    globalThis.__historyStubs['../../utils/api.js'].getTaskHistory = getTaskHistorySpy
+    var page = loadHistoryPage()
+    var vm = instantiate(page.component)
+    vm.currentMemberId = 5
+    vm.page = 1
+    vm.pageSize = 20
+    vm.currentCategory = '学习类'
+    vm.loadHistory(true)
+    await flush()
+    expect(getTaskHistorySpy).toHaveBeenCalledWith(5, 1, 20, '学习类')
+  })
+
+  test('无筛选时第 4 参为 null(契约:不携带 category)', async function () {
+    var getTaskHistorySpy = jest.fn(function () {
+      return Promise.resolve({ code: 200, data: { records: [] } })
+    })
+    globalThis.__historyStubs['../../utils/api.js'].getTaskHistory = getTaskHistorySpy
+    var page = loadHistoryPage()
+    var vm = instantiate(page.component)
+    vm.currentMemberId = 5
+    vm.currentCategory = ''
+    vm.loadHistory(true)
+    await flush()
+    expect(getTaskHistorySpy).toHaveBeenCalledWith(5, 1, 20, null)
+  })
+
+  test('响应 records 装载进 taskList,hasMore 由 records 长度与 pageSize 决定', async function () {
+    var records = []
+    for (var i = 0; i < 20; i++) records.push({ id: i + 1, title: '任务' + (i + 1) })
+    globalThis.__historyStubs['../../utils/api.js'].getTaskHistory = jest.fn(function () {
+      return Promise.resolve({ code: 200, data: { records: records } })
+    })
+    var page = loadHistoryPage()
+    var vm = instantiate(page.component)
+    vm.currentMemberId = 5
+    vm.loadHistory(true)
+    await flush()
+    expect(vm.taskList.length).toBe(20)
+    // 20 条 == pageSize → hasMore 为 true 且 page 递增到 2
+    expect(vm.hasMore).toBe(true)
+    expect(vm.page).toBe(2)
+    expect(vm.loading).toBe(false)
+  })
+
+  test('无 memberId 时不发请求且 hasMore 置 false(空态前提)', async function () {
+    var getTaskHistorySpy = jest.fn()
+    globalThis.__historyStubs['../../utils/api.js'].getTaskHistory = getTaskHistorySpy
+    var page = loadHistoryPage()
+    var vm = instantiate(page.component)
+    vm.currentMemberId = null
+    global.uni._storage.userId = ''
+    vm.loadHistory(true)
+    await flush()
+    expect(getTaskHistorySpy).not.toHaveBeenCalled()
+    expect(vm.hasMore).toBe(false)
+    expect(vm.loading).toBe(false)
+  })
+})