|
|
@@ -0,0 +1,329 @@
|
|
|
+/**
|
|
|
+ * 契约级单测(TDD-RED)——能力键 growth.history.dayGroupView(成长主页「按日期分组」视图)
|
|
|
+ *
|
|
|
+ * 溯源:
|
|
|
+ * requirement: tests/user-scenarios/应用场景总览.md SC-P-95
|
|
|
+ * docs/需求分析/2026-11-06-成就成长打卡需求变更.md §变更B(成长记录→成长历史记录页,
|
|
|
+ * 聚合「今日成长」全部打卡记录,按日期倒序、分组/筛选,空态合理)
|
|
|
+ * docs/tdd/test/blockers.md B-135-1(frontend 16 红 = growth-main 按日期分组未落地)
|
|
|
+ * design: cfc-frontend/pages/growth-main/index.vue(补按日期分组:dateGroups/groupedRecords/
|
|
|
+ * groupByDate 结构;实现仅在 feature 分支,不得假定包路径,本测试命名契约对齐同批
|
|
|
+ * growthHistory.view.test.js 的容错清单——data 字段 v-for 源与 groupByDate 系方法均可选名)
|
|
|
+ * 同批参考: cfc-frontend/__tests__/pages/growth-main/growthHistory.view.test.js(S-cfc-30,已合入,
|
|
|
+ * 仅含 2 条结构级 RED 断言)· cfc-frontend/__tests__/family-join/join-form.required-info.test.js(加载真实 SFC 源码)
|
|
|
+ *
|
|
|
+ * 说明(既有 Jest 体系约束,同 growthHistory.view.test.js):
|
|
|
+ * cfc-frontend 的 jest.config.js 仅 transform `.js`,moduleFileExtensions 不含 `vue`,无 vue-jest/jsdom
|
|
|
+ * 组件环境 —— 既有约定为「抽取被测逻辑断言」:直接加载真实 SFC 的 <script>/<template> 源码——
|
|
|
+ * - <script>:把 import 语句替换为注入桩后求值,得到真实组件的 data()/methods,做行为级断言;
|
|
|
+ * - <template>:读取模板文本,做结构契约断言(按日期分组循环、日期标题、空态分支)。
|
|
|
+ * format.js 的 parseDate/recordDateOf 注入**真实实现**(纯函数),使分组行为契约覆盖后端真实
|
|
|
+ * 日期形态(recordDate 纯日期 / sleepTime MySQL 空格格式 / startTime ISO / checkinDate 纯日期)。
|
|
|
+ *
|
|
|
+ * 红/绿判定:
|
|
|
+ * - 现状 origin/cfclub 的 growth-main/index.vue 仅有「今日成长」progressItems 网格,data 无
|
|
|
+ * dateGroups 系字段、methods 无 groupByDate 系方法、模板无按日期分组渲染 → 全部预期**红**(RED 核心)。
|
|
|
+ * - 使绿改动(GREEN 阶段实现方,仅改 src/main 下本页面,不动本测试):
|
|
|
+ * ① data 增补日期分组状态字段(dateGroups/dateGroupList/groupedRecords/groupedCheckins/
|
|
|
+ * historyGroups/historyDays/checkinGroups/recordGroups/dayGroups/growthHistoryGroups 之一,默认 []);
|
|
|
+ * ② methods 提供把打卡/档案记录流按业务日期(recordDate>startTime>sleepTime>checkinDate>createdAt)
|
|
|
+ * 分组的装配入口(groupByDate/groupRecordsByDate/groupCheckinsByDate/groupHistoryByDate/groupedByDate
|
|
|
+ * 之一):同日合并、分组键 yyyy-MM-dd、多日倒序(新在前)、无有效日期平稳跳过;
|
|
|
+ * ③ 模板以分组集合为 v-for 渲染,分组标题显示日期,集合为空时有空态分支(文案/条件均可)。
|
|
|
+ *
|
|
|
+ * 本文件不修改 pages/、utils/ 任何业务代码。
|
|
|
+ */
|
|
|
+
|
|
|
+import fs from 'fs'
|
|
|
+import path from 'path'
|
|
|
+// format.js 为纯函数工具,注入真实实现保证分组行为契约覆盖后端真实日期形态(与桩隔离无冲突)
|
|
|
+import { parseDate as realParseDate, recordDateOf as realRecordDateOf } from '@/utils/format'
|
|
|
+
|
|
|
+const GROWTH_MAIN_PATH = path.join(__dirname, '../../../pages/growth-main/index.vue')
|
|
|
+
|
|
|
+// 容错命名清单(对齐已合入 growthHistory.view.test.js 的契约,或按之对齐即可)
|
|
|
+const DATA_FIELD_NAMES = ['dateGroups', 'dateGroupList', 'groupedRecords', 'groupedCheckins', 'historyGroups', 'historyDays', 'checkinGroups', 'recordGroups', 'dayGroups', 'growthHistoryGroups']
|
|
|
+const METHOD_NAMES = ['groupByDate', 'groupRecordsByDate', 'groupCheckinsByDate', 'groupHistoryByDate', 'groupedByDate']
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// 工具:加载真实 growth-main/index.vue 的 <script> 逻辑(行为级)与 <template> 结构(结构级)
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+/** 把 ES import 语句替换为从 globalThis.__dayGroupViewStubs 取桩的声明(不解析模块,避免污染业务实现) */
|
|
|
+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.__dayGroupViewStubs[' + 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.__dayGroupViewStubs || {})
|
|
|
+ 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] })
|
|
|
+ return vm
|
|
|
+}
|
|
|
+
|
|
|
+/** 在真实组件中查找 groupByDate 系装配方法的第一个命中名(无则 null) */
|
|
|
+function findGroupMethodName(component) {
|
|
|
+ var methods = component.methods || {}
|
|
|
+ for (var i = 0; i < METHOD_NAMES.length; i++) {
|
|
|
+ if (typeof methods[METHOD_NAMES[i]] === 'function') return METHOD_NAMES[i]
|
|
|
+ }
|
|
|
+ return null
|
|
|
+}
|
|
|
+
|
|
|
+/** 以真实组件 methods 装配入口驱动分组(方法缺失返回 undefined,由断言暴露契约缺口) */
|
|
|
+function invokeGrouping(component, records) {
|
|
|
+ var name = findGroupMethodName(component)
|
|
|
+ if (!name) return undefined
|
|
|
+ var vm = instantiate(component)
|
|
|
+ return vm[name](records)
|
|
|
+}
|
|
|
+
|
|
|
+/** 归一化分组形状(兼容 date/day/groupDate/dayKey/dateStr × records/list/items/checkins/data 命名) */
|
|
|
+function normalizeGroup(g) {
|
|
|
+ var date = g && (g.date || g.day || g.groupDate || g.dayKey || g.dateStr || '')
|
|
|
+ var list = g && (g.records || g.list || g.items || g.checkins || g.data || [])
|
|
|
+ return { date: String(date), list: list }
|
|
|
+}
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// 测试夹具
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+/** 混合类型打卡记录流(业务日期字段仿后端真实形态:饮食 recordDate / 睡眠 sleepTime / 运动 startTime / 心情·记账 checkinDate) */
|
|
|
+function sampleRecords() {
|
|
|
+ return [
|
|
|
+ { type: 'diet', recordDate: '2026-11-08', name: '早餐' },
|
|
|
+ { type: 'sleep', sleepTime: '2026-11-08 23:00', note: '正常' },
|
|
|
+ { type: 'exercise', startTime: '2026-11-07T08:30:00', minutes: 30 },
|
|
|
+ { type: 'emotion', checkinDate: '2026-11-07', mood: '开心' },
|
|
|
+ { type: 'checkin', checkinDate: '2026-11-06', amount: 50 },
|
|
|
+ { type: 'growth', recordDate: '2026-11-06', content: '阅读 30 分钟' },
|
|
|
+ { type: 'diet-backfill', recordDate: '2026-11-09', name: '午餐补录' }
|
|
|
+ ]
|
|
|
+}
|
|
|
+
|
|
|
+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 组件运行时真正调用的,其余以空实现兜底
|
|
|
+ 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: [] }) })
|
|
|
+ }
|
|
|
+ globalThis.__dayGroupViewStubs = {
|
|
|
+ '../../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
|
|
|
+ },
|
|
|
+ // format.js 注入真实实现,保证行为契约覆盖后端真实日期形态(纯函数,无副作用)
|
|
|
+ '../../utils/format.js': { parseDate: realParseDate, recordDateOf: realRecordDateOf },
|
|
|
+ '@/mixins/font-scale.js': { fontScaleMixin: { data: function () { return { fontScale: 1 } }, onShow: function () {} } },
|
|
|
+ '../../components/share-mixin.js': { shareMixin: {} },
|
|
|
+ '../../components/LoginGuideCard.vue': {},
|
|
|
+ '../../components/DimensionActivities.vue': {},
|
|
|
+ '../../components/DimensionProducts.vue': {},
|
|
|
+ '../../components/ArticleFeed.vue': {}
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+afterEach(function () {
|
|
|
+ delete globalThis.__dayGroupViewStubs
|
|
|
+})
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// AC-1 | 状态与装配入口存在(契约新增 → 预期红)
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+describe('AC-1 日期分组状态与装配入口存在(成长主页补按日期分组 → 预期红)', function () {
|
|
|
+ var component
|
|
|
+ beforeEach(function () {
|
|
|
+ component = loadGrowthMain().component
|
|
|
+ })
|
|
|
+
|
|
|
+ it('RED-A1 组件提供「日期分组状态」入口:data 字段(dateGroups/groupedRecords 之一)为数组,或 computed getter', function () {
|
|
|
+ var vm = instantiate(component)
|
|
|
+ var hit = DATA_FIELD_NAMES.filter(function (k) { return vm[k] !== undefined })
|
|
|
+ if (hit.length > 0) {
|
|
|
+ expect(Array.isArray(vm[hit[0]])).toBe(true)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ // computed getter 形式:存在且以默认 data 求值为数组
|
|
|
+ var comp = component.computed || {}
|
|
|
+ var cKeys = DATA_FIELD_NAMES.filter(function (k) { return typeof comp[k] === 'function' })
|
|
|
+ expect(cKeys.length).toBeGreaterThan(0)
|
|
|
+ expect(Array.isArray(comp[cKeys[0]].call(instantiate(component)))).toBe(true)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('RED-A2 组件 methods 提供「按日期分组」装配方法(groupByDate 系方法存在且可调用)', function () {
|
|
|
+ expect(findGroupMethodName(component)).toBeTruthy()
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// AC-2 | 行为级:按日期分组装配(核心契约 → 预期红)
|
|
|
+// GREEN 契约:groupByDate(records) 把记录流按业务日期分组——
|
|
|
+// 同日合并、键 yyyy-MM-dd、多日倒序(新在前)、无有效日期平稳跳过、组内保留原记录。
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+describe('AC-2 行为级:groupByDate 系装配逻辑(契约新增 → 预期红)', function () {
|
|
|
+ var component
|
|
|
+ beforeEach(function () {
|
|
|
+ component = loadGrowthMain().component
|
|
|
+ })
|
|
|
+
|
|
|
+ it('RED-A3 空记录列表 → 返回空分组数组(不抛错)', function () {
|
|
|
+ expect(findGroupMethodName(component)).toBeTruthy()
|
|
|
+ var groups = invokeGrouping(component, [])
|
|
|
+ expect(Array.isArray(groups)).toBe(true)
|
|
|
+ expect(groups.length).toBe(0)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('RED-A4 同日记录合并为一组,分组键为 yyyy-MM-dd(混合类型:饮食 recordDate / 睡眠 sleepTime / 心情 checkinDate)', function () {
|
|
|
+ expect(findGroupMethodName(component)).toBeTruthy()
|
|
|
+ var groups = invokeGrouping(component, [
|
|
|
+ { type: 'diet', recordDate: '2026-11-08', name: '早餐' },
|
|
|
+ { type: 'sleep', sleepTime: '2026-11-08 23:00', note: '正常' },
|
|
|
+ { type: 'emotion', checkinDate: '2026-11-08', mood: '平静' }
|
|
|
+ ])
|
|
|
+ var norm = groups.map(normalizeGroup)
|
|
|
+ expect(norm.length).toBe(1)
|
|
|
+ expect(norm[0].date).toBe('2026-11-08')
|
|
|
+ expect(norm[0].list.length).toBe(3)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('RED-A5 多日记录分组按日期倒序(最新在前)', function () {
|
|
|
+ expect(findGroupMethodName(component)).toBeTruthy()
|
|
|
+ var groups = invokeGrouping(component, sampleRecords())
|
|
|
+ var dates = groups.map(normalizeGroup).map(function (g) { return g.date })
|
|
|
+ expect(dates).toEqual(['2026-11-09', '2026-11-08', '2026-11-07', '2026-11-06'])
|
|
|
+ })
|
|
|
+
|
|
|
+ it('RED-A6 组内保留原记录对象(不丢字段、不改写输入)', function () {
|
|
|
+ expect(findGroupMethodName(component)).toBeTruthy()
|
|
|
+ var input = sampleRecords()
|
|
|
+ var snapshot = JSON.stringify(input)
|
|
|
+ var groups = invokeGrouping(component, input)
|
|
|
+ var flat = []
|
|
|
+ groups.map(normalizeGroup).forEach(function (g) { flat = flat.concat(g.list) })
|
|
|
+ expect(input[0].recordDate).toBe('2026-11-08')
|
|
|
+ expect(JSON.stringify(input)).toBe(snapshot)
|
|
|
+ expect(flat).toEqual(expect.arrayContaining(input))
|
|
|
+ expect(flat.length).toBe(input.length)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('RED-A7 无有效业务日期的记录被跳过(不抛错、不产生空键分组;全无效 → 空分组)', function () {
|
|
|
+ expect(findGroupMethodName(component)).toBeTruthy()
|
|
|
+ var withGarbage = sampleRecords().concat([
|
|
|
+ { type: 'garbage-1', checkinDate: '' },
|
|
|
+ { type: 'garbage-2' },
|
|
|
+ { type: 'garbage-3', checkinDate: 'not-a-date' }
|
|
|
+ ])
|
|
|
+ var groups = invokeGrouping(component, withGarbage)
|
|
|
+ var norm = groups.map(normalizeGroup)
|
|
|
+ norm.forEach(function (g) { expect(g.date).not.toBe('') })
|
|
|
+ var flat = []
|
|
|
+ norm.forEach(function (g) { flat = flat.concat(g.list) })
|
|
|
+ expect(flat.length).toBe(6)
|
|
|
+ // 全为无效记录 → 空分组
|
|
|
+ expect(invokeGrouping(component, [{ checkinDate: 'bad' }, { foo: 1 }]).length).toBe(0)
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// AC-3 | 结构级:模板按日期分组渲染 + 日期标题 + 空态(预期红)
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+describe('AC-3 结构级:模板按日期分组渲染与空态(变更B 验收口径:看到所有打卡记录 → 预期红)', function () {
|
|
|
+ var template
|
|
|
+ beforeAll(function () {
|
|
|
+ // beforeAll 先于 beforeEach:仅做模板结构断言,无需桩函数
|
|
|
+ globalThis.__dayGroupViewStubs = {}
|
|
|
+ template = loadGrowthMain().template
|
|
|
+ })
|
|
|
+
|
|
|
+ it('RED-A8 模板存在「按日期分组」的记录循环渲染(v-for 数据源为 dateGroups/groupedRecords 等)', function () {
|
|
|
+ expect(template).toMatch(/v-for\s*=\s*["'][^"']*(?:dateGroups|dateGroupList|groupedRecords|groupedCheckins|historyGroups|historyDays|checkinGroups|recordGroups|dayGroups|growthHistoryGroups)[^"']*["']/i)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('RED-A9 模板存在日期分组标题渲染(分组键以 .date/.day 插值或 formatDateKey/formatRecordDate 输出)', function () {
|
|
|
+ expect(template).toMatch(/v-for\s*=\s*["'][^"']*(?:dateGroups|dateGroupList|groupedRecords|groupedCheckins|historyGroups|historyDays|checkinGroups|recordGroups|dayGroups|growthHistoryGroups)[^"']*["']/i)
|
|
|
+ expect(template).toMatch(/(?:formatDateKey\s*\(|formatRecordDate\s*\(|\.[dp]ate\s*[}|]|\.[dg]ay\s*[}|]|\bgroupDate\b)/)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('RED-A10 模板存在分组空态分支:集合为空时显示空态(空态文案或 length===0 条件 v-if)', function () {
|
|
|
+ var hasEmptyCopy = /暂无[\s\S]{0,10}记录|还没有[\s\S]{0,10}记录|暂无成长|暂无打卡/.test(template)
|
|
|
+ var hasEmptyBranch = /v-if\s*=\s*["'][^"']*(?:dateGroups|dateGroupList|groupedRecords|groupedCheckins|historyGroups|historyDays|checkinGroups|recordGroups|dayGroups|growthHistoryGroups)[^"']*\.length\s*(?:===?)\s*0/.test(template)
|
|
|
+ expect(hasEmptyCopy || hasEmptyBranch).toBe(true)
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// 权限流:本能力为「家长/教师查看孩子成长与打卡历史」的成长主页按日期分组视图,
|
|
|
+// 页面在已登录态按 currentChildId 取数,无额外前端 role 门禁分支(role 校验在 JwtInterceptor 层),
|
|
|
+// 与同批 growthHistory.view.test.js 口径一致,显式记录为不适用项。
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+describe.skip('权限流:成长主页按日期分组视图无前端 role 门禁分支,不适用', function () {
|
|
|
+ it('占位:该能力面向登录家长/教师,详见汇报', function () {})
|
|
|
+})
|