lamplighter-mcp
Version:
An intelligent context engine for AI-assisted software development
196 lines (167 loc) • 8.97 kB
text/typescript
import * as fs from 'fs/promises';
import * as path from 'path';
import { TaskManager, TaskStatus } from '../../src/modules/taskManager';
// Mock fs/promises
jest.mock('fs/promises');
const mockedFs = jest.mocked(fs);
// Save original environment variables
const originalEnv = process.env;
describe('TaskManager', () => {
const defaultContextDir = './lamplighter_context';
const featureTasksDir = path.join(defaultContextDir, 'feature_tasks');
let taskManager: TaskManager;
const featureId = 'test-feature';
const mockFilePath = path.join(featureTasksDir, `feature_${featureId}_tasks.md`);
beforeEach(() => {
jest.clearAllMocks();
// Mock environment variables
process.env = {
...originalEnv,
LAMPLIGHTER_CONTEXT_DIR: defaultContextDir
};
// Default fs mocks
mockedFs.access.mockResolvedValue(undefined); // Assume file exists
mockedFs.readFile.mockResolvedValue(''); // Default empty content
mockedFs.writeFile.mockResolvedValue(undefined);
taskManager = new TaskManager();
});
afterAll(() => {
process.env = originalEnv;
});
describe('parseTasks', () => {
// Access private method for testing
const parseTasks = (content: string) => (taskManager as any).parseTasks(content);
it('should parse ToDo and Done tasks', () => {
const content = '# Title\n- [ ] Task 1\n- [x] Task 2\nSome other text\n- [ ] Task 3';
const tasks = parseTasks(content);
expect(tasks).toHaveLength(3);
expect(tasks[0]).toMatchObject({ text: 'Task 1', status: 'ToDo', lineNumber: 1 });
expect(tasks[1]).toMatchObject({ text: 'Task 2', status: 'Done', lineNumber: 2 });
expect(tasks[2]).toMatchObject({ text: 'Task 3', status: 'ToDo', lineNumber: 4 });
});
it('should handle variations in spacing and case for Done marker', () => {
const content = '- [ ] Task A\n- [x] Task B \n- [X] Task C';
const tasks = parseTasks(content);
expect(tasks).toHaveLength(3);
expect(tasks[0].status).toBe('ToDo');
expect(tasks[1].status).toBe('Done');
expect(tasks[1].text).toBe('Task B'); // Check trimming
expect(tasks[2].status).toBe('Done');
});
it('should return empty array for content with no tasks', () => {
const content = 'No tasks here.';
expect(parseTasks(content)).toEqual([]);
});
});
describe('updateTaskInContent', () => {
// Access private method for testing
const updateTaskInContent = (content: string, task: any, status: TaskStatus) =>
(taskManager as any).updateTaskInContent(content, task, status);
const taskLine = '- [ ] Initial task';
const content = `# Header\n${taskLine}\n- [x] Another task`;
const mockTask = { text: 'Initial task', status: 'ToDo', lineNumber: 1, originalLine: taskLine };
it('should update ToDo to Done', () => {
const updated = updateTaskInContent(content, mockTask, 'Done');
expect(updated).toContain('- [x] Initial task');
expect(updated).toContain('- [x] Another task'); // Ensure other lines are preserved
});
it('should update Done to ToDo', () => {
const doneTaskLine = '- [x] Done task';
const doneContent = `# Header\n${doneTaskLine}`;
const doneTask = { text: 'Done task', status: 'Done', lineNumber: 1, originalLine: doneTaskLine };
const updated = updateTaskInContent(doneContent, doneTask, 'ToDo');
expect(updated).toContain('- [ ] Done task');
});
it('should update ToDo to InProgress (treated as ToDo)', () => {
const updated = updateTaskInContent(content, mockTask, 'InProgress');
expect(updated).toContain('- [ ] Initial task'); // Should remain [ ]
});
});
describe('updateTaskStatus', () => {
const task1 = 'First Task';
const task2 = 'Second Task';
const initialContent = `# Tasks\n- [ ] ${task1}\n- [ ] ${task2}\n- [x] Already Done`;
beforeEach(() => {
mockedFs.readFile.mockResolvedValue(initialContent);
});
it('should update task status from ToDo to Done by exact match', async () => {
await taskManager.updateTaskStatus(featureId, task1, 'Done');
expect(mockedFs.writeFile).toHaveBeenCalledTimes(1);
expect(mockedFs.writeFile).toHaveBeenCalledWith(mockFilePath, expect.stringContaining(`- [x] ${task1}`), 'utf-8');
expect(mockedFs.writeFile).toHaveBeenCalledWith(mockFilePath, expect.not.stringContaining(`- [ ] ${task1}`), 'utf-8');
expect(mockedFs.writeFile).toHaveBeenCalledWith(mockFilePath, expect.stringContaining(`- [ ] ${task2}`), 'utf-8'); // Check other task unchanged
});
it('should update task status from ToDo to Done by partial match', async () => {
const partialIdentifier = 'Second'; // Partial match for task2
await taskManager.updateTaskStatus(featureId, partialIdentifier, 'Done');
expect(mockedFs.writeFile).toHaveBeenCalledTimes(1);
expect(mockedFs.writeFile).toHaveBeenCalledWith(mockFilePath, expect.stringContaining(`- [x] ${task2}`), 'utf-8');
expect(mockedFs.writeFile).toHaveBeenCalledWith(mockFilePath, expect.not.stringContaining(`- [ ] ${task2}`), 'utf-8');
expect(mockedFs.writeFile).toHaveBeenCalledWith(mockFilePath, expect.stringContaining(`- [ ] ${task1}`), 'utf-8');
});
it('should update task status from Done to ToDo', async () => {
const taskToUpdate = 'Already Done';
await taskManager.updateTaskStatus(featureId, taskToUpdate, 'ToDo');
expect(mockedFs.writeFile).toHaveBeenCalledTimes(1);
expect(mockedFs.writeFile).toHaveBeenCalledWith(mockFilePath, expect.stringContaining(`- [ ] ${taskToUpdate}`), 'utf-8');
expect(mockedFs.writeFile).toHaveBeenCalledWith(mockFilePath, expect.not.stringContaining(`- [x] ${taskToUpdate}`), 'utf-8');
});
it('should throw error if task file not found', async () => {
mockedFs.access.mockRejectedValue(new Error('File not found'));
await expect(taskManager.updateTaskStatus(featureId, task1, 'Done'))
.rejects.toThrow(`Task file for feature "${featureId}" not found.`);
expect(mockedFs.readFile).not.toHaveBeenCalled();
expect(mockedFs.writeFile).not.toHaveBeenCalled();
});
it('should throw error if task identifier not found', async () => {
const nonExistentTask = 'Missing Task';
await expect(taskManager.updateTaskStatus(featureId, nonExistentTask, 'Done'))
.rejects.toThrow(`Task "${nonExistentTask}" not found in feature "${featureId}".`);
expect(mockedFs.writeFile).not.toHaveBeenCalled();
});
it('should handle readFile error', async () => {
const readError = new Error('Read permission denied');
mockedFs.readFile.mockRejectedValue(readError);
await expect(taskManager.updateTaskStatus(featureId, task1, 'Done'))
.rejects.toThrow(`Failed to update task status: ${readError.message}`);
});
it('should handle writeFile error', async () => {
const writeError = new Error('Write permission denied');
mockedFs.writeFile.mockRejectedValue(writeError);
await expect(taskManager.updateTaskStatus(featureId, task1, 'Done'))
.rejects.toThrow(`Failed to update task status: ${writeError.message}`);
});
});
describe('suggestNextTask', () => {
it('should suggest the first ToDo task', async () => {
const content = '- [x] Done 1\n- [ ] First ToDo\n- [ ] Second ToDo';
mockedFs.readFile.mockResolvedValue(content);
const nextTask = await taskManager.suggestNextTask(featureId);
expect(nextTask).toBe('First ToDo');
expect(mockedFs.readFile).toHaveBeenCalledWith(mockFilePath, 'utf-8');
});
it('should return null if no ToDo tasks are found', async () => {
const content = '- [x] Done 1\n- [x] Done 2';
mockedFs.readFile.mockResolvedValue(content);
const nextTask = await taskManager.suggestNextTask(featureId);
expect(nextTask).toBeNull();
});
it('should return null if the file is empty or has no tasks', async () => {
mockedFs.readFile.mockResolvedValue('# Empty File');
const nextTask = await taskManager.suggestNextTask(featureId);
expect(nextTask).toBeNull();
});
it('should throw error if task file not found', async () => {
mockedFs.access.mockRejectedValue(new Error('File not found'));
await expect(taskManager.suggestNextTask(featureId))
.rejects.toThrow(`Task file for feature "${featureId}" not found.`);
expect(mockedFs.readFile).not.toHaveBeenCalled();
});
it('should handle readFile error', async () => {
const readError = new Error('Read permission denied');
mockedFs.readFile.mockRejectedValue(readError);
await expect(taskManager.suggestNextTask(featureId))
.rejects.toThrow(`Failed to suggest next task: ${readError.message}`);
});
});
});