Files
lexgo/learner/src/__tests__/attachments.spec.ts
T
ila 728f7d6c1e feat: 书籍页精简封面、可编辑书名与作者、标题旁显示作者 (#37)
- 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 同步
2026-09-15 23:10:36 +08:00

308 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 })
})
})