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,94 @@
import { expect, test } from '@playwright/test'
/**
* 回归:**hover 触发**时菜单项高亮「闪一下然后消失」。
*
* 最小复现(用户指定):文档站 /ui/overlay/dropdown 的「触发方式 → hover 触发」示例。
*
* 根因(已由开关调用栈 + 逐帧采样证实):
* hover 触发下触发器与浮层是两棵 DOM(浮层 Teleport 到 body)。指针从触发器移进
* 菜单项时会先后触发:
* 1. 触发器 mouseleave(排定关闭定时器,随后被浮层 mouseenter 清掉)
* 2. 浮层自己的 mouseenter → setOpen(true) → **再上报一次 open=true**
* Dropdown.onOpenChange 曾经「见 true 就 activeIndex = -1」,于是菜单项刚点亮的
* 高亮被这次重复上报清掉;指针已停住、不会再派发 mouseenter → 高亮永久不恢复。
* click 触发时鼠标停在触发器上,没有这次重复上报,所以只有 hover 模式复现。
*
* 判定方式刻意避开「指针轨迹」这类不稳定因素:
* - 不用逐帧采样(依赖 Playwright 的移动时序,会飘);
* - 改为「hover 到项上 → 等足够久 → 断言高亮仍在」。
* 修复前:等 100ms(mouseEnterDelay) 后高亮被清 → 断言失败;
* 修复后:高亮一直保持 → 断言通过。
*/
test.use({ baseURL: 'http://localhost:3100' })
test.describe('hover 触发:菜单项高亮不得闪断', () => {
test('hover 到菜单项并停留,高亮必须持续保持', async ({ page }) => {
await page.goto('/ui/overlay/dropdown')
const trigger = page.getByRole('button', { name: 'hover 触发' }).first()
await expect(trigger).toBeVisible()
await trigger.hover()
const menu = page.locator('.g3-dropdown__menu').first()
await expect(menu).toBeVisible({ timeout: 3000 })
const item = menu.locator('.g3-dropdown__item').first()
// hover 到菜单项上
await item.hover()
// 立刻应高亮
await expect(item).toHaveClass(/is-item-active/)
// 关键等待:越过 mouseEnterDelay(100ms) + 若干帧,
// 让「浮层重复上报 open → 清 activeIndex」这条路径有机会发生
await page.waitForTimeout(400)
// 修复前这里会失败(高亮已被清掉且不会恢复)
await expect(item).toHaveClass(/is-item-active/)
// 菜单本身也不该关闭
await expect(menu).toBeVisible()
})
test('在两个菜单项之间移动,高亮正确转移且不残留', async ({ page }) => {
await page.goto('/ui/overlay/dropdown')
const trigger = page.getByRole('button', { name: 'hover 触发' }).first()
await trigger.hover()
const menu = page.locator('.g3-dropdown__menu').first()
await expect(menu).toBeVisible({ timeout: 3000 })
const items = menu.locator('.g3-dropdown__item')
await items.nth(0).hover()
await expect(items.nth(0)).toHaveClass(/is-item-active/)
await items.nth(1).hover()
await expect(items.nth(1)).toHaveClass(/is-item-active/)
await expect(items.nth(0)).not.toHaveClass(/is-item-active/)
// 停留后仍保持
await page.waitForTimeout(400)
await expect(items.nth(1)).toHaveClass(/is-item-active/)
})
test('click 触发作为对照组', async ({ page }) => {
await page.goto('/ui/overlay/dropdown')
const clickTrigger = page.getByRole('button', { name: '点击触发' }).first()
if ((await clickTrigger.count()) === 0) {
test.skip(true, '该页面没有「点击触发」示例按钮')
return
}
await clickTrigger.click()
const menu = page.locator('.g3-dropdown__menu').first()
await expect(menu).toBeVisible()
const item = menu.locator('.g3-dropdown__item').first()
await item.hover()
await expect(item).toHaveClass(/is-item-active/)
await page.waitForTimeout(400)
await expect(item).toHaveClass(/is-item-active/)
})
})
@@ -0,0 +1,76 @@
import { expect, test } from '@playwright/test'
/**
* 偏好设置「布局」卡片回归 —— 针对 ERP 应用。
*
* 背景:原来三张卡片(侧边/双列/顶部)用的是 lucide 抽象图标,看不出布局差异。
* 改成 vben 那样的**布局示意图**(SVG 骨架图)后,需要确认:
* 1. 三张卡片都渲染出 SVG(不是空白 / 不是抽象图标);
* 2. 点击第三张(顶部菜单)能真正切换布局;
* 3. 选中态描边生效(is-active)。
*/
const ERP_URL = process.env.ERP_URL ?? 'http://localhost:5090'
async function openPreferences(page: import('@playwright/test').Page) {
await page.goto(`${ERP_URL}/`)
// 顶栏的偏好设置按钮
await page.getByTitle('偏好设置').click()
// 抽屉里切到「布局」页签
await page.getByRole('tab', { name: '布局' }).or(page.getByText('布局', { exact: true })).first().click()
await page.waitForTimeout(300)
}
test.describe('ERP 偏好设置 - 布局卡片', () => {
test('三张布局卡片都渲染出示意图 SVG', async ({ page }) => {
await openPreferences(page)
const cards = page.locator('.pref-card--layout')
await expect(cards).toHaveCount(3)
// 每张卡片里都要有 SVG 骨架图,且不是 0 尺寸(防"渲染成空")
for (let i = 0; i < 3; i++) {
const svg = cards.nth(i).locator('svg.pref-layout-icon')
await expect(svg).toHaveCount(1)
const box = await svg.boundingBox()
expect(box, `第 ${i + 1} 张卡片的示意图应有实际尺寸`).not.toBeNull()
expect(box!.width, `第 ${i + 1} 张示意图宽度应 > 40px`).toBeGreaterThan(40)
expect(box!.height, `第 ${i + 1} 张示意图高度应 > 25px`).toBeGreaterThan(25)
}
// 卡片标签
const labels = await page.locator('.pref-card--layout .pref-card__name').allTextContents()
expect(labels.join('|')).toContain('侧边菜单')
expect(labels.join('|')).toContain('双列菜单')
expect(labels.join('|')).toContain('顶部菜单')
})
test('点击「双列菜单」卡片能切换到双列布局', async ({ page }) => {
await openPreferences(page)
await page.locator('.pref-card--layout', { hasText: '双列菜单' }).click()
await page.waitForTimeout(500)
// 关掉抽屉看布局
await page.keyboard.press('Escape')
await page.waitForTimeout(400)
await expect(page.locator('.layout-mixed-rail')).toBeVisible()
await expect(page.locator('.layout-mixed-column')).toBeVisible()
})
test('点击「顶部菜单」卡片能切换回顶部菜单布局', async ({ page }) => {
await openPreferences(page)
await page.locator('.pref-card--layout', { hasText: '顶部菜单' }).click()
await page.waitForTimeout(500)
await page.keyboard.press('Escape')
await page.waitForTimeout(400)
await expect(page.locator('.layout--header-nav')).toHaveCount(1)
// 顶部布局下没有侧栏 / 图标栏
await expect(page.locator('.layout-sidebar')).toHaveCount(0)
await expect(page.locator('.layout-mixed-rail')).toHaveCount(0)
})
})
@@ -0,0 +1,135 @@
import { expect, test } from '@playwright/test'
/**
* 双列菜单布局的头部区域回归 —— 针对 ERP 应用。
*
* 结构对照 vben(packages/@core/ui-kit/layout-ui/src/components/layout-sidebar.vue):
* 第一列(layout-mixed-rail,width = mixedWidth):
* · 背景 bg-sidebar-deep(本项目按需求用白色)
* · <slot name="logo"> 在这一列(:410,高度 = headerHeight)
* 第二列(layout-mixed-column,extraWidth):
* · 背景 bg-sidebar
* · <slot name="extra-title"> 在这一列(:446,高度 = extraTitleHeight ?? headerHeight - 1)
*
* ⚠️ 注意区分:vben 的 `isMixedNav` = layout === 'mixed-nav'(顶栏混合),
* 与本布局 `sidebar-mixed-nav` 不是一回事。vben-layout.vue:619 的
* `isSideMode && !isMixedNav && sidebarLogoVisible` 对 sidebar-mixed-nav 成立,
* 所以 logo 渲染在第一列。
*
* 即:logo 在第一列,标题在第二列。
*/
const ERP_URL = process.env.ERP_URL ?? 'http://localhost:5090'
async function useMixedNavLayout(page: import('@playwright/test').Page) {
await page.goto(`${ERP_URL}/`)
await page.evaluate(() => {
const KEY = 'g3erp:preferences'
const raw = localStorage.getItem(KEY)
const pref: Record<string, Record<string, unknown>> = raw ? JSON.parse(raw) : {}
const app = pref.app || {}
app.layout = 'sidebar-mixed-nav'
pref.app = app
const sidebar = pref.sidebar || {}
sidebar.enable = true
sidebar.extraCollapse = false
pref.sidebar = sidebar
const logo = pref.logo || {}
logo.enable = true
logo.showText = true
pref.logo = logo
localStorage.setItem(KEY, JSON.stringify(pref))
for (const key of Object.keys(localStorage)) {
if (key.indexOf('g3erp:app') === 0) localStorage.removeItem(key)
}
})
await page.reload()
await page.waitForTimeout(400)
}
test.describe('ERP 双列菜单 - 头部结构', () => {
test('logo 在第一列(layout-mixed-rail)', async ({ page }) => {
await useMixedNavLayout(page)
await page.goto(`${ERP_URL}/ocean/booking`)
await page.waitForTimeout(400)
const rail = page.locator('.layout-mixed-rail')
await expect(rail).toBeVisible()
// 第一列顶部有 logo 方块
const mark = rail.locator('.layout-mixed-rail__logo-mark')
await expect(mark, '第一列应有 logo 方块').toBeVisible()
await expect(mark).toHaveText('G3')
// 有实际尺寸,不是被压成 0
const box = await mark.boundingBox()
expect(box, 'logo 方块应有实际尺寸').not.toBeNull()
expect(box!.width).toBeGreaterThan(10)
expect(box!.height).toBeGreaterThan(10)
// 第二列不应再出现 logo
await expect(page.locator('.layout-mixed-column__logo')).toHaveCount(0)
// 顶栏也不应出现 logo(双列布局下由第一列承载)
await expect(page.locator('.layout-header__logo')).toHaveCount(0)
})
test('标题行展示的是 logo 文字(不是分组名)', async ({ page }) => {
await useMixedNavLayout(page)
await page.goto(`${ERP_URL}/ocean/booking`)
await page.waitForTimeout(400)
const title = page.locator('.layout-mixed-column__title')
await expect(title, '第二列标题行应可见').toBeVisible()
// 应是 logo 文字(默认 G3Soft ERP),而不是当前分组名「海运」
const text = (await title.innerText()).trim()
expect(text, '标题行应是 logo 文字').not.toBe('海运')
expect(text.length, '标题行应有内容').toBeGreaterThan(0)
// 与单列侧栏/顶栏的 logo 文字取自同一偏好项
const logoText = await page.evaluate(() => {
const raw = localStorage.getItem('g3erp:preferences')
const pref = raw ? JSON.parse(raw) : {}
return pref.logo ? pref.logo.text : null
})
expect(logoText, '应能读到 logo.text 偏好').toBeTruthy()
expect(text, '标题行文本应等于 logo.text').toBe(logoText)
const box = await title.boundingBox()
expect(box).not.toBeNull()
expect(box!.height).toBeGreaterThan(20)
})
test('切换分组不影响标题行(它显示 logo 文字,与分组无关)', async ({ page }) => {
await useMixedNavLayout(page)
await page.goto(`${ERP_URL}/ocean/booking`)
await page.waitForTimeout(400)
const before = (await page.locator('.layout-mixed-column__title').innerText()).trim()
await page.locator('.layout-mixed-rail__item', { hasText: '财务' }).click()
await page.waitForTimeout(500)
const after = (await page.locator('.layout-mixed-column__title').innerText()).trim()
expect(after, '标题行是 logo 文字,切分组不应变化').toBe(before)
// 但列表内容确实换成了财务的子项
const columnText = await page.locator('.layout-mixed-column').innerText()
expect(columnText, '第二列列表应切换为财务子项').toContain('应收费用')
})
test('第一列背景为白色', async ({ page }) => {
await useMixedNavLayout(page)
await page.goto(`${ERP_URL}/ocean/booking`)
await page.waitForTimeout(400)
const rail = page.locator('.layout-mixed-rail')
const bg = await rail.evaluate((el) => getComputedStyle(el).backgroundColor)
expect(bg, `图标栏背景应为白色,实际 ${bg}`).toMatch(/^rgba?\(255,\s*255,\s*255(,\s*1)?\)$/)
})
})
@@ -0,0 +1,109 @@
import { expect, test } from '@playwright/test'
/**
* 双列菜单布局(layout = 'sidebar-mixed-nav')渲染回归 —— 针对 ERP 应用。
*
* 组件编过 ≠ 页面渲染对,所以这里在真实浏览器里断言 DOM 结构:
* 1. 切到双列布局后,左侧图标栏 + 第二列都存在;
* 2. 单列侧栏(.layout-sidebar)不再渲染;
* 3. 第一列只显示顶级项(图标 + 标题),不含子项;
* 4. 第二列显示当前顶级分组的子项;
* 5. 点第一列另一个分组,第二列内容随之切换。
*
* 说明:放在组件库 e2e 工程里跑(已有 Playwright 配置),
* 通过 ERP_URL 指向 ERP dev server,避免给 ERP 应用引入 Playwright 依赖。
*/
const ERP_URL = process.env.ERP_URL ?? 'http://localhost:5090'
/** 通过偏好设置把布局切成双列菜单(直接写 localStorage,避免点 UI 的耦合) */
async function useMixedNavLayout(page: import('@playwright/test').Page) {
await page.goto(`${ERP_URL}/`)
await page.evaluate(() => {
const KEY = 'g3erp:preferences'
const raw = localStorage.getItem(KEY)
const pref: Record<string, Record<string, unknown>> = raw ? JSON.parse(raw) : {}
const app = pref.app || {}
app.layout = 'sidebar-mixed-nav'
pref.app = app
const sidebar = pref.sidebar || {}
sidebar.enable = true
pref.sidebar = sidebar
localStorage.setItem(KEY, JSON.stringify(pref))
// 清掉页签持久化,保证每次从干净状态开始
for (const key of Object.keys(localStorage)) {
if (key.indexOf('g3erp:app') === 0) localStorage.removeItem(key)
}
})
await page.reload()
await page.waitForTimeout(400)
}
test.describe('ERP 双列菜单布局', () => {
test('渲染出图标栏 + 第二列,且不再渲染单列侧栏', async ({ page }) => {
await useMixedNavLayout(page)
// 布局根节点带上了对应 class
await expect(page.locator('.layout--sidebar-mixed-nav')).toHaveCount(1)
// 两列都在
await expect(page.locator('.layout-mixed-rail')).toBeVisible()
await expect(page.locator('.layout-mixed-column')).toBeVisible()
// 单列侧栏不该出现
await expect(page.locator('.layout-sidebar')).toHaveCount(0)
})
test('第一列只放顶级项,第二列放该分组的子项', async ({ page }) => {
await useMixedNavLayout(page)
// 进入海运下的某个叶子,使「海运」成为当前顶级分组
await page.goto(`${ERP_URL}/ocean/booking`)
await page.waitForTimeout(400)
const railItems = page.locator('.layout-mixed-rail__item')
await expect(railItems.first()).toBeVisible()
// 第一列项数应等于顶级菜单数(6 个业务域)
const railCount = await railItems.count()
expect(railCount, '第一列应为顶级菜单数量').toBeGreaterThanOrEqual(5)
// 第一列里不应出现二级标题(如「订舱单」)
const railText = (await railItems.allTextContents()).join('|')
expect(railText, '第一列不应包含子项「订舱单」').not.toContain('订舱单')
expect(railText, '第一列应含顶级项「海运」').toContain('海运')
// 第一列里「海运」应是选中态
const activeRail = page.locator('.layout-mixed-rail__item.is-active')
await expect(activeRail).toHaveCount(1)
await expect(activeRail).toContainText('海运')
// 第二列显示「海运」的子项
const columnText = await page.locator('.layout-mixed-column').innerText()
expect(columnText, '第二列应含「海运」的子项').toContain('订舱单')
})
test('点第一列切换到别的分组,第二列内容随之变化', async ({ page }) => {
await useMixedNavLayout(page)
await page.goto(`${ERP_URL}/ocean/booking`)
await page.waitForTimeout(400)
const columnBefore = await page.locator('.layout-mixed-column').innerText()
expect(columnBefore).toContain('订舱单')
// 点第一列的「财务」
await page.locator('.layout-mixed-rail__item', { hasText: '财务' }).click()
await page.waitForTimeout(500)
const columnAfter = await page.locator('.layout-mixed-column').innerText()
expect(columnAfter, '切到财务后第二列应显示财务的子项').toContain('应收费用')
expect(columnAfter, '第二列不应再显示海运的子项').not.toContain('订舱单')
// 「财务」应变成选中态
await expect(page.locator('.layout-mixed-rail__item.is-active')).toContainText('财务')
})
})
@@ -0,0 +1,135 @@
import { expect, test } from '@playwright/test'
/**
* 双列菜单:第一列选中项样式回归 —— 针对 ERP 应用。
*
* 需求:layout-mixed-rail__item.is-active 的背景要比原来"深一点"、文字白色。
*
* 对照 vben(menu-ui/src/components/menu.vue):
* @mixin menu-item-active (375-379):
* color: var(--menu-item-active-color);
* background: var(--menu-item-active-background-color);
* 深色档赋值 (470-471):
* --menu-item-active-color: hsl(var(--accent-foreground)) ← 高对比文字
* --menu-item-active-background-color: hsl(var(--accent)) ← 强调底色
*
* 判定:选中项背景应比所在列背景明显更深,且文字为亮色(对比度足够)。
*/
const ERP_URL = process.env.ERP_URL ?? 'http://localhost:5090'
async function useMixedNavLayout(page: import('@playwright/test').Page) {
await page.goto(`${ERP_URL}/`)
await page.evaluate(() => {
const KEY = 'g3erp:preferences'
const raw = localStorage.getItem(KEY)
const pref: Record<string, Record<string, unknown>> = raw ? JSON.parse(raw) : {}
const app = pref.app || {}
app.layout = 'sidebar-mixed-nav'
pref.app = app
const sidebar = pref.sidebar || {}
sidebar.enable = true
sidebar.extraCollapse = false
pref.sidebar = sidebar
// 浅色模式,确保测的是浅色档
const theme = pref.theme || {}
theme.mode = 'light'
pref.theme = theme
localStorage.setItem(KEY, JSON.stringify(pref))
for (const key of Object.keys(localStorage)) {
if (key.indexOf('g3erp:app') === 0) localStorage.removeItem(key)
}
})
await page.reload()
await page.waitForTimeout(400)
}
/** 解析 rgb(a) 字符串 → [r,g,b] */
function parseRgb(value: string): [number, number, number] {
const m = value.match(/rgba?\(([^)]+)\)/)
if (!m) throw new Error(`无法解析颜色: ${value}`)
const parts = m[1].split(',').map((s) => Number(s.trim()))
return [parts[0], parts[1], parts[2]]
}
/** 相对亮度(WCAG) */
function luminance([r, g, b]: [number, number, number]): number {
const f = (c: number) => {
const s = c / 255
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
}
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b)
}
/** 对比度 */
function contrast(a: [number, number, number], b: [number, number, number]): number {
const la = luminance(a)
const lb = luminance(b)
const [hi, lo] = la > lb ? [la, lb] : [lb, la]
return (hi + 0.05) / (lo + 0.05)
}
test.describe('ERP 双列菜单 - 图标栏选中态', () => {
test('选中项背景比列背景更深,且文字是亮色(对比度足够)', async ({ page }) => {
await useMixedNavLayout(page)
await page.goto(`${ERP_URL}/ocean/booking`)
await page.waitForTimeout(400)
const rail = page.locator('.layout-mixed-rail')
const active = page.locator('.layout-mixed-rail__item.is-active')
await expect(active, '应有一个选中项').toHaveCount(1)
const styles = await page.evaluate(() => {
const railEl = document.querySelector('.layout-mixed-rail') as HTMLElement
const itemEl = document.querySelector('.layout-mixed-rail__item.is-active') as HTMLElement
const cs = getComputedStyle(itemEl)
return {
railBg: getComputedStyle(railEl).backgroundColor,
itemBg: cs.backgroundColor,
itemColor: cs.color,
fontWeight: cs.fontWeight,
}
})
// eslint-disable-next-line no-console
console.log('[active-style]', JSON.stringify(styles))
const railBg = parseRgb(styles.railBg)
const itemBg = parseRgb(styles.itemBg)
const itemColor = parseRgb(styles.itemColor)
// 1) 选中背景确实比列背景更深(亮度更低)
expect(
luminance(itemBg),
`选中背景应比列背景更深:item=${styles.itemBg} rail=${styles.railBg}`,
).toBeLessThan(luminance(railBg))
// 2) 背景不是透明(否则测的是列背景)
expect(styles.itemBg, '选中背景不应是透明').not.toMatch(/rgba\(0,\s*0,\s*0,\s*0\)/)
// 3) 文字与选中背景的对比度达标(可读)
const ratio = contrast(itemColor, itemBg)
expect(ratio, `文字/背景对比度应 >= 4.5,实际 ${ratio.toFixed(2)}`).toBeGreaterThanOrEqual(4.5)
// 4) 文字比背景亮("白色/亮色文字")
expect(
luminance(itemColor),
`文字应比选中背景更亮:text=${styles.itemColor} bg=${styles.itemBg}`,
).toBeGreaterThan(luminance(itemBg))
})
test('未选中项没有深色底(对照)', async ({ page }) => {
await useMixedNavLayout(page)
await page.goto(`${ERP_URL}/ocean/booking`)
await page.waitForTimeout(400)
const inactive = page.locator('.layout-mixed-rail__item:not(.is-active)').first()
const bg = await inactive.evaluate((el) => getComputedStyle(el).backgroundColor)
// 未选中项是透明(继承列的底色)
expect(bg, `未选中项不应有背景填充,实际 ${bg}`).toMatch(/rgba\(0,\s*0,\s*0,\s*0\)/)
})
})
@@ -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)
})
})
@@ -148,4 +148,47 @@ test.describe('RTL 镜像(几何)', () => {
const box = (await panel.boundingBox())!
expect(box.x + box.width).toBeGreaterThanOrEqual(viewport.width - 2)
})
test('ColorPicker:取色区与色相条在 RTL 下不镜像(横轴是颜色空间,不是行内方向)', async ({
page,
}) => {
/**
* 这条锁的是「刻意不镜像」:取色区底下是 `linear-gradient(to right, …)`(物理方向,
* [dir='rtl'] 不翻转),所以 JS 的取值方向必须与渲染同向。
* 若后人"顺手"把 left 改成 inset-inline-start,光标就会和它底下的颜色对不上。
*/
for (const dir of ['ltr', 'rtl'] as const) {
await gotoDir(page, dir)
// 用组件自己的类名定位:G3ColorPicker 根是 G3Popover(多根:触发器 + Teleport),
// 多根组件不做 attr 透传,data-testid 会被 Vue 丢弃,不能作为定位锚点。
await page.locator('#s-color-picker .g3-color-picker').first().click()
const area = page.locator('.g3-color-picker__area')
await expect(area).toBeVisible()
// 取色区左下角应是低饱和 / 低明度(接近黑),右上角是高饱和高明度
const readAt = async (fx: number, fy: number) => {
const box = (await area.boundingBox())!
await page.mouse.click(box.x + box.width * fx, box.y + box.height * fy)
return page.locator('.g3-color-picker__hex-input').inputValue()
}
// 左端取色 → 饱和度低;右端取色 → 饱和度高。两个方向都要是「左低右高」
const left = await readAt(0.02, 0.5)
const right = await readAt(0.98, 0.5)
const satOf = (hex: string) => {
const n = parseInt(hex.slice(1), 16)
const r = (n >> 16) & 255
const g = (n >> 8) & 255
const b = n & 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
return max === min ? 0 : (max - min) / max
}
expect(satOf(right), `${dir}: 右端应比左端饱和`).toBeGreaterThan(satOf(left))
await page.keyboard.press('Escape')
}
})
})