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 ReviewView from '../views/ReviewView.vue' import { POSITION_KEY_PREFIX } from '../composables/readingPosition' import { useSessionStore } from '../stores/session' const user = { id: 42, username: 'fictional-experience', role: 'learner' as const } const sha = 'c'.repeat(64) const text = 'Curiosity opens the first door.\nThe second door stays closed.\n' const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data })) let wrapper: VueWrapper | undefined const reviewItem = { id: 5, term: 'curiosity', originalForm: 'Curiosity', definition: '好奇心', examples: ['Curious minds ask.'], status: 'new' as const, level: 0, kind: 'word' as const, wordCount: 1, dueAt: '2026-09-15T00:00:00Z', reviewCount: 0, } function mockApi(handlers: Record unknown>): MockInstance { return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { const url = String(input) for (const [fragment, handler] of Object.entries(handlers)) { if (url.includes(fragment)) return ok(handler(init)) } return ok({}) }) } async function mountWith(component: unknown, path: string) { const router = createRouter({ history: createMemoryHistory(), routes: [ { path: '/', component: { template: '
LibraryStub
' } }, { path: '/chapters/:id', component: component as never }, { path: '/review', component: component as never }, ], }) await router.push(path) await router.isReady() wrapper = mount(component as never, { attachTo: document.body, global: { plugins: [router] } }) await flushPromises() await flushPromises() return wrapper } describe('review shortcuts in the page', () => { beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) }) afterEach(() => { vi.restoreAllMocks(); wrapper?.unmount() }) it('reveals and grades from the keyboard', async () => { const answers: string[] = [] useSessionStore().user = { ...user } mockApi({ '/reviews/queue': () => ({ items: [reviewItem], total: 1 }), '/answers': (init) => { answers.push(JSON.parse(String(init?.body)).grade) return { result: 'applied', duplicate: false, grade: 'correct', requeued: false, statusBefore: 'new', statusAfter: 'learning', levelBefore: 0, levelAfter: 1, dueAtBefore: reviewItem.dueAt, dueAtAfter: '2026-09-20T00:00:00Z', item: reviewItem, } }, }) const view = await mountWith(ReviewView, '/review') expect(view.find('[data-testid="review-card"]').exists()).toBe(true) expect(view.get('[data-testid="review-shortcuts"]').text()).toContain('空格') // Space shows the answer instead of grading anything. document.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })) await flushPromises() expect(view.get('[data-testid="review-definition"]').text()).toContain('好奇心') expect(answers).toEqual([]) // A digit grades it; the queue then has nothing left. document.dispatchEvent(new KeyboardEvent('keydown', { key: '1', bubbles: true })) await flushPromises() expect(answers).toEqual(['correct']) expect(view.find('[data-testid="review-summary"]').exists()).toBe(true) }) it('ignores shortcuts typed into a field and with a modifier held', async () => { const answers: string[] = [] useSessionStore().user = { ...user } mockApi({ '/reviews/queue': () => ({ items: [reviewItem], total: 1 }), '/answers': (init) => { answers.push(JSON.parse(String(init?.body)).grade) return { result: 'applied', duplicate: false, grade: 'wrong', requeued: true, statusBefore: 'new', statusAfter: 'new', levelBefore: 0, levelAfter: 0, dueAtBefore: reviewItem.dueAt, dueAtAfter: reviewItem.dueAt, item: reviewItem } }, }) const view = await mountWith(ReviewView, '/review') view.get('[data-testid="review-reveal"]').trigger('click') await flushPromises() // A key press inside an input belongs to the input, wherever the learner is typing. const field = document.createElement('input') document.body.append(field) field.dispatchEvent(new KeyboardEvent('keydown', { key: '2', bubbles: true })) document.dispatchEvent(new KeyboardEvent('keydown', { key: '2', ctrlKey: true, bubbles: true })) await flushPromises() expect(answers).toEqual([]) field.remove() document.body.dispatchEvent(new KeyboardEvent('keydown', { key: '2', bubbles: true })) await flushPromises() expect(answers).toEqual(['wrong']) }) }) describe('reading position in the reader', () => { beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) }) afterEach(() => { vi.restoreAllMocks(); wrapper?.unmount() }) function readerApi() { return mockApi({ '/tokens': () => ({ tokens: [{ start: 0, end: 9, text: 'Curiosity', kind: 'word' }] }), '/chapters/7': () => ({ book: { id: 3, title: 'Fictional reader', language: 'en' }, chapter: { id: 7, bookId: 3, ordinal: 1, title: 'Fictional chapter', status: 'ready', charCount: 31, errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '', contentSha256: sha, originalText: text, }, navigation: { previousChapterId: null, nextChapterId: null }, }), }) } it('restores the saved position for the same account and version', async () => { useSessionStore().user = { ...user } localStorage.setItem(`${POSITION_KEY_PREFIX}42:7`, JSON.stringify({ ratio: 0.5, sha, at: '2026-09-15T03:00:00Z' })) const scrollTo = vi.fn() vi.stubGlobal('scrollTo', scrollTo) // A chapter long enough to have a scrolled position. Object.defineProperty(document.documentElement, 'scrollHeight', { value: 4800, configurable: true }) Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true }) readerApi() const view = await mountWith(ReaderView, '/chapters/7') expect(view.find('.reader-text').exists()).toBe(true) await new Promise(resolve => setTimeout(resolve, 90)) expect(scrollTo).toHaveBeenCalledWith({ top: 2000 }) await flushPromises() expect(view.get('[data-testid="position-notice"]').text()).toContain('上次阅读位置') }) it('does not restore a position taken from another content version', async () => { useSessionStore().user = { ...user } localStorage.setItem(`${POSITION_KEY_PREFIX}42:7`, JSON.stringify({ ratio: 0.5, sha: 'd'.repeat(64) })) const scrollTo = vi.fn() vi.stubGlobal('scrollTo', scrollTo) Object.defineProperty(document.documentElement, 'scrollHeight', { value: 4800, configurable: true }) Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true }) readerApi() const view = await mountWith(ReaderView, '/chapters/7') await new Promise(resolve => setTimeout(resolve, 90)) expect(scrollTo).not.toHaveBeenCalled() expect(view.find('[data-testid="position-notice"]').exists()).toBe(false) }) })