Files
lexgo/learner/e2e/keyboard.spec.ts
ila e069894c4e feat: 补齐桌面与手机体验、显示偏好与键盘操作 (#14)
- 主题(浅色/深色/跟随系统)与正文字号(标准/大/特大)按账号存在本机,
  账号切换不串、退出回默认;深色走 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 与需求更新
2026-09-15 15:11:30 +08:00

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')
})