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

113 lines
7.0 KiB
TypeScript

import { expect, test, type Page } from '@playwright/test'
import { expectMessageBoxOverlay, expectModalOverlay } from './overlay'
// 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 expectModalOverlay(page, page.getByTestId('book-dialog'))
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()
// A confirmation is a modal overlay too, not text appended to the page.
await expectMessageBoxOverlay(page)
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)
})