291 lines
14 KiB
TypeScript
291 lines
14 KiB
TypeScript
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('/shelf.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 c1 = await page.locator('a[href="#ch1"]').first().boundingBox()
|
||
const c2 = await page.locator('a[href="#ch2"]').first().boundingBox()
|
||
const wrap = await page.locator('main div.relative').first().boundingBox()
|
||
expect(c1 && c2 && wrap).toBeTruthy()
|
||
|
||
const stations = await page.locator('svg circle').evaluateAll((ns) =>
|
||
ns.map((n) => ({ cx: +(n.getAttribute('cx') ?? 0), r: +(n.getAttribute('r') ?? 0) })).filter((s) => s.r === 6), // 排除散落 ○ 预期环
|
||
)
|
||
expect(stations.length).toBeGreaterThanOrEqual(4)
|
||
|
||
const c1Right = c1!.x - wrap!.x + c1!.width // 卡右描边(不接阴影)
|
||
const c2Border = c2!.x - wrap!.x
|
||
expect(stations.some((s) => Math.abs(s.cx - c1Right) <= 3), `源站点圆心应骑卡右描边 x≈${c1Right}`).toBeTruthy()
|
||
expect(stations.some((s) => Math.abs(s.cx - c2Border) <= 3), `目标站点圆心应骑左描边 x≈${c2Border}`).toBeTruthy()
|
||
|
||
// 线层必须置于卡之上——否则半嵌的站点被卡盖住,只剩卡外一小块
|
||
const z = await page.evaluate(() => {
|
||
const svg = document.querySelector('main div.relative > svg') as SVGElement | null
|
||
return svg ? getComputedStyle(svg).zIndex : 'missing'
|
||
})
|
||
expect(z).toBe('10')
|
||
})
|
||
|
||
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('/shelf.html'))
|
||
|
||
test('卡片附近空当+随机:不压卡、都在卡附近、不出容器、旋转各异', async ({ page }) => {
|
||
const r = await page.evaluate(() => {
|
||
const layer = document.querySelector('[data-scatter-layer]') as HTMLElement | null
|
||
if (!layer) return null
|
||
const host = layer.parentElement as HTMLElement
|
||
const hb = host.getBoundingClientRect()
|
||
const shapes = [...layer.querySelectorAll('[data-scatter]')] as HTMLElement[]
|
||
const comps = [...host.querySelectorAll('[data-comp]')].map((el) => el.getBoundingClientRect())
|
||
const hit = (a: DOMRect, b: DOMRect) => a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top
|
||
return {
|
||
n: shapes.length,
|
||
inHost: shapes.every((s) => {
|
||
const r = s.getBoundingClientRect()
|
||
return r.left >= hb.left - 1 && r.right <= hb.right + 1 && r.top >= hb.top - 1 && r.bottom <= hb.bottom + 1
|
||
}),
|
||
onComp: shapes.filter((s) => comps.some((c) => hit(s.getBoundingClientRect(), c))).length,
|
||
nearAll: shapes.every((s) => {
|
||
const r = s.getBoundingClientRect()
|
||
const cx = (r.left + r.right) / 2
|
||
const cy = (r.top + r.bottom) / 2
|
||
return comps.some((c) => cx >= c.left - 200 && cx <= c.right + 200 && cy >= c.top - 200 && cy <= c.bottom + 200)
|
||
}),
|
||
transforms: shapes.map((s) => s.style.transform),
|
||
}
|
||
})
|
||
expect(r).not.toBeNull()
|
||
expect(r!.n).toBeGreaterThanOrEqual(5)
|
||
expect(r!.inHost).toBeTruthy() // 不出场景容器
|
||
expect(r!.onComp).toBe(0) // 空当=不压任何内容盒
|
||
expect(r!.nearAll).toBeTruthy() // 只散在卡片附近,不胡乱撒
|
||
expect(new Set(r!.transforms).size).toBe(r!.n) // 落点/旋转各不相同
|
||
r!.transforms.forEach((t) => expect(t).toMatch(/^translate\(-50%, -50%\) rotate\(-?\d+deg\)$/))
|
||
})
|
||
})
|
||
|
||
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')
|
||
})
|
||
})
|