import { expect, test } from '@playwright/test' /** * 页签栏「点击后滚动居中」的判定回归 —— 针对 **ERP 应用**(不是组件库 playground)。 * * 用户明确的规则: * - 点击的页签**左边还有被隐藏的** → 往前滚(居中) * - 点击的页签**右边还有被隐藏的** → 往后滚(居中) * - 自己被折叠了一部分 → 滚 * - 自己完整可见、且左右邻域都没被隐藏 → 不滚 * * 报障场景:右侧有多个被隐藏页签(C D F),A 完整可见,点 A 却纹丝不动; * 但用户点 A 正是因为「临近末尾,想看后面的」,此时必须滚。 * 旧实现只看「自己是否完整可见」→ A 完整可见 → return → 不滚。 * * 说明:本用例放在组件库的 e2e 工程里跑(那边已有 Playwright 配置), * 通过 ERP_URL 指向已启动的 ERP dev server,避免给 ERP 应用引入 Playwright 依赖。 * * 运行:ERP_URL=http://localhost:5090 npx playwright test e2e/erp-tabbar-reveal.spec.ts */ const ERP_URL = process.env.ERP_URL ?? 'http://localhost:5090' /** * 打开足够多的页签,让页签栏溢出。 * * ⚠️ 必须先清掉持久化的页签状态再开:appStore 把已打开页签 persist 到 localStorage, * 同一个 origin 下各用例会互相继承上一轮遗留的页签,页签集合随执行顺序变化 → * 几何随之改变 → 失败用例在几次运行间「轮换」,看起来像随机失败。 * 清掉后每个用例都从同一套页签出发,结果才可复现。 */ async function openManyTabs(page: import('@playwright/test').Page) { const routes = [ '/ocean/booking', '/ocean/bill', '/ocean/schedule', '/air/booking', '/air/awb', '/container/ledger', '/container/tracking', '/finance/receivable', '/finance/payable', '/finance/statement', ] // 先访问同源页面,才有权限清 localStorage await page.goto(`${ERP_URL}/`) await page.evaluate(() => { // 只清页签相关键,避免动到其他偏好(key 见 stores/app.ts: STORAGE_KEY) for (const key of Object.keys(localStorage)) { if (key.startsWith('g3erp:app')) localStorage.removeItem(key) } }) await page.reload() await page.waitForTimeout(200) for (const path of routes) { await page.goto(`${ERP_URL}${path}`) await page.waitForTimeout(120) } } interface TabGeo { key: string left: number right: number fullyVisible: boolean } interface TabbarState { scrollLeft: number scrollWidth: number clientWidth: number overflowed: boolean tabs: TabGeo[] } async function readTabbarState(page: import('@playwright/test').Page): Promise { return page.evaluate(() => { const track = document.querySelector('.layout-tabbar__track') as HTMLElement | null if (!track) return null const trackRect = track.getBoundingClientRect() const tabs = [...track.querySelectorAll('[data-tab-key]')] as HTMLElement[] return { scrollLeft: Math.round(track.scrollLeft), scrollWidth: track.scrollWidth, clientWidth: track.clientWidth, overflowed: track.scrollWidth > track.clientWidth, tabs: tabs.map((el) => { const r = el.getBoundingClientRect() return { key: el.dataset.tabKey ?? '', left: Math.round(r.left - trackRect.left), right: Math.round(r.right - trackRect.left), fullyVisible: r.left >= trackRect.left - 1 && r.right <= trackRect.right + 1, } }), } }) } test.describe('ERP 页签点击后的滚动居中', () => { test('右侧还有被隐藏页签时,点一个完整可见的页签也必须滚动', async ({ page }) => { test.setTimeout(120_000) await openManyTabs(page) const state = await readTabbarState(page) expect(state, '页签栏应已渲染').not.toBeNull() expect(state!.overflowed, '页签应已溢出(否则本用例无意义)').toBe(true) // 诊断:打印实际几何,便于判断为何找不到目标页签 // eslint-disable-next-line no-console console.log('[tabbar-geometry]', JSON.stringify(state, null, 2)) // 找「自己完整可见、但右侧还有不可见页签」的页签 —— 报障场景。 // // ⚠️ 必须取**最后一个**符合条件的,不能取第一个: // 靠最左的页签虽然也「右侧有隐藏」,但它已经在滚动起点, // 居中偏移为负、被 Math.max(0, ...) 钳成 0 → 不动是正确行为,用它测会误判。 // 用户报障的真实场景是「临近末尾时点那一个」,所以取最右侧的合格页签。 let idx = -1 for (let i = 0; i < state!.tabs.length; i++) { const hasHiddenRight = state!.tabs.slice(i + 1).some((n) => !n.fullyVisible) if (state!.tabs[i].fullyVisible && hasHiddenRight) idx = i } expect(idx, '应存在「完整可见但右侧仍有隐藏页签」的页签').toBeGreaterThanOrEqual(0) const target = state!.tabs[idx] const before = state!.scrollLeft await page.click(`[data-tab-key="${target.key}"]`) await page.waitForTimeout(800) // 等平滑滚动结束 const after = await readTabbarState(page) expect( after!.scrollLeft, `页签 ${target.key} 右侧还有隐藏页签,点击后必须滚动(滚动前 ${before})`, ).not.toBe(before) // 应被滚到接近居中 const moved = after!.tabs.find((t) => t.key === target.key)! const trackCenter = after!.clientWidth / 2 const tabCenter = (moved.left + moved.right) / 2 expect( Math.abs(tabCenter - trackCenter), `被点页签应接近居中(实际偏离 ${Math.round(Math.abs(tabCenter - trackCenter))}px)`, ).toBeLessThan(Math.max(140, after!.clientWidth * 0.3)) }) test('最后一个页签完整可见时不滚动(避免无谓抽动)', async ({ page }) => { test.setTimeout(120_000) await openManyTabs(page) await page.waitForSelector('.layout-tabbar__track', { state: 'attached' }) await page.waitForTimeout(300) // 注意:轨道末尾有一段 24px 的纯占位 spacer(.layout-tabbar__spacer), // 它计入 scrollWidth,所以直接把 scrollLeft 设成 scrollWidth 时, // 最后一个页签距右缘还差 24px —— 不会被判为「完整可见」。 // 这里按「最后一个页签的右边缘贴到轨道右缘」来滚,模拟用户手动滚到最右。 await page.evaluate(() => { const track = document.querySelector('.layout-tabbar__track') as HTMLElement | null if (!track) return const tabs = [...track.querySelectorAll('[data-tab-key]')] as HTMLElement[] const last = tabs[tabs.length - 1] if (!last) return const trackRect = track.getBoundingClientRect() const lastRect = last.getBoundingClientRect() // 需要再往右滚的距离 track.scrollLeft += lastRect.right - trackRect.right }) await page.waitForTimeout(300) const state = await readTabbarState(page) const last = state!.tabs[state!.tabs.length - 1] expect(last.fullyVisible, '滚到最右后最后一个页签应完整可见').toBe(true) const before = state!.scrollLeft await page.click(`[data-tab-key="${last.key}"]`) await page.waitForTimeout(800) const after = await readTabbarState(page) expect(after!.scrollLeft, '末尾页签完整可见时不应滚动').toBe(before) }) test('左侧还有被隐藏页签时,点击完整可见的页签也应往前滚', async ({ page }) => { test.setTimeout(120_000) await openManyTabs(page) // 等页签栏真的渲染出来再操作(openManyTabs 最后一次 goto 后可能还在挂载) await page.waitForSelector('.layout-tabbar__track', { state: 'attached' }) await page.waitForTimeout(300) await page.evaluate(() => { const track = document.querySelector('.layout-tabbar__track') as HTMLElement | null if (track) track.scrollLeft = track.scrollWidth }) await page.waitForTimeout(300) const state = await readTabbarState(page) expect(state, '页签栏应已渲染').not.toBeNull() // 取**第一个**符合条件的(最靠左、而又左侧有隐藏): // 与用例 1 镜像 —— 那里取最右,这里取最左,才落在能产生有效偏移的位置 let idx = -1 for (let i = 0; i < state!.tabs.length; i++) { const hasHiddenLeft = state!.tabs.slice(0, i).some((n) => !n.fullyVisible) if (state!.tabs[i].fullyVisible && hasHiddenLeft) { idx = i break } } expect(idx, '应存在「完整可见但左侧仍有隐藏页签」的页签').toBeGreaterThanOrEqual(0) const target = state!.tabs[idx] const before = state!.scrollLeft await page.click(`[data-tab-key="${target.key}"]`) await page.waitForTimeout(800) const after = await readTabbarState(page) expect( after!.scrollLeft, `页签 ${target.key} 左侧还有隐藏页签,点击后应往前滚(滚动前 ${before})`, ).not.toBe(before) }) })