import { mount } from '@vue/test-utils' import type { VueWrapper } from '@vue/test-utils' import dayjs from 'dayjs' import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it } from 'vitest' import { useTagColorStore } from '@/stores/tagColors' import type { Task } from '@/types/task' import TaskCard from './TaskCard.vue' /** * 卡片要从 tagColors store 读圆点颜色,因此每个用例都要有独立的 Pinia, * 否则上一个用例设的颜色会漏到下一个。 */ beforeEach(() => { setActivePinia(createPinia()) }) 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, deletedAt: 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 completedAt = dayjs().subtract(1, 'day').hour(10).minute(0).toISOString() const wrapper = mountCard({ dueDate: yesterday, status: 'done', completedAt }) const badge = wrapper.findAll('span').find((node) => node.text().includes('逾期')) expect(badge?.text()).toBe('逾期 1 天完成') expect(badge?.classes().join(' ')).not.toContain('rose') }) it('当天完成的任务第二天不再显示逾期天数', () => { // 复现:19 号截止、19 号完成,20 号查看时曾显示「逾期 1 天」 const dueDate = dayjs().subtract(1, 'day').format('YYYY-MM-DD') const completedAt = dayjs().subtract(1, 'day').hour(10).minute(0).toISOString() const wrapper = mountCard({ dueDate, status: 'done', completedAt }) expect(wrapper.text()).not.toContain('逾期') expect(wrapper.text()).toContain('原定') expect(wrapper.text()).toMatch(/原定 \d+月\d+日/) }) it('完成时已超期的任务显示「逾期 N 天完成」,不再随日期增长', () => { const dueDate = dayjs().subtract(3, 'day').format('YYYY-MM-DD') const completedAt = dayjs().subtract(2, 'day').hour(9).minute(0).toISOString() const wrapper = mountCard({ dueDate, status: 'done', completedAt }) expect(wrapper.text()).toContain('逾期 1 天完成') }) it('今天到期显示「今天」', () => { expect(mountCard({ dueDate: dayjs().format('YYYY-MM-DD') }).text()).toContain('今天') }) }) describe('已完成样式', () => { it('标题加删除线,但不再整卡降透明度', () => { const wrapper = mountCard({ status: 'done' }) const classes = wrapper.find('article').classes().join(' ') expect(wrapper.find('h3').classes().join(' ')).toContain('line-through') // 整卡 opacity-60 会把标题压到 1.69:1、标签压到 1.61:1(WCAG AA 要求 4.5:1) expect(classes).not.toContain('opacity-60') expect(classes).toContain('bg-white') }) it('已完成卡片的文字不低于 slate-500', () => { const wrapper = mountCard({ status: 'done', priority: 1, tags: ['设计'] }) const html = wrapper.html() expect(wrapper.find('h3').classes().join(' ')).toContain('text-slate-500') // 标签与「低」优先级曾用 slate-400(白底 2.56:1),统一提到 slate-500(4.76:1) expect(html).not.toContain('text-slate-400') expect(html).toContain('text-slate-500') // 完成日期曾带 /80 透明度,混色后 3.75:1 expect(html).not.toContain('emerald-700/80') }) it('未完成时不加删除线', () => { expect(mountCard({ status: 'todo' }).find('h3').classes().join(' ')).not.toContain( 'line-through', ) }) }) describe('时间信息', () => { it('已完成卡片显示完成日期', () => { const completedAt = new Date(2026, 9, 18, 10, 20).toISOString() const wrapper = mountCard({ status: 'done', completedAt }) expect(wrapper.text()).toContain('完成于') expect(wrapper.text()).toContain('10月18日') }) it('未完成卡片不显示完成时间', () => { expect(mountCard({ completedAt: null }).text()).not.toContain('完成于') }) it('卡片 title 暴露完整创建与完成时间', () => { const createdAt = new Date(2026, 9, 18, 10, 8).toISOString() const completedAt = new Date(2026, 9, 18, 10, 20).toISOString() const title = mountCard({ status: 'done', createdAt, completedAt }) .find('article') .attributes('title') expect(title).toContain('创建于 2026-10-18 10:08') expect(title).toContain('完成于 2026-10-18 10:20') }) it('归档任务在 title 中补充归档时间', () => { const archivedAt = new Date(2026, 9, 19, 8, 0).toISOString() const title = mountCard({ archivedAt }).find('article').attributes('title') expect(title).toContain('归档于 2026-10-19 08:00') }) }) 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('#') }) it('未设置颜色的标签不画圆点', () => { const wrapper = mountCard({ tags: ['前端'] }) expect(wrapper.find('.bg-sky-500').exists()).toBe(false) expect(wrapper.text()).toContain('#前端') }) it('设置了颜色的标签显示对应圆点', () => { useTagColorStore().setColor('前端', 'sky') const wrapper = mountCard({ tags: ['前端', '设计'] }) // 只有设过颜色的那个标签带圆点 expect(wrapper.findAll('[data-role="tag-dot"]').length).toBe(1) expect(wrapper.find('.bg-sky-500').exists()).toBe(true) }) it('颜色与标签文字一一对应,不会串到相邻标签上', () => { const store = useTagColorStore() store.setColor('前端', 'sky') store.setColor('设计', 'rose') const wrapper = mountCard({ tags: ['前端', '设计'] }) const labels = wrapper .findAll('.inline-flex.items-center') .filter((node) => node.text().startsWith('#')) expect(labels[0]?.find('.bg-sky-500').exists()).toBe(true) expect(labels[1]?.find('.bg-rose-500').exists()).toBe(true) }) }) 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('勾选按钮的命中区不小于 24×24(视觉仍是 16px 圆点)', () => { const button = mountCard().find('button') // WCAG 2.5.8:命中区 24×24 起;视觉尺寸由内层 span 保持 size-4 expect(button.classes().join(' ')).toContain('h-9') expect(button.classes().join(' ')).toContain('w-8') expect(button.find('span').classes().join(' ')).toContain('size-4') }) 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) }) }) describe('TaskCard 多选态', () => { function mountSelectable(selected = false) { return mount(TaskCard, { props: { task: makeTask({ id: 'task-9' }), selectable: true, selected }, }) } it('平时不带 checkbox 语义,多选时才加上', () => { expect(mountCard().find('article').attributes('role')).toBeUndefined() expect(mountSelectable().find('article').attributes('role')).toBe('checkbox') }) it('选中态通过 aria-checked 暴露给读屏', () => { expect(mountSelectable(false).find('article').attributes('aria-checked')).toBe('false') expect(mountSelectable(true).find('article').attributes('aria-checked')).toBe('true') }) it('选中态有可辨识的高亮样式', () => { const classes = mountSelectable(true).find('article').classes().join(' ') expect(classes).toContain('bg-sky-50') }) it('多选时点击是「选中」而不是「打开详情」', async () => { const wrapper = mountSelectable() await wrapper.find('article').trigger('click') expect(wrapper.emitted('select')).toEqual([['task-9']]) expect(wrapper.emitted('open')).toBeUndefined() }) it('多选时 Enter 与空格切换选中', async () => { const wrapper = mountSelectable() await wrapper.find('article').trigger('keydown', { key: 'Enter' }) await wrapper.find('article').trigger('keydown', { key: ' ' }) expect(wrapper.emitted('select')).toEqual([['task-9'], ['task-9']]) expect(wrapper.emitted('open')).toBeUndefined() }) it('多选时方向键不再移动卡片', async () => { const wrapper = mountSelectable() await wrapper.find('article').trigger('keydown', { key: 'ArrowRight' }) expect(wrapper.emitted('keymove')).toBeUndefined() }) it('多选时不显示「完成」勾选按钮,避免与选中勾选混淆', () => { expect(mountCard().find('article button').exists()).toBe(true) expect(mountSelectable().find('article button').exists()).toBe(false) }) })