import { enableAutoUnmount, mount } from '@vue/test-utils' import dayjs from 'dayjs' import { createPinia, setActivePinia } from 'pinia' import { nextTick } from 'vue' import { afterEach, describe, expect, it } from 'vitest' import BoardColumn from '@/components/BoardColumn.vue' import { createMemoryTaskRepository } from '@/data/taskRepository' import { useFilterStore } from '@/stores/filters' import { useTaskStore } from '@/stores/tasks' import { useToastStore } from '@/stores/toast' import { useUiStore } from '@/stores/ui' import BoardView from './BoardView.vue' // BoardView 会在 document 上注册快捷键监听,必须逐个卸载避免相互干扰 enableAutoUnmount(afterEach) function setup() { const pinia = createPinia() setActivePinia(pinia) const store = useTaskStore() store.init({ repository: createMemoryTaskRepository(), crossTab: false }) return { pinia, store } } function mountBoard(pinia: ReturnType) { // 抽屉内容会被 Teleport 到 body,测试中改为就地渲染以便断言 return mount(BoardView, { global: { plugins: [pinia], stubs: { teleport: true } }, attachTo: document.body, }) } describe('BoardView 结构', () => { it('渲染三个固定列', () => { const { pinia } = setup() const headings = mountBoard(pinia) .findAll('h2') .map((node) => node.text()) expect(headings).toEqual(['待办', '进行中', '已完成']) }) it('空看板显示空状态', () => { const { pinia } = setup() const wrapper = mountBoard(pinia) expect(wrapper.findAll('article')).toHaveLength(0) expect(wrapper.text()).toContain('暂无任务') }) it('每列显示任务计数', () => { const { pinia, store } = setup() store.createTask({ title: '待办事项' }) store.createTask({ title: '进行中事项', status: 'doing' }) const counters = mountBoard(pinia) .findAll('[data-role="column-count"]') .map((node) => node.text()) expect(counters).toEqual(['1', '1', '0']) }) it('渲染任务标题并显示总数', () => { const { pinia, store } = setup() store.createTask({ title: '写方案' }) store.createTask({ title: '做原型', status: 'doing' }) const wrapper = mountBoard(pinia) expect(wrapper.text()).toContain('写方案') expect(wrapper.text()).toContain('做原型') expect(wrapper.text()).toContain('共 2 个任务') }) it('任务按列归位', () => { const { pinia, store } = setup() store.createTask({ title: '待办事项' }) store.createTask({ title: '进行中事项', status: 'doing' }) store.createTask({ title: '已完成事项', status: 'done' }) const sections = mountBoard(pinia).findAll('section') expect(sections[0]?.text()).toContain('待办事项') expect(sections[0]?.text()).not.toContain('进行中事项') expect(sections[1]?.text()).toContain('进行中事项') expect(sections[2]?.text()).toContain('已完成事项') }) it('存在存储提示时由 App 统一展示(看板不再重复渲染)', () => { const { pinia, store } = setup() store.notice = '本地存储不可用' // 提示已提升到 App 层,BoardView 自身不应再渲染一遍 expect(mountBoard(pinia).text()).not.toContain('本地存储不可用') }) }) describe('BoardView 端到端交互', () => { it('在界面上输入并回车即可新增任务', async () => { const { pinia, store } = setup() const wrapper = mountBoard(pinia) const firstColumnInput = wrapper.findAll('section')[0]?.find('input') expect(firstColumnInput).toBeDefined() await firstColumnInput?.setValue('界面新增的任务') await wrapper.findAll('form')[0]?.trigger('submit') expect(store.byColumn.todo.map((task) => task.title)).toEqual(['界面新增的任务']) expect(wrapper.text()).toContain('界面新增的任务') }) it('点击完成按钮后卡片移动到已完成列', async () => { const { pinia, store } = setup() store.createTask({ title: '待办任务' }) const wrapper = mountBoard(pinia) await wrapper.find('article button').trigger('click') expect(store.byColumn.todo).toHaveLength(0) expect(store.byColumn.done.map((task) => task.title)).toEqual(['待办任务']) expect(wrapper.findAll('section')[2]?.text()).toContain('待办任务') }) it('再次点击可撤销完成', async () => { const { pinia, store } = setup() store.createTask({ title: '待办任务' }) const wrapper = mountBoard(pinia) await wrapper.find('article button').trigger('click') await wrapper.find('article button').trigger('click') expect(store.byColumn.todo.map((task) => task.title)).toEqual(['待办任务']) expect(store.byColumn.done).toHaveLength(0) }) }) describe('BoardView 详情抽屉', () => { it('初始不渲染详情表单', () => { const { pinia, store } = setup() store.createTask({ title: '待办任务' }) expect(mountBoard(pinia).find('input[aria-label="标题"]').exists()).toBe(false) }) it('点击卡片打开抽屉并回填标题', async () => { const { pinia, store } = setup() store.createTask({ title: '待办任务' }) const wrapper = mountBoard(pinia) await wrapper.find('article').trigger('click') await nextTick() expect((wrapper.find('input[aria-label="标题"]').element as HTMLInputElement).value).toBe( '待办任务', ) }) it('保存后看板同步更新', async () => { const { pinia, store } = setup() store.createTask({ title: '旧标题' }) const wrapper = mountBoard(pinia) await wrapper.find('article').trigger('click') await nextTick() await wrapper.find('input[aria-label="标题"]').setValue('新标题') const saveButton = wrapper.findAll('button').find((node) => node.text() === '保存') await saveButton?.trigger('click') await nextTick() expect(wrapper.text()).toContain('新标题') expect(store.tasks[0]?.title).toBe('新标题') }) it('有未保存修改时取消先确认,放弃后抽屉关闭且数据不变', async () => { const { pinia, store } = setup() store.createTask({ title: '原标题' }) const wrapper = mountBoard(pinia) await wrapper.find('article').trigger('click') await nextTick() await wrapper.find('input[aria-label="标题"]').setValue('改了一半') const cancelButton = wrapper.findAll('button').find((node) => node.text() === '取消') await cancelButton?.trigger('click') await nextTick() // 先给出确认,抽屉保持打开 expect(wrapper.text()).toContain('有未保存的修改') expect(wrapper.find('input[aria-label="标题"]').exists()).toBe(true) const discardButton = wrapper.findAll('button').find((node) => node.text() === '放弃修改') await discardButton?.trigger('click') await nextTick() expect(wrapper.find('input[aria-label="标题"]').exists()).toBe(false) expect(store.tasks[0]?.title).toBe('原标题') }) it('删除后抽屉关闭且任务从看板消失', async () => { const { pinia, store } = setup() store.createTask({ title: '要删除的任务' }) const wrapper = mountBoard(pinia) await wrapper.find('article').trigger('click') await nextTick() const deleteButton = wrapper.findAll('button').find((node) => node.text() === '删除') await deleteButton?.trigger('click') await nextTick() expect(store.tasks).toHaveLength(0) expect(wrapper.find('input[aria-label="标题"]').exists()).toBe(false) }) }) describe('BoardView 拖拽接线', () => { it('列组件 emit move 时驱动 store 改变状态', async () => { const { pinia, store } = setup() const task = store.createTask({ title: 'A' }) const wrapper = mountBoard(pinia) const column = wrapper.findAllComponents(BoardColumn)[0] column?.vm.$emit('move', { id: task?.id ?? '', toColumn: 'doing', toIndex: 0 }) await nextTick() expect(store.byColumn.todo).toHaveLength(0) expect(store.byColumn.doing.map((item) => item.title)).toEqual(['A']) }) it('列内移动事件驱动顺序变化', async () => { const { pinia, store } = setup() const a = store.createTask({ title: 'A' }) store.createTask({ title: 'B' }) store.createTask({ title: 'C' }) expect(store.byColumn.todo.map((item) => item.title)).toEqual(['C', 'B', 'A']) const wrapper = mountBoard(pinia) const column = wrapper.findAllComponents(BoardColumn)[0] column?.vm.$emit('move', { id: a?.id ?? '', toColumn: 'todo', toIndex: 0 }) await nextTick() expect(store.byColumn.todo.map((item) => item.title)).toEqual(['A', 'C', 'B']) expect(wrapper.findAll('section')[0]?.text()).toContain('A') }) }) describe('BoardView 筛选', () => { it('按优先级筛选后只显示匹配卡片,列计数同步变化', async () => { const { pinia, store } = setup() store.createTask({ title: '高优先级', priority: 3 }) store.createTask({ title: '低优先级', priority: 1 }) const wrapper = mountBoard(pinia) expect(wrapper.findAll('article')).toHaveLength(2) useFilterStore().togglePriority(3) await nextTick() expect(wrapper.findAll('article')).toHaveLength(1) expect(wrapper.text()).toContain('高优先级') expect(wrapper.text()).not.toContain('低优先级') expect(wrapper.findAll('[data-role="column-count"]')[0]?.text()).toBe('1') }) it('显示命中数量提示', async () => { const { pinia, store } = setup() store.createTask({ title: 'A', priority: 3 }) store.createTask({ title: 'B', priority: 1 }) const wrapper = mountBoard(pinia) useFilterStore().togglePriority(3) await nextTick() expect(wrapper.text()).toContain('已隐藏 1 个') }) it('清空筛选后恢复全部卡片', async () => { const { pinia, store } = setup() store.createTask({ title: 'A', priority: 3 }) store.createTask({ title: 'B', priority: 1 }) const wrapper = mountBoard(pinia) const filters = useFilterStore() filters.togglePriority(3) await nextTick() filters.clear() await nextTick() expect(wrapper.findAll('article')).toHaveLength(2) expect(wrapper.text()).not.toContain('已隐藏') }) it('点击「逾期」只显示逾期任务', async () => { const { pinia, store } = setup() store.createTask({ title: '逾期任务', dueDate: dayjs().subtract(1, 'day').format('YYYY-MM-DD'), }) store.createTask({ title: '未来任务', dueDate: dayjs().add(3, 'day').format('YYYY-MM-DD') }) const wrapper = mountBoard(pinia) const overdueButton = wrapper.findAll('button').find((node) => node.text().includes('逾期')) await overdueButton?.trigger('click') await nextTick() expect(wrapper.text()).toContain('逾期任务') expect(wrapper.text()).not.toContain('未来任务') }) it('列内渲染子集时给列组件传 sortDisabled(拖拽保护)', async () => { const { pinia, store } = setup() store.createTask({ title: 'A' }) const wrapper = mountBoard(pinia) expect(wrapper.findAllComponents(BoardColumn)[0]?.props('sortDisabled')).toBe(false) useFilterStore().togglePriority(3) await nextTick() expect(wrapper.findAllComponents(BoardColumn)[0]?.props('sortDisabled')).toBe(true) }) }) describe('BoardView 归档', () => { function archiveButton(wrapper: ReturnType) { return wrapper.findAll('button').find((node) => node.text().includes('个已完成')) } async function createArchivable(store: ReturnType) { const task = store.createTask({ title: '很久前完成的任务', status: 'done' }) store.updateTask(task?.id ?? '', { completedAt: dayjs().subtract(30, 'day').toISOString(), }) return task } it('默认不显示归档任务', async () => { const { pinia, store } = setup() const task = await createArchivable(store) store.archiveTasks([task?.id ?? '']) const wrapper = mountBoard(pinia) expect(wrapper.findAll('article')).toHaveLength(0) expect(wrapper.text()).toContain('已隐藏 1 个') // 提示里要说明是归档造成的,否则用户不知道列内排序为什么拖不动 expect(wrapper.text()).toContain('归档 1') }) it('开启「显示归档」后归档任务重新出现', async () => { const { pinia, store } = setup() const task = await createArchivable(store) store.archiveTasks([task?.id ?? '']) const wrapper = mountBoard(pinia) const toggle = wrapper.findAll('button').find((node) => node.text() === '显示归档') await toggle?.trigger('click') await nextTick() expect(wrapper.findAll('article')).toHaveLength(1) expect(wrapper.text()).toContain('很久前完成的任务') }) it('归档后给出可撤销提示,撤销后任务回到看板', async () => { const { pinia, store } = setup() await createArchivable(store) const wrapper = mountBoard(pinia) await archiveButton(wrapper)?.trigger('click') await nextTick() expect(store.tasks[0]?.archivedAt).not.toBeNull() expect(wrapper.findAll('article')).toHaveLength(0) const toast = useToastStore() expect(toast.toasts[0]?.action?.label).toBe('撤销') toast.runAction(toast.toasts[0]?.id ?? '') await nextTick() expect(store.tasks[0]?.archivedAt).toBeNull() expect(wrapper.findAll('article')).toHaveLength(1) }) it('隐藏归档时「已完成」列禁用列内排序', async () => { const { pinia, store } = setup() const task = await createArchivable(store) store.archiveTasks([task?.id ?? '']) const wrapper = mountBoard(pinia) // 归档任务只可能在「已完成」列 expect(wrapper.findAllComponents(BoardColumn)[2]?.props('sortDisabled')).toBe(true) expect(wrapper.findAllComponents(BoardColumn)[0]?.props('sortDisabled')).toBe(false) }) }) describe('BoardView 快捷键', () => { function pressKey(key: string, target: EventTarget = document): void { target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })) } it('按 N 聚焦待办列的快速新增输入框', async () => { const { pinia } = setup() const wrapper = mountBoard(pinia) await nextTick() pressKey('n') expect(document.activeElement).toBe(wrapper.find('input[aria-label="添加任务"]').element) }) it('按 / 聚焦搜索框', async () => { const { pinia } = setup() const wrapper = mountBoard(pinia) await nextTick() pressKey('/') expect(document.activeElement).toBe(wrapper.find('input[aria-label="搜索任务"]').element) }) it('在输入框内按 N 不触发快捷键', async () => { const { pinia } = setup() const wrapper = mountBoard(pinia) await nextTick() const search = wrapper.find('input[aria-label="搜索任务"]') pressKey('n', search.element) expect(document.activeElement).not.toBe(wrapper.find('input[aria-label="添加任务"]').element) }) it('应用级面板打开时不响应快捷键', async () => { const { pinia } = setup() const wrapper = mountBoard(pinia) await nextTick() // 帮助面板由 App 渲染,这里只验证看板会让出快捷键 useUiStore().openHelp() await nextTick() pressKey('n') expect(document.activeElement).not.toBe(wrapper.find('input[aria-label="添加任务"]').element) }) }) describe('BoardView 键盘移动', () => { type Direction = 'left' | 'right' | 'up' | 'down' /** 模拟卡片发出 keymove(任意一列转发都会到达 BoardView 的处理函数) */ function emitKeyMove( wrapper: ReturnType, id: string, direction: Direction, ): void { wrapper.findAllComponents(BoardColumn)[0]?.vm.$emit('keymove', { id, direction }) } it('右移把任务移到下一列末尾', async () => { const { pinia, store } = setup() const task = store.createTask({ title: 'A' }) const wrapper = mountBoard(pinia) emitKeyMove(wrapper, task?.id ?? '', 'right') await nextTick() expect(store.byColumn.todo).toHaveLength(0) expect(store.byColumn.doing.map((item) => item.title)).toEqual(['A']) }) it('左移把任务移回上一列', async () => { const { pinia, store } = setup() const task = store.createTask({ title: 'A', status: 'doing' }) const wrapper = mountBoard(pinia) emitKeyMove(wrapper, task?.id ?? '', 'left') await nextTick() expect(store.byColumn.doing).toHaveLength(0) expect(store.byColumn.todo.map((item) => item.title)).toEqual(['A']) }) it('最左列再左移无效果', async () => { const { pinia, store } = setup() const task = store.createTask({ title: 'A' }) const wrapper = mountBoard(pinia) emitKeyMove(wrapper, task?.id ?? '', 'left') await nextTick() expect(store.byColumn.todo.map((item) => item.title)).toEqual(['A']) }) it('最右列再右移无效果', async () => { const { pinia, store } = setup() const task = store.createTask({ title: 'A', status: 'done' }) const wrapper = mountBoard(pinia) emitKeyMove(wrapper, task?.id ?? '', 'right') await nextTick() expect(store.byColumn.done.map((item) => item.title)).toEqual(['A']) }) it('上移调整列内顺序', async () => { const { pinia, store } = setup() const a = store.createTask({ title: 'A' }) store.createTask({ title: 'B' }) store.createTask({ title: 'C' }) // 快速新增插入列首,当前顺序为 C, B, A const wrapper = mountBoard(pinia) emitKeyMove(wrapper, a?.id ?? '', 'up') await nextTick() expect(store.byColumn.todo.map((item) => item.title)).toEqual(['C', 'A', 'B']) }) it('下移调整列内顺序', async () => { const { pinia, store } = setup() store.createTask({ title: 'A' }) store.createTask({ title: 'B' }) const c = store.createTask({ title: 'C' }) // 当前顺序 C, B, A const wrapper = mountBoard(pinia) emitKeyMove(wrapper, c?.id ?? '', 'down') await nextTick() expect(store.byColumn.todo.map((item) => item.title)).toEqual(['B', 'C', 'A']) }) it('已在列首时上移无效果', async () => { const { pinia, store } = setup() store.createTask({ title: 'A' }) const first = store.createTask({ title: 'B' }) const wrapper = mountBoard(pinia) emitKeyMove(wrapper, first?.id ?? '', 'up') await nextTick() expect(store.byColumn.todo.map((item) => item.title)).toEqual(['B', 'A']) }) it('列内渲染子集时禁止上下移动(避免写坏全量顺序)', async () => { const { pinia, store } = setup() const a = store.createTask({ title: 'A', priority: 3 }) store.createTask({ title: 'B', priority: 1 }) const wrapper = mountBoard(pinia) useFilterStore().togglePriority(3) await nextTick() emitKeyMove(wrapper, a?.id ?? '', 'down') await nextTick() // 顺序保持原样 expect(store.byColumn.todo.map((item) => item.title)).toEqual(['B', 'A']) }) it('跨列移动在筛选态下依然可用', async () => { const { pinia, store } = setup() const task = store.createTask({ title: 'A', priority: 3 }) const wrapper = mountBoard(pinia) useFilterStore().togglePriority(3) await nextTick() emitKeyMove(wrapper, task?.id ?? '', 'right') await nextTick() expect(store.byColumn.doing.map((item) => item.title)).toEqual(['A']) }) }) describe('BoardView 批量添加', () => { function buttonByText(wrapper: ReturnType, text: string) { return wrapper.findAll('button').find((node) => node.text().includes(text)) } it('点击工具栏「批量添加」打开面板', async () => { const { pinia } = setup() const wrapper = mountBoard(pinia) expect(wrapper.find('textarea[aria-label="批量任务清单"]').exists()).toBe(false) await buttonByText(wrapper, '批量添加')?.trigger('click') await nextTick() expect(wrapper.find('textarea[aria-label="批量任务清单"]').exists()).toBe(true) }) it('批量添加后卡片出现在看板上', async () => { const { pinia, store } = setup() const wrapper = mountBoard(pinia) await buttonByText(wrapper, '批量添加')?.trigger('click') await nextTick() await wrapper.find('textarea[aria-label="批量任务清单"]').setValue('写周报\n评审 PR') await buttonByText(wrapper, '添加 2 个任务')?.trigger('click') await nextTick() expect(store.byColumn.todo.map((item) => item.title)).toEqual(['写周报', '评审 PR']) expect(wrapper.text()).toContain('写周报') }) }) describe('BoardView 多选与批量删除', () => { /** 文案精确匹配:「多选」是「退出多选」的子串 */ function exactButton(wrapper: ReturnType, text: string) { return wrapper.findAll('button').find((node) => node.text() === text) } async function enterSelection(wrapper: ReturnType) { await exactButton(wrapper, '多选')?.trigger('click') await nextTick() } it('点击「多选」后卡片变为可勾选,并出现操作条', async () => { const { pinia, store } = setup() store.createTask({ title: 'A' }) const wrapper = mountBoard(pinia) expect(wrapper.text()).not.toContain('已选') await enterSelection(wrapper) expect(wrapper.text()).toContain('已选 0 个任务') expect(wrapper.find('article').attributes('role')).toBe('checkbox') }) it('点击卡片即选中,计数同步', async () => { const { pinia, store } = setup() store.createTasks([{ title: 'A' }, { title: 'B' }]) const wrapper = mountBoard(pinia) await enterSelection(wrapper) await wrapper.find('[data-task-id]').trigger('click') await nextTick() expect(wrapper.text()).toContain('已选 1 个任务') expect(wrapper.find('article').attributes('aria-checked')).toBe('true') }) it('多选时点击卡片不会打开详情抽屉', async () => { const { pinia, store } = setup() store.createTask({ title: 'A' }) const wrapper = mountBoard(pinia) await enterSelection(wrapper) await wrapper.find('article').trigger('click') await nextTick() expect(wrapper.find('[role="dialog"]').exists()).toBe(false) }) it('全选选中当前可见的全部任务', async () => { const { pinia, store } = setup() store.createTasks([{ title: 'A' }, { title: 'B', status: 'doing' }, { title: 'C' }]) const wrapper = mountBoard(pinia) await enterSelection(wrapper) await wrapper .findAll('button') .find((node) => node.text().includes('全选')) ?.trigger('click') await nextTick() expect(wrapper.text()).toContain('已选 3 个任务') }) it('筛选后全选只覆盖可见任务', async () => { const { pinia, store } = setup() store.createTasks([ { title: '高优先', priority: 3 }, { title: '普通', priority: 1 }, ]) const wrapper = mountBoard(pinia) await enterSelection(wrapper) useFilterStore().togglePriority(3) await nextTick() await wrapper .findAll('button') .find((node) => node.text().includes('全选')) ?.trigger('click') await nextTick() expect(wrapper.text()).toContain('已选 1 个任务') }) it('删除选中任务后弹出可撤销的提示,撤销连原顺序一起还原', async () => { const { pinia, store } = setup() store.createTasks([{ title: 'A' }, { title: 'B' }]) const wrapper = mountBoard(pinia) await enterSelection(wrapper) await wrapper.find('[data-task-id]').trigger('click') await nextTick() await wrapper .findAll('button') .find((node) => node.text().includes('删除')) ?.trigger('click') await nextTick() expect(store.tasks.map((item) => item.title)).toEqual(['B']) // 删除后自动退出多选 expect(wrapper.text()).not.toContain('已选') const toast = useToastStore() expect(toast.toasts.at(-1)?.message).toBe('已删除 1 个任务') toast.toasts.at(-1)?.action?.run() expect(store.byColumn.todo.map((item) => item.title)).toEqual(['A', 'B']) }) it('没有选中任务时删除按钮不可用', async () => { const { pinia, store } = setup() store.createTask({ title: 'A' }) const wrapper = mountBoard(pinia) await enterSelection(wrapper) const removeButton = wrapper.findAll('button').find((node) => node.text().includes('删除 0 个')) expect(removeButton?.attributes('disabled')).toBeDefined() }) it('筛选条件变化会清空选择,避免删掉看不见的任务', async () => { const { pinia, store } = setup() store.createTasks([ { title: 'A', priority: 3 }, { title: 'B', priority: 1 }, ]) const wrapper = mountBoard(pinia) await enterSelection(wrapper) await wrapper.find('[data-task-id]').trigger('click') await nextTick() expect(wrapper.text()).toContain('已选 1 个任务') useFilterStore().togglePriority(1) await nextTick() expect(wrapper.text()).toContain('已选 0 个任务') }) it('退出多选后清空选择并恢复卡片的常规语义', async () => { const { pinia, store } = setup() store.createTask({ title: 'A' }) const wrapper = mountBoard(pinia) await enterSelection(wrapper) await wrapper.find('article').trigger('click') await nextTick() await exactButton(wrapper, '退出多选')?.trigger('click') await nextTick() expect(wrapper.text()).not.toContain('已选') expect(wrapper.find('article').attributes('role')).toBeUndefined() // 恢复后点击卡片重新等于「打开详情」 await wrapper.find('article').trigger('click') await nextTick() expect(wrapper.find('[role="dialog"]').exists()).toBe(true) }) it('归档后清空选择,避免选中已不可见的卡片', async () => { const { pinia, store } = setup() const task = store.createTask({ title: '很久前完成', status: 'done' }) store.updateTask(task?.id ?? '', { completedAt: dayjs().subtract(30, 'day').toISOString() }) const wrapper = mountBoard(pinia) await enterSelection(wrapper) await wrapper .findAll('button') .find((node) => node.text().includes('全选')) ?.trigger('click') await nextTick() expect(wrapper.text()).toContain('已选 1 个任务') await wrapper .findAll('button') .find((node) => node.text().includes('个已完成')) ?.trigger('click') await nextTick() expect(wrapper.text()).toContain('已选 0 个任务') }) })