Files
lexgo/learner/e2e/reading.spec.ts
T
ila a55708cd37 feat: 粘贴英语文本、章节处理与本人阅读 (#5)
schema v3 新增 lexgo_books、lexgo_chapters、lexgo_ingest_jobs;一次粘贴生成一个章节,
原文按收到的字符串逐字保存;处理任务具备持久状态机、启动恢复与 requestId 幂等。
新增书籍/章节/任务 API 一律按认证身份过滤归属,他人编号返回 404,后台任务不使用
客户端用户编号;学习端补齐粘贴导入、书库、章节状态与失败重试、原文阅读与章节切换。

测试:MySQL 集成测试覆盖导入→处理中→就绪→阅读完整路径、失败重试、幂等、越权与
Unicode 原文保真;学习端 Vitest 31 项、Playwright 3 项与构建通过;lexgo_dev 已显式
迁移到 v3,迁移前后既有数据指纹不变。

文档:Wiki 先写后回读(架构、业务规则、本地验证、需求总览、Home),导出核心镜像。
2026-09-11 00:36:23 +08:00

93 lines
4.4 KiB
TypeScript

import { expect, test } from '@playwright/test'
test('paste English text, watch a chapter finish processing, then read it verbatim', async ({ page }) => {
const user = { id: 42, username: 'fictional-reader', role: 'learner' }
const book = { id: 1, title: '虚构样例书', language: 'en' }
const chapterTitle = '虚构样例第一章'
// Line breaks, a tab and repeated spaces must survive the whole round trip.
const pasted = 'First line of the chapter.\n\tIndented line.\nTwo spaces kept.\n\nLast line.\n'
const timestamps = { createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }
// The worker reports the fresh chapter as processing until the worker settles it.
let status: 'processing' | 'ready' = 'processing'
const chapterPayload = () => ({
id: 55,
bookId: book.id,
ordinal: 1,
title: chapterTitle,
status,
charCount: [...pasted].length,
errorReason: '',
errorMessage: '',
jobId: 7,
...timestamps,
})
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 statusCode = 200
if (path === '/api/v1/login') {
expect(route.request().postDataJSON()).toEqual({ username: user.username, password: 'fictional-password' })
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' && method === 'GET') {
data = { items: [{ ...book, chapterCount: 0, pendingCount: 0, processingCount: 0, readyCount: 0, failedCount: 0, ...timestamps }] }
} else if (path === '/api/v1/books' && method === 'POST') {
const body = route.request().postDataJSON() as { requestId: string; title: string; text: string; language: string }
expect(body.requestId).toMatch(/^[0-9a-f-]{36}$/)
expect(body).toMatchObject({ title: chapterTitle, text: pasted, language: 'en' })
statusCode = 201
data = {
book,
chapter: chapterPayload(),
job: { id: 7, bookId: book.id, chapterId: 55, status, attempts: 0, errorReason: '', errorMessage: '', ...timestamps },
duplicate: false,
}
} else if (path === '/api/v1/books/1') data = { book, chapters: [chapterPayload()] }
else if (path === '/api/v1/chapters/55') {
data = {
book,
chapter: { ...chapterPayload(), contentSha256: 'fictional-sha256', ...(status === 'ready' ? { originalText: pasted } : {}) },
navigation: { previousChapterId: null, nextChapterId: null },
}
}
await route.fulfill({ status: statusCode, 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()
// The library lists the caller's book.
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
await expect(page.getByRole('link', { name: book.title })).toBeVisible()
// Paste text through the import form.
await page.getByRole('button', { name: '导入内容' }).click()
await expect(page.getByRole('heading', { name: '导入英文内容' })).toBeVisible()
await page.getByLabel('标题').fill(chapterTitle)
await page.getByLabel('正文').fill(pasted)
await page.getByRole('button', { name: '开始处理' }).click()
// The new book opens with the chapter still processing…
await expect(page).toHaveURL(/\/books\/1$/)
await expect(page.getByText('处理中')).toBeVisible()
// …and the browser poll turns it ready without a page reload.
status = 'ready'
await expect(page.getByText('已就绪')).toBeVisible({ timeout: 15000 })
// Open the chapter and check the pasted text survived verbatim.
await page.getByRole('link', { name: chapterTitle }).click()
await expect(page).toHaveURL(/\/chapters\/55$/)
const readerText = page.locator('.reader-text')
await expect(readerText).toBeVisible()
expect(await readerText.evaluate(element => element.textContent)).toBe(pasted)
expect(await readerText.evaluate(element => getComputedStyle(element).whiteSpace)).toBe('pre-wrap')
await expect(page.getByRole('button', { name: '上一章' })).toBeDisabled()
await expect(page.getByRole('button', { name: '下一章' })).toBeDisabled()
})