import { expect, test } from '@playwright/test' import { addTask, dragCardToColumn } from './helpers' test.beforeEach(async ({ page }) => { await page.goto('/') }) test('每个用例使用独立上下文,初始看板为空', async ({ page }) => { await expect(page.getByText('共 0 个任务')).toBeVisible() await expect(page.locator('article')).toHaveCount(0) await expect(page.getByText('暂无任务')).toHaveCount(3) }) test('新增 → 拖到进行中 → 标记完成 → 刷新后状态仍正确', async ({ page }) => { await addTask(page, 'E2E 主链路任务') await expect(page.locator('[data-column-id="todo"] article')).toHaveCount(1) await dragCardToColumn(page, 'E2E 主链路任务', 'doing') await expect(page.locator('[data-column-id="doing"] article')).toHaveCount(1) await expect(page.locator('[data-column-id="todo"] article')).toHaveCount(0) // 刷新后位置保持 —— 这一条是「拖拽结果真的落盘了」的关键证据 await page.reload() await expect( page.locator('[data-column-id="doing"] article').filter({ hasText: 'E2E 主链路任务' }), ).toBeVisible() // 标记完成 await page .locator('[data-column-id="doing"] article') .filter({ hasText: 'E2E 主链路任务' }) .locator('button') .first() .click() await expect(page.locator('[data-column-id="done"] article')).toHaveCount(1) await page.reload() await expect( page.locator('[data-column-id="done"] article').filter({ hasText: 'E2E 主链路任务' }), ).toBeVisible() }) test('详情抽屉可编辑并持久化', async ({ page }) => { await addTask(page, '待编辑任务') await page.locator('article').filter({ hasText: '待编辑任务' }).click() const titleInput = page.getByLabel('标题') await expect(titleInput).toHaveValue('待编辑任务') await titleInput.fill('已编辑任务') await page.getByLabel('描述').fill('这是描述') // 工具栏也有「高」按钮,必须限定在详情抽屉内 await page.getByRole('dialog').getByRole('button', { name: '高' }).click() await page.getByRole('button', { name: '保存' }).click() await expect(page.locator('article').filter({ hasText: '已编辑任务' })).toBeVisible() await page.reload() const card = page.locator('article').filter({ hasText: '已编辑任务' }) await expect(card).toBeVisible() await expect(card).toContainText('高') }) /** * 标签候选要打通 store → 抽屉 → TagInput 三层,单测覆盖不到接线, * 所以这里从「另一个任务上已有的标签」出发验证整条链路。 */ test('编辑任务时可以直接选已有标签', async ({ page }) => { // 先在一个任务上建出标签 await addTask(page, '任务甲') await page.locator('article').filter({ hasText: '任务甲' }).click() const firstInput = page.getByLabel('添加标签') await firstInput.fill('设计') await firstInput.press('Enter') await page.getByRole('button', { name: '保存' }).click() // 另一个任务:聚焦标签输入框应列出「设计」 await addTask(page, '任务乙') await page.locator('article').filter({ hasText: '任务乙' }).click() const tagInput = page.getByLabel('添加标签') await tagInput.click() const option = page.locator('[role="option"]').filter({ hasText: '#设计' }) await expect(option).toBeVisible() await option.click() await page.getByRole('button', { name: '保存' }).click() await page.reload() await expect(page.locator('article').filter({ hasText: '任务乙' })).toContainText('#设计') }) /** * 批量添加要打通「工具栏 → 抽屉 → store → 看板」四层,单测只到组件边界, * 所以这里走一遍真实点击:填清单、改目标列、提交、刷新后仍存在。 */ test('批量添加:一次录入多行并出现在看板上', async ({ page }) => { await page.getByRole('button', { name: '批量添加' }).click() const list = page.getByLabel('批量任务清单') // 打开面板就该能直接粘贴,焦点应落在清单上 await expect(list).toBeFocused() await list.fill('- 写周报\n- 评审 PR\n- 回归测试') await page.getByRole('dialog').getByRole('button', { name: '进行中' }).click() await page.getByRole('dialog').getByRole('button', { name: '高' }).click() await page.getByRole('button', { name: '添加 3 个任务' }).click() await expect(page.getByText('已添加 3 个任务')).toBeVisible() const doing = page.locator('[data-column-id="doing"] article') await expect(doing).toHaveCount(3) // 卡片顺序与清单顺序一致(第 1 行在最上面) expect(await doing.locator('h3').allInnerTexts()).toEqual(['写周报', '评审 PR', '回归测试']) await page.reload() await expect(doing).toHaveCount(3) }) /** * 批量删除同样跨了「工具栏 → 卡片多选 → 操作条 → store → Toast」, * 关键要验证三件事:勾选能选/能取消、删除后整批消失、撤销能还原。 */ test('批量删除:多选后整批删除,撤销可还原', async ({ page }) => { await addTask(page, '任务一') await addTask(page, '任务二') await addTask(page, '任务三') await page.getByRole('button', { name: '多选', exact: true }).click() await expect(page.getByText('已选 0 个任务')).toBeVisible() await page.getByRole('button', { name: '全选 3 个' }).click() await expect(page.getByText('已选 3 个任务')).toBeVisible() // 再点一张取消勾选,验证「点选 / 取消」这一对行为 await page.locator('[data-column-id="todo"] article').filter({ hasText: '任务二' }).click() await expect(page.getByText('已选 2 个任务')).toBeVisible() await page.getByRole('button', { name: '删除 2 个' }).click() await expect(page.getByText('已删除 2 个任务')).toBeVisible() const cards = page.locator('[data-column-id="todo"] article') await expect(cards).toHaveCount(1) await expect(cards.first()).toContainText('任务二') await page.getByRole('button', { name: '撤销' }).click() await expect(cards).toHaveCount(3) await page.reload() await expect(cards).toHaveCount(3) }) test('删除后可撤销', async ({ page }) => { await addTask(page, '待删除任务') await page.locator('article').filter({ hasText: '待删除任务' }).click() await page.getByRole('button', { name: '删除' }).click() await expect(page.locator('article')).toHaveCount(0) await page.getByRole('button', { name: '撤销' }).click() await expect(page.locator('article').filter({ hasText: '待删除任务' })).toBeVisible() await page.reload() await expect(page.locator('article').filter({ hasText: '待删除任务' })).toBeVisible() }) test('搜索筛选后列计数同步变化', async ({ page }) => { await addTask(page, '写方案') await addTask(page, '买咖啡') await expect(page.locator('article')).toHaveCount(2) await page.getByLabel('搜索任务').fill('方案') // 关键词有 200ms 防抖 await expect(page.locator('article')).toHaveCount(1) await expect(page.locator('article').filter({ hasText: '写方案' })).toBeVisible() await expect(page.locator('section').first().locator('[data-role="column-count"]')).toHaveText('1') }) test('键盘快捷键:/ 聚焦搜索,N 聚焦新增,? 打开帮助', async ({ page }) => { await page.keyboard.press('/') await expect(page.getByLabel('搜索任务')).toBeFocused() await page.keyboard.press('Escape') await page.locator('body').click({ position: { x: 5, y: 5 } }) await page.keyboard.press('n') await expect(page.getByLabel('添加任务').first()).toBeFocused() await page.locator('body').click({ position: { x: 5, y: 5 } }) await page.keyboard.press('?') await expect(page.getByText('聚焦「待办」列的快速新增输入框')).toBeVisible() }) test('输入框内输入不会触发快捷键', async ({ page }) => { const search = page.getByLabel('搜索任务') await search.click() await search.type('n') // 焦点应仍在搜索框,而不是跳到新增输入框 await expect(search).toBeFocused() await expect(search).toHaveValue('n') }) test('深色模式可切换并在刷新后保持', async ({ page }) => { await page.goto('/') const html = page.locator('html') const themeButton = page.getByRole('button', { name: /主题/ }) await expect(html).not.toHaveClass(/dark/) // 系统 → 浅色 → 深色 await themeButton.click() await expect(html).not.toHaveClass(/dark/) await themeButton.click() await expect(html).toHaveClass(/dark/) await page.reload() await expect(html).toHaveClass(/dark/) // 再点一次回到跟随系统 await themeButton.click() await expect(html).not.toHaveClass(/dark/) }) test('键盘方向键可以移动任务卡片', async ({ page }) => { await addTask(page, '键盘移动我') const card = page.locator('article').filter({ hasText: '键盘移动我' }) await card.focus() await page.keyboard.press('ArrowRight') await expect(page.locator('[data-column-id="doing"] article')).toHaveCount(1) await page.keyboard.press('ArrowLeft') await expect(page.locator('[data-column-id="todo"] article')).toHaveCount(1) await page.reload() await expect(page.locator('[data-column-id="todo"] article')).toHaveCount(1) }) test('Ctrl+Z 撤销创建、Ctrl+Shift+Z 重做,刷新后历史清空', async ({ page }) => { await addTask(page, '撤销测试 A') await addTask(page, '撤销测试 B') await expect(page.locator('article')).toHaveCount(2) // 焦点从快速新增输入框移开,否则 Ctrl+Z 交给浏览器撤销文字 await page.locator('body').click({ position: { x: 5, y: 5 } }) await page.keyboard.press('Control+z') await expect(page.locator('article')).toHaveCount(1) await expect(page.locator('article').filter({ hasText: '撤销测试 A' })).toBeVisible() await page.keyboard.press('Control+z') await expect(page.locator('article')).toHaveCount(0) await page.keyboard.press('Control+Shift+z') await expect(page.locator('article')).toHaveCount(1) // 撤销结果真的落盘了 await page.reload() await expect(page.locator('article')).toHaveCount(1) // 但历史不落盘:刷新之后没有可撤销的步骤,卡片原地不动 await page.locator('body').click({ position: { x: 5, y: 5 } }) await page.keyboard.press('Control+z') await expect(page.getByText('没有可撤销的操作')).toBeVisible() await expect(page.locator('article')).toHaveCount(1) }) test('输入框内 Ctrl+Z 不劫持任务撤销', async ({ page }) => { await addTask(page, '不该被撤销') await expect(page.locator('article')).toHaveCount(1) // 焦点停在快速新增输入框时,Ctrl+Z 属于文字输入,任务不该被撤销 const input = page.getByLabel('添加任务').first() await input.click() await page.keyboard.press('Control+z') await expect(page.locator('article')).toHaveCount(1) })