1198 lines
55 KiB
TypeScript
1198 lines
55 KiB
TypeScript
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)
|
||
})
|
||
|
||
/**
|
||
* 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('边框交由库绘制:库根节点带 .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)
|
||
})
|
||
})
|
||
|
||
/**
|
||
* 固定列 + 校验态的几何契约。
|
||
*
|
||
* 历史 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)
|
||
})
|
||
})
|