mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
Resolves issues where total tracked time was showing on task cards even when the property wasn't explicitly enabled, and ensures it only displays when the value is greater than 0 minutes. Changes: - Remove legacy fallback that automatically displayed time tracking info - Add > 0 check to totalTrackedTime property renderer - Update tests to reflect new expected behavior - Add tests for explicit property enabling and zero-value handling Now total tracked time only appears when: 1. The totalTrackedTime property is explicitly enabled in settings/filter 2. The tracked time value is greater than 0 minutes This gives users proper control over when time tracking information is displayed on their task cards.
1024 lines
No EOL
37 KiB
TypeScript
1024 lines
No EOL
37 KiB
TypeScript
/**
|
|
* TaskCard Component Tests
|
|
*
|
|
* Tests for the TaskCard UI component including:
|
|
* - Task card creation with various options
|
|
* - Status and priority indicators
|
|
* - Interactive elements (checkboxes, context menus)
|
|
* - Recurring task UI behavior
|
|
* - Event handlers and user interactions
|
|
* - Task card updates and reconciliation
|
|
* - Context menu functionality
|
|
* - Error handling and edge cases
|
|
*/
|
|
|
|
import {
|
|
createTaskCard,
|
|
updateTaskCard,
|
|
showTaskContextMenu,
|
|
showDeleteConfirmationModal,
|
|
TaskCardOptions,
|
|
DEFAULT_TASK_CARD_OPTIONS
|
|
} from '../../../src/ui/TaskCard';
|
|
|
|
import { TaskInfo } from '../../../src/types';
|
|
import { TaskFactory } from '../../helpers/mock-factories';
|
|
import { MockObsidian, TFile, Menu, Notice, Modal, App } from '../../__mocks__/obsidian';
|
|
|
|
// Mock external dependencies
|
|
jest.mock('obsidian');
|
|
jest.mock('date-fns', () => ({
|
|
format: jest.fn((date: Date, formatStr: string) => {
|
|
if (formatStr === 'yyyy-MM-dd') {
|
|
return date.toISOString().split('T')[0];
|
|
}
|
|
return date.toISOString();
|
|
})
|
|
}));
|
|
|
|
// Mock helper functions
|
|
jest.mock('../../../src/utils/helpers', () => ({
|
|
calculateTotalTimeSpent: jest.fn((entries) => entries?.length ? entries.length * 30 : 0),
|
|
getEffectiveTaskStatus: jest.fn((task, date) => {
|
|
if (task.recurrence && task.complete_instances?.includes('2025-01-15')) {
|
|
return 'done';
|
|
}
|
|
return task.status || 'open';
|
|
}),
|
|
shouldUseRecurringTaskUI: jest.fn((task) => !!task.recurrence),
|
|
getRecurringTaskCompletionText: jest.fn(() => 'Not completed for this date'),
|
|
getRecurrenceDisplayText: jest.fn((recurrence) => 'Daily'),
|
|
filterEmptyProjects: jest.fn((projects) => projects?.filter(p => p && p.trim()) || [])
|
|
}));
|
|
|
|
jest.mock('../../../src/utils/dateUtils', () => ({
|
|
isTodayTimeAware: jest.fn((date) => date === '2025-01-15'),
|
|
isOverdueTimeAware: jest.fn((date) => date === '2020-01-01'),
|
|
formatDateTimeForDisplay: jest.fn((date, options) => {
|
|
if (options?.dateFormat === '') return '2:30 PM';
|
|
if (date === '2025-01-15T14:30:00') return 'Jan 15, 2025 2:30 PM';
|
|
return 'Jan 15, 2025';
|
|
}),
|
|
getDatePart: jest.fn((date) => date?.split('T')[0] || ''),
|
|
getTimePart: jest.fn((date) => date?.includes('T') ? date.split('T')[1]?.split(':').slice(0, 2).join(':') : null)
|
|
}));
|
|
|
|
// Mock TaskContextMenu to use the mocked Menu internally
|
|
let lastMenuInstance: any = null;
|
|
|
|
jest.mock('../../../src/components/TaskContextMenu', () => {
|
|
return {
|
|
TaskContextMenu: jest.fn().mockImplementation((options) => {
|
|
// Create a new Menu instance (which will be mocked)
|
|
const { Menu } = require('obsidian');
|
|
const menuInstance = new Menu();
|
|
lastMenuInstance = menuInstance;
|
|
|
|
// Simulate basic menu building - always add at least one item
|
|
menuInstance.addItem(() => {});
|
|
|
|
// Simulate the actual TaskContextMenu behavior for recurring tasks
|
|
if (options.task.recurrence) {
|
|
menuInstance.addSeparator();
|
|
}
|
|
|
|
// Simulate the actual TaskContextMenu behavior
|
|
const mockTaskContextMenu = {
|
|
show: jest.fn((event) => {
|
|
return menuInstance.showAtMouseEvent(event);
|
|
})
|
|
};
|
|
|
|
return mockTaskContextMenu;
|
|
})
|
|
};
|
|
});
|
|
|
|
describe('TaskCard Component', () => {
|
|
let mockPlugin: any;
|
|
let mockApp: any;
|
|
let container: HTMLElement;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
MockObsidian.reset();
|
|
|
|
// Create DOM container
|
|
document.body.innerHTML = '';
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
|
|
// Mock plugin
|
|
mockApp = new App();
|
|
mockPlugin = {
|
|
app: mockApp,
|
|
selectedDate: new Date('2025-01-15'),
|
|
statusManager: {
|
|
isCompletedStatus: jest.fn((status) => status === 'done'),
|
|
getStatusConfig: jest.fn((status) => ({
|
|
value: status,
|
|
label: status,
|
|
color: '#666666'
|
|
})),
|
|
getAllStatuses: jest.fn(() => [
|
|
{ value: 'open', label: 'Open' },
|
|
{ value: 'done', label: 'Done' }
|
|
]),
|
|
getNonCompletionStatuses: jest.fn(() => [
|
|
{ value: 'open', label: 'Open' },
|
|
{ value: 'in-progress', label: 'In Progress' }
|
|
]),
|
|
getNextStatus: jest.fn(() => 'done')
|
|
},
|
|
priorityManager: {
|
|
getPriorityConfig: jest.fn((priority) => ({
|
|
value: priority,
|
|
label: priority,
|
|
color: '#ff0000'
|
|
})),
|
|
getPrioritiesByWeight: jest.fn(() => [
|
|
{ value: 'high', label: 'High' },
|
|
{ value: 'normal', label: 'Normal' }
|
|
])
|
|
},
|
|
getActiveTimeSession: jest.fn(() => null),
|
|
toggleTaskStatus: jest.fn(),
|
|
toggleRecurringTaskComplete: jest.fn(),
|
|
updateTaskProperty: jest.fn(),
|
|
openTaskEditModal: jest.fn(),
|
|
openDueDateModal: jest.fn(),
|
|
openScheduledDateModal: jest.fn(),
|
|
startTimeTracking: jest.fn(),
|
|
stopTimeTracking: jest.fn(),
|
|
toggleTaskArchive: jest.fn(),
|
|
formatTime: jest.fn((minutes) => `${minutes}m`),
|
|
cacheManager: {
|
|
getTaskInfo: jest.fn()
|
|
},
|
|
taskService: {
|
|
deleteTask: jest.fn()
|
|
},
|
|
projectSubtasksService: {
|
|
isTaskUsedAsProject: jest.fn().mockResolvedValue(false)
|
|
},
|
|
settings: {
|
|
singleClickAction: 'edit',
|
|
doubleClickAction: 'none',
|
|
showExpandableSubtasks: true,
|
|
subtaskChevronPosition: 'right'
|
|
}
|
|
};
|
|
|
|
// Ensure app has proper mock methods - use the same mockApp variable
|
|
mockApp.vault = mockApp.vault || {};
|
|
mockApp.vault.getAbstractFileByPath = jest.fn();
|
|
mockApp.workspace = mockApp.workspace || {};
|
|
mockApp.workspace.getLeaf = jest.fn().mockReturnValue({
|
|
openFile: jest.fn()
|
|
});
|
|
mockApp.workspace.trigger = jest.fn();
|
|
mockApp.metadataCache = mockApp.metadataCache || {};
|
|
mockApp.metadataCache.getFirstLinkpathDest = jest.fn();
|
|
|
|
// Mock console methods
|
|
jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
jest.spyOn(console, 'debug').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.restoreAllMocks();
|
|
document.body.innerHTML = '';
|
|
});
|
|
|
|
describe('createTaskCard', () => {
|
|
it('should create basic task card', () => {
|
|
const task = TaskFactory.createTask({
|
|
title: 'Test Task',
|
|
status: 'open',
|
|
priority: 'high'
|
|
});
|
|
|
|
const card = createTaskCard(task, mockPlugin);
|
|
|
|
expect(card).toBeInstanceOf(HTMLElement);
|
|
expect(card.classList.contains('task-card')).toBe(true);
|
|
expect(card.classList.contains('task-card--priority-high')).toBe(true);
|
|
expect(card.classList.contains('task-card--status-open')).toBe(true);
|
|
expect(card.dataset.taskPath).toBe(task.path);
|
|
expect(card.dataset.status).toBe('open');
|
|
});
|
|
|
|
it('should create task card with all default options', () => {
|
|
const task = TaskFactory.createTask();
|
|
const card = createTaskCard(task, mockPlugin);
|
|
|
|
// Should not have checkbox by default
|
|
expect(card.querySelector('.task-card__checkbox')).toBeNull();
|
|
|
|
// Should have status dot
|
|
expect(card.querySelector('.task-card__status-dot')).toBeTruthy();
|
|
|
|
// Should have title
|
|
const titleEl = card.querySelector('.task-card__title');
|
|
expect(titleEl?.textContent).toBe(task.title);
|
|
});
|
|
|
|
it('should create task card with checkbox when enabled', () => {
|
|
const task = TaskFactory.createTask({ status: 'done' });
|
|
const options: Partial<TaskCardOptions> = { showCheckbox: true };
|
|
|
|
const card = createTaskCard(task, mockPlugin, undefined, options);
|
|
|
|
const checkbox = card.querySelector('.task-card__checkbox') as HTMLInputElement;
|
|
expect(checkbox).toBeTruthy();
|
|
expect(checkbox.checked).toBe(true);
|
|
expect(card.classList.contains('task-card--checkbox-enabled')).toBe(true);
|
|
});
|
|
|
|
it('should create recurring task card', () => {
|
|
const task = TaskFactory.createRecurringTask('FREQ=DAILY');
|
|
const card = createTaskCard(task, mockPlugin);
|
|
|
|
expect(card.classList.contains('task-card--recurring')).toBe(true);
|
|
expect(card.querySelector('.task-card__recurring-indicator')).toBeTruthy();
|
|
});
|
|
|
|
it('should create completed task card', () => {
|
|
const task = TaskFactory.createTask({ status: 'done' });
|
|
const card = createTaskCard(task, mockPlugin);
|
|
|
|
expect(card.classList.contains('task-card--completed')).toBe(true);
|
|
|
|
const titleEl = card.querySelector('.task-card__title');
|
|
expect(titleEl?.classList.contains('completed')).toBe(true);
|
|
});
|
|
|
|
it('should create archived task card', () => {
|
|
const task = TaskFactory.createTask({ archived: true });
|
|
const card = createTaskCard(task, mockPlugin);
|
|
|
|
expect(card.classList.contains('task-card--archived')).toBe(true);
|
|
});
|
|
|
|
it('should create actively tracked task card', () => {
|
|
const task = TaskFactory.createTask();
|
|
mockPlugin.getActiveTimeSession.mockReturnValue({
|
|
startTime: '2025-01-15T10:00:00Z'
|
|
});
|
|
|
|
const card = createTaskCard(task, mockPlugin);
|
|
|
|
expect(card.classList.contains('task-card--actively-tracked')).toBe(true);
|
|
});
|
|
|
|
it('should add has-projects class for tasks with projects (issue #355)', () => {
|
|
const taskWithProjects = TaskFactory.createTask({
|
|
projects: ['[[My Project]]', 'Another Project']
|
|
});
|
|
const cardWithProjects = createTaskCard(taskWithProjects, mockPlugin);
|
|
|
|
expect(cardWithProjects.classList.contains('task-card--has-projects')).toBe(true);
|
|
|
|
const taskWithoutProjects = TaskFactory.createTask({
|
|
projects: []
|
|
});
|
|
const cardWithoutProjects = createTaskCard(taskWithoutProjects, mockPlugin);
|
|
|
|
expect(cardWithoutProjects.classList.contains('task-card--has-projects')).toBe(false);
|
|
|
|
const taskWithEmptyProjects = TaskFactory.createTask({
|
|
projects: ['', ' ', null as any, undefined as any]
|
|
});
|
|
const cardWithEmptyProjects = createTaskCard(taskWithEmptyProjects, mockPlugin);
|
|
|
|
expect(cardWithEmptyProjects.classList.contains('task-card--has-projects')).toBe(false);
|
|
});
|
|
|
|
it('should apply priority and status colors', () => {
|
|
const task = TaskFactory.createTask({
|
|
status: 'open',
|
|
priority: 'high'
|
|
});
|
|
|
|
const card = createTaskCard(task, mockPlugin);
|
|
|
|
expect(card.style.getPropertyValue('--priority-color')).toBe('#ff0000');
|
|
expect(card.style.getPropertyValue('--current-status-color')).toBe('#666666');
|
|
});
|
|
|
|
it('should create priority indicator dot', () => {
|
|
const task = TaskFactory.createTask({ priority: 'high' });
|
|
const card = createTaskCard(task, mockPlugin);
|
|
|
|
const priorityDot = card.querySelector('.task-card__priority-dot') as HTMLElement;
|
|
expect(priorityDot).toBeTruthy();
|
|
expect(priorityDot.style.borderColor).toBe('rgb(255, 0, 0)');
|
|
expect(priorityDot.getAttribute('aria-label')).toBe('Priority: high');
|
|
});
|
|
|
|
it('should create metadata line with various task properties', () => {
|
|
const task = TaskFactory.createTask({
|
|
due: '2025-01-15T14:30:00',
|
|
scheduled: '2025-01-15',
|
|
contexts: ['work', 'urgent'],
|
|
timeEstimate: 60,
|
|
timeEntries: [{ startTime: '2025-01-15T10:00:00Z', endTime: '2025-01-15T10:30:00Z' }]
|
|
});
|
|
|
|
const card = createTaskCard(task, mockPlugin);
|
|
const metadataLine = card.querySelector('.task-card__metadata');
|
|
|
|
expect(metadataLine?.textContent).toContain('Due:');
|
|
expect(metadataLine?.textContent).toContain('Scheduled:');
|
|
expect(metadataLine?.textContent).toContain('@work, @urgent');
|
|
// Time tracking info should only show when explicitly configured as visible properties
|
|
expect(metadataLine?.textContent).not.toContain('30m spent');
|
|
expect(metadataLine?.textContent).not.toContain('60m estimated');
|
|
});
|
|
|
|
it('should show time tracking properties when explicitly enabled', () => {
|
|
const task = TaskFactory.createTask({
|
|
timeEstimate: 60,
|
|
timeEntries: [{ startTime: '2025-01-15T10:00:00Z', endTime: '2025-01-15T10:30:00Z' }],
|
|
totalTrackedTime: 30
|
|
});
|
|
|
|
// Test with timeEstimate and totalTrackedTime properties explicitly enabled
|
|
const visibleProperties = ['timeEstimate', 'totalTrackedTime'];
|
|
const card = createTaskCard(task, mockPlugin, visibleProperties);
|
|
const metadataLine = card.querySelector('.task-card__metadata');
|
|
|
|
expect(metadataLine?.textContent).toContain('60m estimated');
|
|
expect(metadataLine?.textContent).toContain('30m tracked');
|
|
});
|
|
|
|
it('should not show totalTrackedTime when value is 0', () => {
|
|
const task = TaskFactory.createTask({
|
|
totalTrackedTime: 0
|
|
});
|
|
|
|
// Enable totalTrackedTime property but value is 0
|
|
const visibleProperties = ['totalTrackedTime'];
|
|
const card = createTaskCard(task, mockPlugin, visibleProperties);
|
|
const metadataLine = card.querySelector('.task-card__metadata');
|
|
|
|
expect(metadataLine?.textContent).not.toContain('tracked');
|
|
});
|
|
|
|
it('should create clickable project links for wikilink projects', () => {
|
|
const task = TaskFactory.createTask({
|
|
projects: ['[[Project A]]', '[[Project B]]', 'regular-project']
|
|
});
|
|
|
|
const card = createTaskCard(task, mockPlugin);
|
|
const metadataLine = card.querySelector('.task-card__metadata');
|
|
|
|
// Check that project links are rendered
|
|
expect(metadataLine?.textContent).toContain('+Project A');
|
|
expect(metadataLine?.textContent).toContain('+Project B');
|
|
expect(metadataLine?.textContent).toContain('+regular-project');
|
|
|
|
// Check that wikilink projects have clickable links
|
|
const projectLinks = card.querySelectorAll('.task-card__project-link');
|
|
expect(projectLinks.length).toBe(2); // Only wikilink projects should have clickable links
|
|
|
|
// Check link properties
|
|
expect(projectLinks[0].textContent).toBe('Project A');
|
|
expect(projectLinks[0].getAttribute('data-href')).toBe('Project A');
|
|
expect(projectLinks[0].classList.contains('internal-link')).toBe(true);
|
|
|
|
expect(projectLinks[1].textContent).toBe('Project B');
|
|
expect(projectLinks[1].getAttribute('data-href')).toBe('Project B');
|
|
expect(projectLinks[1].classList.contains('internal-link')).toBe(true);
|
|
});
|
|
|
|
it('should handle project link clicks and open files', async () => {
|
|
const task = TaskFactory.createTask({
|
|
projects: ['[[Test Project]]']
|
|
});
|
|
|
|
const mockFile = new TFile('test-project.md');
|
|
mockPlugin.app.metadataCache.getFirstLinkpathDest = jest.fn().mockReturnValue(mockFile);
|
|
|
|
const card = createTaskCard(task, mockPlugin);
|
|
const projectLink = card.querySelector('.task-card__project-link') as HTMLElement;
|
|
|
|
expect(projectLink).toBeTruthy();
|
|
|
|
// Simulate click
|
|
const clickEvent = new MouseEvent('click', { bubbles: true });
|
|
projectLink.dispatchEvent(clickEvent);
|
|
|
|
// Wait for async operations
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
|
|
expect(mockPlugin.app.metadataCache.getFirstLinkpathDest).toHaveBeenCalledWith('Test Project', '');
|
|
expect(mockPlugin.app.workspace.getLeaf).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it('should show notice when project link file not found', async () => {
|
|
const task = TaskFactory.createTask({
|
|
projects: ['[[Nonexistent Project]]']
|
|
});
|
|
|
|
mockPlugin.app.metadataCache.getFirstLinkpathDest = jest.fn().mockReturnValue(null);
|
|
|
|
const card = createTaskCard(task, mockPlugin);
|
|
const projectLink = card.querySelector('.task-card__project-link') as HTMLElement;
|
|
|
|
expect(projectLink).toBeTruthy();
|
|
|
|
// Simulate click
|
|
const clickEvent = new MouseEvent('click', { bubbles: true });
|
|
projectLink.dispatchEvent(clickEvent);
|
|
|
|
// Wait for async operations
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
|
|
expect(mockPlugin.app.metadataCache.getFirstLinkpathDest).toHaveBeenCalledWith('Nonexistent Project', '');
|
|
expect(Notice).toHaveBeenCalledWith('Note "Nonexistent Project" not found');
|
|
});
|
|
|
|
it('should hide metadata line when no metadata available', () => {
|
|
const task = TaskFactory.createTask({
|
|
due: undefined,
|
|
scheduled: undefined,
|
|
contexts: undefined,
|
|
timeEstimate: undefined,
|
|
timeEntries: undefined
|
|
});
|
|
|
|
const card = createTaskCard(task, mockPlugin);
|
|
const metadataLine = card.querySelector('.task-card__metadata') as HTMLElement;
|
|
|
|
// Should be hidden (either 'none' or empty string depending on browser)
|
|
expect(metadataLine.style.display === 'none' || metadataLine.style.display === '').toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Task Card Interactions', () => {
|
|
let task: TaskInfo;
|
|
let card: HTMLElement;
|
|
|
|
beforeEach(() => {
|
|
task = TaskFactory.createTask();
|
|
card = createTaskCard(task, mockPlugin, undefined, { showCheckbox: true });
|
|
container.appendChild(card);
|
|
});
|
|
|
|
it('should handle checkbox click for regular tasks', async () => {
|
|
const checkbox = card.querySelector('.task-card__checkbox') as HTMLInputElement;
|
|
|
|
checkbox.click();
|
|
|
|
expect(mockPlugin.toggleTaskStatus).toHaveBeenCalledWith(task);
|
|
});
|
|
|
|
it('should handle checkbox click for recurring tasks', async () => {
|
|
const recurringTask = TaskFactory.createRecurringTask('FREQ=DAILY');
|
|
const recurringCard = createTaskCard(recurringTask, mockPlugin, undefined, { showCheckbox: true });
|
|
const checkbox = recurringCard.querySelector('.task-card__checkbox') as HTMLInputElement;
|
|
|
|
checkbox.click();
|
|
|
|
expect(mockPlugin.toggleRecurringTaskComplete).toHaveBeenCalledWith(
|
|
recurringTask,
|
|
mockPlugin.selectedDate
|
|
);
|
|
});
|
|
|
|
it('should handle status dot click for regular tasks', async () => {
|
|
mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(task);
|
|
|
|
const statusDot = card.querySelector('.task-card__status-dot') as HTMLElement;
|
|
statusDot.click();
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
|
|
expect(mockPlugin.cacheManager.getTaskInfo).toHaveBeenCalledWith(task.path);
|
|
expect(mockPlugin.updateTaskProperty).toHaveBeenCalledWith(task, 'status', 'done');
|
|
});
|
|
|
|
it('should handle status dot click for recurring tasks', async () => {
|
|
const recurringTask = TaskFactory.createRecurringTask('FREQ=DAILY');
|
|
const recurringCard = createTaskCard(recurringTask, mockPlugin);
|
|
mockPlugin.toggleRecurringTaskComplete.mockResolvedValue(recurringTask);
|
|
|
|
const statusDot = recurringCard.querySelector('.task-card__status-dot') as HTMLElement;
|
|
statusDot.click();
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
|
|
expect(mockPlugin.toggleRecurringTaskComplete).toHaveBeenCalledWith(
|
|
recurringTask,
|
|
mockPlugin.selectedDate
|
|
);
|
|
});
|
|
|
|
it('should handle context menu icon click', async () => {
|
|
const contextIcon = card.querySelector('.task-card__context-menu') as HTMLElement;
|
|
const mockEvent = new MouseEvent('click', { bubbles: true });
|
|
|
|
// Mock showTaskContextMenu
|
|
const showTaskContextMenuSpy = jest.fn();
|
|
jest.doMock('../../../src/ui/TaskCard', () => ({
|
|
...jest.requireActual('../../../src/ui/TaskCard'),
|
|
showTaskContextMenu: showTaskContextMenuSpy
|
|
}));
|
|
|
|
contextIcon.dispatchEvent(mockEvent);
|
|
|
|
// Context menu should be triggered
|
|
expect(contextIcon).toBeTruthy();
|
|
});
|
|
|
|
it('should handle card click to open edit modal', async () => {
|
|
const clickEvent = new MouseEvent('click', { bubbles: true });
|
|
|
|
// Dispatch the event and wait for the handler
|
|
card.dispatchEvent(clickEvent);
|
|
|
|
// Wait for the async click handler to complete
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
|
|
expect(mockPlugin.openTaskEditModal).toHaveBeenCalledWith(task);
|
|
});
|
|
|
|
it('should handle Ctrl+click to open source note', async () => {
|
|
const mockFile = new TFile('test.md');
|
|
mockApp.vault.getAbstractFileByPath.mockReturnValue(mockFile);
|
|
|
|
const ctrlClickEvent = new MouseEvent('click', {
|
|
bubbles: true,
|
|
ctrlKey: true
|
|
});
|
|
|
|
card.dispatchEvent(ctrlClickEvent);
|
|
|
|
// Wait for the async click handler to complete
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
|
|
expect(mockApp.vault.getAbstractFileByPath).toHaveBeenCalledWith(task.path);
|
|
expect(mockApp.workspace.getLeaf).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it('should handle right-click context menu', async () => {
|
|
const contextMenuEvent = new MouseEvent('contextmenu', { bubbles: true });
|
|
|
|
card.dispatchEvent(contextMenuEvent);
|
|
|
|
// Should prevent default and trigger context menu
|
|
expect(card.dataset.taskPath).toBe(task.path);
|
|
});
|
|
|
|
it('should handle mouseover for hover preview', () => {
|
|
const mockFile = new TFile('test.md');
|
|
mockApp.vault.getAbstractFileByPath.mockReturnValue(mockFile);
|
|
|
|
const mouseoverEvent = new MouseEvent('mouseover', { bubbles: true });
|
|
card.dispatchEvent(mouseoverEvent);
|
|
|
|
expect(mockApp.workspace.trigger).toHaveBeenCalledWith('hover-link', {
|
|
event: mouseoverEvent,
|
|
source: 'tasknotes-task-card',
|
|
hoverParent: card,
|
|
targetEl: card,
|
|
linktext: task.path,
|
|
sourcePath: task.path
|
|
});
|
|
});
|
|
|
|
it('should handle errors in event handlers gracefully', async () => {
|
|
mockPlugin.toggleTaskStatus.mockRejectedValue(new Error('Network error'));
|
|
|
|
const checkbox = card.querySelector('.task-card__checkbox') as HTMLInputElement;
|
|
checkbox.click();
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
|
|
expect(console.error).toHaveBeenCalled();
|
|
expect(Notice).toHaveBeenCalledWith(expect.stringContaining('Failed to toggle task status'));
|
|
});
|
|
});
|
|
|
|
describe('updateTaskCard', () => {
|
|
let task: TaskInfo;
|
|
let card: HTMLElement;
|
|
|
|
beforeEach(() => {
|
|
task = TaskFactory.createTask({
|
|
title: 'Original Task',
|
|
status: 'open',
|
|
priority: 'normal'
|
|
});
|
|
card = createTaskCard(task, mockPlugin, undefined, { showCheckbox: true });
|
|
});
|
|
|
|
it('should update task card with new data', () => {
|
|
const updatedTask = TaskFactory.createTask({
|
|
...task,
|
|
title: 'Updated Task',
|
|
status: 'done',
|
|
priority: 'high'
|
|
});
|
|
|
|
updateTaskCard(card, updatedTask, mockPlugin);
|
|
|
|
expect(card.classList.contains('task-card--completed')).toBe(true);
|
|
expect(card.classList.contains('task-card--priority-high')).toBe(true);
|
|
expect(card.classList.contains('task-card--status-done')).toBe(true);
|
|
|
|
const titleEl = card.querySelector('.task-card__title');
|
|
expect(titleEl?.textContent).toBe('Updated Task');
|
|
expect(titleEl?.classList.contains('completed')).toBe(true);
|
|
});
|
|
|
|
it('should update checkbox state', () => {
|
|
const updatedTask = TaskFactory.createTask({
|
|
...task,
|
|
status: 'done'
|
|
});
|
|
|
|
updateTaskCard(card, updatedTask, mockPlugin);
|
|
|
|
const checkbox = card.querySelector('.task-card__checkbox') as HTMLInputElement;
|
|
expect(checkbox.checked).toBe(true);
|
|
});
|
|
|
|
it('should add priority indicator when task gains priority', () => {
|
|
// Create a task without priority initially
|
|
const taskWithoutPriority = TaskFactory.createTask({
|
|
title: 'Task without priority',
|
|
status: 'open',
|
|
priority: undefined
|
|
});
|
|
const cardWithoutPriority = createTaskCard(taskWithoutPriority, mockPlugin, undefined, { showCheckbox: true });
|
|
|
|
// Task initially has no priority indicator
|
|
expect(cardWithoutPriority.querySelector('.task-card__priority-dot')).toBeNull();
|
|
|
|
const updatedTask = TaskFactory.createTask({
|
|
...taskWithoutPriority,
|
|
priority: 'high'
|
|
});
|
|
|
|
updateTaskCard(cardWithoutPriority, updatedTask, mockPlugin);
|
|
|
|
const priorityDot = cardWithoutPriority.querySelector('.task-card__priority-dot');
|
|
expect(priorityDot).toBeTruthy();
|
|
});
|
|
|
|
it('should remove priority indicator when task loses priority', () => {
|
|
// Start with priority
|
|
const taskWithPriority = TaskFactory.createTask({
|
|
...task,
|
|
priority: 'high'
|
|
});
|
|
const cardWithPriority = createTaskCard(taskWithPriority, mockPlugin);
|
|
|
|
expect(cardWithPriority.querySelector('.task-card__priority-dot')).toBeTruthy();
|
|
|
|
const updatedTask = TaskFactory.createTask({
|
|
...taskWithPriority,
|
|
priority: undefined
|
|
});
|
|
|
|
updateTaskCard(cardWithPriority, updatedTask, mockPlugin);
|
|
|
|
expect(cardWithPriority.querySelector('.task-card__priority-dot')).toBeNull();
|
|
});
|
|
|
|
it('should update status dot color', () => {
|
|
mockPlugin.statusManager.getStatusConfig.mockReturnValue({
|
|
value: 'done',
|
|
label: 'Done',
|
|
color: '#00ff00'
|
|
});
|
|
|
|
const updatedTask = TaskFactory.createTask({
|
|
...task,
|
|
status: 'done'
|
|
});
|
|
|
|
updateTaskCard(card, updatedTask, mockPlugin);
|
|
|
|
const statusDot = card.querySelector('.task-card__status-dot') as HTMLElement;
|
|
expect(statusDot.style.borderColor).toBe('rgb(0, 255, 0)');
|
|
});
|
|
|
|
it('should update metadata line', () => {
|
|
const updatedTask = TaskFactory.createTask({
|
|
...task,
|
|
due: '2025-01-15T14:30:00',
|
|
contexts: ['work']
|
|
});
|
|
|
|
updateTaskCard(card, updatedTask, mockPlugin);
|
|
|
|
const metadataLine = card.querySelector('.task-card__metadata');
|
|
expect(metadataLine?.textContent).toContain('Due:');
|
|
expect(metadataLine?.textContent).toContain('@work');
|
|
});
|
|
});
|
|
|
|
describe('showTaskContextMenu', () => {
|
|
let task: TaskInfo;
|
|
let mockMenu: any;
|
|
|
|
beforeEach(() => {
|
|
task = TaskFactory.createTask();
|
|
mockMenu = {
|
|
addItem: jest.fn((callback) => {
|
|
const mockItem = {
|
|
setTitle: jest.fn().mockReturnThis(),
|
|
setIcon: jest.fn().mockReturnThis(),
|
|
onClick: jest.fn().mockReturnThis()
|
|
};
|
|
callback(mockItem);
|
|
return mockItem;
|
|
}),
|
|
addSeparator: jest.fn(),
|
|
showAtMouseEvent: jest.fn()
|
|
};
|
|
|
|
MockObsidian.Menu.mockImplementation(() => mockMenu);
|
|
mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(task);
|
|
});
|
|
|
|
it('should create context menu with basic actions', async () => {
|
|
const mockEvent = new MouseEvent('contextmenu');
|
|
|
|
await showTaskContextMenu(mockEvent, task.path, mockPlugin, new Date('2025-01-15'));
|
|
|
|
expect(mockPlugin.cacheManager.getTaskInfo).toHaveBeenCalledWith(task.path);
|
|
expect(lastMenuInstance.addItem).toHaveBeenCalled();
|
|
// Note: Menu.showAtMouseEvent might not be trackable in test environment due to Obsidian mocking complexities
|
|
// The key behavior (context menu creation and population) is verified by addItem calls
|
|
});
|
|
|
|
it('should add status options to context menu', async () => {
|
|
const mockEvent = new MouseEvent('contextmenu');
|
|
|
|
await showTaskContextMenu(mockEvent, task.path, mockPlugin, new Date('2025-01-15'));
|
|
|
|
// Should have called addItem for each status
|
|
expect(lastMenuInstance.addItem).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should add completion toggle for recurring tasks', async () => {
|
|
const recurringTask = TaskFactory.createRecurringTask('FREQ=DAILY');
|
|
mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(recurringTask);
|
|
|
|
const mockEvent = new MouseEvent('contextmenu');
|
|
|
|
await showTaskContextMenu(mockEvent, recurringTask.path, mockPlugin, new Date('2025-01-15'));
|
|
|
|
expect(lastMenuInstance.addSeparator).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle task not found', async () => {
|
|
mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(null);
|
|
|
|
const mockEvent = new MouseEvent('contextmenu');
|
|
|
|
await showTaskContextMenu(mockEvent, 'nonexistent.md', mockPlugin, new Date('2025-01-15'));
|
|
|
|
expect(console.error).toHaveBeenCalledWith(expect.stringContaining('No task found'));
|
|
});
|
|
|
|
it('should handle errors gracefully', async () => {
|
|
mockPlugin.cacheManager.getTaskInfo.mockRejectedValue(new Error('Cache error'));
|
|
|
|
const mockEvent = new MouseEvent('contextmenu');
|
|
|
|
await showTaskContextMenu(mockEvent, task.path, mockPlugin, new Date('2025-01-15'));
|
|
|
|
expect(console.error).toHaveBeenCalled();
|
|
expect(Notice).toHaveBeenCalledWith(expect.stringContaining('Failed to create context menu'));
|
|
});
|
|
});
|
|
|
|
describe('showDeleteConfirmationModal', () => {
|
|
let task: TaskInfo;
|
|
|
|
beforeEach(() => {
|
|
task = TaskFactory.createTask({ title: 'Task to Delete' });
|
|
});
|
|
|
|
it('should create and show delete confirmation modal', async () => {
|
|
const deletePromise = showDeleteConfirmationModal(task, mockPlugin);
|
|
|
|
// Should create promise for deletion
|
|
expect(deletePromise).toBeInstanceOf(Promise);
|
|
|
|
// Simulate user confirming deletion
|
|
mockPlugin.taskService.deleteTask.mockResolvedValue(undefined);
|
|
});
|
|
|
|
it('should handle successful deletion', async () => {
|
|
mockPlugin.taskService.deleteTask.mockResolvedValue(undefined);
|
|
|
|
const deletePromise = showDeleteConfirmationModal(task, mockPlugin);
|
|
|
|
// Modal should be created - just verify the promise exists
|
|
expect(deletePromise).toBeInstanceOf(Promise);
|
|
});
|
|
|
|
it('should handle deletion errors', async () => {
|
|
mockPlugin.taskService.deleteTask.mockRejectedValue(new Error('Delete failed'));
|
|
|
|
const deletePromise = showDeleteConfirmationModal(task, mockPlugin);
|
|
|
|
// Modal should still be created - just verify the promise exists
|
|
expect(deletePromise).toBeInstanceOf(Promise);
|
|
});
|
|
});
|
|
|
|
describe('Edge Cases and Error Handling', () => {
|
|
it('should handle null plugin gracefully', () => {
|
|
const task = TaskFactory.createTask();
|
|
|
|
// This test should throw since the function does access plugin properties early
|
|
// The test expectation was wrong - it should throw
|
|
expect(() => createTaskCard(task, null as any, undefined, { targetDate: new Date() })).toThrow();
|
|
});
|
|
|
|
it('should handle malformed task data', () => {
|
|
const malformedTask = {
|
|
title: null,
|
|
status: undefined,
|
|
path: ''
|
|
} as any;
|
|
|
|
expect(() => createTaskCard(malformedTask, mockPlugin)).not.toThrow();
|
|
});
|
|
|
|
it('should handle missing DOM elements in update', () => {
|
|
const task = TaskFactory.createTask();
|
|
const emptyCard = document.createElement('div');
|
|
|
|
expect(() => updateTaskCard(emptyCard, task, mockPlugin)).not.toThrow();
|
|
});
|
|
|
|
it('should handle network errors in async operations', async () => {
|
|
const task = TaskFactory.createTask();
|
|
const card = createTaskCard(task, mockPlugin, undefined, { showCheckbox: true });
|
|
|
|
mockPlugin.toggleTaskStatus.mockRejectedValue(new Error('Network timeout'));
|
|
|
|
const checkbox = card.querySelector('.task-card__checkbox') as HTMLInputElement;
|
|
checkbox.click();
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
|
|
expect(console.error).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle missing file references', async () => {
|
|
const task = TaskFactory.createTask({ path: 'nonexistent.md' });
|
|
mockApp.vault.getAbstractFileByPath.mockReturnValue(null);
|
|
|
|
const card = createTaskCard(task, mockPlugin);
|
|
|
|
// Simulate Ctrl+click to trigger file opening behavior
|
|
const ctrlClickEvent = new MouseEvent('click', {
|
|
bubbles: true,
|
|
ctrlKey: true
|
|
});
|
|
card.dispatchEvent(ctrlClickEvent);
|
|
|
|
// Wait for the async click handler to complete
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
|
|
expect(mockApp.vault.getAbstractFileByPath).toHaveBeenCalledWith('nonexistent.md');
|
|
});
|
|
});
|
|
|
|
describe('Performance and Memory', () => {
|
|
it('should handle large numbers of task cards efficiently', () => {
|
|
const startTime = Date.now();
|
|
|
|
for (let i = 0; i < 100; i++) {
|
|
const task = TaskFactory.createTask({ title: `Task ${i}` });
|
|
const card = createTaskCard(task, mockPlugin);
|
|
container.appendChild(card);
|
|
}
|
|
|
|
const endTime = Date.now();
|
|
expect(endTime - startTime).toBeLessThan(1000);
|
|
expect(container.children.length).toBe(100);
|
|
});
|
|
|
|
it('should clean up event listeners properly', () => {
|
|
const task = TaskFactory.createTask();
|
|
const card = createTaskCard(task, mockPlugin);
|
|
|
|
container.appendChild(card);
|
|
container.removeChild(card);
|
|
|
|
// Should not leak memory - hard to test directly but ensures structure is correct
|
|
expect(card.parentNode).toBeNull();
|
|
});
|
|
|
|
it('should handle rapid updates efficiently', () => {
|
|
const task = TaskFactory.createTask();
|
|
const card = createTaskCard(task, mockPlugin);
|
|
|
|
const startTime = Date.now();
|
|
|
|
for (let i = 0; i < 50; i++) {
|
|
const updatedTask = TaskFactory.createTask({
|
|
...task,
|
|
title: `Updated ${i}`,
|
|
status: i % 2 === 0 ? 'open' : 'done'
|
|
});
|
|
updateTaskCard(card, updatedTask, mockPlugin);
|
|
}
|
|
|
|
const endTime = Date.now();
|
|
expect(endTime - startTime).toBeLessThan(500);
|
|
});
|
|
});
|
|
|
|
|
|
describe('Subtask chevron position', () => {
|
|
it('should add task-card--chevron-left when setting is left (create)', async () => {
|
|
const task = TaskFactory.createTask({ title: 'Project Task' });
|
|
// Ensure this task is considered a project and chevron feature is on
|
|
mockPlugin.projectSubtasksService.isTaskUsedAsProject.mockResolvedValue(true);
|
|
mockPlugin.settings = { showExpandableSubtasks: true, subtaskChevronPosition: 'left' };
|
|
|
|
const card = createTaskCard(task, mockPlugin);
|
|
|
|
// Wait for async project check to complete
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
|
|
expect(card.classList.contains('task-card--chevron-left')).toBe(true);
|
|
// Chevron should render for projects when feature enabled
|
|
expect(card.querySelector('.task-card__chevron')).not.toBeNull();
|
|
});
|
|
|
|
it('should not add task-card--chevron-left when setting is right/default (create)', async () => {
|
|
const task = TaskFactory.createTask({ title: 'Project Task' });
|
|
mockPlugin.projectSubtasksService.isTaskUsedAsProject.mockResolvedValue(true);
|
|
mockPlugin.settings = { showExpandableSubtasks: true, subtaskChevronPosition: 'right' };
|
|
|
|
const card = createTaskCard(task, mockPlugin);
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
|
|
expect(card.classList.contains('task-card--chevron-left')).toBe(false);
|
|
expect(card.querySelector('.task-card__chevron')).not.toBeNull();
|
|
});
|
|
|
|
it('should toggle chevron-left class on update when setting changes', async () => {
|
|
const task = TaskFactory.createTask({ title: 'Project Task' });
|
|
// Start with right/default
|
|
mockPlugin.projectSubtasksService.isTaskUsedAsProject.mockResolvedValue(true);
|
|
mockPlugin.settings = { showExpandableSubtasks: true, subtaskChevronPosition: 'right' };
|
|
|
|
const card = createTaskCard(task, mockPlugin);
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
expect(card.classList.contains('task-card--chevron-left')).toBe(false);
|
|
|
|
// Change to left and update
|
|
mockPlugin.settings.subtaskChevronPosition = 'left';
|
|
updateTaskCard(card, task, mockPlugin);
|
|
expect(card.classList.contains('task-card--chevron-left')).toBe(true);
|
|
|
|
// Change back to right and update
|
|
mockPlugin.settings.subtaskChevronPosition = 'right';
|
|
updateTaskCard(card, task, mockPlugin);
|
|
expect(card.classList.contains('task-card--chevron-left')).toBe(false);
|
|
});
|
|
});
|
|
describe('Accessibility', () => {
|
|
it('should have proper ARIA labels', () => {
|
|
const task = TaskFactory.createTask({ priority: 'high' });
|
|
const card = createTaskCard(task, mockPlugin);
|
|
|
|
const priorityDot = card.querySelector('.task-card__priority-dot');
|
|
expect(priorityDot?.getAttribute('aria-label')).toBe('Priority: high');
|
|
|
|
const contextIcon = card.querySelector('.task-card__context-menu');
|
|
expect(contextIcon?.getAttribute('aria-label')).toBe('Task options');
|
|
});
|
|
|
|
it('should support keyboard navigation', () => {
|
|
const task = TaskFactory.createTask();
|
|
const card = createTaskCard(task, mockPlugin, undefined, { showCheckbox: true });
|
|
|
|
const checkbox = card.querySelector('.task-card__checkbox') as HTMLInputElement;
|
|
expect(checkbox.tabIndex).toBe(0);
|
|
});
|
|
|
|
it('should have proper semantic structure', () => {
|
|
const task = TaskFactory.createTask();
|
|
const card = createTaskCard(task, mockPlugin);
|
|
|
|
expect(card.querySelector('.task-card__title')).toBeTruthy();
|
|
expect(card.querySelector('.task-card__metadata')).toBeTruthy();
|
|
expect(card.querySelector('.task-card__content')).toBeTruthy();
|
|
});
|
|
});
|
|
}); |