mirror of
https://github.com/taskgenius/taskgenius-plugin.git
synced 2026-07-22 06:40:25 +00:00
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.
387 lines
14 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|