Files
claude-task-master/tests/unit/dependency-manager.test.js
Eyal Toledano e70f44b6fb fix: Correct handling of dependencies between subtasks
This commit fixes an issue with the dependency management system where
dependencies between subtasks of the same parent task were not being
handled correctly. Previously, when trying to add a dependency between
subtasks (e.g., making 23.8 depend on 23.13), the system incorrectly
interpreted it as a circular dependency of the parent task depending
on itself.

Changes:
- Modified add-dependency and remove-dependency commands to preserve
  string format for subtask IDs containing dots instead of converting
  them to integers
- Updated dependency detection logic to properly handle string-based
  subtask IDs
- Added specific tests verifying both successful dependency chains
  and circular dependency detection between subtasks of the same parent

The fix ensures proper task ordering for development workflows where
related subtasks must be completed in sequence (like establishing tests
before implementing a feature). It maintains backward compatibility with
existing task structures while correctly enforcing dependency chains
within subtask groups.

Tests:
- Added tests for valid dependency chains between subtasks of the same parent
- Added tests for circular dependency detection in subtask relationships
- Added specific test for the 23.8->23.13->23.10 subtask dependency chain

Resolves the issue where the suggested task development workflow couldn't
be properly enforced through the dependency management system.
2025-03-29 18:19:39 -04:00

782 lines
24 KiB
JavaScript

