Files
workspace/code/g3soft-libs/packages/ui/e2e/table.spec.ts
T
2026-10-14 22:24:12 +08:00

1222 lines
57 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 }) => {
// 边框设计(见 docs/table-design.md §3.2):外框与横线由组件自绘,纵向列线用库的
// 渐变。组件把 `bordered` 翻成库的 'v' 档(见 G3Table.vue 的 `:bordered` 绑定)。
// data-testid 透传给内层 StkTable(组件 inheritAttrs: false),所以按类名定位根节点。
const root = page.locator('[data-testid="table-wrap"] .g3-table').first()
await expect(root).toHaveClass(/is-bordered/)
// 自绘外框:根节点上有 1px 描边 + 圆角(库的外框做不出圆角,这正是自绘的理由)
const outer = await root.evaluate((el) => {
const s = getComputedStyle(el)
return { width: s.borderTopWidth, radius: s.borderTopLeftRadius }
})
expect(outer.width).toBe('1px')
expect(outer.radius).not.toBe('0px')
// 库那层外框必须被抹掉:'v' 档是字符串,库会同时挂 .bordered 与 .bordered-v,
// 前者的 `border-left` 若留着,就和自绘外框叠成双线
const stk = page.locator('[data-testid="table-wrap"] .stk-table').first()
await expect(stk).toHaveClass(/bordered-v/)
expect(await stk.evaluate((el) => getComputedStyle(el).borderLeftWidth)).toBe('0px')
})
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)
})
/**
* select 的编辑格底色。
*
* 回归的 bug:编辑格的白底判据曾经是 `td:focus-within`,而 **G3Select 展开时会把 DOM
* 焦点移进 Teleport 到 body 的下拉浮层** —— 那一刻 td 的 `:focus-within` 立刻失效,
* 底色退回库的选区色(`--cs-bgc`,浅蓝)。表现是「select 编辑格时白时不白」,
* 而 input 没有浮层、永远正常。
*
* 所以这里必须在**浮层已夺焦**的状态下断言,用别的方式(例如只看点击后一瞬间)
* 是测不出来的。判据改为编辑器上的 `is-cell-editing` 类(见 editable.ts)。
*/
test('select 展开(焦点被浮层拿走)后,编辑格仍是基色而不是选区色', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const cell = await cellByTitle(page, '[data-testid="table-wrap"]', '状态')
await cell.click()
// 前置条件:浮层确实拿到了焦点,且焦点已不在表格里(这正是修前的失效条件)
await expect(page.locator('.g3-select__dropdown')).toBeVisible()
expect(await page.evaluate(() => !document.activeElement?.closest('.stk-table'))).toBe(true)
const measured = await cell.evaluate((el) => ({
bg: getComputedStyle(el).backgroundColor,
marked: !!el.querySelector('.is-cell-editing'),
}))
// 机制在:编辑器带着「当前编辑格」标记(td 侧靠 :has(> …) 命中)
expect(measured.marked).toBe(true)
// 底色是基色(亮色主题下即 #fff),不是库的选区色 #d3eafd
expect(measured.bg).toBe('rgb(255, 255, 255)')
})
})
/**
* 校验态、列宽拖拽、当前行高亮 —— 这三条是本组件反复回归过的行为,
* 专门用真浏览器锁住(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('不可改的格子灰底常驻:整列只读 / 按行禁用,都不需要先点开某行', async ({ page }) => {
/**
* 判据是「**这一格现在改不了**」,不再是「编辑行里没有编辑器的格子」——
* 后者只在被点过的那一行可见,用户仍要一行行点过去才发现某格点不动。
*
* 两种来源分别锁:整列只读(列上没有 editor,走 td 类名)与按行禁用
* (`disabled` 是函数,走内容节点标记);顺带反向断言可编辑列**不**灰。
*/
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const rows = wrap.locator('.stk-tbody-main tr')
const bgOf = (selector: string, rowIndex: number) =>
rows.nth(rowIndex).locator(selector).evaluate((el) => getComputedStyle(el).backgroundColor)
// 「名称」列没有 editor(整列只读)→ 进页面就是灰的
expect(await bgOf('td[data-col-key="name"]', 0)).not.toBe('rgba(0, 0, 0, 0)')
// 「金额」列有 editor → 不灰
expect(await bgOf('td[data-col-key="amount"]', 0)).toBe('rgba(0, 0, 0, 0)')
// 按行禁用:第 2 行 enabled=0 → 状态列灰;第 1 行不灰
expect(await bgOf('td[data-col-key="status"]', 1)).not.toBe('rgba(0, 0, 0, 0)')
expect(await bgOf('td[data-col-key="status"]', 0)).toBe('rgba(0, 0, 0, 0)')
})
/**
* 灰底的门控:**这张表里得先有人能编辑**,灰底才是信息。
*
* 灰底的语义是「这一格现在改不了」,它靠「旁边有可改的格子」传达意义。
* 一张纯展示表(列上都没有 `editor`)里每格都改不了,若照旧逐格铺灰,
* 整张表看上去就是「禁用状态」—— 文档 7 张 demo 里有 6 张是这种纯展示表,
* 用户看到的就是「demo 一片灰、像是被禁用了」。
*
* 两个反例都锁上:
* · `table-plain-wrap`:列上根本没有 `editor`;
* · `table-noedit-wrap`:列上有 `editor`,但 `editable: false` 把编辑整个关掉。
* 正向(可编辑表里的只读列**仍然**常驻灰底)由上面那条用例守着,两条一起构成门控。
*/
test('纯展示表 / editable: false:不铺「不可改」灰底', async ({ page }) => {
await page.goto('/')
const greyCount = async (wrapTestId: string) =>
page
.locator(`[data-testid="${wrapTestId}"]`)
.locator('.stk-tbody-main td')
.evaluateAll(
(tds) =>
tds.filter(
(td) =>
// 整列只读:类名落在 td 上
td.classList.contains('g3-table__cell-readonly') ||
// 按行禁用:标记落在内容节点上
!!td.querySelector('.g3-table__cell-readonly'),
).length,
)
// 纯展示表:一个灰格都不该有
expect(await greyCount('table-plain-wrap')).toBe(0)
// editable: false:同理(列上声明了 editor 也不算「有人能编辑」)
expect(await greyCount('table-noedit-wrap')).toBe(0)
// 对照:可编辑的 table-wrap 里,没有 editor 的「名称」列仍然有灰底(门控不能把这条改坏)
expect(await greyCount('table-wrap')).toBeGreaterThan(0)
})
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)
})
/**
* 编辑态让位:正在编辑的校验格**不铺校验底色**,底色回到基色。
*
* 原因(实际录入反馈):铺着底色时输入区被染色、对比度下降,录入时「看不清」。
* 编辑时的校验信号改由**语义色焦点环 + 红字**承载;离开该格后底色回来。
*
* 判据是编辑器上的 `is-cell-editing` 标记,不是 `:focus-within`:
* select 展开时焦点被移进 Teleport 到 body 的浮层,`:focus-within` 会时有时无。
*
* 顺带锁住「校验格与普通格内边距一致」:右上角的 `!` 角标已**整体去掉**,
* 为它让位的 `padding-right: 14px` 也一并删除 —— 录入区不该被缩窄。
*/
test('编辑校验格:底色回到基色;换格后恢复;不再为角标留右边距', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
// 宿主第 2 行「金额」= 0 → validate 不通过,是这张表里的 error 格
const errCell = wrap.locator('.stk-tbody-main tr').nth(1).locator('td[data-col-key="amount"]')
const read = () =>
errCell.evaluate((el) => ({
bg: getComputedStyle(el).backgroundColor,
badge: getComputedStyle(el, '::after').content,
shadow: getComputedStyle(el).boxShadow,
}))
// ① 未编辑:铺校验底色;角标已去掉(::after 无内容)
const idle = await read()
expect(idle.bg).not.toBe('rgb(255, 255, 255)')
expect(idle.badge).toBe('none')
// 内边距与同列的普通格一致(没有为角标让位的 14px)
const displayPad = (rowIndex: number) =>
wrap
.locator('.stk-tbody-main tr')
.nth(rowIndex)
.locator('td[data-col-key="amount"] .g3-table__cell-display')
.evaluate((el) => getComputedStyle(el).paddingRight)
expect(await displayPad(1)).toBe(await displayPad(0))
// ② 进入编辑:底色 = 基色(亮色主题即 #fff),只剩语义色焦点环
await errCell.click()
await expect(wrap.locator('.is-cell-editing')).toHaveCount(1)
const editing = await read()
expect(editing.bg).toBe('rgb(255, 255, 255)')
expect(editing.badge).toBe('none')
expect(editing.shadow).toContain('2px')
// ③ 换到别的格 → 它不再是编辑格,底色回来
await wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="amount"]').click()
await expect.poll(async () => (await read()).bg).not.toBe('rgb(255, 255, 255)')
})
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('边框自绘:库只保留竖线,横线走渐变而不是真 border', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
// 我们传的是库的 'v' 档(字符串才挂 bordered-xxx)
const stk = wrap.locator('.stk-table').first()
await expect(stk).toHaveClass(/bordered-v/)
/** 数「非透明的背景渐变层数」——即这一格画了几条线(不看库内部怎么拼) */
const lineCount = (locator: import('@playwright/test').Locator) =>
locator.evaluate((el) =>
getComputedStyle(el)
.backgroundImage.split(/,(?![^(]*\))/)
.filter((layer) => /rgb\(\d+, \d+, \d+\)/.test(layer)).length,
)
// 横线:`'v'` 档把 --bg-border-bottom 清零(只剩竖线一条),组件按底座配方把它
// 拿回来 —— 所以这里应当数出**两条**。但它**不能**是真 border:格线一进盒模型,
// 单元格多 1px 就会被行内编辑器顶高(行高是虚拟滚动 translateY 的基准)。
const td = wrap.locator('.stk-tbody-main td').first()
const normalLines = await lineCount(td)
expect(normalLines).toBeGreaterThan(1)
expect(await td.evaluate((el) => getComputedStyle(el).borderBottomWidth)).toBe('0px')
// 竖线:末列的外侧那条已被抹掉(归自绘外框管),所以比普通列**正好少一条**
const lastTd = wrap.locator('.stk-tbody-main td:last-child').first()
expect(await lineCount(lastTd)).toBe(normalLines - 1)
// 行高没被格线动过(30 = 默认 rowHeight,也是虚拟滚动 translateY 的基准)
expect(
await wrap
.locator('.stk-tbody-main tr')
.first()
.evaluate((el) => Math.round(el.getBoundingClientRect().height)),
).toBe(30)
})
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)
})
})
/**
* 固定列 + 校验态的几何契约。
*
* 历史 bug(值得留在这里,因为它是这类问题的模板):校验态样式曾给 td 写
* `position: relative`(为了让绝对定位的角标有个基准),而固定列的 td 上
* `position` 是库在用的 —— 被遮挡时是 `sticky`,且吸附位移以**内联** `left` / `right`
* 写在 td 上。改成 relative 之后那条内联 `left` 就变成普通相对位移,整个格子被推开
* 「前面所有左固定列宽度之和」:表现是「错误底色跑到了隔壁两列中间」,
* 看着完全不像定位问题。
*
* 角标现在整体去掉了(见 docs 的 errorStyle),那两条 `position` 规则也随之删除 ——
* 这个坑从机制上消失。本组用例继续守着「固定列 + 校验底色」的几何:
* 校验格必须与它所属列对齐、滚完仍吸附在原位。
*
* 只能在这里量:happy-dom 没有布局引擎,`:has()` 命中与 sticky 都不成立。
* 宿主数据在 playground/main.ts(`table-fixed-wrap`):左固定两列 + 一行空名称。
*/
test.describe('G3Table 固定列 + 校验态', () => {
const WRAP = '[data-testid="table-fixed-wrap"]'
/** 按列标题取表头单元格的几何(固定列的表头与数据格走同一套 sticky) */
async function thBox(page: import('@playwright/test').Page, title: string) {
const th = page.locator(WRAP).locator('thead th', { hasText: title })
await expect(th).toBeVisible()
const box = await th.boundingBox()
if (!box) throw new Error(`表头「${title}」没有几何信息`)
return box
}
/** 按列标题取「第一行」的单元格:列序由调用方决定,不写死 nth-child */
async function cellByTitleIn(
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)
}
/** 滚动到最右,返回实际 scrollLeft(为 0 说明这张表根本没横向溢出,用例失去意义) */
async function scrollToEnd(page: import('@playwright/test').Page) {
return page.locator(`${WRAP} .stk-table`).evaluate((el) => {
el.scrollLeft = el.scrollWidth
return el.scrollLeft
})
}
test('校验未通过的格子留在本列内,不被固定列偏移推开', async ({ page }) => {
await page.goto('/')
const wrap = page.locator(WRAP)
await wrap.scrollIntoViewIfNeeded()
await page.locator(`${WRAP} .stk-table`).evaluate((el) => {
el.scrollLeft = 0
})
const nameTh = await thBox(page, '名称(校验列)')
// 第 2 行名称为空 → 固定列上必然有一个 error 格(同页其它表格的校验格不在这个 wrap 内)
const errorCell = wrap.locator('td:has(> .is-editor-error)')
await expect(errorCell).toHaveCount(1)
const cell = await errorCell.boundingBox()
expect(cell).not.toBeNull()
// 起点不许被推右:错误格与它所属列的表头左边缘对齐(1px 容差给亚像素)
expect(Math.abs(cell!.x - nameTh.x)).toBeLessThanOrEqual(1)
expect(Math.abs(cell!.width - nameTh.width)).toBeLessThanOrEqual(1)
// 而且必须真的在「前一列」的右边 —— 挡住「整格被推走但恰好没超出容差」的漏网
const codeTh = await thBox(page, '编号(左固定)')
expect(cell!.x).toBeGreaterThanOrEqual(codeTh.x + codeTh.width - 1)
})
test('受控 activeRowKey:点可编辑格进入编辑,当前行照样高亮', async ({ page }) => {
/**
* 回归的 bug:受控调用方(fms 那套 `:active-row-key` + `@row-click`)
* 点**可编辑格**进编辑时整行不高亮,点只读格却正常。
*
* 根因在浏览器:点可编辑格会挂载编辑器,把 mousedown 命中的 `.cell-display`
* span 换掉,Chromium 因此**不派发 click**,库的 `row-click` 不触发,
* 调用方那个唯一写入者永远收不到消息。
* 组件侧改为在「激活编辑」这条路径上无条件同步当前行(见 G3Table 注释)。
*/
await page.goto('/')
const wrap = page.locator(WRAP)
await wrap.scrollIntoViewIfNeeded()
const rows = wrap.locator('.stk-tbody-main tr')
const firstRow = rows.first()
await expect(firstRow).not.toHaveClass(/active/)
// 「名称(校验列)」是这张表里的可编辑列(带 editor),点它进入编辑
const editableCell = await cellByTitleIn(page, WRAP, '名称(校验列)')
await editableCell.click()
await expect(wrap.locator('.g3-table__cell-editor-root.g3-input').first()).toBeVisible()
// 当前行必须高亮 —— 这正是调用方靠 row-click 拿不到的那次点击
await expect(firstRow).toHaveClass(/active/)
// 只读格那条路径也不能坏(第二行的「编号」列没有 editor)
await rows.nth(1).locator('td[data-col-key="code"]').click()
await expect(rows.nth(1)).toHaveClass(/active/)
})
test('横向滚动后:左固定列与其中的错误格一起吸附在原位', async ({ page }) => {
await page.goto('/')
const wrap = page.locator(WRAP)
await wrap.scrollIntoViewIfNeeded()
const beforeCode = await thBox(page, '编号(左固定)')
const beforeError = await wrap.locator('td:has(> .is-editor-error)').boundingBox()
expect(beforeError).not.toBeNull()
// 前提校验:这张表确实溢出(否则固定列无从谈起,断言会变成「什么都没测」)
expect(await scrollToEnd(page)).toBeGreaterThan(100)
await page.waitForTimeout(150)
const afterCode = await thBox(page, '编号(左固定)')
const afterError = await wrap.locator('td:has(> .is-editor-error)').boundingBox()
expect(afterError).not.toBeNull()
// 左固定列滚完仍在视口的同一横坐标上(吸附生效)
expect(Math.abs(afterCode.x - beforeCode.x)).toBeLessThanOrEqual(1)
// 带校验态的那一格同理:校验态样式不许把吸附定位打断
expect(Math.abs(afterError!.x - beforeError!.x)).toBeLessThanOrEqual(1)
})
/**
* 冻结列的选中态与行状态叠色的关系。
*
* 回归的 bug:新加的「冻结列行状态叠色」用的是 `box-shadow`,而库的选区描边
* (`td[data-cs-s]` 的 4 个方向 inset)**也在 box-shadow 上** —— 单值属性,
* 只写叠色那一条就会把选中框整体替换掉。表现是「冻结列上的选中格没有蓝色选中边」,
* 非冻结列没有这条规则所以只有冻结列会缺,很容易被当成「选中态没生效」。
*
* 另一半:编辑格**不参与**行状态叠色(普通列的编辑格是纯基色 + 焦点环),
* 否则同一行里冻结列的编辑格会带行色、普通列的却是纯色。
*/
test('冻结列:选中格保留选中描边,编辑格与普通列一样只有焦点环', async ({ page }) => {
await page.goto('/')
const wrap = page.locator(WRAP)
await wrap.scrollIntoViewIfNeeded()
const firstRow = wrap.locator('.stk-tbody-main tr').first()
const nameCell = await cellByTitleIn(page, WRAP, '名称(校验列)')
await nameCell.click()
/**
* 编辑格:应当只有「一圈 2px 内描边」(焦点环),**不带**行状态的大范围叠色。
* 叠色的计算值是 `0px 0px 0px <视口宽>px inset`,形状完全不同 —— 用形状区分,
* 不去比颜色(颜色随主题变)。
*/
const editingShadow = await nameCell.evaluate((el) => getComputedStyle(el).boxShadow)
expect(editingShadow).toMatch(/0px 0px 0px 2px inset/)
// 再 Shift+点前面那个只读的左固定格:选区扩成两格,它就是「选中但不在编辑」的格子
const codeCell = firstRow.locator('td[data-col-key="code"]')
await codeCell.click({ modifiers: ['Shift'] })
await page.waitForTimeout(150)
const measured = await codeCell.evaluate((el) => {
// 把 --cs-bc(选区描边色)解析成 rgb:getPropertyValue 给的是原样字符串,比不了
const probe = document.createElement('span')
probe.style.color = 'var(--cs-bc)'
el.appendChild(probe)
const borderColor = getComputedStyle(probe).color
probe.remove()
return {
isFixed: el.classList.contains('fixed-cell'),
isSelected: el.hasAttribute('data-cs-s'),
shadow: getComputedStyle(el).boxShadow,
borderColor,
}
})
expect(measured.isFixed).toBe(true)
expect(measured.isSelected).toBe(true)
// 叠色规则里必须连同选区描边一起写,否则这一格上的选中框会被替换掉
expect(measured.shadow).toContain(measured.borderColor)
})
})
/**
* Tab / Shift+Tab 跳格。
*
* 只能在这里测:事件源可能是 Teleport 到 body 的 select 浮层(真实 DOM 概念),
* happy-dom 既没有 Teleport 的真实 DOM,也没有焦点语义。
*
* 宿主是 playground 的 `table-wrap` 表(可编辑),列序为
* 名称(只读) / 金额(number) / 状态(select,enabled=0 的行禁用) / 启用(checkbox) / 备注(text) / 操作(只读),
* 数据 3 行(第 2 行 enabled=0)。这一组合同时覆盖三条规则:
* 跳过只读列、跳过「按行禁用」的列、行尾回绕到下一行。
*/
test.describe('G3Table 键盘跳格:Tab / Shift+Tab', () => {
const WRAP = '[data-testid="table-wrap"]'
/**
* 焦点当前所在的那一格。
*
* 不能只读 `document.activeElement.closest('td')`:select 展开时焦点被移进 Teleport 出去的
* 浮层里,浮层不属于任何 td。此时用 ARIA 关联(浮层 id ↔ 触发器 aria-controls)把焦点
* 还原成「它逻辑上所在的格」——与组件内部判定用的是同一套口径。
* 焦点完全不在表格里(例如跑到了页面其它元素上)时返回 null —— 这正是修前会出现的状态。
*/
async function focusedCell(page: import('@playwright/test').Page) {
return page.evaluate(() => {
const active = document.activeElement as HTMLElement | null
if (!active) return null
let td = active.closest('td[data-col-key]') as HTMLElement | null
if (!td) {
const overlay = active.closest('.g3-select__dropdown') as HTMLElement | null
const trigger = overlay?.id
? document.querySelector(`[aria-controls="${overlay.id}"]`)
: null
td = trigger?.closest('td[data-col-key]') ?? null
}
if (!td) return null
return {
colKey: td.getAttribute('data-col-key'),
// 库给 tr 写了绝对行索引(不受虚拟窗口影响)
rowIndex: td.closest('tr')?.getAttribute('data-row-i') ?? null,
}
})
}
test('焦点在 select 的浮层里按 Tab:落到下一个可编辑格,而不是页面其它元素', async ({ page }) => {
await page.goto('/')
const wrap = page.locator(WRAP)
await wrap.scrollIntoViewIfNeeded()
// 进入第一行「金额」格编辑(number 编辑器,焦点在原生 input 上)
await wrap.locator('td[data-col-key="amount"]').first().click()
await expect(wrap.locator('.g3-table__cell-editor-root.g3-input').first()).toBeVisible()
expect(await focusedCell(page)).toEqual({ colKey: 'amount', rowIndex: '0' })
// ① input → Tab:落到相邻的 select 列;G3Select 展开后把焦点移进 Teleport 到 body 的浮层
await page.keyboard.press('Tab')
await expect(page.locator('.g3-select__dropdown')).toBeVisible()
await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'status', rowIndex: '0' })
/**
* ② 关键回归:焦点在浮层里(DOM 上已经不在 `.stk-table` 内)时按 Tab。
*
* 修前:浮层的 keydown 到不了底座的 Tab 导航(监听器挂在表格容器上),
* 浏览器按文档顺序把焦点移出表格 —— 实测落到文档开头的第一个可聚焦元素,
* 用户看到的就是「按 Tab 没有选中下一个」。
*/
await page.keyboard.press('Tab')
await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'enabled', rowIndex: '0' })
// ③ 只读的「操作」列被跳过,行尾回绕到下一行的第一个可编辑格
await page.keyboard.press('Tab')
await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'remark', rowIndex: '0' })
await page.keyboard.press('Tab')
await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'amount', rowIndex: '1' })
// ④ 第二行 status 是「按行禁用」的(enabled=0):它连同只读列一起被跳过
await page.keyboard.press('Tab')
await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'enabled', rowIndex: '1' })
})
test('Shift+Tab 反向跳格,并在两端循环', async ({ page }) => {
await page.goto('/')
const wrap = page.locator(WRAP)
await wrap.scrollIntoViewIfNeeded()
// 从第二行「金额」往回:先跨过只读的「操作」列落到上一行「备注」
await wrap.locator('td[data-col-key="amount"]').nth(1).click()
await expect(wrap.locator('.g3-table__cell-editor-root.g3-input').first()).toBeVisible()
expect(await focusedCell(page)).toEqual({ colKey: 'amount', rowIndex: '1' })
await page.keyboard.press('Shift+Tab')
await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'remark', rowIndex: '0' })
// 全表第一个可编辑格:「金额」再往回 → 循环到全表最后一格(第三行「备注」)
await page.keyboard.press('Shift+Tab')
await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'enabled', rowIndex: '0' })
await page.keyboard.press('Shift+Tab')
await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'status', rowIndex: '0' })
await page.keyboard.press('Shift+Tab')
await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'amount', rowIndex: '0' })
await page.keyboard.press('Shift+Tab')
await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'remark', rowIndex: '2' })
})
/**
* 连按 Tab(间隔压在底座的连按防抖窗口内)。
*
* 回归的 bug:跳格是「改选区 → 激活编辑 → 重挂编辑器 → 新编辑器聚焦」的异步链路,
* 其中 100ms 防抖 + 一帧渲染期间焦点没有任何落点(`document.activeElement` 是 body)。
* 此时再按 Tab,事件源既不在编辑器也不在浮层 —— 若就此放行,浏览器会把焦点按文档
* 顺序送出表格,表现就是「按快一点还是掉出表格」。
* 另一半成因是起点取错:编辑态的 `focusCol` 被防抖推迟,连按时会算回同一格(原地不动)。
*/
test('连按 Tab(40ms 间隔):每下一次前进一格,且不跳出表格', async ({ page }) => {
await page.goto('/')
const wrap = page.locator(WRAP)
await wrap.scrollIntoViewIfNeeded()
await wrap.locator('td[data-col-key="amount"]').first().click()
await expect(wrap.locator('.g3-table__cell-editor-root.g3-input').first()).toBeVisible()
expect(await focusedCell(page)).toEqual({ colKey: 'amount', rowIndex: '0' })
for (let i = 0; i < 3; i += 1) {
await page.keyboard.press('Tab')
await page.waitForTimeout(40)
}
// 三下 = 前进三格:amount → status → enabled → remark(防抖只延后编辑器的重挂,不吞按键)
await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'remark', rowIndex: '0' })
// 焦点仍在表格里(修前会掉到页面其它元素上)
expect(
await page.evaluate(() => !!document.activeElement?.closest('[data-testid="table-wrap"]')),
).toBe(true)
})
/**
* 编辑行被**虚拟滚动**滚出渲染窗口后按 Tab。
*
* 这条路径与按键速度无关(实测慢速必现),是「Tab 逃出表格」的另一个入口:
* 编辑行滚出渲染窗口 → 编辑器被卸载 → 焦点掉到 `body`;此时按 Tab,事件源既不在
* 编辑器也不在浮层 → 若判定「不是给我的」放行,浏览器就按文档顺序把焦点送出表格
* (实测落到侧边栏菜单上)。而编辑态与选区其实都还在 —— 滚回来就恢复。
*
* 宿主是 playground 的 `table-big-wrap`(5000 行,纵向虚拟开启,后两列带 editor)。
*/
test('编辑行被虚拟滚动滚出窗口后按 Tab:拉回编辑行并前进一格,而不是逃到页面元素上', async ({
page,
}) => {
const BIG = '[data-testid="table-big-wrap"]'
await page.goto('/')
const big = page.locator(BIG)
await big.scrollIntoViewIfNeeded()
await big.locator('.stk-tbody-main tr[data-row-i="0"] td[data-col-key="name"]').click()
await expect(big.locator('.g3-table__cell-editor-root.g3-input').first()).toBeVisible()
expect(await focusedCell(page)).toEqual({ colKey: 'name', rowIndex: '0' })
// 纵向滚走:编辑行离开渲染窗口 → 编辑器卸载、焦点回到 body
await big.locator('.stk-table').evaluate((el) => {
el.scrollTop = 3000
})
await page.waitForTimeout(300)
expect(await page.evaluate(() => document.activeElement === document.body)).toBe(true)
expect(
await page.evaluate(() => !document.querySelector('[data-testid="table-big-wrap"] .is-cell-editing')),
).toBe(true)
// Tab:必须被接管(修前这里会 preventDefault=false,焦点落到页面其它元素上)
await page.keyboard.press('Tab')
await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'value', rowIndex: '0' })
})
/**
* 按住 Tab 连续走(keydown autoRepeat),且从**只读列**起步。
*
* 用户实测 100% 复现的手势:在 ERP 里点「订舱号」(只读、没有编辑器)那一格,
* 然后一直按着 Tab 往右走,走到「起运港」时就掉出表格了。
*
* 成因是两条叠加出来的同一个状态 ——「编辑会话在,但 DOM 里一个编辑器都没有」:
* · 只读列起步:编辑态是 `editing=true` 而 `focusCol=null`,没有任何编辑器被挂载;
* · 底座的**连按 / 长按优化**:处理 repeat 事件时会 `clearActiveEditing()`(把
* `editing` 也置 false),长按期间刻意保持 0 编辑器,等松手再挂回来。
* 旧判据只认 `editing === true`,于是从第 3 下起就认为「不是给表格的 Tab」而放行,
* 浏览器按文档顺序把焦点送出表格。
*/
test('按住 Tab(autoRepeat)从只读列起步:一格一格前进,不掉出表格', async ({ page }) => {
await page.goto('/')
const wrap = page.locator(WRAP)
await wrap.scrollIntoViewIfNeeded()
// 「名称」列没有 editor(对应 ERP 的「订舱号」),点它建立编辑会话
await wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="name"]').click()
await expect(wrap.locator('.is-cell-editing')).toHaveCount(0)
// 按住不放:Playwright 对**已按下**的键会把后续 keydown 标成 autoRepeat,与真实长按一致
await page.keyboard.down('Tab')
for (let i = 0; i < 3; i += 1) {
await page.keyboard.down('Tab')
await page.waitForTimeout(250)
}
// 长按期间底座刻意保持 0 编辑器 —— 这正是「会话在但没编辑器」那个状态
expect(await wrap.locator('.is-cell-editing').count()).toBe(0)
await page.keyboard.up('Tab')
// 四下 = 名称(只读) → 金额 → 状态 → 启用 → 备注;松手后编辑器落到最后一格
await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'remark', rowIndex: '0' })
expect(
await page.evaluate(() => !!document.activeElement?.closest('[data-testid="table-wrap"]')),
).toBe(true)
})
/**
* 复选框与其它单元格**行为一致**:点一下就切换(不需要先激活整行),
* 并且这一格随之成为当前格(行高亮 + 选中框 + 编辑会话),键盘也能从这里继续走。
*
* 回归的 bug:为了「一次点击就切换」,createCheckbox 把 mousedown 停掉了(浏览器在
* 节点被替换时不派发 click,不停就会变成「第一次点击只进编辑、第二次才切换」)。
* 但停了之后底座收不到 mousedown,也就不会像点击其它格那样设选区、激活该行编辑、
* 置当前行 —— 实测后果是:值改了、`change` 也抛了,可整行不高亮、格子没有选中框,
* 而且表格里**没有任何选区**,接着按 Tab 会从 (0,0) 起步、焦点掉到 `body`。
*/
test('点复选框:一次点击即切换,且该格成为当前格(键盘可继续走)', async ({ page }) => {
await page.goto('/')
const wrap = page.locator(WRAP)
await wrap.scrollIntoViewIfNeeded()
const firstRow = wrap.locator('.stk-tbody-main tr').first()
const cell = firstRow.locator('td[data-col-key="enabled"]')
const before = await cell.locator('input').isChecked()
// 点可见的复选框(原生 input 藏在 label 里,由 label 承担命中)
await cell.locator('label').click()
// ① 一次点击就切换(这条是原本就正确的行为,别改坏)
expect(await cell.locator('input').isChecked()).toBe(!before)
// ② 这一格成为当前格:行高亮 + 选中框 + 编辑格标记
await expect(firstRow).toHaveClass(/active/)
await expect(cell).toHaveAttribute('data-cs-s')
expect(await cell.evaluate((el) => !!el.querySelector('.is-cell-editing'))).toBe(true)
// ③ 于是键盘可以从这里继续走:启用 → 备注(同行下一个可编辑列)
await page.keyboard.press('Tab')
await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'remark', rowIndex: '0' })
})
})
/**
* 斑马纹(`stripe`)与固定列。
*
* 库把条纹画在 `tr` 上,而冻结列的单元格底色**必须不透明**(否则横向滚动时被压住的
* 列会透上来),这层不透明底色会把 `tr` 上的条纹盖住 —— 于是「整表隔行灰、只有序号列没有」。
* 组件的做法是在冻结列上补一层**同色内阴影**把条纹接上(见 style/_overrides.scss 的 stripe 段)。
*
* 所以这里量的是「冻结列单元格上到底有没有那层叠色」,而不是它的 background-color:
* 底色必须一直是不透明的 `--td-bgc`(那是冻结列的硬约束),条纹只能是叠上去的。
*
* 两个宿主覆盖两条 CSS 分支:`table-stripe-wrap`(2 行,纵向虚拟关闭 → `nth-child(even)`)
* 与 `table-big-wrap`(5000 行 → `vt-on`,库改取 `nth-child(odd)`)。
*/
test.describe('G3Table 斑马纹与固定列', () => {
/**
* 取某一行**第一个单元格**(固定列/序号列)的底色与叠色情况。
*
* `boxShadow` 里除了叠色还有库的选区描边(`--cs-bc` 未设时是 transparent),
* 所以按「有没有非零 alpha 的颜色」判断,而不是比对整串字符串。
*/
async function fixedCellPaint(
page: import('@playwright/test').Page,
wrapTestId: string,
rowIndex: number,
) {
return page
.locator(`[data-testid="${wrapTestId}"] .stk-tbody-main tr`)
.nth(rowIndex)
.locator('td')
.first()
.evaluate((td) => {
const cs = getComputedStyle(td)
const alphas = [...cs.boxShadow.matchAll(/rgba?\(([^)]+)\)/g)].map((m) => {
const parts = m[1].split(',').map((s) => s.trim())
return parts.length === 4 ? Number(parts[3]) : 1
})
const bg = cs.backgroundColor
const bgParts = bg.match(/rgba?\(([^)]+)\)/)
const bgAlpha = bgParts
? (() => {
const parts = bgParts[1].split(',').map((s) => s.trim())
return parts.length === 4 ? Number(parts[3]) : 1
})()
: 1
return { bg, bgAlpha, maxShadowAlpha: Math.max(0, ...alphas), isFixed: td.classList.contains('fixed-cell') }
})
}
test('纵向虚拟关闭时(nth-child(even)):序号列跟着上条纹,底色仍不透明', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-stripe-wrap"]')
await wrap.scrollIntoViewIfNeeded()
await expect(wrap.locator('.stk-table')).toHaveClass(/stripe/)
// 第 2 行是条纹行
const striped = await fixedCellPaint(page, 'table-stripe-wrap', 1)
// 前提:这真的是固定列(序号列注入时就是 fixed: 'left')
expect(striped.isFixed).toBe(true)
// 关键断言:条纹叠色在(修前这里是 0 —— 条纹被不透明底色盖掉了)
expect(striped.maxShadowAlpha).toBeGreaterThan(0)
// 且底色仍然不透明:横滚不许透字,这是冻结列的硬约束,不能被条纹改掉
expect(striped.bgAlpha).toBe(1)
// 第 1 行不是条纹行:不应有叠色(叠色只在条纹行上出现)
expect((await fixedCellPaint(page, 'table-stripe-wrap', 0)).maxShadowAlpha).toBe(0)
})
test('纵向虚拟开启时(vt-on,库改取 nth-child(odd)):同一条规则要跟着换', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-big-wrap"]')
await wrap.scrollIntoViewIfNeeded()
await expect(wrap.locator('.stk-table')).toHaveClass(/vt-on/)
await expect(wrap.locator('.stk-table')).toHaveClass(/stripe/)
// vt-on 下 DOM 首行是 striped(odd)
expect((await fixedCellPaint(page, 'table-big-wrap', 0)).maxShadowAlpha).toBeGreaterThan(0)
expect((await fixedCellPaint(page, 'table-big-wrap', 1)).maxShadowAlpha).toBe(0)
})
})