- 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 改为章级口径
264 lines
14 KiB
TypeScript
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 })
|
|
})
|
|
})
|