Files
lexgo/learner/e2e/review.spec.ts
T
ila 0328505b77 feat: 完成到期单词复习与幂等答题 (#8)
- schema v6 新增 lexgo_term_reviews 与 lexgo_review_answers:语句都是可重试的加法迁移,
  既有已保存词汇按 created_at 立即进入队列;旧二进制回到 v5 仍可写入个人词条
- 固定间隔表 1/2/4/7/15/30/60 天:答对升级封顶 7、答错降级最低 1、再学一次不改等级,
  答错与再学立即回到本轮;已知与忽略不入队
- GET /api/v1/reviews/queue 与 POST /api/v1/reviews/:termId/answers:按 answerId 去重、
  按 expectedDueAt 判定过期标签页,重复提交与并发都不重复更新次数和间隔
- 学习端新增 /review 路由与到期复习入口,正面挖空例句、答案面评分、完成页与空队列页
- 同步 Architecture-and-Code-Map、Business-Rules-and-Glossary、
  Local-Development-and-Verification、Product-Requirements-Overview 与 Home
2026-09-11 20:54:17 +08:00

121 lines
6.4 KiB
TypeScript

import { expect, test } from '@playwright/test'
// The review round against a mocked API: queue, reveal, grades, relearn and the summary.
test('review the due words, requeue a missed one and finish the round', async ({ page }) => {
const user = { id: 42, username: 'fictional-reviewer', role: 'learner' }
const first = {
id: 7, term: 'curiosity', originalForm: 'curiosity', definition: '好奇心;求知欲',
examples: ['Learning begins with curiosity.'], status: 'new', level: 0,
dueAt: '2026-09-11T10:00:00Z', reviewCount: 0,
}
const second = {
id: 8, term: 'step', originalForm: 'step', definition: '一步',
examples: ['Take a small step, every day.'], status: 'learning', level: 2,
dueAt: '2026-09-11T10:00:00Z', reviewCount: 4,
}
// The wrong answer puts its word back in the same round, exactly as the server does.
const submitted: { grade: string; answerId: string }[] = []
let requeued = false
await page.route('**/api/v1/**', async route => {
const path = new URL(route.request().url()).pathname
const method = route.request().method()
let data: unknown = null
let status = 200
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: requeued ? [second] : [first, second], total: requeued ? 1 : 2 }
} else if (path.endsWith('/answers') && method === 'POST') {
const body = route.request().postDataJSON() as { answerId: string; grade: string; expectedDueAt: string }
submitted.push({ grade: body.grade, answerId: body.answerId })
expect(body.answerId).toMatch(/^[0-9a-f-]{36}$/)
const term = path.includes('8') ? second : first
// The client answers the card it was shown, so it echoes that card's due time.
expect(body.expectedDueAt).toBe(term.dueAt)
const wrong = body.grade === 'wrong'
requeued = wrong
const dueAtAfter = wrong ? '2026-09-11T10:05:00Z' : '2026-09-12T10:00:00Z'
term.dueAt = dueAtAfter
status = 201
data = {
result: 'applied', grade: body.grade, requeued: wrong,
statusBefore: term.status, statusAfter: wrong ? term.status : 'learning',
levelBefore: term.level, levelAfter: wrong ? 1 : term.level + 1,
dueAtBefore: body.expectedDueAt, dueAtAfter,
item: { ...term, dueAt: dueAtAfter, reviewCount: term.reviewCount + 1 },
}
}
await route.fulfill({ status, json: { code: 200, data } })
})
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()
// The library links into the due queue.
await page.getByRole('link', { name: '到期复习' }).click()
await expect(page).toHaveURL(/\/review$/)
await expect(page.getByTestId('review-position')).toHaveText('到期复习 · 1 / 2')
await expect(page.getByRole('heading', { name: 'curiosity' })).toBeVisible()
// The answer stays hidden, and the example shows a blank instead of the word.
await expect(page.getByText('Learning begins with _____.')).toBeVisible()
await expect(page.getByTestId('review-definition')).toHaveCount(0)
await page.getByTestId('review-reveal').click()
await expect(page.getByTestId('review-definition')).toHaveText('好奇心;求知欲')
await expect(page.getByTestId('review-correct')).toBeFocused()
await page.getByTestId('review-correct').click()
// The second word is learning level 2 and can be sent back into the round.
await expect(page.getByTestId('review-position')).toHaveText('到期复习 · 2 / 2')
await expect(page.getByText('学习中 · 等级 2')).toBeVisible()
await page.getByTestId('review-reveal').click()
await expect(page.getByTestId('review-definition')).toHaveText('一步')
await page.getByTestId('review-wrong').click()
// Requeued: the same word asks again and the round grows instead of pretending it ended.
await expect(page.getByTestId('review-position')).toHaveText('到期复习 · 3 / 3')
await expect(page.getByTestId('review-definition')).toHaveCount(0)
await page.getByTestId('review-reveal').click()
await page.getByTestId('review-correct').click()
await expect(page.getByTestId('review-summary')).toContainText('复习了 2 个词条 · 共 3 次作答')
await expect(page.getByTestId('review-summary')).toContainText('答对 2 · 答错或再学 1')
expect(submitted.map(entry => entry.grade)).toEqual(['correct', 'wrong', 'correct'])
expect(new Set(submitted.map(entry => entry.answerId)).size).toBe(3)
await page.getByTestId('review-finish').click()
await expect(page).toHaveURL(/\/$/)
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
})
test('an empty due queue says so instead of showing a card', async ({ page }) => {
const user = { id: 42, username: 'fictional-reviewer', role: 'learner' }
await page.route('**/api/v1/**', async route => {
const path = new URL(route.request().url()).pathname
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: [], total: 0 }
await route.fulfill({ json: { code: 200, data } })
})
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 page.goto('/review')
await expect(page.getByTestId('review-empty')).toContainText('今天没有到期词条')
await expect(page.getByTestId('review-card')).toHaveCount(0)
// The narrow layout keeps the grading controls reachable without horizontal overflow.
await page.setViewportSize({ width: 390, height: 844 })
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow).toBeLessThanOrEqual(0)
})