Bläddra i källkod

test(cfc-frontend): 合入 S-cfc-29/30/32/50 RED 契约测试

Sisyphus Agent 2 dagar sedan
förälder
incheckning
26380e1008

+ 314 - 0
cfc-frontend/__tests__/pages/growth-main/growthHistory.view.test.js

@@ -0,0 +1,314 @@
+/**
+ * 契约级单测(TDD-RED)——能力键 growth.history.view(SC-P-95 / 变更B)
+ *
+ * 溯源:
+ *   requirement: tests/user-scenarios/应用场景总览.md SC-P-95
+ *                docs/需求分析/2026-11-06-成就成长打卡需求变更.md §变更B(成长记录→成长历史记录页)
+ *   design:      cfc-frontend/pages/growth-main/index.vue「今日成长」网格
+ *                cfc-frontend/utils/api.js getGrowthRecordList(~L1409,POST /api/growth/record/list)
+ *                healthCheckinList(~L2253,POST /api/health/checkin/list)
+ *
+ * 说明(既有 Jest 体系约束):
+ *   cfc-frontend 的 jest.config.js 仅 transform `.js`(babel-jest),moduleFileExtensions 不含 `vue`,
+ *   且未安装 vue-jest/jsdom 组件环境 —— 既有约定为「抽取被测逻辑断言」而非挂载 .vue
+ *   (见 __tests__/components/FamilyRelationGraph.test.js,导入 shallowMount 但实际只测抽取函数;
+ *    本文件沿用 reference 风格 __tests__/family-join/join-form.required-info.test.js —— 直接加载真实
+ *    SFC 的 <script>/<template> 源码做结构/行为契约断言;纯 fs 读取,无渲染引擎)。
+ *   - <script>:把 import 语句替换为注入桩后求值,得到真实组件的 data()/methods,做行为级断言;
+ *   - <template>:读取模板文本,做结构契约断言(今日成长网格容器、历史按日期分组渲染)。
+ *
+ * 红/绿判定:
+ *   - 页面存在、今日成长网格、两个 API 封装(URL/POST 方法)——现状已合入 origin/cfclub → 绿(无回归锚点);
+ *   - 「成长历史记录按日期分组渲染」(变更B 验收口径:看到所有打卡记录,按日期倒序/分组)——
+ *     现状 growth-main/index.vue 仅渲染「今日成长」progressItems 网格,无任何按日期分组的
+ *     历史记录结构 → 红(契约缺口,即 RED 核心)。GREEN 实现方需在 growth-main 页面补足
+ *     下列命名契约(或对齐本文件断言):模板 v-for 数据源为 dateGroups/groupedRecords/
+ *     groupedCheckins/historyGroups/checkinGroups/recordGroups/dayGroups 之一,或 methods
+ *     (/computed)提供 groupByDate/groupRecordsByDate/groupCheckinsByDate/groupHistoryByDate。
+ *
+ * 本文件不修改 pages/、utils/ 任何业务代码。
+ */
+
+import fs from 'fs'
+import path from 'path'
+import { getGrowthRecordList as apiGetGrowthRecordList, healthCheckinList as apiHealthCheckinList, clearApiDedup } from '@/utils/api'
+
+const GROWTH_MAIN_PATH = path.join(__dirname, '../../../pages/growth-main/index.vue')
+
+// ---------------------------------------------------------------------------
+// 工具:加载真实 growth-main/index.vue 的 <script> 逻辑(行为级)与 <template> 结构(结构级)
+// ---------------------------------------------------------------------------
+
+/** 把 ES import 语句替换为从 globalThis.__growthHistoryStubs 取桩的声明(不解析模块,避免污染业务实现) */
+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.__growthHistoryStubs[' + JSON.stringify(id) + '] || {})[' + JSON.stringify(n) + '];'
+    }).join('\n')
+  })
+}
+
+/** 加载真实 SFC:返回 { component, template }(component = 真实 <script> 的 export default 对象) */
+function loadGrowthMain() {
+  var src = fs.readFileSync(GROWTH_MAIN_PATH, 'utf8')
+  var tmplMatch = src.match(/<template>([\s\S]*?)<\/template>/)
+  var scriptMatch = src.match(/<script>([\s\S]*?)<\/script>/)
+  if (!scriptMatch) throw new Error('无法在 growth-main/index.vue 中定位 <script> 块')
+  var script = stubImports(scriptMatch[1], globalThis.__growthHistoryStubs || {})
+  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 构造可驱动上下文(模拟 Vue 把 data 与 methods 合并到实例) */
+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] })
+  // 顶层生命周期钩子(onShow/onLoad 等)不在 methods 内,直接取自组件根对象
+  Object.keys(component).forEach(function (k) {
+    if (typeof component[k] === 'function' && vm[k] === undefined) vm[k] = component[k]
+  })
+  return vm
+}
+
+// ---------------------------------------------------------------------------
+// 测试夹具
+// ---------------------------------------------------------------------------
+
+var stubs
+
+beforeEach(function () {
+  global.uni._storage = {}
+  global.uni.showToast.mockReset()
+  global.uni.reLaunch.mockReset()
+  global.uni.request.mockReset()
+  // growth-main 的 <script> 会声明 9 个 API 依赖:只 stub 组件运行时真正调用的,
+  // 其余以空实现兜底,避免 eval 阶段缺名(var 未定义即可,方法体才使用)
+  stubs = {
+    healthCheckinList: jest.fn(function () { return Promise.resolve({ code: 200, message: 'ok', data: [] }) }),
+    getHealthMealList: jest.fn(function () { return Promise.resolve({ code: 200, message: 'ok', data: [] }) }),
+    getGrowthRecommendations: jest.fn(function () { return Promise.resolve({ code: 200, message: 'ok', data: { articles: [] } }) }),
+    getExerciseRecords: jest.fn(function () { return Promise.resolve({ code: 200, message: 'ok', data: [] }) }),
+    getSleepRecords: jest.fn(function () { return Promise.resolve({ code: 200, message: 'ok', data: [] }) }),
+    getEmotionCheckinList: jest.fn(function () { return Promise.resolve({ code: 200, message: 'ok', data: [] }) }),
+    getCheckinList: jest.fn(function () { return Promise.resolve({ code: 200, message: 'ok', data: [] }) }),
+    getTodayTasks: jest.fn(function () { return Promise.resolve({ code: 200, message: 'ok', data: [] }) }),
+    reportAppEnter: jest.fn(),
+    getGrowthRecordList: jest.fn(function () { return Promise.resolve({ code: 200, message: 'ok', data: [] }) }),
+    parseDate: jest.fn(function (v) { return v ? new Date(String(v).replace(/-/g, '/')) : null }),
+    recordDateOf: jest.fn(function () { return '' }),
+    fontScaleMixin: { data: function () { return { fontScale: 1 } }, onShow: function () {} },
+    shareMixin: {}
+  }
+  globalThis.__growthHistoryStubs = {
+    // 注意:growth-main/index.vue 的 import 使用相对路径(../../utils/api.js 等),桩键须与真实 import id 一致
+    '../../utils/api.js': {
+      healthCheckinList: stubs.healthCheckinList,
+      getHealthMealList: stubs.getHealthMealList,
+      getGrowthRecommendations: stubs.getGrowthRecommendations,
+      getExerciseRecords: stubs.getExerciseRecords,
+      getSleepRecords: stubs.getSleepRecords,
+      getEmotionCheckinList: stubs.getEmotionCheckinList,
+      getCheckinList: stubs.getCheckinList,
+      getTodayTasks: stubs.getTodayTasks,
+      reportAppEnter: stubs.reportAppEnter
+    },
+    '../../utils/format.js': { parseDate: stubs.parseDate, recordDateOf: stubs.recordDateOf },
+    '@/mixins/font-scale.js': { fontScaleMixin: stubs.fontScaleMixin },
+    '../../components/share-mixin.js': { shareMixin: stubs.shareMixin },
+    '../../components/LoginGuideCard.vue': {},
+    '../../components/DimensionActivities.vue': {},
+    '../../components/DimensionProducts.vue': {},
+    '../../components/ArticleFeed.vue': {}
+  }
+  jest.useFakeTimers()
+})
+
+afterEach(function () {
+  jest.useRealTimers()
+  delete globalThis.__growthHistoryStubs
+})
+
+// ---------------------------------------------------------------------------
+// AC_CONTRACT_1 | 页面存在 + 今日成长网格结构契约(现状锚点,预期绿)
+// ---------------------------------------------------------------------------
+
+describe('AC_CONTRACT_1 页面存在与今日成长网格结构(现状锚点)', function () {
+  var template
+  beforeAll(function () {
+    // beforeAll 先于 beforeEach 执行:仅做模板结构断言,无需桩函数,置空即可(loadGrowthMain 对桩做可选访问)
+    globalThis.__growthHistoryStubs = {}
+    template = loadGrowthMain().template
+  })
+
+  it('被测页面 growth-main/index.vue 存在且可解析(含 template/script 块)', function () {
+    expect(fs.existsSync(GROWTH_MAIN_PATH)).toBe(true)
+    expect(template.length).toBeGreaterThan(0)
+  })
+
+  it('今日成长区域容器存在(progress-card / progress-title)', function () {
+    expect(template).toMatch(/progress-card/)
+    expect(template).toMatch(/今日成长/)
+  })
+
+  it('今日成长网格容器存在(progress-grid),按 progressItems 渲染', function () {
+    expect(template).toMatch(/class="progress-grid"/)
+    expect(template).toMatch(/progressItems\.length > 0/)
+  })
+
+  it('网格渲染项存在:progress-tile v-for 循环 + :key + 点击处理器', function () {
+    expect(template).toMatch(/class="progress-tile"[^>]*v-for="\(item, idx\) in progressItems"/)
+    expect(template).toMatch(/:key="getProgressKey\(item\)"/)
+    expect(template).toMatch(/handleProgressClick\(idx\)/)
+  })
+
+  it('今日成长六项打卡(饮食/运动/睡眠/心情/记账 + 更多任务)为结构的一部分(无回归锚点)', function () {
+    expect(template).toMatch(/饮食/)
+    expect(template).toMatch(/运动/)
+    expect(template).toMatch(/睡眠/)
+    expect(template).toMatch(/心情/)
+    expect(template).toMatch(/记账/)
+    expect(template).toMatch(/更多任务/)
+  })
+})
+
+// ---------------------------------------------------------------------------
+// AC_CONTRACT_2 | 成长历史按日期分组渲染(变更B 验收口径:看到所有打卡记录、按日期分组)
+//                现状 growth-main/index.vue 仅渲染「今日成长」,无按日期分组结构 → 预期红(RED 核心)
+// ---------------------------------------------------------------------------
+
+describe('AC_CONTRACT_2 成长历史记录按日期分组渲染(变更B → 预期红)', function () {
+  var src
+  beforeAll(function () {
+    // 直接扫描原始 SFC 全文(template + script 原始文本):methods/computed 不能被序列化丢弃,
+    // 日期分组装配逻辑须在原始 <script> 源码中可见
+    src = fs.readFileSync(GROWTH_MAIN_PATH, 'utf8')
+  })
+
+  it('RED#1 模板存在「按日期分组」的记录循环渲染(如 v-for 于 dateGroups/groupedRecords/historyGroups 等)', function () {
+    // GREEN 实现契约:历史记录须以「日期分组集合」为 v-for 数据源渲染(dateGroups | groupedRecords |
+    // groupedCheckins | historyGroups | checkinGroups | recordGroups | dayGroups | growthHistoryGroups)
+    expect(src).toMatch(/v-for\s*=\s*["'][^"']*(?:dateGroups|dateGroupList|groupedRecords|groupedCheckins|historyGroups|historyDays|checkinGroups|recordGroups|dayGroups|growthHistoryGroups)[^"']*["']/i)
+  })
+
+  it('RED#2 组件提供按日期分组的装配逻辑(methods/computed 存在 groupByDate 系方法)', function () {
+    // GREEN 实现契约:组件需提供把打卡/档案记录流按日期分组的装配入口
+    // (groupByDate | groupRecordsByDate | groupCheckinsByDate | groupHistoryByDate | groupedByDate)
+    expect(src).toMatch(/(?:groupByDate|groupRecordsByDate|groupCheckinsByDate|groupHistoryByDate|groupedByDate)\s*(?:\(|:)/i)
+  })
+})
+
+// ---------------------------------------------------------------------------
+// AC_CONTRACT_3 | 行为级:真实组件 data()/methods —— 今日成长数据装配(现状锚点,预期绿)
+// ---------------------------------------------------------------------------
+
+describe('AC_CONTRACT_3 真实组件行为:今日成长数据装配(现状锚点)', function () {
+  var vm
+
+  beforeEach(function () {
+    var component = loadGrowthMain().component
+    vm = instantiate(component)
+  })
+
+  it('data 提供 progressItems 数组(今日成长网格数据源)', function () {
+    expect(Array.isArray(vm.progressItems)).toBe(true)
+    expect(vm.todayDate).toBe('')
+  })
+
+  it('onShow 设置 todayDate 为今日 yyyy-MM-dd 并调用 loadAllData(已登录态)', async function () {
+    global.uni.setStorageSync('token', 'T-1')
+    global.uni.setStorageSync('currentChildId', '88')
+    await vm.onShow()
+    expect(vm.todayDate).toMatch(/^\d{4}-\d{2}-\d{2}$/)
+    var now = new Date()
+    var pad = function (n) { return n < 10 ? '0' + n : '' + n }
+    expect(vm.todayDate).toBe(now.getFullYear() + '-' + pad(now.getMonth() + 1) + '-' + pad(now.getDate()))
+    expect(vm.isLoggedIn).toBe(true)
+    expect(stubs.reportAppEnter).toHaveBeenCalledWith('growth')
+  })
+
+  it('loadProgressItems 装配六项打卡(diet/exercise/sleep/emotion/checkin + 更多任务入口)', async function () {
+    await vm.loadProgressItems()
+    expect(stubs.getTodayTasks).toHaveBeenCalled()
+    expect(Array.isArray(vm.progressItems)).toBe(true)
+    var keys = vm.progressItems.map(function (it) { return it.key })
+    expect(keys).toEqual(expect.arrayContaining(['diet', 'exercise', 'sleep', 'emotion', 'checkin']))
+    expect(vm.progressItems.length).toBeGreaterThanOrEqual(5)
+  })
+
+  it('loadMilestones 通过 healthCheckinList 拉取连续打卡天数(限 30 天封顶的既有锚点)', async function () {
+    stubs.healthCheckinList.mockResolvedValueOnce({ code: 200, message: 'ok', data: [1, 2, 3] })
+    await vm.loadMilestones()
+    expect(stubs.healthCheckinList).toHaveBeenCalled()
+    expect(vm.streakDays).toBe(3)
+    expect(vm.completeness).toBe(30)
+  })
+})
+
+// ---------------------------------------------------------------------------
+// AC_CONTRACT_4 | 设计锚点:utils/api.js 封装契约(现状已合入,预期绿)
+//   getGrowthRecordList → POST /api/growth/record/list
+//   healthCheckinList   → POST /api/health/checkin/list
+// ---------------------------------------------------------------------------
+
+describe('AC_CONTRACT_4 设计锚点:api.js 封装 URL/方法契约', function () {
+  it('getGrowthRecordList 发起 POST /api/growth/record/list', async function () {
+    var captured = null
+    global.uni.request.mockImplementation(function (opts) {
+      captured = opts
+      process.nextTick(function () { opts.success({ data: { code: 200, message: 'ok', data: [] } }) })
+    })
+    clearApiDedup()
+    await apiGetGrowthRecordList()
+    expect(captured.url).toContain('/api/growth/record/list')
+    expect(captured.method).toBe('POST')
+  })
+
+  it('healthCheckinList 发起 POST /api/health/checkin/list 并透传请求体', async function () {
+    var captured = null
+    global.uni.request.mockImplementation(function (opts) {
+      captured = opts
+      process.nextTick(function () { opts.success({ data: { code: 200, message: 'ok', data: [] } }) })
+    })
+    clearApiDedup()
+    await apiHealthCheckinList({ memberId: 88, page: 1, size: 20 })
+    expect(captured.url).toContain('/api/health/checkin/list')
+    expect(captured.method).toBe('POST')
+    expect(captured.data).toEqual(expect.objectContaining({ memberId: 88, page: 1, size: 20 }))
+  })
+})
+
+// ---------------------------------------------------------------------------
+// 权限流:本能力为「家长查看孩子成长/打卡历史」,页面在已登录态按 currentChildId 取数,
+// 无额外 role 门禁分支(role 校验在后端 JwtInterceptor 层),显式记录为不适用项。
+// ---------------------------------------------------------------------------
+
+describe.skip('权限流:成长历史查看无前端 role 门禁分支,不适用', function () {
+  it('占位:该能力面向登录家长/教师查看成长记录,详见汇报', function () {})
+})

+ 114 - 0
cfc-frontend/__tests__/pages/profile-main/achievement.fiveCol.test.js

@@ -0,0 +1,114 @@
+/**
+ * 契约级单测(TDD-RED)——能力键 achievement.grid.fiveCol
+ *
+ * 溯源:
+ *   requirement: tests/user-scenarios/应用场景总览.md SC-A-27(成就与成长历史·家长端)
+ *                + docs/需求分析/2026-11-06-成就成长打卡需求变更.md 变更 D(我的成就固定五列)
+ *   design:      cfc-frontend/pages/profile-main/profile.vue「我的成就」区块
+ *                模板 L151-187(.achievement-grid.cols-5 + 5 个 .ach-item)+
+ *                样式 原 .ach-item{width:25%} 改五列等分 .achievement-grid.cols-5 .ach-item{width:20%}
+ *
+ * 缺陷本质(本用例守护的规则):
+ *   「我的成就」区块固定 5 张成就卡(连续打卡/完成任务/收集勋章/成长记录/参加挑战)。
+ *   原 .ach-item{width:25%}(4 列等分)→ 第 5 张卡折行错位。
+ *   变更 D 要求该区块独占 5 列等分(width:20%),单行排布不折行;
+ *   且必须用独立网格类(.cols-5)隔离,不得全局改 .achievement-grid(其被「我的团队」.team-grid / 「卡券中心」复用)。
+ *
+ * 断言口径(静态结构契约,读真实 SFC 源码,不依赖渲染引擎):
+ *   1) 模板「我的成就」区块根容器使用 .achievement-grid.cols-5
+ *   2) 样式存在 .achievement-grid.cols-5 .ach-item 规则,且 width 为 20%(五列等分)
+ *   3) 该区块含 ≥5 个成就入口,且跳转方法为 goToStreak/goToTaskHistory/goToBadges/goToGrowth/goToChallenges
+ *   4) 隔离性:全局 .achievement-grid 未被改成 20%(保护「我的团队」3 列布局不被波及)
+ *
+ * 说明(既有 Jest 体系约束):
+ *   cfc-frontend jest.config.js 仅 transform .js,未装 vue-jest/jsdom 组件环境——
+ *   沿用既有约定:读取真实 SFC 源码做结构契约断言(参考 __tests__/family-join/join-form.required-info.test.js、
+ *   __tests__/pages/growth-main/growthGrid.layout.test.js)。本文件不修改 pages/ 任何业务代码。
+ */
+
+import fs from 'fs'
+import path from 'path'
+
+const PAGE_PATH = path.join(__dirname, '../../../pages/profile-main/profile.vue')
+const SRC = fs.readFileSync(PAGE_PATH, 'utf8')
+
+/** 截取某个 CSS 选择器的声明块({} 内文本),返回 null 表示不存在 */
+function cssBlock(src, selector) {
+  const idx = src.indexOf('\n' + selector + ' {')
+  const altIdx = src.indexOf(selector + ' {')
+  const start = idx >= 0 ? idx : altIdx
+  if (start < 0) return null
+  const open = src.indexOf('{', start)
+  const close = src.indexOf('}', open)
+  if (open < 0 || close < 0) return null
+  return src.slice(open + 1, close)
+}
+
+/** 截取模板中「我的成就」section 块(class="achievement-section" 且其内含「我的成就」) */
+function achievementSectionTemplate(src) {
+  const tmpl = src.match(/<template>([\s\S]*?)<\/template>/)
+  if (!tmpl) return ''
+  const t = tmpl[1]
+  // 成就区块以 class="achievement-section" 起始,逐段切分找到含「我的成就」的那段
+  const marker = 'achievement-section'
+  let from = 0
+  while (true) {
+    const secStart = t.indexOf(marker, from)
+    if (secStart < 0) return ''
+    const secEnd = t.indexOf(marker, secStart + 1)
+    const chunk = t.slice(secStart, secEnd > 0 ? secEnd : t.length)
+    if (chunk.indexOf('我的成就') >= 0) return chunk
+    from = secStart + 1
+  }
+}
+
+// ---------------------------------------------------------------------------
+// 变更 D · 我的成就固定五列
+// ---------------------------------------------------------------------------
+
+describe('achievement.grid.fiveCol · 我的成就固定五列(变更 D / SC-A-27)', () => {
+  test('TC-A5-01 模板「我的成就」区块使用 .achievement-grid.cols-5 根容器', () => {
+    const section = achievementSectionTemplate(SRC)
+    expect(section).not.toBe('')
+    expect(section).toMatch(/achievement-grid\s+cols-5/)
+  })
+
+  test('TC-A5-02 .achievement-grid.cols-5 .ach-item 宽度为 20%(五列等分)', () => {
+    const block = cssBlock(SRC, '.achievement-grid.cols-5 .ach-item')
+    expect(block).not.toBeNull()
+    const m = /width:\s*([\d.]+)%/.exec(block)
+    expect(m).not.toBeNull()
+    expect(Number(m[1])).toBe(20)
+  })
+
+  test('TC-A5-03 成就区块含 ≥5 个 .ach-item 成就入口,且不折行错位(5 张卡单行)', () => {
+    const section = achievementSectionTemplate(SRC)
+    const items = section.match(/class="ach-item"/g) || []
+    expect(items.length).toBeGreaterThanOrEqual(5)
+  })
+
+  test('TC-A5-04 5 个成就入口跳转方法齐备(goToStreak/TaskHistory/Badges/Growth/Challenges)', () => {
+    const section = achievementSectionTemplate(SRC)
+    const required = ['goToStreak', 'goToTaskHistory', 'goToBadges', 'goToGrowth', 'goToChallenges']
+    required.forEach((fn) => {
+      // 每个成就卡的 @click 绑定到对应方法
+      expect(section).toMatch(new RegExp('@click="' + fn + '"'))
+    })
+  })
+
+  test('TC-A5-05 隔离性:全局 .achievement-grid 未被改成 20%(保护「我的团队」布局)', () => {
+    // 全局 .achievement-grid 仅声明 flex 布局,不含 width:20%;团队网格用 33.33%
+    const globalGrid = cssBlock(SRC, '.achievement-grid')
+    expect(globalGrid).not.toBeNull()
+    expect(globalGrid).not.toMatch(/width:\s*20%/)
+  })
+})
+
+// ---------------------------------------------------------------------------
+// 权限流:本能力为家长端「我的成就」展示布局,无角色分支门禁,
+// 属《编排派发》中「权限(适用时)」的不适用项,显式记录。
+// ---------------------------------------------------------------------------
+
+describe.skip('权限流:成就五列布局无角色门禁,不适用', () => {
+  it('占位:该能力面向家长端个人中心展示,无 role 分支(详见汇报)', () => {})
+})

+ 242 - 0
cfc-frontend/__tests__/pages/signature/signature.openPage.test.js

@@ -0,0 +1,242 @@
+/**
+ * 契约级单测(TDD-RED)——能力键 family.signature.openPage
+ *
+ * 溯源:
+ *   requirement: docs/superpowers/specs/2026-11-07-signature-page-landscape.md
+ *                §3 AC-R1(独立页跳转 navigateTo 打开签名页)、§5 WI-1(新建独立横屏签名页)
+ *                关联 AC5.1(pages.json 路由注册正确)
+ *   design:      cfc-frontend/pages/signature/signature.vue(签名页)
+ *                + 入口页面 health-plan-summary.vue / consignee-edit.vue / checkout.vue
+ *                  (goSignature 经 uni.navigateTo 打开 pages/signature/signature)
+ *
+ * 说明(既有 Jest 体系约束):
+ *   cfc-frontend 的 jest.config.js moduleFileExtensions 不含 `vue`,testEnvironment 为 node,
+ *   既有约定为「抽取被测逻辑断言」而非挂载 .vue(见 __tests__/pages/signature/signature.canvas2d.test.js)。
+ *   本文件沿用该约定:直接加载真实 SFC 的 <script>/<template> 源码做结构契约断言,
+ *   并对 onConfirm/onLoad/onUnload 做行为级断言(真实 export default 求值 + 方法驱动)。
+ *   若实现方后续改动签名页契约,本文件断言即暴露;本文件不修改 pages/、utils/ 任何业务代码。
+ */
+
+const fs = require('fs')
+const path = require('path')
+
+const PAGE = path.join(__dirname, '../../../pages/signature/signature.vue')
+const PAGES_JSON = path.join(__dirname, '../../../pages.json')
+
+const ENTRY_FILES = {
+  healthPlanSummary: path.join(__dirname, '../../../pages/health/health-plan-summary.vue'),
+  consigneeEdit: path.join(__dirname, '../../../pages/shop/consignee-edit/consignee-edit.vue'),
+  checkout: path.join(__dirname, '../../../pages/shop/checkout/checkout.vue')
+}
+
+// ---------------------------------------------------------------------------
+// 工具:加载真实 signature.vue 的 <script>(行为级)与 <template>(结构级)
+// ---------------------------------------------------------------------------
+
+/** import 语句 -> 同名空桩变量(保留脚本内引用,避免 ReferenceError) */
+function stubImports(code) {
+  return code.replace(/import\s+([\s\S]*?)\s+from\s+['"][^'"]+['"];?/g, function (_all, clause) {
+    const ids = String(clause)
+      .replace(/[{}]/g, ' ')
+      .split(',')
+      .map(function (x) {
+        const parts = x.trim().split(/\s+as\s+/)
+        return parts[parts.length - 1].trim()
+      })
+      .filter(function (x) {
+        return /^[A-Za-z_$][\w$]*$/.test(x)
+      })
+    return ids
+      .map(function (id) {
+        return 'var ' + id + ' = function () {};'
+      })
+      .join(' ')
+  })
+}
+
+/** 求值 <script> 得到真实 export default 对象(可注入 uni/wx/window/模块桩) */
+function loadOptions(sandbox) {
+  let code = stubImports(readScript(PAGE)).replace(/export\s+default/, 'var __opts__ =')
+  code = code + '\nreturn __opts__'
+  const names = Object.keys(sandbox)
+  // eslint-disable-next-line no-new-func
+  const fn = new Function(names.join(','), code)
+  return fn.apply(
+    null,
+    names.map(function (n) {
+      return sandbox[n]
+    })
+  )
+}
+
+function readScript(file) {
+  const src = fs.readFileSync(file, 'utf8')
+  const m = src.match(/<script>([\s\S]*?)<\/script>/)
+  if (!m) throw new Error('no <script> in ' + file)
+  return m[1]
+}
+
+function readTemplate(file) {
+  const src = fs.readFileSync(file, 'utf8')
+  const m = src.match(/<template>([\s\S]*?)<\/template>/)
+  if (!m) return ''
+  return m[1]
+}
+
+function readAll(file) {
+  return fs.readFileSync(file, 'utf8')
+}
+
+/** 构造可驱动页面实例:合并 data() 与 methods,注入 $refs,并暴露顶层生命周期 opts */
+function makePage(sandbox) {
+  const base = {
+    uni: {
+      getWindowInfo: function () { return { windowHeight: 390, windowWidth: 844 } },
+      navigateBack: function () {},
+      showToast: function () {}
+    },
+    wx: undefined,
+    window: undefined
+  }
+  const merged = Object.assign({}, base, sandbox || {})
+  if (sandbox && sandbox.uni) merged.uni = Object.assign({}, base.uni, sandbox.uni)
+  const opts = loadOptions(merged)
+  const data = typeof opts.data === 'function' ? opts.data() : opts.data
+  const vm = Object.assign({}, data, opts.methods)
+  vm.$refs = {}
+  // 暴露顶层生命周期(onLoad/onUnload 不在 methods 内)
+  vm.__opts__ = opts
+  return vm
+}
+
+// ---------------------------------------------------------------------------
+// AC1.1 / AC5.1 页面存在 + pages.json 路由注册
+// ---------------------------------------------------------------------------
+
+describe('AC1.1/AC5.1 签名页存在并注册为可打开页面', function () {
+  test('signature.vue 存在且为可打开页面(有 template + script + export default 组件)', function () {
+    expect(fs.existsSync(PAGE)).toBe(true)
+    const tpl = readTemplate(PAGE)
+    const script = readScript(PAGE)
+    // 可打开页面:模板有根节点 + 脚本 export default 组件定义
+    expect(tpl).toMatch(/<view[^>]*class="signature-page"/)
+    expect(script).toMatch(/export\s+default\s*\{/)
+    // 复用 HandSignature 组件
+    expect(script).toMatch(/components\s*:\s*\{\s*HandSignature\s*\}/)
+  })
+
+  test('pages.json 注册 pages/signature/signature 路由(无重复 path)', function () {
+    const json = readAll(PAGES_JSON)
+    // 分包 root + path + 真全屏 navigationStyle
+    expect(json).toMatch(/"root"\s*:\s*"pages\/signature"/)
+    expect(json).toMatch(/"path"\s*:\s*"signature"/)
+    expect(json).toMatch(/"navigationBarTitleText"\s*:\s*"签名"/)
+    expect(json).toMatch(/"navigationStyle"\s*:\s*"custom"/)
+    // 路径唯一(无重复注册)
+    const pathMatches = json.match(/"path"\s*:\s*"signature"/g) || []
+    expect(pathMatches.length).toBe(1)
+  })
+})
+
+// ---------------------------------------------------------------------------
+// AC-R1 存在入口通过 uni.navigateTo 打开 pages/signature/signature
+// ---------------------------------------------------------------------------
+
+describe('AC-R1 入口页面经 uni.navigateTo 打开签名页', function () {
+  const ENTRY_PATH_RE = /['"]\/pages\/signature\/signature\?requireAgree=1&agreeText=['"]/
+  const NAVIGATE_RE = /uni\.navigateTo\s*\(\s*\{[\s\S]{0,200}url\s*:\s*['"]\/pages\/signature\/signature\?requireAgree=1/
+
+  test('health-plan-summary.vue(问卷完成签名)经 navigateTo 打开签名页', function () {
+    const src = readAll(ENTRY_FILES.healthPlanSummary)
+    expect(src).toMatch(NAVIGATE_RE)
+    expect(src).toMatch(ENTRY_PATH_RE)
+  })
+
+  test('consignee-edit.vue(水机购买收货人签名)经 navigateTo 打开签名页', function () {
+    const src = readAll(ENTRY_FILES.consigneeEdit)
+    expect(src).toMatch(NAVIGATE_RE)
+    expect(src).toMatch(ENTRY_PATH_RE)
+  })
+
+  test('checkout.vue(订单确认签署)经 navigateTo 打开签名页', function () {
+    const src = readAll(ENTRY_FILES.checkout)
+    expect(src).toMatch(NAVIGATE_RE)
+    expect(src).toMatch(ENTRY_PATH_RE)
+  })
+
+  test('入口跳转前经 signature-bus 写入初始值(setSignaturePrefill)', function () {
+    const src = readAll(ENTRY_FILES.healthPlanSummary)
+    expect(src).toMatch(/setSignaturePrefill\s*\(/)
+  })
+})
+
+// ---------------------------------------------------------------------------
+// AC1.4 行为级:点「确认」经 eventChannel 回传 base64 + navigateBack;「返回」不回传直接退出
+// ---------------------------------------------------------------------------
+
+describe('AC1.4 签名页回传/退出行为', function () {
+  test('onConfirm 经 getOpenerEventChannel().emit(signatureConfirm, dataUrl) 回传后 doBack', function () {
+    const emitted = []
+    const navigated = []
+    const vm = makePage({
+      uni: { navigateBack: function () { navigated.push('navigateBack') } }
+    })
+    vm.getOpenerEventChannel = function () {
+      return { emit: function (name, payload) { emitted.push([name, payload]) } }
+    }
+    const dataUrl = 'data:image/png;base64,QUJD'
+    vm.onConfirm(dataUrl)
+    expect(emitted.length).toBe(1)
+    expect(emitted[0][0]).toBe('signatureConfirm')
+    expect(emitted[0][1]).toBe(dataUrl)
+    expect(navigated).toEqual(['navigateBack'])
+  })
+
+  test('onBack 不回传直接 navigateBack(恢复竖屏)', function () {
+    const emitted = []
+    const seq = []
+    const vm = makePage({
+      wx: { setPageOrientation: function (cfg) { seq.push('orient:' + cfg.orientation) } },
+      uni: { navigateBack: function () { seq.push('navigateBack') } }
+    })
+    vm.getOpenerEventChannel = function () {
+      return { emit: function (name, payload) { emitted.push([name, payload]) } }
+    }
+    vm.onBack()
+    expect(emitted.length).toBe(0)
+    expect(seq).toEqual(['orient:portrait', 'navigateBack'])
+  })
+})
+
+// ---------------------------------------------------------------------------
+// AC1.5 行为级:onLoad 读取 requireAgree/agreeText 与 prefill 回显
+// ---------------------------------------------------------------------------
+
+describe('AC1.5 onLoad 入参与 prefill 回显', function () {
+  test('onLoad 从 signature-bus 读取 prefill;requireAgree=1 置真、agreeText 解码', function () {
+    const script = readScript(PAGE)
+    // 结构契约:onLoad 经 signature-bus 读取初始值(读后清空,避免残留串扰)
+    expect(script).toMatch(/consumeSignaturePrefill\s*\(\)/)
+    expect(script).toMatch(/this\.prefill\s*=\s*consumeSignaturePrefill\s*\(\)/)
+    // 行为级:驱动真实 onLoad(consumeSignaturePrefill 已 stub 为空函数,不抛错)
+    const vm = makePage({ wx: undefined, window: undefined })
+    vm.__opts__.onLoad.call(vm, { requireAgree: '1', agreeText: encodeURIComponent('我已阅读并确认') })
+    expect(vm.requireAgree).toBe(true)
+    expect(vm.agreeText).toBe('我已阅读并确认')
+  })
+
+  test('onLoad: requireAgree 未传则 false;agreeText 未传保留默认文案', function () {
+    const vm = makePage({ wx: undefined, window: undefined })
+    vm.__opts__.onLoad.call(vm, {})
+    expect(vm.requireAgree).toBe(false)
+    expect(vm.agreeText).toBe('我已阅读并确认以上内容真实、准确')
+  })
+})
+
+// ---------------------------------------------------------------------------
+// 权限流:签名页为通用签署页,不区分角色(无 role 门禁),显式记录。
+// ---------------------------------------------------------------------------
+
+describe.skip('权限流:签名页无角色门禁,不适用', function () {
+  it('占位:该能力为通用签署页,无 role 分支(详见汇报)', function () {})
+})

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