Files
Kanban/src/components/TaskCard.spec.ts
T
chenshoulin 61fa5515b8
CI / verify (push) Successful in 1m44s
CI / 部署到服务器 (push) Successful in 26s
feat: 添加标签颜色功能并更新相关组件和测试用例
2026-10-24 22:03:04 +08:00

384 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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> = {}): 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<Task> = {}) {
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)
})
})