Files
lexgo/learner/e2e/progress.spec.ts
T
ila a9952b3516 feat: 完成章节并查看个人基础进度 (#13)
- schema v7 新增 lexgo_chapter_progress(chapter_id 主键、read_sha256 内容版本门控、
  外键级联到章节/书籍/账号),仍然只用可重放的 CREATE TABLE IF NOT EXISTS
- POST /api/v1/chapters/:id/complete:只记已读、不改词语状态;只有 ready 章节可标记;
  重复调用返回同一行并带 duplicate,不移动已读时间也不重复计数;正文新版本后该章
  回到未读但保留记录,重新标记更新同一行
- GET /api/v1/progress:已读/可阅读章节、已知/学习中/新词/忽略、已保存词条、待复习
  与每本书的已读进度;到期数量与复习队列共用 dueTermsQuery 与同一时钟
- ChapterSummary 增加 readAt,书籍列表、阅读器与编辑响应都会解析
- 学习端新增 /progress 页面与导航、stores/progress.ts,阅读器新增显式「标记本章已读」
  区块,书籍页显示已读标记
- Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
2026-09-15 12:31:53 +08:00

105 lines
6.0 KiB
TypeScript

import { expect, test, type Page } from '@playwright/test'
// Completing a chapter and reading the progress page, against a mocked API.
test('marking a chapter read shows up in the book and on the progress page once', async ({ page }) => {
const user = { id: 42, username: 'fictional-progress', role: 'learner' }
const chapter = {
id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', status: 'ready', charCount: 31,
errorReason: '', errorMessage: '', jobId: 9, readAt: null as string | null, createdAt: '', updatedAt: '',
}
let completions = 0
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: [{ id: 7, title: 'Fictional reader', language: 'en', chapterCount: 1, pendingCount: 0, processingCount: 0, readyCount: 1, failedCount: 0, createdAt: '', updatedAt: '' }] }
else if (path === '/api/v1/books/7') data = { book: { id: 7, title: 'Fictional reader', language: 'en' }, chapters: [chapter] }
else if (path === '/api/v1/chapters/3/tokens') data = { tokens: [{ start: 0, end: 9, text: 'Curiosity', kind: 'word' }] }
else if (path === '/api/v1/chapters/3' && method === 'GET') {
data = { book: { id: 7, title: 'Fictional reader', language: 'en' }, chapter: { ...chapter, contentSha256: 'sha', originalText: 'Curiosity opens the first door.\n' }, navigation: { previousChapterId: null, nextChapterId: null } }
} else if (path === '/api/v1/chapters/3/complete') {
completions += 1
const readAt = '2026-09-15T03:04:05Z'
chapter.readAt = readAt
data = { progress: { chapterId: 3, bookId: 7, read: true, readAt, duplicate: completions > 1 } }
} else if (path === '/api/v1/progress') {
data = {
readChapters: chapter.readAt ? 1 : 0, totalChapters: 1,
knownTerms: 2, learningTerms: 1, newTerms: 3, ignoredTerms: 1, savedTerms: 7,
dueNow: chapter.readAt ? 1 : 4,
books: [{ id: 7, title: 'Fictional reader', readChapters: chapter.readAt ? 1 : 0, totalChapters: 1 }],
}
} 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 expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
// The reader offers the explicit completion action and says what it does and does not do.
await page.goto('/chapters/3')
await expect(page.getByRole('heading', { name: 'Fictional chapter' })).toBeVisible()
await expect(page.getByTestId('mark-read')).toHaveText('标记本章已读')
await expect(page.locator('.chapter-complete')).toContainText('只记录已读,不改变词语状态')
await page.screenshot({ path: '../.local/evidence/issue13-reader-unread.png' })
// Completing shows the reading time, and repeating is answered without counting twice.
await page.getByTestId('mark-read').click()
await expect(page.getByTestId('chapter-read-state')).toContainText('本章已读')
await expect(page.getByTestId('complete-notice')).toContainText('已记为读完本章')
await page.screenshot({ path: '../.local/evidence/issue13-reader-read.png' })
await page.getByTestId('mark-read').click()
await expect(page.getByTestId('complete-notice')).toContainText('没有重复计数')
// The book list shows which chapters are read.
await page.goto('/books/7')
await expect(page.getByTestId('chapter-read-chip').first()).toHaveText('已读')
// The progress page reports the reading total and keeps the four word statuses apart.
await page.getByRole('link', { name: '进度' }).click()
await expect(page).toHaveURL(/\/progress/)
await expect(page.getByTestId('stat-read')).toContainText('1 / 1')
await expect(page.getByTestId('stat-known')).toContainText('2')
await expect(page.getByTestId('stat-ignored')).toContainText('1')
await expect(page.getByTestId('stat-due')).toContainText('1')
await expect(page.getByTestId('progress-books')).toContainText('Fictional reader')
await page.screenshot({ path: '../.local/evidence/issue13-progress.png' })
// Reloading keeps the same numbers: they come from the server, not from the page.
await page.reload()
await expect(page.getByTestId('stat-read')).toContainText('1 / 1')
expect(completions).toBe(2)
})
test('a fresh space explains the empty progress instead of showing zeros alone', async ({ page }) => {
const user = { id: 42, username: 'fictional-progress', 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/progress') {
data = { readChapters: 0, totalChapters: 0, knownTerms: 0, learningTerms: 0, newTerms: 0, ignoredTerms: 0, savedTerms: 0, dueNow: 0, books: [] }
}
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('/progress')
await expect(page.getByTestId('stat-read')).toContainText('0 / 0')
await expect(page.getByTestId('progress-no-books')).toContainText('还没有书')
await expect(page.getByTestId('stat-ignored')).toContainText('0')
})