| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336 |
- /**
- * 契约级单测(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)
- })
- })
|