Why: creation entries were split (project + at sidebar top-left, new session at bottom-left) and the project tree forced per-project navigation; the approved option A design makes one primary New Task entry, organization a view mode, and identity always present. Evidence: - New TaskSidebar: single New Task split button (current-project context, Cmd-N, secondary menu for new project / new task in project), project <-> timeline organization with persisted collapse, pinned section, archive view toggle, hover row actions, account footer fed by /api/me (embedded mode shows local tenant; logout clears the key), workspace icon strip retained (D25 shared list with Cmd-1..9). - store: tasks = cross-project list via GET /api/conversations; openTask switches project context; pin/archive optimistic + refresh; last project context persisted; stale conversations field removed. - tasklist.ts pure helpers + 6 vitest cases; npm run build passes; headless walk on the real binary: cross-project open, pinned-first, archive hide/show/restore, new-task flow, zero console errors. Impact: ProjectNav removed; contracts.ts regenerated from core (pinned/archived); no composer/right-pane changes (D25/D26/D31 intact). Refs: Agent-System-Documents docs/proposals/2026-09-30-ui-redesign-and-auth.md
72 lines
3.3 KiB
TypeScript
72 lines
3.3 KiB
TypeScript
// 任务列表组织纯函数测试(方案 A R1)。
|
|
import { describe, it, expect } from 'vitest'
|
|
import { groupByProject, groupByTimeline, relativeTime, filterTasks } from '../src/tasklist'
|
|
import type { Conversation, Project } from '../src/contracts'
|
|
|
|
const NOW = new Date('2026-09-30T15:00:00').getTime()
|
|
const H = 3_600_000
|
|
|
|
function conv(id: string, projectId: string, updatedAt: string, extra: Partial<Conversation> = {}): Conversation {
|
|
return { id, projectId, title: id, createdAt: updatedAt, updatedAt, ...extra }
|
|
}
|
|
|
|
describe('groupByProject', () => {
|
|
it('groups tasks under projects in project order and never drops orphans', () => {
|
|
const projects: Project[] = [
|
|
{ id: 'p2', name: '乙', createdAt: '', updatedAt: '' },
|
|
{ id: 'p1', name: '甲', createdAt: '', updatedAt: '' },
|
|
]
|
|
const tasks = [conv('a', 'p1', '2026-09-30T10:00:00Z'), conv('b', 'p2', '2026-09-29T10:00:00Z'), conv('c', 'pX', '2026-09-28T10:00:00Z')]
|
|
const groups = groupByProject(tasks, projects)
|
|
expect(groups.map(g => g.label)).toEqual(['乙', '甲', '未分组'])
|
|
expect(groups[0].projectId).toBe('p2')
|
|
expect(groups[2].tasks.map(t => t.id)).toEqual(['c'])
|
|
})
|
|
it('skips projects with zero visible tasks', () => {
|
|
const projects: Project[] = [{ id: 'p1', name: '甲', createdAt: '', updatedAt: '' }]
|
|
expect(groupByProject([], projects)).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('groupByTimeline', () => {
|
|
it('buckets by local day and week boundaries', () => {
|
|
// NOW=周三 15:00(本地);构造今天/昨天/本周更早(周一)/更早 各一条。
|
|
const tasks = [
|
|
conv('today', 'p', new Date(NOW - H).toISOString()),
|
|
conv('yesterday', 'p', new Date(NOW - 26 * H).toISOString()),
|
|
conv('week', 'p', new Date(NOW - 50 * H).toISOString()),
|
|
conv('older', 'p', new Date(NOW - 10 * 24 * H).toISOString()),
|
|
]
|
|
const groups = groupByTimeline(tasks, NOW)
|
|
expect(groups.map(g => g.label)).toEqual(['今天', '昨天', '本周更早', '更早'])
|
|
expect(groups[0].tasks[0].id).toBe('today')
|
|
})
|
|
it('hides empty buckets and tolerates bad timestamps', () => {
|
|
const groups = groupByTimeline([conv('x', 'p', 'not-a-date')], NOW)
|
|
expect(groups).toHaveLength(1)
|
|
expect(groups[0].label).toBe('更早')
|
|
})
|
|
})
|
|
|
|
describe('relativeTime', () => {
|
|
it('formats gradients and year rollover', () => {
|
|
expect(relativeTime(new Date(NOW - 30_000).toISOString(), NOW)).toBe('刚刚')
|
|
expect(relativeTime(new Date(NOW - 5 * 60_000).toISOString(), NOW)).toBe('5 分钟')
|
|
expect(relativeTime(new Date(NOW - 3 * H).toISOString(), NOW)).toBe('3 小时')
|
|
expect(relativeTime(new Date(NOW - 30 * H).toISOString(), NOW)).toBe('昨天')
|
|
expect(relativeTime(new Date(NOW - 3 * 24 * H).toISOString(), NOW)).toBe('3 天')
|
|
expect(relativeTime('2026-05-01T00:00:00', NOW)).toBe('5/1')
|
|
expect(relativeTime('2025-12-31T00:00:00', NOW)).toBe('2025/12/31')
|
|
expect(relativeTime('bad')).toBe('')
|
|
})
|
|
})
|
|
|
|
describe('filterTasks', () => {
|
|
it('matches title case-insensitively and passes through on empty query', () => {
|
|
const tasks = [conv('a', 'p', '', { title: '周报 Draft' }), conv('b', 'p', '', { title: '接口评审' })]
|
|
expect(filterTasks(tasks, '周报')).toEqual([tasks[0]])
|
|
expect(filterTasks(tasks, ' ')).toEqual(tasks)
|
|
expect(filterTasks(tasks, 'DRAFT')).toEqual([tasks[0]])
|
|
})
|
|
})
|