- adjustRange 改变范围时同时清空短语链接与已加载条目:调整端点后不再声称「已保存」, 已输入文本保留,保存按当前范围确定的身份写入(可能更新另一条,但绝不重复) - 新增 TestWordKeysNeverContainSpaces:显式锁定「词片段不含空白」这一不变量, termKind/termWordCount 完全依赖它,此前只有间接覆盖 - e2e 断言超时提高到 15s:11 个用例共用开发服务器并行运行时,首屏模块加载可能超过 默认 5s 造成偶发失败(产品行为未变,连续两次并行全量运行均通过)
187 lines
10 KiB
TypeScript
187 lines
10 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
|
import { createPinia, setActivePinia } from 'pinia'
|
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
|
import ReaderView from '../views/ReaderView.vue'
|
|
import { useSessionStore } from '../stores/session'
|
|
import type { PhraseSpan, ReaderToken } from '../composables/useReaderLookup'
|
|
|
|
// The same fixture shape the phrase tests use on the server side.
|
|
const original = 'Take a small, step\nevery day.\nMira took a small step again.\n'
|
|
const fragments: [string, ReaderToken['kind']][] = [
|
|
['Take', 'word'], [' ', 'space'], ['a', 'word'], [' ', 'space'], ['small', 'word'], [',', 'punctuation'],
|
|
[' ', 'space'], ['step', 'word'], ['\n', 'space'], ['every', 'word'], [' ', 'space'], ['day', 'word'],
|
|
['.', 'punctuation'], ['\n', 'space'], ['Mira', 'word'], [' ', 'space'], ['took', 'word'], [' ', 'space'],
|
|
['a', 'word'], [' ', 'space'], ['small', 'word'], [' ', 'space'], ['step', 'word'], [' ', 'space'],
|
|
['again', 'word'], ['.', 'punctuation'], ['\n', 'space'],
|
|
]
|
|
let cp = 0
|
|
let utf16 = 0
|
|
const tokens: ReaderToken[] = fragments.map(([text, kind]) => {
|
|
const token: ReaderToken = { text, kind, start: cp, end: cp + [...text].length, startUtf16: utf16, endUtf16: utf16 + text.length }
|
|
cp = token.end
|
|
utf16 = token.endUtf16
|
|
return token
|
|
})
|
|
const chapter = { id: 55, bookId: 1, title: '虚构章节', status: 'ready', originalText: original, contentSha256: 'same-sha' }
|
|
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
|
|
const savedPhrase = {
|
|
id: 7, term: 'a small step', originalForm: 'a small, step', definition: '一小步',
|
|
examples: ['Take a small step, every day.'], status: 'new', level: 0, kind: 'phrase', wordCount: 3,
|
|
}
|
|
let wrapper: VueWrapper | undefined
|
|
|
|
interface OpenOptions {
|
|
phrases?: PhraseSpan[]
|
|
tokens?: ReaderToken[]
|
|
original?: string
|
|
save?: (body: Record<string, unknown>) => Promise<Response>
|
|
termRead?: () => Promise<Response>
|
|
}
|
|
|
|
async function open(options: OpenOptions = {}) {
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
|
const url = String(input)
|
|
if (url.endsWith('/tokens')) return ok({ textSha256: 'same-sha', tokens: options.tokens ?? tokens, phrases: options.phrases ?? [] })
|
|
if (url.endsWith('/lookup')) return ok({ status: 'not_found', query: 'a', matchedForm: null, candidates: [], entries: [] })
|
|
if (url.includes('/terms/')) return options.termRead ? options.termRead() : ok({ term: savedPhrase })
|
|
if (url.endsWith('/phrases') || url.endsWith('/terms')) {
|
|
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
|
|
if (options.save) return options.save(body)
|
|
return ok({ term: savedPhrase, created: true })
|
|
}
|
|
// A test that supplies its own tokens must also supply the matching chapter text.
|
|
const text2 = options.original ?? original
|
|
return ok({ chapter: { ...chapter, originalText: text2 }, navigation: { previousChapterId: null, nextChapterId: 56 } })
|
|
})
|
|
useSessionStore().user = { id: 42, username: 'fictional', role: 'learner' }
|
|
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/chapters/:id', component: ReaderView }, { path: '/', component: { template: '<div />' } }] })
|
|
await router.push('/chapters/55')
|
|
wrapper = mount(ReaderView, { attachTo: document.body, global: { plugins: [router] } })
|
|
await flushPromises()
|
|
return { fetchMock, router, view: wrapper }
|
|
}
|
|
|
|
/** Clicks one word and shift-clicks the next matching word after it to extend the range. */
|
|
async function shiftSelect(view: VueWrapper, first: string, last: string) {
|
|
const words = view.findAll('.reader-word')
|
|
const startIndex = words.findIndex(item => item.text() === first)
|
|
const endIndex = words.findIndex((item, index) => index > startIndex && item.text() === last)
|
|
await words[startIndex]!.trigger('click')
|
|
await flushPromises()
|
|
await words[endIndex]!.trigger('click', { shiftKey: true })
|
|
await flushPromises()
|
|
}
|
|
|
|
describe('phrase selection and panel', () => {
|
|
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() })
|
|
afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.restoreAllMocks() })
|
|
|
|
it('builds a phrase from a shift-click range and saves it through the phrases endpoint', async () => {
|
|
let body: Record<string, unknown> | undefined
|
|
const { view } = await open({ save: async value => { body = value; return ok({ term: savedPhrase, created: true }) } })
|
|
await shiftSelect(view, 'a', 'step')
|
|
// The panel names the phrase and shows how many words it holds.
|
|
expect(view.get('[data-testid="phrase-range"]').text()).toContain('短语 · 3 个单词')
|
|
expect(view.text()).toContain('新词条')
|
|
// A phrase has no dictionary lookup section.
|
|
expect(view.find('[data-testid="lookup-retry"]').exists()).toBe(false)
|
|
await view.get('#term-definition').setValue('一小步')
|
|
await view.get('[data-testid="term-save"]').trigger('click')
|
|
await flushPromises()
|
|
// The selection travels as code point offsets of the whole phrase, punctuation included.
|
|
expect(body).toMatchObject({ chapterId: 55, start: 5, end: 18, definition: '一小步', status: 'new' })
|
|
expect(view.text()).toContain('已保存')
|
|
// The saved phrase highlights immediately.
|
|
expect(view.findAll('.reader-word').some(item => item.classes().includes('is-phrase'))).toBe(true)
|
|
})
|
|
|
|
it('adjusts both ends by whole words from the panel', async () => {
|
|
const { view } = await open()
|
|
await shiftSelect(view, 'a', 'step')
|
|
expect(view.get('[data-testid="phrase-range"]').text()).toContain('3 个单词')
|
|
await view.get('[data-testid="range-start-right"]').trigger('click')
|
|
await flushPromises()
|
|
expect(view.get('[data-testid="phrase-range"]').text()).toContain('2 个单词')
|
|
// Moving the start back restores the three-word phrase.
|
|
await view.get('[data-testid="range-start-left"]').trigger('click')
|
|
await flushPromises()
|
|
expect(view.get('[data-testid="phrase-range"]').text()).toContain('3 个单词')
|
|
// A two-word phrase cannot shrink further, so the shrinking buttons are disabled.
|
|
await view.get('[data-testid="range-start-right"]').trigger('click')
|
|
await flushPromises()
|
|
expect(view.get('[data-testid="phrase-range"]').text()).toContain('2 个单词')
|
|
expect(view.get('[data-testid="range-start-right"]').attributes('disabled')).toBeDefined()
|
|
expect(view.get('[data-testid="range-end-left"]').attributes('disabled')).toBeDefined()
|
|
// Growing it is still possible on either side.
|
|
expect(view.get('[data-testid="range-start-left"]').attributes('disabled')).toBeUndefined()
|
|
expect(view.get('[data-testid="range-end-right"]').attributes('disabled')).toBeUndefined()
|
|
})
|
|
|
|
it('opens the saved phrase when its highlighted range is clicked', async () => {
|
|
const span: PhraseSpan = { id: 7, status: 'new', wordCount: 3, startToken: 2, endToken: 7 }
|
|
const { view, fetchMock } = await open({ phrases: [span] })
|
|
const inside = view.findAll('.reader-word').find(item => item.text() === 'small')!
|
|
expect(inside.classes()).toContain('is-phrase')
|
|
await inside.trigger('click')
|
|
await flushPromises()
|
|
// The stored entry is read through the same route a word uses, and the panel edits it.
|
|
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/terms/7'))).toBe(true)
|
|
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('一小步')
|
|
expect(view.get('[data-testid="phrase-range"]').text()).toContain('已保存')
|
|
})
|
|
|
|
it('stops claiming a stored phrase once its range is adjusted', async () => {
|
|
const span: PhraseSpan = { id: 7, status: 'new', wordCount: 3, startToken: 2, endToken: 7 }
|
|
const { view } = await open({ phrases: [span] })
|
|
await view.findAll('.reader-word').find(item => item.text() === 'small')!.trigger('click')
|
|
await flushPromises()
|
|
expect(view.get('[data-testid="phrase-range"]').text()).toContain('已保存')
|
|
await view.get('[data-testid="range-end-right"]').trigger('click')
|
|
await flushPromises()
|
|
// The adjusted range is another identity, so the panel no longer says it is stored, while
|
|
// the text the learner already had stays in the form.
|
|
expect(view.get('.lookup-panel').text()).toContain('新词条')
|
|
expect(view.get('.lookup-panel').text()).not.toContain('已保存')
|
|
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('一小步')
|
|
})
|
|
|
|
it('refuses a range longer than the phrase limit and keeps the panel closed', async () => {
|
|
// One word more than a phrase may hold.
|
|
const longTokens: ReaderToken[] = []
|
|
const words: string[] = []
|
|
let cursor = 0
|
|
for (let index = 0; index < 14; index++) {
|
|
const word = `w${index}`
|
|
words.push(word)
|
|
longTokens.push({ text: word, start: cursor, end: cursor + word.length, startUtf16: cursor, endUtf16: cursor + word.length, kind: 'word' })
|
|
cursor += word.length
|
|
longTokens.push({ text: ' ', start: cursor, end: cursor + 1, startUtf16: cursor, endUtf16: cursor + 1, kind: 'space' })
|
|
cursor += 1
|
|
}
|
|
const { view } = await open({ tokens: longTokens, original: longTokens.map(token => token.text).join('') })
|
|
await shiftSelect(view, words[0]!, words[13]!)
|
|
expect(view.get('[data-testid="range-notice"]').text()).toContain('短语最多 12 个单词')
|
|
expect(view.find('[data-testid="phrase-range"]').exists()).toBe(false)
|
|
})
|
|
|
|
it('closes the phrase panel with Escape and restores the reading position', async () => {
|
|
const { view } = await open()
|
|
await shiftSelect(view, 'a', 'step')
|
|
expect(view.find('[data-testid="phrase-range"]').exists()).toBe(true)
|
|
await view.get('.lookup-panel').trigger('keydown', { key: 'Escape' })
|
|
await flushPromises()
|
|
expect(view.find('.lookup-panel').exists()).toBe(false)
|
|
})
|
|
|
|
it('keeps the typed phrase text when saving fails', async () => {
|
|
const { view } = await open({ save: async () => new Response(JSON.stringify({ code: 400, msg: '短语过长,请缩短选择范围' }), { status: 400 }) })
|
|
await shiftSelect(view, 'a', 'step')
|
|
await view.get('#term-definition').setValue('虚构释义')
|
|
await view.get('[data-testid="term-save"]').trigger('click')
|
|
await flushPromises()
|
|
expect(view.text()).toContain('短语过长,请缩短选择范围')
|
|
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('虚构释义')
|
|
})
|
|
})
|