- 主题(浅色/深色/跟随系统)与正文字号(标准/大/特大)按账号存在本机, 账号切换不串、退出回默认;深色走 html[data-theme] 与 Element Plus 的 html.dark - style.css 收敛为语义调色板::root 的 53 个变量是文件内仅有的颜色字面量, 其余规则全部走 var(),暗色只覆盖变量 - 字号经 --reader-font-scale 只作用于阅读面,不做全局缩放 - 阅读位置按账号+章节保存滚动比例与该章 content_sha256,正文换版本后不恢复 - 复习页键盘:空格/Enter 显示答案、1/2/3 评分;输入控件与聚焦按钮的按键不被劫持 - 站点头部新增「显示」控件,七个学习页面共用 - Playwright 新增 390×844 hasTouch 的 mobile 项目与移动用例(无溢出、可返回、 触摸滑动不误开面板、深色与字号持久化、账号隔离);新增主题变量回归用例 - Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
95 lines
4.7 KiB
TypeScript
95 lines
4.7 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test'
|
|
|
|
const user = { id: 42, username: 'fictional-keyboard', role: 'learner' }
|
|
const item = { id: 11, term: 'curiosity', originalForm: 'curiosity', definition: '好奇心;求知欲', examples: ['Curious minds ask.'], status: 'new', level: 0, kind: 'word', wordCount: 1, dueAt: '2026-01-02T00:00:00Z', reviewCount: 0 }
|
|
|
|
async function mockApi(page: Page): Promise<void> {
|
|
await page.route('**/api/v1/**', async route => {
|
|
const url = new URL(route.request().url())
|
|
const path = url.pathname
|
|
const method = route.request().method()
|
|
let data: unknown = null
|
|
if (path === '/api/v1/login') data = { token: 'fictional-session', user }
|
|
else if (path === '/api/v1/me') data = user
|
|
else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' }
|
|
else if (path === '/api/v1/books') data = { items: [] }
|
|
else if (path === '/api/v1/reviews/queue') data = { items: [item], total: 1 }
|
|
else if (path.endsWith('/answers') && method === 'POST') {
|
|
data = { result: 'applied', duplicate: false, grade: 'correct', requeued: false, statusBefore: 'new', statusAfter: 'learning', levelBefore: 0, levelAfter: 1, dueAtBefore: item.dueAt, dueAtAfter: '2026-01-09T00:00:00Z', item }
|
|
} else if (path === '/api/v1/terms' && method === 'GET') {
|
|
data = { items: [{ ...item, updatedAt: '2026-01-02T00:00:00Z' }], total: 1, page: 1, limit: 20 }
|
|
}
|
|
await route.fulfill({ json: { code: 200, data } })
|
|
})
|
|
}
|
|
|
|
/** Presses Tab until the target holds the focus, so reachability is what is being tested. */
|
|
async function tabUntilFocused(page: Page, target: ReturnType<Page['getByTestId']>, limit = 15): Promise<boolean> {
|
|
for (let step = 0; step < limit; step += 1) {
|
|
if (await target.evaluate(element => element === document.activeElement).catch(() => false)) return true
|
|
await page.keyboard.press('Tab')
|
|
}
|
|
return target.evaluate(element => element === document.activeElement).catch(() => false)
|
|
}
|
|
|
|
async function login(page: Page): Promise<void> {
|
|
await page.goto('/')
|
|
await page.getByLabel('账号').fill(user.username)
|
|
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
|
|
await page.getByRole('button', { name: '登录', exact: true }).click()
|
|
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
|
|
}
|
|
|
|
test('the due review can be driven from the keyboard alone', async ({ page }) => {
|
|
await mockApi(page)
|
|
await login(page)
|
|
await page.locator('.site-header nav').getByRole('link', { name: '到期复习' }).click()
|
|
await expect(page.getByTestId('review-card')).toBeVisible()
|
|
await expect(page.getByTestId('review-shortcuts')).toContainText('空格')
|
|
|
|
// The first action already holds the focus, so a keyboard user can keep going.
|
|
await expect(page.getByTestId('review-reveal')).toBeFocused()
|
|
|
|
// Space shows the answer, and the answer panel is announced as text.
|
|
await page.keyboard.press('Space')
|
|
await expect(page.getByTestId('review-definition')).toContainText('好奇心')
|
|
await expect(page.getByTestId('review-correct')).toBeFocused()
|
|
|
|
// A digit grades the card without touching the mouse.
|
|
await page.keyboard.press('1')
|
|
await expect(page.getByTestId('review-summary')).toBeVisible()
|
|
await page.screenshot({ path: '../.local/evidence/issue14-keyboard-review.png' })
|
|
|
|
// Tab reaches the closing action within a few presses, not after walking the whole page.
|
|
expect(await tabUntilFocused(page, page.getByTestId('review-finish'), 6)).toBe(true)
|
|
await page.keyboard.press('Enter')
|
|
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
|
|
})
|
|
|
|
test('typing in a form never triggers a global shortcut', async ({ page }) => {
|
|
await mockApi(page)
|
|
await login(page)
|
|
await page.locator('.site-header nav').getByRole('link', { name: '生词本' }).click()
|
|
await expect(page.locator('.vocab-row')).toHaveCount(1)
|
|
const search = page.getByLabel('搜索词 / 释义')
|
|
await search.click()
|
|
await search.type('curiosity 1 2 quit')
|
|
await expect(search).toHaveValue('curiosity 1 2 quit')
|
|
// The list only changes when the search is submitted, not while typing.
|
|
await expect(page.locator('.vocab-row')).toHaveCount(1)
|
|
})
|
|
|
|
test('the display control shows a visible focus ring', async ({ page }) => {
|
|
await mockApi(page)
|
|
await login(page)
|
|
const trigger = page.getByTestId('display-trigger')
|
|
// Reached by keyboard, which is what makes the focus ring appear in the first place.
|
|
expect(await tabUntilFocused(page, trigger)).toBe(true)
|
|
const outline = await trigger.evaluate(element => {
|
|
const style = getComputedStyle(element)
|
|
return { width: style.outlineWidth, style: style.outlineStyle }
|
|
})
|
|
expect(parseFloat(outline.width)).toBeGreaterThanOrEqual(2)
|
|
expect(outline.style).not.toBe('none')
|
|
})
|