Files
lexgo/learner/e2e/edit.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

109 lines
6.8 KiB
TypeScript

import { expect, test, type Page } from '@playwright/test'
// Renaming, editing and deleting through the real dialogs of the accepted prototype, against
// a mocked API.
test('rename the book, edit a chapter into a new version and delete both', async ({ page }) => {
const user = { id: 42, username: 'fictional-editor', role: 'learner' }
const timestamps = { createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }
const first = { id: 9, bookId: 1, ordinal: 1, title: 'First chapter', status: 'ready', charCount: 24, errorReason: '', errorMessage: '', jobId: 5, ...timestamps }
const second = { id: 10, bookId: 1, ordinal: 2, title: 'Second chapter', status: 'ready', charCount: 12, errorReason: '', errorMessage: '', jobId: 6, ...timestamps }
let bookTitle = 'A small step'
let chapters = [first, second]
let firstText = 'Mira opened the workshop.\n'
let processing = false
const requests: string[] = []
const listBook = () => ({
book: { id: 1, title: bookTitle, language: 'en' },
chapters: chapters.map(item => (item.id === 9 && processing ? { ...item, status: 'processing' } : item)),
})
await page.route('**/api/v1/**', async route => {
const path = new URL(route.request().url()).pathname
const method = route.request().method()
requests.push(`${method} ${path}`)
let data: unknown = null
let status = 200
if (path === '/api/v1/login') data = { token: 'fictional-session', user }
else if (path === '/api/v1/me') data = user
else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' }
else if (path === '/api/v1/books' && method === 'GET') {
data = { items: bookTitle === '' ? [] : [{ id: 1, title: bookTitle, language: 'en', chapterCount: chapters.length, pendingCount: 0, processingCount: 0, readyCount: chapters.length, failedCount: 0, ...timestamps }] }
} else if (path === '/api/v1/books/1' && method === 'PATCH') {
bookTitle = (route.request().postDataJSON() as { title: string }).title
data = { book: { id: 1, title: bookTitle, language: 'en' } }
} else if (path === '/api/v1/books/1' && method === 'DELETE') {
chapters = []
bookTitle = ''
data = { deleted: { bookId: 1, chapters: 2, remaining: 0 } }
} else if (path === '/api/v1/books/1') data = listBook()
else if (path === '/api/v1/chapters/9/source') data = { source: { id: 9, bookId: 1, ordinal: 1, title: first.title, text: firstText, status: 'ready', contentSha256: 'sha-a', charCount: [...firstText].length } }
else if (path === '/api/v1/chapters/9' && method === 'PATCH') {
const body = route.request().postDataJSON() as { title?: string; text?: string }
const changed = body.text !== undefined && body.text !== firstText
if (body.title !== undefined) first.title = body.title
if (changed) {
firstText = body.text as string
processing = true
first.status = 'pending'
setTimeout(() => { processing = false; first.status = 'ready' }, 400)
}
status = 200
data = { chapter: first, job: changed ? { id: 7, bookId: 1, chapterId: 9, status: 'pending', attempts: 0, errorReason: '', errorMessage: '', ...timestamps } : null, versionChanged: changed }
} else if (path === '/api/v1/chapters/9' && method === 'DELETE') {
chapters = chapters.filter(item => item.id !== 9).map((item, index) => ({ ...item, ordinal: index + 1 }))
data = { deleted: { chapterId: 9, bookId: 1, remaining: chapters.length } }
}
await route.fulfill({ status, json: { code: 200, data } })
})
await page.goto('/')
await page.getByLabel('账号').fill(user.username)
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
await page.getByRole('button', { name: '登录', exact: true }).click()
await page.getByRole('link', { name: 'A small step' }).click()
await expect(page.getByRole('heading', { name: 'A small step' })).toBeVisible()
// The subtitle no longer claims a system cover: covers are attachments now (#21).
await expect(page.getByText('语言 英语')).toBeVisible()
await expect(page.getByTestId('attachments')).toBeVisible()
// Rename the book through the dialog.
await page.getByTestId('edit-book').click()
await expect(page.getByLabel('书名', { exact: true })).toHaveValue('A small step')
await page.getByLabel('书名', { exact: true }).fill('A long step')
await page.getByTestId('save-book').click()
await expect(page.getByTestId('book-notice')).toContainText('书名已更新')
await expect(page.getByRole('heading', { name: 'A long step' })).toBeVisible()
// Edit the chapter text: the new version re-processes and later becomes ready again.
await page.getByTestId('edit-chapter-9').click()
await expect(page.getByLabel('章节标题', { exact: true })).toHaveValue('First chapter')
await expect(page.getByLabel('正文', { exact: true })).toHaveValue(firstText)
await page.getByLabel('正文', { exact: true }).fill('A replacement body.\n')
await page.getByTestId('save-chapter').click()
await expect(page.getByTestId('book-notice')).toContainText('已保存为新版本,正在重新处理')
// The saved version is queued first and becomes readable again when the worker finishes.
await expect(page.locator('.chapter-row').first()).toContainText('待处理')
await expect(page.locator('.chapter-row').first()).toContainText('已就绪', { timeout: 10000 })
// Deleting a chapter asks first, then reports the remaining count.
await page.getByTestId('edit-chapter-9').click()
await page.getByTestId('delete-chapter').click()
await expect(page.locator('.el-message-box__message').last()).toContainText('本章正文将被删除,已保存词条保留')
await page.locator('.el-message-box').last().getByRole('button', { name: '取消' }).click()
expect(requests.filter(entry => entry === 'DELETE /api/v1/chapters/9')).toHaveLength(0)
await page.getByTestId('delete-chapter').click()
await page.locator('.el-message-box').last().getByRole('button', { name: '确认删除章节' }).click()
await expect(page.getByTestId('book-notice')).toContainText('章节已删除 · 剩余 1 章')
await expect(page.locator('.chapter-row')).toHaveCount(1)
// Deleting the book asks first and returns to the library without it.
await page.getByTestId('delete-book').click()
await expect(page.locator('.el-message-box__message').last()).toContainText('已保存的生词和短语将保留')
await page.locator('.el-message-box').last().getByRole('button', { name: '确认删除' }).click()
await expect(page).toHaveURL(/\?deleted=\d+$/)
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
await expect(page.getByTestId('library-notice')).toContainText('书籍已删除 · 已保存的生词和短语仍保留在生词本')
await expect(page.getByRole('link', { name: 'A long step' })).toHaveCount(0)
})