Files
lexgo/learner/e2e/attachments.spec.ts
T
ila c22f9c3f06 fix: 导入 Element Plus 对话框与确认框样式,弹窗不再是文档流块 (#37)
- 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 记录缺陷、根因、修复与新增回归断言
2026-09-15 22:22:50 +08:00

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