|
|
@@ -0,0 +1,368 @@
|
|
|
+/**
|
|
|
+ * 契约级单测(TDD-RED→GREEN)——能力键 content.article.search(前端部分 C4-C6)
|
|
|
+ *
|
|
|
+ * 溯源:
|
|
|
+ * requirement: docs/需求分析/2026-11-14-小程序知识分享页搜索功能需求变更.md
|
|
|
+ * (ARTICLE-SEARCH-2026-11-14-V1,契约 C4 触发语义 / C5 空态区分 / C6 标签互斥重置)
|
|
|
+ * design: docs/design/2026-11-14-article-center-search-design.md
|
|
|
+ * (D3 前端交互完善 + D3a 输入防抖与搜索历史,交互参考 pages/shop/search/search.vue)
|
|
|
+ * 契约布局:___tests__/api/api.test.js(C4 请求体 keyword 透传断言)+ 本文件(页面方法/空态/互斥)
|
|
|
+ *
|
|
|
+ * 说明(既有 Jest 体系约束):
|
|
|
+ * cfc-frontend 无 vue-jest/jsdom 组件环境 —— 沿用「读取真实 SFC 源码 → 桩化 import → 求值
|
|
|
+ * <script> 拿真实 export default → 驱动 data()/methods/computed 做行为断言,模板做结构断言」,
|
|
|
+ * 参考 __tests__/pages/article-center/articleTagFilter.contract.test.js。
|
|
|
+ * 本文件不修改 pages/、utils/ 任何业务代码。
|
|
|
+ *
|
|
|
+ * 小程序约束(AGENTS.md):禁可选链 ?.、禁 CSS Grid、禁 :key 表达式、禁 new Date(string)。
|
|
|
+ */
|
|
|
+
|
|
|
+import fs from 'fs'
|
|
|
+import path from 'path'
|
|
|
+import { BrandColors } from '@/utils/brand-colors.js'
|
|
|
+
|
|
|
+const PAGE_PATH = path.join(__dirname, '../../../pages/article-center/index.vue')
|
|
|
+
|
|
|
+/** 把 ES import 语句替换为从 globalThis.__articleCenterSearchStubs 取桩的声明(不解析模块,避免污染业务实现) */
|
|
|
+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.__articleCenterSearchStubs || {})[' + 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.__articleCenterSearchStubs || {})
|
|
|
+ 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/computed 构造可驱动上下文(模拟 Vue 合并实例) */
|
|
|
+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] })
|
|
|
+ var computed = component.computed || {}
|
|
|
+ Object.keys(computed).forEach(function (k) {
|
|
|
+ Object.defineProperty(vm, k, { get: computed[k], enumerable: true, configurable: true })
|
|
|
+ })
|
|
|
+ return vm
|
|
|
+}
|
|
|
+
|
|
|
+/** 每次调用时重新加载真实 SFC(读取当时的 __articleCenterSearchStubs),避免闭包捕获旧桩 */
|
|
|
+function getComponent() {
|
|
|
+ return loadPage().component
|
|
|
+}
|
|
|
+
|
|
|
+/** 刷新微任务队列:让 fire-and-forget 的 async 方法(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()
|
|
|
+ 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.__articleCenterSearchStubs = {
|
|
|
+ '../../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.__articleCenterSearchStubs
|
|
|
+})
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// 契约 C4:触发语义 —— onSearch 重置分页/清列表/noMore=false,请求体携带 trim keyword;
|
|
|
+// 空 keyword(取消)还原全量;400ms 防抖即时搜索;搜索历史本地存储(上限 10、去重、可清空)
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+describe('契约C4 index.vue:onSearch 触发语义 + 防抖 + 搜索历史', function () {
|
|
|
+ test('onSearch 同步重置 page=1/articles=[]/noMore=false,请求体携带 trim 后的 keyword', async function () {
|
|
|
+ stubs.getArticleList.mockResolvedValue({ code: 200, message: 'ok', data: { records: [{ id: 1, title: '睡眠指南' }], total: 1 } })
|
|
|
+ var vm = instantiate(getComponent())
|
|
|
+ vm.currentChildId = null
|
|
|
+ vm.page = 5
|
|
|
+ vm.articles = [{ id: 999 }]
|
|
|
+ vm.noMore = true
|
|
|
+ vm.searchKeyword = ' 睡眠 '
|
|
|
+ vm.onSearch()
|
|
|
+ // 提交瞬间即重置(契约 C4:page=1、列表清空、noMore=false)
|
|
|
+ expect(vm.page).toBe(1)
|
|
|
+ expect(vm.articles).toEqual([])
|
|
|
+ expect(vm.noMore).toBe(false)
|
|
|
+ await flushPromises()
|
|
|
+ var params = stubs.getArticleList.mock.calls[0][0]
|
|
|
+ expect(params.keyword).toBe('睡眠')
|
|
|
+ expect(vm.articles).toEqual([{ id: 1, title: '睡眠指南' }])
|
|
|
+ })
|
|
|
+
|
|
|
+ test('onSearch 空 keyword(取消语义)→ 还原全量,请求体不带 keyword 且历史不落盘', async function () {
|
|
|
+ stubs.getArticleList.mockResolvedValue({ code: 200, message: 'ok', data: { records: [{ id: 2, title: 'x' }], total: 1 } })
|
|
|
+ var vm = instantiate(getComponent())
|
|
|
+ vm.currentChildId = null
|
|
|
+ vm.searchKeyword = ' '
|
|
|
+ vm.onSearch()
|
|
|
+ await flushPromises()
|
|
|
+ var params = stubs.getArticleList.mock.calls[0][0]
|
|
|
+ expect(params.keyword).toBeUndefined()
|
|
|
+ expect(params.tag).toBeUndefined()
|
|
|
+ expect(vm.articles).toEqual([{ id: 2, title: 'x' }])
|
|
|
+ expect(global.uni._storage['article_search_history']).toBeUndefined()
|
|
|
+ })
|
|
|
+
|
|
|
+ test('模板:搜索框保留 v-model/@confirm 并接入 @input="onSearchInput" 防抖', function () {
|
|
|
+ var template = loadPage().template
|
|
|
+ expect(template).toMatch(/v-model="searchKeyword"/)
|
|
|
+ expect(template).toMatch(/@confirm="onSearch"/)
|
|
|
+ expect(template).toMatch(/@input="onSearchInput"/)
|
|
|
+ })
|
|
|
+
|
|
|
+ test('onSearchInput 400ms 防抖即时搜索:只发一次且携带当前 keyword', function () {
|
|
|
+ jest.useFakeTimers()
|
|
|
+ try {
|
|
|
+ stubs.getArticleList.mockResolvedValue({ code: 200, message: 'ok', data: { records: [], total: 0 } })
|
|
|
+ var vm = instantiate(getComponent())
|
|
|
+ vm.currentChildId = null
|
|
|
+ vm.searchKeyword = '睡眠'
|
|
|
+ vm.onSearchInput({ detail: { value: '睡眠' } })
|
|
|
+ expect(stubs.getArticleList).not.toHaveBeenCalled()
|
|
|
+ jest.advanceTimersByTime(399)
|
|
|
+ expect(stubs.getArticleList).not.toHaveBeenCalled()
|
|
|
+ jest.advanceTimersByTime(1)
|
|
|
+ expect(stubs.getArticleList).toHaveBeenCalledTimes(1)
|
|
|
+ expect(stubs.getArticleList.mock.calls[0][0].keyword).toBe('睡眠')
|
|
|
+ } finally {
|
|
|
+ jest.useRealTimers()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ test('onSearchInput 连续输入只在防抖窗口结束后请求一次并取最新词', function () {
|
|
|
+ jest.useFakeTimers()
|
|
|
+ try {
|
|
|
+ var vm = instantiate(getComponent())
|
|
|
+ vm.currentChildId = null
|
|
|
+ vm.onSearchInput({ detail: { value: '睡' } })
|
|
|
+ vm.onSearchInput({ detail: { value: '睡眠' } })
|
|
|
+ vm.onSearchInput({ detail: { value: ' 睡眠法 ' } })
|
|
|
+ expect(stubs.getArticleList).not.toHaveBeenCalled()
|
|
|
+ jest.advanceTimersByTime(400)
|
|
|
+ expect(stubs.getArticleList).toHaveBeenCalledTimes(1)
|
|
|
+ expect(stubs.getArticleList.mock.calls[0][0].keyword).toBe('睡眠法')
|
|
|
+ } finally {
|
|
|
+ jest.useRealTimers()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ test('onSearch 保存 trim 关键词到本地历史(article_search_history):去重、提到最前', function () {
|
|
|
+ var vm = instantiate(getComponent())
|
|
|
+ vm.searchKeyword = '睡眠'
|
|
|
+ vm.onSearch()
|
|
|
+ expect(vm.searchHistory).toEqual(['睡眠'])
|
|
|
+ expect(JSON.parse(global.uni._storage['article_search_history'])).toEqual(['睡眠'])
|
|
|
+
|
|
|
+ vm.searchKeyword = '成长'
|
|
|
+ vm.onSearch()
|
|
|
+ expect(vm.searchHistory).toEqual(['成长', '睡眠'])
|
|
|
+
|
|
|
+ // 去重:再次搜索相同关键词提到最前
|
|
|
+ vm.searchKeyword = '睡眠'
|
|
|
+ vm.onSearch()
|
|
|
+ expect(vm.searchHistory).toEqual(['睡眠', '成长'])
|
|
|
+ expect(vm.searchHistory.length).toBe(2)
|
|
|
+ })
|
|
|
+
|
|
|
+ test('搜索历史上限 10 条,超出丢弃最旧', function () {
|
|
|
+ var vm = instantiate(getComponent())
|
|
|
+ var seed = []
|
|
|
+ for (var i = 1; i <= 10; i++) seed.push('k' + i)
|
|
|
+ vm.searchHistory = seed.slice()
|
|
|
+ vm.searchKeyword = 'new'
|
|
|
+ vm.onSearch()
|
|
|
+ expect(vm.searchHistory.length).toBe(10)
|
|
|
+ expect(vm.searchHistory[0]).toBe('new')
|
|
|
+ expect(vm.searchHistory[9]).toBe('k9')
|
|
|
+ expect(vm.searchHistory.indexOf('k10')).toBe(-1)
|
|
|
+ })
|
|
|
+
|
|
|
+ test('onLoad 读取历史;非法 JSON 容错为空数组', async function () {
|
|
|
+ global.uni._storage['article_search_history'] = JSON.stringify(['睡眠', '成长'])
|
|
|
+ var vm = instantiate(getComponent())
|
|
|
+ var opts = getComponent()
|
|
|
+ await opts.onLoad.call(vm, {})
|
|
|
+ await flushPromises()
|
|
|
+ expect(vm.searchHistory).toEqual(['睡眠', '成长'])
|
|
|
+
|
|
|
+ global.uni._storage['article_search_history'] = '{bad json'
|
|
|
+ var vm2 = instantiate(getComponent())
|
|
|
+ await opts.onLoad.call(vm2, {})
|
|
|
+ expect(vm2.searchHistory).toEqual([])
|
|
|
+ })
|
|
|
+
|
|
|
+ test('clearSearchHistory 清空列表并删除本地存储', function () {
|
|
|
+ var vm = instantiate(getComponent())
|
|
|
+ vm.searchHistory = ['睡眠']
|
|
|
+ global.uni._storage['article_search_history'] = JSON.stringify(['睡眠'])
|
|
|
+ vm.clearSearchHistory()
|
|
|
+ expect(vm.searchHistory).toEqual([])
|
|
|
+ expect(global.uni._storage['article_search_history']).toBeUndefined()
|
|
|
+ })
|
|
|
+
|
|
|
+ test('模板:搜索历史条 v-for 渲染 searchHistory,点击历史项回填关键词', function () {
|
|
|
+ var template = loadPage().template
|
|
|
+ expect(template).toMatch(/v-for="\(item, idx\) in searchHistory"/)
|
|
|
+ expect(template).toMatch(/searchHistory.length/)
|
|
|
+ expect(template).toMatch(/clickSearchHistory\(item\)/)
|
|
|
+ expect(template).toMatch(/clearSearchHistory/)
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// 契约 C5:空态区分 —— noMore && articles.length === 0 时,
|
|
|
+// searchKeyword 非空 →「未找到相关文章」,否则 →「暂无文章」
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+describe('契约C5 index.vue:搜索空态与「暂无文章」区分', function () {
|
|
|
+ test('模板:空态门禁 noMore && articles.length === 0,文案按 isSearchEmpty 区分', function () {
|
|
|
+ var template = loadPage().template
|
|
|
+ expect(template).toMatch(/v-else-if="noMore && articles\.length === 0"/)
|
|
|
+ expect(template).toMatch(/未找到相关文章/)
|
|
|
+ expect(template).toMatch(/暂无文章/)
|
|
|
+ expect(template).toMatch(/isSearchEmpty/)
|
|
|
+ })
|
|
|
+
|
|
|
+ test('搜索无命中(records=[] total=0)→ articles=[] 且 noMore=true,isSearchEmpty=true', async function () {
|
|
|
+ stubs.getArticleList.mockResolvedValue({ code: 200, message: 'ok', data: { records: [], total: 0 } })
|
|
|
+ var vm = instantiate(getComponent())
|
|
|
+ vm.currentChildId = null
|
|
|
+ vm.searchKeyword = '睡眠'
|
|
|
+ vm.onSearch()
|
|
|
+ await flushPromises()
|
|
|
+ expect(vm.articles).toEqual([])
|
|
|
+ expect(vm.noMore).toBe(true)
|
|
|
+ expect(vm.isSearchEmpty).toBe(true)
|
|
|
+ })
|
|
|
+
|
|
|
+ test('非搜索态空列表(无 keyword)→ isSearchEmpty=false(走「暂无文章」)', async function () {
|
|
|
+ stubs.getArticleList.mockResolvedValue({ code: 200, message: 'ok', data: { records: [], total: 0 } })
|
|
|
+ var vm = instantiate(getComponent())
|
|
|
+ vm.currentChildId = null
|
|
|
+ vm.searchKeyword = ''
|
|
|
+ vm.onSearch()
|
|
|
+ await flushPromises()
|
|
|
+ expect(vm.articles).toEqual([])
|
|
|
+ expect(vm.noMore).toBe(true)
|
|
|
+ expect(vm.isSearchEmpty).toBe(false)
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// 契约 C6:互斥重置 —— 进入搜索态清当前 Tab 回全量;切换 Tab 清搜索关键词与焦点
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+describe('契约C6 index.vue:搜索与标签筛选互斥重置', function () {
|
|
|
+ test('onTabChange 清空 searchKeyword/searchFocused,请求体带 tag 且不带 keyword', async function () {
|
|
|
+ var vm = instantiate(getComponent())
|
|
|
+ vm.currentChildId = null
|
|
|
+ vm.searchKeyword = '睡眠'
|
|
|
+ vm.searchFocused = true
|
|
|
+ vm.onTabChange('减脂')
|
|
|
+ expect(vm.currentTab).toBe('减脂')
|
|
|
+ expect(vm.searchKeyword).toBe('')
|
|
|
+ expect(vm.searchFocused).toBe(false)
|
|
|
+ await flushPromises()
|
|
|
+ var params = stubs.getArticleList.mock.calls[0][0]
|
|
|
+ expect(params.tag).toBe('减脂')
|
|
|
+ expect(params.keyword).toBeUndefined()
|
|
|
+ })
|
|
|
+
|
|
|
+ test('focusSearch 进入搜索态:清当前 Tab 回全量并重拉全量列表(无 tag)', async function () {
|
|
|
+ var vm = instantiate(getComponent())
|
|
|
+ vm.currentChildId = null
|
|
|
+ vm.currentTab = '减脂'
|
|
|
+ vm.focusSearch()
|
|
|
+ expect(vm.searchFocused).toBe(true)
|
|
|
+ expect(vm.currentTab).toBe('')
|
|
|
+ expect(vm.page).toBe(1)
|
|
|
+ expect(vm.articles).toEqual([])
|
|
|
+ await flushPromises()
|
|
|
+ var params = stubs.getArticleList.mock.calls[0][0]
|
|
|
+ expect(params.tag).toBeUndefined()
|
|
|
+ })
|
|
|
+
|
|
|
+ test('focusSearch 已在全量时不重复重拉列表', function () {
|
|
|
+ var vm = instantiate(getComponent())
|
|
|
+ vm.currentChildId = null
|
|
|
+ vm.currentTab = ''
|
|
|
+ stubs.getArticleList.mockClear()
|
|
|
+ vm.focusSearch()
|
|
|
+ expect(vm.searchFocused).toBe(true)
|
|
|
+ expect(vm.currentTab).toBe('')
|
|
|
+ expect(stubs.getArticleList).not.toHaveBeenCalled()
|
|
|
+ })
|
|
|
+
|
|
|
+ test('标签态发起搜索(onSearch)→ currentTab 清空回全量,请求体无 tag 只带 keyword', async function () {
|
|
|
+ stubs.getArticleList.mockResolvedValue({ code: 200, message: 'ok', data: { records: [{ id: 7, title: '睡眠' }], total: 1 } })
|
|
|
+ var vm = instantiate(getComponent())
|
|
|
+ vm.currentChildId = null
|
|
|
+ vm.currentTab = '减脂'
|
|
|
+ vm.searchKeyword = '睡眠'
|
|
|
+ vm.onSearch()
|
|
|
+ expect(vm.currentTab).toBe('')
|
|
|
+ await flushPromises()
|
|
|
+ var params = stubs.getArticleList.mock.calls[0][0]
|
|
|
+ expect(params.keyword).toBe('睡眠')
|
|
|
+ expect(params.tag).toBeUndefined()
|
|
|
+ })
|
|
|
+})
|