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