- 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
121 lines
6.4 KiB
TypeScript
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)
|
|
})
|