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) }) })