Files
stack/apps/design-lab/e2e/pages.spec.ts
T

276 lines
13 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'
/**
* 设计系统结构契约断言——规格级防回归。
* 布局微调不误报,但破坏设计规格(描边/投影/比例/半嵌/裁切)必红。
*/
const PAGES = ['index', 'colors', 'symbols', 'components', 'shelf'] as const
test.describe('全局画布', () => {
for (const p of PAGES) {
test(`${p}.html 渲染且有内容`, async ({ page }) => {
await page.goto(`/${p}.html`)
await expect(page.locator('#root')).not.toBeEmpty()
await expect(page.locator('main')).toBeVisible()
})
}
test('body 大底为纸色 #FFF9F0', async ({ page }) => {
await page.goto('/index.html')
const bg = await page.evaluate(() => getComputedStyle(document.body).backgroundColor)
expect(bg).toBe('rgb(255, 249, 240)')
})
test('全站无色彩渐变', async ({ page }) => {
await page.goto('/shelf.html')
const gradients = await page.evaluate(() => {
const bad: string[] = []
document.querySelectorAll<HTMLElement>('*').forEach((el) => {
if (getComputedStyle(el).backgroundImage.includes('gradient')) bad.push(el.tagName)
})
return bad
})
expect(gradients, gradients.join(',')).toEqual([])
})
})
test.describe('CourseCard 规格契约', () => {
test.beforeEach(async ({ page }) => await page.goto('/components.html'))
test('2px ink 描边 + 统一硬投影(单层 ink,无纸叠)', async ({ page }) => {
const card = page.locator('a[href="#ch1"]').first()
await expect(card).toBeVisible()
const s = await card.evaluate((el) => {
const cs = getComputedStyle(el)
return { bw: cs.borderTopWidth, bc: cs.borderTopColor, shadow: cs.boxShadow }
})
expect(s.bw).toBe('2px')
expect(s.bc).toBe('rgb(17, 17, 17)')
// 唯一配方 --shadow-hard:恰好一层 ink 硬投影(tw 变体层均为透明占位),无纸叠多层
expect(s.shadow.split('rgb(17, 17, 17)').length - 1).toBe(1)
expect(s.shadow).toContain('rgb(17, 17, 17) 5px 5px 0px 0px')
})
test('基础组件投影统一:同一配方复用,不得各写各的', async ({ page }) => {
const { kinds, recipes } = await page.evaluate(() => {
const els = [...document.querySelectorAll('[data-comp]')]
// 两种机制同一配方:box-shadow(常规卡)与 filter: drop-shadow(票券——跟随挖空轮廓)
const recipe = (cs: CSSStyleDeclaration) => (cs.filter !== 'none' ? cs.filter : cs.boxShadow)
return {
kinds: [...new Set(els.map((el) => el.dataset.comp))].sort(),
recipes: els.map((el) => recipe(getComputedStyle(el))),
}
})
expect(kinds).toEqual(['assign', 'course-card', 'entry-card', 'ticket']) // 四类基础组件都有钩子
expect(recipes.length).toBeGreaterThanOrEqual(6)
recipes.forEach((r) => expect(r).toContain('rgb(17, 17, 17) 5px 5px')) // 唯一配方 --shadow-hard / --drop-shadow-hard
recipes.forEach((r) => expect(r.split('rgb(17, 17, 17)').length - 1).toBe(1)) // 每卡只出一份阴影
})
test('价值区标记用法卡:相框静置态过程文字带荧光 Mark', async ({ page }) => {
const m = await page.evaluate(() => {
const card = document.querySelector('a[href="#ch4"]')
const mark = card?.querySelector('mark')
if (!card || !mark) return null
const cs = getComputedStyle(mark)
return { bg: cs.backgroundColor, rot: cs.rotate, inZone: !!card.querySelector('.aspect-video mark'), txt: mark.textContent }
})
expect(m).not.toBeNull()
expect(m!.inZone).toBeTruthy() // 标记骑在相框内的过程文字上
expect(m!.bg).toBe('rgb(255, 217, 61)') // 荧光笔 mustard
expect(m!.rot).toContain('deg')
expect(m!.txt).toContain('咬合')
})
test('16:9 相框:比例、3px 黑边、内阴影、章节撞色', async ({ page }) => {
const zone = page.locator('a[href="#ch1"] .aspect-video').first()
const s = await zone.evaluate((el) => {
const cs = getComputedStyle(el)
const r = el.getBoundingClientRect()
return { ratio: r.width / r.height, bw: cs.borderTopWidth, shadow: cs.boxShadow, bg: cs.backgroundColor }
})
expect(Math.abs(s.ratio - 16 / 9)).toBeLessThan(0.02)
expect(s.bw).toBe('3px')
expect(s.shadow).toContain('inset')
expect(s.bg).toBe('rgb(255, 107, 107)') // ch1 coral 只进相框
})
test('卡头三件套:▲ + 标题 + 积木点(实心=已学)', async ({ page }) => {
const card = page.locator('a[href="#ch2"]').first()
const dots = card.locator('span[aria-label^="积木"] i')
expect(await dots.count()).toBe(4)
const filled = await dots.evaluateAll((ns) => ns.filter((n) => getComputedStyle(n).backgroundColor === 'rgb(17, 17, 17)').length)
expect(filled).toBe(2) // ch2 已学 2/4
// ▲ 在卡头(相框 ○ 环之后):用 evaluate 通道断言,规避 toBeVisible 协议偶发崩溃
const hasTri = await card.evaluate((el) => !!el.querySelector('.mt-2 svg'))
expect(hasTri).toBeTruthy()
})
test('入口徽章 ①②(推荐起点,非强制)', async ({ page }) => {
await expect(page.locator('span[aria-label="推荐入口 1"]')).toBeVisible()
await expect(page.locator('span[aria-label="推荐入口 2"]')).toBeVisible()
})
})
test.describe('RouteLine 规格契约', () => {
test.beforeEach(async ({ page }) => await page.goto('/components.html'))
test('线形:实线先后 2.5px 无 dash;点线同族 0.1/6 圆帽', async ({ page }) => {
const specs = await page.locator('svg path').evaluateAll((ns) =>
ns.map((n) => ({ w: n.getAttribute('stroke-width'), dash: n.getAttribute('stroke-dasharray'), cap: n.getAttribute('stroke-linecap') })),
)
expect(specs.some((s) => s.w === '2.5' && !s.dash)).toBeTruthy()
expect(specs.some((s) => s.dash === '0.1 6' && s.cap === 'round')).toBeTruthy()
})
test('虚线相关 4:2(symbols 页线形样张)', async ({ page }) => {
await page.goto('/symbols.html')
const specs = await page.locator('svg path').evaluateAll((ns) => ns.map((n) => n.getAttribute('stroke-dasharray')))
expect(specs).toContain('4 2')
})
test('站点 r6 半嵌:源/目标圆心都骑卡描边(各半在卡上),线层在卡之上', async ({ page }) => {
const r = await page.evaluate(() => {
const svg = [...document.querySelectorAll('svg')].find((s) => s.getAttribute('width') === '1160')
if (!svg) return null
const wrap = svg.parentElement
const wb = wrap.getBoundingClientRect()
const c1 = wrap.querySelector('a[href="#ch1"]').getBoundingClientRect()
const c2 = wrap.querySelector('a[href="#ch2"]').getBoundingClientRect()
const stations = [...svg.querySelectorAll('circle')]
.map((n) => ({ cx: +(n.getAttribute('cx') ?? 0), r: +(n.getAttribute('r') ?? 0) }))
.filter((s) => s.r === 6) // 排除散落 ○ 预期环
return {
n: stations.length,
cxs: stations.map((s) => s.cx),
c1Right: c1.x - wb.x + c1.width, // 卡右描边(不接阴影)
c2Border: c2.x - wb.x,
z: getComputedStyle(svg).zIndex,
}
})
expect(r).not.toBeNull()
expect(r!.n).toBeGreaterThanOrEqual(4)
expect(r!.cxs.some((cx) => Math.abs(cx - r!.c1Right) <= 3), `源站点圆心应骑卡右描边 x≈${r!.c1Right}`).toBeTruthy()
expect(r!.cxs.some((cx) => Math.abs(cx - r!.c2Border) <= 3), `目标站点圆心应骑左描边 x≈${r!.c2Border}`).toBeTruthy()
expect(r!.z).toBe('10') // 线层置于卡之上——否则半嵌站点被卡盖住
})
test('hover 卡片:线与站点跟随浮起(-3px 同步,直线变斜线)', async ({ page }) => {
const get = () =>
page.evaluate(() => {
const svg = [...document.querySelectorAll('svg')].find((s) => s.getAttribute('width') === '1160')
return {
stations: [...svg.querySelectorAll('g[style]')].map((g) => (g as HTMLElement).style.transform),
d25: [...svg.querySelectorAll('path')].map((el) => el.getAttribute('d') || '').find((d) => d.startsWith('M 630')),
cardTranslate: getComputedStyle(document.querySelector('a[data-comp="course-card"][href="#ch2"]')).translate,
}
})
const before = await get()
await page.locator('div.absolute.left-\\[380px\\]').hover()
await page.waitForTimeout(250)
const after = await get()
expect(before.d25).toBe('M 630 108 L 700 108') // 静置:几何水平(L 结构可平滑插值)
expect(after.d25).toBe('M 630 105 L 700 108') // hover 卡2:源端随站点抬升,直线变斜线
expect(before.stations.every((t) => t === 'translateY(0px)')).toBeTruthy()
expect(after.stations.filter((t) => t === 'translateY(-3px)').length).toBe(2) // 源+目标站点跟随
expect(after.cardTranslate).toBe('0px -3px') // 卡片本体同步浮起
})
test('实线端点落在水平端口(M x y H 起、H x 止)', async ({ page }) => {
const d = await page.locator('svg path[stroke-width="2.5"]').first().getAttribute('d')
expect(d).toMatch(/^M [\d.]+ [\d.]+ H/)
expect(d).toMatch(/H [\d.]+$/)
})
})
test.describe('价值区荧光标记契约', () => {
test.beforeEach(async ({ page }) => await page.goto('/symbols.html'))
test('荧光笔标记文字:mustard 底、无描边、微斜——是标记不是形状', async ({ page }) => {
const s = await page.evaluate(() => {
const m = document.querySelector('mark')
if (!m) return null
const cs = getComputedStyle(m)
const r = m.getBoundingClientRect()
return { bg: cs.backgroundColor, bw: cs.borderTopWidth, rot: cs.rotate, h: r.height }
})
expect(s).not.toBeNull()
expect(s!.bg).toBe('rgb(255, 217, 61)') // clash2a mustard——荧光笔
expect(s!.bw).toBe('0px') // 标记无描边:2px ink 是形状的规则,标记划在文字上
expect(s!.rot).toContain('deg') // 微斜(rotate 属性生效,手划手感)
expect(s!.h).toBeGreaterThan(14) // 骑在文字上,有字高
})
})
test.describe('票券规格契约', () => {
test.beforeEach(async ({ page }) => await page.goto('/symbols.html'))
test('裁切式:overflow hidden + 单一 2px 描边 + 头部色块无独立边框', async ({ page }) => {
const done = page.locator('.ticket-pk').first()
await expect(done).toBeVisible()
const s = await done.evaluate((el) => {
const cs = getComputedStyle(el)
const head = el.firstElementChild as HTMLElement
const hcs = getComputedStyle(head)
return { ov: cs.overflow, bw: cs.borderTopWidth, headBorder: hcs.borderTopWidth, headBg: hcs.backgroundColor }
})
expect(s.ov).toBe('hidden')
expect(s.bw).toBe('2px')
expect(s.headBorder).toBe('0px') // 头部色块永不自带描边
expect(s.headBg).not.toBe('rgba(0, 0, 0, 0)')
})
test('打孔三件套:mask 差集挖空 + 孔缘弧线 + 阴影跟随轮廓', async ({ page }) => {
const done = page.locator('.ticket-pk').first()
const mask = await done.evaluate((el) => {
const cs = getComputedStyle(el)
return cs.maskImage || cs.webkitMaskImage || 'none'
})
expect(mask).toContain('radial-gradient')
expect(mask.split('radial-gradient').length - 1).toBe(2) // 上下各一孔
// 阴影在 wrapper 以 drop-shadow 生效——跟随挖空后的 alpha 轮廓(box-shadow 会被 mask 裁掉)
const f = await done.evaluate((el) => {
const w = el.parentElement as HTMLElement
return { filter: getComputedStyle(w).filter, innerShadow: getComputedStyle(el).boxShadow }
})
expect(f.filter).toBe('drop-shadow(rgb(17, 17, 17) 5px 5px 0px)')
expect(f.innerShadow).toBe('none')
// 孔缘弧线:2px ink 半圆环,clip 只留票内一半,圆心骑撕线两端(hole-x=172.75)
const arcs = await page.evaluate(() => {
const done = document.querySelector('.ticket-pk') as HTMLElement
const w = done.parentElement as HTMLElement
const wx = w.getBoundingClientRect().x
return [...w.querySelectorAll('.pk-arc')].map((a) => {
const cs = getComputedStyle(a)
const r = a.getBoundingClientRect()
return { clip: cs.clipPath, bw: cs.borderTopWidth, bc: cs.borderTopColor, cx: r.x + r.width / 2 - wx }
})
})
expect(arcs).toHaveLength(2)
arcs.forEach((a) => {
expect(a.bw).toBe('2px')
expect(a.bc).toBe('rgb(17, 17, 17)')
expect(Math.abs(a.cx - 172.75)).toBeLessThan(0.5)
})
expect(arcs[0].clip).toBe('inset(50% 0px 0px)')
expect(arcs[1].clip).toBe('inset(0px 0px 50%)')
// 未开始票不带 mask
const plain = await page.evaluate(() => {
const t = [...document.querySelectorAll('div')].filter((d) => String(d.className).includes('w-[216px]') && !String(d.className).includes('ticket-pk'))[0]
return t ? getComputedStyle(t).maskImage : 'none'
})
expect(plain === 'none' || plain === '').toBeTruthy()
})
test('撕线:完成=虚线、未开始=实线', async ({ page }) => {
const styles = await page.evaluate(() =>
[...document.querySelectorAll('div')]
.filter((el) => String(el.className).includes('w-[216px]'))
.map((el) => getComputedStyle(el.lastElementChild as HTMLElement).borderLeftStyle),
)
expect(styles).toContain('dashed')
expect(styles).toContain('solid')
})
})