- schema v5 新增 lexgo_terms:身份为学习者+语言+规范化词形,唯一键保证
重复保存只更新同一条记录,不产生冲突副本
- POST /api/v1/terms 幂等保存并返回 created,GET /api/v1/terms/:id 仅本人可读,
章节 tokens 为 word 片段附带 term:{id,status,level}
- 状态与等级边界:新词/学习中/已知/忽略,只有学习中带 1~7 级,其余必须为 0,
并由数据库检查约束守住
- 学习端面板可编辑释义、例句与学习状态,正文按状态高亮;打开已保存词先读取原内容,
读取失败时禁用保存,切换账号或退出后清理表单、状态与高亮
- 同步 Architecture-and-Code-Map、Business-Rules-and-Glossary、
Local-Development-and-Verification、Product-Requirements-Overview 与 Home
158 lines
8.6 KiB
TypeScript
158 lines
8.6 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'
|
|
// The personal record the learner saves during this run, served back on reload.
|
|
let savedTerm: { id: number; term: string; originalForm: string; definition: string; examples: string[]; status: string; level: number } | null = null
|
|
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 },
|
|
}
|
|
} else if (path === '/api/v1/chapters/55/tokens') {
|
|
let offset = 0
|
|
const tokens = (pasted.match(/[A-Za-z]+|\s+|[^A-Za-z\s]+/g) ?? []).map(text => {
|
|
const start = offset
|
|
offset += text.length
|
|
return { text, start, end: offset, startUtf16: start, endUtf16: offset, kind: /^[A-Za-z]+$/.test(text) ? 'word' : /^\s+$/.test(text) ? 'space' : 'punctuation' }
|
|
})
|
|
data = {
|
|
textSha256: 'fictional-sha256',
|
|
tokens: tokens.map(token => savedTerm && token.text === 'First'
|
|
? { ...token, term: { id: savedTerm.id, status: savedTerm.status, level: savedTerm.level } }
|
|
: token),
|
|
}
|
|
} else if (path === '/api/v1/terms' && method === 'POST') {
|
|
const body = route.request().postDataJSON() as { chapterId: number; start: number; end: number; definition: string; examples: string[]; status: string }
|
|
expect(body).toEqual({ chapterId: 55, start: 0, end: 5, definition: '虚构的个人释义', examples: ['A fictional example.'], status: 'new' })
|
|
savedTerm = { id: 9, term: 'first', originalForm: 'First', definition: body.definition, examples: body.examples, status: body.status, level: 0 }
|
|
statusCode = 201
|
|
data = { term: savedTerm, created: true }
|
|
} else if (path === '/api/v1/terms/9') data = { term: savedTerm }
|
|
else if (path === '/api/v1/lookup') {
|
|
expect(route.request().postDataJSON()).toEqual({ chapterId: 55, start: 0, end: 5 })
|
|
data = { status: 'exact', query: 'first', matchedForm: 'first', candidates: [], entries: [{ lemma: 'first', pos: 'adjective', definition: 'Coming before all others.', examples: ['The first fictional chapter.'] }] }
|
|
}
|
|
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()
|
|
const word = page.locator('.reader-word').first()
|
|
await expect(word).toHaveAttribute('aria-label', '查询 First')
|
|
await word.focus()
|
|
await word.press('Enter')
|
|
await expect(page.getByText('Coming before all others.')).toBeVisible()
|
|
expect(await readerText.evaluate(element => element.textContent)).toBe(pasted)
|
|
|
|
// Browser narrow viewport check only; this is not real-device acceptance.
|
|
await page.setViewportSize({ width: 390, height: 844 })
|
|
await word.click()
|
|
await expect(page.getByText('Coming before all others.')).toBeVisible()
|
|
const panelBounds = await page.locator('.lookup-panel').boundingBox()
|
|
expect(panelBounds!.y + panelBounds!.height).toBeLessThanOrEqual(845)
|
|
expect(panelBounds!.height).toBeLessThanOrEqual(844 * 0.46)
|
|
expect(await page.locator('.lookup-panel').evaluate(element => getComputedStyle(element).position)).toBe('fixed')
|
|
const wordBounds = await word.boundingBox()
|
|
expect(wordBounds!.y + wordBounds!.height).toBeLessThanOrEqual(panelBounds!.y)
|
|
expect(await page.getByLabel('我的释义 新词条').inputValue()).toBe('')
|
|
|
|
// Save a personal record: definition, example and the default status.
|
|
await page.setViewportSize({ width: 1280, height: 900 })
|
|
await page.getByLabel('我的释义 新词条').fill('虚构的个人释义')
|
|
await page.getByLabel('例句 每行一条,最多 5 条').fill('A fictional example.')
|
|
await page.getByRole('button', { name: '保存到生词本' }).click()
|
|
await expect(page.getByText('已保存 · 新词')).toBeVisible()
|
|
await expect(word).toHaveClass(/is-new/)
|
|
expect(await readerText.evaluate(element => element.textContent)).toBe(pasted)
|
|
await page.getByRole('button', { name: '关闭释义' }).press('Escape')
|
|
await expect(page.locator('.lookup-panel')).toHaveCount(0)
|
|
await expect(word).toBeFocused()
|
|
|
|
// Returning to the chapter shows the same state and the stored text.
|
|
await page.reload()
|
|
const reloadedWord = page.locator('.reader-word').first()
|
|
await expect(reloadedWord).toHaveAttribute('aria-label', '查询 First,已保存')
|
|
await expect(reloadedWord).toHaveClass(/is-new/)
|
|
await reloadedWord.click()
|
|
await expect(page.getByLabel('我的释义 已保存')).toHaveValue('虚构的个人释义')
|
|
await expect(page.getByLabel('例句 每行一条,最多 5 条')).toHaveValue('A fictional example.')
|
|
await expect(page.getByRole('radio', { name: '新词' })).toBeChecked()
|
|
})
|