/**
* Dependency Manager module tests
*/
import { jest } from '@jest/globals';
import {
validateTaskDependencies,
isCircularDependency,
removeDuplicateDependencies,
cleanupSubtaskDependencies,
ensureAtLeastOneIndependentSubtask,
validateAndFixDependencies
} from '../../scripts/modules/dependency-manager.js';
import * as utils from '../../scripts/modules/utils.js';
import { sampleTasks } from '../fixtures/sample-tasks.js';
// Mock dependencies
jest.mock('path');
jest.mock('chalk', () => ({
green: jest.fn(text => `<green>${text}</green>`),
yellow: jest.fn(text => `<yellow>${text}</yellow>`),
red: jest.fn(text => `<red>${text}</red>`),
cyan: jest.fn(text => `<cyan>${text}</cyan>`),
bold: jest.fn(text => `<bold>${text}</bold>`),
}));
jest.mock('boxen', () => jest.fn(text => `[boxed: ${text}]`));
jest.mock('@anthropic-ai/sdk', () => ({
Anthropic: jest.fn().mockImplementation(() => ({})),
}));
// Mock utils module
const mockTaskExists = jest.fn();
const mockFormatTaskId = jest.fn();
const mockFindCycles = jest.fn();
const mockLog = jest.fn();
const mockReadJSON = jest.fn();
const mockWriteJSON = jest.fn();
jest.mock('../../scripts/modules/utils.js', () => ({
log: mockLog,
readJSON: mockReadJSON,
writeJSON: mockWriteJSON,
taskExists: mockTaskExists,
formatTaskId: mockFormatTaskId,
findCycles: mockFindCycles
}));
jest.mock('../../scripts/modules/ui.js', () => ({
displayBanner: jest.fn(),
}));
jest.mock('../../scripts/modules/task-manager.js', () => ({
generateTaskFiles: jest.fn(),
}));
// Create a path for test files
const TEST_TASKS_PATH = 'tests/fixture/test-tasks.json';
describe('Dependency Manager Module', () => {
beforeEach(() => {
jest.clearAllMocks();
// Set default implementations
mockTaskExists.mockImplementation((tasks, id) => {
if (Array.isArray(tasks)) {
if (typeof id === 'string' && id.includes('.')) {
const [taskId, subtaskId] = id.split('.').map(Number);
const task = tasks.find(t => t.id === taskId);
return task && task.subtasks && task.subtasks.some(st => st.id === subtaskId);
}
return tasks.some(task => task.id === (typeof id === 'string' ? parseInt(id, 10) : id));
}
return false;
});
mockFormatTaskId.mockImplementation(id => {
if (typeof id === 'string' && id.includes('.')) {
return id;
}
return parseInt(id, 10);
});
mockFindCycles.mockImplementation((tasks) => {
// Simplified cycle detection for testing
const dependencyMap = new Map();
// Build dependency map
tasks.forEach(task => {
if (task.dependencies) {
dependencyMap.set(task.id, task.dependencies);
}
});
const visited = new Set();
const recursionStack = new Set();
function dfs(taskId) {
visited.add(taskId);
recursionStack.add(taskId);
const dependencies = dependencyMap.get(taskId) || [];
for (const depId of dependencies) {
if (!visited.has(depId)) {
if (dfs(depId)) return true;
} else if (recursionStack.has(depId)) {
return true;
}
}
recursionStack.delete(taskId);
return false;
}
// Check for cycles starting from each unvisited node
for (const taskId of dependencyMap.keys()) {
if (!visited.has(taskId)) {
if (dfs(taskId)) return true;
}
}
return false;
});
});
describe('isCircularDependency function', () => {
test('should detect a direct circular dependency', () => {
const tasks = [
{ id: 1, dependencies: [2] },
{ id: 2, dependencies: [1] }
];
const result = isCircularDependency(tasks, 1);
expect(result).toBe(true);
});
test('should detect an indirect circular dependency', () => {
const tasks = [
{ id: 1, dependencies: [2] },
{ id: 2, dependencies: [3] },
{ id: 3, dependencies: [1] }
];
const result = isCircularDependency(tasks, 1);
expect(result).toBe(true);
});
test('should return false for non-circular dependencies', () => {
const tasks = [
{ id: 1, dependencies: [2] },
{ id: 2, dependencies: [3] },
{ id: 3, dependencies: [] }
];
const result = isCircularDependency(tasks, 1);
expect(result).toBe(false);
});
test('should handle a task with no dependencies', () => {
const tasks = [
{ id: 1, dependencies: [] },
{ id: 2, dependencies: [1] }
];
const result = isCircularDependency(tasks, 1);
expect(result).toBe(false);
});
test('should handle a task depending on itself', () => {
const tasks = [
{ id: 1, dependencies: [1] }
];
const result = isCircularDependency(tasks, 1);
expect(result).toBe(true);
});
test('should handle subtask dependencies correctly', () => {
const tasks = [
{
id: 1,
dependencies: [],
subtasks: [
{ id: 1, dependencies: ["1.2"] },
{ id: 2, dependencies: ["1.3"] },
{ id: 3, dependencies: ["1.1"] }
]
}
];
// This creates a circular dependency: 1.1 -> 1.2 -> 1.3 -> 1.1
const result = isCircularDependency(tasks, "1.1", ["1.3", "1.2"]);
expect(result).toBe(true);
});
test('should allow non-circular subtask dependencies within same parent', () => {
const tasks = [
{
id: 1,
dependencies: [],
subtasks: [
{ id: 1, dependencies: [] },
{ id: 2, dependencies: ["1.1"] },
{ id: 3, dependencies: ["1.2"] }
]
}
];
// This is a valid dependency chain: 1.3 -> 1.2 -> 1.1
const result = isCircularDependency(tasks, "1.1", []);
expect(result).toBe(false);
});
test('should properly handle dependencies between subtasks of the same parent', () => {
const tasks = [
{
id: 1,
dependencies: [],
subtasks: [
{ id: 1, dependencies: [] },
{ id: 2, dependencies: ["1.1"] },
{ id: 3, dependencies: [] }
]
}
];
// Check if adding a dependency from subtask 1.3 to 1.2 creates a circular dependency
// This should be false as 1.3 -> 1.2 -> 1.1 is a valid chain
mockTaskExists.mockImplementation(() => true);
const result = isCircularDependency(tasks, "1.3", ["1.2"]);
expect(result).toBe(false);
});
test('should correctly detect circular dependencies in subtasks of the same parent', () => {
const tasks = [
{
id: 1,
dependencies: [],
subtasks: [
{ id: 1, dependencies: ["1.3"] },
{ id: 2, dependencies: ["1.1"] },
{ id: 3, dependencies: ["1.2"] }
]
}
];
// This creates a circular dependency: 1.1 -> 1.3 -> 1.2 -> 1.1
mockTaskExists.mockImplementation(() => true);
const result = isCircularDependency(tasks, "1.2", ["1.1"]);
expect(result).toBe(true);
});
});
describe('validateTaskDependencies function', () => {
test('should detect missing dependencies', () => {
const tasks = [
{ id: 1, dependencies: [99] }, // 99 doesn't exist
{ id: 2, dependencies: [1] }
];
const result = validateTaskDependencies(tasks);
expect(result.valid).toBe(false);
expect(result.issues.length).toBeGreaterThan(0);
expect(result.issues[0].type).toBe('missing');
expect(result.issues[0].taskId).toBe(1);
expect(result.issues[0].dependencyId).toBe(99);
});
test('should detect circular dependencies', () => {
const tasks = [
{ id: 1, dependencies: [2] },
{ id: 2, dependencies: [1] }
];
const result = validateTaskDependencies(tasks);
expect(result.valid).toBe(false);
expect(result.issues.some(issue => issue.type === 'circular')).toBe(true);
});
test('should detect self-dependencies', () => {
const tasks = [
{ id: 1, dependencies: [1] }
];
const result = validateTaskDependencies(tasks);
expect(result.valid).toBe(false);
expect(result.issues.some(issue =>
issue.type === 'self' && issue.taskId === 1
)).toBe(true);
});
test('should return valid for correct dependencies', () => {
const tasks = [
{ id: 1, dependencies: [] },
{ id: 2, dependencies: [1] },
{ id: 3, dependencies: [1, 2] }
];
const result = validateTaskDependencies(tasks);
expect(result.valid).toBe(true);
expect(result.issues.length).toBe(0);
});
test('should handle tasks with no dependencies property', () => {
const tasks = [
{ id: 1 }, // Missing dependencies property
{ id: 2, dependencies: [1] }
];
const result = validateTaskDependencies(tasks);
// Should be valid since a missing dependencies property is interpreted as an empty array
expect(result.valid).toBe(true);
});
test('should handle subtask dependencies correctly', () => {
const tasks = [
{
id: 1,
dependencies: [],
subtasks: [
{ id: 1, dependencies: [] },
{ id: 2, dependencies: ["1.1"] }, // Valid - depends on another subtask
{ id: 3, dependencies: ["1.2"] } // Valid - depends on another subtask
]
},
{
id: 2,
dependencies: ["1.3"], // Valid - depends on a subtask from task 1
subtasks: []
}
];
// Set up mock to handle subtask validation
mockTaskExists.mockImplementation((tasks, id) => {
if (typeof id === 'string' && id.includes('.')) {
const [taskId, subtaskId] = id.split('.').map(Number);
const task = tasks.find(t => t.id === taskId);
return task && task.subtasks && task.subtasks.some(st => st.id === subtaskId);
}
return tasks.some(task => task.id === parseInt(id, 10));
});
const result = validateTaskDependencies(tasks);
expect(result.valid).toBe(true);
expect(result.issues.length).toBe(0);
});
test('should detect missing subtask dependencies', () => {
const tasks = [
{
id: 1,
dependencies: [],
subtasks: [
{ id: 1, dependencies: ["1.4"] }, // Invalid - subtask 4 doesn't exist
{ id: 2, dependencies: ["2.1"] } // Invalid - task 2 has no subtasks
]
},
{
id: 2,
dependencies: [],
subtasks: []
}
];
// Mock taskExists to correctly identify missing subtasks
mockTaskExists.mockImplementation((taskArray, depId) => {
if (typeof depId === 'string' && depId === "1.4") {
return false; // Subtask 1.4 doesn't exist
}
if (typeof depId === 'string' && depId === "2.1") {
return false; // Subtask 2.1 doesn't exist
}
return true; // All other dependencies exist
});
const result = validateTaskDependencies(tasks);
expect(result.valid).toBe(false);
expect(result.issues.length).toBeGreaterThan(0);
// Should detect missing subtask dependencies
expect(result.issues.some(issue =>
issue.type === 'missing' && String(issue.taskId) === "1.1" && String(issue.dependencyId) === "1.4"
)).toBe(true);
});
test('should detect circular dependencies between subtasks', () => {
const tasks = [
{
id: 1,
dependencies: [],
subtasks: [
{ id: 1, dependencies: ["1.2"] },
{ id: 2, dependencies: ["1.1"] } // Creates a circular dependency with 1.1
]
}
];
// Mock isCircularDependency for subtasks
mockFindCycles.mockReturnValue(true);
const result = validateTaskDependencies(tasks);
expect(result.valid).toBe(false);
expect(result.issues.some(issue => issue.type === 'circular')).toBe(true);
});
test('should properly validate dependencies between subtasks of the same parent', () => {
const tasks = [
{
id: 23,
dependencies: [],
subtasks: [
{ id: 8, dependencies: ["23.13"] },
{ id: 10, dependencies: ["23.8"] },
{ id: 13, dependencies: [] }
]
}
];
// Mock taskExists to validate the subtask dependencies
mockTaskExists.mockImplementation((taskArray, id) => {
if (typeof id === 'string') {
if (id === "23.8" || id === "23.10" || id === "23.13") {
return true;
}
}
return false;
});
const result = validateTaskDependencies(tasks);
expect(result.valid).toBe(true);
expect(result.issues.length).toBe(0);
});
});
describe('removeDuplicateDependencies function', () => {
test('should remove duplicate dependencies from tasks', () => {
const tasksData = {
tasks: [
{ id: 1, dependencies: [2, 2, 3, 3, 3] },
{ id: 2, dependencies: [3] },
{ id: 3, dependencies: [] }
]
};
const result = removeDuplicateDependencies(tasksData);
expect(result.tasks[0].dependencies).toEqual([2, 3]);
expect(result.tasks[1].dependencies).toEqual([3]);
expect(result.tasks[2].dependencies).toEqual([]);
});
test('should handle empty dependencies array', () => {
const tasksData = {
tasks: [
{ id: 1, dependencies: [] },
{ id: 2, dependencies: [1] }
]
};
const result = removeDuplicateDependencies(tasksData);
expect(result.tasks[0].dependencies).toEqual([]);
expect(result.tasks[1].dependencies).toEqual([1]);
});
test('should handle tasks with no dependencies property', () => {
const tasksData = {
tasks: [
{ id: 1 }, // No dependencies property
{ id: 2, dependencies: [1] }
]
};
const result = removeDuplicateDependencies(tasksData);
expect(result.tasks[0]).not.toHaveProperty('dependencies');
expect(result.tasks[1].dependencies).toEqual([1]);
});
});
describe('cleanupSubtaskDependencies function', () => {
test('should remove dependencies to non-existent subtasks', () => {
const tasksData = {
tasks: [
{
id: 1,
dependencies: [],
subtasks: [
{ id: 1, dependencies: [] },
{ id: 2, dependencies: [3] } // Dependency 3 doesn't exist
]
},
{
id: 2,
dependencies: ['1.2'], // Valid subtask dependency
subtasks: [
{ id: 1, dependencies: ['1.1'] } // Valid subtask dependency
]
}
]
};
const result = cleanupSubtaskDependencies(tasksData);
// Should remove the invalid dependency to subtask 3
expect(result.tasks[0].subtasks[1].dependencies).toEqual([]);
// Should keep valid dependencies
expect(result.tasks[1].dependencies).toEqual(['1.2']);
expect(result.tasks[1].subtasks[0].dependencies).toEqual(['1.1']);
});
test('should handle tasks without subtasks', () => {
const tasksData = {
tasks: [
{ id: 1, dependencies: [] },
{ id: 2, dependencies: [1] }
]
};
const result = cleanupSubtaskDependencies(tasksData);
// Should return the original data unchanged
expect(result).toEqual(tasksData);
});
});
describe('ensureAtLeastOneIndependentSubtask function', () => {
test('should clear dependencies of first subtask if none are independent', () => {
const tasksData = {
tasks: [
{
id: 1,
subtasks: [
{ id: 1, dependencies: [2] },
{ id: 2, dependencies: [1] }
]
}
]
};
const result = ensureAtLeastOneIndependentSubtask(tasksData);
expect(result).toBe(true);
expect(tasksData.tasks[0].subtasks[0].dependencies).toEqual([]);
expect(tasksData.tasks[0].subtasks[1].dependencies).toEqual([1]);
});
test('should not modify tasks if at least one subtask is independent', () => {
const tasksData = {
tasks: [
{
id: 1,
subtasks: [
{ id: 1, dependencies: [] },
{ id: 2, dependencies: [1] }
]
}
]
};
const result = ensureAtLeastOneIndependentSubtask(tasksData);
expect(result).toBe(false);
expect(tasksData.tasks[0].subtasks[0].dependencies).toEqual([]);
expect(tasksData.tasks[0].subtasks[1].dependencies).toEqual([1]);
});
test('should handle tasks without subtasks', () => {
const tasksData = {
tasks: [
{ id: 1 },
{ id: 2, dependencies: [1] }
]
};
const result = ensureAtLeastOneIndependentSubtask(tasksData);
expect(result).toBe(false);
expect(tasksData).toEqual({
tasks: [
{ id: 1 },
{ id: 2, dependencies: [1] }
]
});
});
test('should handle empty subtasks array', () => {
const tasksData = {
tasks: [
{ id: 1, subtasks: [] }
]
};
const result = ensureAtLeastOneIndependentSubtask(tasksData);
expect(result).toBe(false);
expect(tasksData).toEqual({
tasks: [
{ id: 1, subtasks: [] }
]
});
});
});
describe('validateAndFixDependencies function', () => {
test('should fix multiple dependency issues and return true if changes made', () => {
const tasksData = {
tasks: [
{
id: 1,
dependencies: [1, 1, 99], // Self-dependency and duplicate and invalid dependency
subtasks: [
{ id: 1, dependencies: [2, 2] }, // Duplicate dependencies
{ id: 2, dependencies: [1] }
]
},
{
id: 2,
dependencies: [1],
subtasks: [
{ id: 1, dependencies: [99] } // Invalid dependency
]
}
]
};
// Mock taskExists for validating dependencies
mockTaskExists.mockImplementation((tasks, id) => {
// Convert id to string for comparison
const idStr = String(id);
// Handle subtask references (e.g., "1.2")
if (idStr.includes('.')) {
const [parentId, subtaskId] = idStr.split('.').map(Number);
const task = tasks.find(t => t.id === parentId);
return task && task.subtasks && task.subtasks.some(st => st.id === subtaskId);
}
// Handle regular task references
const taskId = parseInt(idStr, 10);
return taskId === 1 || taskId === 2; // Only tasks 1 and 2 exist
});
// Make a copy for verification that original is modified
const originalData = JSON.parse(JSON.stringify(tasksData));
const result = validateAndFixDependencies(tasksData);
expect(result).toBe(true);
// Check that data has been modified
expect(tasksData).not.toEqual(originalData);
// Check specific changes
// 1. Self-dependency removed
expect(tasksData.tasks[0].dependencies).not.toContain(1);
// 2. Invalid dependency removed
expect(tasksData.tasks[0].dependencies).not.toContain(99);
// 3. Dependencies have been deduplicated
if (tasksData.tasks[0].subtasks[0].dependencies.length > 0) {
expect(tasksData.tasks[0].subtasks[0].dependencies).toEqual(
expect.arrayContaining([])
);
}
// 4. Invalid subtask dependency removed
expect(tasksData.tasks[1].subtasks[0].dependencies).toEqual([]);
// IMPORTANT: Verify no calls to writeJSON with actual tasks.json
expect(mockWriteJSON).not.toHaveBeenCalledWith('tasks/tasks.json', expect.anything());
});
test('should return false if no changes needed', () => {
const tasksData = {
tasks: [
{
id: 1,
dependencies: [],
subtasks: [
{ id: 1, dependencies: [] }, // Already has an independent subtask
{ id: 2, dependencies: ['1.1'] }
]
},
{
id: 2,
dependencies: [1]
}
]
};
// Mock taskExists to validate all dependencies as valid
mockTaskExists.mockImplementation((tasks, id) => {
// Convert id to string for comparison
const idStr = String(id);
// Handle subtask references
if (idStr.includes('.')) {
const [parentId, subtaskId] = idStr.split('.').map(Number);
const task = tasks.find(t => t.id === parentId);
return task && task.subtasks && task.subtasks.some(st => st.id === subtaskId);
}
// Handle regular task references
const taskId = parseInt(idStr, 10);
return taskId === 1 || taskId === 2;
});
const originalData = JSON.parse(JSON.stringify(tasksData));
const result = validateAndFixDependencies(tasksData);
expect(result).toBe(false);
// Verify data is unchanged
expect(tasksData).toEqual(originalData);
// IMPORTANT: Verify no calls to writeJSON with actual tasks.json
expect(mockWriteJSON).not.toHaveBeenCalledWith('tasks/tasks.json', expect.anything());
});
test('should handle invalid input', () => {
expect(validateAndFixDependencies(null)).toBe(false);
expect(validateAndFixDependencies({})).toBe(false);
expect(validateAndFixDependencies({ tasks: null })).toBe(false);
expect(validateAndFixDependencies({ tasks: 'not an array' })).toBe(false);
// IMPORTANT: Verify no calls to writeJSON with actual tasks.json
expect(mockWriteJSON).not.toHaveBeenCalledWith('tasks/tasks.json', expect.anything());
});
test('should save changes when tasksPath is provided', () => {
const tasksData = {
tasks: [
{
id: 1,
dependencies: [1, 1], // Self-dependency and duplicate
subtasks: [
{ id: 1, dependencies: [99] } // Invalid dependency
]
}
]
};
// Mock taskExists for this specific test
mockTaskExists.mockImplementation((tasks, id) => {
// Convert id to string for comparison
const idStr = String(id);
// Handle subtask references
if (idStr.includes('.')) {
const [parentId, subtaskId] = idStr.split('.').map(Number);
const task = tasks.find(t => t.id === parentId);
return task && task.subtasks && task.subtasks.some(st => st.id === subtaskId);
}
// Handle regular task references
const taskId = parseInt(idStr, 10);
return taskId === 1; // Only task 1 exists
});
// Copy the original data to verify changes
const originalData = JSON.parse(JSON.stringify(tasksData));
// Call the function with our test path instead of the actual tasks.json
const result = validateAndFixDependencies(tasksData, TEST_TASKS_PATH);
// First verify that the result is true (changes were made)
expect(result).toBe(true);
// Verify the data was modified
expect(tasksData).not.toEqual(originalData);
// IMPORTANT: Verify no calls to writeJSON with actual tasks.json
expect(mockWriteJSON).not.toHaveBeenCalledWith('tasks/tasks.json', expect.anything());
});
});
});