test: design-lab 加 E2e 防画面偏差——结构契约断言 + 五页视觉基线(reducedMotion 稳定截图)
This commit is contained in:
1 parent
869e38aef7
commit
15f66a8fa6
15 files changed
+296
-11
No files matched your search
@@ -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 翻转与动效关闭,截图稳定)。
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 |
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -3172,6 +3172,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@playwright/test@npm:1.63.0":
|
||||
version: 1.63.0
|
||||
resolution: "@playwright/test@npm:1.63.0"
|
||||
dependencies:
|
||||
playwright: "npm:1.63.0"
|
||||
bin:
|
||||
playwright: cli.js
|
||||
checksum: 10/b5e561b8f11488b8062b98c5cf975740f48ae7e9b455fb7152e0fb90d8e13f20c3c3ae6a15833655364246b99f5a46f6601daa5a9eef01de5004c8be7e2b3af3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@pnpm/config.env-replace@npm:^1.1.0":
|
||||
version: 1.1.0
|
||||
resolution: "@pnpm/config.env-replace@npm:1.1.0"
|
||||
@@ -3447,6 +3458,7 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@stack/design-lab@workspace:apps/design-lab"
|
||||
dependencies:
|
||||
"@playwright/test": "npm:1.63.0"
|
||||
"@tailwindcss/vite": "npm:^4.1.0"
|
||||
"@types/react": "npm:^18.3.12"
|
||||
"@types/react-dom": "npm:^18.3.1"
|
||||
@@ -9905,6 +9917,26 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"playwright-core@npm:1.63.0":
|
||||
version: 1.63.0
|
||||
resolution: "playwright-core@npm:1.63.0"
|
||||
bin:
|
||||
playwright-core: cli.js
|
||||
checksum: 10/d0f7c70786f5e08aa7553a77ba28c07427086e323ae8c4126a647debd8181966dc11d6f8fe1462f2a776ad60c95451b08f01fbee17b11e69a5bd9a2070fe75a6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"playwright@npm:1.63.0":
|
||||
version: 1.63.0
|
||||
resolution: "playwright@npm:1.63.0"
|
||||
dependencies:
|
||||
playwright-core: "npm:1.63.0"
|
||||
bin:
|
||||
playwright: cli.js
|
||||
checksum: 10/ec87c22888148603a8955bc2a092f416455af931fe53925b9143ce333b83e42a6d8c159c00a4f2d232b27100cf3eadd93cec8a6e5c4e12c1c00663d2034dd515
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"postcss-attribute-case-insensitive@npm:^7.0.1":
|
||||
version: 7.0.1
|
||||
resolution: "postcss-attribute-case-insensitive@npm:7.0.1"
|
||||
|
||||
Reference in new issue
Block a user