callumalpass_tasknotes/tests/unit/ui/TaskCard.test.ts
2026-04-26 18:57:21 +10:00

1143 lines
41 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 options?.userTimeFormat === '12' ? '2:30 PM' : '14:30';
if (date === '2025-01-15T14:30:00') {
return options?.userTimeFormat === '12' ? 'Jan 15, 2025 2:30 PM' : 'Jan 15, 2025 14:30';
}
if (date === '2025-01-15') return 'Jan 15, 2025';
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),
formatDateForStorage: jest.fn((value: Date | string) => {
if (value instanceof Date) {
return value.toISOString().split('T')[0];
}
return value?.split('T')[0] || '';
})
}));
// 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'),
fieldMapper: {
isPropertyForField: jest.fn(() => false),
toUserField: jest.fn((field) => field),
toInternalField: jest.fn((field) => field),
getMapping: jest.fn(() => ({
status: 'status',
priority: 'priority',
due: 'due',
scheduled: 'scheduled',
title: 'title',
tags: 'tags',
contexts: 'contexts',
projects: 'projects',
})),
},
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'),
getCompletedStatuses: 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),
isTaskUsedAsProjectSync: jest.fn().mockReturnValue(false)
},
expandedProjectsService: {
isExpanded: jest.fn(() => false),
toggle: jest.fn(() => true),
},
i18n: {
translate: jest.fn((key, vars) => {
if (key === 'ui.taskCard.priorityAriaLabel') {
return `Priority: ${vars?.label ?? ''}`;
}
if (key === 'ui.taskCard.taskOptions') {
return 'Task options';
}
if (key === 'ui.taskCard.blockingToggle') {
return `Blocking ${vars?.count ?? 0}`;
}
return key;
}),
},
settings: {
singleClickAction: 'edit',
doubleClickAction: 'none',
showExpandableSubtasks: true,
subtaskChevronPosition: 'right',
calendarViewSettings: {
timeFormat: '12' // Default to 12-hour format for test consistency
}
}
};
// 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.openLinkText = 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.skip('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.skip('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.skip('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.skip('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.skip('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.skip('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.skip('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.skip('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.skip('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 not open the edit modal when clicking a Bases-rendered tag', async () => {
const basesTagsValue = {
renderTo: (el: HTMLElement) => {
el.createEl('a', {
cls: 'tag',
text: '#client',
attr: { href: '#client' },
});
},
toString: () => '#client',
};
const tagTask = TaskFactory.createTask({
customProperties: {
'file.tags': basesTagsValue,
},
});
mockPlugin.fieldMapper.lookupMappingKey = jest.fn(() => null);
const tagCard = createTaskCard(tagTask, mockPlugin, ['file.tags']);
container.appendChild(tagCard);
const tag = tagCard.querySelector('.tag') as HTMLElement;
expect(tag).toBeTruthy();
tag.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await new Promise(resolve => setTimeout(resolve, 10));
expect(mockPlugin.openTaskEditModal).not.toHaveBeenCalled();
});
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.openLinkText).toHaveBeenCalledWith(task.path, "", true);
});
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.skip('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.skip('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.skip('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 by showing file context menu', async () => {
mockPlugin.cacheManager.getTaskInfo.mockResolvedValue(null);
const mockFile = new TFile('nonexistent.md');
mockApp.vault.getAbstractFileByPath.mockReturnValue(mockFile);
const mockEvent = new MouseEvent('contextmenu');
await showTaskContextMenu(mockEvent, 'nonexistent.md', mockPlugin, new Date('2025-01-15'));
expect(mockApp.workspace.trigger).toHaveBeenCalledTimes(1);
const triggerArgs = mockApp.workspace.trigger.mock.calls[0];
expect(triggerArgs[0]).toBe('file-menu');
expect(triggerArgs[1]).toBeDefined();
expect(triggerArgs[2]).toBe(mockFile);
expect(triggerArgs[3]).toBe('tasknotes-bases-view');
});
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.skip('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.projectSubtasksService.isTaskUsedAsProjectSync.mockReturnValue(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.projectSubtasksService.isTaskUsedAsProjectSync.mockReturnValue(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.projectSubtasksService.isTaskUsedAsProjectSync.mockReturnValue(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);
});
it('should mark the subtask chevron as a no-drag control', () => {
const task = TaskFactory.createTask({ title: 'Project Task' });
mockPlugin.projectSubtasksService.isTaskUsedAsProjectSync.mockReturnValue(true);
const card = createTaskCard(task, mockPlugin);
const chevron = card.querySelector('.task-card__chevron') as HTMLElement;
const mouseDown = new MouseEvent('mousedown', { bubbles: true, cancelable: true });
expect(chevron.getAttribute('data-tn-no-drag')).toBe('true');
expect(chevron.getAttribute('draggable')).toBe('false');
chevron.dispatchEvent(mouseDown);
expect(mouseDown.defaultPrevented).toBe(true);
});
it('should mark the blocking toggle as a no-drag control', () => {
const task = TaskFactory.createTask({
blocking: ['tasks/dependent-a.md'],
isBlocking: true,
});
const card = createTaskCard(task, mockPlugin);
const toggle = card.querySelector('.task-card__blocking-toggle') as HTMLElement;
const mouseDown = new MouseEvent('mousedown', { bubbles: true, cancelable: true });
expect(toggle.getAttribute('data-tn-no-drag')).toBe('true');
expect(toggle.getAttribute('draggable')).toBe('false');
toggle.dispatchEvent(mouseDown);
expect(mouseDown.defaultPrevented).toBe(true);
});
});
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.skip('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();
});
});
});