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