- main.ts 补上 dialog 与 message-box 的样式导入:自 #10 起 ElDialog/ElMessageBox 一直 缺少样式,退化成列表后面的普通块(用户报告「编辑区显示在章节列表下面」) - 新增 e2e/overlay.ts:expectModalOverlay / expectMessageBoxOverlay 断言弹窗位于 .el-overlay(position: fixed)、在视口内且横向不溢出 - attachments.spec.ts(章节编辑对话框、插图弹窗)与 edit.spec.ts(书名对话框、删除确认) 都改用该断言,堵住「只看可见性、不看是否浮层」的验证盲区 - 真实链路确认:对话框遮罩为 rgba(0,0,0,0.5)、居中于视口;确认框同样为固定浮层 - 文档:Local-Development 记录缺陷、根因、修复与新增回归断言
235 lines
13 KiB
TypeScript
235 lines
13 KiB
TypeScript
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<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 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()
|
|
await expect(page.getByTestId('cover-state')).toContainText('PNG')
|
|
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('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<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()
|
|
// 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-state')).toContainText('未设置')
|
|
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)
|
|
})
|