Quellcode durchsuchen

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

Sisyphus Agent vor 2 Tagen
Ursprung
Commit
d6de1eba01
1 geänderte Dateien mit 314 neuen und 0 gelöschten Zeilen
  1. 314 0
      cfc-frontend/__tests__/pages/growth-main/growthHistory.view.test.js

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