Files
lexgo/learner/e2e/attachments.spec.ts
T
ila ab23ff60e5 feat: 书籍封面与音频附件、播放器与播放位置 (#21)
- 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(备份范围含附件与体积提示)
2026-09-15 18:39:56 +08:00

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)
})