- 主题(浅色/深色/跟随系统)与正文字号(标准/大/特大)按账号存在本机, 账号切换不串、退出回默认;深色走 html[data-theme] 与 Element Plus 的 html.dark - style.css 收敛为语义调色板::root 的 53 个变量是文件内仅有的颜色字面量, 其余规则全部走 var(),暗色只覆盖变量 - 字号经 --reader-font-scale 只作用于阅读面,不做全局缩放 - 阅读位置按账号+章节保存滚动比例与该章 content_sha256,正文换版本后不恢复 - 复习页键盘:空格/Enter 显示答案、1/2/3 评分;输入控件与聚焦按钮的按键不被劫持 - 站点头部新增「显示」控件,七个学习页面共用 - Playwright 新增 390×844 hasTouch 的 mobile 项目与移动用例(无溢出、可返回、 触摸滑动不误开面板、深色与字号持久化、账号隔离);新增主题变量回归用例 - Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
159 lines
7.2 KiB
TypeScript
159 lines
7.2 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 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<string, (init?: RequestInit) => 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: '<div>LibraryStub</div>' } },
|
|
{ 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)
|
|
})
|
|
})
|