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 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('section header span') .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('存在存储提示时展示提示条', () => { const { pinia, store } = setup() store.notice = '本地存储不可用' expect(mountBoard(pinia).text()).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.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('section header span')[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 / 2 个任务') }) 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('显示 1 / 2') }) 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('显示 0 / 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('按 ? 打开快捷键帮助', async () => { const { pinia } = setup() const wrapper = mountBoard(pinia) await nextTick() pressKey('?') await nextTick() expect(wrapper.text()).toContain('聚焦「待办」列的快速新增输入框') }) 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() await wrapper.find('button[aria-label="快捷键帮助"]').trigger('click') await nextTick() pressKey('n') expect(document.activeElement).not.toBe(wrapper.find('input[aria-label="添加任务"]').element) }) it('按 Esc 关闭已打开的面板', async () => { const { pinia } = setup() const wrapper = mountBoard(pinia) await nextTick() await wrapper.find('button[aria-label="快捷键帮助"]').trigger('click') await nextTick() expect(wrapper.text()).toContain('聚焦「待办」列的快速新增输入框') pressKey('Escape') await nextTick() expect(wrapper.text()).not.toContain('聚焦「待办」列的快速新增输入框') }) })