import { expect, test, type Page } from '@playwright/test' import { expectModalOverlay } from './overlay' // 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( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==", "base64", ) interface Attachment { kind: 'audio' | 'cover' | 'illustration'; 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 illustration: Attachment | null = options.cover === false ? null : { kind: 'illustration', mime: 'image/png', byteSize: pngBytes.length, version: 'illustration-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 }) const chapterFiles = () => ({ ...chapter, illustrationVersion: illustration?.version ?? '', audioVersion: audio?.version ?? '', 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: [chapterFiles()] }) const attachmentPath = path === '/api/v1/books/7/cover' ? 'cover' : path === '/api/v1/chapters/3/illustration' ? 'illustration' : path === '/api/v1/chapters/3/audio' ? 'audio' : null if (attachmentPath !== null) { const kind = attachmentPath as 'cover' | 'illustration' | 'audio' if (method === 'GET') { const body = kind === 'audio' ? Buffer.concat([Buffer.from('ID3'), Buffer.alloc(4096)]) : pngBytes return route.fulfill({ status: 200, headers: { 'Content-Type': kind === 'audio' ? 'audio/mpeg' : 'image/png' }, 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, mime: kind === 'audio' ? 'audio/mpeg' : 'image/png', byteSize: 2048, version: `${kind}-new`, updatedAt: '2026-09-15T01:00:00Z' } if (kind === 'cover') cover = next else if (kind === 'illustration') illustration = next else { audio = next; playbackSeconds = 0 } return json({ attachment: next }) } if (method === 'DELETE') { deleted.push(kind) if (kind === 'cover') cover = null else if (kind === 'illustration') illustration = null else audio = null return json({ deleted: true }) } } if (path === '/api/v1/chapters/3/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/source') { return json({ source: { id: 3, bookId: 7, ordinal: 1, title: 'Fictional chapter', text: chapterText, status: 'ready', contentSha256: 'sha', charCount: chapterText.length } }) } if (path === '/api/v1/chapters/3' && method === 'PATCH') return json({ chapter: chapterFiles(), job: null, versionChanged: false }) if (path === '/api/v1/chapters/3') return json({ book: { ...book, attachments: attachments() }, chapter: { ...chapterFiles(), contentSha256: 'sha', originalText: chapterText }, navigation: { previousChapterId: null, nextChapterId: null } }) return json({}) }) return { posted, deleted, positions } } async function login(page: Page): Promise { 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 cover and the chapter dialog manages its files', 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() // The cover block is one compact row: preview plus actions, no headings or size hints. await expect(page.getByTestId('cover-preview')).toBeVisible() await expect(page.locator('.cover-row')).not.toContainText('像素不超过') await expect(page.locator('.chapter-meta')).toContainText('有音频') // The chapter row carries no image: it only has the audio meta and the attachment entry point. await expect(page.getByTestId('chapter-thumb-3')).toHaveCount(0) await expect(page.locator('.chapter-thumb')).toHaveCount(0) // One entry point per row: the editor holds the title, the text and both files. await expect(page.getByTestId('chapter-attachments-3')).toHaveCount(0) await page.screenshot({ path: '../.local/evidence/issue37-chapter-row.png' }) await page.getByTestId('edit-chapter-3').click() // A real modal, not content appended below the chapter list. await expectModalOverlay(page, page.getByTestId('chapter-dialog')) await expect(page.locator('#chapter-title')).toBeVisible() await expect(page.getByTestId('chapter-author')).toBeVisible() await expect(page.locator('.field-row label[for="chapter-author"]')).toBeVisible() await expect(page.getByTestId('attachment-timing')).toContainText('立即上传') await expect(page.getByTestId('illustration-state')).toContainText('已设置') await expect(page.getByTestId('chapter-audio-state')).toContainText('已设置') await page.screenshot({ path: '../.local/evidence/issue37-attachment-dialog.png' }) // Replacing the illustration and the audio goes to the chapter endpoints. await page.setInputFiles('[data-testid="illustration-input"]', { name: 'chapter.png', mimeType: 'image/png', buffer: pngBytes }) await expect(page.getByTestId('book-notice')).toContainText('插图已更新') await page.setInputFiles('[data-testid="chapter-audio-input"]', { name: 'track.mp3', mimeType: 'audio/mpeg', buffer: Buffer.concat([Buffer.from('ID3'), Buffer.alloc(2048)]) }) await expect(page.getByTestId('book-notice')).toContainText('音频已更新') expect(calls.posted).toEqual(['illustration', 'audio']) // A file of the wrong type is refused in the page, before any request. await page.setInputFiles('[data-testid="chapter-audio-input"]', { name: 'song.wav', mimeType: 'audio/wav', buffer: Buffer.from('RIFF....') }) await expect(page.locator('[data-testid="chapter-dialog"] .field-error')).toContainText('音频只支持 MP3') expect(calls.posted).toEqual(['illustration', 'audio']) // Removing the audio asks first and then reports the empty state. await page.getByTestId('chapter-audio-remove').click() await page.getByRole('button', { name: '确认移除' }).click() await expect(page.getByTestId('chapter-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 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() // The chapter's illustration is a thumbnail above the text; clicking it shows the original. const thumbnail = page.getByTestId('chapter-illustration') await expect(thumbnail).toBeVisible() await expect(thumbnail).toContainText('点击查看大图') await thumbnail.click() await expectModalOverlay(page, page.getByTestId('illustration-dialog')) await expect(page.getByTestId('illustration-dialog').locator('img')).toHaveAttribute('src', /^blob:/) await page.screenshot({ path: '../.local/evidence/issue37-illustration-dialog.png' }) await page.keyboard.press('Escape') await expect(page.getByTestId('illustration-dialog')).toBeHidden() 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 attachments shows placeholders and no player', async ({ page }) => { await mockApi(page, { audio: false, cover: false }) await login(page) await expect(page.getByTestId('book-cover')).toHaveCount(0) // The management sections stay: adding a file 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-preview')).toHaveCount(0) await expect(page.getByTestId('cover-input')).toBeAttached() await expect(page.getByTestId('chapter-thumb-3')).toHaveCount(0) await page.getByTestId('edit-chapter-3').click() await expect(page.getByTestId('illustration-state')).toContainText('未设置') await expect(page.getByTestId('chapter-audio-state')).toContainText('未设置') await page.goto('/chapters/3') await expect(page.locator('.reader-text')).toBeVisible() await expect(page.getByTestId('audio-player')).toHaveCount(0) await expect(page.getByTestId('chapter-illustration')).toHaveCount(0) })