- schema v8:lexgo_book_attachments(按 book_id+kind 唯一,bytes MEDIUMBLOB)与 lexgo_playback_positions(owner_id+book_id 主键),只用可重放 DDL,外键级联删书 - 后端:按文件头 magic bytes 判定类型(MP3 / JPG / PNG / WebP,含 WebP 维度解析), 音频 ≤20 MiB、封面 ≤2 MiB 且 ≤4096²;上传/替换/移除;binaryResponse 交给 http.ServeContent 提供 Range 206、416、ETag 与 304;播放位置 upsert,替换音频时重置 - 学习端:带凭据的 fetch + 对象 URL(令牌不进 URL),书库封面、书籍页「音频与封面」 区块、阅读页常驻播放器(播放/暂停、进度、0.75–1.5 倍速、错误重试、位置上报) - 测试:Go 86 项(新增附件单测/集成与 v7→v8 迁移)、学习端 157 单测与 26 项 E2E - 顺带修掉 e2e/phrase.spec.ts 的偶发失败:重试条件改为断言期望词数、重读坐标、关面板后重试 - 文档:Architecture / Business-Rules / Local-Development / Requirements / Home / Deployment-and-Operations(备份范围含附件与体积提示)
195 lines
10 KiB
TypeScript
195 lines
10 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test'
|
|
|
|
// Book audio and covers against a mocked API: upload, replace, remove, and the reader's player.
|
|
const user = { id: 42, username: 'fictional-attachment', role: 'learner' }
|
|
const book = { id: 7, title: 'Fictional reader', language: 'en' }
|
|
const chapterText = 'Curiosity opens the first door.\nThe second door stays closed.\n'
|
|
const chapter = {
|
|
id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', status: 'ready', charCount: chapterText.length,
|
|
errorReason: '', errorMessage: '', jobId: 9, readAt: null, createdAt: '', updatedAt: '',
|
|
}
|
|
const pngBytes = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex')
|
|
|
|
interface Attachment { kind: 'audio' | 'cover'; mime: string; byteSize: number; version: string; updatedAt: string }
|
|
|
|
async function mockApi(page: Page, options: { audio?: boolean; cover?: boolean } = {}): Promise<{ posted: string[]; deleted: string[]; positions: number[] }> {
|
|
let audio: Attachment | null = options.audio === false ? null : { kind: 'audio', mime: 'audio/mpeg', byteSize: 4096, version: 'audio-sha', updatedAt: '2026-09-15T00:00:00Z' }
|
|
let cover: Attachment | null = options.cover === false ? null : { kind: 'cover', mime: 'image/png', byteSize: pngBytes.length, version: 'cover-sha', updatedAt: '2026-09-15T00:00:00Z' }
|
|
let playbackSeconds = 0
|
|
const posted: string[] = []
|
|
const deleted: string[] = []
|
|
const positions: number[] = []
|
|
|
|
await page.route('**/api/v1/**', async route => {
|
|
const url = new URL(route.request().url())
|
|
const path = url.pathname
|
|
const method = route.request().method()
|
|
const json = (data: unknown, status = 200) => route.fulfill({ status, json: { code: 200, data } })
|
|
const attachments = () => ({ cover, audio, playbackSeconds })
|
|
|
|
if (path === '/api/v1/login') return json({ token: 'fictional-session', user })
|
|
if (path === '/api/v1/me') return json(user)
|
|
if (path === '/api/v1/space') return json({ ownerId: user.id, language: 'en' })
|
|
if (path === '/api/v1/books' && method === 'GET') {
|
|
return json({ items: [{ ...book, chapterCount: 1, pendingCount: 0, processingCount: 0, readyCount: 1, failedCount: 0, coverVersion: cover?.version ?? '', hasAudio: audio !== null, createdAt: '', updatedAt: '' }] })
|
|
}
|
|
if (path === '/api/v1/books/7' && method === 'GET') return json({ book: { ...book, attachments: attachments() }, chapters: [chapter] })
|
|
if (path === '/api/v1/books/7/cover' || path === '/api/v1/books/7/audio') {
|
|
const kind = path.endsWith('cover') ? 'cover' : 'audio'
|
|
if (method === 'GET') {
|
|
const body = kind === 'cover' ? pngBytes : Buffer.concat([Buffer.from('ID3'), Buffer.alloc(4096)])
|
|
return route.fulfill({ status: 200, headers: { 'Content-Type': kind === 'cover' ? 'image/png' : 'audio/mpeg' }, body })
|
|
}
|
|
if (method === 'POST') {
|
|
posted.push(kind)
|
|
// The mock answers exactly like the backend: the new version replaces the old one.
|
|
const next: Attachment = { kind: kind as 'cover' | 'audio', mime: kind === 'cover' ? 'image/png' : 'audio/mpeg', byteSize: 2048, version: `${kind}-new`, updatedAt: '2026-09-15T01:00:00Z' }
|
|
if (kind === 'cover') cover = next
|
|
else { audio = next; playbackSeconds = 0 }
|
|
return json({ attachment: next })
|
|
}
|
|
if (method === 'DELETE') {
|
|
deleted.push(kind)
|
|
if (kind === 'cover') cover = null
|
|
else audio = null
|
|
return json({ deleted: true })
|
|
}
|
|
}
|
|
if (path === '/api/v1/books/7/playback' && method === 'PUT') {
|
|
const body = route.request().postDataJSON() as { positionSeconds: number }
|
|
positions.push(body.positionSeconds)
|
|
playbackSeconds = body.positionSeconds
|
|
return json({ playbackSeconds })
|
|
}
|
|
if (path === '/api/v1/chapters/3/tokens') {
|
|
let offset = 0
|
|
const tokens = (chapterText.match(/[A-Za-z]+|\s+|[^A-Za-z\s]+/g) ?? []).map(piece => {
|
|
const start = offset
|
|
offset += piece.length
|
|
return { text: piece, start, end: offset, startUtf16: start, endUtf16: offset, kind: /^[A-Za-z]+$/.test(piece) ? 'word' : 'space' }
|
|
})
|
|
return json({ textSha256: 'sha', tokens })
|
|
}
|
|
if (path === '/api/v1/chapters/3') return json({ book: { ...book, attachments: attachments() }, chapter: { ...chapter, contentSha256: 'sha', originalText: chapterText }, navigation: { previousChapterId: null, nextChapterId: null } })
|
|
return json({})
|
|
})
|
|
return { posted, deleted, positions }
|
|
}
|
|
|
|
async function login(page: Page): Promise<void> {
|
|
await page.addInitScript(() => sessionStorage.setItem('lexgo-learner-token', 'fictional-session'))
|
|
await page.goto('/')
|
|
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
|
|
}
|
|
|
|
test('the library shows the stored cover and the book page manages attachments', async ({ page }) => {
|
|
const calls = await mockApi(page)
|
|
await login(page)
|
|
await expect(page.getByTestId('book-cover')).toBeVisible()
|
|
await page.screenshot({ path: '../.local/evidence/issue21-library-cover.png' })
|
|
|
|
await page.getByRole('link', { name: 'Fictional reader', exact: true }).click()
|
|
await expect(page.getByTestId('attachments')).toBeVisible()
|
|
await expect(page.getByTestId('cover-state')).toContainText('PNG')
|
|
await expect(page.getByTestId('audio-state')).toContainText('MP3')
|
|
await page.screenshot({ path: '../.local/evidence/issue21-attachments.png' })
|
|
|
|
// Replacing the cover uploads the picked file and reports the new state.
|
|
await page.setInputFiles('[data-testid="cover-input"]', { name: 'cover.png', mimeType: 'image/png', buffer: pngBytes })
|
|
await expect(page.getByTestId('book-notice')).toContainText('封面已更新')
|
|
expect(calls.posted).toEqual(['cover'])
|
|
|
|
// A file of the wrong type is refused in the page, before any request.
|
|
await page.setInputFiles('[data-testid="audio-input"]', { name: 'song.wav', mimeType: 'audio/wav', buffer: Buffer.from('RIFF....') })
|
|
await expect(page.locator('.field-error')).toContainText('音频只支持 MP3')
|
|
expect(calls.posted).toEqual(['cover'])
|
|
|
|
// Uploading audio clears the stored position, which the state line shows.
|
|
await page.setInputFiles('[data-testid="audio-input"]', { name: 'track.mp3', mimeType: 'audio/mpeg', buffer: Buffer.concat([Buffer.from('ID3'), Buffer.alloc(2048)]) })
|
|
await expect(page.getByTestId('audio-state')).toContainText('MP3')
|
|
expect(calls.posted).toEqual(['cover', 'audio'])
|
|
|
|
// Removing asks first and then reports the empty state.
|
|
await page.getByTestId('audio-remove').click()
|
|
await page.getByRole('button', { name: '确认移除' }).click()
|
|
await expect(page.getByTestId('audio-state')).toContainText('未设置')
|
|
expect(calls.deleted).toEqual(['audio'])
|
|
})
|
|
|
|
test('the reader plays the audio without starting it and remembers the position', async ({ page }) => {
|
|
// The fixture bytes are not a decodable MP3, so the media element itself is stubbed: the test is
|
|
// about the player's behaviour, not about Chromium decoding a synthetic file.
|
|
await page.addInitScript(() => {
|
|
const media = HTMLMediaElement.prototype as unknown as Record<string, unknown>
|
|
Object.defineProperty(media, 'duration', { configurable: true, get: () => 180 })
|
|
// paused has to follow the stub, otherwise the player would try to play again instead of pausing.
|
|
Object.defineProperty(media, 'paused', {
|
|
configurable: true,
|
|
get(this: HTMLMediaElement & { __playing?: boolean }) { return !this.__playing },
|
|
})
|
|
Object.defineProperty(media, 'play', {
|
|
configurable: true,
|
|
value(this: HTMLMediaElement & { __playing?: boolean }) {
|
|
this.__playing = true
|
|
this.dispatchEvent(new Event('play'))
|
|
return Promise.resolve()
|
|
},
|
|
})
|
|
Object.defineProperty(media, 'pause', {
|
|
configurable: true,
|
|
value(this: HTMLMediaElement & { __playing?: boolean }) {
|
|
this.__playing = false
|
|
this.dispatchEvent(new Event('pause'))
|
|
},
|
|
})
|
|
Object.defineProperty(media, 'load', {
|
|
configurable: true,
|
|
value(this: HTMLMediaElement) { this.dispatchEvent(new Event('loadedmetadata')) },
|
|
})
|
|
})
|
|
const calls = await mockApi(page)
|
|
await login(page)
|
|
await page.goto('/chapters/3')
|
|
await expect(page.locator('.reader-text')).toBeVisible()
|
|
const player = page.getByTestId('audio-player')
|
|
await expect(player).toBeVisible()
|
|
// The player is sticky above the text so the controls stay reachable while reading.
|
|
await expect(player).toHaveCSS('position', 'sticky')
|
|
await expect(player).toContainText('0:00')
|
|
await page.screenshot({ path: '../.local/evidence/issue21-reader-player.png' })
|
|
|
|
// Nothing plays until the button is pressed.
|
|
await expect(page.getByTestId('audio-toggle')).toHaveAttribute('aria-label', '播放音频')
|
|
await page.getByTestId('audio-toggle').click()
|
|
await expect(page.getByTestId('audio-toggle')).toHaveAttribute('aria-label', '暂停音频')
|
|
await page.screenshot({ path: '../.local/evidence/issue21-reader-playing.png' })
|
|
|
|
// Seeking moves the position and pausing reports it to the book.
|
|
// A range input cannot be filled, so the value is set the way dragging the handle would.
|
|
await page.getByTestId('audio-seek').evaluate((element, value) => {
|
|
const input = element as HTMLInputElement
|
|
input.value = String(value)
|
|
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
}, 12)
|
|
await page.getByTestId('audio-toggle').click()
|
|
await expect.poll(() => calls.positions.length).toBeGreaterThan(0)
|
|
|
|
// The speed control states which rate is active.
|
|
await page.getByTestId('audio-rate-1.5').click()
|
|
await expect(page.getByTestId('audio-rate-1.5')).toHaveAttribute('aria-pressed', 'true')
|
|
})
|
|
|
|
test('a book without audio shows no player at all', async ({ page }) => {
|
|
await mockApi(page, { audio: false, cover: false })
|
|
await login(page)
|
|
await expect(page.getByTestId('book-cover')).toHaveCount(0)
|
|
// The management section stays: adding an attachment must be possible before one exists.
|
|
await page.getByRole('link', { name: 'Fictional reader', exact: true }).click()
|
|
await expect(page.getByTestId('attachments')).toBeVisible()
|
|
await expect(page.getByTestId('cover-state')).toContainText('未设置')
|
|
await expect(page.getByTestId('audio-state')).toContainText('未设置')
|
|
await page.goto('/chapters/3')
|
|
await expect(page.locator('.reader-text')).toBeVisible()
|
|
await expect(page.getByTestId('audio-player')).toHaveCount(0)
|
|
})
|