Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
453 changes: 453 additions & 0 deletions src/__tests__/exceeded-requeue.test.ts

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions src/__tests__/listNonInteractive-completedActions.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const {
mockDeleteCompletedTask,
mockDeleteTask,
mockListAllTaskItems,
mockMergeBranch,
mockDeleteBranch,
mockInfo,
} = vi.hoisted(() => ({
mockDeleteCompletedTask: vi.fn(),
mockDeleteTask: vi.fn(),
mockListAllTaskItems: vi.fn(),
mockMergeBranch: vi.fn(),
mockDeleteBranch: vi.fn(),
Expand All @@ -20,8 +20,8 @@ vi.mock('../infra/task/index.js', () => ({
listAllTaskItems() {
return mockListAllTaskItems();
}
deleteCompletedTask(name: string) {
mockDeleteCompletedTask(name);
deleteTask(name: string, kind: string) {
mockDeleteTask(name, kind);
}
},
}));
Expand Down Expand Up @@ -64,7 +64,7 @@ describe('listTasksNonInteractive completed actions', () => {
});

expect(mockMergeBranch).toHaveBeenCalled();
expect(mockDeleteCompletedTask).toHaveBeenCalledWith('completed-task');
expect(mockDeleteTask).toHaveBeenCalledWith('completed-task', 'completed');
});

it('should delete completed record after delete action', async () => {
Expand All @@ -78,6 +78,6 @@ describe('listTasksNonInteractive completed actions', () => {
});

expect(mockDeleteBranch).toHaveBeenCalled();
expect(mockDeleteCompletedTask).toHaveBeenCalledWith('completed-task');
expect(mockDeleteTask).toHaveBeenCalledWith('completed-task', 'completed');
});
});
5 changes: 2 additions & 3 deletions src/__tests__/listTasksInteractivePendingLabel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,8 @@ vi.mock('../features/tasks/list/taskActions.js', () => ({
}));

vi.mock('../features/tasks/list/taskDeleteActions.js', () => ({
deletePendingTask: mockDeletePendingTask,
deleteFailedTask: vi.fn(),
deleteCompletedTask: vi.fn(),
deleteTaskByKind: mockDeletePendingTask,
deleteAllTasks: vi.fn(),
}));

