Files
workspace/code/g3soft-libs/packages/ui/e2e/erp-tabbar-reveal.spec.ts
T
2026-10-10 17:33:59 +08:00

227 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<TabbarState | null> {
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)
})
})