taskgenius_taskgenius-plugin/src/__tests__/TimelineSidebarView.test.ts
Quorafind f28ea8e9c0 refactor(v10): modularize entry + gantt year-view and project grouping fixes
Decompose the monolithic plugin entry into bootstrap modules and extracted
pure functions (src/bootstrap, src/modules), advance dataflow project
resolution, and reconcile the Phase 0 contract tests.

Fixes bundled in:
- gantt: fix year-zoom timeline so year/month labels render and a full year
  fits on screen. Major-label gate no longer keyed on single-day width; add
  a Year minor-label branch; formatMinorTick('Year') returns a real month
  name; lower MIN_DAY_WIDTH to 2; drop per-render logging; register the
  offscreen-indicator click handlers once to stop a listener leak.
- project: stop the Projects view fragmenting into one task per file.
  determineTgProject() gains an applyDefaultNaming option (default true,
  preserving the public API / File Source / unit tests); the dataflow cache
  and worker-sync paths pass false so they match ProjectData.worker.
- build: restore RegExpCursor in progress-bar-widget (the SearchCursor
  migration was incomplete -- SearchCursor cannot do regex search).

Pre-commit hook (npm run build) verified manually; hook could not spawn on
this Windows Git Bash setup. Tests: 1718 passing; production build green.
2026-06-28 16:09:40 +08:00

387 lines
14 KiB
TypeScript

