Files
ila f2fdf405a9 style: 放大章节阅读区与编辑区,使其随窗口宽度自适应
阅读区:正文列上限由固定 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 全部通过。无长期文档影响(仅样式数值)。
2026-09-16 23:33:50 +08:00

187 lines
11 KiB
TypeScript
Raw Permalink 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)
// 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()
})