Files
lexgo/learner/e2e/phrase.spec.ts
T
ila bd77aa0a46 feat: 选择连续短语,保存并加入到期复习 (#11)
- 短语与单词共用 lexgo_terms:身份键为按序规范化词形以空格连接,单词键不含空格,
  因此 kind 与词数由身份键派生,不需要新列或第二套复习逻辑
- POST /api/v1/phrases 由服务端从本人 ready 章节推导词序列与身份,切进单词的范围 400;
  章节 tokens 增加 phrases 区间,队列项增加 kind/wordCount
- 跨章节匹配按连续词形比对,重叠取最左最长;短语高亮覆盖内部单词但不修改单词数据
- 学习端新增 readerRange 纯函数层与 useTextSelection(原生拖选 + 手机手柄,不拦截
  touchmove),面板提供短语标题与按词调整端点的按钮,复习卡把整段短语挖成一个空
- Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
2026-09-14 22:21:51 +08:00

131 lines
7.3 KiB
TypeScript

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<string, unknown>) => 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<string, unknown>)
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. */
async function dragWords(page: Page, from: string, to: string) {
const words = page.locator('.reader-word')
const start = words.filter({ hasText: new RegExp(`^${from}$`) }).first()
const end = words.filter({ hasText: new RegExp(`^${to}$`) }).first()
const startBox = (await start.boundingBox())!
const endBox = (await end.boundingBox())!
await page.mouse.move(startBox.x + startBox.width / 2, startBox.y + startBox.height / 2)
await page.mouse.down()
await page.mouse.move(endBox.x + endBox.width / 2, endBox.y + endBox.height / 2, { steps: 8 })
await page.mouse.up()
}
test('drag a continuous phrase, save it and highlight it', async ({ page }) => {
let body: Record<string, unknown> | undefined
await openChapter(page, { onPhrase: value => { body = value } })
await dragWords(page, 'Take', 'small')
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')
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' })
})