- 短语与单词共用 lexgo_terms:身份键为按序规范化词形以空格连接,单词键不含空格, 因此 kind 与词数由身份键派生,不需要新列或第二套复习逻辑 - POST /api/v1/phrases 由服务端从本人 ready 章节推导词序列与身份,切进单词的范围 400; 章节 tokens 增加 phrases 区间,队列项增加 kind/wordCount - 跨章节匹配按连续词形比对,重叠取最左最长;短语高亮覆盖内部单词但不修改单词数据 - 学习端新增 readerRange 纯函数层与 useTextSelection(原生拖选 + 手机手柄,不拦截 touchmove),面板提供短语标题与按词调整端点的按钮,复习卡把整段短语挖成一个空 - Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
131 lines
7.3 KiB
TypeScript
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' })
|
|
})
|