- schema v11:lexgo_books 增加 author VARCHAR(120) NOT NULL DEFAULT '',复用 v10 的条件加列 助手(改名 addAuthorColumn,表名取自本文件常量),保持迁移可重放;新建库 v3 语句也带该列 - PATCH /api/v1/books/:id 接受可选 author(省略保留、空串清空、trim、≤120),BookSummary 与 BookRef 返回它;「编辑书名」改为「编辑书籍」,对话框同行编辑书名与作者 - 书籍页封面压成一行紧凑控件(预览+上传/替换/移除),去掉「封面」「书籍封面」规格与 「音频与插图按章节设置」等文字,把高度让给章节列表;书名右侧显示书级作者(未设置不显示) - 测试:Go 91 项(新增书级作者往返与 v10→v11 迁移用例)、学习端 157 单测与 26 项 E2E - 真实链路:封面行高 68px、删掉的四段文字不再出现、作者显示在书名同一行右侧且刷新后保留 - 文档:Architecture / Business-Rules / Local-Development / Requirements / Home 同步
308 lines
17 KiB
TypeScript
308 lines
17 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', author: '', language: 'en' }
|
||
const pngBytes = new Uint8Array([1, 2, 3, 4])
|
||
const baseChapter = {
|
||
id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', author: '', 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.endsWith('/chapters/3/audio') || url.endsWith('/chapters/3/illustration')
|
||
|| url.endsWith('/chapters/3/playback')) {
|
||
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.endsWith('/chapters/3/source')) {
|
||
return ok({ source: { id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', text: 'Curiosity opens the first door.' + String.fromCharCode(10), status: 'ready', contentSha256: 'sha', charCount: 31 } })
|
||
}
|
||
if (url.endsWith('/chapters/3') && method === 'PATCH') return ok({ chapter: baseChapter, job: null, versionChanged: false })
|
||
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 and the chapter audio state without any chapter image', async () => {
|
||
const { fetchMock } = mockApi()
|
||
const { view } = await mountWith(BookView, '/books/7')
|
||
// The cover block is one compact row now: preview and actions, no headings or hints.
|
||
await vi.waitFor(() => expect(useLibraryStore().coverUrls[7]).toBeTruthy())
|
||
await flushPromises()
|
||
expect(view.find('[data-testid="cover-preview"]').exists()).toBe(true)
|
||
expect(view.find('[data-testid="cover-input"]').exists()).toBe(true)
|
||
expect(view.text()).not.toContain('书籍封面')
|
||
expect(view.text()).not.toContain('像素不超过')
|
||
await flushPromises()
|
||
// The chapter list shows no illustration and does not fetch one.
|
||
expect(view.find('[data-testid="chapter-thumb-3"]').exists()).toBe(false)
|
||
expect(view.find('.chapter-thumb').exists()).toBe(false)
|
||
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/illustration'))).toBe(false)
|
||
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 editor now, and the row has
|
||
// exactly one entry point.
|
||
expect(view.text()).not.toContain('音频 · 3.4 MB')
|
||
expect(view.find('[data-testid="chapter-attachments-3"]').exists()).toBe(false)
|
||
expect(view.find('[data-testid="edit-chapter-3"]').exists()).toBe(true)
|
||
})
|
||
|
||
it('edits the title, the text and both files in one chapter editor', async () => {
|
||
const { fetchMock } = mockApi({ illustration: false, audio: false, playbackSeconds: 0 })
|
||
const { view } = await mountWith(BookView, '/books/7')
|
||
await view.get('[data-testid="edit-chapter-3"]').trigger('click')
|
||
await flushPromises()
|
||
// One dialog holds the title, the text and both files.
|
||
expect(view.find('[data-testid="chapter-dialog"]').exists()).toBe(true)
|
||
expect(view.find('#chapter-title').exists()).toBe(true)
|
||
// The author shares the label row layout and is optional.
|
||
expect(view.find('[data-testid="chapter-author"]').exists()).toBe(true)
|
||
expect(view.find('.field-row label[for="chapter-author"]').exists()).toBe(true)
|
||
expect(view.find('#chapter-text').exists()).toBe(true)
|
||
expect(view.get('[data-testid="attachment-timing"]').text()).toContain('立即上传')
|
||
// The file hints are one short line each, so the height goes to the text editor.
|
||
expect(view.get('.attachment-block').text()).toContain('≤2 MiB')
|
||
expect(view.text()).toContain('≤4096×4096')
|
||
expect(view.text()).not.toContain('在阅读页显示为缩略图')
|
||
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('音频已更新')
|
||
|
||
// The text is still saved by its own button, inside the same dialog.
|
||
await view.get('#chapter-title').setValue('Fictional chapter renamed')
|
||
await view.get('[data-testid="save-chapter"]').trigger('click')
|
||
await flushPromises()
|
||
expect(fetchMock.mock.calls.some(([url, init]) => String(url).includes('/chapters/3') && String(init?.method) === 'PATCH')).toBe(true)
|
||
})
|
||
|
||
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="edit-chapter-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 a chapter thumbnail above the text and opens the original in a dialog', 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()
|
||
|
||
const thumb = view.get('[data-testid="chapter-illustration"]')
|
||
expect(thumb.element.tagName).toBe('BUTTON')
|
||
expect(thumb.attributes('aria-label')).toContain('插图大图')
|
||
expect(thumb.find('img').exists()).toBe(true)
|
||
// The thumbnail sits between the heading and the text, and the dialog starts closed.
|
||
const html = view.html()
|
||
expect(html.indexOf('chapter-illustration')).toBeLessThan(html.indexOf('reader-workspace'))
|
||
expect(document.querySelector('[data-testid="illustration-dialog"]')).toBeNull()
|
||
|
||
await thumb.trigger('click')
|
||
await flushPromises()
|
||
const dialog = document.querySelector('[data-testid="illustration-dialog"]')
|
||
expect(dialog).not.toBeNull()
|
||
expect(dialog?.querySelector('img')?.getAttribute('src')).toMatch(/^blob:fictional-/)
|
||
expect(view.find('[data-testid="audio-player"]').exists()).toBe(true)
|
||
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 })
|
||
})
|
||
})
|