docs(tabbar test): 补全测试文档与场景注释

重构并完善了TabBar高亮测试文件的注释文档:
1.  新增完整的测试说明文档,包含测试原则、核心逻辑、术语表、调试方法和场景索引
2.  为每个测试场景补充了对应的设计决策和业务规则注释
3.  新增边界测试踩坑记录和单元测试说明
4.  新增测试模板和调试指南,方便后续新增测试用例
This commit is contained in:
2026-07-16 13:53:08 +08:00
parent 88024cbd11
commit 29e96a9357

View File

@@ -1,13 +1,124 @@
/** /**
* TabBar 高亮 — 行为测试 (按用户故事组织) * TabBar 高亮 — 行为测试 (按用户故事组织)
* ============================================================================
* *
* 原则: * ## 这是什么?
* - 每个测试都是一个真实的用户故事, 不是为了能测试过而迎合代码 *
* - 测试 input → 行为, 不测试代码结构 ("wiring 测试" 已删除) * 验证自定义 TabBar 在各种跳转场景下, 哪个 tab 应该高亮.
* - 失败的测试 = 真实用户能看到的高亮错误 * 失败的测试 = 真实用户能看到的高亮错误.
*
* ## 跑法
* *
* 跑法 (需要 Node 18+):
* node --test tests/tabbar-highlight.test.js * 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'; 'use strict';
@@ -171,6 +282,28 @@ function assertHighlight(actual, expected, label) {
// ============================================================= // =============================================================
// 场景 1: 跨包导航 — "从哪个 tab 出发, 非 tab 页就归属哪个 tab" // 场景 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 链接 → 首页保持高亮', () => { test('场景 1.1: 用户从首页点 diy 链接 → 首页保持高亮', () => {
@@ -214,6 +347,24 @@ test('场景 1.5: 经过 login 弹窗后, 仍记为来自原 tab', () => {
// ============================================================= // =============================================================
// 场景 2: 同包导航 — 用户在当前包内浏览, 高亮当前包的 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 属于分类频道', () => { test('场景 2.1: /pages_goods/list → /pages_goods/detail 属于分类频道', () => {
@@ -326,6 +477,21 @@ test('场景 4.6: 分类 tab → 商品详情 → 分类保持高亮 (PAGE_TAB_M
// ============================================================= // =============================================================
// 场景 5: 显式 PAGE_TAB_MAP 优先级 // 场景 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)', () => { test('场景 5.1: /pages_tool/files/list 显式绑 null → 不点亮 (业务规则: 文件页不归属任何 tab)', () => {
@@ -399,6 +565,23 @@ test('场景 6.5: 分享 fenxiao 链接, 朋友点开 → 不高亮 (不在任
// ============================================================= // =============================================================
// 场景 7: 全屏 modal 流程 — login / pay / webview // 场景 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)', () => { test('场景 7.1: 在 home 点登录 → 全屏不点亮 (NO_TAB_PAGES)', () => {
@@ -544,6 +727,30 @@ test('场景 10.2: 同场景下 (current + history), 高亮结果应一致 (幂
// ============================================================= // =============================================================
// 场景 11: 边界 — 输入异常时不能崩 // 场景 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 都不亮', () => { test('场景 11.1: tabBarList 为空 (服务端配置异常) → 任何 tab 都不亮', () => {
@@ -642,6 +849,18 @@ test('场景 12.3: 登出后, 旧 packageEntryTab 保持 (惰性, 下次 updateC
// ============================================================= // =============================================================
// 场景 13: 核心单元测试 (resolveEntryTab 5 步 + lookupPageTabMap) // 场景 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', () => { test('单元 13.1: 当前是 tab 根 → 走 step 1 精确匹配, 不查 history', () => {
@@ -734,6 +953,18 @@ test('兜底 14.2: 当前页面在 NO_TAB_PAGES → 任何 tab 都不亮', () =>
// ============================================================= // =============================================================
// 场景 15: 核心 8 场景对照表 (业务高频路径) // 场景 15: 核心 8 场景对照表 (业务高频路径)
/* ------------------------------------------------------------------
* 这是"业务冒烟测试", 覆盖 80% 用户实际会遇到的导航场景.
*
* 任何 TabBar 相关 PR 都要先跑过这一组, 才能 merge.
*
* 场景选择标准:
* 1. 业务高频 (每天 > 1000 用户会遇到)
* 2. 跨多个 step (覆盖整个 5 步逻辑)
* 3. 回归影响面广 (改一处会影响多个)
*
* 新增业务场景时, 应该先加在这里 (8 → 12), 然后再考虑加细节.
* ------------------------------------------------------------------ */
// ============================================================= // =============================================================
const SCENARIOS = [ const SCENARIOS = [
@@ -833,6 +1064,31 @@ test('场景 17.1: tabBarList 异步加载场景 (文档化行为)', () => {
// ============================================================= // =============================================================
// 场景 18: P1-4 调试开关 (__TABBAR_DEBUG__) // 场景 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', () => { test('场景 18.1: __TABBAR_DEBUG__ 开启时, 决策路径有 console.log', () => {
@@ -1212,4 +1468,68 @@ test('场景 23.3: tabBarList 从 null 恢复 (用户等了很久后端才返回
tabBarList: STANDARD_TABBAR, tabBarList: STANDARD_TABBAR,
}); });
assert.equal(r2, HOME, 'tabBarList 恢复后: HOME'); assert.equal(r2, HOME, 'tabBarList 恢复后: HOME');
}); });
// =============================================================
// 模板: 如何加新测试
// =============================================================
//
// 场景 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)
//
// =============================================================