import { expect, test, type Page } from '@playwright/test' // Real mouse selection over the rendered chapter, against a mocked API: the interaction #4 // verified is exercised as a continuous drag, not through a preset button. const original = 'Take a small step\nevery day.\n' const fragments: [string, 'word' | 'space' | 'punctuation'][] = [ ['Take', 'word'], [' ', 'space'], ['a', 'word'], [' ', 'space'], ['small', 'word'], [' ', 'space'], ['step', 'word'], ['\n', 'space'], ['every', 'word'], [' ', 'space'], ['day', 'word'], ['.', 'punctuation'], ['\n', 'space'], ] let cp = 0 let utf16 = 0 const tokens = fragments.map(([text, kind]) => { const token = { text, kind, start: cp, end: cp + [...text].length, startUtf16: utf16, endUtf16: utf16 + text.length } cp = token.end utf16 = token.endUtf16 return token }) const book = { id: 1, title: 'Phrase chapter', language: 'en' } const timestamps = { createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' } const chapter = { id: 55, bookId: 1, ordinal: 1, title: 'Phrase chapter', status: 'ready', charCount: [...original].length, errorReason: '', errorMessage: '', jobId: 5, contentSha256: 'fictional-sha', originalText: original, ...timestamps } async function openChapter(page: Page, options: { phrases?: unknown[]; onPhrase?: (body: Record) => void } = {}) { const user = { id: 42, username: 'fictional-phrase', role: 'learner' } await page.addInitScript(() => sessionStorage.setItem('lexgo-learner-token', 'fictional-session')) 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 status = 200 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') data = { items: [] } else if (path === '/api/v1/chapters/55/tokens') data = { textSha256: 'fictional-sha', tokens, phrases: options.phrases ?? [] } else if (path === '/api/v1/chapters/55') data = { book, chapter, navigation: { previousChapterId: null, nextChapterId: null } } else if (path === '/api/v1/lookup') data = { status: 'not_found', query: 'Take', matchedForm: null, candidates: [], entries: [] } else if (path === '/api/v1/phrases' && method === 'POST') { options.onPhrase?.(route.request().postDataJSON() as Record) status = 201 data = { term: { id: 9, term: 'take a small', originalForm: 'Take a small', definition: '拿一小步', examples: [], status: 'new', level: 0, kind: 'phrase', wordCount: 3 }, created: true, } } else if (path === '/api/v1/terms/9') data = { term: { id: 9, term: 'take a small', originalForm: 'Take a small', definition: '拿一小步', examples: ['Take a small step.'], status: 'new', level: 0, kind: 'phrase', wordCount: 3 } } await route.fulfill({ status, json: { code: 200, data } }) }) await page.goto('/chapters/55') await expect(page.locator('.reader-text')).toBeVisible() } /** * Drags from the centre of one word to the centre of another, like a person selecting text. * * A synthetic drag sometimes stops one word short or collapses entirely in Chromium, especially * while other tests load the same dev server, so the drag is repeated until the highlighted run * has the expected number of words. The retry condition is the property the test is about, not * just "a panel appeared": a real regression in selection handling still fails all attempts. */ async function dragWords(page: Page, from: string, to: string, words: number) { const word = page.locator('.reader-word') const start = word.filter({ hasText: new RegExp(`^${from}$`) }).first() const end = word.filter({ hasText: new RegExp(`^${to}$`) }).first() const range = page.getByTestId('phrase-range') for (let attempt = 1; attempt <= 4; attempt += 1) { // The boxes are read again on every attempt: an open panel reflows the page, so a cached // coordinate would drag to the wrong place and keep the retry failing for the same reason. const startBox = (await start.boundingBox())! const endBox = (await end.boundingBox())! const startX = startBox.x + startBox.width / 2 const startY = startBox.y + startBox.height / 2 await page.mouse.move(startX, startY) await page.mouse.down() // A couple of pixels first: the browser only starts extending the selection once the pointer // has actually moved, and a single jump can be treated as a click. await page.mouse.move(startX + 3, startY, { steps: 2 }) await page.mouse.move(endBox.x + endBox.width / 2, endBox.y + endBox.height / 2, { steps: 12 }) // Under load the browser may not have extended the selection by the time the button is // released, which is what produces a run that stops one word short. await page.waitForTimeout(80) await page.mouse.up() try { await expect(range).toContainText(`短语 · ${words} 个单词`, { timeout: 2500 }) return } catch (reason) { if (attempt === 4) throw reason // Close whatever panel the partial selection opened, so the next attempt starts clean. await page.keyboard.press('Escape') await expect(page.locator('.lookup-panel')).toHaveCount(0) } } } test('drag a continuous phrase, save it and highlight it', async ({ page }) => { let body: Record | undefined await openChapter(page, { onPhrase: value => { body = value } }) await dragWords(page, 'Take', 'small', 3) const range = page.getByTestId('phrase-range') await expect(range).toContainText('短语 · 3 个单词') // A phrase that is not stored yet is labelled as a new entry. await expect(page.locator('.lookup-panel')).toContainText('新词条') await page.getByLabel(/^我的释义/).fill('拿一小步') await page.screenshot({ path: '../.local/evidence/issue11-phrase-selected.png' }) await page.getByTestId('term-save').click() await expect(page.getByText('已保存 · 新词')).toBeVisible() // The code point range covers the whole run including its interior separator. expect(body).toMatchObject({ chapterId: 55, start: 0, end: 12, definition: '拿一小步', status: 'new' }) // The saved phrase is underlined in the text. await expect(page.locator('.reader-word.is-phrase')).toHaveCount(3) }) test('adjust the endpoints by whole words from the panel', async ({ page }) => { await openChapter(page) await dragWords(page, 'Take', 'small', 3) await expect(page.getByTestId('phrase-range')).toContainText('3 个单词') await page.getByTestId('range-end-right').click() await expect(page.getByTestId('phrase-range')).toContainText('4 个单词') await page.getByTestId('range-start-right').click() await expect(page.getByTestId('phrase-range')).toContainText('3 个单词') await page.getByTestId('range-start-left').click() await expect(page.getByTestId('phrase-range')).toContainText('4 个单词') }) test('open a saved phrase from its highlight', async ({ page }) => { await openChapter(page, { phrases: [{ id: 9, status: 'new', wordCount: 3, startToken: 0, endToken: 4 }] }) await expect(page.locator('.reader-word.is-phrase')).toHaveCount(3) await page.locator('.reader-word.is-phrase').first().click() await expect(page.getByTestId('phrase-range')).toContainText('已保存') await expect(page.getByLabel(/^我的释义/)).toHaveValue('拿一小步') await page.screenshot({ path: '../.local/evidence/issue11-phrase-saved.png' }) }) test('a phrase review card masks the whole run as one blank', async ({ page }) => { const user = { id: 42, username: 'fictional-phrase', role: 'learner' } await page.addInitScript(() => sessionStorage.setItem('lexgo-learner-token', 'fictional-session')) await page.route('**/api/v1/**', async route => { const path = new URL(route.request().url()).pathname let data: unknown = null if (path === '/api/v1/me') data = user else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' } else if (path === '/api/v1/reviews/queue') { data = { items: [{ id: 9, term: 'a small step', originalForm: 'a small step', definition: '一小步', examples: ['Take a small step, every day.'], status: 'new', level: 0, kind: 'phrase', wordCount: 3, dueAt: '2026-01-01T00:00:00Z', reviewCount: 0, }], total: 1, } } await route.fulfill({ json: { code: 200, data } }) }) await page.goto('/review') await expect(page.getByTestId('review-position')).toContainText('到期复习 · 1 / 1') await expect(page.getByText('Take _____, every day.')).toBeVisible() await expect(page.getByText('短语 · 3 个单词')).toBeVisible() await page.getByTestId('review-reveal').click() await expect(page.getByTestId('review-definition')).toHaveText('一小步') await page.screenshot({ path: '../.local/evidence/issue11-phrase-review.png' }) })