mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2025-12-05 19:40:59 +00:00
* docs: Auto-update and format models.md * docs(ui,cli): remove --force from cross-tag move guidance; recommend --with-dependencies/--ignore-dependencies - scripts/modules/ui.js: drop force tip in conflict resolution - scripts/modules/commands.js: remove force examples from move help - docs/cross-tag-task-movement.md: purge force mentions; add explicit with/ignore examples * test(move): update cross-tag move tests to drop --force; assert with/ignore deps behavior and current-tag fallback - CLI integration: remove force expectations, keep with/ignore, current-tag fallback - Integration: remove force-path test - Unit: add scoped traversal test, adjust fixtures to avoid id collision * fix(move): scope dependency traversal to source tag; tag-aware ignore-dependencies filtering - resolveDependencies: traverse only sourceTag tasks to avoid cross-tag contamination - filter dependent IDs to those present in source tag, numeric only - ignore-dependencies: drop deps pointing to tasks from sourceTag; keep targetTag deps * test(mcp): ensure cross-tag move passes only with/ignore options and returns conflict suggestions - new test: tests/unit/mcp/tools/move-task-cross-tag-options.test.js * feat(move): add advisory tips when ignoring cross-tag dependencies; add integration test case * feat(cli/move): improve ID collision UX for cross-tag moves\n\n- Print Next Steps tips when core returns them (e.g., after ignore-dependencies)\n- Add dedicated help block when an ID already exists in target tag * feat(move/mcp): improve ID collision UX and suggestions\n\n- Core: include suggestions on TASK_ALREADY_EXISTS errors\n- MCP: map ID collision to TASK_ALREADY_EXISTS with suggestions\n- Tests: add MCP unit test for ID collision suggestions * test(move/cli): print tips on ignore-dependencies results; print ID collision suggestions\n\n- CLI integration test: assert Next Steps tips printed when result.tips present\n- Integration test: assert TASK_ALREADY_EXISTS error includes suggestions payload * chore(changeset): add changeset for cross-tag move UX improvements (CLI/MCP/core/tests) * Add cross-tag task movement help and validation improvements - Introduced a detailed help command for cross-tag task movement, enhancing user guidance on usage and options. - Updated validation logic in `validateCrossTagMove` to include checks for indirect dependencies, improving accuracy in conflict detection. - Refactored tests to ensure comprehensive coverage of new validation scenarios and error handling. - Cleaned up documentation to reflect the latest changes in task movement functionality. * refactor(commands): remove redundant tips printing after move operation - Eliminated duplicate printing of tips for next steps after the move operation, streamlining the output for users. - This change enhances clarity by ensuring tips are only displayed when relevant, improving overall user experience. * docs(move): clarify "force move" options and improve examples - Updated documentation to replace the deprecated "force move" concept with clear alternatives: `--with-dependencies` and `--ignore-dependencies`. - Enhanced Scenario 3 with explicit options and improved inline comments for better readability. - Removed confusing commented code in favor of a straightforward note in the Force Move section. * chore: run formatter * Update .changeset/clarify-force-move-docs.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update docs/cross-tag-task-movement.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update tests/unit/scripts/modules/task-manager/move-task-cross-tag.test.js Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * test(move): add test for dependency traversal scoping with --with-dependencies option - Introduced a new test to ensure that the dependency traversal is limited to tasks from the source tag when using the --with-dependencies option, addressing potential ID collisions across tags. * test(move): enhance tips validation in cross-tag task movement integration test --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
794 lines
20 KiB
JavaScript
794 lines
20 KiB
JavaScript
import { jest } from '@jest/globals';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
// Mock dependencies before importing
|
|
const mockUtils = {
|
|
readJSON: jest.fn(),
|
|
writeJSON: jest.fn(),
|
|
findProjectRoot: jest.fn(() => '/test/project/root'),
|
|
log: jest.fn(),
|
|
setTasksForTag: jest.fn(),
|
|
traverseDependencies: jest.fn((sourceTasks, allTasks, options = {}) => {
|
|
// Mock realistic dependency behavior for testing
|
|
const { direction = 'forward' } = options;
|
|
|
|
if (direction === 'forward') {
|
|
// Return dependencies that tasks have
|
|
const result = [];
|
|
sourceTasks.forEach((task) => {
|
|
if (task.dependencies && Array.isArray(task.dependencies)) {
|
|
result.push(...task.dependencies);
|
|
}
|
|
});
|
|
return result;
|
|
} else if (direction === 'reverse') {
|
|
// Return tasks that depend on the source tasks
|
|
const sourceIds = sourceTasks.map((t) => t.id);
|
|
const normalizedSourceIds = sourceIds.map((id) => String(id));
|
|
const result = [];
|
|
allTasks.forEach((task) => {
|
|
if (task.dependencies && Array.isArray(task.dependencies)) {
|
|
const hasDependency = task.dependencies.some((depId) =>
|
|
normalizedSourceIds.includes(String(depId))
|
|
);
|
|
if (hasDependency) {
|
|
result.push(task.id);
|
|
}
|
|
}
|
|
});
|
|
return result;
|
|
}
|
|
return [];
|
|
})
|
|
};
|
|
|
|
// Mock the utils module
|
|
jest.unstable_mockModule('../../scripts/modules/utils.js', () => mockUtils);
|
|
|
|
// Mock other dependencies
|
|
jest.unstable_mockModule(
|
|
'../../scripts/modules/task-manager/is-task-dependent.js',
|
|
() => ({
|
|
default: jest.fn(() => false)
|
|
})
|
|
);
|
|
|
|
jest.unstable_mockModule('../../scripts/modules/dependency-manager.js', () => ({
|
|
findCrossTagDependencies: jest.fn(() => {
|
|
// Since dependencies can only exist within the same tag,
|
|
// this function should never find any cross-tag conflicts
|
|
return [];
|
|
}),
|
|
getDependentTaskIds: jest.fn(
|
|
(sourceTasks, crossTagDependencies, allTasks) => {
|
|
// Since we now use findAllDependenciesRecursively in the actual implementation,
|
|
// this mock simulates finding all dependencies recursively within the same tag
|
|
const dependentIds = new Set();
|
|
const processedIds = new Set();
|
|
|
|
function findAllDependencies(taskId) {
|
|
if (processedIds.has(taskId)) return;
|
|
processedIds.add(taskId);
|
|
|
|
const task = allTasks.find((t) => t.id === taskId);
|
|
if (!task || !Array.isArray(task.dependencies)) return;
|
|
|
|
task.dependencies.forEach((depId) => {
|
|
const normalizedDepId =
|
|
typeof depId === 'string' ? parseInt(depId, 10) : depId;
|
|
if (!isNaN(normalizedDepId) && normalizedDepId !== taskId) {
|
|
dependentIds.add(normalizedDepId);
|
|
findAllDependencies(normalizedDepId);
|
|
}
|
|
});
|
|
}
|
|
|
|
sourceTasks.forEach((sourceTask) => {
|
|
if (sourceTask && sourceTask.id) {
|
|
findAllDependencies(sourceTask.id);
|
|
}
|
|
});
|
|
|
|
return Array.from(dependentIds);
|
|
}
|
|
),
|
|
validateSubtaskMove: jest.fn((taskId, sourceTag, targetTag) => {
|
|
// Throw error for subtask IDs
|
|
const taskIdStr = String(taskId);
|
|
if (taskIdStr.includes('.')) {
|
|
throw new Error('Cannot move subtasks directly between tags');
|
|
}
|
|
})
|
|
}));
|
|
|
|
jest.unstable_mockModule(
|
|
'../../scripts/modules/task-manager/generate-task-files.js',
|
|
() => ({
|
|
default: jest.fn().mockResolvedValue()
|
|
})
|
|
);
|
|
|
|
// Import the modules we'll be testing after mocking
|
|
const { moveTasksBetweenTags } = await import(
|
|
'../../scripts/modules/task-manager/move-task.js'
|
|
);
|
|
|
|
describe('Cross-Tag Task Movement Integration Tests', () => {
|
|
let testDataPath;
|
|
let mockTasksData;
|
|
|
|
beforeEach(() => {
|
|
// Setup test data path
|
|
testDataPath = path.join(__dirname, 'temp-test-tasks.json');
|
|
|
|
// Initialize mock data with multiple tags
|
|
mockTasksData = {
|
|
backlog: {
|
|
tasks: [
|
|
{
|
|
id: 1,
|
|
title: 'Backlog Task 1',
|
|
description: 'A task in backlog',
|
|
status: 'pending',
|
|
dependencies: [],
|
|
priority: 'medium',
|
|
tag: 'backlog'
|
|
},
|
|
{
|
|
id: 2,
|
|
title: 'Backlog Task 2',
|
|
description: 'Another task in backlog',
|
|
status: 'pending',
|
|
dependencies: [1],
|
|
priority: 'high',
|
|
tag: 'backlog'
|
|
},
|
|
{
|
|
id: 3,
|
|
title: 'Backlog Task 3',
|
|
description: 'Independent task',
|
|
status: 'pending',
|
|
dependencies: [],
|
|
priority: 'low',
|
|
tag: 'backlog'
|
|
}
|
|
]
|
|
},
|
|
'in-progress': {
|
|
tasks: [
|
|
{
|
|
id: 4,
|
|
title: 'In Progress Task 1',
|
|
description: 'A task being worked on',
|
|
status: 'in-progress',
|
|
dependencies: [],
|
|
priority: 'high',
|
|
tag: 'in-progress'
|
|
}
|
|
]
|
|
},
|
|
done: {
|
|
tasks: [
|
|
{
|
|
id: 5,
|
|
title: 'Completed Task 1',
|
|
description: 'A completed task',
|
|
status: 'done',
|
|
dependencies: [],
|
|
priority: 'medium',
|
|
tag: 'done'
|
|
}
|
|
]
|
|
}
|
|
};
|
|
|
|
// Setup mock utils
|
|
mockUtils.readJSON.mockReturnValue(mockTasksData);
|
|
mockUtils.writeJSON.mockImplementation((path, data, projectRoot, tag) => {
|
|
// Simulate writing to file
|
|
return Promise.resolve();
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.clearAllMocks();
|
|
// Clean up temp file if it exists
|
|
if (fs.existsSync(testDataPath)) {
|
|
fs.unlinkSync(testDataPath);
|
|
}
|
|
});
|
|
|
|
describe('Basic Cross-Tag Movement', () => {
|
|
it('should move a single task between tags successfully', async () => {
|
|
const taskIds = [1];
|
|
const sourceTag = 'backlog';
|
|
const targetTag = 'in-progress';
|
|
|
|
const result = await moveTasksBetweenTags(
|
|
testDataPath,
|
|
taskIds,
|
|
sourceTag,
|
|
targetTag,
|
|
{},
|
|
{ projectRoot: '/test/project' }
|
|
);
|
|
|
|
// Verify readJSON was called with correct parameters
|
|
expect(mockUtils.readJSON).toHaveBeenCalledWith(
|
|
testDataPath,
|
|
'/test/project',
|
|
sourceTag
|
|
);
|
|
|
|
// Verify writeJSON was called with updated data
|
|
expect(mockUtils.writeJSON).toHaveBeenCalledWith(
|
|
testDataPath,
|
|
expect.objectContaining({
|
|
backlog: expect.objectContaining({
|
|
tasks: expect.arrayContaining([
|
|
expect.objectContaining({ id: 2 }),
|
|
expect.objectContaining({ id: 3 })
|
|
])
|
|
}),
|
|
'in-progress': expect.objectContaining({
|
|
tasks: expect.arrayContaining([
|
|
expect.objectContaining({ id: 4 }),
|
|
expect.objectContaining({
|
|
id: 1,
|
|
tag: 'in-progress'
|
|
})
|
|
])
|
|
})
|
|
}),
|
|
'/test/project',
|
|
null
|
|
);
|
|
|
|
// Verify result structure
|
|
expect(result).toEqual({
|
|
message: 'Successfully moved 1 tasks from "backlog" to "in-progress"',
|
|
movedTasks: [
|
|
{
|
|
id: 1,
|
|
fromTag: 'backlog',
|
|
toTag: 'in-progress'
|
|
}
|
|
]
|
|
});
|
|
});
|
|
|
|
it('should move multiple tasks between tags', async () => {
|
|
const taskIds = [1, 3];
|
|
const sourceTag = 'backlog';
|
|
const targetTag = 'done';
|
|
|
|
const result = await moveTasksBetweenTags(
|
|
testDataPath,
|
|
taskIds,
|
|
sourceTag,
|
|
targetTag,
|
|
{},
|
|
{ projectRoot: '/test/project' }
|
|
);
|
|
|
|
// Verify the moved tasks are in the target tag
|
|
expect(mockUtils.writeJSON).toHaveBeenCalledWith(
|
|
testDataPath,
|
|
expect.objectContaining({
|
|
backlog: expect.objectContaining({
|
|
tasks: expect.arrayContaining([expect.objectContaining({ id: 2 })])
|
|
}),
|
|
done: expect.objectContaining({
|
|
tasks: expect.arrayContaining([
|
|
expect.objectContaining({ id: 5 }),
|
|
expect.objectContaining({
|
|
id: 1,
|
|
tag: 'done'
|
|
}),
|
|
expect.objectContaining({
|
|
id: 3,
|
|
tag: 'done'
|
|
})
|
|
])
|
|
})
|
|
}),
|
|
'/test/project',
|
|
null
|
|
);
|
|
|
|
// Verify result structure
|
|
expect(result.movedTasks).toHaveLength(2);
|
|
expect(result.movedTasks).toEqual(
|
|
expect.arrayContaining([
|
|
{ id: 1, fromTag: 'backlog', toTag: 'done' },
|
|
{ id: 3, fromTag: 'backlog', toTag: 'done' }
|
|
])
|
|
);
|
|
});
|
|
|
|
it('should create target tag if it does not exist', async () => {
|
|
const taskIds = [1];
|
|
const sourceTag = 'backlog';
|
|
const targetTag = 'new-tag';
|
|
|
|
const result = await moveTasksBetweenTags(
|
|
testDataPath,
|
|
taskIds,
|
|
sourceTag,
|
|
targetTag,
|
|
{},
|
|
{ projectRoot: '/test/project' }
|
|
);
|
|
|
|
// Verify new tag was created
|
|
expect(mockUtils.writeJSON).toHaveBeenCalledWith(
|
|
testDataPath,
|
|
expect.objectContaining({
|
|
'new-tag': expect.objectContaining({
|
|
tasks: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: 1,
|
|
tag: 'new-tag'
|
|
})
|
|
])
|
|
})
|
|
}),
|
|
'/test/project',
|
|
null
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Dependency Handling', () => {
|
|
it('should move task with dependencies when withDependencies is true', async () => {
|
|
const taskIds = [2]; // Task 2 depends on Task 1
|
|
const sourceTag = 'backlog';
|
|
const targetTag = 'in-progress';
|
|
|
|
const result = await moveTasksBetweenTags(
|
|
testDataPath,
|
|
taskIds,
|
|
sourceTag,
|
|
targetTag,
|
|
{ withDependencies: true },
|
|
{ projectRoot: '/test/project' }
|
|
);
|
|
|
|
// Verify both task 2 and its dependency (task 1) were moved
|
|
expect(mockUtils.writeJSON).toHaveBeenCalledWith(
|
|
testDataPath,
|
|
expect.objectContaining({
|
|
backlog: expect.objectContaining({
|
|
tasks: expect.arrayContaining([expect.objectContaining({ id: 3 })])
|
|
}),
|
|
'in-progress': expect.objectContaining({
|
|
tasks: expect.arrayContaining([
|
|
expect.objectContaining({ id: 4 }),
|
|
expect.objectContaining({
|
|
id: 1,
|
|
tag: 'in-progress'
|
|
}),
|
|
expect.objectContaining({
|
|
id: 2,
|
|
tag: 'in-progress'
|
|
})
|
|
])
|
|
})
|
|
}),
|
|
'/test/project',
|
|
null
|
|
);
|
|
});
|
|
|
|
it('should move task normally when ignoreDependencies is true (no cross-tag conflicts to ignore)', async () => {
|
|
const taskIds = [2]; // Task 2 depends on Task 1
|
|
const sourceTag = 'backlog';
|
|
const targetTag = 'in-progress';
|
|
|
|
const result = await moveTasksBetweenTags(
|
|
testDataPath,
|
|
taskIds,
|
|
sourceTag,
|
|
targetTag,
|
|
{ ignoreDependencies: true },
|
|
{ projectRoot: '/test/project' }
|
|
);
|
|
|
|
// Since dependencies only exist within tags, there are no cross-tag conflicts to ignore
|
|
// Task 2 moves with its dependencies intact
|
|
expect(mockUtils.writeJSON).toHaveBeenCalledWith(
|
|
testDataPath,
|
|
expect.objectContaining({
|
|
backlog: expect.objectContaining({
|
|
tasks: expect.arrayContaining([
|
|
expect.objectContaining({ id: 1 }),
|
|
expect.objectContaining({ id: 3 })
|
|
])
|
|
}),
|
|
'in-progress': expect.objectContaining({
|
|
tasks: expect.arrayContaining([
|
|
expect.objectContaining({ id: 4 }),
|
|
expect.objectContaining({
|
|
id: 2,
|
|
tag: 'in-progress',
|
|
dependencies: [1] // Dependencies preserved since no cross-tag conflicts
|
|
})
|
|
])
|
|
})
|
|
}),
|
|
'/test/project',
|
|
null
|
|
);
|
|
});
|
|
|
|
it('should provide advisory tips when ignoreDependencies breaks deps', async () => {
|
|
// Move a task that has dependencies so cross-tag conflicts would be broken
|
|
const taskIds = [2]; // backlog:2 depends on 1
|
|
const sourceTag = 'backlog';
|
|
const targetTag = 'in-progress';
|
|
|
|
// Override cross-tag detection to simulate conflicts for this case
|
|
const depManager = await import(
|
|
'../../scripts/modules/dependency-manager.js'
|
|
);
|
|
depManager.findCrossTagDependencies.mockReturnValueOnce([
|
|
{ taskId: 2, dependencyId: 1, dependencyTag: sourceTag }
|
|
]);
|
|
|
|
const result = await moveTasksBetweenTags(
|
|
testDataPath,
|
|
taskIds,
|
|
sourceTag,
|
|
targetTag,
|
|
{ ignoreDependencies: true },
|
|
{ projectRoot: '/test/project' }
|
|
);
|
|
|
|
expect(Array.isArray(result.tips)).toBe(true);
|
|
const expectedTips = [
|
|
'Run "task-master validate-dependencies" to check for dependency issues.',
|
|
'Run "task-master fix-dependencies" to automatically repair dangling dependencies.'
|
|
];
|
|
expect(result.tips).toHaveLength(expectedTips.length);
|
|
expect(result.tips).toEqual(expect.arrayContaining(expectedTips));
|
|
});
|
|
|
|
it('should move task without cross-tag dependency conflicts (since dependencies only exist within tags)', async () => {
|
|
const taskIds = [2]; // Task 2 depends on Task 1 (both in same tag)
|
|
const sourceTag = 'backlog';
|
|
const targetTag = 'in-progress';
|
|
|
|
// Since dependencies can only exist within the same tag,
|
|
// there should be no cross-tag conflicts
|
|
const result = await moveTasksBetweenTags(
|
|
testDataPath,
|
|
taskIds,
|
|
sourceTag,
|
|
targetTag,
|
|
{},
|
|
{ projectRoot: '/test/project' }
|
|
);
|
|
|
|
// Verify task was moved successfully (without dependencies)
|
|
expect(mockUtils.writeJSON).toHaveBeenCalledWith(
|
|
testDataPath,
|
|
expect.objectContaining({
|
|
backlog: expect.objectContaining({
|
|
tasks: expect.arrayContaining([
|
|
expect.objectContaining({ id: 1 }), // Task 1 stays in backlog
|
|
expect.objectContaining({ id: 3 })
|
|
])
|
|
}),
|
|
'in-progress': expect.objectContaining({
|
|
tasks: expect.arrayContaining([
|
|
expect.objectContaining({ id: 4 }),
|
|
expect.objectContaining({
|
|
id: 2,
|
|
tag: 'in-progress'
|
|
})
|
|
])
|
|
})
|
|
}),
|
|
'/test/project',
|
|
null
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Error Handling', () => {
|
|
it('should throw error for invalid source tag', async () => {
|
|
const taskIds = [1];
|
|
const sourceTag = 'nonexistent-tag';
|
|
const targetTag = 'in-progress';
|
|
|
|
// Mock readJSON to return data without the source tag
|
|
mockUtils.readJSON.mockReturnValue({
|
|
'in-progress': { tasks: [] }
|
|
});
|
|
|
|
await expect(
|
|
moveTasksBetweenTags(
|
|
testDataPath,
|
|
taskIds,
|
|
sourceTag,
|
|
targetTag,
|
|
{},
|
|
{ projectRoot: '/test/project' }
|
|
)
|
|
).rejects.toThrow('Source tag "nonexistent-tag" not found or invalid');
|
|
});
|
|
|
|
it('should throw error for invalid task IDs', async () => {
|
|
const taskIds = [999]; // Non-existent task ID
|
|
const sourceTag = 'backlog';
|
|
const targetTag = 'in-progress';
|
|
|
|
await expect(
|
|
moveTasksBetweenTags(
|
|
testDataPath,
|
|
taskIds,
|
|
sourceTag,
|
|
targetTag,
|
|
{},
|
|
{ projectRoot: '/test/project' }
|
|
)
|
|
).rejects.toThrow('Task 999 not found in source tag "backlog"');
|
|
});
|
|
|
|
it('should throw error for subtask movement', async () => {
|
|
const taskIds = ['1.1']; // Subtask ID
|
|
const sourceTag = 'backlog';
|
|
const targetTag = 'in-progress';
|
|
|
|
await expect(
|
|
moveTasksBetweenTags(
|
|
testDataPath,
|
|
taskIds,
|
|
sourceTag,
|
|
targetTag,
|
|
{},
|
|
{ projectRoot: '/test/project' }
|
|
)
|
|
).rejects.toThrow('Cannot move subtasks directly between tags');
|
|
});
|
|
|
|
it('should handle ID conflicts in target tag', async () => {
|
|
// Setup data with conflicting IDs
|
|
const conflictingData = {
|
|
backlog: {
|
|
tasks: [
|
|
{
|
|
id: 1,
|
|
title: 'Backlog Task',
|
|
tag: 'backlog'
|
|
}
|
|
]
|
|
},
|
|
'in-progress': {
|
|
tasks: [
|
|
{
|
|
id: 1, // Same ID as in backlog
|
|
title: 'In Progress Task',
|
|
tag: 'in-progress'
|
|
}
|
|
]
|
|
}
|
|
};
|
|
|
|
mockUtils.readJSON.mockReturnValue(conflictingData);
|
|
|
|
const taskIds = [1];
|
|
const sourceTag = 'backlog';
|
|
const targetTag = 'in-progress';
|
|
|
|
await expect(
|
|
moveTasksBetweenTags(
|
|
testDataPath,
|
|
taskIds,
|
|
sourceTag,
|
|
targetTag,
|
|
{},
|
|
{ projectRoot: '/test/project' }
|
|
)
|
|
).rejects.toThrow('Task 1 already exists in target tag "in-progress"');
|
|
|
|
// Validate suggestions on the error payload
|
|
try {
|
|
await moveTasksBetweenTags(
|
|
testDataPath,
|
|
taskIds,
|
|
sourceTag,
|
|
targetTag,
|
|
{},
|
|
{ projectRoot: '/test/project' }
|
|
);
|
|
} catch (err) {
|
|
expect(err.code).toBe('TASK_ALREADY_EXISTS');
|
|
expect(Array.isArray(err.data?.suggestions)).toBe(true);
|
|
const s = (err.data?.suggestions || []).join(' ');
|
|
expect(s).toContain('different target tag');
|
|
expect(s).toContain('different set of IDs');
|
|
expect(s).toContain('within-tag');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Edge Cases', () => {
|
|
it('should handle empty task list in source tag', async () => {
|
|
const emptyData = {
|
|
backlog: { tasks: [] },
|
|
'in-progress': { tasks: [] }
|
|
};
|
|
|
|
mockUtils.readJSON.mockReturnValue(emptyData);
|
|
|
|
const taskIds = [1];
|
|
const sourceTag = 'backlog';
|
|
const targetTag = 'in-progress';
|
|
|
|
await expect(
|
|
moveTasksBetweenTags(
|
|
testDataPath,
|
|
taskIds,
|
|
sourceTag,
|
|
targetTag,
|
|
{},
|
|
{ projectRoot: '/test/project' }
|
|
)
|
|
).rejects.toThrow('Task 1 not found in source tag "backlog"');
|
|
});
|
|
|
|
it('should preserve task metadata during move', async () => {
|
|
const taskIds = [1];
|
|
const sourceTag = 'backlog';
|
|
const targetTag = 'in-progress';
|
|
|
|
const result = await moveTasksBetweenTags(
|
|
testDataPath,
|
|
taskIds,
|
|
sourceTag,
|
|
targetTag,
|
|
{},
|
|
{ projectRoot: '/test/project' }
|
|
);
|
|
|
|
// Verify task metadata is preserved
|
|
expect(mockUtils.writeJSON).toHaveBeenCalledWith(
|
|
testDataPath,
|
|
expect.objectContaining({
|
|
'in-progress': expect.objectContaining({
|
|
tasks: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: 1,
|
|
title: 'Backlog Task 1',
|
|
description: 'A task in backlog',
|
|
status: 'pending',
|
|
priority: 'medium',
|
|
tag: 'in-progress', // Tag should be updated
|
|
metadata: expect.objectContaining({
|
|
moveHistory: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
fromTag: 'backlog',
|
|
toTag: 'in-progress',
|
|
timestamp: expect.any(String)
|
|
})
|
|
])
|
|
})
|
|
})
|
|
])
|
|
})
|
|
}),
|
|
'/test/project',
|
|
null
|
|
);
|
|
});
|
|
|
|
// Note: force flag deprecated for cross-tag moves; covered by with/ignore dependencies tests
|
|
});
|
|
|
|
describe('Complex Scenarios', () => {
|
|
it('should handle complex moves without cross-tag conflicts (dependencies only within tags)', async () => {
|
|
// Setup data with valid within-tag dependencies
|
|
const validData = {
|
|
backlog: {
|
|
tasks: [
|
|
{
|
|
id: 1,
|
|
title: 'Task 1',
|
|
dependencies: [], // No dependencies
|
|
tag: 'backlog'
|
|
},
|
|
{
|
|
id: 3,
|
|
title: 'Task 3',
|
|
dependencies: [1], // Depends on Task 1 (same tag)
|
|
tag: 'backlog'
|
|
}
|
|
]
|
|
},
|
|
'in-progress': {
|
|
tasks: [
|
|
{
|
|
id: 2,
|
|
title: 'Task 2',
|
|
dependencies: [], // No dependencies
|
|
tag: 'in-progress'
|
|
}
|
|
]
|
|
}
|
|
};
|
|
|
|
mockUtils.readJSON.mockReturnValue(validData);
|
|
|
|
const taskIds = [3];
|
|
const sourceTag = 'backlog';
|
|
const targetTag = 'in-progress';
|
|
|
|
// Should succeed since there are no cross-tag conflicts
|
|
const result = await moveTasksBetweenTags(
|
|
testDataPath,
|
|
taskIds,
|
|
sourceTag,
|
|
targetTag,
|
|
{},
|
|
{ projectRoot: '/test/project' }
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
message: 'Successfully moved 1 tasks from "backlog" to "in-progress"',
|
|
movedTasks: [{ id: 3, fromTag: 'backlog', toTag: 'in-progress' }]
|
|
});
|
|
});
|
|
|
|
it('should handle bulk move with mixed dependency scenarios', async () => {
|
|
const taskIds = [1, 2, 3]; // Multiple tasks with dependencies
|
|
const sourceTag = 'backlog';
|
|
const targetTag = 'in-progress';
|
|
|
|
const result = await moveTasksBetweenTags(
|
|
testDataPath,
|
|
taskIds,
|
|
sourceTag,
|
|
targetTag,
|
|
{ withDependencies: true },
|
|
{ projectRoot: '/test/project' }
|
|
);
|
|
|
|
// Verify all tasks were moved
|
|
expect(mockUtils.writeJSON).toHaveBeenCalledWith(
|
|
testDataPath,
|
|
expect.objectContaining({
|
|
backlog: expect.objectContaining({
|
|
tasks: [] // All tasks should be moved
|
|
}),
|
|
'in-progress': expect.objectContaining({
|
|
tasks: expect.arrayContaining([
|
|
expect.objectContaining({ id: 4 }),
|
|
expect.objectContaining({ id: 1, tag: 'in-progress' }),
|
|
expect.objectContaining({ id: 2, tag: 'in-progress' }),
|
|
expect.objectContaining({ id: 3, tag: 'in-progress' })
|
|
])
|
|
})
|
|
}),
|
|
'/test/project',
|
|
null
|
|
);
|
|
|
|
// Verify result structure
|
|
expect(result.movedTasks).toHaveLength(3);
|
|
expect(result.movedTasks).toEqual(
|
|
expect.arrayContaining([
|
|
{ id: 1, fromTag: 'backlog', toTag: 'in-progress' },
|
|
{ id: 2, fromTag: 'backlog', toTag: 'in-progress' },
|
|
{ id: 3, fromTag: 'backlog', toTag: 'in-progress' }
|
|
])
|
|
);
|
|
});
|
|
});
|
|
});
|