diff --git a/e2e/board.spec.ts b/e2e/board.spec.ts index 0cc4291..202a654 100644 --- a/e2e/board.spec.ts +++ b/e2e/board.spec.ts @@ -92,6 +92,66 @@ test('编辑任务时可以直接选已有标签', async ({ page }) => { 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() diff --git a/src/components/BatchAddDrawer.spec.ts b/src/components/BatchAddDrawer.spec.ts new file mode 100644 index 0000000..f0bd24e --- /dev/null +++ b/src/components/BatchAddDrawer.spec.ts @@ -0,0 +1,134 @@ +import { enableAutoUnmount, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, describe, expect, it } from 'vitest' + +import { createMemoryTaskRepository } from '@/data/taskRepository' +import { useTaskStore } from '@/stores/tasks' +import { useToastStore } from '@/stores/toast' + +import BatchAddDrawer from './BatchAddDrawer.vue' + +enableAutoUnmount(afterEach) + +function setup(open = true) { + const pinia = createPinia() + setActivePinia(pinia) + const store = useTaskStore() + store.init({ repository: createMemoryTaskRepository(), crossTab: false }) + + // 抽屉会被 Teleport 到 body,测试中改为就地渲染以便断言 + const wrapper = mount(BatchAddDrawer, { + props: { open }, + global: { plugins: [pinia], stubs: { teleport: true } }, + attachTo: document.body, + }) + return { store, wrapper } +} + +function buttonByText(wrapper: ReturnType['wrapper'], text: string) { + return wrapper.findAll('button').find((node) => node.text().includes(text)) +} + +describe('BatchAddDrawer 录入', () => { + it('打开后显示多行清单输入框', () => { + const { wrapper } = setup() + expect(wrapper.find('textarea[aria-label="批量任务清单"]').exists()).toBe(true) + }) + + it('按行实时统计将添加的任务数', async () => { + const { wrapper } = setup() + await wrapper.find('textarea').setValue('第一条\n\n- 第二条\n第三条') + + expect(buttonByText(wrapper, '个任务')?.text()).toContain('添加 3 个任务') + }) + + it('清单为空时提交按钮不可用', () => { + const { wrapper } = setup() + const button = buttonByText(wrapper, '添加 0 个任务') + + expect(button).toBeDefined() + expect(button?.attributes('disabled')).toBeDefined() + }) + + it('只有空行时同样不可提交', async () => { + const { wrapper } = setup() + await wrapper.find('textarea').setValue(' \n\n') + + expect(buttonByText(wrapper, '添加 0 个任务')?.attributes('disabled')).toBeDefined() + }) +}) + +describe('BatchAddDrawer 提交', () => { + it('按清单顺序创建任务,第一条在最上面', async () => { + const { store, wrapper } = setup() + await wrapper.find('textarea').setValue('第一条\n第二条\n第三条') + + await buttonByText(wrapper, '添加 3 个任务')?.trigger('click') + + expect(store.byColumn.todo.map((task) => task.title)).toEqual(['第一条', '第二条', '第三条']) + }) + + it('目标列、优先级、截止日期与标签整批共用', async () => { + const { store, wrapper } = setup() + await wrapper.find('textarea').setValue('第一条\n第二条') + + await buttonByText(wrapper, '进行中')?.trigger('click') + await buttonByText(wrapper, '高')?.trigger('click') + await wrapper.find('input[type="date"]').setValue('2026-10-20') + + const tagInput = wrapper.find('input[aria-label="添加标签"]') + await tagInput.setValue('前端') + await tagInput.trigger('keydown.enter') + + await buttonByText(wrapper, '添加 2 个任务')?.trigger('click') + + const created = store.byColumn.doing + expect(created.map((task) => task.title)).toEqual(['第一条', '第二条']) + expect(created[0]?.priority).toBe(3) + expect(created[0]?.dueDate).toBe('2026-10-20') + expect(created[0]?.tags).toEqual(['前端']) + expect(created[1]?.tags).toEqual(['前端']) + }) + + it('提交后请求关闭并提示数量', async () => { + const { wrapper } = setup() + await wrapper.find('textarea').setValue('A\nB') + + await buttonByText(wrapper, '添加 2 个任务')?.trigger('click') + + expect(wrapper.emitted('close')).toHaveLength(1) + expect(useToastStore().toasts.at(-1)?.message).toBe('已添加 2 个任务') + }) + + it('取消不创建任何任务', async () => { + const { store, wrapper } = setup() + await wrapper.find('textarea').setValue('A\nB') + + await buttonByText(wrapper, '取消')?.trigger('click') + + expect(store.tasks).toHaveLength(0) + expect(wrapper.emitted('close')).toHaveLength(1) + }) + + it('再次打开时清空上一次的残留', async () => { + const { wrapper } = setup() + await wrapper.find('textarea').setValue('上一次的清单') + + await wrapper.setProps({ open: false }) + await wrapper.setProps({ open: true }) + + expect((wrapper.find('textarea').element as HTMLTextAreaElement).value).toBe('') + }) + + it('误提交时可以整批撤销', async () => { + const { store, wrapper } = setup() + await wrapper.find('textarea').setValue('A\nB') + await buttonByText(wrapper, '添加 2 个任务')?.trigger('click') + + const action = useToastStore().toasts.at(-1)?.action + expect(action?.label).toBe('撤销') + action?.run() + + expect(store.tasks).toHaveLength(0) + }) +}) diff --git a/src/components/BatchAddDrawer.vue b/src/components/BatchAddDrawer.vue new file mode 100644 index 0000000..6987898 --- /dev/null +++ b/src/components/BatchAddDrawer.vue @@ -0,0 +1,182 @@ + + +