This commit is contained in:
oneao committed 2026-10-10 17:33:59 +08:00
1 parent 5f28a10d89
commit 32d423b759
59 files changed
+5101 -1098

No files matched your search

@@ -0,0 +1,226 @@
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)
})
})