diff --git a/mcp-server/src/core/direct-functions/move-task-cross-tag.js b/mcp-server/src/core/direct-functions/move-task-cross-tag.js index 636d89f559..4b38262a65 100644 --- a/mcp-server/src/core/direct-functions/move-task-cross-tag.js +++ b/mcp-server/src/core/direct-functions/move-task-cross-tag.js @@ -117,11 +117,23 @@ export async function moveTaskCrossTagDirect(args, log, context = {}) { { projectRoot } ); + // Check if any tasks were renumbered during the move + const renumberedTasks = (result.movedTasks || []).filter( + (t) => t.newId !== undefined + ); + let message = `Successfully moved ${sourceIds.length} task(s) from "${args.sourceTag}" to "${args.targetTag}"`; + if (renumberedTasks.length > 0) { + const renumberDetails = renumberedTasks + .map((t) => `${t.originalId} → ${t.newId}`) + .join(', '); + message += `. Renumbered to avoid ID collisions: ${renumberDetails}`; + } + return { success: true, data: { ...result, - message: `Successfully moved ${sourceIds.length} task(s) from "${args.sourceTag}" to "${args.targetTag}"`, + message, moveOptions, sourceTag: args.sourceTag, targetTag: args.targetTag diff --git a/scripts/modules/config-manager.js b/scripts/modules/config-manager.js index 688bbfd6ae..df2ff908d4 100644 --- a/scripts/modules/config-manager.js +++ b/scripts/modules/config-manager.js @@ -58,7 +58,8 @@ const DEFAULTS = { responseLanguage: 'English', enableCodebaseAnalysis: true, enableProxy: false, - anonymousTelemetry: true // Allow users to opt out of Sentry telemetry for local storage + anonymousTelemetry: true, // Allow users to opt out of Sentry telemetry for local storage + slimDoneTasks: true // Auto-slim completed tasks to reduce tasks.json size }, claudeCode: {}, codexCli: {}, @@ -747,6 +748,12 @@ function getAnonymousTelemetryEnabled(explicitRoot = null) { return config.anonymousTelemetry !== false; // Default true if undefined } +function isSlimDoneTasksEnabled(explicitRoot = null) { + // Return boolean-safe value with default true (opt-in by default) + const config = getGlobalConfig(explicitRoot); + return config.slimDoneTasks !== false; // Default true if undefined +} + function isProxyEnabled(session = null, projectRoot = null) { // Priority 1: Environment variable const envFlag = resolveEnvVariable( @@ -1305,6 +1312,7 @@ export { getProxyEnabled, isProxyEnabled, getAnonymousTelemetryEnabled, + isSlimDoneTasksEnabled, getParametersForRole, getUserId, // Operating mode diff --git a/scripts/modules/task-manager/move-task.js b/scripts/modules/task-manager/move-task.js index f900b93502..7bd699ff2b 100644 --- a/scripts/modules/task-manager/move-task.js +++ b/scripts/modules/task-manager/move-task.js @@ -816,6 +816,23 @@ async function resolveDependencies( }; } +/** + * Get the next available task ID in the target tag. + * Finds the maximum existing ID among target tag tasks and any already-assigned + * new IDs from the current move batch, then returns max + 1. + * @param {Object} rawData - Raw data object + * @param {string} targetTag - Target tag name + * @param {Map} idRemapping - Map of old ID -> new ID for tasks already processed in this batch + * @returns {number} Next available task ID + */ +function getNextAvailableId(rawData, targetTag, idRemapping) { + const existingIds = rawData[targetTag].tasks.map((t) => t.id); + const remappedIds = Array.from(idRemapping.values()); + const allIds = [...existingIds, ...remappedIds]; + if (allIds.length === 0) return 1; + return Math.max(...allIds) + 1; +} + /** * Execute the actual move operation * @param {Array} tasksToMove - Array of task IDs to move @@ -836,6 +853,8 @@ async function executeMoveOperation( ) { const { projectRoot } = context; const movedTasks = []; + // Track ID remapping: oldId -> newId (only for tasks that needed renumbering) + const idRemapping = new Map(); // Move each task from source to target tag for (const taskId of tasksToMove) { @@ -860,22 +879,39 @@ async function executeMoveOperation( const existingTaskIndex = rawData[targetTag].tasks.findIndex( (t) => t.id === normalizedTaskId ); + + let assignedId = normalizedTaskId; + if (existingTaskIndex !== -1) { - throw new MoveTaskError( - MOVE_ERROR_CODES.TASK_ALREADY_EXISTS, - `Task ${taskId} already exists in target tag "${targetTag}"`, - { - conflictingId: normalizedTaskId, - targetTag, - suggestions: [ - 'Choose a different target tag without conflicting IDs', - 'Move a different set of IDs (avoid existing ones)', - 'If needed, move within-tag to a new ID first, then cross-tag move' - ] - } + // ID collision detected — auto-renumber to the next available ID + assignedId = getNextAvailableId(rawData, targetTag, idRemapping); + idRemapping.set(normalizedTaskId, assignedId); + log( + 'info', + `Task ${normalizedTaskId} conflicts with existing ID in "${targetTag}", renumbered to ${assignedId}` ); } + // Apply the new ID to the task + taskToMove.id = assignedId; + + // Update internal subtask dependency references that pointed to the old parent ID + if (Array.isArray(taskToMove.subtasks)) { + taskToMove.subtasks.forEach((subtask) => { + if (Array.isArray(subtask.dependencies)) { + subtask.dependencies = subtask.dependencies.map((dep) => { + if (typeof dep === 'string' && dep.includes('.')) { + const [depParent, depSub] = dep.split('.'); + if (parseInt(depParent, 10) === normalizedTaskId) { + return `${assignedId}.${depSub}`; + } + } + return dep; + }); + } + }); + } + // Remove from source tag rawData[sourceTag].tasks.splice(sourceTaskIndex, 1); @@ -887,13 +923,65 @@ async function executeMoveOperation( ); rawData[targetTag].tasks.push(taskWithPreservedMetadata); - movedTasks.push({ + const moveEntry = { id: taskId, fromTag: sourceTag, toTag: targetTag - }); + }; + if (assignedId !== normalizedTaskId) { + moveEntry.originalId = normalizedTaskId; + moveEntry.newId = assignedId; + } + movedTasks.push(moveEntry); - log('info', `Moved task ${taskId} from "${sourceTag}" to "${targetTag}"`); + log( + 'info', + `Moved task ${taskId} from "${sourceTag}" to "${targetTag}"${assignedId !== normalizedTaskId ? ` (renumbered to ${assignedId})` : ''}` + ); + } + + // After all tasks are moved, update cross-references within the moved batch. + // If task A depended on task B and both were moved but B got renumbered, + // update A's dependency to point to B's new ID. + if (idRemapping.size > 0) { + for (const taskId of tasksToMove) { + const normalizedTaskId = + typeof taskId === 'string' ? parseInt(taskId, 10) : taskId; + // Find the task in the target tag (it may have been renumbered) + const finalId = idRemapping.get(normalizedTaskId) || normalizedTaskId; + const movedTask = rawData[targetTag].tasks.find( + (t) => t.id === finalId + ); + if (movedTask && Array.isArray(movedTask.dependencies)) { + movedTask.dependencies = movedTask.dependencies.map((dep) => { + const normalizedDep = normalizeDependency(dep); + if ( + Number.isFinite(normalizedDep) && + idRemapping.has(normalizedDep) + ) { + return idRemapping.get(normalizedDep); + } + return dep; + }); + } + // Also update subtask dependencies that reference remapped IDs + if (movedTask && Array.isArray(movedTask.subtasks)) { + movedTask.subtasks.forEach((subtask) => { + if (Array.isArray(subtask.dependencies)) { + subtask.dependencies = subtask.dependencies.map((dep) => { + const normalizedDep = normalizeDependency(dep); + if ( + Number.isFinite(normalizedDep) && + idRemapping.has(normalizedDep) + ) { + return idRemapping.get(normalizedDep); + } + return dep; + }); + } + }); + } + } } return { rawData, movedTasks }; @@ -922,8 +1010,19 @@ async function finalizeMove( // Write the updated data writeJSON(tasksPath, rawData, projectRoot, null); + // Check if any tasks were renumbered during the move + const renumberedTasks = movedTasks.filter((t) => t.newId !== undefined); + + let message = `Successfully moved ${movedTasks.length} tasks from "${sourceTag}" to "${targetTag}"`; + if (renumberedTasks.length > 0) { + const renumberDetails = renumberedTasks + .map((t) => `${t.originalId} → ${t.newId}`) + .join(', '); + message += `. Renumbered to avoid ID collisions: ${renumberDetails}`; + } + const response = { - message: `Successfully moved ${movedTasks.length} tasks from "${sourceTag}" to "${targetTag}"`, + message, movedTasks }; @@ -938,6 +1037,14 @@ async function finalizeMove( ]; } + // If tasks were renumbered, suggest validating dependencies + if (renumberedTasks.length > 0) { + if (!response.tips) response.tips = []; + response.tips.push( + 'Some tasks were renumbered to avoid ID collisions. Run "task-master validate-dependencies" to verify dependency integrity.' + ); + } + return response; } diff --git a/scripts/modules/task-manager/set-task-status.js b/scripts/modules/task-manager/set-task-status.js index 2ade22051a..06771b3c34 100644 --- a/scripts/modules/task-manager/set-task-status.js +++ b/scripts/modules/task-manager/set-task-status.js @@ -6,7 +6,7 @@ import { TASK_STATUS_OPTIONS, isValidTaskStatus } from '../../../src/constants/task-status.js'; -import { getDebugFlag } from '../config-manager.js'; +import { getDebugFlag, isSlimDoneTasksEnabled } from '../config-manager.js'; import { validateTaskDependencies } from '../dependency-manager.js'; import { displayBanner } from '../ui.js'; import { @@ -84,6 +84,9 @@ async function setTaskStatus(tasksPath, taskIdInput, newStatus, options = {}) { const taskIds = taskIdInput.split(',').map((id) => id.trim()); const updatedTasks = []; + // Check if auto-slim on done is enabled + const slimOnDone = isSlimDoneTasksEnabled(projectRoot); + // Update each task and capture old status for display for (const id of taskIds) { // Capture old status before updating @@ -106,7 +109,9 @@ async function setTaskStatus(tasksPath, taskIdInput, newStatus, options = {}) { oldStatus = task?.status || 'pending'; } - await updateSingleTaskStatus(tasksPath, id, newStatus, data, !isMcpMode); + await updateSingleTaskStatus(tasksPath, id, newStatus, data, !isMcpMode, { + slimOnDone + }); updatedTasks.push({ id, oldStatus, newStatus }); } diff --git a/scripts/modules/task-manager/slim-task.js b/scripts/modules/task-manager/slim-task.js new file mode 100644 index 0000000000..7bf08f67b8 --- /dev/null +++ b/scripts/modules/task-manager/slim-task.js @@ -0,0 +1,138 @@ +import { log } from '../utils.js'; + +/** + * Maximum length for truncated description on slimmed tasks. + * @type {number} + */ +const DESCRIPTION_TRUNCATE_LENGTH = 200; + +/** + * Slim a completed task by removing verbose fields that are no longer actionable. + * Removes `details` and `testStrategy`, truncates `description` to 200 chars + ellipsis. + * This is a one-way operation — git history preserves the original content. + * + * @param {Object} task - The task object to slim + * @returns {Object} The same task object, mutated in place + */ +function slimTask(task) { + if (!task) return task; + + // Clear verbose fields + if (task.details) { + task.details = ''; + } + + if (task.testStrategy) { + task.testStrategy = ''; + } + + // Truncate description to max length + if ( + task.description && + task.description.length > DESCRIPTION_TRUNCATE_LENGTH + ) { + task.description = + task.description.substring(0, DESCRIPTION_TRUNCATE_LENGTH) + '...'; + } + + return task; +} + +/** + * Slim a completed subtask by removing verbose fields. + * Subtasks typically have fewer fields, but we still clear details/testStrategy + * and truncate description. + * + * @param {Object} subtask - The subtask object to slim + * @returns {Object} The same subtask object, mutated in place + */ +function slimSubtask(subtask) { + if (!subtask) return subtask; + + if (subtask.details) { + subtask.details = ''; + } + + if (subtask.testStrategy) { + subtask.testStrategy = ''; + } + + if ( + subtask.description && + subtask.description.length > DESCRIPTION_TRUNCATE_LENGTH + ) { + subtask.description = + subtask.description.substring(0, DESCRIPTION_TRUNCATE_LENGTH) + '...'; + } + + return subtask; +} + +/** + * Slim a task and all its subtasks when the task transitions to "done". + * Only slims if the transition is TO a done/completed status. + * + * @param {Object} task - The task object + * @param {string} oldStatus - The previous status + * @param {string} newStatus - The new status being set + * @returns {Object} The task, slimmed if transitioning to done + */ +function slimTaskOnComplete(task, oldStatus, newStatus) { + const isDoneStatus = + newStatus.toLowerCase() === 'done' || + newStatus.toLowerCase() === 'completed'; + const wasDone = + oldStatus.toLowerCase() === 'done' || + oldStatus.toLowerCase() === 'completed'; + + // Only slim on transition TO done, not if already done + if (!isDoneStatus || wasDone) { + return task; + } + + log('info', `Slimming completed task ${task.id}: "${task.title}"`); + + slimTask(task); + + // Also slim all subtasks + if (task.subtasks && task.subtasks.length > 0) { + for (const subtask of task.subtasks) { + slimSubtask(subtask); + } + } + + return task; +} + +/** + * Slim a subtask when it transitions to "done". + * + * @param {Object} subtask - The subtask object + * @param {string} oldStatus - The previous status + * @param {string} newStatus - The new status being set + * @returns {Object} The subtask, slimmed if transitioning to done + */ +function slimSubtaskOnComplete(subtask, oldStatus, newStatus) { + const isDoneStatus = + newStatus.toLowerCase() === 'done' || + newStatus.toLowerCase() === 'completed'; + const wasDone = + oldStatus.toLowerCase() === 'done' || + oldStatus.toLowerCase() === 'completed'; + + if (!isDoneStatus || wasDone) { + return subtask; + } + + log('info', `Slimming completed subtask ${subtask.id}: "${subtask.title}"`); + + return slimSubtask(subtask); +} + +export { + DESCRIPTION_TRUNCATE_LENGTH, + slimTask, + slimSubtask, + slimTaskOnComplete, + slimSubtaskOnComplete +}; diff --git a/scripts/modules/task-manager/update-single-task-status.js b/scripts/modules/task-manager/update-single-task-status.js index 4a8b425c13..03fed76bd0 100644 --- a/scripts/modules/task-manager/update-single-task-status.js +++ b/scripts/modules/task-manager/update-single-task-status.js @@ -1,7 +1,11 @@ import chalk from 'chalk'; -import { isValidTaskStatus } from '../../../src/constants/task-status.js'; +import { + TASK_STATUS_OPTIONS, + isValidTaskStatus +} from '../../../src/constants/task-status.js'; import { log } from '../utils.js'; +import { slimSubtaskOnComplete, slimTaskOnComplete } from './slim-task.js'; /** * Update the status of a single task @@ -10,14 +14,18 @@ import { log } from '../utils.js'; * @param {string} newStatus - New status * @param {Object} data - Tasks data * @param {boolean} showUi - Whether to show UI elements + * @param {Object} [statusOptions] - Additional options + * @param {boolean} [statusOptions.slimOnDone=false] - Whether to slim tasks when marking as done */ async function updateSingleTaskStatus( tasksPath, taskIdInput, newStatus, data, - showUi = true + showUi = true, + statusOptions = {} ) { + const { slimOnDone = false } = statusOptions || {}; if (!isValidTaskStatus(newStatus)) { throw new Error( `Error: Invalid status value: ${newStatus}. Use one of: ${TASK_STATUS_OPTIONS.join(', ')}` @@ -52,6 +60,11 @@ async function updateSingleTaskStatus( const oldStatus = subtask.status || 'pending'; subtask.status = newStatus; + // Slim subtask if transitioning to done and slimming is enabled + if (slimOnDone) { + slimSubtaskOnComplete(subtask, oldStatus, newStatus); + } + log( 'info', `Updated subtask ${parentId}.${subtaskId} status from '${oldStatus}' to '${newStatus}'` @@ -127,6 +140,11 @@ async function updateSingleTaskStatus( }); } } + + // Slim task (and its subtasks) if transitioning to done and slimming is enabled + if (slimOnDone) { + slimTaskOnComplete(task, oldStatus, newStatus); + } } } diff --git a/tests/integration/move-task-cross-tag.integration.test.js b/tests/integration/move-task-cross-tag.integration.test.js index 16c00e9a63..15e96f771f 100644 --- a/tests/integration/move-task-cross-tag.integration.test.js +++ b/tests/integration/move-task-cross-tag.integration.test.js @@ -550,7 +550,7 @@ describe('Cross-Tag Task Movement Integration Tests', () => { ).rejects.toThrow('Cannot move subtasks directly between tags'); }); - it('should handle ID conflicts in target tag', async () => { + it('should auto-renumber tasks on ID conflicts in target tag', async () => { // Setup data with conflicting IDs const conflictingData = { backlog: { @@ -558,7 +558,8 @@ describe('Cross-Tag Task Movement Integration Tests', () => { { id: 1, title: 'Backlog Task', - tag: 'backlog' + tag: 'backlog', + dependencies: [] } ] }, @@ -567,7 +568,8 @@ describe('Cross-Tag Task Movement Integration Tests', () => { { id: 1, // Same ID as in backlog title: 'In Progress Task', - tag: 'in-progress' + tag: 'in-progress', + dependencies: [] } ] } @@ -579,35 +581,32 @@ describe('Cross-Tag Task Movement Integration Tests', () => { 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"'); + const result = await moveTasksBetweenTags( + testDataPath, + taskIds, + sourceTag, + targetTag, + {}, + { projectRoot: '/test/project' } + ); - // 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'); - } + // Task should be moved successfully with a new ID + expect(result.message).toContain('Successfully moved 1 tasks'); + expect(result.message).toContain('Renumbered'); + expect(result.movedTasks).toHaveLength(1); + expect(result.movedTasks[0].originalId).toBe(1); + expect(result.movedTasks[0].newId).toBe(2); // Next available ID after existing ID 1 + + // Verify the target tag now has both tasks + expect(mockUtils.writeJSON).toHaveBeenCalled(); + const writtenData = mockUtils.writeJSON.mock.calls[0][1]; + expect(writtenData['in-progress'].tasks).toHaveLength(2); + expect(writtenData['in-progress'].tasks[0].id).toBe(1); // Original task + expect(writtenData['in-progress'].tasks[1].id).toBe(2); // Renumbered moved task + expect(writtenData['in-progress'].tasks[1].title).toBe('Backlog Task'); + + // Source tag should be empty + expect(writtenData.backlog.tasks).toHaveLength(0); }); }); diff --git a/tests/unit/config-manager.test.js b/tests/unit/config-manager.test.js index 41cd490c03..d03b9a89cb 100644 --- a/tests/unit/config-manager.test.js +++ b/tests/unit/config-manager.test.js @@ -148,7 +148,8 @@ const DEFAULT_CONFIG = { bedrockBaseURL: 'https://bedrock.us-east-1.amazonaws.com', enableCodebaseAnalysis: true, enableProxy: false, - responseLanguage: 'English' + responseLanguage: 'English', + slimDoneTasks: true }, claudeCode: {}, codexCli: {}, diff --git a/tests/unit/scripts/modules/task-manager/set-task-status.test.js b/tests/unit/scripts/modules/task-manager/set-task-status.test.js index 7628b97ce7..ab2dc930b7 100644 --- a/tests/unit/scripts/modules/task-manager/set-task-status.test.js +++ b/tests/unit/scripts/modules/task-manager/set-task-status.test.js @@ -71,7 +71,8 @@ jest.unstable_mockModule( jest.unstable_mockModule( '../../../../../scripts/modules/config-manager.js', () => ({ - getDebugFlag: jest.fn(() => false) + getDebugFlag: jest.fn(() => false), + isSlimDoneTasksEnabled: jest.fn(() => true) }) ); @@ -507,7 +508,8 @@ describe('setTaskStatus', () => { tag: 'master', _rawTaggedData: expect.any(Object) }), - false + false, + expect.objectContaining({ slimOnDone: expect.any(Boolean) }) ); expect(updateSingleTaskStatus).toHaveBeenCalledWith( tasksPath, @@ -518,7 +520,8 @@ describe('setTaskStatus', () => { tag: 'master', _rawTaggedData: expect.any(Object) }), - false + false, + expect.objectContaining({ slimOnDone: expect.any(Boolean) }) ); expect(updateSingleTaskStatus).toHaveBeenCalledWith( tasksPath, @@ -529,7 +532,8 @@ describe('setTaskStatus', () => { tag: 'master', _rawTaggedData: expect.any(Object) }), - false + false, + expect.objectContaining({ slimOnDone: expect.any(Boolean) }) ); expect(result).toBeDefined(); }); diff --git a/tests/unit/scripts/modules/task-manager/slim-task.test.js b/tests/unit/scripts/modules/task-manager/slim-task.test.js new file mode 100644 index 0000000000..b333ed7c30 --- /dev/null +++ b/tests/unit/scripts/modules/task-manager/slim-task.test.js @@ -0,0 +1,417 @@ +/** + * Tests for the slim-task.js module + * + * Covers: + * - Description truncation at 200 chars + ellipsis + * - details and testStrategy cleared to empty string + * - Slimming only occurs on transition TO done (not already-done tasks) + * - Subtask slimming behavior + */ +import { jest } from '@jest/globals'; + +// Mock the utils.js log function before importing the module under test +jest.unstable_mockModule('../../../../../scripts/modules/utils.js', () => ({ + log: jest.fn() +})); + +const { + DESCRIPTION_TRUNCATE_LENGTH, + slimTask, + slimSubtask, + slimTaskOnComplete, + slimSubtaskOnComplete +} = await import('../../../../../scripts/modules/task-manager/slim-task.js'); + +describe('slim-task module', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('slimTask', () => { + test('should clear details field to empty string', () => { + const task = { + id: 1, + title: 'Test Task', + description: 'Short description', + details: 'This is a very detailed explanation of the task.', + testStrategy: '', + status: 'done' + }; + + slimTask(task); + + expect(task.details).toBe(''); + }); + + test('should clear testStrategy field to empty string', () => { + const task = { + id: 1, + title: 'Test Task', + description: 'Short description', + details: '', + testStrategy: + 'Run unit tests, integration tests, and manual QA testing.', + status: 'done' + }; + + slimTask(task); + + expect(task.testStrategy).toBe(''); + }); + + test('should truncate description longer than 200 chars and append ellipsis', () => { + const longDescription = 'A'.repeat(250); + const task = { + id: 1, + title: 'Test Task', + description: longDescription, + details: '', + testStrategy: '', + status: 'done' + }; + + slimTask(task); + + expect(task.description.length).toBe( + DESCRIPTION_TRUNCATE_LENGTH + 3 + ); // 200 + '...' + expect(task.description).toBe('A'.repeat(200) + '...'); + }); + + test('should not truncate description that is exactly 200 chars', () => { + const exactDescription = 'B'.repeat(200); + const task = { + id: 1, + title: 'Test Task', + description: exactDescription, + details: '', + testStrategy: '', + status: 'done' + }; + + slimTask(task); + + expect(task.description).toBe(exactDescription); + expect(task.description.length).toBe(200); + }); + + test('should not truncate description shorter than 200 chars', () => { + const shortDescription = 'Short description that is well under the limit.'; + const task = { + id: 1, + title: 'Test Task', + description: shortDescription, + details: 'some details', + testStrategy: 'some strategy', + status: 'done' + }; + + slimTask(task); + + expect(task.description).toBe(shortDescription); + // But details and testStrategy should still be cleared + expect(task.details).toBe(''); + expect(task.testStrategy).toBe(''); + }); + + test('should handle null task gracefully', () => { + expect(slimTask(null)).toBeNull(); + }); + + test('should handle undefined task gracefully', () => { + expect(slimTask(undefined)).toBeUndefined(); + }); + + test('should handle task with no details or testStrategy fields', () => { + const task = { + id: 1, + title: 'Test Task', + description: 'Some description', + status: 'done' + }; + + const result = slimTask(task); + + expect(result).toBe(task); + expect(task.description).toBe('Some description'); + }); + + test('should return the same task object (mutated in place)', () => { + const task = { + id: 1, + title: 'Test Task', + description: 'Desc', + details: 'Details', + testStrategy: 'Strategy', + status: 'done' + }; + + const result = slimTask(task); + + expect(result).toBe(task); // Same reference + }); + }); + + describe('slimSubtask', () => { + test('should clear details and testStrategy on subtask', () => { + const subtask = { + id: 1, + title: 'Subtask 1', + description: 'A subtask description', + details: 'Subtask details here', + testStrategy: 'Subtask test strategy', + status: 'done' + }; + + slimSubtask(subtask); + + expect(subtask.details).toBe(''); + expect(subtask.testStrategy).toBe(''); + expect(subtask.description).toBe('A subtask description'); + }); + + test('should truncate long subtask description', () => { + const subtask = { + id: 1, + title: 'Subtask 1', + description: 'C'.repeat(300), + details: '', + testStrategy: '', + status: 'done' + }; + + slimSubtask(subtask); + + expect(subtask.description).toBe('C'.repeat(200) + '...'); + }); + + test('should handle null subtask gracefully', () => { + expect(slimSubtask(null)).toBeNull(); + }); + }); + + describe('slimTaskOnComplete', () => { + test('should slim task when transitioning from pending to done', () => { + const task = { + id: 1, + title: 'Test Task', + description: 'D'.repeat(250), + details: 'Detailed info', + testStrategy: 'Test strategy', + status: 'done', + subtasks: [] + }; + + slimTaskOnComplete(task, 'pending', 'done'); + + expect(task.details).toBe(''); + expect(task.testStrategy).toBe(''); + expect(task.description).toBe('D'.repeat(200) + '...'); + }); + + test('should slim task when transitioning from in-progress to done', () => { + const task = { + id: 1, + title: 'Test Task', + description: 'Short desc', + details: 'Some details', + testStrategy: 'Strategy', + status: 'done', + subtasks: [] + }; + + slimTaskOnComplete(task, 'in-progress', 'done'); + + expect(task.details).toBe(''); + expect(task.testStrategy).toBe(''); + }); + + test('should slim task when transitioning to completed status', () => { + const task = { + id: 1, + title: 'Test Task', + description: 'Short desc', + details: 'Some details', + testStrategy: 'Strategy', + status: 'completed', + subtasks: [] + }; + + slimTaskOnComplete(task, 'pending', 'completed'); + + expect(task.details).toBe(''); + expect(task.testStrategy).toBe(''); + }); + + test('should NOT slim task when already done (done -> done)', () => { + const task = { + id: 1, + title: 'Test Task', + description: 'E'.repeat(250), + details: 'Should remain', + testStrategy: 'Should remain', + status: 'done', + subtasks: [] + }; + + slimTaskOnComplete(task, 'done', 'done'); + + expect(task.details).toBe('Should remain'); + expect(task.testStrategy).toBe('Should remain'); + expect(task.description).toBe('E'.repeat(250)); + }); + + test('should NOT slim task when transitioning away from done', () => { + const task = { + id: 1, + title: 'Test Task', + description: 'E'.repeat(250), + details: 'Should remain', + testStrategy: 'Should remain', + status: 'pending', + subtasks: [] + }; + + slimTaskOnComplete(task, 'done', 'pending'); + + expect(task.details).toBe('Should remain'); + expect(task.testStrategy).toBe('Should remain'); + }); + + test('should NOT slim task when transitioning between non-done statuses', () => { + const task = { + id: 1, + title: 'Test Task', + description: 'E'.repeat(250), + details: 'Should remain', + testStrategy: 'Should remain', + status: 'in-progress', + subtasks: [] + }; + + slimTaskOnComplete(task, 'pending', 'in-progress'); + + expect(task.details).toBe('Should remain'); + expect(task.testStrategy).toBe('Should remain'); + }); + + test('should also slim all subtasks when parent transitions to done', () => { + const task = { + id: 1, + title: 'Test Task', + description: 'Short desc', + details: 'Parent details', + testStrategy: 'Parent strategy', + status: 'done', + subtasks: [ + { + id: 1, + title: 'Subtask 1', + description: 'F'.repeat(250), + details: 'Subtask 1 details', + testStrategy: 'Subtask 1 strategy', + status: 'done' + }, + { + id: 2, + title: 'Subtask 2', + description: 'Short subtask desc', + details: 'Subtask 2 details', + testStrategy: 'Subtask 2 strategy', + status: 'done' + } + ] + }; + + slimTaskOnComplete(task, 'pending', 'done'); + + // Parent slimmed + expect(task.details).toBe(''); + expect(task.testStrategy).toBe(''); + + // Subtask 1 slimmed (long description truncated) + expect(task.subtasks[0].details).toBe(''); + expect(task.subtasks[0].testStrategy).toBe(''); + expect(task.subtasks[0].description).toBe('F'.repeat(200) + '...'); + + // Subtask 2 slimmed (short description preserved) + expect(task.subtasks[1].details).toBe(''); + expect(task.subtasks[1].testStrategy).toBe(''); + expect(task.subtasks[1].description).toBe('Short subtask desc'); + }); + + test('should handle task with no subtasks', () => { + const task = { + id: 1, + title: 'Test Task', + description: 'Desc', + details: 'Details', + testStrategy: 'Strategy', + status: 'done' + }; + + const result = slimTaskOnComplete(task, 'pending', 'done'); + + expect(result).toBe(task); + expect(task.details).toBe(''); + expect(task.testStrategy).toBe(''); + }); + }); + + describe('slimSubtaskOnComplete', () => { + test('should slim subtask when transitioning to done', () => { + const subtask = { + id: 1, + title: 'Subtask 1', + description: 'G'.repeat(250), + details: 'Subtask details', + testStrategy: 'Subtask strategy', + status: 'done' + }; + + slimSubtaskOnComplete(subtask, 'pending', 'done'); + + expect(subtask.details).toBe(''); + expect(subtask.testStrategy).toBe(''); + expect(subtask.description).toBe('G'.repeat(200) + '...'); + }); + + test('should NOT slim subtask when already done', () => { + const subtask = { + id: 1, + title: 'Subtask 1', + description: 'G'.repeat(250), + details: 'Should remain', + testStrategy: 'Should remain', + status: 'done' + }; + + slimSubtaskOnComplete(subtask, 'done', 'done'); + + expect(subtask.details).toBe('Should remain'); + expect(subtask.testStrategy).toBe('Should remain'); + }); + + test('should NOT slim subtask when transitioning to non-done status', () => { + const subtask = { + id: 1, + title: 'Subtask 1', + description: 'Short desc', + details: 'Should remain', + testStrategy: 'Should remain', + status: 'in-progress' + }; + + slimSubtaskOnComplete(subtask, 'pending', 'in-progress'); + + expect(subtask.details).toBe('Should remain'); + expect(subtask.testStrategy).toBe('Should remain'); + }); + }); + + describe('DESCRIPTION_TRUNCATE_LENGTH constant', () => { + test('should be 200', () => { + expect(DESCRIPTION_TRUNCATE_LENGTH).toBe(200); + }); + }); +});