Files
lexgo/learner/e2e/reading.spec.ts
T
ila b1b2e6c6fe feat: 英汉词典与规范音标、多词典并存 (#40)
- ECDICT 常用子集(82,721 条,含中文释义)与 CMUdict ARPAbet→IPA 转写音标,
  由 scripts/dict_prepare.py 从 sha256 pin 的源显式准备,运行时不联网。
- schema v12:放开 lexgo_dictionaries 单行约束并增加 provider 列,英英与英汉词典
  可分别启用;查词合并时中文释义在前、英英释义随后。
- 音标只取可验证来源:优先 CMUdict 转写,否则保留 ECDICT 记法并标注来源,
  两者都不可靠时不显示;含 ^ 等丢失首音的记法整条丢弃。
- 屈折形经 WordNet lemma 解析后回查中文词典,但不把词目音标复制到屈折形上。
- 管理端可区分两种词典并分别启停;学习端查词面板显示音标与中文释义。
2026-09-16 13:17:26 +08:00

175 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: [], phonetic: 'fɜːst', phoneticSource: 'cmudict',
resources: [{ name: '简明英汉字典增强版(ECDICT)+ CMUdict 音标', version: 'ecdict-1.0.28+cmudict-0.7b' }, { name: 'Princeton WordNet', version: '3.0' }],
resource: { name: 'Princeton WordNet', version: '3.0' },
entries: [
{ lemma: 'first', pos: '', definition: '', examples: [], translation: 'a. 第一的, 最早的\nn. 开始, 第一', provider: 'ecdict' },
{ lemma: 'first', pos: 'adjective', definition: 'Coming before all others.', examples: ['The first fictional chapter.'], provider: 'wordnet' },
],
}
}
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()
// The merged dictionary answers with the IPA phonetic and the Chinese gloss first, then the
// English detail from WordNet; both directories are named in the footnote.
await expect(page.getByTestId('lookup-phonetic')).toHaveText('/fɜːst/')
await expect(page.getByTestId('lookup-translation')).toContainText('第一的')
const senses = page.locator('.lookup-senses li')
await expect(senses.nth(0)).toHaveAttribute('data-provider', 'ecdict')
await expect(senses.nth(1)).toHaveAttribute('data-provider', 'wordnet')
await expect(page.locator('.lookup-panel')).toContainText('Princeton WordNet')
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()
await expect(page.getByTestId('lookup-phonetic')).toHaveText('/fɜːst/')
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()
})