import { expect, test } from '@playwright/test' import zlib from 'node:zlib' /** * 最小 PNG 解码(8bit,colorType 2/6,非隔行)。 * 用于「读真实渲染像素」——只量 computed 样式会漏掉「声明正确但没画出来」 * 这类问题(表头半透明底色最初就是这么漏过去的)。 */ function decodePng(buf: Buffer) { let pos = 8 let width = 0 let height = 0 let colorType = 6 const idat: Buffer[] = [] while (pos < buf.length) { const len = buf.readUInt32BE(pos) const type = buf.toString('ascii', pos + 4, pos + 8) const data = buf.subarray(pos + 8, pos + 8 + len) if (type === 'IHDR') { width = data.readUInt32BE(0) height = data.readUInt32BE(4) colorType = data[9] } else if (type === 'IDAT') idat.push(data) pos += 12 + len } const channels = colorType === 6 ? 4 : 3 const raw = zlib.inflateSync(Buffer.concat(idat)) const bpp = channels const stride = width * bpp const out = Buffer.alloc(height * stride) let rp = 0 for (let y = 0; y < height; y += 1) { const filter = raw[rp] rp += 1 const rs = y * stride for (let x = 0; x < stride; x += 1) { const cur = raw[rp + x] const left = x >= bpp ? out[rs + x - bpp] : 0 const up = y > 0 ? out[rs - stride + x] : 0 const ul = y > 0 && x >= bpp ? out[rs - stride + x - bpp] : 0 let v = cur if (filter === 1) v = cur + left else if (filter === 2) v = cur + up else if (filter === 3) v = cur + ((left + up) >> 1) else if (filter === 4) { const p = left + up - ul const pa = Math.abs(p - left) const pb = Math.abs(p - up) const pc = Math.abs(p - ul) v = cur + (pa <= pb && pa <= pc ? left : pb <= pc ? up : ul) } out[rs + x] = v & 0xff } rp += stride } return { width, height, pixels: out, bpp } } /** 取图片主色(跳过 1px 边缘,避开边框线),返回 "r,g,b" */ function dominantColor(img: ReturnType): string { const { width, height, pixels, bpp } = img const hist = new Map() for (let y = 1; y < height - 1; y += 1) { for (let x = 1; x < width - 1; x += 1) { const i = (y * width + x) * bpp const k = pixels[i] + ',' + pixels[i + 1] + ',' + pixels[i + 2] hist.set(k, (hist.get(k) ?? 0) + 1) } } return [...hist.entries()].sort((a, b) => b[1] - a[1])[0][0] } /** * 文档站上 G3Table 的渲染核对。 * * 放在独立的 e2e-docs 目录、用 playwright.docs.config.ts 跑: * 组件自带的 e2e/ 指向 playground,那里没有 VitePress 的 .vp-doc 上下文, * 量不到文档页才有的样式问题(前几轮反复踩坑就在这)。 */ test.describe('文档站 /ui/data/table', () => { test.beforeEach(async ({ page }) => { await page.goto('/ui/data/table') await page.waitForTimeout(1000) }) test('七个示例都渲染出表格', async ({ page }) => { // basic / virtual / selection / sorting / editable / actions / resizable(+2 表) const tables = page.locator('.g3-table') const count = await tables.count() expect(count).toBeGreaterThanOrEqual(7) }) test('没有 Vue 渲染告警泄漏到页面(控制台无 error)', async ({ page }) => { const errors: string[] = [] page.on('pageerror', (e) => errors.push(e.message)) await page.goto('/ui/data/table') await page.waitForTimeout(800) expect(errors).toEqual([]) }) test('内部边框是 1px:逐像素量横线带与竖线宽', async ({ page }) => { const table = page.locator('.g3-table').first() await table.scrollIntoViewIfNeeded() await page.waitForTimeout(300) // 用 canvas 采样不方便,这里退一步量计算样式: // 库用 background-image 画线,.vp-doc 会用 border 画四边框 —— 后者必须被清掉。 const cell = table.locator('.stk-tbody-main td').first() const styles = await cell.evaluate((el) => { const cs = getComputedStyle(el) return { borderTop: cs.borderTopWidth, borderBottom: cs.borderBottomWidth, borderLeft: cs.borderLeftWidth, borderRight: cs.borderRightWidth, bg: cs.backgroundImage, } }) // .vp-doc 的四边框必须已被 demo 隔离规则清掉 expect(styles.borderTop).toBe('0px') expect(styles.borderBottom).toBe('0px') expect(styles.borderLeft).toBe('0px') expect(styles.borderRight).toBe('0px') // 库自己的线仍在(background-image 里至少一条可见渐变) expect(styles.bg).not.toBe('none') expect(styles.bg).toContain('gradient') }) test('「名称长度(自定义)」列显示数字而不是名称,且点表头能排序', async ({ page }) => { const table = page .locator('.g3-table') .filter({ has: page.locator('th', { hasText: '名称长度' }) }) .first() await table.scrollIntoViewIfNeeded() const headers = await table.locator('thead th').allTextContents() const idx = headers.findIndex((h) => h.includes('名称长度')) + 1 const read = async () => (await table.locator(`.stk-tbody-main tr td:nth-child(${idx})`).allTextContents()).map((s) => s.trim(), ) // 该列必须显示**长度值**(dataIndex 指向真实字段),不能显示名称本身 const before = await read() expect(before).toEqual(['4', '8', '3']) await table.locator('th', { hasText: '名称长度' }).first().click() await page.waitForTimeout(300) expect(await read()).toEqual(['8', '4', '3']) // desc await table.locator('th', { hasText: '名称长度' }).first().click() await page.waitForTimeout(300) expect(await read()).toEqual(['3', '4', '8']) // asc }) /** * 虚拟滚动开启时表头必须钉住。 * * 这条专门防「宿主 CSS 泄漏」回归:VitePress 的 * `.vp-doc table { display:block; overflow-x:auto; margin:20px 0 }` * 会命中组件内部的 ``,让它变成滚动容器, * 于是 th 的 sticky 基准错位、表头跟着滚走(实测 delta=-600)。 * custom.css 里已把这几个属性还原,这里用位移量锁住。 * * 注意 playground 的同类断言抓不到这个 bug —— 那里没有 .vp-doc。 */ test('虚拟滚动:纵向滚动 600px 后表头仍钉在容器顶部', async ({ page }) => { await page.goto('/ui/data/table') await page.waitForTimeout(1000) const section = page .locator('.vitepress-demo-plugin__container') .filter({ has: page.locator('th', { hasText: '数值' }) }) .first() await section.scrollIntoViewIfNeeded() await page.waitForTimeout(300) const r = await page.evaluate(() => { const stk = document.querySelector('.stk-table.virtual') as HTMLElement | null if (!stk) return null const th = stk.querySelector('thead th') as HTMLElement const top0 = stk.getBoundingClientRect().top const before = th.getBoundingClientRect().top - top0 stk.scrollTop = 600 const after = th.getBoundingClientRect().top - top0 return { before, after, delta: after - before, scrollTop: stk.scrollTop } }) expect(r).not.toBeNull() expect(r!.scrollTop).toBe(600) // 表头相对滚动容器顶部的偏移不应变化 expect(Math.abs(r!.delta)).toBeLessThan(2) }) /** * 表头底色必须不透明。 * * 库把表头底色做成 alpha 值(--th-bgc: rgba(0,0,0,.04))画在 thead tr 上、 * th 继承它;th 又是 sticky,滚动时表体行从它下面高速掠过,半透明像素会与 * 「此刻经过的那一行」实时合成 —— 表现为滚动时能看到行内容一闪而过。 * 静止截图看不出(下方恰好是白行),所以这里主动塞一行纯红到表头正下方, * 再截图表头:若还能读出红色,说明底色仍是半透明。 * * 注意:这条同样只有文档站能跑出意义 —— playground 不会有 .vp-doc 干扰, * 但真正要防的是「表头底色被改回 alpha」,两边都成立;放这里是为了顺带 * 覆盖文档站的真实渲染。 */ test('表头底色不透明:下方内容不会透出来', async ({ page }) => { await page.goto('/ui/data/table') await page.waitForTimeout(1000) const section = page .locator('.vitepress-demo-plugin__container') .filter({ has: page.locator('th', { hasText: '数值' }) }) .first() await section.scrollIntoViewIfNeeded() await page.waitForTimeout(400) const table = section.locator('.g3-table').first() // 1) alpha 必须是 1 const alpha = await table.evaluate((root) => { const th = root.querySelector('thead th') as HTMLElement const c = getComputedStyle(th).backgroundColor const m = c.match(/rgba?\(([^)]+)\)/) if (!m) return null const parts = m[1].split(',').map((p) => p.trim()) return { bg: c, alpha: parts.length > 3 ? Number(parts[3]) : 1 } }) expect(alpha).not.toBeNull() expect(alpha!.alpha).toBe(1) // 2) 行为验证:把表体行染成纯红、滚到表头正下方,截图表头 —— 读像素判断。 // 只量 computed 是不够的(正是那种检查漏掉了原 bug),必须看真实渲染像素。 await table.evaluate((root) => { const rows = root.querySelectorAll('.stk-tbody-main tr') const r = rows[0] as HTMLElement | undefined if (r) { r.querySelectorAll('td').forEach((td) => { ;(td as HTMLElement).style.backgroundColor = 'rgb(255,0,0)' ;(td as HTMLElement).style.backgroundImage = 'none' }) } const stk = root.querySelector('.stk-table') as HTMLElement stk.scrollTop = 1 }) await page.waitForTimeout(400) const shot = await table.locator('thead th').first().screenshot() const color = dominantColor(decodePng(Buffer.from(shot))) const [r, g, b] = color.split(',').map(Number) // 表头主色不应偏红 —— 红行若透上来,R 会显著高于 G/B expect(r - Math.max(g, b)).toBeLessThan(20) }) })