Browse Source

test(cfc-frontend): S-cfc-131 content.article.listByTag 契约测试(实现已合入6e9a511e2,意外全绿 17/17)

Sisyphus Agent 5 days ago
parent
commit
7f97e9f53

+ 329 - 0
cfc-frontend/__tests__/pages/article-center/articleTagFilter.contract.test.js

@@ -0,0 +1,329 @@
+/**
+ * 契约级单测(TDD-RED)——能力键 content.article.listByTag
+ *
+ * 溯源:
+ *   requirement: docs/superpowers/specs/2026-11-10-article-tag-filter-design.md
+ *                §1 需求概述 —— 分类 Tab 从五维(身/心/智/富/行)改为按文章标签(articles.tags)动态筛选;
+ *                顶部关键词搜索保留;卡片五维浮标保留(信息展示,非分类筛选)
+ *   design:      §3 方案设计(实现已合入 origin/cfclub commit 6e9a511e2)
+ *                - ArticleController /list 增 tag 参数 + 新增 POST /tags 返回 Top20 标签
+ *                - ArticleService.getPublicList(10 参,位置4=tag,parseCommaOrJsonTags 内存过滤) + listTopTags
+ *                - 前端 cfc-frontend/pages/article-center/index.vue:tabList 动态化(全部+各标签)
+ *                  + loadArticles params.dimensionCode→tag + loadTags()(失败静默保留全部)
+ *                - api.js 新增 getArticleTags
+ *
+ * 说明(既有 Jest 体系约束):
+ *   cfc-frontend 的 jest.config.js 仅 transform `.js`(babel-jest),moduleFileExtensions 不含 `vue`,
+ *   未安装 vue-jest/jsdom 组件环境 —— 既有约定为「读取真实 SFC 源码 → 桩化 import → 求值 <script> 拿
+ *   真实 export default → 驱动 data()/methods 做行为断言,模板做结构契约断言」,
+ *   参考 __tests__/family-join/join-form.required-info.test.js。
+ *   本文件不修改 pages/、utils/ 任何业务代码。
+ */
+
+import fs from 'fs'
+import path from 'path'
+import { BrandColors } from '@/utils/brand-colors.js'
+import {
+  getArticleList as apiGetArticleList,
+  getArticleTags as apiGetArticleTags,
+  clearApiDedup
+} from '@/utils/api'
+
+const PAGE_PATH = path.join(__dirname, '../../../pages/article-center/index.vue')
+const API_PATH = path.join(__dirname, '../../../utils/api.js')
+const COLORS_PATH = path.join(__dirname, '../../../utils/brand-colors.js')
+
+/** 把 ES import 语句替换为从 globalThis.__articleCenterStubs 取桩的声明(不解析模块,避免污染业务实现) */
+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.__articleCenterStubs || {})[' + JSON.stringify(id) + '] || {})[' + JSON.stringify(n) + '];'
+    }).join('\n')
+  })
+}
+
+/** 加载真实 SFC:返回 { component, template }(component = 真实 <script> 的 export default 对象) */
+function loadPage() {
+  var src = fs.readFileSync(PAGE_PATH, 'utf8')
+  var tmplMatch = src.match(/<template>([\s\S]*?)<\/template>/)
+  var scriptMatch = src.match(/<script>([\s\S]*?)<\/script>/)
+  if (!scriptMatch) throw new Error('无法在 article-center/index.vue 中定位 <script> 块')
+  var script = stubImports(scriptMatch[1], globalThis.__articleCenterStubs || {})
+  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
+}
+
+/** 读取 utils/api.js 源码文本(结构契约断言) */
+function readApiSource() {
+  return fs.readFileSync(API_PATH, 'utf8')
+}
+
+/**
+ * 每次调用时重新加载真实 SFC(读取当时的 __articleCenterStubs),避免 beforeAll 先于
+ * beforeEach 执行导致组件闭包捕获到未注入的桩变量(data() 引用 BrandColors.* 会崩)。
+ */
+function getComponent() {
+  return loadPage().component
+}
+
+/** 刷新微任务队列:让 onLoad 内 fire-and-forget 的 async 方法(loadTags/loadArticles)结算完成 */
+function flushPromises() {
+  return new Promise(function (resolve) { setTimeout(resolve, 0) })
+}
+
+// ---------------------------------------------------------------------------
+// 测试夹具
+// ---------------------------------------------------------------------------
+
+var stubs
+
+beforeEach(function () {
+  global.uni._storage = {}
+  global.uni.showToast.mockReset()
+  global.uni.navigateTo.mockReset()
+  global.uni.request.mockReset()
+  clearApiDedup()
+  stubs = {
+    getArticleList: jest.fn(function () { return Promise.resolve({ code: 200, message: 'ok', data: { records: [], total: 0 } }) }),
+    getArticleTags: jest.fn(function () { return Promise.resolve({ code: 200, message: 'ok', data: [] }) }),
+    getFeaturedArticles: jest.fn(function () { return Promise.resolve({ code: 200, message: 'ok', data: [] }) })
+  }
+  globalThis.__articleCenterStubs = {
+    '../../utils/brand-colors.js': { BrandColors: BrandColors },
+    '@/utils/api.js': {
+      getArticleList: stubs.getArticleList,
+      getArticleTags: stubs.getArticleTags,
+      getFeaturedArticles: stubs.getFeaturedArticles
+    },
+    '@/config.js': { config: { API_BASE_URL: 'http://127.0.0.1:9082' } },
+    '@/mixins/font-scale.js': { fontScaleMixin: { data: function () { return {} } } }
+  }
+})
+
+afterEach(function () {
+  delete globalThis.__articleCenterStubs
+})
+
+// ---------------------------------------------------------------------------
+// 契约 1:api.js 提供 getArticleTags(POST /api/articles/tags)+ getArticleList(POST /api/articles/list)
+// ---------------------------------------------------------------------------
+
+describe('契约1 api.js:getArticleTags / getArticleList 行为', function () {
+  test('getArticleTags 已导出且调用 POST /api/articles/tags(data || {})', async function () {
+    expect(typeof apiGetArticleTags).toBe('function')
+    global.uni.request.mockImplementation(function (opts) {
+      opts.success({ statusCode: 200, data: { code: 200, message: 'ok', data: ['减脂', '睡眠'] } })
+      return { abort: function () {} }
+    })
+    var res = await apiGetArticleTags()
+    expect(global.uni.request).toHaveBeenCalled()
+    var req = global.uni.request.mock.calls[0][0]
+    expect(req.url).toMatch(/\/api\/articles\/tags$/)
+    expect(req.method).toBe('POST')
+    expect(res.code).toBe(200)
+    expect(res.data).toEqual(['减脂', '睡眠'])
+    // 无参调用也能发出(页面 loadTags() 调 getArticleTags() 不带参数)
+    expect(req.data || {}).toEqual({})
+  })
+
+  test('getArticleList 调用 POST /api/articles/list 且原样透传 tag 参数(列表按标签筛选的通道)', async function () {
+    expect(typeof apiGetArticleList).toBe('function')
+    global.uni.request.mockImplementation(function (opts) {
+      opts.success({ statusCode: 200, data: { code: 200, message: 'ok', data: { records: [{ id: 1, title: 'x', tags: '["减脂"]' }], total: 1 } } })
+      return { abort: function () {} }
+    })
+    var res = await apiGetArticleList({ page: 1, size: 10, tag: '减脂' })
+    var req = global.uni.request.mock.calls[0][0]
+    expect(req.url).toMatch(/\/api\/articles\/list$/)
+    expect(req.method).toBe('POST')
+    expect(req.data.tag).toBe('减脂')
+    expect(req.data.page).toBe(1)
+    expect(res.data.records[0].id).toBe(1)
+  })
+
+  test('api.js 源码不含五维 dimensionCode 作为列表筛参(筛选参数应为 tag)', function () {
+    var src = readApiSource()
+    // getArticleList 永远 POST /api/articles/list,且其请求体直接透传调用方参数(tag 由页面注入)
+    expect(src).toMatch(/getArticleList[\s\S]*?\/api\/articles\/list/)
+  })
+})
+
+// ---------------------------------------------------------------------------
+// 契约 2:index.vue tabList 动态化(不再硬编码五维,默认【全部】,loadTags 成功后再拼标签)
+// ---------------------------------------------------------------------------
+
+describe('契约2 index.vue:tabList 按标签动态化', function () {
+  test('data().tabList 默认仅为「全部」(无五维静态 Tab)', function () {
+    var vm = instantiate(getComponent())
+    expect(Array.isArray(vm.tabList)).toBe(true)
+    expect(vm.tabList.length).toBe(1)
+    expect(vm.tabList[0]).toEqual({ code: '', name: '全部', color: BrandColors.primary })
+    expect(vm.currentTab).toBe('')
+  })
+
+  test('data() 中不存在以 body/mind/wisdom/action/wealth 为 code 的静态 tab 定义', function () {
+    var src = fs.readFileSync(PAGE_PATH, 'utf8')
+    // 兼容 parseDimensions 中合法出现的五维映射 key:仅断言「tabList 静态数组内不得以五维 code 开头」
+    var staticTabDef = src.match(/tabList\s*:\s*\[\s*\{[^}]*code\s*:\s*['"](body|mind|wisdom|action|wealth)['"]/)
+    expect(staticTabDef).toBeNull()
+  })
+
+  test('loadTags() 成功后 tabList = [全部, ...各标签],标签映射为 { code: tag, name: tag }', async function () {
+    stubs.getArticleTags.mockResolvedValue({ code: 200, message: 'ok', data: ['减脂', '睡眠'] })
+    var vm = instantiate(getComponent())
+    await vm.loadTags()
+    expect(stubs.getArticleTags).toHaveBeenCalled()
+    expect(vm.tabList.length).toBe(3)
+    expect(vm.tabList[0].name).toBe('全部')
+    expect(vm.tabList[1]).toEqual({ code: '减脂', name: '减脂', color: BrandColors.primary })
+    expect(vm.tabList[2]).toEqual({ code: '睡眠', name: '睡眠', color: BrandColors.primary })
+  })
+
+  test('loadTags() 失败(reject)时静默保留【全部】,页面仍可浏览', async function () {
+    stubs.getArticleTags.mockRejectedValue(new Error('network'))
+    var vm = instantiate(getComponent())
+    await expect(vm.loadTags()).resolves.toBeUndefined()
+    expect(vm.tabList.length).toBe(1)
+    expect(vm.tabList[0].name).toBe('全部')
+  })
+
+  test('loadTags() 返回空数组时同样保留【全部】(Top20 为空场景)', async function () {
+    stubs.getArticleTags.mockResolvedValue({ code: 200, message: 'ok', data: [] })
+    var vm = instantiate(getComponent())
+    await vm.loadTags()
+    expect(vm.tabList.length).toBe(1)
+    expect(vm.tabList[0].name).toBe('全部')
+  })
+})
+
+// ---------------------------------------------------------------------------
+// 契约 3:loadArticles 以 params.tag(而非 dimensionCode)做筛选
+// ---------------------------------------------------------------------------
+
+describe('契约3 index.vue:列表按 currentTab 标签筛选', function () {
+  test('currentTab 有值时请求体带 params.tag(即为当前标签名)', async function () {
+    var vm = instantiate(getComponent())
+    vm.currentChildId = null
+    vm.currentTab = '减脂'
+    vm.searchKeyword = ''
+    await vm.loadArticles()
+    expect(stubs.getArticleList).toHaveBeenCalled()
+    var params = stubs.getArticleList.mock.calls[0][0]
+    expect(params.tag).toBe('减脂')
+    expect(params.dimensionCode).toBeUndefined()
+  })
+
+  test('currentTab 为空(全部)时不带 tag 参数', async function () {
+    var vm = instantiate(getComponent())
+    vm.currentChildId = null
+    vm.currentTab = ''
+    vm.searchKeyword = ''
+    await vm.loadArticles()
+    var params = stubs.getArticleList.mock.calls[0][0]
+    expect(params.tag).toBeUndefined()
+    expect(params.dimensionCode).toBeUndefined()
+  })
+
+  test('onTabChange(code) 切换 Tab:更新 currentTab、清空搜索、重置分页并重新拉取', async function () {
+    var vm = instantiate(getComponent())
+    vm.currentChildId = null
+    vm.page = 3
+    vm.articles = [{ id: 999 }]
+    vm.searchKeyword = '旧关键词'
+    vm.onTabChange('减脂')
+    expect(vm.currentTab).toBe('减脂')
+    expect(vm.searchKeyword).toBe('')
+    expect(vm.page).toBe(1)
+    expect(vm.articles).toEqual([])
+    expect(stubs.getArticleList).toHaveBeenCalled()
+    expect(stubs.getArticleList.mock.calls[0][0].tag).toBe('减脂')
+  })
+
+  test('onLoad 首次进入即 loadTags() + loadArticles(),标签与列表双双就绪', async function () {
+    stubs.getArticleTags.mockResolvedValue({ code: 200, message: 'ok', data: ['睡眠'] })
+    stubs.getArticleList.mockResolvedValue({ code: 200, message: 'ok', data: { records: [{ id: 1 }], total: 1 } })
+    var vm = instantiate(getComponent())
+    vm.currentChildId = 5
+    var opts = getComponent()
+    await opts.onLoad.call(vm, {})
+    await flushPromises()
+    expect(stubs.getArticleTags).toHaveBeenCalled()
+    expect(stubs.getArticleList).toHaveBeenCalled()
+    expect(vm.tabList.length).toBe(2)
+    expect(vm.articles.length).toBe(1)
+  })
+
+  test('旧入口传 dimension(新语义下降级为标签命中失败)仍设置 currentTab 不崩溃', async function () {
+    var vm = instantiate(getComponent())
+    var opts = getComponent()
+    await opts.onLoad.call(vm, { dimension: 'body' })
+    await flushPromises()
+    expect(vm.currentTab).toBe('body')
+  })
+})
+
+// ---------------------------------------------------------------------------
+// 契约 4:模板结构——Tab 以 v-for 动态渲染,点击驱动 onTabChange;搜索栏保留
+// ---------------------------------------------------------------------------
+
+describe('契约4 index.vue:模板结构与关键词搜索保留', function () {
+  var template
+  beforeAll(function () {
+    // beforeAll 先于 beforeEach 执行:仅做模板结构断言,无需桩函数,置空即可(loadPage 对桩做可选访问)
+    globalThis.__articleCenterStubs = {}
+    template = loadPage().template
+  })
+
+  test('Tab 区域以 v-for="tab in tabList" 动态渲染,key 绑定 tab.code,点击 onTabChange', function () {
+    expect(template).toMatch(/v-for="tab in tabList"/)
+    expect(template).toMatch(/:key="tab\.code"/)
+    expect(template).toMatch(/@click="onTabChange\(tab\.code\)"/)
+  })
+
+  test('当前选中项以 currentTab === tab.code 高亮', function () {
+    expect(template).toMatch(/currentTab === tab\.code/)
+  })
+
+  test('顶部关键词搜索保留(input + v-model searchKeyword + onSearch)', function () {
+    expect(template).toMatch(/v-model="searchKeyword"/)
+    expect(template).toMatch(/onSearch/)
+  })
+
+  test('卡片列表渲染每篇文章(文章标签展示为信息性浮标,非分类入口)', function () {
+    expect(template).toMatch(/v-for=.*item.*in articles/)
+  })
+})