Files
workspace/code/g3soft-libs/packages/ui/e2e/table.spec.ts
T
2026-10-13 17:29:55 +08:00

536 lines
23 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'
/**
* G3Table 的几何 / 命中类断言 —— happy-dom 没有布局引擎,这些只能在真浏览器里量。
*
* 用例只按 data-testid 找元素(不依赖文案与结构),宿主页在 playground/main.ts。
*/
test.describe('G3Table 表格', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.locator('[data-testid="table"]').scrollIntoViewIfNeeded()
})
test('基础渲染:表头与数据行都在,且序号列存在', async ({ page }) => {
const table = page.locator('[data-testid="table-wrap"]')
await expect(table.locator('.stk-table')).toBeVisible()
// 表头至少有我们的业务列文案
await expect(table.locator('th', { hasText: '名称' })).toBeVisible()
await expect(table.locator('th', { hasText: '金额' })).toBeVisible()
// 数据行渲染出来了
const rows = table.locator('.stk-tbody-main tr')
await expect(rows).toHaveCount(3)
})
test('小数据量(3 行):纵向虚拟滚动关闭,DOM 里是真实 3 行', async ({ page }) => {
const table = page.locator('[data-testid="table-wrap"]')
// 库开启虚拟滚动时会给根加 .virtual 类;门控生效时不应有
await expect(table.locator('.stk-table')).not.toHaveClass(/virtual/)
})
test('大数据量(5000 行):纵向虚拟滚动开启,DOM 行数远少于数据量', async ({ page }) => {
const big = page.locator('[data-testid="table-big-wrap"]')
await big.scrollIntoViewIfNeeded()
await expect(big.locator('.stk-table')).toHaveClass(/virtual/)
// 虚拟滚动的意义:只渲染视口内若干行,DOM 行数应当是个位数/几十,而不是 5000
const rendered = await big.locator('.stk-tbody-main tr').count()
expect(rendered).toBeGreaterThan(0)
expect(rendered).toBeLessThan(200)
})
test('大数据量:滚动后仍只渲染视口内行(虚拟窗口跟随)', async ({ page }) => {
const big = page.locator('[data-testid="table-big-wrap"]')
await big.scrollIntoViewIfNeeded()
const scroller = big.locator('.stk-table-scroll-container, .stk-table-main').first()
await scroller.evaluate((el) => {
el.scrollTop = 3000
})
await page.waitForTimeout(150)
const rendered = await big.locator('.stk-tbody-main tr').count()
expect(rendered).toBeGreaterThan(0)
expect(rendered).toBeLessThan(200)
})
test('高度约束:容器限高时表格不溢出', async ({ page }) => {
const wrap = page.locator('[data-testid="table-wrap"]')
const wrapBox = await wrap.boundingBox()
const inner = wrap.locator('.stk-table').first()
const innerBox = await inner.boundingBox()
expect(wrapBox).not.toBeNull()
expect(innerBox).not.toBeNull()
// 表格不应高于它的容器(留 2px 容差:边框/亚像素)
expect(innerBox!.height).toBeLessThanOrEqual(wrapBox!.height + 2)
})
test('边框由库绘制:不再有自绘外框与圆角', async ({ page }) => {
// 组件已改为把边框完全交给库(传 bordered: true),自己不再画外框 / 圆角。
// data-testid 透传给内层 StkTable(组件 inheritAttrs: false),所以按类名定位根节点。
const root = page.locator('[data-testid="table-wrap"] .g3-table').first()
await expect(root).toHaveClass(/is-bordered/)
// 自绘外框已删除:根节点不该再有 border / 圆角
const radius = await root.evaluate((el) => getComputedStyle(el).borderRadius)
expect(radius).toBe('0px')
const borderWidth = await root.evaluate((el) => getComputedStyle(el).borderTopWidth)
expect(borderWidth).toBe('0px')
// 边框改由库的 .bordered 提供
await expect(page.locator('[data-testid="table-wrap"] .stk-table').first()).toHaveClass(
/bordered/,
)
})
test('表头显示序号列(seq 默认开启)', async ({ page }) => {
const table = page.locator('[data-testid="table-wrap"]')
await expect(table.locator('th', { hasText: '序号' })).toBeVisible()
})
test('窄容器下不横向溢出容器宽度', async ({ page }) => {
const wrap = page.locator('[data-testid="table-narrow-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const wrapBox = await wrap.boundingBox()
const rootBox = await wrap.locator('[data-testid="table-narrow"]').boundingBox()
expect(rootBox!.width).toBeLessThanOrEqual(wrapBox!.width + 2)
})
})
test.describe('G3Table 选择列(真浏览器命中)', () => {
test('点击行复选框:勾选态可见地变化', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const firstRowCheckbox = wrap.locator('.g3-table__checkbox').first()
await firstRowCheckbox.scrollIntoViewIfNeeded()
await expect(firstRowCheckbox).not.toHaveClass(/is-checked/)
await firstRowCheckbox.click()
await expect(firstRowCheckbox).toHaveClass(/is-checked/)
})
test('每行复选框数 = 选择列 + checkbox 编辑器列(演示数据定义了两列)', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
// 演示数据的 tableColumns 里有两个复选框列:选择列(注入)+ 启用列(editor: checkbox)。
// 所以每行 2 个是预期值——这正是为什么不能靠「数复选框个数」判断是否有重复渲染。
const bodyRows = wrap.locator('.stk-tbody-main tr')
await expect(bodyRows).toHaveCount(3)
for (let i = 0; i < 3; i += 1) {
await expect(bodyRows.nth(i).locator('.g3-table__checkbox')).toHaveCount(2)
}
})
test('无重复渲染:每行 td 数等于表头列数', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
// 单元格数必须与表头列数一致;固定列若被双份渲染,这里会多出来。
const headerCount = await wrap.locator('thead th').count()
const firstRowTd = await wrap.locator('.stk-tbody-main tr').first().locator('td').count()
expect(firstRowTd).toBe(headerCount)
})
test('表头全选:点击后表头与所有数据行的选择列都被勾选', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const headerCheckbox = wrap.locator('th .g3-table__checkbox').first()
await headerCheckbox.scrollIntoViewIfNeeded()
await headerCheckbox.click()
// 表头 choice 项 + 3 行选择列 = 4 个处于勾选态的选择框
// (「启用」列的 checkbox 是数据值,初始为 1 的那两行也会是勾选态,
// 所以这里按选择列的固定类名限定范围)
await expect(wrap.locator('th .g3-table__checkbox.is-checked')).toHaveCount(1)
await expect(wrap.locator('.stk-tbody-main td:first-child .g3-table__checkbox.is-checked')).toHaveCount(3)
})
})
/**
* 行内编辑的几何契约。
*
* happy-dom 没有布局引擎,这些只能在真浏览器里量:编辑器的盒高必须等于行高,
* 且进入编辑**不得改变**所在行的高度(同行的纯文本列也不能跟着跳)。
*/
test.describe('G3Table 行内编辑:无外间距、无常驻边框', () => {
/** 量表格里每一行的高度(取整) */
async function rowHeights(page: import('@playwright/test').Page, wrapSelector: string) {
return page.locator(wrapSelector).locator('.stk-tbody-main tr').evaluateAll((rows) =>
rows.map((el) => Math.round(el.getBoundingClientRect().height)),
)
}
/**
* 按**列标题**定位单元格,不写死列序号。
*
* 写死 `td:nth-child(n)` 是错的来源:列顺序由调用方决定(演示数据里
* 「备注」是第 7 列、「状态」是第 5 列),列一变断言就指到别的格子,
* 表现为「莫名其妙失败」。
*/
async function cellByTitle(
page: import('@playwright/test').Page,
wrapSelector: string,
title: string,
) {
const wrap = page.locator(wrapSelector)
const index = await wrap
.locator('thead th')
.evaluateAll((ths, t) => ths.findIndex((th) => (th.textContent || '').trim() === t), title)
if (index < 0) throw new Error(`找不到列标题「${title}」`)
return wrap.locator('.stk-tbody-main tr').first().locator('td').nth(index)
}
/**
* 编辑器根节点选择器。
*
* ⚠️ 不能只用 `.g3-table__cell-editor-root`:checkbox 编辑器也用同一个类
* (`label.g3-table__checkbox.g3-table__cell-editor-root`),演示数据里每行
* 有两个复选框列,于是 strict mode 直接报「resolved to 3 elements」。
* 这里只取 Input / Select 这两种「带外框的」编辑器。
*/
const EDITOR = '.g3-table__cell-editor-root.g3-input, .g3-table__cell-editor-root.g3-select'
test('根节点带 is-editor-seamless(默认开启)', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
await expect(wrap.locator('.g3-table').first()).toHaveClass(/is-editor-seamless/)
})
test('进入编辑:行高不变,且编辑器铺满单元格(无外间距)', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const before = await rowHeights(page, '[data-testid="table-wrap"]')
await (await cellByTitle(page, '[data-testid="table-wrap"]', '备注')).click()
const editor = wrap.locator(EDITOR).first()
await expect(editor).toBeVisible()
// 编辑器盒高 == 所在 td 的高(td 上下 padding 为 0 → 两者相等)
const metrics = await editor.evaluate((el) => {
const td = el.closest('td') as HTMLElement
const style = getComputedStyle(el)
const tdStyle = getComputedStyle(td)
return {
editor: Math.round(el.getBoundingClientRect().height),
td: Math.round(td.getBoundingClientRect().height),
tdPadTop: tdStyle.paddingTop,
tdPadBottom: tdStyle.paddingBottom,
border: style.borderTopWidth,
radius: style.borderTopLeftRadius,
background: style.backgroundColor,
}
})
// 无外间距:编辑器与格同高,td 垂直 padding 为 0(编辑器不被内缩)
expect(metrics.editor).toBe(metrics.td)
expect(metrics.tdPadTop).toBe('0px')
expect(metrics.tdPadBottom).toBe('0px')
// 无常驻边框 / 圆角 / 底色
expect(metrics.border).toBe('0px')
expect(metrics.radius).toBe('0px')
expect(metrics.background).toBe('rgba(0, 0, 0, 0)')
// 关键回归:编辑后行高不变(历史 bug 是从 30px 变 42px)
expect(await rowHeights(page, '[data-testid="table-wrap"]')).toEqual(before)
})
test('Input 编辑器:内边距转移到内层原生 input,不落在外壳上', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
await (await cellByTitle(page, '[data-testid="table-wrap"]', '备注')).click()
await expect(wrap.locator('.g3-table__cell-editor-root.g3-input').first()).toBeVisible()
const padding = await wrap
.locator('.g3-table__cell-editor-root.g3-input')
.first()
.evaluate((el) => ({
shell: getComputedStyle(el).padding,
// 精确取原生控件;`.g3-input input` 会连 password/clear 按钮一起算进来
control: getComputedStyle(el.querySelector('.g3-input__control') as HTMLElement).padding,
}))
// 外壳无内边距 = 无外间距;网格间距由内层控件补回
expect(padding.shell).toBe('0px')
expect(padding.control).not.toBe('0px')
})
test('焦点态才出现视觉提示:单元格内嵌焦点环', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const target = await cellByTitle(page, '[data-testid="table-wrap"]', '备注')
// 未聚焦时该格没有内嵌焦点环
const idle = await target.evaluate((el) => getComputedStyle(el).boxShadow)
expect(idle).not.toContain('inset')
await target.click()
await expect(wrap.locator(EDITOR).first()).toBeVisible()
// 焦点环画在**被点的那一格**上(限定 target,避免匹配到同行其它格)
const focused = await target.evaluate((el) => getComputedStyle(el).boxShadow)
expect(focused).toContain('inset')
})
test('select 编辑器同样铺满:触发器的描边 / 圆角 / 32px 下限都被去掉', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const before = await rowHeights(page, '[data-testid="table-wrap"]')
await (await cellByTitle(page, '[data-testid="table-wrap"]', '状态')).click()
const select = wrap.locator('.g3-table__cell-editor-root.g3-select').first()
await expect(select).toBeVisible()
/**
* 关键:量**触发器**而不是组件根。
*
* 触发器的真实 DOM 位置是 `div.g3-select > [Popover] > span.g3-select__trigger`,
* 不在根的直接子树里 —— 只量根会漏掉真正带描边/圆角的那个元素
* (这正是此前「改了但看着没变」的原因)。
*/
const metrics = await select.evaluate((el) => {
const trigger = el.querySelector('.g3-select__trigger') as HTMLElement
const s = getComputedStyle(trigger)
const root = getComputedStyle(el)
return {
rootClass: el.className,
triggerHeight: Math.round(trigger.getBoundingClientRect().height),
triggerBorder: s.borderTopWidth,
triggerRadius: s.borderTopLeftRadius,
triggerBg: s.backgroundColor,
rootBorder: root.borderTopWidth,
rootRadius: root.borderTopLeftRadius,
}
})
// 无缝修饰类必须真的落到 G3Select 根上(否则下面的样式规则不生效)
expect(metrics.rootClass).toContain('is-seamless')
expect(metrics.triggerBorder).toBe('0px')
expect(metrics.triggerRadius).toBe('0px')
expect(metrics.triggerBg).toBe('rgba(0, 0, 0, 0)')
expect(metrics.rootBorder).toBe('0px')
expect(metrics.rootRadius).toBe('0px')
// 触发器不再锁 32px(行高 30px),否则会把行顶高 2px
const rowHeight = await wrap
.locator('.stk-tbody-main tr')
.first()
.evaluate((el) => Math.round(el.getBoundingClientRect().height))
expect(metrics.triggerHeight).toBeLessThanOrEqual(rowHeight)
expect(await rowHeights(page, '[data-testid="table-wrap"]')).toEqual(before)
})
})
/**
* 校验态、列宽拖拽、当前行高亮 —— 这三条是本组件反复回归过的行为,
* 专门用真浏览器锁住(happy-dom 没有布局引擎,也测不了 :has() 命中)。
*
* 宿主导入的 playground 数据里:
* - 金额列 validate:> 0 否则 error(第 2 行 amount=0 → 必有一处 error)
* - 备注列 validateWarning:非空否则 warning(第 2 行 remark='' → 必有一处 warning)
* - table-wrap 绑了 v-model:columns(列宽拖拽的前提,缺了拖不动)
*/
test.describe('G3Table 校验态 / 列宽拖拽 / 行高亮', () => {
test('校验态全表可见:不点任何行就能看到 error 与 warning', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
// 关键前提:进入页面后**不点击任何单元格**,直接断言校验态存在。
// 这锁的是「校验是数据的状态,不依赖哪一行被交互过」——此前 text/number
// 列在非编辑态走库原生 td,校验态根本不渲染(必须点行才出现)。
const errCells = wrap.locator('td:has(> .is-editor-error)')
const warnCells = wrap.locator('td:has(> .is-editor-warning)')
await expect(errCells).toHaveCount(1) // 金额列第 2 行 amount=0
await expect(warnCells).toHaveCount(1) // 备注列第 2 行 remark=''
})
test('校验态落在正确的列上(error=金额、warning=备注)', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
// 用列标题定位,不写死索引
const ths = await wrap.locator('thead th').evaluateAll((all) =>
all.map((t) => (t.textContent || '').trim()),
)
const amountIdx = ths.findIndex((t) => t === '金额')
const remarkIdx = ths.findIndex((t) => t === '备注')
// 第 2 行(合同 B)的金额格是 error、备注格是 warning
const row = wrap.locator('.stk-tbody-main tr').nth(1)
await expect(row.locator('td').nth(amountIdx).locator('.is-editor-error')).toHaveCount(1)
await expect(row.locator('td').nth(remarkIdx).locator('.is-editor-warning')).toHaveCount(1)
})
test('列宽拖拽:绑定 v-model:columns 后可拖动,宽度真实变化', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
// 表格应带 col-resizable 类(列宽拖拽已启用)
await expect(wrap.locator('.stk-table')).toHaveClass(/col-resizable/)
// 取「名称」列表头宽度(第 2 个 th,序号列是第 1 个)
const th = wrap.locator('thead th').nth(2)
const before = await th.evaluate((el) => Math.round(el.getBoundingClientRect().width))
// 拖它的右边缘手柄 +60px
const handle = th.locator('.table-header-resizer.right')
const hb = await handle.boundingBox()
expect(hb).not.toBeNull()
if (hb) {
const cx = hb.x + hb.width / 2
const cy = hb.y + hb.height / 2
await page.mouse.move(cx, cy)
await page.mouse.down()
for (let i = 1; i <= 15; i += 1) {
await page.mouse.move(cx + i * 4, cy)
await page.waitForTimeout(15)
}
await page.mouse.up()
await page.waitForTimeout(500)
}
const after = await th.evaluate((el) => Math.round(el.getBoundingClientRect().width))
expect(after).toBeGreaterThan(before)
})
test('点击行进入编辑时,当前行高亮(active)跟随', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const rows = wrap.locator('.stk-tbody-main tr')
// 初始无 active 行
const activeCount = async () =>
wrap.locator('.stk-tbody-main tr.active').count()
expect(await activeCount()).toBe(0)
// 点第 2 行的一个可编辑格(金额列),进入编辑并高亮
const ths = await wrap.locator('thead th').evaluateAll((all) =>
all.map((t) => (t.textContent || '').trim()),
)
const amountIdx = ths.findIndex((t) => t === '金额')
await rows.nth(1).locator('td').nth(amountIdx).click()
await page.waitForTimeout(300)
// 第 2 行应带 active
await expect(rows.nth(1)).toHaveClass(/active/)
expect(await activeCount()).toBe(1)
})
})
/**
* 这两条盯的是「库的 flex 布局 + 自绘分隔线」这对组合:
* 库的 DOM 里 table.stk-table-main 是 .stk-table-scroll-container 的 flex item,
* 库默认给 flex:1,而表头 th 靠 position:sticky 固定,一旦 table 被拉伸,
* sticky 几何就会失效(表头跟着滚、分隔线错位叠线)。
*/
test.describe('G3Table 布局:表头固定与分隔线', () => {
test('table 不被拉伸:.stk-table-main 是 flex:none(非 autoFitWidth)', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const flex = await wrap
.locator('.stk-table-main')
.first()
.evaluate((el) => getComputedStyle(el).flexGrow)
expect(flex).toBe('0')
})
test('滚动后表头仍固定在容器顶部', async ({ page }) => {
await page.goto('/')
const big = page.locator('[data-testid="table-big-wrap"]')
await big.scrollIntoViewIfNeeded()
const header = big.locator('thead th').first()
const before = await header.boundingBox()
// 滚动表体
const scroller = big.locator('.stk-table-scroll-container').first()
await scroller.evaluate((el) => {
el.scrollTop = 1200
})
await page.waitForTimeout(200)
const after = await header.boundingBox()
expect(before).not.toBeNull()
expect(after).not.toBeNull()
// 表头是 sticky:滚动前后它相对视口的位置不应发生位移
expect(Math.abs(after!.y - before!.y)).toBeLessThan(2)
})
test('边框交由库绘制:库根节点带 .bordered,且不再叠加自绘实线', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
// 我们传 bordered: true,库会给根节点挂 .bordered(并因是字符串才挂 bordered-xxx)
const stk = wrap.locator('.stk-table').first()
await expect(stk).toHaveClass(/bordered/)
// 自绘的行分隔线已删除,单元格不应再有真的 border-bottom
// (库用 background-image 渐变画线,不走 border)
const td = await wrap
.locator('.stk-tbody-main td')
.first()
.evaluate((el) => getComputedStyle(el).borderBottomWidth)
expect(td).toBe('0px')
})
test('库自绘边框:单元格带可见的横竖渐变线', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
// 库的 .bordered th,td 用 background-image 画 --bg-border-right / --bg-border-bottom。
// 这里只断言「至少有一条可见渐变线」,不锁具体层数——那是库的内部实现。
const layers = await wrap
.locator('.stk-tbody-main td')
.first()
.evaluate((el) => {
const bg = getComputedStyle(el).backgroundImage
return bg
.split(/,(?![^(]*\))/)
.map((s) => s.trim())
.filter((s) => /rgb\(\d+, \d+, \d+\)/.test(s) && !/rgba\(0, 0, 0, 0\)/.test(s)).length
})
expect(layers).toBeGreaterThan(0)
})
})
test.describe('G3Table RTL 镜像', () => {
test('RTL 下固定列与表格布局不塌陷', async ({ page }) => {
await page.goto('/?dir=rtl')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const wrapBox = await wrap.boundingBox()
const tableBox = await wrap.locator('.stk-table').first().boundingBox()
expect(tableBox).not.toBeNull()
// 表格仍应占据容器的整宽(镜像不该把宽度算丢)
expect(tableBox!.width).toBeGreaterThan(wrapBox!.width * 0.9)
})
})