vi.mock('../features/tasks/list/taskRetryActions.js', () => ({
Expand Down
73 changes: 64 additions & 9 deletions src/__tests__/listTasksInteractiveStatusActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,34 @@ const {
mockInfo,
mockBlankLine,
mockListAllTaskItems,
mockDeleteCompletedRecord,
mockDeleteTask,
mockShowDiffAndPromptActionForTask,
mockMergeBranch,
mockDeleteCompletedTask,
mockRequeueExceededTask,
} = vi.hoisted(() => ({
mockSelectOption: vi.fn(),
mockHeader: vi.fn(),
mockInfo: vi.fn(),
mockBlankLine: vi.fn(),
mockListAllTaskItems: vi.fn(),
mockDeleteCompletedRecord: vi.fn(),
mockDeleteTask: vi.fn(),
mockShowDiffAndPromptActionForTask: vi.fn(),
mockMergeBranch: vi.fn(),
mockDeleteCompletedTask: vi.fn(),
mockRequeueExceededTask: vi.fn(),
}));

vi.mock('../infra/task/index.js', () => ({
TaskRunner: class {
listAllTaskItems() {
return mockListAllTaskItems();
}
deleteCompletedTask(name: string) {
mockDeleteCompletedRecord(name);
deleteTask(name: string, kind: string) {
mockDeleteTask(name, kind);
}
requeueExceededTask(name: string) {
mockRequeueExceededTask(name);
}
},
}));
Expand All @@ -54,9 +59,8 @@ vi.mock('../features/tasks/list/taskActions.js', () => ({
}));

vi.mock('../features/tasks/list/taskDeleteActions.js', () => ({
deletePendingTask: vi.fn(),
deleteFailedTask: vi.fn(),
deleteCompletedTask: mockDeleteCompletedTask,
deleteTaskByKind: mockDeleteCompletedTask,
deleteAllTasks: vi.fn(),
}));

vi.mock('../features/tasks/list/taskRetryActions.js', () => ({
Expand Down Expand Up @@ -88,6 +92,16 @@ const completedTaskWithoutBranch: TaskListItem = {
name: 'completed-without-branch',
};

const exceededTask: TaskListItem = {
kind: 'exceeded',
name: 'exceeded-task',
createdAt: '2026-02-14T00:00:00.000Z',
filePath: '/project/.takt/tasks.yaml',
content: 'iteration limit reached',
exceededMaxMovements: 60,
exceededCurrentIteration: 30,
};

describe('listTasks interactive status actions', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -129,7 +143,7 @@ describe('listTasks interactive status actions', () => {
await listTasks('/project');

expect(mockMergeBranch).toHaveBeenCalledWith('/project', completedTaskWithBranch);
expect(mockDeleteCompletedRecord).toHaveBeenCalledWith('completed-task');
expect(mockDeleteTask).toHaveBeenCalledWith('completed-task', 'completed');
});

it('completed delete 選択時は deleteCompletedTask を呼ぶ', async () => {
Expand All @@ -142,6 +156,47 @@ describe('listTasks interactive status actions', () => {
await listTasks('/project');

expect(mockDeleteCompletedTask).toHaveBeenCalledWith(completedTaskWithBranch);
expect(mockDeleteCompletedRecord).not.toHaveBeenCalled();
expect(mockDeleteTask).not.toHaveBeenCalled();
});

describe('exceeded status action handling', () => {
it('exceeded requeue 選択時は requeueExceededTask を呼ぶ', async () => {
mockListAllTaskItems.mockReturnValue([exceededTask]);
mockSelectOption
.mockResolvedValueOnce('exceeded:0')
.mockResolvedValueOnce('requeue')
.mockResolvedValueOnce(null);

await listTasks('/project');

expect(mockRequeueExceededTask).toHaveBeenCalledWith('exceeded-task');
expect(mockDeleteCompletedTask).not.toHaveBeenCalled();
});

it('exceeded delete 選択時は deleteTaskByKind を呼ぶ', async () => {
mockListAllTaskItems.mockReturnValue([exceededTask]);
mockSelectOption
.mockResolvedValueOnce('exceeded:0')
.mockResolvedValueOnce('delete')
.mockResolvedValueOnce(null);

await listTasks('/project');

expect(mockDeleteCompletedTask).toHaveBeenCalledWith(exceededTask);
expect(mockRequeueExceededTask).not.toHaveBeenCalled();
});

it('exceeded でキャンセル選択時は何も呼ばれない', async () => {
mockListAllTaskItems.mockReturnValue([exceededTask]);
mockSelectOption
.mockResolvedValueOnce('exceeded:0')
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null);

await listTasks('/project');

expect(mockRequeueExceededTask).not.toHaveBeenCalled();
expect(mockDeleteCompletedTask).not.toHaveBeenCalled();
});
});
});
150 changes: 150 additions & 0 deletions src/__tests__/task-delete-task.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* Unit tests for TaskRunner.deleteTask (generic delete by kind)
*
* Covers:
* - deleteTask('name', 'pending') → pending task removed
* - deleteTask('name', 'failed') → failed task removed
* - deleteTask('name', 'completed') → completed task removed
* - deleteTask('name', 'exceeded') → exceeded task removed
* - Error when task does not exist
* - Error when kind does not match actual task status
* - Sibling tasks are not affected
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdirSync, existsSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import { TaskRunner } from '../infra/task/runner.js';

function loadTasksFile(testDir: string): { tasks: Array<Record<string, unknown>> } {
const raw = readFileSync(join(testDir, '.takt', 'tasks.yaml'), 'utf-8');
return parseYaml(raw) as { tasks: Array<Record<string, unknown>> };
}

function writeRecord(testDir: string, record: Record<string, unknown>): void {
mkdirSync(join(testDir, '.takt'), { recursive: true });
writeFileSync(
join(testDir, '.takt', 'tasks.yaml'),
stringifyYaml({ tasks: [record] }),
'utf-8',
);
}

describe('TaskRunner - deleteTask', () => {
const testDir = `/tmp/takt-delete-task-test-${Date.now()}`;
let runner: TaskRunner;

beforeEach(() => {
mkdirSync(testDir, { recursive: true });
runner = new TaskRunner(testDir);
});

afterEach(() => {
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true, force: true });
}
});

it('should delete a pending task by kind', () => {
// Given: a pending task
runner.addTask('Task A');
const taskName = (loadTasksFile(testDir).tasks[0] as Record<string, unknown>).name as string;

// When: deleteTask is called with kind 'pending'
runner.deleteTask(taskName, 'pending');

// Then: task is removed from the store
expect(loadTasksFile(testDir).tasks).toHaveLength(0);
});

it('should delete a failed task by kind', () => {
// Given: a failed task written directly to YAML
writeRecord(testDir, {
name: 'task-a',
status: 'failed',
content: 'Do work',
created_at: '2026-01-01T00:00:00.000Z',
started_at: '2026-01-01T00:01:00.000Z',
completed_at: '2026-01-01T00:05:00.000Z',
owner_pid: null,
failure: { error: 'Something went wrong' },
});

// When: deleteTask is called with kind 'failed'
runner.deleteTask('task-a', 'failed');

// Then: task is removed from the store
expect(loadTasksFile(testDir).tasks).toHaveLength(0);
});

it('should delete a completed task by kind', () => {
// Given: a completed task written directly to YAML
writeRecord(testDir, {
name: 'task-a',
status: 'completed',
content: 'Do work',
created_at: '2026-01-01T00:00:00.000Z',
started_at: '2026-01-01T00:01:00.000Z',
completed_at: '2026-01-01T00:05:00.000Z',
owner_pid: null,
});

// When: deleteTask is called with kind 'completed'
runner.deleteTask('task-a', 'completed');

// Then: task is removed from the store
expect(loadTasksFile(testDir).tasks).toHaveLength(0);
});

it('should delete an exceeded task by kind', () => {
// Given: an exceeded task written directly to YAML
writeRecord(testDir, {
name: 'task-a',
status: 'exceeded',
content: 'Do work',
created_at: '2026-01-01T00:00:00.000Z',
started_at: '2026-01-01T00:01:00.000Z',
completed_at: '2026-01-01T00:05:00.000Z',
owner_pid: null,
start_movement: 'implement',
exceeded_max_movements: 60,
exceeded_current_iteration: 30,
});

// When: deleteTask is called with kind 'exceeded'
runner.deleteTask('task-a', 'exceeded');

// Then: task is removed from the store
expect(loadTasksFile(testDir).tasks).toHaveLength(0);
});

it('should throw when task does not exist', () => {
// Given: no tasks in the store
// When/Then: deleteTask throws with not-found error
expect(() => runner.deleteTask('nonexistent', 'pending')).toThrow(/not found/i);
});

it('should throw when kind does not match the actual task status', () => {
// Given: a pending task
runner.addTask('Task A');
const taskName = (loadTasksFile(testDir).tasks[0] as Record<string, unknown>).name as string;

// When: deleteTask is called with wrong kind ('failed' instead of 'pending')
// Then: throws because no running task with that name exists under 'failed' status
expect(() => runner.deleteTask(taskName, 'failed')).toThrow(/not found/i);
});

it('should not affect sibling tasks when deleting one task', () => {
// Given: two pending tasks
runner.addTask('Task A');
runner.addTask('Task B');
const taskName = (loadTasksFile(testDir).tasks[0] as Record<string, unknown>).name as string;

// When: deleteTask is called for the first task
runner.deleteTask(taskName, 'pending');

// Then: only the targeted task is removed; sibling remains
expect(loadTasksFile(testDir).tasks).toHaveLength(1);
});
});
Loading