259 lines
10 KiB
TypeScript
259 lines
10 KiB
TypeScript
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<typeof decodePng>): string {
|
||
const { width, height, pixels, bpp } = img
|
||
const hist = new Map<string, number>()
|
||
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 }`
|
||
* 会命中组件内部的 `<table class="stk-table-main">`,让它变成滚动容器,
|
||
* 于是 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)
|
||
})
|
||
})
|