阅读区:正文列上限由固定 820px 改为 min(1040px, 92vw);打开查词面板时的容器由 1120px 改为 min(1240px, 96vw),大屏上正文列不再比不开面板时更窄。编辑区:章节编辑对话框由固定 620px 改为 min(1180px, 94vw),正文输入框最小高度改为 62vh(行数仅作初始值),书籍对话框 460px 改为 560px。 新增验证:reading 用例检查 768/1024/1280 三档宽度不横向溢出,并在 1280 下断言正文列宽 > 820px; edit 用例断言章节对话框宽 > 900px、正文输入框高 > 400px 且不溢出。手机(≤760px)的底部抽屉样式未改动。 测试:学习端 159 单测、26 项 e2e、vue-tsc、build 全部通过。无长期文档影响(仅样式数值)。
187 lines
11 KiB
TypeScript
187 lines
11 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: [], 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)
|
||
|
||
// The reading and editing surfaces must grow with the window instead of staying at a fixed
|
||
// 820px column, and no intermediate width may overflow sideways (the blind spot before this).
|
||
for (const width of [768, 1024, 1280]) {
|
||
await page.setViewportSize({ width, height: 900 })
|
||
await page.waitForTimeout(60)
|
||
const overflow = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, inner: window.innerWidth }))
|
||
expect(overflow.scroll, `reader must not overflow at ${width}px`).toBeLessThanOrEqual(overflow.inner)
|
||
}
|
||
await page.setViewportSize({ width: 1280, height: 900 })
|
||
await page.waitForTimeout(60)
|
||
const wideBody = await page.locator('.reader-body').boundingBox()
|
||
expect(wideBody!.width, 'the text column follows the window').toBeGreaterThan(820)
|
||
// 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()
|
||
})
|