- 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 与需求更新
105 lines
6.0 KiB
TypeScript
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')
|
|
})
|