Files
lexgo/learner/src/__tests__/experience.spec.ts
T
ila e069894c4e feat: 补齐桌面与手机体验、显示偏好与键盘操作 (#14)
- 主题(浅色/深色/跟随系统)与正文字号(标准/大/特大)按账号存在本机,
  账号切换不串、退出回默认;深色走 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 与需求更新
2026-09-15 15:11:30 +08:00

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)
})
})