import { mount } from '@vue/test-utils' import type { VueWrapper } from '@vue/test-utils' import dayjs from 'dayjs' import { describe, expect, it } from 'vitest' import type { Task } from '@/types/task' import TaskCard from './TaskCard.vue' function makeTask(overrides: Partial = {}): Task { return { id: 'task-1', title: '写方案', description: '', status: 'todo', priority: 2, tags: [], dueDate: null, sortOrder: 1024, createdAt: '2026-10-18T00:00:00.000Z', updatedAt: '2026-10-18T00:00:00.000Z', completedAt: null, archivedAt: null, ...overrides, } } function mountCard(overrides: Partial = {}) { return mount(TaskCard, { props: { task: makeTask(overrides) } }) } /** 通过文案定位优先级徽章,避免依赖 DOM 层级 */ function priorityBadge(wrapper: VueWrapper) { return wrapper.findAll('span').find((node) => ['低', '中', '高'].includes(node.text())) } describe('TaskCard 基础渲染', () => { it('渲染标题', () => { expect(mountCard({ title: '写方案' }).find('h3').text()).toBe('写方案') }) it('渲染优先级徽章', () => { expect(priorityBadge(mountCard({ priority: 3 }))?.text()).toBe('高') }) it('无标签无截止日期时仍然显示优先级徽章', () => { const wrapper = mountCard({ priority: 1, tags: [], dueDate: null }) expect(priorityBadge(wrapper)?.text()).toBe('低') }) it('无截止日期时不渲染日期徽章', () => { const wrapper = mountCard({ dueDate: null }) expect(wrapper.text()).not.toContain('今天') expect(wrapper.text()).not.toContain('逾期') }) }) describe('优先级视觉区分', () => { it('三档优先级使用不同色系', () => { const high = priorityBadge(mountCard({ priority: 3 }))?.classes().join(' ') ?? '' const medium = priorityBadge(mountCard({ priority: 2 }))?.classes().join(' ') ?? '' const low = priorityBadge(mountCard({ priority: 1 }))?.classes().join(' ') ?? '' expect(high).toContain('rose') expect(medium).toContain('amber') expect(low).toContain('slate') expect(new Set([high, medium, low]).size).toBe(3) }) }) describe('截止日期', () => { it('逾期任务使用警示样式', () => { const yesterday = dayjs().subtract(2, 'day').format('YYYY-MM-DD') const wrapper = mountCard({ dueDate: yesterday }) expect(wrapper.text()).toContain('逾期 2 天') const badge = wrapper.findAll('span').find((node) => node.text().includes('逾期')) expect(badge?.classes().join(' ')).toContain('rose') }) it('已完成任务不再标记逾期', () => { const yesterday = dayjs().subtract(2, 'day').format('YYYY-MM-DD') const wrapper = mountCard({ dueDate: yesterday, status: 'done' }) const badge = wrapper.findAll('span').find((node) => node.text().includes('逾期')) expect(badge?.classes().join(' ')).not.toContain('rose') }) it('今天到期显示「今天」', () => { expect(mountCard({ dueDate: dayjs().format('YYYY-MM-DD') }).text()).toContain('今天') }) }) describe('已完成样式', () => { it('标题加删除线并整体降饱和', () => { const wrapper = mountCard({ status: 'done' }) expect(wrapper.find('h3').classes().join(' ')).toContain('line-through') expect(wrapper.find('article').classes().join(' ')).toContain('opacity-60') }) it('未完成时不加删除线', () => { expect(mountCard({ status: 'todo' }).find('h3').classes().join(' ')).not.toContain( 'line-through', ) }) }) describe('标签展示', () => { it('最多显示 3 个标签', () => { const wrapper = mountCard({ tags: ['a', 'b', 'c', 'd', 'e'] }) expect(wrapper.text()).toContain('#a') expect(wrapper.text()).toContain('#c') expect(wrapper.text()).not.toContain('#d') expect(wrapper.text()).toContain('+2') }) it('标签不超过 3 个时不显示折叠计数', () => { const wrapper = mountCard({ tags: ['a', 'b', 'c'] }) expect(wrapper.text()).not.toContain('+') }) it('无标签时不渲染标签', () => { expect(mountCard({ tags: [] }).text()).not.toContain('#') }) }) describe('交互', () => { it('点击完成按钮 emit toggle 并带上 id', async () => { const wrapper = mountCard({ id: 'task-42' }) await wrapper.find('button').trigger('click') expect(wrapper.emitted('toggle')).toEqual([['task-42']]) }) it('完成按钮带可访问性属性', () => { expect(mountCard({ status: 'todo' }).find('button').attributes('aria-label')).toBe( '标记为已完成', ) expect(mountCard({ status: 'done' }).find('button').attributes('aria-pressed')).toBe('true') }) it('点击卡片 emit open 而不是 toggle', async () => { const wrapper = mountCard({ id: 'task-7' }) await wrapper.find('article').trigger('click') expect(wrapper.emitted('open')).toEqual([['task-7']]) expect(wrapper.emitted('toggle')).toBeUndefined() }) it('卡片可点击(带 cursor-pointer)', () => { expect(mountCard().find('article').classes().join(' ')).toContain('cursor-pointer') }) }) describe('TaskCard 键盘操作', () => { it('卡片可聚焦', () => { expect(mountCard().find('article').attributes('tabindex')).toBe('0') }) it('方向键 emit keymove 并带上方向', async () => { const wrapper = mountCard({ id: 'task-1' }) const article = wrapper.find('article') await article.trigger('keydown', { key: 'ArrowLeft' }) await article.trigger('keydown', { key: 'ArrowRight' }) await article.trigger('keydown', { key: 'ArrowUp' }) await article.trigger('keydown', { key: 'ArrowDown' }) expect(wrapper.emitted('keymove')).toEqual([ [{ id: 'task-1', direction: 'left' }], [{ id: 'task-1', direction: 'right' }], [{ id: 'task-1', direction: 'up' }], [{ id: 'task-1', direction: 'down' }], ]) }) it('Enter 与空格都能打开详情', async () => { const wrapper = mountCard({ id: 'task-2' }) const article = wrapper.find('article') await article.trigger('keydown', { key: 'Enter' }) await article.trigger('keydown', { key: ' ' }) expect(wrapper.emitted('open')).toEqual([['task-2'], ['task-2']]) }) it('其他按键不产生任何事件', async () => { const wrapper = mountCard() const article = wrapper.find('article') await article.trigger('keydown', { key: 'a' }) await article.trigger('keydown', { key: 'Tab' }) expect(wrapper.emitted('keymove')).toBeUndefined() expect(wrapper.emitted('open')).toBeUndefined() }) it('方向键被 preventDefault(避免连带着滚动看板)', async () => { const wrapper = mountCard() const article = wrapper.find('article') const event = new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true }) article.element.dispatchEvent(event) expect(event.defaultPrevented).toBe(true) }) })