Forráskód Böngészése

test(frontend): S-cfc-134 growth.history.dayGroupView 契约级单测(TDD-RED 10红,待GREEN实现)

为成长主页「按日期分组」视图补行为级 RED 契约(仅新增 __tests__ 测试文件,不改 src):
- AC-1 状态/装配入口:dateGroups 系 data 字段 + groupByDate 系装配方法存在
- AC-2 行为级:空输入->空分组、同日合并(键 yyyy-MM-dd)、多日倒序、组内保留原记录、无效业务日期跳过
- AC-3 结构级:模板以分组集合 v-for 渲染、日期分组标题、分组空态分支
溯源 SC-P-95 + 2026-11-06 需求变更B + B-135-1;design: cfc-frontend/pages/growth-main/index.vue
(dateGroups/groupedRecords/groupByDate,实现仅在 feature 分支未落地)。
jest 实测 10 失败 / 1 跳过 / 11 总,失败原因=契约缺口,符合预期 RED。
Sisyphus Agent 1 napja
szülő
commit
d3fe276eb

+ 329 - 0
cfc-frontend/__tests__/pages/growth-main/growthHistory.dayGroupView.test.js

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