Files
Kanban/src/components/TaskCard.spec.ts
T
chenshoulin 8721e8ab0b feat: 补齐今日视图待办组、卡片键盘操作、深色模式
今日视图
- 补上「待办」组。此前只收 逾期 / 今天到期 / 进行中,导致待办列里没设
  截止日期的任务在今日视图里完全消失 —— 用户明明有一堆事没做,却看到
  「今天没有待办事项」。现在只要任务未完成且未归档,就必定出现在某个
  分组里(归属优先级:逾期 > 今天到期 > 进行中 > 待办)。
- 统计卡片改为与分组一一对应(逾期 / 今天到期 / 进行中 / 本周完成),
  不再用「今日待办」这种把四组混在一起、与列表长度对不上的指标。

键盘操作(可访问性)
- 卡片可聚焦(Tab / 点击),← → 换列、↑ ↓ 列内调序、Enter 打开详情。
- 换列时追加到目标列末尾:用全量长度而不是可见下标,筛选态下也安全;
  列内调序要求该列渲染的是全量,否则禁用(与拖拽的规则一致)。
- 移动后手动恢复焦点 —— 卡片跨列会被重建,不恢复的话连续按键会失效。

深色模式
- 三态:跟随系统 / 浅色 / 深色,选择持久化到 localStorage。
- index.html 内联脚本在首帧之前应用主题,避免深色下闪白。
- 用 @custom-variant dark 做 class 控制(而不是纯 media query),
  才能支持「强制浅色」;html 的 color-scheme 让原生控件与滚动条跟随。

验证:单元测试 480 个、端到端测试 25 个全部通过,lint 与构建无错误。
截图脚本新增深色与移动端深色两组,便于后续对照。
2026-10-18 17:48:55 +08:00

205 lines
6.8 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,
...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 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)
})
})