/** * TabBar 高亮 — 行为测试 (按用户故事组织) * ============================================================================ * * ## 这是什么? * * 验证自定义 TabBar 在各种跳转场景下, 哪个 tab 应该高亮. * 失败的测试 = 真实用户能看到的高亮错误. * * ## 跑法 * * 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'; const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); // ============================================================= // 加载被测源码 (ESM → CommonJS 沙箱) // ============================================================= const TABBAR_SRC = fs.readFileSync( path.join(__dirname, '..', 'common', 'js', 'util-tabbar.js'), 'utf8', ); function loadTabbarInSandbox() { const moduleObj = { exports: {} }; const wrapped = TABBAR_SRC .replace(/^export\s+function\s+/gm, 'function ') .replace(/^export\s+const\s+/gm, 'const ') .replace(/^export\s+let\s+/gm, 'let ') .replace(/^export\s+var\s+/gm, 'var ') + '\nmodule.exports = { resolvePageUrl, isPageInRouteList, resolveLinkUrl, ' + 'makeSystemTabBarList, adaptSubpackageUrl, isTabBarRootPage, ' + 'isCurrentPageTabBarRoot, checkTabBarActive, resolveEntryTab, lookupPageTabMap };\n'; const sandbox = { console, globalThis: {}, module: moduleObj, exports: moduleObj.exports }; sandbox.globalThis = sandbox; vm.createContext(sandbox); vm.runInContext(wrapped, sandbox, { filename: 'util-tabbar.js' }); return sandbox.module.exports; } const { resolveEntryTab, checkTabBarActive, lookupPageTabMap } = loadTabbarInSandbox(); // ============================================================= // 业务测试夹具 // ============================================================= // 5 个 Tab + 5 个分包的标准布局 const HOME = '/pages/index/index'; const CATEGORY = '/pages_goods/category'; const CART = '/pages_goods/cart'; const MERCHANT = '/pages_promotion/merch/merchcategory'; const MEMBER = '/pages_tool/member/index'; // 常见非 tab 落地页 const DIY = '/pages_tool/index/diy'; const MEMBER_INFO = '/pages_tool/member/info'; const MEMBER_COUPON = '/pages_tool/member/coupon'; const MEMBER_BALANCE = '/pages_tool/member/balance'; const GOODS_LIST = '/pages_goods/list'; const GOODS_DETAIL = '/pages_goods/detail'; const SEARCH = '/pages_tool/goods/search'; const BRAND = '/pages_tool/goods/brand'; const COUPON = '/pages_tool/goods/coupon'; const HELP_LIST = '/pages_tool/help/list'; const NOTICE_LIST = '/pages_tool/notice/list'; const FILES_LIST = '/pages_tool/files/list'; const FENXIAO = '/pages_promotion/fenxiao/index'; // 全屏流程 (modal 风格) const LOGIN = '/pages_tool/login/login'; const PAY = '/pages_tool/pay/index'; const WEBVIEW = '/pages_tool/webview/webview'; function makeTabBarList(tabs) { return { list: tabs.map((t, i) => ({ id: t.id || (i + 1), text: t.text || '', link: { wap_url: t.url }, })), }; } const STANDARD_TABBAR = makeTabBarList([ { id: 1, text: '首页', url: HOME }, { id: 2, text: '分类', url: CATEGORY }, { id: 3, text: '购物车', url: CART }, { id: 4, text: '商户', url: MERCHANT }, { id: 5, text: '我的', url: MEMBER }, ]); // PAGE_TAB_MAP: 显式声明的页面归属 (精确 + null) const PAGE_TAB_MAP = { // 首页频道 [HOME]: HOME, '/pages/empty/index': HOME, [HELP_LIST]: HOME, [NOTICE_LIST]: HOME, // 分类频道 — tab 根 [CATEGORY]: CATEGORY, [GOODS_LIST]: CATEGORY, [GOODS_DETAIL]: CATEGORY, // 商户频道 [MERCHANT]: MERCHANT, '/pages_promotion/merch/detail': MERCHANT, // 购物车 [CART]: CART, // 我的频道 — tab 根 [MEMBER]: MEMBER, '/pages_order/list': MEMBER, '/pages_tool/recharge/list': MEMBER, '/pages_tool/recharge/order_list': MEMBER, // 显式"不点亮" [FILES_LIST]: null, }; // 通配符 (按子树批量归属, 减少维护成本) const PAGE_TAB_WILDCARDS = [ { pattern: '/pages_tool/member/*', tab: MEMBER }, { pattern: '/pages_tool/goods/*', tab: CATEGORY }, ]; // Modal / 全屏流程页 (在归属判定中被跳过, 视为透明) const NO_TAB_PAGES = new Set([ LOGIN, PAY, WEBVIEW, '/pages_tool/ai-chat/index', '/pages_tool/vr/index', '/pages_tool/cases/index', '/pages_tool/webview/webview', ]); /** * 端到端: 给定当前页面 + 历史栈, 算出哪个 tab 高亮 * @returns {string|null} tab URL 或 null */ function findHighlightedTab({ currentPageRoute, historyRoutes = [], tabBarList = STANDARD_TABBAR }) { const entryTab = resolveEntryTab({ currentPageRoute, tabBarList, historyRoutes, noTabPages: NO_TAB_PAGES, }); const currentPackageName = currentPageRoute.split('?')[0].split('/')[1] || ''; const packageEntryTab = entryTab ? { [currentPackageName]: entryTab } : {}; for (const tab of tabBarList.list) { if (!tab.link || !tab.link.wap_url) continue; if (checkTabBarActive( tab.link.wap_url, currentPageRoute, tabBarList, packageEntryTab, {}, { noTabPages: NO_TAB_PAGES, pageTabMap: PAGE_TAB_MAP, pageTabWildcards: PAGE_TAB_WILDCARDS }, )) { return tab.link.wap_url; } } return null; } function assertHighlight(actual, expected, label) { assert.equal(actual, expected, `[${label}] 期望 ${expected || '不点亮'}, 实际 ${actual || '不点亮'}`); } // ============================================================= // 场景 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 链接 → 首页保持高亮', () => { assertHighlight(findHighlightedTab({ currentPageRoute: DIY, historyRoutes: [HOME], }), HOME, 'home → diy'); }); test('场景 1.2: 用户从分类点 diy 链接 → 分类高亮', () => { assertHighlight(findHighlightedTab({ currentPageRoute: DIY, historyRoutes: [CATEGORY], }), CATEGORY, 'category → diy'); }); test('场景 1.3: 用户从商户 tab 进 diy 微页面 → 商户高亮', () => { assertHighlight(findHighlightedTab({ currentPageRoute: DIY, historyRoutes: [MERCHANT], }), MERCHANT, 'merchant → diy'); }); test('场景 1.4: 深层导航, 跳过非 tab 中间页', () => { // 用户从首页逛到商品列表, 再点 diy 链接 // (中间经过非 tab 的 goods_list) assertHighlight(findHighlightedTab({ currentPageRoute: DIY, historyRoutes: [HOME, GOODS_LIST], }), HOME, 'home → goods_list → diy'); }); test('场景 1.5: 经过 login 弹窗后, 仍记为来自原 tab', () => { // 用户在 home, 弹登录页, 登录成功回到 diy // login 是 modal, 不应该"洗掉"来源 tab assertHighlight(findHighlightedTab({ currentPageRoute: DIY, historyRoutes: [HOME, LOGIN], }), HOME, 'home → login → diy'); }); // ============================================================= // 场景 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 属于分类频道', () => { assertHighlight(findHighlightedTab({ currentPageRoute: GOODS_DETAIL, historyRoutes: [GOODS_LIST], }), CATEGORY, 'goods/list → detail'); }); test('场景 2.2: /pages_tool/member/coupon → /pages_tool/member/info 属于"我的"', () => { assertHighlight(findHighlightedTab({ currentPageRoute: MEMBER_INFO, historyRoutes: [MEMBER_COUPON], }), MEMBER, 'member/coupon → info'); }); test('场景 2.3: 在"我的"频道内多页浏览, 高亮始终是"我的"', () => { // home → member → coupon → info (都在 pages_tool 包) assertHighlight(findHighlightedTab({ currentPageRoute: MEMBER_INFO, historyRoutes: [HOME, MEMBER, MEMBER_COUPON], }), MEMBER, 'home → member → coupon → info'); }); // ============================================================= // 场景 3: tab 切换 — 用户点底部 tab 跳转 // ============================================================= test('场景 3.1: tab→tab 跳转, 目标 tab 高亮 (不受来源干扰)', () => { assertHighlight(findHighlightedTab({ currentPageRoute: CATEGORY, historyRoutes: [HOME], }), CATEGORY, 'home → category tab'); }); test('场景 3.2: 在 diy 页面点"我的" tab → 我的高亮 (同包)', () => { // 实际场景: diy 页面底部 TabBar 用户点"我的" // 走 switchTab/reLaunch, 栈变成 [MEMBER] // diy 的临时 packageEntryTab 会被 MEMBER 覆盖 assertHighlight(findHighlightedTab({ currentPageRoute: MEMBER, historyRoutes: [DIY], }), MEMBER, 'diy → member tab'); }); test('场景 3.3: 在 diy 页面点"首页" tab → 首页高亮 (跨包)', () => { assertHighlight(findHighlightedTab({ currentPageRoute: HOME, historyRoutes: [DIY], }), HOME, 'diy → home tab'); }); test('场景 3.4: 链式 tab 切换 home→分类→我的→购物车→首页', () => { // 模拟用户连续点 tab, 每次都更新高亮 const tabs = [HOME, CATEGORY, MEMBER, CART, HOME]; for (let i = 0; i < tabs.length; i++) { assertHighlight(findHighlightedTab({ currentPageRoute: tabs[i], historyRoutes: tabs.slice(0, i), }), tabs[i], `链式第 ${i + 1} 步: ${tabs[i]}`); } }); // ============================================================= // 场景 4: tab→tab 内部非 tab 页 — 通配符命中 // ============================================================= test('场景 4.1: 首页 → 商品搜索 → 分类高亮 (通配符 /pages_tool/goods/*)', () => { assertHighlight(findHighlightedTab({ currentPageRoute: SEARCH, historyRoutes: [HOME], }), CATEGORY, 'home → search (goods/* 通配符)'); }); test('场景 4.2: 首页 → 商品品牌页 → 分类高亮 (通配符)', () => { assertHighlight(findHighlightedTab({ currentPageRoute: BRAND, historyRoutes: [HOME], }), CATEGORY, 'home → brand (goods/* 通配符)'); }); test('场景 4.3: 首页 → 会员中心 → 我的高亮 (通配符 /pages_tool/member/*)', () => { assertHighlight(findHighlightedTab({ currentPageRoute: MEMBER_INFO, historyRoutes: [HOME], }), MEMBER, 'home → member/info (member/* 通配符)'); }); test('场景 4.4: 首页 → 优惠券 → 我的高亮 (通配符)', () => { assertHighlight(findHighlightedTab({ currentPageRoute: MEMBER_COUPON, historyRoutes: [HOME], }), MEMBER, 'home → member/coupon (member/* 通配符)'); }); test('场景 4.5: 首页 → 余额 → 我的高亮 (通配符)', () => { assertHighlight(findHighlightedTab({ currentPageRoute: MEMBER_BALANCE, historyRoutes: [HOME], }), MEMBER, 'home → member/balance (member/* 通配符)'); }); test('场景 4.6: 分类 tab → 商品详情 → 分类保持高亮 (PAGE_TAB_MAP 精确优先)', () => { // 即使来源是分类 tab, 商品详情仍在 PAGE_TAB_MAP 绑 CATEGORY assertHighlight(findHighlightedTab({ currentPageRoute: GOODS_DETAIL, historyRoutes: [CATEGORY], }), CATEGORY, 'category → goods/detail (PAGE_TAB_MAP wins)'); }); // ============================================================= // 场景 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)', () => { assertHighlight(findHighlightedTab({ currentPageRoute: FILES_LIST, historyRoutes: [HOME], }), null, 'home → files/list (null 优先)'); }); test('场景 5.2: /pages_tool/index/diy 不在 PAGE_TAB_MAP, 走 history-walk', () => { // diy 不在精确表也不在通配符, 走 history-walk // 从 home 来 → HOME 高亮 assertHighlight(findHighlightedTab({ currentPageRoute: DIY, historyRoutes: [HOME], }), HOME, 'diy 走 history-walk, home 高亮'); }); test('场景 5.3: diy 没在 PAGE_TAB_MAP 显式声明 (避免硬绑)', () => { // 反向验证: 修复后, diy 不在 PAGE_TAB_MAP, 完全靠 history-walk // 这是关键修复 — 之前 diy:HOME 导致深链误高亮 // 此测试防回归: 任何人重新加回 diy:HOME, 这个测试 + 行为测试都会挂 const currentMap = PAGE_TAB_MAP[DIY]; assert.equal(currentMap, undefined, 'diy 不应在 PAGE_TAB_MAP (修复后靠 history-walk 决定)'); }); // ============================================================= // 场景 6: 深链冷启动 (扫码/分享) — iOS HIG // ============================================================= test('场景 6.1: 用户扫码进 diy → 不高亮任何 tab (iOS HIG)', () => { // 用户从来没在任何 tab 页面, 没有来源记忆 assertHighlight(findHighlightedTab({ currentPageRoute: DIY, historyRoutes: [], }), null, '深链 diy'); }); test('场景 6.2: 分享 diy 链接给朋友, 朋友点开 → 不高亮 (无历史)', () => { assertHighlight(findHighlightedTab({ currentPageRoute: DIY + '?id=123', historyRoutes: [], }), null, '深链 diy?id=123'); }); test('场景 6.3: 分享商品详情链接, 朋友点开 → 分类高亮 (通配符)', () => { // 朋友点链接进入商品详情, 没有历史 // 商品详情在 PAGE_TAB_MAP 精确绑 CATEGORY → CATEGORY 高亮 assertHighlight(findHighlightedTab({ currentPageRoute: GOODS_DETAIL, historyRoutes: [], }), CATEGORY, '深链 goods/detail'); }); test('场景 6.4: 分享会员中心链接, 朋友点开 → 我的高亮 (精确绑)', () => { assertHighlight(findHighlightedTab({ currentPageRoute: MEMBER, historyRoutes: [], }), MEMBER, '深链 member (精确)'); }); test('场景 6.5: 分享 fenxiao 链接, 朋友点开 → 不高亮 (不在任何 map)', () => { // fenxiao 不在 PAGE_TAB_MAP, 不在通配符, 不在 tabBarList // iOS HIG: 不高亮 assertHighlight(findHighlightedTab({ currentPageRoute: FENXIAO, historyRoutes: [], }), null, '深链 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)', () => { assertHighlight(findHighlightedTab({ currentPageRoute: LOGIN, historyRoutes: [HOME], }), null, 'home → login (NO_TAB_PAGES)'); }); test('场景 7.2: 支付流程中 → 不点亮 (NO_TAB_PAGES)', () => { assertHighlight(findHighlightedTab({ currentPageRoute: PAY, historyRoutes: [HOME, GOODS_LIST], }), null, 'home → list → pay (NO_TAB_PAGES)'); }); test('场景 7.3: 登录后回到原页面, 高亮不丢失', () => { // 实际场景: 用户在 home, 弹登录, 登录成功, 跳到 diy // 期望: diy 仍归属 home (login 是 modal, 不算"离开" home) assertHighlight(findHighlightedTab({ currentPageRoute: DIY, historyRoutes: [HOME, LOGIN], }), HOME, 'home → login → diy (modal 透明)'); }); test('场景 7.4: 支付完成后回到原页面, 高亮跟随当前包', () => { // 用户: home → 进入商品区(逛购物车) → 支付 → 回到商品列表 // 实际栈: home → cart → pay → list // 跳过 pay (NO_TAB_PAGES), 上一非 NO_TAB_PAGES 是 cart (pages_goods) // 当前 list 在 pages_goods → 同包 → CATEGORY (商品频道的入口 tab) assertHighlight(findHighlightedTab({ currentPageRoute: GOODS_LIST, historyRoutes: [HOME, CART, PAY], }), CATEGORY, 'home → cart → pay → list (modal 透明 + 同包)'); }); // ============================================================= // 场景 8: from_tab query 机制 (旧机制, 仍在用) // ============================================================= test('场景 8.1: URL 带 from_tab=home → 强制高亮 home (即使当前在 pages_tool)', () => { // diy-bottom-nav.vue#redirectTo 在用户点 TabBar 时会写 from_tab query const result = checkTabBarActive( HOME, DIY, STANDARD_TABBAR, {}, { from_tab: HOME }, { noTabPages: NO_TAB_PAGES, pageTabMap: {}, pageTabWildcards: PAGE_TAB_WILDCARDS }, ); assert.equal(result, true, 'from_tab=home → HOME 高亮'); }); test('场景 8.2: from_tab=member 但 PAGE_TAB_MAP 已显式声明 → PAGE_TAB_MAP 优先', () => { // PAGE_TAB_MAP 显式声明的页面, from_tab 不能覆盖 // 这是设计: 显式声明 > 任何 hint const result = checkTabBarActive( MEMBER, DIY, STANDARD_TABBAR, {}, { from_tab: MEMBER }, { noTabPages: NO_TAB_PAGES, pageTabMap: { [DIY]: HOME }, pageTabWildcards: [] }, ); assert.equal(result, false, 'PAGE_TAB_MAP 优先于 from_tab'); }); test('场景 8.3: 非法 from_tab 值 → 忽略, 走后续链路', () => { // from_tab 不是任何 tab, 视为无效 const result = checkTabBarActive( HOME, DIY, STANDARD_TABBAR, {}, { from_tab: '/foo/bar' }, { noTabPages: NO_TAB_PAGES, pageTabMap: {}, pageTabWildcards: [] }, ); assert.equal(result, false, '非法 from_tab → 忽略, 无其它信号 → 不亮'); }); // ============================================================= // 场景 9: URL 参数不影响高亮判定 // ============================================================= test('场景 9.1: diy 带 query ?id=123&from=share → 行为不变', () => { assertHighlight(findHighlightedTab({ currentPageRoute: '/pages_tool/index/diy?id=123&from=share', historyRoutes: [HOME], }), HOME, 'home → diy?id=123&from=share'); }); test('场景 9.2: tab 根带 query 也精确匹配', () => { // 即使带 ?id=123, 也能识别是 home tab const result = resolveEntryTab({ currentPageRoute: '/pages/index/index?from=share', tabBarList: STANDARD_TABBAR, historyRoutes: [], }); assert.equal(result, HOME, 'home tab 根 + query'); }); test('场景 9.3: 老路径 /pages/member/index 自动适配到 /pages_tool/member/index', () => { // 兼容老 URL (旧业务数据) const result = resolveEntryTab({ currentPageRoute: DIY, tabBarList: STANDARD_TABBAR, historyRoutes: ['/pages/member/index'], }); assert.equal(result, MEMBER, '老路径自动 adaptSubpackageUrl'); }); // ============================================================= // 场景 10: 一致性 — 同时只有 1 个 tab 高亮 // ============================================================= test('场景 10.1: 5 个 tab 中, 同时只有 1 个高亮 (互斥)', () => { const results = STANDARD_TABBAR.list.map(t => checkTabBarActive( t.link.wap_url, DIY, STANDARD_TABBAR, { pages_tool: HOME }, {}, { noTabPages: NO_TAB_PAGES, pageTabMap: PAGE_TAB_MAP, pageTabWildcards: PAGE_TAB_WILDCARDS }, ) ); const trueCount = results.filter(Boolean).length; assert.equal(trueCount, 1, `5 个 tab 应只亮 1 个, 实际 ${trueCount} 个亮`); }); test('场景 10.2: 同场景下 (current + history), 高亮结果应一致 (幂等)', () => { // 同样的 input 调两次, 结果应一致 const opts = { currentPageRoute: DIY, historyRoutes: [HOME, GOODS_LIST], tabBarList: STANDARD_TABBAR, }; const r1 = resolveEntryTab(opts); const r2 = resolveEntryTab(opts); assert.equal(r1, r2, 'resolveEntryTab 幂等'); assert.equal(r1, HOME, '且结果应为 home'); }); // ============================================================= // 场景 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 都不亮', () => { // 即使 diy 在 PAGE_TAB_MAP, 没有 tab 也没法亮 assertHighlight(findHighlightedTab({ currentPageRoute: DIY, historyRoutes: [HOME], tabBarList: { list: [] }, }), null, 'tabBarList 空'); }); test('场景 11.2: tabBarList.list 含脏数据 (link 为 null/空) → 不崩', () => { const dirtyTabBar = { list: [ { id: 1, link: null }, { id: 2, link: {} }, { id: 3, text: '首页', link: { wap_url: HOME } }, { id: 4, text: '我的', link: { wap_url: MEMBER } }, ], }; const r = resolveEntryTab({ currentPageRoute: DIY, tabBarList: dirtyTabBar, historyRoutes: [HOME], }); assert.equal(r, HOME, '脏数据不影响查找'); }); test('场景 11.3: historyRoutes 含 null/空/无效值 → 安全跳过', () => { const r = resolveEntryTab({ currentPageRoute: DIY, tabBarList: STANDARD_TABBAR, historyRoutes: [null, '', undefined, HOME], }); assert.equal(r, HOME, 'historyRoutes 脏数据 → 跳过'); }); test('场景 11.4: historyRoutes 不是数组 → 视为无历史 (iOS HIG)', () => { // 防御性编程: 异常入参不能崩 // 行为: 当作无历史 → 走 iOS HIG (无历史不点亮) const r = resolveEntryTab({ currentPageRoute: DIY, tabBarList: STANDARD_TABBAR, historyRoutes: 'not-an-array', }); assert.equal(r, null, 'historyRoutes 非数组 → 视为空 → 不点亮'); }); test('场景 11.5: 当前页面是 /pages_goods/some-unmapped-page 深链 → 不亮', () => { // 业务上新加的商品页, 既不在 PAGE_TAB_MAP 也不在通配符 assertHighlight(findHighlightedTab({ currentPageRoute: '/pages_goods/some-unmapped-page', historyRoutes: [], }), null, '深链 unmapped 页'); }); // ============================================================= // 场景 12: 业务连续性 — packageEntryTab 惰性更新 // ============================================================= test('场景 12.1: 用户在 home → diy → 切到 member → 回 home → 进 diy, 仍归 home', () => { // 完整路径模拟: // stack=[home] (home 高亮) // stack=[home, diy] (home 保持) // stack=[member] (member 高亮, 弹栈) // stack=[home] (用户返回) // stack=[home, diy] (再次进 diy, 应仍归 home) // // 最后一步: 当前 diy, 历史 = [home] // 期望: HOME 高亮 (因为用户在 home 上下文再次进了 diy) assertHighlight(findHighlightedTab({ currentPageRoute: DIY, historyRoutes: [HOME], }), HOME, 'home → diy → member → 返回 → home → diy'); }); test('场景 12.2: 进入 cart tab 后, 旧 packageEntryTab 被新 tab 覆盖', () => { // 用户在 home → diy (写入 pages_tool → home) // 然后点 cart tab → 弹栈, 跳 CART // 跳 CART 后: pages_goods → CART (覆盖旧 entry) assertHighlight(findHighlightedTab({ currentPageRoute: CART, historyRoutes: [HOME], }), CART, 'home → diy → 点 cart tab → CART'); }); test('场景 12.3: 登出后, 旧 packageEntryTab 保持 (惰性, 下次 updateCurrentRoute 重算)', () => { // 登出不应清 packageEntryTab (故意惰性) // 下次进新页面时 updateCurrentRoute 会基于新栈重算, 自然覆盖 // 这里只验证行为: 登出后访问 diy, 仍按 history-walk 决定 assertHighlight(findHighlightedTab({ currentPageRoute: DIY, historyRoutes: [HOME], }), HOME, '登出后 diy 高亮仍由 history-walk 决定'); }); // ============================================================= // 场景 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', () => { assert.equal( resolveEntryTab({ currentPageRoute: CATEGORY, tabBarList: STANDARD_TABBAR, historyRoutes: [HOME, MEMBER] }), CATEGORY, 'category 是 tab 根, 不被 history 干扰', ); }); test('单元 13.2: lookupPageTabMap 精确匹配优先于通配符', () => { // /pages_tool/member/index 在精确表里是 MEMBER assert.equal( lookupPageTabMap(MEMBER, PAGE_TAB_MAP, PAGE_TAB_WILDCARDS), MEMBER, ); }); test('单元 13.3: lookupPageTabMap 通配符命中非精确条目', () => { assert.equal( lookupPageTabMap('/pages_tool/member/balance_detail', PAGE_TAB_MAP, PAGE_TAB_WILDCARDS), MEMBER, '通配符 /pages_tool/member/* → MEMBER', ); assert.equal( lookupPageTabMap('/pages_tool/goods/brand', PAGE_TAB_MAP, PAGE_TAB_WILDCARDS), CATEGORY, '通配符 /pages_tool/goods/* → CATEGORY', ); }); test('单元 13.4: lookupPageTabMap null 优先于通配符', () => { // 即使有 /pages_tool/files/* 通配符, files/list 显式 null 应优先 const customMap = { [FILES_LIST]: null }; const customWildcards = [{ pattern: '/pages_tool/files/*', tab: HOME }]; assert.equal( lookupPageTabMap(FILES_LIST, customMap, customWildcards), null, 'files/list 显式 null 优先于通配符', ); }); test('单元 13.5: lookupPageTabMap 无匹配 → undefined (走 history-walk)', () => { assert.equal( lookupPageTabMap(DIY, PAGE_TAB_MAP, PAGE_TAB_WILDCARDS), undefined, 'diy 不在任何表, 走 history-walk', ); }); test('单元 13.6: lookupPageTabMap 通配符不误匹配前缀相近路径', () => { // /pages_tool/members (复数) 不会被 /pages_tool/member/* 匹配 assert.equal( lookupPageTabMap('/pages_tool/members', PAGE_TAB_MAP, PAGE_TAB_WILDCARDS), undefined, '复数路径不匹配单数通配符', ); }); // ============================================================= // 场景 14: checkTabBarActive 兜底 (直接函数调用, 验证边界) // ============================================================= test('兜底 14.1: link URL 不在 tabBarList → 直接 false (早返回)', () => { // 询问一个非法 link URL const result = checkTabBarActive( '/pages/non-existent-tab', DIY, STANDARD_TABBAR, { pages_tool: HOME }, { from_tab: HOME }, { noTabPages: NO_TAB_PAGES, pageTabMap: PAGE_TAB_MAP, pageTabWildcards: PAGE_TAB_WILDCARDS }, ); assert.equal(result, false, '非法 link URL → false (无视所有信号)'); }); test('兜底 14.2: 当前页面在 NO_TAB_PAGES → 任何 tab 都不亮', () => { for (const tabUrl of [HOME, CATEGORY, CART, MERCHANT, MEMBER]) { const result = checkTabBarActive( tabUrl, LOGIN, STANDARD_TABBAR, { pages: HOME, pages_tool: MEMBER }, {}, { noTabPages: NO_TAB_PAGES, pageTabMap: PAGE_TAB_MAP, pageTabWildcards: PAGE_TAB_WILDCARDS }, ); assert.equal(result, false, `login 页面, ${tabUrl} 不该亮`); } }); // ============================================================= // 场景 15: 核心 8 场景对照表 (业务高频路径) /* ------------------------------------------------------------------ * 这是"业务冒烟测试", 覆盖 80% 用户实际会遇到的导航场景. * * 任何 TabBar 相关 PR 都要先跑过这一组, 才能 merge. * * 场景选择标准: * 1. 业务高频 (每天 > 1000 用户会遇到) * 2. 跨多个 step (覆盖整个 5 步逻辑) * 3. 回归影响面广 (改一处会影响多个) * * 新增业务场景时, 应该先加在这里 (8 → 12), 然后再考虑加细节. * ------------------------------------------------------------------ */ // ============================================================= const SCENARIOS = [ { label: '深链扫 diy', current: DIY, history: [], expected: null }, { label: '首页 → diy 链接', current: DIY, history: [HOME], expected: HOME }, { label: '首页 → 会员中心', current: MEMBER, history: [HOME], expected: MEMBER }, { label: '分类 → diy 链接', current: DIY, history: [CATEGORY], expected: CATEGORY }, { label: '分类 → 商品列表 → diy', current: DIY, history: [CATEGORY, GOODS_LIST], expected: CATEGORY }, { label: '我的 → 优惠券 → diy', current: DIY, history: [MEMBER, MEMBER_COUPON], expected: MEMBER }, { label: '首页 → 登录页 → diy', current: DIY, history: [HOME, LOGIN], expected: HOME }, { label: '深链扫 member/info', current: MEMBER_INFO, history: [], expected: MEMBER }, ]; test('场景对照: 8 个核心业务场景', () => { for (const sc of SCENARIOS) { assertHighlight( findHighlightedTab({ currentPageRoute: sc.current, historyRoutes: sc.history, }), sc.expected, sc.label, ); } }); // ============================================================= // 场景 16: P0 修复验证 — 通用化 DIY_VIEW_INDEX // ============================================================= test('场景 16.1: 在 home page (name=DIY_VIEW_INDEX), 自点 home tab → home 高亮', () => { // 模拟: 用户在 home page, 点击 home tab // verify 的 DIY_VIEW_INDEX 短路 + step 2 精确匹配, 都应高亮 // (实际测试中, 短路 vs 精确匹配谁先触发不重要, 结果都是 true) const result = checkTabBarActive( HOME, HOME, STANDARD_TABBAR, { pages: HOME }, {}, { noTabPages: NO_TAB_PAGES, pageTabMap: PAGE_TAB_MAP, pageTabWildcards: PAGE_TAB_WILDCARDS }, ); assert.equal(result, true, '在 home 上点 home tab → 高亮'); }); test('场景 16.2: 在 home page, 点 cart tab → cart 不亮 (无来源记忆)', () => { // 用户在 home, 没去过 cart, packageEntryTab 只有 pages const result = checkTabBarActive( CART, HOME, STANDARD_TABBAR, { pages: HOME }, {}, { noTabPages: NO_TAB_PAGES, pageTabMap: PAGE_TAB_MAP, pageTabWildcards: PAGE_TAB_WILDCARDS }, ); // step 5 pageTabMap['/pages/index/index'] = HOME → 询问 cart 链接 → false assert.equal(result, false, '在 home 上点 cart → cart 不该高亮'); }); test('场景 16.3: 在 home page, 点 merchant tab → 走 PAGE_TAB_MAP 兜底', () => { // pageTabMap['/pages/index/index'] = HOME, 询问 merchant → false const result = checkTabBarActive( MERCHANT, HOME, STANDARD_TABBAR, { pages: HOME }, {}, { noTabPages: NO_TAB_PAGES, pageTabMap: PAGE_TAB_MAP, pageTabWildcards: PAGE_TAB_WILDCARDS }, ); assert.equal(result, false, 'home → merchant: HOME 优先, merchant 不亮'); }); // ============================================================= // 场景 17: P0-2 修复 — tabBarList 异步加载竞态 // ============================================================= test('场景 17.1: tabBarList 异步加载场景 (文档化行为)', () => { // 实际修复在 vue 组件里 (watch tabBarReady), 这里验证 resolveEntryTab 在 tabBarList 空时的行为 // 修复前: tabBarList 为空时, step 2 history-walk 会得到空 tabUrls, 走 fallback // 修复后: resolveEntryTab 直接 return null, 不会用空 tabUrls 计算 const r1 = resolveEntryTab({ currentPageRoute: DIY, tabBarList: { list: [] }, // 空 historyRoutes: [HOME], noTabPages: NO_TAB_PAGES, }); assert.equal(r1, null, 'tabBarList 空时, resolveEntryTab 应安全返回 null'); const r2 = resolveEntryTab({ currentPageRoute: DIY, tabBarList: null, // null historyRoutes: [HOME], noTabPages: NO_TAB_PAGES, }); assert.equal(r2, null, 'tabBarList null 时, resolveEntryTab 应安全返回 null'); }); // ============================================================= // 场景 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', () => { // 在沙箱里, 临时设 globalThis.__TABBAR_DEBUG__ = true 并捕获 console.log const logCapture = []; const origLog = console.log; const origGlobal = globalThis.__TABBAR_DEBUG__; console.log = (...a) => logCapture.push(a.join(' ')); globalThis.__TABBAR_DEBUG__ = true; try { checkTabBarActive( HOME, DIY, STANDARD_TABBAR, {}, {}, { noTabPages: NO_TAB_PAGES, pageTabMap: PAGE_TAB_MAP, pageTabWildcards: PAGE_TAB_WILDCARDS }, ); } finally { console.log = origLog; globalThis.__TABBAR_DEBUG__ = origGlobal; } assert.ok(logCapture.length > 0, `应至少有一次 console.log, 实际 ${logCapture.length} 次`); assert.ok(logCapture.some(l => l.includes('[TABBAR]')), '日志应包含 [TABBAR] 前缀'); }); test('场景 18.2: __TABBAR_DEBUG__ 未开启时, 决策路径无 console.log', () => { const logCapture = []; const origLog = console.log; const origGlobal = globalThis.__TABBAR_DEBUG__; console.log = (...a) => logCapture.push(a.join(' ')); globalThis.__TABBAR_DEBUG__ = false; try { checkTabBarActive( HOME, DIY, STANDARD_TABBAR, {}, {}, { noTabPages: NO_TAB_PAGES, pageTabMap: PAGE_TAB_MAP, pageTabWildcards: PAGE_TAB_WILDCARDS }, ); } finally { console.log = origLog; globalThis.__TABBAR_DEBUG__ = origGlobal; } const tabbarLogs = logCapture.filter(l => l.includes('[TABBAR]')); assert.equal(tabbarLogs.length, 0, '__TABBAR_DEBUG__ 关闭时不应有 [TABBAR] 日志'); }); test('场景 18.3: opts.debug = true 也能触发调试', () => { const logCapture = []; const origLog = console.log; const origGlobal = globalThis.__TABBAR_DEBUG__; console.log = (...a) => logCapture.push(a.join(' ')); globalThis.__TABBAR_DEBUG__ = false; try { checkTabBarActive( HOME, DIY, STANDARD_TABBAR, {}, {}, { noTabPages: NO_TAB_PAGES, pageTabMap: PAGE_TAB_MAP, pageTabWildcards: PAGE_TAB_WILDCARDS, debug: true }, ); } finally { console.log = origLog; globalThis.__TABBAR_DEBUG__ = origGlobal; } const tabbarLogs = logCapture.filter(l => l.includes('[TABBAR]')); assert.ok(tabbarLogs.length > 0, 'opts.debug=true 应触发 [TABBAR] 日志'); }); // ============================================================= // 场景 19: 多语言切换 — 高亮应保持稳定 // ============================================================= test('场景 19.1: 多语言切换时高亮结果不变 (zh → en → ja)', () => { // 用户: home → diy (HIGH 亮) // 切换语言, 再切回来, 高亮应仍为 HOME // 业务意义: 语言切换不应该"重置"高亮记忆 const initial = findHighlightedTab({ currentPageRoute: DIY, historyRoutes: [HOME], }); assert.equal(initial, HOME, '语言切换前: HOME'); // 模拟多次重新计算 (模拟 componentRefresh 触发的 re-render) const afterRefresh1 = findHighlightedTab({ currentPageRoute: DIY, historyRoutes: [HOME], }); const afterRefresh2 = findHighlightedTab({ currentPageRoute: DIY, historyRoutes: [HOME], }); assert.equal(afterRefresh1, HOME, '第 1 次 re-render: HOME'); assert.equal(afterRefresh2, HOME, '第 2 次 re-render: HOME'); assert.equal(initial, afterRefresh1, '高亮幂等'); assert.equal(afterRefresh1, afterRefresh2, '高亮幂等'); }); test('场景 19.2: 语言切换时不同页面的高亮都正确', () => { // 不同页面在不同语言下都应该高亮正确的 tab const pages = [ { current: DIY, history: [HOME], expected: HOME, label: 'home → diy' }, { current: DIY, history: [CATEGORY], expected: CATEGORY, label: 'category → diy' }, { current: MEMBER_INFO, history: [HOME], expected: MEMBER, label: 'home → member/info' }, { current: GOODS_DETAIL, history: [HOME], expected: CATEGORY, label: 'home → goods/detail' }, ]; for (const sc of pages) { // 模拟 3 次语言切换 (重新计算 3 次) for (let i = 0; i < 3; i++) { const result = findHighlightedTab({ currentPageRoute: sc.current, historyRoutes: sc.history, }); assert.equal(result, sc.expected, `${sc.label} (第 ${i + 1} 次)`); } } }); // ============================================================= // 场景 20: 登出/换号 — packageEntryTab 状态保持惰性 // ============================================================= test('场景 20.1: 登出后 packageEntryTab 保持 (惰性, 不主动清)', () => { // 模拟: home → diy (写入 pages_tool → home) const before = findHighlightedTab({ currentPageRoute: DIY, historyRoutes: [HOME], }); assert.equal(before, HOME, '登出前: HOME 高亮'); // 模拟登出 (setMemberInfo(null) 不应触碰 packageEntryTab) // 这里验证 store/index.js 里的 setMemberInfo 实现确实没清 packageEntryTab const storeSrc = fs.readFileSync( path.join(__dirname, '..', 'store', 'index.js'), 'utf8', ); // 找 setMemberInfo 函数体 const setMemberInfoMatch = storeSrc.match(/setMemberInfo\s*\([^)]*\)\s*\{([\s\S]*?)\n\s*\t*\}/); if (setMemberInfoMatch) { assert.doesNotMatch( setMemberInfoMatch[1], /packageEntryTab/, 'setMemberInfo 函数体不应包含 packageEntryTab (惰性更新)', ); } }); test('场景 20.2: 登出后新导航自然覆盖 packageEntryTab', () => { // 模拟: 用户原本在 pages_tool, packageEntryTab[pages_tool] = HOME (来自 history-walk) // 登出后, 用户进 diy (无历史), 期望: pages_tool 不被错误写入 const r1 = findHighlightedTab({ currentPageRoute: DIY, historyRoutes: [], // 登出后, 历史栈清空 (实际是空) }); assert.equal(r1, null, '登出后深链 diy → 不点亮 (iOS HIG)'); // 然后用户从 home 重新进 diy const r2 = findHighlightedTab({ currentPageRoute: DIY, historyRoutes: [HOME], }); assert.equal(r2, HOME, '登出后重新进 diy → HOME (新 history-walk)'); }); test('场景 20.3: 登出后登入另一账号, 高亮反映新行为', () => { // 用户 A: home → diy → 我的 → diy (pages_tool_entry = MEMBER) // 登出 // 用户 B: home → diy (pages_tool_entry = HOME) // 期望: 高亮 = HOME (不是 MEMBER, 因为登出后状态应被新行为覆盖) const r = findHighlightedTab({ currentPageRoute: DIY, historyRoutes: [HOME], }); assert.equal(r, HOME, '新账号进 diy → HOME (新 history-walk 决定)'); }); // ============================================================= // 场景 21: 性能 — 500+ 步历史栈应在合理时间完成 // ============================================================= test('场景 21.1: 500 步历史栈应在 50ms 内完成 resolveEntryTab', () => { // 构造 500 步历史栈 (大部分是 diy 假页, 最后一个是 home tab) const fakePages = []; for (let i = 0; i < 500; i++) { fakePages.push(`/pages_tool/fake/page_${i}`); } fakePages.push(HOME); // 最后一个是 tab const start = process.hrtime.bigint(); const result = resolveEntryTab({ currentPageRoute: '/pages_tool/fake/current', tabBarList: STANDARD_TABBAR, historyRoutes: fakePages, noTabPages: NO_TAB_PAGES, }); const elapsedMs = Number(process.hrtime.bigint() - start) / 1_000_000; assert.equal(result, HOME, '500 步栈应找到 HOME'); assert.ok(elapsedMs < 50, `应 < 50ms, 实际 ${elapsedMs.toFixed(2)}ms`); }); test('场景 21.2: 1000 步历史栈性能依然可接受', () => { const fakePages = []; for (let i = 0; i < 1000; i++) { fakePages.push(`/pages/fake/${i}`); } const start = process.hrtime.bigint(); resolveEntryTab({ currentPageRoute: '/pages/fake/current', tabBarList: STANDARD_TABBAR, historyRoutes: fakePages, noTabPages: NO_TAB_PAGES, }); const elapsedMs = Number(process.hrtime.bigint() - start) / 1_000_000; assert.ok(elapsedMs < 100, `1000 步应 < 100ms, 实际 ${elapsedMs.toFixed(2)}ms`); }); test('场景 21.3: 1000 次 findHighlightedTab 端到端性能', () => { // 模拟用户疯狂切换 tab 1000 次, 总耗时应在合理范围 const start = process.hrtime.bigint(); for (let i = 0; i < 1000; i++) { findHighlightedTab({ currentPageRoute: DIY, historyRoutes: [HOME, GOODS_LIST, MEMBER_COUPON], }); } const elapsedMs = Number(process.hrtime.bigint() - start) / 1_000_000; const perCall = elapsedMs / 1000; assert.ok(perCall < 1, `每次应 < 1ms, 实际 ${perCall.toFixed(4)}ms`); }); // ============================================================= // 场景 22: 极端 URL — 长路径、特殊字符、URL 编码 // ============================================================= test('场景 22.1: 极长路径名 (200 字符) 仍能正确匹配', () => { const longName = 'a'.repeat(200); const longPath = `/pages_tool/member/${longName}`; const result = checkTabBarActive( MEMBER, longPath, STANDARD_TABBAR, {}, {}, { noTabPages: NO_TAB_PAGES, pageTabMap: PAGE_TAB_MAP, pageTabWildcards: PAGE_TAB_WILDCARDS }, ); // 通配符 /pages_tool/member/* 仍应匹配 assert.equal(result, true, '极长路径应被通配符命中'); }); test('场景 22.2: URL 编码的 query 参数 (含 %xx) 不影响匹配', () => { // 用户分享链接: /pages_tool/index/diy?id=123%20%26name=test // URL 编码的空格(%20) 和 &(%26) 不应干扰 const result = findHighlightedTab({ currentPageRoute: '/pages_tool/index/diy?id=123%20%26name=test', historyRoutes: [HOME], }); assert.equal(result, HOME, 'URL 编码 query 不影响 diy 高亮'); }); test('场景 22.3: query 含特殊字符 (& =) 不影响路径匹配', () => { // 真实业务: 商品链接 /pages_goods/detail?id=1&sku=2&from=share const result = findHighlightedTab({ currentPageRoute: '/pages_goods/detail?id=1&sku=2&from=share', historyRoutes: [HOME], }); assert.equal(result, CATEGORY, '商品详情 (复杂 query) → CATEGORY'); }); test('场景 22.4: 路径含 hash (H5 模式) 不影响匹配', () => { // H5 hash 模式: /#/pages/index/index // 我们的 _normalizePath 不处理 hash, 这是已知行为 // 但路径形式 /pages/index/index 应该被识别 const result = findHighlightedTab({ currentPageRoute: '/pages/index/index', historyRoutes: [], }); assert.equal(result, HOME, '标准路径 → HOME'); }); test('场景 22.5: 路径前缀相近但不匹配通配符 (members vs member)', () => { // /pages_tool/members 不应被 /pages_tool/member/* 匹配 const result = checkTabBarActive( MEMBER, '/pages_tool/members/list', STANDARD_TABBAR, {}, {}, { noTabPages: NO_TAB_PAGES, pageTabMap: PAGE_TAB_MAP, pageTabWildcards: PAGE_TAB_WILDCARDS }, ); // /pages_tool/members/list 不在任何精确表, 不在通配符 (前缀不匹配) // 也没有 from_tab/packageEntryTab, 返回 false assert.equal(result, false, '前缀相近路径不应被错误匹配'); }); // ============================================================= // 场景 23: tabBarList 动态变化 (后端重新下发配置) // ============================================================= test('场景 23.1: tabBarList 从一种配置切到另一种, 高亮应基于新配置', () => { // 模拟后端重置: 新 tabBarList 只有 home + member 两个 tab const altTabBar = makeTabBarList([ { id: 1, text: '首页', url: HOME }, { id: 2, text: '我的', url: MEMBER }, ]); // 用户在 diy 上, 按新配置, 应该是哪都不亮 (CATEGORY 被移除了) const result = findHighlightedTab({ currentPageRoute: DIY, historyRoutes: [HOME], tabBarList: altTabBar, }); // history-walk 找到 home → HOME (新配置里 home 还在) assert.equal(result, HOME, '新配置下, home → HOME 仍高亮'); }); test('场景 23.2: 切换 tabBarList 后 PAGE_TAB_MAP 仍指向已删除的 tab', () => { // 新配置里没有 member tab, 但 PAGE_TAB_MAP 仍说 MEMBER_INFO → MEMBER // 这是配置不一致: PAGE_TAB_MAP 是声明式, 优先于 tabBarList 变化 const altTabBar = makeTabBarList([ { id: 1, text: '首页', url: HOME }, ]); // 用户在 MEMBER_INFO, 询问 HOME 链接 // step 5: PAGE_TAB_MAP 命中 MEMBER (不在新 tabBarList, 但仍声明) // 比较: link=HOME vs mapped=MEMBER → 不等 → false const result = checkTabBarActive( HOME, MEMBER_INFO, altTabBar, {}, {}, { noTabPages: NO_TAB_PAGES, pageTabMap: PAGE_TAB_MAP, pageTabWildcards: PAGE_TAB_WILDCARDS }, ); // PAGE_TAB_MAP 显式声明 MEMBER_INFO 属于 MEMBER, 即使 MEMBER 已被移出 tabBarList // 当前页该归 MEMBER (即便 MEMBER 不显示), 不归 HOME assert.equal(result, false, 'PAGE_TAB_MAP 优先: MEMBER_INFO 不归 HOME (即使 MEMBER 已删除)'); // 验证 PAGE_TAB_MAP 的兜底逻辑本身仍工作 // 如果新配置也移除了 MEMBER_INFO 的 PAGE_TAB_MAP 条目, 那才归 HOME const result2 = checkTabBarActive( HOME, MEMBER_INFO, altTabBar, {}, {}, { noTabPages: NO_TAB_PAGES, pageTabMap: {}, pageTabWildcards: PAGE_TAB_WILDCARDS }, ); // 没 PAGE_TAB_MAP 命中, 没 from_tab, 没 packageEntryTab → false (iOS HIG) assert.equal(result2, false, '没任何信号 → 不点亮'); }); test('场景 23.3: tabBarList 从 null 恢复 (用户等了很久后端才返回)', () => { // 模拟: tabBarList 一开始是 null, 用户在 diy 上, 高亮 = null const r1 = findHighlightedTab({ currentPageRoute: DIY, historyRoutes: [HOME], tabBarList: { list: [] }, }); assert.equal(r1, null, 'tabBarList 空时不点亮'); // 后端终于返回, 高亮 = HOME (重新计算) const r2 = findHighlightedTab({ currentPageRoute: DIY, historyRoutes: [HOME], tabBarList: STANDARD_TABBAR, }); 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) // // =============================================================