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() })