|
@@ -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)
|
|
|
|
|
+ })
|
|
|
|
|
+})
|