import { expect, test } from '@playwright/test' /** * 组件细节回归 —— 每一条都对应一个真实报障,之前全部漏测。 * * 共同点:它们要么靠布局计算(滑块尺寸、等高、线性位移),要么靠伪类层叠 * (:hover 的特异性、相邻边框的 z-index),要么靠 CSS 变量的实际解析结果 * (color-mix 里的 var())。这些在 happy-dom 里量不出来,必须放 E2E。 */ test.describe('ButtonGroup', () => { test('横向:hover 的按钮浮到上层,被压住的那条边框仍可见', async ({ page }) => { await page.goto('/') const second = page.getByTestId('btn-group-plain').locator('.g3-button').nth(1) await second.hover() // 相邻按钮用 -1px 负边距重叠,不抬 z-index 时后一个按钮的边框会盖住它 await expect(second).toHaveCSS('z-index', '1') // 边框色有 0.2s 过渡:必须用会重试的断言,直接 evaluate 会读到过渡中间值 await expect(second).toHaveCSS('border-top-color', 'rgb(24, 24, 27)') }) test('纵向:hover 的按钮同样抬升', async ({ page }) => { await page.goto('/') const second = page.getByTestId('btn-group-vertical').locator('.g3-button').nth(1) await second.hover() await expect(second).toHaveCSS('z-index', '1') await expect(second).toHaveCSS('border-top-color', 'rgb(24, 24, 27)') }) }) test.describe('Segmented', () => { test('选中滑块与选中项同尺寸同位置(滑块曾塌成 0 高,看不出选中)', async ({ page }) => { await page.goto('/') // 必须限定在同一个实例内:页面上有多组 Segmented,跨实例比较会拿到别人的滑块 const group = page.locator('.g3-segmented__group').first() const thumb = group.locator('.g3-segmented__thumb') const item = group.locator('.g3-segmented__item.is-selected') const t = (await thumb.boundingBox())! const i = (await item.boundingBox())! // 滑块尺寸来自 offsetWidth/offsetHeight(整数),实际布局是亚像素,允许 1px 误差 for (const [name, a, b] of [ ['width', t.width, i.width], ['height', t.height, i.height], ['x', t.x, i.x], ['y', t.y, i.y], ] as const) { expect(Math.abs(a - b), `滑块与选中项的 ${name} 不一致`).toBeLessThanOrEqual(1) } expect(t.height).toBeGreaterThan(0) // 滑块就是选中底色,不能是透明的 expect(await thumb.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe( 'rgb(255, 255, 255)', ) }) }) test.describe('Pagination', () => { test('hover 当前页仍保持主色填充(曾被通用 hover 覆盖成白底白字)', async ({ page }) => { await page.goto('/') const current = page.locator('.g3-pagination__page.is-page-current') await current.hover() await expect(current).toHaveCSS('background-color', 'rgb(24, 24, 27)') await expect(current).toHaveCSS('color', 'rgb(250, 250, 250)') }) }) test.describe('Tooltip', () => { test('气泡没有描边(投影由 popup 的 drop-shadow 统一施加)', async ({ page }) => { await page.goto('/') await page.getByTestId('tooltip-trigger').hover() const content = page.locator('.g3-popover__content').first() await expect(content).toBeVisible() await expect(content).toHaveCSS('border-top-width', '0px') await expect(content).toHaveCSS('border-left-width', '0px') }) }) test.describe('Tabs 垂直方向', () => { for (const placement of ['left', 'right'] as const) { test(`${placement}:标签栏与内容区之间有竖向分隔线,选中项带侧边指示线与底色`, async ({ page, }) => { await page.goto('/') const tabs = page.locator(`.g3-tabs--${placement}`).first() const nav = tabs.locator('.g3-tabs__nav') const active = tabs.locator('.g3-tabs__tab.is-tab-active').first() if (placement === 'left') { await expect(nav).toHaveCSS('border-right-width', '1px') await expect(active).toHaveCSS('border-right-width', '2px') } else { await expect(nav).toHaveCSS('border-left-width', '1px') await expect(active).toHaveCSS('border-left-width', '2px') } // 选中项底色不再是 transparent const bg = await active.evaluate((el) => getComputedStyle(el).backgroundColor) expect(bg).not.toBe('rgba(0, 0, 0, 0)') }) } }) test.describe('Tree', () => { test('点击节点有选中底色(token color-mix 曾解析失败导致背景透明)', async ({ page }) => { await page.goto('/') const node = page.locator('.g3-tree-node', { hasText: '节点一' }).first() await node.click() await expect(node).toHaveClass(/is-selected/) const bg = await node.evaluate((el) => getComputedStyle(el).backgroundColor) expect(bg, `选中底色不应为透明:${bg}`).not.toBe('rgba(0, 0, 0, 0)') }) test('整层都是叶子时不渲染展开箭头占位', async ({ page }) => { await page.goto('/') // 扁平树:两个节点都没有子节点 const flatTree = page.locator('.g3-tree').filter({ hasText: '节点一(无子节点)' }).first() await expect(flatTree.locator('.g3-tree-node')).toHaveCount(2) await expect(flatTree.locator('.g3-tree-node__switcher')).toHaveCount(0) }) test('拖拽需要长按武装:直接拖动不会产生落点', async ({ page }) => { await page.goto('/') const draggableTree = page.locator('.g3-tree').filter({ hasText: '节点一(无子节点)' }) // 用带 draggable 的那棵树里的第一行 const row = page.locator('.g3-tree-node').first() const box = (await row.boundingBox())! await page.mouse.move(box.x + 40, box.y + box.height / 2) await page.mouse.down() // 立刻移动(未到 400ms 阈值):draggable 属性还没武装 await page.mouse.move(box.x + 60, box.y + box.height / 2 + 30, { steps: 3 }) expect(await row.getAttribute('draggable')).toBeNull() await page.mouse.up() expect(await draggableTree.count()).toBeGreaterThan(0) }) }) test.describe('DatePicker 时间列', () => { test('时/分/秒三列并排:定宽、列间分隔线、列表与日历等高且可滚动', async ({ page }) => { await page.goto('/') await page.locator('.g3-date-picker__input').first().click() const calendar = page.locator('.g3-calendar') await expect(calendar).toBeVisible() const cols = calendar.locator('.g3-time-select__col') await expect(cols).toHaveCount(3) await expect(cols.first()).toHaveCSS('width', '56px') await expect(cols.nth(1)).toHaveCSS('border-left-width', '1px') const main = (await calendar.locator('.g3-calendar__main').boundingBox())! const time = (await calendar.locator('.g3-calendar__time').boundingBox())! expect(Math.round(main.height)).toBe(Math.round(time.height)) const list = calendar.locator('.g3-time-select__list').first() const listBox = (await list.boundingBox())! expect(Math.round(listBox.height)).toBe(Math.round(main.height)) expect( await list.evaluate((el) => el.scrollHeight > el.clientHeight), '时间列应当内部滚动而不是撑开面板', ).toBe(true) // 单个选项 28px 高 await expect(calendar.locator('.g3-time-select__item').first()).toHaveCSS('height', '28px') }) test('年 / 月选择面板:3 列 × 4 行,标题显示年份区间,当前年有标记', async ({ page }) => { await page.goto('/') await page.locator('.g3-date-picker__input').first().click() await page.locator('.g3-calendar__title-button').first().click() const selector = page.locator('.g3-calendar__selector') await expect(selector).toBeVisible() await expect(selector.locator('.g3-calendar__selector-item')).toHaveCount(12) await expect(selector).toHaveCSS('grid-template-columns', /(\d+(\.\d+)?px ){2}/) const title = await page.locator('.g3-calendar__title-button').first().textContent() expect(title).toMatch(/\d{4}\s*-\s*\d{4}/) await expect(selector.locator('.g3-calendar__selector-item.is-selected')).toHaveCount(1) }) }) test.describe('Splitter 拖拽', () => { test('面板宽度随指针线性变化(曾经一拖就跳)', async ({ page }) => { await page.goto('/') const bar = page.locator('.g3-splitter__bar').first() // mouse API 用视口坐标且不会自动滚动,元素不在视口内时拖拽会落空 await bar.scrollIntoViewIfNeeded() const box = (await bar.boundingBox())! const widths = () => page.$$eval('.g3-splitter-panel', (els) => els.map((el) => Math.round(el.getBoundingClientRect().width)), ) const before = await widths() const startX = box.x + box.width / 2 const y = box.y + box.height / 2 await page.mouse.move(startX, y) await page.mouse.down() const samples: number[][] = [] for (const dx of [20, 40, 60]) { await page.mouse.move(startX + dx, y) await page.waitForTimeout(60) samples.push(await widths()) } await page.mouse.up() // 每移动 20px,左侧面板就该宽 20px(严格 1:1,不是跳到某个值) samples.forEach((sample, index) => { const delta = sample[0] - before[0] expect(delta, `第 ${index + 1} 次移动后左侧宽度增量`).toBeGreaterThanOrEqual((index + 1) * 20 - 2) expect(delta).toBeLessThanOrEqual((index + 1) * 20 + 2) }) }) })