576 lines
13 KiB
TypeScript
576 lines
13 KiB
TypeScript
/// <reference types="bun-types/test-globals" />
|
|
import type { Task } from "../../features/claude-tasks/types";
|
|
import {
|
|
syncTaskToTodo,
|
|
syncAllTasksToTodos,
|
|
syncTaskTodoUpdate,
|
|
type TodoInfo,
|
|
} from "./todo-sync";
|
|
|
|
describe("syncTaskToTodo", () => {
|
|
it("converts pending task to pending todo", () => {
|
|
// given
|
|
const task: Task = {
|
|
id: "T-123",
|
|
subject: "Fix bug",
|
|
description: "Fix critical bug",
|
|
status: "pending",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
};
|
|
|
|
// when
|
|
const result = syncTaskToTodo(task);
|
|
|
|
// then
|
|
expect(result).toEqual({
|
|
id: "T-123",
|
|
content: "Fix bug",
|
|
status: "pending",
|
|
priority: "medium",
|
|
});
|
|
});
|
|
|
|
it("converts in_progress task to in_progress todo", () => {
|
|
// given
|
|
const task: Task = {
|
|
id: "T-456",
|
|
subject: "Implement feature",
|
|
description: "Add new feature",
|
|
status: "in_progress",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
};
|
|
|
|
// when
|
|
const result = syncTaskToTodo(task);
|
|
|
|
// then
|
|
expect(result?.status).toBe("in_progress");
|
|
expect(result?.content).toBe("Implement feature");
|
|
});
|
|
|
|
it("converts completed task to completed todo", () => {
|
|
// given
|
|
const task: Task = {
|
|
id: "T-789",
|
|
subject: "Review PR",
|
|
description: "Review pull request",
|
|
status: "completed",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
};
|
|
|
|
// when
|
|
const result = syncTaskToTodo(task);
|
|
|
|
// then
|
|
expect(result?.status).toBe("completed");
|
|
});
|
|
|
|
it("returns null for deleted task", () => {
|
|
// given
|
|
const task: Task = {
|
|
id: "T-del",
|
|
subject: "Deleted task",
|
|
description: "This task is deleted",
|
|
status: "deleted",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
};
|
|
|
|
// when
|
|
const result = syncTaskToTodo(task);
|
|
|
|
// then
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("extracts priority from metadata", () => {
|
|
// given
|
|
const task: Task = {
|
|
id: "T-high",
|
|
subject: "Critical task",
|
|
description: "High priority task",
|
|
status: "pending",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
metadata: { priority: "high" },
|
|
};
|
|
|
|
// when
|
|
const result = syncTaskToTodo(task);
|
|
|
|
// then
|
|
expect(result?.priority).toBe("high");
|
|
});
|
|
|
|
it("handles medium priority", () => {
|
|
// given
|
|
const task: Task = {
|
|
id: "T-med",
|
|
subject: "Medium task",
|
|
description: "Medium priority",
|
|
status: "pending",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
metadata: { priority: "medium" },
|
|
};
|
|
|
|
// when
|
|
const result = syncTaskToTodo(task);
|
|
|
|
// then
|
|
expect(result?.priority).toBe("medium");
|
|
});
|
|
|
|
it("handles low priority", () => {
|
|
// given
|
|
const task: Task = {
|
|
id: "T-low",
|
|
subject: "Low task",
|
|
description: "Low priority",
|
|
status: "pending",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
metadata: { priority: "low" },
|
|
};
|
|
|
|
// when
|
|
const result = syncTaskToTodo(task);
|
|
|
|
// then
|
|
expect(result?.priority).toBe("low");
|
|
});
|
|
|
|
it("ignores invalid priority values", () => {
|
|
// given
|
|
const task: Task = {
|
|
id: "T-invalid",
|
|
subject: "Invalid priority",
|
|
description: "Invalid priority value",
|
|
status: "pending",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
metadata: { priority: "urgent" },
|
|
};
|
|
|
|
// when
|
|
const result = syncTaskToTodo(task);
|
|
|
|
// then
|
|
expect(result?.priority).toBe("medium");
|
|
});
|
|
|
|
it("handles missing metadata", () => {
|
|
// given
|
|
const task: Task = {
|
|
id: "T-no-meta",
|
|
subject: "No metadata",
|
|
description: "Task without metadata",
|
|
status: "pending",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
};
|
|
|
|
// when
|
|
const result = syncTaskToTodo(task);
|
|
|
|
// then
|
|
expect(result?.priority).toBe("medium");
|
|
});
|
|
|
|
it("uses subject as todo content", () => {
|
|
// given
|
|
const task: Task = {
|
|
id: "T-content",
|
|
subject: "This is the subject",
|
|
description: "This is the description",
|
|
status: "pending",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
};
|
|
|
|
// when
|
|
const result = syncTaskToTodo(task);
|
|
|
|
// then
|
|
expect(result?.content).toBe("This is the subject");
|
|
});
|
|
});
|
|
|
|
describe("syncTaskTodoUpdate", () => {
|
|
let mockCtx: any;
|
|
|
|
beforeEach(() => {
|
|
mockCtx = {
|
|
client: {
|
|
session: {
|
|
todo: vi.fn(),
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
it("writes updated todo and preserves existing items", async () => {
|
|
// given
|
|
const task: Task = {
|
|
id: "T-1",
|
|
subject: "Updated task",
|
|
description: "",
|
|
status: "in_progress",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
};
|
|
const currentTodos: TodoInfo[] = [
|
|
{ id: "T-1", content: "Old task", status: "pending" },
|
|
{ id: "T-2", content: "Keep task", status: "pending" },
|
|
];
|
|
mockCtx.client.session.todo.mockResolvedValue({ data: currentTodos });
|
|
let called = false;
|
|
const writer = async (input: { sessionID: string; todos: TodoInfo[] }) => {
|
|
called = true;
|
|
expect(input.sessionID).toBe("session-1");
|
|
expect(input.todos.length).toBe(2);
|
|
expect(
|
|
input.todos.find((todo: TodoInfo) => todo.id === "T-1")?.content,
|
|
).toBe("Updated task");
|
|
expect(input.todos.some((todo: TodoInfo) => todo.id === "T-2")).toBe(
|
|
true,
|
|
);
|
|
};
|
|
|
|
// when
|
|
await syncTaskTodoUpdate(mockCtx, task, "session-1", writer);
|
|
|
|
// then
|
|
expect(called).toBe(true);
|
|
});
|
|
|
|
it("removes deleted task from todos", async () => {
|
|
// given
|
|
const task: Task = {
|
|
id: "T-1",
|
|
subject: "Deleted task",
|
|
description: "",
|
|
status: "deleted",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
};
|
|
const currentTodos: TodoInfo[] = [
|
|
{ id: "T-1", content: "Old task", status: "pending" },
|
|
{ id: "T-2", content: "Keep task", status: "pending" },
|
|
];
|
|
mockCtx.client.session.todo.mockResolvedValue(currentTodos);
|
|
let called = false;
|
|
const writer = async (input: { sessionID: string; todos: TodoInfo[] }) => {
|
|
called = true;
|
|
expect(input.todos.length).toBe(1);
|
|
expect(input.todos.some((todo: TodoInfo) => todo.id === "T-1")).toBe(
|
|
false,
|
|
);
|
|
expect(input.todos.some((todo: TodoInfo) => todo.id === "T-2")).toBe(
|
|
true,
|
|
);
|
|
};
|
|
|
|
// when
|
|
await syncTaskTodoUpdate(mockCtx, task, "session-1", writer);
|
|
|
|
// then
|
|
expect(called).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("syncAllTasksToTodos", () => {
|
|
let mockCtx: any;
|
|
|
|
beforeEach(() => {
|
|
mockCtx = {
|
|
client: {
|
|
session: {
|
|
todo: vi.fn(),
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
it("fetches current todos from OpenCode", async () => {
|
|
// given
|
|
const tasks: Task[] = [
|
|
{
|
|
id: "T-1",
|
|
subject: "Task 1",
|
|
description: "Description 1",
|
|
status: "pending",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
},
|
|
];
|
|
const currentTodos: TodoInfo[] = [
|
|
{
|
|
id: "T-existing",
|
|
content: "Existing todo",
|
|
status: "pending",
|
|
},
|
|
];
|
|
mockCtx.client.session.todo.mockResolvedValue(currentTodos);
|
|
|
|
// when
|
|
await syncAllTasksToTodos(mockCtx, tasks, "session-1");
|
|
|
|
// then
|
|
expect(mockCtx.client.session.todo).toHaveBeenCalledWith({
|
|
path: { id: "session-1" },
|
|
});
|
|
});
|
|
|
|
it("handles API response with data property", async () => {
|
|
// given
|
|
const tasks: Task[] = [];
|
|
const currentTodos: TodoInfo[] = [
|
|
{
|
|
id: "T-1",
|
|
content: "Todo 1",
|
|
status: "pending",
|
|
},
|
|
];
|
|
mockCtx.client.session.todo.mockResolvedValue({
|
|
data: currentTodos,
|
|
});
|
|
|
|
// when
|
|
await syncAllTasksToTodos(mockCtx, tasks, "session-1");
|
|
|
|
// then
|
|
expect(mockCtx.client.session.todo).toHaveBeenCalled();
|
|
});
|
|
|
|
it("gracefully handles fetch failure", async () => {
|
|
// given
|
|
const tasks: Task[] = [
|
|
{
|
|
id: "T-1",
|
|
subject: "Task 1",
|
|
description: "Description 1",
|
|
status: "pending",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
},
|
|
];
|
|
mockCtx.client.session.todo.mockRejectedValue(new Error("API error"));
|
|
|
|
// when
|
|
const result = await syncAllTasksToTodos(mockCtx, tasks, "session-1");
|
|
|
|
// then
|
|
expect(result).toBeUndefined();
|
|
});
|
|
|
|
it("converts multiple tasks to todos", async () => {
|
|
// given
|
|
const tasks: Task[] = [
|
|
{
|
|
id: "T-1",
|
|
subject: "Task 1",
|
|
description: "Description 1",
|
|
status: "pending",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
metadata: { priority: "high" },
|
|
},
|
|
{
|
|
id: "T-2",
|
|
subject: "Task 2",
|
|
description: "Description 2",
|
|
status: "in_progress",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
metadata: { priority: "low" },
|
|
},
|
|
];
|
|
mockCtx.client.session.todo.mockResolvedValue([]);
|
|
|
|
// when
|
|
await syncAllTasksToTodos(mockCtx, tasks, "session-1");
|
|
|
|
// then
|
|
expect(mockCtx.client.session.todo).toHaveBeenCalled();
|
|
});
|
|
|
|
it("removes deleted tasks from todo list", async () => {
|
|
// given
|
|
const tasks: Task[] = [
|
|
{
|
|
id: "T-1",
|
|
subject: "Task 1",
|
|
description: "Description 1",
|
|
status: "deleted",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
},
|
|
];
|
|
const currentTodos: TodoInfo[] = [
|
|
{
|
|
id: "T-1",
|
|
content: "Task 1",
|
|
status: "pending",
|
|
},
|
|
];
|
|
mockCtx.client.session.todo.mockResolvedValue(currentTodos);
|
|
let writtenTodos: TodoInfo[] = [];
|
|
const writer = async (input: { sessionID: string; todos: TodoInfo[] }) => {
|
|
writtenTodos = input.todos;
|
|
};
|
|
|
|
// when
|
|
await syncAllTasksToTodos(mockCtx, tasks, "session-1", writer);
|
|
|
|
// then
|
|
expect(writtenTodos.some((t: TodoInfo) => t.id === "T-1")).toBe(false);
|
|
});
|
|
|
|
it("preserves existing todos not in task list", async () => {
|
|
// given
|
|
const tasks: Task[] = [
|
|
{
|
|
id: "T-1",
|
|
subject: "Task 1",
|
|
description: "Description 1",
|
|
status: "pending",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
},
|
|
];
|
|
const currentTodos: TodoInfo[] = [
|
|
{
|
|
id: "T-1",
|
|
content: "Task 1",
|
|
status: "pending",
|
|
},
|
|
{
|
|
id: "T-existing",
|
|
content: "Existing todo",
|
|
status: "pending",
|
|
},
|
|
];
|
|
mockCtx.client.session.todo.mockResolvedValue(currentTodos);
|
|
let writtenTodos: TodoInfo[] = [];
|
|
const writer = async (input: { sessionID: string; todos: TodoInfo[] }) => {
|
|
writtenTodos = input.todos;
|
|
};
|
|
|
|
// when
|
|
await syncAllTasksToTodos(mockCtx, tasks, "session-1", writer);
|
|
|
|
// then
|
|
expect(writtenTodos.some((t: TodoInfo) => t.id === "T-existing")).toBe(true);
|
|
expect(writtenTodos.some((t: TodoInfo) => t.content === "Task 1")).toBe(true);
|
|
});
|
|
|
|
it("handles empty task list", async () => {
|
|
// given
|
|
const tasks: Task[] = [];
|
|
mockCtx.client.session.todo.mockResolvedValue([]);
|
|
|
|
// when
|
|
await syncAllTasksToTodos(mockCtx, tasks, "session-1");
|
|
|
|
// then
|
|
expect(mockCtx.client.session.todo).toHaveBeenCalled();
|
|
});
|
|
|
|
it("calls writer with final todos", async () => {
|
|
// given
|
|
const tasks: Task[] = [
|
|
{
|
|
id: "T-1",
|
|
subject: "Task 1",
|
|
description: "Description 1",
|
|
status: "pending",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
},
|
|
];
|
|
mockCtx.client.session.todo.mockResolvedValue([]);
|
|
let writerCalled = false;
|
|
const writer = async (input: { sessionID: string; todos: TodoInfo[] }) => {
|
|
writerCalled = true;
|
|
expect(input.sessionID).toBe("session-1");
|
|
expect(input.todos.length).toBe(1);
|
|
expect(input.todos[0].content).toBe("Task 1");
|
|
};
|
|
|
|
// when
|
|
await syncAllTasksToTodos(mockCtx, tasks, "session-1", writer);
|
|
|
|
// then
|
|
expect(writerCalled).toBe(true);
|
|
});
|
|
|
|
it("deduplicates no-id todos when task replaces existing content", async () => {
|
|
// given
|
|
const tasks: Task[] = [
|
|
{
|
|
id: "T-1",
|
|
subject: "Task 1 (updated)",
|
|
description: "Description 1",
|
|
status: "in_progress",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
},
|
|
];
|
|
const currentTodos: TodoInfo[] = [
|
|
{
|
|
content: "Task 1 (updated)",
|
|
status: "pending",
|
|
},
|
|
];
|
|
mockCtx.client.session.todo.mockResolvedValue(currentTodos);
|
|
let writtenTodos: TodoInfo[] = [];
|
|
const writer = async (input: { sessionID: string; todos: TodoInfo[] }) => {
|
|
writtenTodos = input.todos;
|
|
};
|
|
|
|
// when
|
|
await syncAllTasksToTodos(mockCtx, tasks, "session-1", writer);
|
|
|
|
// then — no duplicates
|
|
const matching = writtenTodos.filter((t: TodoInfo) => t.content === "Task 1 (updated)");
|
|
expect(matching.length).toBe(1);
|
|
expect(matching[0].status).toBe("in_progress");
|
|
});
|
|
|
|
it("preserves todos without id field", async () => {
|
|
// given
|
|
const tasks: Task[] = [
|
|
{
|
|
id: "T-1",
|
|
subject: "Task 1",
|
|
description: "Description 1",
|
|
status: "pending",
|
|
blocks: [],
|
|
blockedBy: [],
|
|
},
|
|
];
|
|
const currentTodos: TodoInfo[] = [
|
|
{
|
|
id: "T-1",
|
|
content: "Task 1",
|
|
status: "pending",
|
|
},
|
|
{
|
|
content: "Todo without id",
|
|
status: "pending",
|
|
},
|
|
];
|
|
mockCtx.client.session.todo.mockResolvedValue(currentTodos);
|
|
|
|
// when
|
|
await syncAllTasksToTodos(mockCtx, tasks, "session-1");
|
|
|
|
// then
|
|
expect(mockCtx.client.session.todo).toHaveBeenCalled();
|
|
});
|
|
});
|