227 lines
8.9 KiB
TypeScript
227 lines
8.9 KiB
TypeScript
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)
|
||
})
|
||
})
|