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 { 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, limit = 15): Promise { 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 { 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') })