Files
Kanban/src/components/TaskCard.spec.ts
T
chenshoulin b39267fba7
CI / verify (push) Successful in 1m43s
CI / 部署到服务器 (push) Successful in 26s
feat(board): 展示任务时间,并修正已完成任务的逾期基准
时间展示(ADR-008):
- 详情抽屉新增只读「时间」区块:创建 / 更新常驻,完成时间缺失时标注「未完成」,归档任务追加归档时间;时间不进入草稿与 dirty 判定
- 已完成卡片在元信息行右侧显示「完成于 M月D日」,卡片根元素 title 汇总创建 / 完成 / 归档时间(精确到分钟)
- 回收站条目补创建时间,删掉内联 formatTime,统一走 @/utils/date
- date.ts 新增 formatDateTime(YYYY-MM-DD HH:mm)与 formatCompactDate(同年 M月D日 / 跨年带年份),空值与非法时间戳返回 null

逾期基准修复:
- 新增 formatDueDateForTask:未完成任务仍以「今天」推算;已完成任务改以「完成日」推算,19 号截止、19 号当天完成的任务不会在 20 号显示「逾期 1 天」,完成时确实超期则定格为「逾期 N 天完成」,不再随日期增长
- 新增 localDateOf:completedAt 存 UTC 时间戳,按本地时区取日期,避免跨日偏差
- TaskCard 改用 dueLabel computed 取代模板内直接调用 formatDueDate;颜色仍由 isOverdue 决定,已完成卡片保持中性色

测试:date / TaskCard 补时间格式化与逾期基准用例(当天完成、提前完成、完成时超期定格、缺 completedAt、跨年),全量 728 单测通过;vue-tsc 与 eslint 干净。
2026-10-20 09:23:29 +08:00

312 lines
11 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 { describe, expect, it } from 'vitest'
import type { Task } from '@/types/task'
import TaskCard from './TaskCard.vue'
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' })
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('已完成卡片显示完成日期', () => {
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('#')
})
})
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)
})
})
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)
})
})