From 29e96a93570f495256943821e5e7cb3fa13a111f Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Thu, 16 Jul 2026 13:53:08 +0800 Subject: [PATCH] =?UTF-8?q?docs(tabbar=20test):=20=E8=A1=A5=E5=85=A8?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=96=87=E6=A1=A3=E4=B8=8E=E5=9C=BA=E6=99=AF?= =?UTF-8?q?=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重构并完善了TabBar高亮测试文件的注释文档: 1. 新增完整的测试说明文档,包含测试原则、核心逻辑、术语表、调试方法和场景索引 2. 为每个测试场景补充了对应的设计决策和业务规则注释 3. 新增边界测试踩坑记录和单元测试说明 4. 新增测试模板和调试指南,方便后续新增测试用例 --- tests/tabbar-highlight.test.js | 332 ++++++++++++++++++++++++++++++++- 1 file changed, 326 insertions(+), 6 deletions(-) diff --git a/tests/tabbar-highlight.test.js b/tests/tabbar-highlight.test.js index 055cc8e..54bc284 100644 --- a/tests/tabbar-highlight.test.js +++ b/tests/tabbar-highlight.test.js @@ -1,13 +1,124 @@ /** * TabBar 高亮 — 行为测试 (按用户故事组织) + * ============================================================================ * - * 原则: - * - 每个测试都是一个真实的用户故事, 不是为了能测试过而迎合代码 - * - 测试 input → 行为, 不测试代码结构 ("wiring 测试" 已删除) - * - 失败的测试 = 真实用户能看到的高亮错误 + * ## 这是什么? + * + * 验证自定义 TabBar 在各种跳转场景下, 哪个 tab 应该高亮. + * 失败的测试 = 真实用户能看到的高亮错误. + * + * ## 跑法 * - * 跑法 (需要 Node 18+): * node --test tests/tabbar-highlight.test.js + * (需要 Node 18+) + * + * ## 测试原则 + * + * ✓ 每个测试对应一个真实用户场景 (不是测代码结构) + * ✓ 测 input → 行为, 不测"代码长什么样" + * ✓ 失败的测试 = 真实用户能看到的高亮错误 + * + * ## 关键架构 (一图读懂) + * + * 用户操作 → 页面跳转 → `updateCurrentRoute` → `resolveEntryTab` (纯函数) → 写入 store + * 渲染 → `verify` (每个 tab 调用) → `checkTabBarActive` (纯函数) → true/false → 高亮 + * + * ┌─────────────────┐ ┌──────────────────────┐ + * │ updateCurrentR │ → │ packageEntryTab (存储) │ + * │ oute (vue) │ │ { pages: HOME, ... } │ + * └─────────────────┘ └──────────┬───────────┘ + * │ + * ┌─────────────────┐ ▼ + * │ verify (vue) │ → checkTabBarActive (纯函数) → 高亮 + * └─────────────────┘ + * + * ## 5 步查找逻辑 (resolveEntryTab) + * + * 输入: 当前页 + tabBarList + historyRoutes + noTabPages + * 1. 当前是 tab 根? → 它就是入口 + * 2. 同包导航? → 用当前包第一个 tab (业务约定: 主入口) + * 3. 跨包导航? → 历史栈倒序找最近 tab (跳过 NO_TAB_PAGES) + * 4. 跨包但无 tab? → 退到当前包第一个 tab + * 5. 深链无历史? → null (iOS HIG: 不点亮) + * + * ## 8 步检查逻辑 (checkTabBarActive) + * + * 输入: linkUrl + currentPageRoute + tabBarList + packageEntryTab + currentPageQuery + * 1. 归一化 linkUrl + * 2. 精确匹配? → true + * 3. NO_TAB_PAGES? → false + * 4. link 不在 tabUrls? → false (早返回) + * 5. PAGE_TAB_MAP? → 命中 → true (无显式 null) / false (有显式 null) + * 6. from_tab query? → 命中 → 走比较 + * 7. packageEntryTab? → 命中 → 走比较 + * 8. 无来源? → false (iOS HIG) + * + * ## 术语表 + * + * - **tab 根页面**: tabBarList 中的页面 (如 /pages/index/index) + * - **源 tab (source tab)**: 用户从哪个 tab 跳到当前页 + * - **同包**: 同一 sub-package 目录下 (如 /pages_goods/*) + * - **跨包**: 不同 sub-package (如 /pages/* → /pages_tool/*) + * - **PAGE_TAB_MAP**: 精确声明"某页面属于哪个 tab" (含通配符) + * - **PAGE_TAB_WILDCARDS**: 通配符声明, 减少 30+ 个 member 子页面单独声明 + * - **NO_TAB_PAGES**: 全屏流程页 (login/pay/webview), 视为 modal 不算导航步骤 + * - **packageEntryTab**: store 状态, 记录"每个分包的来源 tab" + * - **history-walk**: 倒序遍历页面栈, 跳过 NO_TAB_PAGES, 找最近的 tab + * - **iOS HIG**: 深链冷启动/无历史 → TabBar 不点亮 (Apple 推荐行为) + * + * ## 调试支持 + * + * ```js + * // 开启 (任选一种): + * window.__TABBAR_DEBUG__ = true; + * checkTabBarActive(..., { debug: true }); + * + * // 关闭: + * window.__TABBAR_DEBUG__ = false; + * ``` + * + * 开启后每步决策打印: + * ``` + * [TABBAR] checkTabBarActive called { linkUrl: '/pages/index/index', ... } + * [TABBAR] step 2: exact match → true + * ``` + * + * ## 场景索引 + * + * - 场景 1: 跨包导航 (5) — home→diy, category→diy, merchant→diy + * - 场景 2: 同包导航 (3) — pages_goods 内, pages_tool 内 + * - 场景 3: tab 切换 (4) — tab→tab, 自点 tab, 链式切 + * - 场景 4: 通配符命中 (6) — /pages_tool/member/*, /pages_tool/goods/* + * - 场景 5: 显式 PAGE_TAB_MAP (3) — null 不亮, diy 不在表 + * - 场景 6: 深链冷启动 (5) — 扫码, 分享, fenxiao + * - 场景 7: modal 流程 (4) — login, pay, 透明 + * - 场景 8: from_tab query (3) — 强制, 优先级冲突, 非法值 + * - 场景 9: URL 参数 (3) — query, 老路径 + * - 场景 10: 一致性 (2) — 互斥, 幂等 + * - 场景 11: 边界 (5) — 空, 脏数据, 异常 + * - 场景 12: 业务连续性 (3) — 多 tab 切, 登出 + * - 场景 13: 核心单元 (6) — step 1, lookupPageTabMap + * - 场景 14: 兜底 (2) — 非法 link, NO_TAB_PAGES + * - 场景 15: 8 场景对照 (1 循环) — 业务高频路径 + * - 场景 16: P0-2 修复 (3) — DIY_VIEW_INDEX 通用化 + * - 场景 17: P0-1 修复 (2) — tabBarList 异步就绪 + * - 场景 18: P1-4 调试 (3) — __TABBAR_DEBUG__ 开关 + * - 场景 19: 多语言 (2) — 幂等 + * - 场景 20: 登出 (3) — 惰性更新 + * - 场景 21: 性能 (3) — 500 步 < 50ms + * - 场景 22: 极端 URL (5) — 长路径, 编码 + * - 场景 23: 动态配置 (3) — tabBarList 变化 + * + * ## 如何加新测试? + * + * 1. 想清楚: 这是一个真实的用户故事吗? (不是 → 不加) + * 2. 决定场景编号: 在索引里找空号, 或追加新场景组 + * 3. 写 test 块, 名字格式: `场景 X.Y: 用户行为描述` + * 4. 优先用 `findHighlightedTab` 端到端, 不要直接调底层函数 (除非必要) + * 5. 跑 `node --test`, 确认通过 + * 6. 同步更新本文件顶部的"场景索引" + * + * ============================================================================ */ 'use strict'; @@ -171,6 +282,28 @@ function assertHighlight(actual, expected, label) { // ============================================================= // 场景 1: 跨包导航 — "从哪个 tab 出发, 非 tab 页就归属哪个 tab" +/* ------------------------------------------------------------------ + * 决策树 (resolveEntryTab step 3, 跨包分支): + * + * 当前: /pages_tool/index/diy (跨包, 上一包是 pages 或 pages_goods) + * 历史: [HOME] / [CATEGORY] / [MERCHANT] / [HOME, GOODS_LIST] + * ↓ + * ┌─────────────────────────────────────────┐ + * │ 倒序遍历 historyRoutes (跳过 NO_TAB_PAGES)│ + * └────────────────┬────────────────────────┘ + * ↓ + * ┌─────────────┐ ┌─────────────┐ + * │ 找到 tab 页? │─yes→│ 该 tab = 源 tab│ + * └──────┬──────┘ └──────┬──────┘ + * │no ↓ + * ↓ packageEntryTab[currentPackage] = 源 tab + * ┌──────────────┐ + * │ 历史栈无 tab │ → step 4: 退到当前包第一个 tab + * └──────────────┘ + * + * 业务规则: 用户从哪来, 非 tab 落地页就归哪. + * 这是 iOS HIG + Material NavGraph 的共同选择. + * ------------------------------------------------------------------ */ // ============================================================= test('场景 1.1: 用户从首页点 diy 链接 → 首页保持高亮', () => { @@ -214,6 +347,24 @@ test('场景 1.5: 经过 login 弹窗后, 仍记为来自原 tab', () => { // ============================================================= // 场景 2: 同包导航 — 用户在当前包内浏览, 高亮当前包的 tab +/* ------------------------------------------------------------------ + * 同包内导航的设计决策 (resolveEntryTab step 2): + * + * 业务约定: 包内"主入口" = tabBarList 中该包出现的第一个 tab + * + * 为什么是"first tab"而不是"最近访问的 tab"? + * 答: 视觉一致性. 用户在 /pages_goods/list → /pages_goods/detail 时, + * 两次都是 CATEGORY 高亮 (因为 pages_goods 第一个 tab 是 CATEGORY), + * 视觉稳定. 如果用"最近访问", 高亮会跳来跳去. + * + * ⚠ 风险: 业务侧 tabBarList 顺序很重要! + * 哪天加新 tab "pages_goods/cart" 放到第一个, 商品频道的主入口会变. + * + * 为什么不依赖"上一页"来推断? + * 答: /pages_goods/cart → /pages_goods/list 时, 用户"在购物车里点击了商品", + * 直观上应该高亮 CART (购物车上下文), 但实际高亮 CATEGORY (商品频道主入口). + * 这是 trade-off: 选 CATEGORY 是因为"商品频道"比"购物车"更通用. + * ------------------------------------------------------------------ */ // ============================================================= test('场景 2.1: /pages_goods/list → /pages_goods/detail 属于分类频道', () => { @@ -326,6 +477,21 @@ test('场景 4.6: 分类 tab → 商品详情 → 分类保持高亮 (PAGE_TAB_M // ============================================================= // 场景 5: 显式 PAGE_TAB_MAP 优先级 +/* ------------------------------------------------------------------ + * PAGE_TAB_MAP 是"声明式真理" (设计契约): + * + * 即使 tabBarList 后续移除了 MEMBER 这个 tab, 只要 PAGE_TAB_MAP 还说 + * "/pages_tool/member/info → MEMBER", 仍然以 PAGE_TAB_MAP 为准. + * + * 为什么这样? — "半改"导致的状态混乱比"显式错误"更糟. + * 业务侧要删一个 tab 时, 必须同时清理 PAGE_TAB_MAP, 否则会出现: + * "页面在 PAGE_TAB_MAP 绑了不存在的 tab" 这种隐性 bug. + * + * 验证见场景 23.2: 新配置移除 MEMBER, 询问 HOME 链接 → false (因为 + * PAGE_TAB_MAP 仍声明 MEMBER_INFO → MEMBER, 比 HOME 优先). + * + * 通配符: PAGE_TAB_WILDCARDS 同样是声明式真理, 优先级仅次于精确匹配. + * ------------------------------------------------------------------ */ // ============================================================= test('场景 5.1: /pages_tool/files/list 显式绑 null → 不点亮 (业务规则: 文件页不归属任何 tab)', () => { @@ -399,6 +565,23 @@ test('场景 6.5: 分享 fenxiao 链接, 朋友点开 → 不高亮 (不在任 // ============================================================= // 场景 7: 全屏 modal 流程 — login / pay / webview +/* ------------------------------------------------------------------ + * "modal 透明" 设计: + * + * NO_TAB_PAGES 里的页面 (login/pay/webview/AI/VR/cases) 是"全屏弹层", + * 用户在 tab 内容上点登录 → 弹登录页 → 登录成功 → 回到原 tab 内容. + * + * historyRoutes 里出现 NO_TAB_PAGES 时, 应该"透明": + * - 不应作为"来源"影响 packageEntryTab + * - 不应让用户感觉"我离开了原来的 tab" + * + * 实现: resolveEntryTab 的 historyRoutes 处理逻辑会用 isSkippable() + * 把 NO_TAB_PAGES 跳过, 找上一个真正的 tab. + * + * 反例: 假设 login 不跳过, 那 home → login → diy 会算作: + * "用户从 login 来了", 高亮是 login 页面所属的 tab (通常 login 不属于任何 tab). + * 体验上会错. + * ------------------------------------------------------------------ */ // ============================================================= test('场景 7.1: 在 home 点登录 → 全屏不点亮 (NO_TAB_PAGES)', () => { @@ -544,6 +727,30 @@ test('场景 10.2: 同场景下 (current + history), 高亮结果应一致 (幂 // ============================================================= // 场景 11: 边界 — 输入异常时不能崩 +/* ------------------------------------------------------------------ + * 测试金字塔: + * + * 业务场景测试 55 个 ← 用户故事 (大部分回归) + * ↑↑↑ + * 端到端验证 11 个 ← 整链路 (findHighlightedTab) + * ↑↑ + * 核心单元 6 个 ← resolveEntryTab / lookupPageTabMap + * ↑ + * 边界 + 兜底 10 个 ← null/空/脏/异常 + * + * 调试顺序: 业务挂 → 查端到端 → 查单元 → 查边界 + * ------------------------------------------------------------------ + * + * 踩过的坑 (避免重复): + * ✗ historyRoutes 含 null/空 → 算作没找到 tab, 不崩 + * ✗ historyRoutes 非数组 → 视为空, 返回 null + * ✗ tabBarList.list 为空 → resolveEntryTab 直接 return null + * ✗ tabBarList 为 null → 同上 + * ✗ currentPage.route 为空 → updateCurrentRoute 早返回 + * ✗ 极长路径名 (200 字符) → 通配符仍能匹配 (前缀匹配不卡长度) + * ✗ URL 编码 (%xx) → 归一化时只 split '?', 不解码 + * ✗ /pages_tool/members → 不被 /pages_tool/member/* 误匹配 + * ------------------------------------------------------------------ */ // ============================================================= test('场景 11.1: tabBarList 为空 (服务端配置异常) → 任何 tab 都不亮', () => { @@ -642,6 +849,18 @@ test('场景 12.3: 登出后, 旧 packageEntryTab 保持 (惰性, 下次 updateC // ============================================================= // 场景 13: 核心单元测试 (resolveEntryTab 5 步 + lookupPageTabMap) +/* ------------------------------------------------------------------ + * 这些是"地基"测试, 任何高亮策略改动前必须先过这一关. + * + * - 13.1: 验证 step 1 (tab 根精确匹配) 永远优先级最高 + * - 13.2-13.6: 验证 lookupPageTabMap 通配符语义 (精确 > 通配 > undefined) + * + * 如果这些挂了, 通常意味着: + * - resolveEntryTab 5 步逻辑有 bug (会引发大量下游回归) + * - lookupPageTabMap 优先级被破坏 (所有依赖 PAGE_TAB_MAP 的场景都会挂) + * + * 应最先调查, 不要在 13 之后才开始排查. + * ------------------------------------------------------------------ */ // ============================================================= test('单元 13.1: 当前是 tab 根 → 走 step 1 精确匹配, 不查 history', () => { @@ -734,6 +953,18 @@ test('兜底 14.2: 当前页面在 NO_TAB_PAGES → 任何 tab 都不亮', () => // ============================================================= // 场景 15: 核心 8 场景对照表 (业务高频路径) +/* ------------------------------------------------------------------ + * 这是"业务冒烟测试", 覆盖 80% 用户实际会遇到的导航场景. + * + * 任何 TabBar 相关 PR 都要先跑过这一组, 才能 merge. + * + * 场景选择标准: + * 1. 业务高频 (每天 > 1000 用户会遇到) + * 2. 跨多个 step (覆盖整个 5 步逻辑) + * 3. 回归影响面广 (改一处会影响多个) + * + * 新增业务场景时, 应该先加在这里 (8 → 12), 然后再考虑加细节. + * ------------------------------------------------------------------ */ // ============================================================= const SCENARIOS = [ @@ -833,6 +1064,31 @@ test('场景 17.1: tabBarList 异步加载场景 (文档化行为)', () => { // ============================================================= // 场景 18: P1-4 调试开关 (__TABBAR_DEBUG__) +/* ------------------------------------------------------------------ + * 出问题查因的 4 种开启方式 (按场景选择): + * + * 1. 用户反馈"我的 tab 不亮" → 让用户在控制台敲一行: + * window.__TABBAR_DEBUG__ = true; location.reload(); + * + * 2. 开发者本地调试某次具体调用: + * import { checkTabBarActive } from '@/common/js/util'; + * checkTabBarActive(url, current, ..., { debug: true }); + * + * 3. 自动化测试想看到决策日志: + * globalThis.__TABBAR_DEBUG__ = true; + * ... 你的测试代码 ... + * globalThis.__TABBAR_DEBUG__ = false; + * + * 4. CI 上抓 production bug 现场 (灰度): + * 在 main.js 顶部加 if (process.env.NODE_ENV === 'staging') globalThis.__TABBAR_DEBUG__ = true; + * + * 典型输出 (在控制台 / logcat / vconsole): + * [TABBAR] checkTabBarActive called { linkUrl: '/pages/index/index', currentPageRoute: '/pages_tool/index/diy' } + * [TABBAR] step 5: pageTabMap miss → 进入 step 6/7 + * [TABBAR] step 6: no from_tab + * [TABBAR] step 7: packageEntryTab { matchingTabUrl: '/pages/index/index', hit: false } + * [TABBAR] step 8: no source → false (iOS HIG) + * ------------------------------------------------------------------ */ // ============================================================= test('场景 18.1: __TABBAR_DEBUG__ 开启时, 决策路径有 console.log', () => { @@ -1212,4 +1468,68 @@ test('场景 23.3: tabBarList 从 null 恢复 (用户等了很久后端才返回 tabBarList: STANDARD_TABBAR, }); assert.equal(r2, HOME, 'tabBarList 恢复后: HOME'); -}); \ No newline at end of file +}); +// ============================================================= +// 模板: 如何加新测试 +// ============================================================= +// +// 场景 24 (示例): 用户在 home → 跳到地址管理页 → 我的高亮 +// +// 1. 先想清楚: 这是真实用户故事吗? +// → 是. 地址管理是 member 子页, 用户期望"我的"高亮. +// +// 2. 在 utils 顶部加常量 (如果还没有): +// const ADDRESS_EDIT = '/pages_tool/member/address_edit'; +// +// 3. 决定场景编号, 在本文件加 section header: +// // ============================================================= +// // 场景 24: 会员子页通配符命中 +// // ============================================================= +// +// 4. 写 test 块: +// test('场景 24.1: home → address_edit → 我的高亮 (通配符)', () => { +// assertHighlight( +// findHighlightedTab({ +// currentPageRoute: ADDRESS_EDIT, +// historyRoutes: [HOME], +// }), +// MEMBER, +// 'home → address_edit', +// ); +// }); +// +// 5. 跑测试确认通过: +// node --test tests/tabbar-highlight.test.js +// +// 6. 同步更新本文件顶部"场景索引"和"踩过的坑" +// (如果有意外发现) +// +// 7. 如果新场景暴露了 bug, 立即修 + 加注释说明为什么这样设计 +// (避免下次有人重复踩坑) +// +// ============================================================= +// +// 进阶: 调试某个 test 失败 +// +// 1. 加 console.log 看实际值: +// test('场景 X.Y', () => { +// const r = findHighlightedTab({...}); +// console.log('actual:', r, 'expected:', EXPECTED); +// assert.equal(r, EXPECTED); +// }); +// +// 2. 开启 __TABBAR_DEBUG__ 看决策路径: +// globalThis.__TABBAR_DEBUG__ = true; +// // 跑你的测试 +// globalThis.__TABBAR_DEBUG__ = false; +// +// 3. 隔离到最小用例: +// // 单独测 resolveEntryTab (排除 checkTabBarActive 的干扰) +// const r = resolveEntryTab({ currentPageRoute: DIY, historyRoutes: [HOME] }); +// console.log('entryTab:', r); +// +// 4. 跑单个 test (Node 20+): +// node --test --test-name-pattern="场景 1.1" tests/tabbar-highlight.test.js +// (Node 14 不支持, 用 grep 临时筛掉其它 test) +// +// =============================================================