test: design-lab 加 E2e 防画面偏差——结构契约断言 + 五页视觉基线(reducedMotion 稳定截图)

This commit is contained in:
cqh963852 committed 2026-10-05 20:17:28 +08:00
1 parent 869e38aef7
commit 15f66a8fa6
15 files changed
+297 -12

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
# design-lab · 设计系统可运行版
孟菲斯 × 拟物卡设计系统的 Vite MPA 实现。设计规格文档见 `design-refs/mockup/design-system/`(四册 PDF)。
## 结构
- 根目录每个 `*.html` 是一个入口,对应 `src/pages/<name>/main.tsx`(vite.config 自动扫描)
- `src/styles/tokens.css`:Tailwind v4 `@theme` 设计 token(撞色对 / 中性阶 / 章节色)
- `src/components/`:MemphisCard、RouteLine、Ticket、shapes、EntryGuide
- `src/lib/chapters.ts`:章节/关系/画像数据(改内容只动这里)
## 命令
```bash
yarn dev # 本地开发
yarn build # 构建 MPA
yarn test:e2e # E2E:结构契约断言 + 视觉基线
yarn test:e2e:update # 有意的设计变更后,更新截图基线并提交
```
## E2E 防画面偏差(两层)
1. **结构契约**(`e2e/pages.spec.ts`):规格级断言——卡片 2px 描边/硬投影/纸叠三层、16:9 相框比例与内阴影、积木点实心数、站点 r6 半嵌(源骑黑影外缘/目标贴描边)、票券裁切式(overflow hidden + 头部无框 + 双打孔 + 撕线虚/实)。布局微调不误报,破坏规格必红。
2. **视觉基线**(`e2e/visual.spec.ts` + `e2e/visual.spec.ts-snapshots/`):五页整页截图像素比对,容差 1%。**基线需随代码提交**;有意变更用 `yarn test:e2e:update` 生成新基线后一并提交。
测试在 `prefers-reduced-motion` 下运行(卡片的 8000ms 翻转与动效关闭,截图稳定)。
+159
View File
@@ -0,0 +1,159 @@
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 描边 + 硬投影 + 纸叠三层边', 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)')
expect((s.shadow.match(/rgb|#/g) ?? []).length).toBeGreaterThanOrEqual(4) // 三层纸叠+硬投影(按色值计数)
expect(s.shadow).toContain('rgb(17, 17, 17)')
})
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
expect(await card.locator('svg').first()).toBeVisible() // ▲
})
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 c1ShadowEdge = c1!.x - wrap!.x + c1!.width + 7 // 描边外 + 7px 黑影
const c2Border = c2!.x - wrap!.x
expect(stations.some((s) => Math.abs(s.cx - c1ShadowEdge) <= 3), `源站点应骑黑影外缘 x≈${c1ShadowEdge}`).toBeTruthy()
expect(stations.some((s) => Math.abs(s.cx - c2Border) <= 3), `目标站点应贴左描边 x≈${c2Border}`).toBeTruthy()
})
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('裁切式: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('打孔伪元素在位(完成票 ×2 孔)', async ({ page }) => {
const holes = await page.locator('.ticket-pk').first().evaluate((el) => {
const b = getComputedStyle(el, '::before')
const a = getComputedStyle(el, '::after')
return [b.content, a.content].filter((c) => c !== 'none').length
})
expect(holes).toBe(2)
})
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')
})
})
+26
View File
@@ -0,0 +1,26 @@
import { expect, test } from '@playwright/test'
/**
* 像素级视觉回归基线——防止后续改动出现画面偏差。
* 有意的设计变更:`yarn test:e2e -- --update-snapshots` 后提交新基线。
*/
const CASES: [name: string, path: string][] = [
['index', '/index.html'],
['colors', '/colors.html'],
['symbols', '/symbols.html'],
['components', '/components.html'],
['shelf', '/shelf.html'],
]
for (const [name, path] of CASES) {
test(`视觉基线 · ${name}`, async ({ page }) => {
await page.goto(path)
await page.evaluate(() => document.fonts.ready)
await page.waitForTimeout(300)
await expect(page).toHaveScreenshot(`${name}.png`, {
fullPage: true,
maxDiffPixelRatio: 0.01, // 允许 1% 以内的字体渲染差异
animations: 'disabled',
})
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

+5 -2
View File
@@ -6,13 +6,16 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test:e2e": "playwright test",
"test:e2e:update": "playwright test --update-snapshots"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@playwright/test": "1.63.0",
"@tailwindcss/vite": "^4.1.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
@@ -21,4 +24,4 @@
"typescript": "~5.6.2",
"vite": "^6.0.0"
}
}
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig, devices } from '@playwright/test'
/**
* 设计系统 E2E:防止后续改动出现画面偏差。
* - pages.spec:结构契约断言(规格级:描边/投影/比例/半嵌坐标)
* - visual.spec:整页截图基线(像素级回归)
* 截图基线在 e2e/__screenshots__/,需随代码一起提交;
* 有意的设计变更请运行 `yarn test:e2e -- --update-snapshots` 后提交新基线。
*/
export default defineConfig({
testDir: 'e2e',
timeout: 30_000,
fullyParallel: true,
use: {
baseURL: 'http://localhost:4490',
// 卡片尊重 prefers-reduced-motion:翻转关闭、动画停用,截图才稳定
contextOptions: { reducedMotion: 'reduce', viewport: { width: 1280, height: 900 } },
},
webServer: {
command: 'yarn preview --port 4490 --strictPort',
url: 'http://localhost:4490/index.html',
reuseExistingServer: true,
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
})
@@ -6,7 +6,6 @@ export function Ticket({ chapter, done }: { chapter: Chapter; done: boolean }) {
return (
<div
className={`relative flex w-[216px] items-stretch overflow-hidden rounded-xl border-2 border-ink bg-paper shadow-[5px_5px_0_var(--color-ink)] ${done ? 'ticket-pk' : ''}`}
style={{ ['--tk-color' as string]: chapter.color }}
>
{/* 头部媒体块:章节色纯填充,无独立边框(由票体裁切) */}
<div className="flex w-14 shrink-0 items-center justify-center text-[22px]" style={{ background: chapter.color }}>{chapter.emoji}</div>
@@ -21,14 +20,6 @@ export function Ticket({ chapter, done }: { chapter: Chapter; done: boolean }) {
>
№{String(chapter.id).padStart(2, '0')}
</div>
<style>{`
.ticket-pk::before, .ticket-pk::after {
content: ''; position: absolute; left: 160px; width: 14px; height: 14px;
border-radius: 50%; background: var(--color-paper); border: 2px solid var(--color-ink);
}
.ticket-pk::before { top: -10px; }
.ticket-pk::after { bottom: -10px; }
`}</style>
</div>
)
}
+1 -1
View File
@@ -9,7 +9,7 @@ import { Bar, Cross, MissMark, Ring, Tremble, Tri, Wave } from '../../components
const nodes = {
1: { id: 1, borderLeft: 40, right: 299, y: 180 },
2: { id: 2, borderLeft: 378, right: 639, y: 108 },
4: { id: 4, borderLeft: 698, right: 848, y: 108 },
5: { id: 5, borderLeft: 698, right: 848, y: 108 },
} as const
const SCATTER = [
+19
View File
@@ -31,3 +31,22 @@
.sem-shadow {
filter: drop-shadow(0 1px 0 rgba(255, 255, 255, 0.9));
}
/* 票券打孔:⌀14 圆跨边,被 overflow-hidden 裁成半圆缺口(完成票专用) */
.ticket-pk::before,
.ticket-pk::after {
content: '';
position: absolute;
left: 160px;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--color-paper);
border: 2px solid var(--color-ink);
}
.ticket-pk::before {
top: -10px;
}
.ticket-pk::after {
bottom: -10px;
}
@@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}