Add Maestro E2E test flows for app launch and sidebar interactions. Implement download progress tracking with speed/ETA display in file explorer. Extend task CLI with parent-child hierarchy, body content from stdin, and ancestor context in task show. Fix explorer sidebar gesture to allow horizontal scrolling while preserving close swipe. Replace hardcoded monospace font with Fonts.mono constant. Fix RefreshControl to only show spinner on manual pull-to-refresh.
833 lines
26 KiB
TypeScript
833 lines
26 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { FileTaskStore } from "./task-store.js";
|
|
|
|
describe("FileTaskStore", () => {
|
|
let tempDir: string;
|
|
let store: FileTaskStore;
|
|
|
|
beforeEach(async () => {
|
|
tempDir = await mkdtemp(join(tmpdir(), "task-store-test-"));
|
|
store = new FileTaskStore(tempDir);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("create", () => {
|
|
it("creates a task with default status open", async () => {
|
|
const task = await store.create("My first task");
|
|
|
|
expect(task.id).toMatch(/^[a-f0-9]{8}$/);
|
|
expect(task.title).toBe("My first task");
|
|
expect(task.status).toBe("open");
|
|
expect(task.deps).toEqual([]);
|
|
expect(task.parentId).toBeUndefined();
|
|
expect(task.body).toBe("");
|
|
expect(task.notes).toEqual([]);
|
|
expect(task.created).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
expect(task.assignee).toBeUndefined();
|
|
});
|
|
|
|
it("creates a task with custom status", async () => {
|
|
const task = await store.create("Draft task", { status: "draft" });
|
|
|
|
expect(task.status).toBe("draft");
|
|
});
|
|
|
|
it("creates a task with dependencies", async () => {
|
|
const dep1 = await store.create("Dependency 1");
|
|
const dep2 = await store.create("Dependency 2");
|
|
const task = await store.create("Main task", {
|
|
deps: [dep1.id, dep2.id],
|
|
});
|
|
|
|
expect(task.deps).toEqual([dep1.id, dep2.id]);
|
|
});
|
|
|
|
it("creates a task with body", async () => {
|
|
const task = await store.create("Task with body", {
|
|
body: "This is a **long** body\n\nWith multiple lines.",
|
|
});
|
|
|
|
expect(task.body).toBe(
|
|
"This is a **long** body\n\nWith multiple lines."
|
|
);
|
|
});
|
|
|
|
it("creates a task with parentId", async () => {
|
|
const parent = await store.create("Parent task");
|
|
const child = await store.create("Child task", { parentId: parent.id });
|
|
|
|
expect(child.parentId).toBe(parent.id);
|
|
});
|
|
|
|
it("throws when creating task with non-existent parent", async () => {
|
|
await expect(
|
|
store.create("Child", { parentId: "nonexistent" })
|
|
).rejects.toThrow("Parent task not found");
|
|
});
|
|
|
|
it("creates a task with assignee", async () => {
|
|
const task = await store.create("Task for Claude", {
|
|
assignee: "claude",
|
|
});
|
|
|
|
expect(task.assignee).toBe("claude");
|
|
});
|
|
|
|
it("generates unique IDs for each task", async () => {
|
|
const task1 = await store.create("Task 1");
|
|
const task2 = await store.create("Task 2");
|
|
const task3 = await store.create("Task 3");
|
|
|
|
const ids = [task1.id, task2.id, task3.id];
|
|
expect(new Set(ids).size).toBe(3);
|
|
});
|
|
|
|
it("sets created timestamp", async () => {
|
|
const before = new Date().toISOString();
|
|
const task = await store.create("Task");
|
|
const after = new Date().toISOString();
|
|
|
|
expect(task.created >= before).toBe(true);
|
|
expect(task.created <= after).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("get", () => {
|
|
it("returns task by id", async () => {
|
|
const created = await store.create("Test task");
|
|
const retrieved = await store.get(created.id);
|
|
|
|
expect(retrieved).toEqual(created);
|
|
});
|
|
|
|
it("returns null for non-existent task", async () => {
|
|
const result = await store.get("nonexistent");
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("preserves assignee field", async () => {
|
|
const created = await store.create("Task", { assignee: "codex" });
|
|
const retrieved = await store.get(created.id);
|
|
|
|
expect(retrieved?.assignee).toBe("codex");
|
|
});
|
|
});
|
|
|
|
describe("list", () => {
|
|
it("returns empty array when no tasks", async () => {
|
|
const tasks = await store.list();
|
|
|
|
expect(tasks).toEqual([]);
|
|
});
|
|
|
|
it("returns all tasks", async () => {
|
|
await store.create("Task 1");
|
|
await store.create("Task 2");
|
|
await store.create("Task 3");
|
|
|
|
const tasks = await store.list();
|
|
|
|
expect(tasks).toHaveLength(3);
|
|
expect(tasks.map((t) => t.title).sort()).toEqual([
|
|
"Task 1",
|
|
"Task 2",
|
|
"Task 3",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("update", () => {
|
|
it("updates task title", async () => {
|
|
const task = await store.create("Original title");
|
|
const updated = await store.update(task.id, { title: "New title" });
|
|
|
|
expect(updated.title).toBe("New title");
|
|
expect(updated.id).toBe(task.id);
|
|
});
|
|
|
|
it("updates task body", async () => {
|
|
const task = await store.create("Task");
|
|
const updated = await store.update(task.id, {
|
|
body: "New body",
|
|
});
|
|
|
|
expect(updated.body).toBe("New body");
|
|
});
|
|
|
|
it("updates task assignee", async () => {
|
|
const task = await store.create("Task");
|
|
const updated = await store.update(task.id, { assignee: "claude" });
|
|
|
|
expect(updated.assignee).toBe("claude");
|
|
});
|
|
|
|
it("persists updates", async () => {
|
|
const task = await store.create("Task");
|
|
await store.update(task.id, { title: "Updated" });
|
|
|
|
const retrieved = await store.get(task.id);
|
|
expect(retrieved?.title).toBe("Updated");
|
|
});
|
|
|
|
it("preserves created timestamp on update", async () => {
|
|
const task = await store.create("Task");
|
|
const originalCreated = task.created;
|
|
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
await store.update(task.id, { title: "Updated" });
|
|
|
|
const retrieved = await store.get(task.id);
|
|
expect(retrieved?.created).toBe(originalCreated);
|
|
});
|
|
|
|
it("throws for non-existent task", async () => {
|
|
await expect(
|
|
store.update("nonexistent", { title: "New" })
|
|
).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("status transitions", () => {
|
|
describe("open", () => {
|
|
it("transitions draft to open", async () => {
|
|
const task = await store.create("Draft", { status: "draft" });
|
|
await store.open(task.id);
|
|
|
|
const updated = await store.get(task.id);
|
|
expect(updated?.status).toBe("open");
|
|
});
|
|
|
|
it("throws when task is not draft", async () => {
|
|
const task = await store.create("Open task", { status: "open" });
|
|
|
|
await expect(store.open(task.id)).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("start", () => {
|
|
it("transitions open to in_progress", async () => {
|
|
const task = await store.create("Task");
|
|
await store.start(task.id);
|
|
|
|
const updated = await store.get(task.id);
|
|
expect(updated?.status).toBe("in_progress");
|
|
});
|
|
|
|
it("throws when task is draft", async () => {
|
|
const task = await store.create("Draft", { status: "draft" });
|
|
|
|
await expect(store.start(task.id)).rejects.toThrow();
|
|
});
|
|
|
|
it("throws when task is already done", async () => {
|
|
const task = await store.create("Task");
|
|
await store.close(task.id);
|
|
|
|
await expect(store.start(task.id)).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("close", () => {
|
|
it("transitions open to done", async () => {
|
|
const task = await store.create("Task");
|
|
await store.close(task.id);
|
|
|
|
const updated = await store.get(task.id);
|
|
expect(updated?.status).toBe("done");
|
|
});
|
|
|
|
it("transitions in_progress to done", async () => {
|
|
const task = await store.create("Task");
|
|
await store.start(task.id);
|
|
await store.close(task.id);
|
|
|
|
const updated = await store.get(task.id);
|
|
expect(updated?.status).toBe("done");
|
|
});
|
|
|
|
it("transitions draft to done", async () => {
|
|
const task = await store.create("Task", { status: "draft" });
|
|
await store.close(task.id);
|
|
|
|
const updated = await store.get(task.id);
|
|
expect(updated?.status).toBe("done");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("dependencies", () => {
|
|
describe("addDep", () => {
|
|
it("adds a dependency", async () => {
|
|
const dep = await store.create("Dependency");
|
|
const task = await store.create("Task");
|
|
|
|
await store.addDep(task.id, dep.id);
|
|
|
|
const updated = await store.get(task.id);
|
|
expect(updated?.deps).toContain(dep.id);
|
|
});
|
|
|
|
it("does not duplicate dependencies", async () => {
|
|
const dep = await store.create("Dependency");
|
|
const task = await store.create("Task");
|
|
|
|
await store.addDep(task.id, dep.id);
|
|
await store.addDep(task.id, dep.id);
|
|
|
|
const updated = await store.get(task.id);
|
|
expect(updated?.deps).toEqual([dep.id]);
|
|
});
|
|
|
|
it("throws for non-existent task", async () => {
|
|
const dep = await store.create("Dependency");
|
|
|
|
await expect(store.addDep("nonexistent", dep.id)).rejects.toThrow();
|
|
});
|
|
|
|
it("throws for non-existent dependency", async () => {
|
|
const task = await store.create("Task");
|
|
|
|
await expect(store.addDep(task.id, "nonexistent")).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("removeDep", () => {
|
|
it("removes a dependency", async () => {
|
|
const dep = await store.create("Dependency");
|
|
const task = await store.create("Task", { deps: [dep.id] });
|
|
|
|
await store.removeDep(task.id, dep.id);
|
|
|
|
const updated = await store.get(task.id);
|
|
expect(updated?.deps).toEqual([]);
|
|
});
|
|
|
|
it("is idempotent for non-existent dep", async () => {
|
|
const task = await store.create("Task");
|
|
|
|
await store.removeDep(task.id, "nonexistent");
|
|
|
|
const updated = await store.get(task.id);
|
|
expect(updated?.deps).toEqual([]);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("notes", () => {
|
|
it("adds a note with timestamp", async () => {
|
|
const task = await store.create("Task");
|
|
const before = new Date().toISOString();
|
|
|
|
await store.addNote(task.id, "This is a note");
|
|
|
|
const updated = await store.get(task.id);
|
|
expect(updated?.notes).toHaveLength(1);
|
|
expect(updated?.notes[0].content).toBe("This is a note");
|
|
expect(updated?.notes[0].timestamp >= before).toBe(true);
|
|
});
|
|
|
|
it("appends multiple notes in order", async () => {
|
|
const task = await store.create("Task");
|
|
|
|
await store.addNote(task.id, "First note");
|
|
await store.addNote(task.id, "Second note");
|
|
await store.addNote(task.id, "Third note");
|
|
|
|
const updated = await store.get(task.id);
|
|
expect(updated?.notes).toHaveLength(3);
|
|
expect(updated?.notes.map((n) => n.content)).toEqual([
|
|
"First note",
|
|
"Second note",
|
|
"Third note",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("getReady", () => {
|
|
it("returns open tasks with no deps", async () => {
|
|
const task = await store.create("Ready task");
|
|
|
|
const ready = await store.getReady();
|
|
|
|
expect(ready).toHaveLength(1);
|
|
expect(ready[0].id).toBe(task.id);
|
|
});
|
|
|
|
it("excludes draft tasks", async () => {
|
|
await store.create("Draft task", { status: "draft" });
|
|
|
|
const ready = await store.getReady();
|
|
|
|
expect(ready).toHaveLength(0);
|
|
});
|
|
|
|
it("excludes in_progress tasks", async () => {
|
|
const task = await store.create("Task");
|
|
await store.start(task.id);
|
|
|
|
const ready = await store.getReady();
|
|
|
|
expect(ready).toHaveLength(0);
|
|
});
|
|
|
|
it("excludes done tasks", async () => {
|
|
const task = await store.create("Task");
|
|
await store.close(task.id);
|
|
|
|
const ready = await store.getReady();
|
|
|
|
expect(ready).toHaveLength(0);
|
|
});
|
|
|
|
it("excludes tasks with unresolved deps", async () => {
|
|
const dep = await store.create("Dependency");
|
|
await store.create("Blocked task", { deps: [dep.id] });
|
|
|
|
const ready = await store.getReady();
|
|
|
|
expect(ready).toHaveLength(1);
|
|
expect(ready[0].id).toBe(dep.id);
|
|
});
|
|
|
|
it("includes tasks when all deps are done", async () => {
|
|
const dep = await store.create("Dependency");
|
|
const task = await store.create("Task", { deps: [dep.id] });
|
|
await store.close(dep.id);
|
|
|
|
const ready = await store.getReady();
|
|
|
|
expect(ready).toHaveLength(1);
|
|
expect(ready[0].id).toBe(task.id);
|
|
});
|
|
|
|
it("handles multiple deps correctly", async () => {
|
|
const dep1 = await store.create("Dep 1");
|
|
const dep2 = await store.create("Dep 2");
|
|
const task = await store.create("Task", { deps: [dep1.id, dep2.id] });
|
|
|
|
// Only one dep done - task not ready
|
|
await store.close(dep1.id);
|
|
let ready = await store.getReady();
|
|
expect(ready.map((t) => t.id)).not.toContain(task.id);
|
|
|
|
// Both deps done - task ready
|
|
await store.close(dep2.id);
|
|
ready = await store.getReady();
|
|
expect(ready.map((t) => t.id)).toContain(task.id);
|
|
});
|
|
|
|
it("sorts by created date (oldest first)", async () => {
|
|
const task1 = await store.create("Task 1");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
const task2 = await store.create("Task 2");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
const task3 = await store.create("Task 3");
|
|
|
|
const ready = await store.getReady();
|
|
|
|
expect(ready.map((t) => t.id)).toEqual([task1.id, task2.id, task3.id]);
|
|
});
|
|
|
|
describe("scoped to epic", () => {
|
|
it("returns only ready tasks in epic dep tree", async () => {
|
|
await store.create("Unrelated task");
|
|
const dep = await store.create("Epic dep");
|
|
const epic = await store.create("Epic", { deps: [dep.id] });
|
|
|
|
const ready = await store.getReady(epic.id);
|
|
|
|
expect(ready).toHaveLength(1);
|
|
expect(ready[0].id).toBe(dep.id);
|
|
});
|
|
|
|
it("returns empty when epic has no ready deps", async () => {
|
|
const dep = await store.create("Dep", { status: "draft" });
|
|
const epic = await store.create("Epic", { deps: [dep.id] });
|
|
|
|
const ready = await store.getReady(epic.id);
|
|
|
|
expect(ready).toHaveLength(0);
|
|
});
|
|
|
|
it("handles nested deps", async () => {
|
|
const leaf = await store.create("Leaf");
|
|
const middle = await store.create("Middle", { deps: [leaf.id] });
|
|
const epic = await store.create("Epic", { deps: [middle.id] });
|
|
|
|
// Only leaf is ready initially
|
|
let ready = await store.getReady(epic.id);
|
|
expect(ready).toHaveLength(1);
|
|
expect(ready[0].id).toBe(leaf.id);
|
|
|
|
// After leaf done, middle is ready
|
|
await store.close(leaf.id);
|
|
ready = await store.getReady(epic.id);
|
|
expect(ready).toHaveLength(1);
|
|
expect(ready[0].id).toBe(middle.id);
|
|
|
|
// After middle done, epic itself is ready (but we're scoped, so epic not in results)
|
|
await store.close(middle.id);
|
|
ready = await store.getReady(epic.id);
|
|
expect(ready).toHaveLength(0);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("getBlocked", () => {
|
|
it("returns tasks with unresolved deps", async () => {
|
|
const dep = await store.create("Dependency");
|
|
const blocked = await store.create("Blocked", { deps: [dep.id] });
|
|
|
|
const result = await store.getBlocked();
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].id).toBe(blocked.id);
|
|
});
|
|
|
|
it("excludes tasks with no deps", async () => {
|
|
await store.create("No deps");
|
|
|
|
const result = await store.getBlocked();
|
|
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
|
|
it("excludes tasks with all deps done", async () => {
|
|
const dep = await store.create("Dep");
|
|
await store.create("Task", { deps: [dep.id] });
|
|
await store.close(dep.id);
|
|
|
|
const result = await store.getBlocked();
|
|
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
|
|
it("excludes draft tasks", async () => {
|
|
const dep = await store.create("Dep");
|
|
await store.create("Draft blocked", { status: "draft", deps: [dep.id] });
|
|
|
|
const result = await store.getBlocked();
|
|
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
|
|
it("includes in_progress tasks with unresolved deps", async () => {
|
|
const dep = await store.create("Dep");
|
|
const task = await store.create("Task", { deps: [dep.id] });
|
|
// Force start even with unresolved deps (edge case)
|
|
await store.update(task.id, { status: "in_progress" });
|
|
|
|
const result = await store.getBlocked();
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].id).toBe(task.id);
|
|
});
|
|
|
|
describe("scoped to epic", () => {
|
|
it("returns only blocked tasks in epic dep tree", async () => {
|
|
const unrelatedDep = await store.create("Unrelated dep");
|
|
await store.create("Unrelated blocked", {
|
|
deps: [unrelatedDep.id],
|
|
});
|
|
|
|
const epicDep = await store.create("Epic dep");
|
|
const epicChild = await store.create("Epic child", {
|
|
deps: [epicDep.id],
|
|
});
|
|
const epic = await store.create("Epic", { deps: [epicChild.id] });
|
|
|
|
const blocked = await store.getBlocked(epic.id);
|
|
|
|
expect(blocked).toHaveLength(1);
|
|
expect(blocked[0].id).toBe(epicChild.id);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("getClosed", () => {
|
|
it("returns done tasks", async () => {
|
|
const task = await store.create("Task");
|
|
await store.close(task.id);
|
|
|
|
const closed = await store.getClosed();
|
|
|
|
expect(closed).toHaveLength(1);
|
|
expect(closed[0].id).toBe(task.id);
|
|
});
|
|
|
|
it("excludes non-done tasks", async () => {
|
|
await store.create("Open task");
|
|
await store.create("Draft task", { status: "draft" });
|
|
const inProgress = await store.create("In progress");
|
|
await store.start(inProgress.id);
|
|
|
|
const closed = await store.getClosed();
|
|
|
|
expect(closed).toHaveLength(0);
|
|
});
|
|
|
|
it("sorts by created date (most recent first)", async () => {
|
|
const task1 = await store.create("Task 1");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
const task2 = await store.create("Task 2");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
const task3 = await store.create("Task 3");
|
|
|
|
await store.close(task1.id);
|
|
await store.close(task2.id);
|
|
await store.close(task3.id);
|
|
|
|
const closed = await store.getClosed();
|
|
|
|
expect(closed.map((t) => t.id)).toEqual([task3.id, task2.id, task1.id]);
|
|
});
|
|
|
|
describe("scoped to epic", () => {
|
|
it("returns only closed tasks in epic dep tree", async () => {
|
|
const unrelated = await store.create("Unrelated");
|
|
await store.close(unrelated.id);
|
|
|
|
const dep = await store.create("Epic dep");
|
|
const epic = await store.create("Epic", { deps: [dep.id] });
|
|
await store.close(dep.id);
|
|
|
|
const closed = await store.getClosed(epic.id);
|
|
|
|
expect(closed).toHaveLength(1);
|
|
expect(closed[0].id).toBe(dep.id);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("getDepTree", () => {
|
|
it("returns empty for task with no deps", async () => {
|
|
const task = await store.create("Leaf task");
|
|
|
|
const tree = await store.getDepTree(task.id);
|
|
|
|
expect(tree).toEqual([]);
|
|
});
|
|
|
|
it("returns direct deps", async () => {
|
|
const dep1 = await store.create("Dep 1");
|
|
const dep2 = await store.create("Dep 2");
|
|
const task = await store.create("Task", { deps: [dep1.id, dep2.id] });
|
|
|
|
const tree = await store.getDepTree(task.id);
|
|
|
|
expect(tree).toHaveLength(2);
|
|
expect(tree.map((t) => t.id).sort()).toEqual([dep1.id, dep2.id].sort());
|
|
});
|
|
|
|
it("returns nested deps recursively", async () => {
|
|
const leaf = await store.create("Leaf");
|
|
const middle = await store.create("Middle", { deps: [leaf.id] });
|
|
const root = await store.create("Root", { deps: [middle.id] });
|
|
|
|
const tree = await store.getDepTree(root.id);
|
|
|
|
expect(tree).toHaveLength(2);
|
|
expect(tree.map((t) => t.id).sort()).toEqual([leaf.id, middle.id].sort());
|
|
});
|
|
|
|
it("handles diamond deps without duplicates", async () => {
|
|
const shared = await store.create("Shared");
|
|
const left = await store.create("Left", { deps: [shared.id] });
|
|
const right = await store.create("Right", { deps: [shared.id] });
|
|
const root = await store.create("Root", { deps: [left.id, right.id] });
|
|
|
|
const tree = await store.getDepTree(root.id);
|
|
|
|
expect(tree).toHaveLength(3);
|
|
expect(tree.map((t) => t.id).sort()).toEqual(
|
|
[shared.id, left.id, right.id].sort()
|
|
);
|
|
});
|
|
|
|
it("handles circular deps gracefully", async () => {
|
|
const task1 = await store.create("Task 1");
|
|
const task2 = await store.create("Task 2", { deps: [task1.id] });
|
|
await store.addDep(task1.id, task2.id); // create cycle
|
|
|
|
// Should not infinite loop
|
|
const tree = await store.getDepTree(task1.id);
|
|
|
|
expect(tree.map((t) => t.id)).toContain(task2.id);
|
|
});
|
|
|
|
it("throws for non-existent task", async () => {
|
|
await expect(store.getDepTree("nonexistent")).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("file persistence", () => {
|
|
it("persists tasks across store instances", async () => {
|
|
const task = await store.create("Persistent task", {
|
|
body: "With body",
|
|
assignee: "claude",
|
|
});
|
|
await store.addNote(task.id, "A note");
|
|
|
|
// Create new store instance pointing to same dir
|
|
const store2 = new FileTaskStore(tempDir);
|
|
const retrieved = await store2.get(task.id);
|
|
|
|
expect(retrieved).not.toBeNull();
|
|
expect(retrieved?.title).toBe("Persistent task");
|
|
expect(retrieved?.body).toBe("With body");
|
|
expect(retrieved?.assignee).toBe("claude");
|
|
expect(retrieved?.created).toBe(task.created);
|
|
expect(retrieved?.notes).toHaveLength(1);
|
|
expect(retrieved?.notes[0].content).toBe("A note");
|
|
});
|
|
|
|
it("persists parentId across store instances", async () => {
|
|
const parent = await store.create("Parent");
|
|
const child = await store.create("Child", { parentId: parent.id });
|
|
|
|
const store2 = new FileTaskStore(tempDir);
|
|
const retrieved = await store2.get(child.id);
|
|
|
|
expect(retrieved?.parentId).toBe(parent.id);
|
|
});
|
|
});
|
|
|
|
describe("parent-child hierarchy", () => {
|
|
describe("getAncestors", () => {
|
|
it("returns empty array for task with no parent", async () => {
|
|
const task = await store.create("Root task");
|
|
|
|
const ancestors = await store.getAncestors(task.id);
|
|
|
|
expect(ancestors).toEqual([]);
|
|
});
|
|
|
|
it("returns parent for child task", async () => {
|
|
const parent = await store.create("Parent");
|
|
const child = await store.create("Child", { parentId: parent.id });
|
|
|
|
const ancestors = await store.getAncestors(child.id);
|
|
|
|
expect(ancestors).toHaveLength(1);
|
|
expect(ancestors[0].id).toBe(parent.id);
|
|
});
|
|
|
|
it("returns full ancestor chain in order", async () => {
|
|
const grandparent = await store.create("Grandparent");
|
|
const parent = await store.create("Parent", {
|
|
parentId: grandparent.id,
|
|
});
|
|
const child = await store.create("Child", { parentId: parent.id });
|
|
|
|
const ancestors = await store.getAncestors(child.id);
|
|
|
|
expect(ancestors).toHaveLength(2);
|
|
expect(ancestors[0].id).toBe(parent.id);
|
|
expect(ancestors[1].id).toBe(grandparent.id);
|
|
});
|
|
|
|
it("throws for non-existent task", async () => {
|
|
await expect(store.getAncestors("nonexistent")).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("getChildren", () => {
|
|
it("returns empty array for task with no children", async () => {
|
|
const task = await store.create("Leaf task");
|
|
|
|
const children = await store.getChildren(task.id);
|
|
|
|
expect(children).toEqual([]);
|
|
});
|
|
|
|
it("returns direct children", async () => {
|
|
const parent = await store.create("Parent");
|
|
const child1 = await store.create("Child 1", { parentId: parent.id });
|
|
const child2 = await store.create("Child 2", { parentId: parent.id });
|
|
|
|
const children = await store.getChildren(parent.id);
|
|
|
|
expect(children).toHaveLength(2);
|
|
expect(children.map((c) => c.id).sort()).toEqual(
|
|
[child1.id, child2.id].sort()
|
|
);
|
|
});
|
|
|
|
it("does not return grandchildren", async () => {
|
|
const grandparent = await store.create("Grandparent");
|
|
const parent = await store.create("Parent", {
|
|
parentId: grandparent.id,
|
|
});
|
|
await store.create("Grandchild", { parentId: parent.id });
|
|
|
|
const children = await store.getChildren(grandparent.id);
|
|
|
|
expect(children).toHaveLength(1);
|
|
expect(children[0].id).toBe(parent.id);
|
|
});
|
|
});
|
|
|
|
describe("setParent", () => {
|
|
it("sets parent on a task", async () => {
|
|
const parent = await store.create("Parent");
|
|
const task = await store.create("Task");
|
|
|
|
await store.setParent(task.id, parent.id);
|
|
|
|
const updated = await store.get(task.id);
|
|
expect(updated?.parentId).toBe(parent.id);
|
|
});
|
|
|
|
it("removes parent when set to null", async () => {
|
|
const parent = await store.create("Parent");
|
|
const task = await store.create("Task", { parentId: parent.id });
|
|
|
|
await store.setParent(task.id, null);
|
|
|
|
const updated = await store.get(task.id);
|
|
expect(updated?.parentId).toBeUndefined();
|
|
});
|
|
|
|
it("throws for non-existent task", async () => {
|
|
const parent = await store.create("Parent");
|
|
|
|
await expect(store.setParent("nonexistent", parent.id)).rejects.toThrow(
|
|
"Task not found"
|
|
);
|
|
});
|
|
|
|
it("throws for non-existent parent", async () => {
|
|
const task = await store.create("Task");
|
|
|
|
await expect(store.setParent(task.id, "nonexistent")).rejects.toThrow(
|
|
"Parent task not found"
|
|
);
|
|
});
|
|
|
|
it("throws when setting task as its own parent", async () => {
|
|
const task = await store.create("Task");
|
|
|
|
await expect(store.setParent(task.id, task.id)).rejects.toThrow(
|
|
"Task cannot be its own parent"
|
|
);
|
|
});
|
|
|
|
it("throws when creating circular reference", async () => {
|
|
const parent = await store.create("Parent");
|
|
const child = await store.create("Child", { parentId: parent.id });
|
|
|
|
await expect(store.setParent(parent.id, child.id)).rejects.toThrow(
|
|
"circular reference"
|
|
);
|
|
});
|
|
});
|
|
});
|
|
});
|