import { Task } from '../types/task';
import { TimelineSidebarView } from '../components/features/timeline-sidebar/TimelineSidebarView';
// Mock translations first
jest.mock('../translations/helper', () => ({
t: jest.fn((key: string) => key),
}));
// Mock all Obsidian dependencies
jest.mock('obsidian', () => {
const actualMoment = jest.requireActual('moment');
const mockMoment = jest.fn().mockImplementation((date?: any) => {
return actualMoment(date);
});
// Add moment methods
mockMoment.locale = jest.fn(() => 'en');
mockMoment.format = actualMoment.format;
return {
ItemView: class MockItemView {
constructor(leaf: any) {}
getViewType() { return 'mock'; }
getDisplayText() { return 'Mock'; }
getIcon() { return 'mock'; }
},
moment: mockMoment,
setIcon: jest.fn(),
debounce: jest.fn((fn: any) => fn),
Component: class MockComponent {},
ButtonComponent: class MockButtonComponent {},
Platform: {},
TFile: class MockTFile {},
EditorSuggest: class MockEditorSuggest {},
AbstractInputSuggest: class MockAbstractInputSuggest {},
App: class MockApp {},
Modal: class MockModal {},
Setting: class MockSetting {},
PluginSettingTab: class MockPluginSettingTab {},
};
});
// Mock other dependencies
jest.mock('../components/features/quick-capture/modals/QuickCaptureModal', () => ({
QuickCaptureModal: class MockQuickCaptureModal {},
}));
jest.mock('../editor-extensions/core/markdown-editor', () => ({
createEmbeddableMarkdownEditor: jest.fn(),
}));
jest.mock('../utils/file/file-operations', () => ({
saveCapture: jest.fn(),
}));
jest.mock('../components/features/task/view/details', () => ({
createTaskCheckbox: jest.fn(),
}));
jest.mock('@/components/ui/renderers/MarkdownRenderer', () => ({
MarkdownRendererComponent: class MockMarkdownRendererComponent {},
}));
const actualMoment = jest.requireActual('moment');
const moment = actualMoment;
// Mock plugin and dependencies
const mockPlugin = {
taskManager: {
getAllTasks: jest.fn(() => []),
updateTask: jest.fn(),
},
dataflowOrchestrator: {
getQueryAPI: jest.fn(() => ({
getAllTasks: mockPlugin.taskManager.getAllTasks,
})),
},
settings: {
timelineSidebar: {
showCompletedTasks: true,
},
taskStatuses: {
completed: 'x',
notStarted: ' ',
abandoned: '-',
},
quickCapture: {
targetType: 'file',
targetFile: 'test.md',
},
},
app: {
vault: {
on: jest.fn(),
getFileByPath: jest.fn(),
},
workspace: {
on: jest.fn(),
getLeavesOfType: jest.fn(() => []),
getLeaf: jest.fn(),
setActiveLeaf: jest.fn(),
},
},
};
const mockLeaf = {
view: null,
};
describe('TimelineSidebarView Date Deduplication', () => {
let timelineView: TimelineSidebarView;
beforeEach(() => {
jest.clearAllMocks();
timelineView = new TimelineSidebarView(mockLeaf as any, mockPlugin as any);
});
// Helper function to create a mock task
const createMockTask = (
id: string,
content: string,
metadata: Partial<Task['metadata']> = {}
): Task => ({
id,
content,
filePath: 'test.md',
line: 1,
status: ' ',
completed: false,
metadata: {
dueDate: undefined,
scheduledDate: undefined,
startDate: undefined,
completedDate: undefined,
tags: [],
...metadata,
},
} as Task);
describe('deduplicateDatesByPriority', () => {
it('should return empty array for empty input', () => {
const result = (timelineView as any).deduplicateDatesByPriority([]);
expect(result).toEqual([]);
});
it('should return single date unchanged', () => {
const dates = [{ date: new Date('2025-01-15'), type: 'due' }];
const result = (timelineView as any).deduplicateDatesByPriority(dates);
expect(result).toEqual(dates);
});
it('should keep different dates on different days', () => {
const dates = [
{ date: new Date('2025-01-15'), type: 'due' },
{ date: new Date('2025-01-16'), type: 'scheduled' },
];
const result = (timelineView as any).deduplicateDatesByPriority(dates);
expect(result).toHaveLength(2);
expect(result).toEqual(expect.arrayContaining(dates));
});
it('should prioritize due over completed on same day', () => {
const dates = [
{ date: new Date('2025-01-15T10:00:00'), type: 'due' },
{ date: new Date('2025-01-15T14:00:00'), type: 'completed' },
];
const result = (timelineView as any).deduplicateDatesByPriority(dates);
expect(result).toHaveLength(1);
expect(result[0].type).toBe('due');
});
it('should prioritize due over scheduled on same day', () => {
const dates = [
{ date: new Date('2025-01-15T10:00:00'), type: 'scheduled' },
{ date: new Date('2025-01-15T14:00:00'), type: 'due' },
];
const result = (timelineView as any).deduplicateDatesByPriority(dates);
expect(result).toHaveLength(1);
expect(result[0].type).toBe('due');
});
it('should prioritize scheduled over start on same day', () => {
const dates = [
{ date: new Date('2025-01-15T10:00:00'), type: 'start' },
{ date: new Date('2025-01-15T14:00:00'), type: 'scheduled' },
];
const result = (timelineView as any).deduplicateDatesByPriority(dates);
expect(result).toHaveLength(1);
expect(result[0].type).toBe('scheduled');
});
it('should handle multiple date types with correct priority order', () => {
const dates = [
{ date: new Date('2025-01-15T08:00:00'), type: 'start' },
{ date: new Date('2025-01-15T10:00:00'), type: 'scheduled' },
{ date: new Date('2025-01-15T12:00:00'), type: 'due' },
{ date: new Date('2025-01-15T16:00:00'), type: 'completed' },
];
const result = (timelineView as any).deduplicateDatesByPriority(dates);
expect(result).toHaveLength(1);
expect(result[0].type).toBe('due');
});
it('should handle mixed same-day and different-day dates', () => {
const dates = [
{ date: new Date('2025-01-15T10:00:00'), type: 'due' },
{ date: new Date('2025-01-15T14:00:00'), type: 'completed' },
{ date: new Date('2025-01-16T10:00:00'), type: 'scheduled' },
{ date: new Date('2025-01-17T10:00:00'), type: 'start' },
];
const result = (timelineView as any).deduplicateDatesByPriority(dates);
expect(result).toHaveLength(3);
const jan15Result = result.find((d: any) => moment(d.date).format('YYYY-MM-DD') === '2025-01-15');
const jan16Result = result.find((d: any) => moment(d.date).format('YYYY-MM-DD') === '2025-01-16');
const jan17Result = result.find((d: any) => moment(d.date).format('YYYY-MM-DD') === '2025-01-17');
expect(jan15Result?.type).toBe('due');
expect(jan16Result?.type).toBe('scheduled');
expect(jan17Result?.type).toBe('start');
});
});
describe('extractDatesFromTask', () => {
it('should return empty array for task with no dates', () => {
const task = createMockTask('test-1', 'Test task');
const result = (timelineView as any).extractDatesFromTask(task);
expect(result).toEqual([]);
});
it('should return single date for task with one date type', () => {
const dueDate = new Date('2025-01-15').getTime();
const task = createMockTask('test-1', 'Test task', { dueDate });
const result = (timelineView as any).extractDatesFromTask(task);
expect(result).toHaveLength(1);
expect(result[0].type).toBe('due');
});
// New tests for task-level deduplication
describe('completed task behavior', () => {
it('should return due date for completed task with due date', () => {
const task = createMockTask('test-1', 'Test task', {
dueDate: new Date('2025-01-15T10:00:00').getTime(),
completedDate: new Date('2025-01-16T16:00:00').getTime(),
});
task.completed = true;
const result = (timelineView as any).extractDatesFromTask(task);
expect(result).toHaveLength(1);
expect(result[0].type).toBe('due');
});
it('should return completed date for completed task without due date', () => {
const task = createMockTask('test-1', 'Test task', {
scheduledDate: new Date('2025-01-14T10:00:00').getTime(),
completedDate: new Date('2025-01-16T16:00:00').getTime(),
});
task.completed = true;
const result = (timelineView as any).extractDatesFromTask(task);
expect(result).toHaveLength(1);
expect(result[0].type).toBe('completed');
});
it('should always return due date for completed task regardless of other dates', () => {
const task = createMockTask('test-1', 'Test task', {
startDate: new Date('2025-01-13T08:00:00').getTime(),
scheduledDate: new Date('2025-01-14T10:00:00').getTime(),
dueDate: new Date('2025-01-15T12:00:00').getTime(),
completedDate: new Date('2025-01-16T16:00:00').getTime(),
});
task.completed = true;
const result = (timelineView as any).extractDatesFromTask(task);
expect(result).toHaveLength(1);
expect(result[0].type).toBe('due');
});
});
describe('non-completed task behavior', () => {
it('should return highest priority date for non-completed task with multiple dates', () => {
const task = createMockTask('test-1', 'Test task', {
startDate: new Date('2025-01-13T08:00:00').getTime(),
scheduledDate: new Date('2025-01-14T10:00:00').getTime(),
dueDate: new Date('2025-01-15T12:00:00').getTime(),
});
const result = (timelineView as any).extractDatesFromTask(task);
expect(result).toHaveLength(1);
expect(result[0].type).toBe('due');
});
it('should return scheduled date when no due date exists', () => {
const task = createMockTask('test-1', 'Test task', {
startDate: new Date('2025-01-13T08:00:00').getTime(),
scheduledDate: new Date('2025-01-14T10:00:00').getTime(),
});
const result = (timelineView as any).extractDatesFromTask(task);
expect(result).toHaveLength(1);
expect(result[0].type).toBe('scheduled');
});
it('should return start date when only start date exists', () => {
const task = createMockTask('test-1', 'Test task', {
startDate: new Date('2025-01-13T08:00:00').getTime(),
});
const result = (timelineView as any).extractDatesFromTask(task);
expect(result).toHaveLength(1);
expect(result[0].type).toBe('start');
});
});
});
describe('loadEvents - Abandoned Tasks Filtering', () => {
it('should filter out abandoned tasks when showCompletedTasks is false', async () => {
// Create mock tasks with different statuses
const mockTasks = [
createMockTask('task-1', 'Active task', { dueDate: new Date('2025-01-15').getTime() }),
{ ...createMockTask('task-2', 'Completed task', { dueDate: new Date('2025-01-15').getTime() }), completed: true, status: 'x' },
{ ...createMockTask('task-3', 'Abandoned task', { dueDate: new Date('2025-01-15').getTime() }), status: '-' },
{ ...createMockTask('task-4', 'In progress task', { dueDate: new Date('2025-01-15').getTime() }), status: '/' },
];
// Configure plugin to not show completed tasks
mockPlugin.settings.timelineSidebar.showCompletedTasks = false;
mockPlugin.taskManager.getAllTasks.mockReturnValue(mockTasks);
// Create a new view instance and call loadEvents
const view = new TimelineSidebarView(mockLeaf as any, mockPlugin as any);
await (view as any).loadEvents();
// Check that only active and in-progress tasks are included
const events = (view as any).events;
expect(events).toHaveLength(2);
expect(events.map((e: any) => e.task?.content)).toEqual(
expect.arrayContaining(['Active task', 'In progress task'])
);
expect(events.map((e: any) => e.task?.content)).not.toContain('Completed task');
expect(events.map((e: any) => e.task?.content)).not.toContain('Abandoned task');
});
it('should show all tasks including abandoned when showCompletedTasks is true', async () => {
// Create mock tasks with different statuses
const mockTasks = [
createMockTask('task-1', 'Active task', { dueDate: new Date('2025-01-15').getTime() }),
{ ...createMockTask('task-2', 'Completed task', { dueDate: new Date('2025-01-15').getTime() }), completed: true, status: 'x' },
{ ...createMockTask('task-3', 'Abandoned task', { dueDate: new Date('2025-01-15').getTime() }), status: '-' },
];
// Configure plugin to show completed tasks
mockPlugin.settings.timelineSidebar.showCompletedTasks = true;
mockPlugin.taskManager.getAllTasks.mockReturnValue(mockTasks);
// Create a new view instance and call loadEvents
const view = new TimelineSidebarView(mockLeaf as any, mockPlugin as any);
await (view as any).loadEvents();
// Check that all tasks are included
const events = (view as any).events;
expect(events).toHaveLength(3);
expect(events.map((e: any) => e.task?.content)).toEqual(
expect.arrayContaining(['Active task', 'Completed task', 'Abandoned task'])
);
});
it('should handle multiple abandoned status markers', async () => {
// Set multiple abandoned status markers
mockPlugin.settings.taskStatuses.abandoned = '-|_|>';
// Create mock tasks with different abandoned statuses
const mockTasks = [
createMockTask('task-1', 'Active task', { dueDate: new Date('2025-01-15').getTime() }),
{ ...createMockTask('task-2', 'Abandoned with dash', { dueDate: new Date('2025-01-15').getTime() }), status: '-' },
{ ...createMockTask('task-3', 'Abandoned with underscore', { dueDate: new Date('2025-01-15').getTime() }), status: '_' },
{ ...createMockTask('task-4', 'Abandoned with arrow', { dueDate: new Date('2025-01-15').getTime() }), status: '>' },
];
// Configure plugin to not show completed tasks
mockPlugin.settings.timelineSidebar.showCompletedTasks = false;
mockPlugin.taskManager.getAllTasks.mockReturnValue(mockTasks);
// Create a new view instance and call loadEvents
const view = new TimelineSidebarView(mockLeaf as any, mockPlugin as any);
await (view as any).loadEvents();
// Check that only the active task is included
const events = (view as any).events;
expect(events).toHaveLength(1);
expect(events[0].task?.content).toBe('Active task');
});
});
});