Files
lexgo/learner/src/__tests__/attachments.spec.ts
T
ila 0fd661e061 feat: 音频与插图改为章节级,封面维持书级 (#37)
- schema v9:lexgo_chapter_attachments(chapter_id+kind 唯一,MEDIUMBLOB)与
  lexgo_chapter_playback_positions(owner_id+chapter_id),并用幂等语句清空书级音频历史行
- 书级音频与播放位置接口下线(路由不再注册);封面仍是书级且行为不变
- 章节级接口:POST/DELETE/GET /chapters/:id/{audio,illustration}、PUT /chapters/:id/playback;
  章节列表与阅读器响应带 illustrationVersion/audioVersion/playbackSeconds(始终存在)
- 复用 #21 的类型嗅探、尺寸校验、ServeContent Range/ETag 包装与“先校验后写入”流程
- 学习端:封面区块收窄、章节行新增缩略图与「附件」入口、独立的章节附件对话框、
  阅读页正文上方插图与章级播放器(audioChapterId 防止复用上一章音频)、切换章节上报位置
- 测试:Go 87 项(含 v8→v9 迁移与回退、书级接口已下线、章级附件集成)、学习端 157 单测与
  26 项 E2E、真实 API 52 项、#15 恢复演练第三次 22 项
- 文档:Architecture / Business-Rules / Local-Development / Requirements / Home /
  Deployment 改为章级口径
2026-09-15 21:08:43 +08:00

264 lines
14 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, type Router } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import AudioPlayer from '../components/AudioPlayer.vue'
import BookView from '../views/BookView.vue'
import LibraryView from '../views/LibraryView.vue'
import ReaderView from '../views/ReaderView.vue'
import { useLibraryStore } from '../stores/library'
import { useSessionStore } from '../stores/session'
const user = { id: 42, username: 'fictional-attachment', role: 'learner' as const }
const cover = { kind: 'cover' as const, mime: 'image/png', byteSize: 1234, version: 'cover-sha', updatedAt: '2026-09-15T00:00:00Z' }
const book = { id: 7, title: 'Fictional reader', language: 'en' }
const pngBytes = new Uint8Array([1, 2, 3, 4])
const baseChapter = {
id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', status: 'ready' as const, charCount: 31,
errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '',
}
let wrapper: VueWrapper | undefined
/** jsdom has no object URLs, so the two functions are added to the existing URL object. */
function stubObjectUrls(): void {
let counter = 0
;(URL as unknown as Record<string, unknown>).createObjectURL = vi.fn(() => `blob:fictional-${++counter}`)
;(URL as unknown as Record<string, unknown>).revokeObjectURL = vi.fn()
}
function putBackObjectUrls(): void {
delete (URL as unknown as Record<string, unknown>).createObjectURL
delete (URL as unknown as Record<string, unknown>).revokeObjectURL
}
function stubMedia(): { play: MockInstance; pause: MockInstance } {
const play = vi.spyOn(HTMLMediaElement.prototype, 'play').mockImplementation(async () => undefined)
const pause = vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined)
return { play, pause }
}
/** Sets the files of a hidden input the way a file picker would, then fires the change event. */
async function pickFile(view: VueWrapper, testid: string, file: File): Promise<void> {
const input = view.get(`[data-testid="${testid}"]`).element as HTMLInputElement
Object.defineProperty(input, 'files', { value: [file], configurable: true })
await view.get(`[data-testid="${testid}"]`).trigger('change')
await flushPromises()
}
interface Fixture {
chapter: typeof baseChapter & { illustrationVersion?: string; audioVersion?: string; playbackSeconds?: number }
requests: string[]
}
/** An API mock whose chapter carries an illustration, audio and a stored position. */
function mockApi(options: { illustration?: boolean; audio?: boolean; playbackSeconds?: number } = {}): Fixture & { fetchMock: MockInstance } {
const chapter = {
...baseChapter,
illustrationVersion: options.illustration === false ? '' : 'illustration-sha',
audioVersion: options.audio === false ? '' : 'audio-sha',
playbackSeconds: options.playbackSeconds ?? 65,
}
const requests: string[] = []
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = String(input)
const method = String(init?.method ?? 'GET')
requests.push(`${method} ${url}`)
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
if (url.endsWith('/me')) return ok(user)
if (url.endsWith('/space')) return ok({ ownerId: user.id, language: 'en' })
if (url.endsWith('/books') && method === 'GET') {
return ok({ items: [{ ...book, chapterCount: 1, pendingCount: 0, processingCount: 0, readyCount: 1, failedCount: 0, coverVersion: 'cover-sha', hasAudio: false, createdAt: '', updatedAt: '' }] })
}
if (url.endsWith('/books/7/cover') || url.includes('/chapters/3/')) {
if (method === 'GET') {
return new Response(pngBytes, { status: 200, headers: { 'Content-Type': 'application/octet-stream' } })
}
return ok({ attachment: { kind: url.includes('illustration') ? 'illustration' : 'audio', mime: 'application/octet-stream', byteSize: 2048, version: 'new-sha', updatedAt: '' }, deleted: true, playbackSeconds: 12 })
}
if (url.endsWith('/books/7')) return ok({ book: { ...book, attachments: { cover } }, chapters: [chapter] })
if (url.includes('/chapters/3/tokens')) return ok({ textSha256: 'sha', tokens: [] })
if (url.endsWith('/chapters/3')) {
return ok({ book: { ...book, attachments: { cover } }, chapter: { ...chapter, contentSha256: 'sha', originalText: 'Curiosity opens the first door.\n' }, navigation: { previousChapterId: null, nextChapterId: null } })
}
return ok({})
})
return { chapter, requests, fetchMock }
}
async function mountWith(component: unknown, path: string): Promise<{ view: VueWrapper; router: Router }> {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: LibraryView },
{ path: '/books/:id', component: BookView },
{ path: '/chapters/:id', component: ReaderView },
{ path: '/vocab', component: { template: '<div />' } },
{ path: '/review', component: { template: '<div />' } },
{ path: '/progress', component: { template: '<div />' } },
{ path: '/import', component: { template: '<div />' } },
],
})
await router.push(path)
await router.isReady()
wrapper = mount(component as never, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
await flushPromises()
return { view: wrapper, router }
}
describe('book cover in the library', () => {
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()); stubObjectUrls(); useSessionStore().user = { ...user } })
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); putBackObjectUrls(); wrapper?.unmount() })
it('shows the stored cover and loads its bytes once', async () => {
const { fetchMock } = mockApi()
const { view } = await mountWith(LibraryView, '/')
await vi.waitFor(() => expect(useLibraryStore().coverUrls[7]).toBeTruthy())
await flushPromises()
expect(view.get('[data-testid="book-cover"]').attributes('src')).toMatch(/^blob:fictional-/)
expect(fetchMock.mock.calls.filter(([url]) => String(url).includes('/books/7/cover'))).toHaveLength(1)
})
it('keeps the default cover when the book has none', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async input => {
const url = String(input)
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
if (url.endsWith('/me')) return ok(user)
if (url.endsWith('/space')) return ok({ ownerId: user.id, language: 'en' })
if (url.endsWith('/books')) return ok({ items: [{ ...book, chapterCount: 0, pendingCount: 0, processingCount: 0, readyCount: 0, failedCount: 0, createdAt: '', updatedAt: '' }] })
return ok({})
})
const { view } = await mountWith(LibraryView, '/')
await flushPromises()
expect(view.find('[data-testid="book-cover"]').exists()).toBe(false)
expect(view.get('.book-cover-default').text()).toBe('F')
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/cover'))).toBe(false)
})
})
describe('chapter attachments on the book page', () => {
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()); stubObjectUrls(); useSessionStore().user = { ...user } })
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); putBackObjectUrls(); wrapper?.unmount() })
it('shows the cover, a chapter thumbnail and the chapter audio state', async () => {
mockApi()
const { view } = await mountWith(BookView, '/books/7')
expect(view.get('[data-testid="cover-state"]').text()).toContain('PNG')
await vi.waitFor(() => expect(useLibraryStore().illustrationUrls[3]).toBeTruthy())
await flushPromises()
expect(view.get('[data-testid="chapter-thumb-3"]').attributes('src')).toMatch(/^blob:fictional-/)
expect(view.get('.chapter-meta').text()).toContain('有音频')
expect(view.get('.chapter-meta').text()).toContain('上次 1:05')
// The book-level audio row is gone: audio lives in the chapter dialog now.
expect(view.text()).not.toContain('音频 · 3.4 MB')
expect(view.get('[data-testid="chapter-attachments-3"]').text()).toBe('附件')
})
it('uploads an illustration and an audio file from the chapter dialog', async () => {
const { fetchMock } = mockApi({ illustration: false, audio: false, playbackSeconds: 0 })
const { view } = await mountWith(BookView, '/books/7')
await view.get('[data-testid="chapter-attachments-3"]').trigger('click')
await flushPromises()
expect(view.get('[data-testid="illustration-state"]').text()).toBe('未设置')
expect(view.get('[data-testid="chapter-audio-state"]').text()).toBe('未设置')
await pickFile(view, 'illustration-input', new File([pngBytes], 'chapter.png', { type: 'image/png' }))
expect(fetchMock.mock.calls.some(([url, init]) => String(url).endsWith('/chapters/3/illustration') && String(init?.method) === 'POST')).toBe(true)
await pickFile(view, 'chapter-audio-input', new File([new Uint8Array(64)], 'track.mp3', { type: 'audio/mpeg' }))
expect(fetchMock.mock.calls.some(([url, init]) => String(url).endsWith('/chapters/3/audio') && String(init?.method) === 'POST')).toBe(true)
expect(view.get('[data-testid="book-notice"]').text()).toContain('音频已更新')
})
it('refuses a wrong type before uploading and removes after confirmation', async () => {
const { fetchMock } = mockApi()
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never)
const { view } = await mountWith(BookView, '/books/7')
await view.get('[data-testid="chapter-attachments-3"]').trigger('click')
await flushPromises()
await pickFile(view, 'chapter-audio-input', new File([new Uint8Array(16)], 'song.wav', { type: 'audio/wav' }))
expect(view.get('.field-error').text()).toContain('音频只支持 MP3')
expect(fetchMock.mock.calls.filter(([, init]) => String(init?.method) === 'POST')).toHaveLength(0)
await view.get('[data-testid="illustration-remove"]').trigger('click')
await flushPromises()
const deleted = fetchMock.mock.calls.find(([, init]) => String(init?.method) === 'DELETE')
expect(String(deleted?.[0])).toContain('/chapters/3/illustration')
expect(view.get('[data-testid="illustration-state"]').text()).toBe('未设置')
})
})
describe('audio player component', () => {
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); wrapper?.unmount() })
it('formats the clock, changes the rate and reports an error', async () => {
stubMedia()
const positions: number[] = []
wrapper = mount(AudioPlayer, { props: { src: 'blob:fictional', initialPosition: 65, onPosition: (value: number) => positions.push(value) } })
await flushPromises()
expect(wrapper.get('[data-testid="audio-clock"]').text()).toContain('1:05')
await wrapper.get('[data-testid="audio-rate-1.5"]').trigger('click')
expect(wrapper.get('[data-testid="audio-rate-1.5"]').attributes('aria-pressed')).toBe('true')
const element = wrapper.get('[data-testid="audio-element"]').element as HTMLAudioElement
expect(element.playbackRate).toBe(1.5)
element.currentTime = 42
await wrapper.get('[data-testid="audio-element"]').trigger('pause')
expect(positions).toEqual([42])
await wrapper.get('[data-testid="audio-element"]').trigger('error')
expect(wrapper.get('[data-testid="audio-error"]').text()).toContain('音频加载失败')
})
it('never starts on its own and tolerates a rejected play', async () => {
const { play } = stubMedia()
play.mockRejectedValueOnce(new Error('blocked'))
wrapper = mount(AudioPlayer, { props: { src: 'blob:fictional', initialPosition: 0, onPosition: () => undefined } })
await flushPromises()
expect(play).not.toHaveBeenCalled()
await wrapper.get('[data-testid="audio-toggle"]').trigger('click')
await flushPromises()
expect(play).toHaveBeenCalledTimes(1)
expect(wrapper.get('[data-testid="audio-error"]').text()).toContain('无法播放')
})
})
describe('chapter illustration and player in the reader', () => {
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()); stubObjectUrls(); stubMedia(); useSessionStore().user = { ...user } })
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); putBackObjectUrls(); wrapper?.unmount() })
it('shows the chapter illustration above the text and plays only this chapter', async () => {
mockApi()
const { view } = await mountWith(ReaderView, '/chapters/3')
expect(view.find('.reader-text').exists()).toBe(true)
await vi.waitFor(() => expect(useLibraryStore().audioUrl).not.toBe(''))
await flushPromises()
expect(view.find('[data-testid="chapter-illustration"]').exists()).toBe(true)
expect(view.find('[data-testid="audio-player"]').exists()).toBe(true)
// The illustration sits between the heading and the text.
const html = view.html()
expect(html.indexOf('chapter-illustration')).toBeLessThan(html.indexOf('reader-workspace'))
expect(useLibraryStore().audioChapterId).toBe(3)
})
it('stays silent for a chapter without audio or illustration', async () => {
mockApi({ audio: false, illustration: false, playbackSeconds: 0 })
const { view } = await mountWith(ReaderView, '/chapters/3')
await flushPromises()
expect(view.find('[data-testid="audio-player"]').exists()).toBe(false)
expect(view.find('[data-testid="chapter-illustration"]').exists()).toBe(false)
})
it('sends the position to the chapter instead of only keeping it locally', async () => {
const { fetchMock } = mockApi()
const { view } = await mountWith(ReaderView, '/chapters/3')
const element = view.get('[data-testid="audio-element"]').element as HTMLAudioElement
element.currentTime = 77
await view.get('[data-testid="audio-element"]').trigger('pause')
await flushPromises()
const reported = fetchMock.mock.calls.find(([, init]) => String(init?.method) === 'PUT')
expect(String(reported?.[0])).toContain('/api/v1/chapters/3/playback')
expect(JSON.parse(String(reported?.[1]?.body))).toEqual({ positionSeconds: 77 })
})
})