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 (body.author !== undefined) first.author = body.author 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.getByRole('dialog', { name: '编辑书籍' })).toBeVisible() 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. // The chapter editor is the main writing surface, so on a wide window it must use that width and // a tall body instead of a fixed 620px box. await page.setViewportSize({ width: 1280, height: 900 }) await page.getByTestId('edit-chapter-9').click() await expectModalOverlay(page, page.getByTestId('chapter-dialog')) await expect(page.getByLabel('章节标题', { exact: true })).toHaveValue('First chapter') await expect(page.getByLabel('正文', { exact: true })).toHaveValue(firstText) const dialog = await page.getByTestId('chapter-dialog').boundingBox() expect(dialog!.width, 'the chapter editor follows the window').toBeGreaterThan(900) const body = await page.getByLabel('正文', { exact: true }).boundingBox() expect(body!.height, 'the text area is tall enough to write in').toBeGreaterThan(400) const noOverflow = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, inner: window.innerWidth })) expect(noOverflow.scroll, 'the wide editor must not overflow').toBeLessThanOrEqual(noOverflow.inner) await page.setViewportSize({ width: 1280, height: 900 }) 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) })