backlog-md/src/test/core.test.ts

510 lines
16 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { join } from "node:path";
import { $ } from "bun";
import { Core } from "../core/backlog.ts";
import type { Document, Task } from "../types/index.ts";
import { createUniqueTestDir, safeCleanup } from "./test-utils.ts";
let TEST_DIR: string;
describe("Core", () => {
let core: Core;
beforeEach(async () => {
TEST_DIR = createUniqueTestDir("test-core");
core = new Core(TEST_DIR);
await core.filesystem.ensureBacklogStructure();
// Initialize git repository for testing
await $`git init -b main`.cwd(TEST_DIR).quiet();
await $`git config user.name "Test User"`.cwd(TEST_DIR).quiet();
await $`git config user.email test@example.com`.cwd(TEST_DIR).quiet();
});
afterEach(async () => {
try {
await safeCleanup(TEST_DIR);
} catch {
// Ignore cleanup errors - the unique directory names prevent conflicts
}
});
describe("initialization", () => {
it("should have filesystem and git operations available", () => {
expect(core.filesystem).toBeDefined();
expect(core.gitOps).toBeDefined();
});
it("should initialize project with default config", async () => {
await core.initializeProject("Test Project", true);
const config = await core.filesystem.loadConfig();
expect(config?.projectName).toBe("Test Project");
expect(config?.statuses).toEqual(["To Do", "In Progress", "Done"]);
expect(config?.defaultStatus).toBe("To Do");
});
});
describe("task operations", () => {
const sampleTask: Task = {
id: "task-1",
title: "Test Task",
status: "To Do",
assignee: [],
createdDate: "2025-06-07",
labels: ["test"],
dependencies: [],
description: "This is a test task",
};
beforeEach(async () => {
await core.initializeProject("Test Project", true);
});
it("should create task without auto-commit", async () => {
await core.createTask(sampleTask, false);
const loadedTask = await core.filesystem.loadTask("task-1");
expect(loadedTask?.id).toBe("task-1");
expect(loadedTask?.title).toBe("Test Task");
});
it("should create task with auto-commit", async () => {
await core.createTask(sampleTask, true);
// Check if task file was created
const loadedTask = await core.filesystem.loadTask("task-1");
expect(loadedTask?.id).toBe("task-1");
// Check git status to see if there are uncommitted changes
const _hasChanges = await core.gitOps.hasUncommittedChanges();
const lastCommit = await core.gitOps.getLastCommitMessage();
// For now, just check that we have a commit (could be initialization or task)
expect(lastCommit).toBeDefined();
expect(lastCommit.length).toBeGreaterThan(0);
});
it("should update task with auto-commit", async () => {
await core.createTask(sampleTask, true);
// Check original task
const originalTask = await core.filesystem.loadTask("task-1");
expect(originalTask?.title).toBe("Test Task");
await core.updateTaskFromInput("task-1", { title: "Updated Task" }, true);
// Check if task was updated
const loadedTask = await core.filesystem.loadTask("task-1");
expect(loadedTask?.title).toBe("Updated Task");
const lastCommit = await core.gitOps.getLastCommitMessage();
// For now, just check that we have a commit (could be initialization or task)
expect(lastCommit).toBeDefined();
expect(lastCommit.length).toBeGreaterThan(0);
});
it("should archive task with auto-commit", async () => {
await core.createTask(sampleTask, true);
const archived = await core.archiveTask("task-1", true);
expect(archived).toBe(true);
const lastCommit = await core.gitOps.getLastCommitMessage();
expect(lastCommit).toContain("backlog: Archive task task-1");
});
it("should demote task with auto-commit", async () => {
await core.createTask(sampleTask, true);
const demoted = await core.demoteTask("task-1", true);
expect(demoted).toBe(true);
const lastCommit = await core.gitOps.getLastCommitMessage();
expect(lastCommit).toContain("backlog: Demote task task-1");
});
it("should resolve tasks using flexible ID formats", async () => {
const standardTask: Task = { ...sampleTask, id: "task-5", title: "Standard" };
const paddedTask: Task = { ...sampleTask, id: "task-007", title: "Padded" };
await core.createTask(standardTask, false);
await core.createTask(paddedTask, false);
const uppercase = await core.getTask("TASK-5");
expect(uppercase?.id).toBe("task-5");
const bare = await core.getTask("5");
expect(bare?.id).toBe("task-5");
const zeroPadded = await core.getTask("0007");
expect(zeroPadded?.id).toBe("task-007");
const mixedCase = await core.getTask("Task-007");
expect(mixedCase?.id).toBe("task-007");
});
it("should return false when archiving non-existent task", async () => {
const archived = await core.archiveTask("non-existent", true);
expect(archived).toBe(false);
});
it("should apply default status when task has empty status", async () => {
const taskWithoutStatus: Task = {
...sampleTask,
status: "",
};
await core.createTask(taskWithoutStatus, false);
const loadedTask = await core.filesystem.loadTask("task-1");
expect(loadedTask?.status).toBe("To Do"); // Should use default from config
});
it("should not override existing status", async () => {
const taskWithStatus: Task = {
...sampleTask,
status: "In Progress",
};
await core.createTask(taskWithStatus, false);
const loadedTask = await core.filesystem.loadTask("task-1");
expect(loadedTask?.status).toBe("In Progress");
});
it("should preserve description text when saving without header markers", async () => {
const taskNoHeader: Task = {
...sampleTask,
id: "task-2",
description: "Just text",
};
await core.createTask(taskNoHeader, false);
const loaded = await core.filesystem.loadTask("task-2");
expect(loaded?.description).toBe("Just text");
const body = await core.getTaskContent("task-2");
const matches = (body?.match(/## Description/g) ?? []).length;
expect(matches).toBe(1);
});
it("should not duplicate description header in saved content", async () => {
const taskWithHeader: Task = {
...sampleTask,
id: "task-3",
description: "Existing",
};
await core.createTask(taskWithHeader, false);
const body = await core.getTaskContent("task-3");
const matches = (body?.match(/## Description/g) ?? []).length;
expect(matches).toBe(1);
});
it("should handle task creation without auto-commit when git fails", async () => {
// Create task in directory without git
const nonGitCore = new Core(join(TEST_DIR, "no-git"));
await nonGitCore.filesystem.ensureBacklogStructure();
// This should succeed even without git
await nonGitCore.createTask(sampleTask, false);
const loadedTask = await nonGitCore.filesystem.loadTask("task-1");
expect(loadedTask?.id).toBe("task-1");
});
it("should normalize assignee for string and array inputs", async () => {
const stringTask = {
...sampleTask,
id: "task-2",
title: "String Assignee",
assignee: "@alice",
} as unknown as Task;
await core.createTask(stringTask, false);
const loadedString = await core.filesystem.loadTask("task-2");
expect(loadedString?.assignee).toEqual(["@alice"]);
const arrayTask: Task = {
...sampleTask,
id: "task-3",
title: "Array Assignee",
assignee: ["@bob"],
};
await core.createTask(arrayTask, false);
const loadedArray = await core.filesystem.loadTask("task-3");
expect(loadedArray?.assignee).toEqual(["@bob"]);
});
it("should normalize assignee when updating tasks", async () => {
await core.createTask(sampleTask, false);
await core.updateTaskFromInput("task-1", { assignee: ["@carol"] }, false);
let loaded = await core.filesystem.loadTask("task-1");
expect(loaded?.assignee).toEqual(["@carol"]);
await core.updateTaskFromInput("task-1", { assignee: ["@dave"] }, false);
loaded = await core.filesystem.loadTask("task-1");
expect(loaded?.assignee).toEqual(["@dave"]);
});
it("should create sub-tasks with proper hierarchical IDs", async () => {
await core.initializeProject("Subtask Project", true);
// Create parent task
const { task: parent } = await core.createTaskFromInput({
title: "Parent Task",
status: "To Do",
});
expect(parent.id).toBe("task-1");
// Create first sub-task
const { task: child1 } = await core.createTaskFromInput({
title: "First Child",
parentTaskId: parent.id,
status: "To Do",
});
expect(child1.id).toBe("task-1.1");
expect(child1.parentTaskId).toBe("task-1");
// Create second sub-task
const { task: child2 } = await core.createTaskFromInput({
title: "Second Child",
parentTaskId: parent.id,
status: "To Do",
});
expect(child2.id).toBe("task-1.2");
expect(child2.parentTaskId).toBe("task-1");
// Create another parent task to ensure sequential numbering still works
const { task: parent2 } = await core.createTaskFromInput({
title: "Second Parent",
status: "To Do",
});
expect(parent2.id).toBe("task-2");
});
});
describe("document operations", () => {
const baseDocument: Document = {
id: "doc-1",
title: "Operations Guide",
type: "guide",
createdDate: "2025-06-07",
rawContent: "# Ops Guide",
};
beforeEach(async () => {
await core.initializeProject("Test Project", false);
});
it("updates a document title without leaving the previous file behind", async () => {
await core.createDocument(baseDocument, false);
const [initialFile] = await Array.fromAsync(new Bun.Glob("doc-*.md").scan({ cwd: core.filesystem.docsDir }));
expect(initialFile).toBe("doc-1 - Operations-Guide.md");
const documents = await core.filesystem.listDocuments();
const existingDoc = documents[0];
if (!existingDoc) {
throw new Error("Expected document to exist after creation");
}
expect(existingDoc.title).toBe("Operations Guide");
await core.updateDocument({ ...existingDoc, title: "Operations Guide Updated" }, "# Updated content", false);
const docFiles = await Array.fromAsync(new Bun.Glob("doc-*.md").scan({ cwd: core.filesystem.docsDir }));
expect(docFiles).toHaveLength(1);
expect(docFiles[0]).toBe("doc-1 - Operations-Guide-Updated.md");
const updatedDocs = await core.filesystem.listDocuments();
expect(updatedDocs[0]?.title).toBe("Operations Guide Updated");
});
it("shows a git rename when the document title changes", async () => {
await core.createDocument(baseDocument, true);
const renamedDoc: Document = {
...baseDocument,
title: "Operations Guide Renamed",
};
await core.updateDocument(renamedDoc, "# Ops Guide", false);
await $`git add -A`.cwd(TEST_DIR).quiet();
const diffResult = await $`git diff --name-status -M HEAD`.cwd(TEST_DIR).quiet();
const diff = diffResult.stdout.toString();
const previousPath = "backlog/docs/doc-1 - Operations-Guide.md";
const renamedPath = "backlog/docs/doc-1 - Operations-Guide-Renamed.md";
const escapeForRegex = (value: string) => value.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&");
expect(diff).toMatch(
new RegExp(`^R\\d*\\t${escapeForRegex(previousPath)}\\t${escapeForRegex(renamedPath)}`, "m"),
);
});
});
describe("draft operations", () => {
const sampleDraft: Task = {
id: "task-draft",
title: "Draft Task",
status: "Draft",
assignee: [],
createdDate: "2025-06-07",
labels: [],
dependencies: [],
description: "Draft task",
};
beforeEach(async () => {
await core.initializeProject("Draft Project", true);
});
it("should create draft without auto-commit", async () => {
await core.createDraft(sampleDraft, false);
const loaded = await core.filesystem.loadDraft("task-draft");
expect(loaded?.id).toBe("task-draft");
});
it("should create draft with auto-commit", async () => {
await core.createDraft(sampleDraft, true);
const loaded = await core.filesystem.loadDraft("task-draft");
expect(loaded?.id).toBe("task-draft");
const lastCommit = await core.gitOps.getLastCommitMessage();
expect(lastCommit).toBeDefined();
expect(lastCommit.length).toBeGreaterThan(0);
});
it("should promote draft with auto-commit", async () => {
await core.createDraft(sampleDraft, true);
const promoted = await core.promoteDraft("task-draft", true);
expect(promoted).toBe(true);
const lastCommit = await core.gitOps.getLastCommitMessage();
expect(lastCommit).toContain("backlog: Promote draft task-draft");
});
it("should archive draft with auto-commit", async () => {
await core.createDraft(sampleDraft, true);
const archived = await core.archiveDraft("task-draft", true);
expect(archived).toBe(true);
const lastCommit = await core.gitOps.getLastCommitMessage();
expect(lastCommit).toContain("backlog: Archive draft task-draft");
});
it("should normalize assignee for string and array inputs", async () => {
const draftString = {
...sampleDraft,
id: "task-draft-1",
title: "Draft String",
assignee: "@erin",
} as unknown as Task;
await core.createDraft(draftString, false);
const loadedString = await core.filesystem.loadDraft("task-draft-1");
expect(loadedString?.assignee).toEqual(["@erin"]);
const draftArray: Task = {
...sampleDraft,
id: "task-draft-2",
title: "Draft Array",
assignee: ["@frank"],
};
await core.createDraft(draftArray, false);
const loadedArray = await core.filesystem.loadDraft("task-draft-2");
expect(loadedArray?.assignee).toEqual(["@frank"]);
});
});
describe("integration with config", () => {
it("should use custom default status from config", async () => {
// Initialize with custom config
await core.initializeProject("Custom Project");
// Update config with custom default status
const config = await core.filesystem.loadConfig();
if (config) {
config.defaultStatus = "Custom Status";
await core.filesystem.saveConfig(config);
}
const taskWithoutStatus: Task = {
id: "task-custom",
title: "Custom Task",
status: "",
assignee: [],
createdDate: "2025-06-07",
labels: [],
dependencies: [],
description: "Task without status",
};
await core.createTask(taskWithoutStatus, false);
const loadedTask = await core.filesystem.loadTask("task-custom");
expect(loadedTask?.status).toBe("Custom Status");
});
it("should fall back to To Do when config has no default status", async () => {
// Initialize project
await core.initializeProject("Fallback Project");
// Update config to remove default status
const config = await core.filesystem.loadConfig();
if (config) {
config.defaultStatus = undefined;
await core.filesystem.saveConfig(config);
}
const taskWithoutStatus: Task = {
id: "task-fallback",
title: "Fallback Task",
status: "",
assignee: [],
createdDate: "2025-06-07",
labels: [],
dependencies: [],
description: "Task without status",
};
await core.createTask(taskWithoutStatus, false);
const loadedTask = await core.filesystem.loadTask("task-fallback");
expect(loadedTask?.status).toBe("To Do");
});
});
describe("directory accessor integration", () => {
it("should use FileSystem directory accessors for git operations", async () => {
await core.initializeProject("Accessor Test");
const task: Task = {
id: "task-accessor",
title: "Accessor Test Task",
status: "To Do",
assignee: [],
createdDate: "2025-06-07",
labels: [],
dependencies: [],
description: "Testing directory accessors",
};
// Create task without auto-commit to avoid potential git timing issues
await core.createTask(task, false);
// Verify the task file was created in the correct directory
const _tasksDir = core.filesystem.tasksDir;
// List all files to see what was actually created
const allFiles = await core.filesystem.listTasks();
// Check that a task with the expected ID exists
const createdTask = allFiles.find((t) => t.id === "task-accessor");
expect(createdTask).toBeDefined();
expect(createdTask?.title).toBe("Accessor Test Task");
}, 10000);
});
});