import { describe, it, expect, vi, beforeEach } from "vitest"; import { get } from "svelte/store"; import { invoke } from "@tauri-apps/api/core"; import { setMockInvokeResult } from "../../../vitest.setup"; import { prdStore, PRD_FILENAME, type PrdTask } from "./prd"; describe("PRD_FILENAME", () => { it("is hikari-tasks.json", () => { expect(PRD_FILENAME).toBe("hikari-tasks.json"); }); }); describe("prdStore", () => { beforeEach(() => { vi.clearAllMocks(); prdStore.reset(); }); describe("initial state", () => { it("has empty goal", () => { expect(get(prdStore.goal)).toBe(""); }); it("has empty tasks array", () => { expect(get(prdStore.tasks)).toEqual([]); }); it("has false isGenerating", () => { expect(get(prdStore.isGenerating)).toBe(false); }); it("has false isLoaded", () => { expect(get(prdStore.isLoaded)).toBe(false); }); it("has false isLoading", () => { expect(get(prdStore.isLoading)).toBe(false); }); it("has false isSaving", () => { expect(get(prdStore.isSaving)).toBe(false); }); it("exposes all expected methods", () => { expect(typeof prdStore.loadFromFile).toBe("function"); expect(typeof prdStore.saveToFile).toBe("function"); expect(typeof prdStore.generatePrd).toBe("function"); expect(typeof prdStore.finishGenerating).toBe("function"); expect(typeof prdStore.addTask).toBe("function"); expect(typeof prdStore.updateTask).toBe("function"); expect(typeof prdStore.removeTask).toBe("function"); expect(typeof prdStore.moveTaskUp).toBe("function"); expect(typeof prdStore.moveTaskDown).toBe("function"); expect(typeof prdStore.reset).toBe("function"); }); }); describe("loadFromFile", () => { const mockPrdFile = JSON.stringify({ version: 1, goal: "Build a REST API", tasks: [ { id: "task-1", title: "Set up project", prompt: "Initialise the Node.js project", priority: "high", }, ], }); it("calls read_file_content with the correct path", async () => { setMockInvokeResult("read_file_content", mockPrdFile); await prdStore.loadFromFile("/home/naomi/myproject"); expect(invoke).toHaveBeenCalledWith("read_file_content", { path: "/home/naomi/myproject/hikari-tasks.json", }); }); it("sets goal from loaded file", async () => { setMockInvokeResult("read_file_content", mockPrdFile); await prdStore.loadFromFile("/home/naomi/myproject"); expect(get(prdStore.goal)).toBe("Build a REST API"); }); it("sets tasks from loaded file", async () => { setMockInvokeResult("read_file_content", mockPrdFile); await prdStore.loadFromFile("/home/naomi/myproject"); const tasks = get(prdStore.tasks); expect(tasks).toHaveLength(1); expect(tasks[0].title).toBe("Set up project"); }); it("sets isLoaded to true on success", async () => { setMockInvokeResult("read_file_content", mockPrdFile); await prdStore.loadFromFile("/home/naomi/myproject"); expect(get(prdStore.isLoaded)).toBe(true); }); it("sets isLoaded to false when file not found", async () => { const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); setMockInvokeResult("read_file_content", new Error("File not found")); await prdStore.loadFromFile("/home/naomi/myproject"); expect(get(prdStore.isLoaded)).toBe(false); consoleSpy.mockRestore(); }); it("sets isLoaded to false on JSON parse error", async () => { const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); setMockInvokeResult("read_file_content", "not valid json {{{"); await prdStore.loadFromFile("/home/naomi/myproject"); expect(get(prdStore.isLoaded)).toBe(false); consoleSpy.mockRestore(); }); it("sets isLoading to false after success", async () => { setMockInvokeResult("read_file_content", mockPrdFile); await prdStore.loadFromFile("/home/naomi/myproject"); expect(get(prdStore.isLoading)).toBe(false); }); it("sets isLoading to false on error", async () => { const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); setMockInvokeResult("read_file_content", new Error("Read error")); await prdStore.loadFromFile("/home/naomi/myproject"); expect(get(prdStore.isLoading)).toBe(false); consoleSpy.mockRestore(); }); it("logs error when file load fails", async () => { const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); setMockInvokeResult("read_file_content", new Error("File not found")); await prdStore.loadFromFile("/home/naomi/myproject"); expect(consoleSpy).toHaveBeenCalledWith("Failed to load PRD file:", expect.any(Error)); consoleSpy.mockRestore(); }); }); describe("saveToFile", () => { it("calls write_file_content with the correct path", async () => { setMockInvokeResult("write_file_content", undefined); await prdStore.saveToFile("/home/naomi/myproject"); expect(invoke).toHaveBeenCalledWith("write_file_content", { path: "/home/naomi/myproject/hikari-tasks.json", content: expect.any(String), }); }); it("writes valid JSON with version 1", async () => { setMockInvokeResult("write_file_content", undefined); await prdStore.saveToFile("/home/naomi/myproject"); const writeCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "write_file_content"); const content = (writeCall?.[1] as { content: string } | undefined)?.content ?? ""; const parsed = JSON.parse(content) as { version: number }; expect(parsed.version).toBe(1); }); it("includes current goal in the saved file", async () => { setMockInvokeResult("write_file_content", undefined); const mockPrdFile = JSON.stringify({ version: 1, goal: "My goal", tasks: [] }); setMockInvokeResult("read_file_content", mockPrdFile); await prdStore.loadFromFile("/home/naomi/myproject"); vi.clearAllMocks(); setMockInvokeResult("write_file_content", undefined); await prdStore.saveToFile("/home/naomi/myproject"); const writeCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "write_file_content"); const content = (writeCall?.[1] as { content: string } | undefined)?.content ?? ""; const parsed = JSON.parse(content) as { goal: string }; expect(parsed.goal).toBe("My goal"); }); it("returns true on success", async () => { setMockInvokeResult("write_file_content", undefined); const result = await prdStore.saveToFile("/home/naomi/myproject"); expect(result).toBe(true); }); it("returns false on failure", async () => { const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); setMockInvokeResult("write_file_content", new Error("Write failed")); const result = await prdStore.saveToFile("/home/naomi/myproject"); expect(result).toBe(false); consoleSpy.mockRestore(); }); it("logs error on failure", async () => { const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); setMockInvokeResult("write_file_content", new Error("Write failed")); await prdStore.saveToFile("/home/naomi/myproject"); expect(consoleSpy).toHaveBeenCalledWith("Failed to save PRD file:", expect.any(Error)); consoleSpy.mockRestore(); }); it("sets isSaving to false after success", async () => { setMockInvokeResult("write_file_content", undefined); await prdStore.saveToFile("/home/naomi/myproject"); expect(get(prdStore.isSaving)).toBe(false); }); it("sets isSaving to false on error", async () => { const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); setMockInvokeResult("write_file_content", new Error("Write failed")); await prdStore.saveToFile("/home/naomi/myproject"); expect(get(prdStore.isSaving)).toBe(false); consoleSpy.mockRestore(); }); }); describe("generatePrd", () => { it("calls send_prompt with the conversation id", async () => { setMockInvokeResult("send_prompt", undefined); await prdStore.generatePrd("Build an API", "/home/naomi/myproject", "conv-123"); expect(invoke).toHaveBeenCalledWith("send_prompt", { conversationId: "conv-123", message: expect.any(String), }); }); it("prompt includes the user goal", async () => { setMockInvokeResult("send_prompt", undefined); await prdStore.generatePrd("Build an API", "/home/naomi/myproject", "conv-123"); const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt"); const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? ""; expect(message).toContain("Build an API"); }); it("prompt includes the working directory", async () => { setMockInvokeResult("send_prompt", undefined); await prdStore.generatePrd("Build an API", "/home/naomi/myproject", "conv-123"); const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt"); const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? ""; expect(message).toContain("/home/naomi/myproject"); }); it("prompt mentions hikari-tasks.json", async () => { setMockInvokeResult("send_prompt", undefined); await prdStore.generatePrd("Build an API", "/home/naomi/myproject", "conv-123"); const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt"); const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? ""; expect(message).toContain("hikari-tasks.json"); }); it("sets the goal in the store", async () => { setMockInvokeResult("send_prompt", undefined); await prdStore.generatePrd("Build an API", "/home/naomi/myproject", "conv-123"); expect(get(prdStore.goal)).toBe("Build an API"); }); it("resets isGenerating to false on send_prompt error", async () => { const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); setMockInvokeResult("send_prompt", new Error("Send failed")); await prdStore.generatePrd("Build an API", "/home/naomi/myproject", "conv-123"); expect(get(prdStore.isGenerating)).toBe(false); consoleSpy.mockRestore(); }); it("logs error when send_prompt fails", async () => { const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); setMockInvokeResult("send_prompt", new Error("Send failed")); await prdStore.generatePrd("Build an API", "/home/naomi/myproject", "conv-123"); expect(consoleSpy).toHaveBeenCalledWith("Failed to generate PRD:", expect.any(Error)); consoleSpy.mockRestore(); }); }); describe("finishGenerating", () => { it("sets isGenerating to false", () => { prdStore.finishGenerating(); expect(get(prdStore.isGenerating)).toBe(false); }); }); describe("executePrd", () => { beforeEach(() => { setMockInvokeResult("write_file_content", undefined); setMockInvokeResult("send_prompt", undefined); prdStore.addTask({ title: "Set up project", prompt: "Initialise the repo", priority: "high", }); prdStore.addTask({ title: "Write tests", prompt: "Add vitest tests", priority: "medium" }); }); it("saves the file before sending the prompt", async () => { await prdStore.executePrd("/home/naomi/myproject", "conv-123"); const writeCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "write_file_content"); expect(writeCall).toBeDefined(); }); it("calls send_prompt with the conversation id", async () => { await prdStore.executePrd("/home/naomi/myproject", "conv-123"); expect(invoke).toHaveBeenCalledWith("send_prompt", { conversationId: "conv-123", message: expect.any(String), }); }); it("prompt includes all task titles", async () => { await prdStore.executePrd("/home/naomi/myproject", "conv-123"); const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt"); const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? ""; expect(message).toContain("Set up project"); expect(message).toContain("Write tests"); }); it("prompt includes all task prompts", async () => { await prdStore.executePrd("/home/naomi/myproject", "conv-123"); const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt"); const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? ""; expect(message).toContain("Initialise the repo"); expect(message).toContain("Add vitest tests"); }); it("prompt includes the working directory", async () => { await prdStore.executePrd("/home/naomi/myproject", "conv-123"); const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt"); const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? ""; expect(message).toContain("/home/naomi/myproject"); }); it("prompt references hikari-tasks.json", async () => { await prdStore.executePrd("/home/naomi/myproject", "conv-123"); const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt"); const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? ""; expect(message).toContain("hikari-tasks.json"); }); it("exposes executePrd as a method", () => { expect(typeof prdStore.executePrd).toBe("function"); }); }); describe("addTask", () => { it("appends a new task to the list", () => { prdStore.addTask({ title: "Task A", prompt: "Do A", priority: "high" }); const tasks = get(prdStore.tasks); expect(tasks).toHaveLength(1); expect(tasks[0].title).toBe("Task A"); }); it("assigns a unique id to each task", () => { prdStore.addTask({ title: "Task A", prompt: "Do A", priority: "high" }); prdStore.addTask({ title: "Task B", prompt: "Do B", priority: "low" }); const tasks = get(prdStore.tasks); expect(tasks[0].id).not.toBe(tasks[1].id); }); it("preserves all task fields", () => { prdStore.addTask({ title: "My Task", prompt: "Do the thing", priority: "medium" }); const task = get(prdStore.tasks)[0]; expect(task.title).toBe("My Task"); expect(task.prompt).toBe("Do the thing"); expect(task.priority).toBe("medium"); }); it("adds tasks in order", () => { prdStore.addTask({ title: "First", prompt: "A", priority: "high" }); prdStore.addTask({ title: "Second", prompt: "B", priority: "low" }); const tasks = get(prdStore.tasks); expect(tasks[0].title).toBe("First"); expect(tasks[1].title).toBe("Second"); }); }); describe("updateTask", () => { let taskId: string; beforeEach(() => { prdStore.addTask({ title: "Original", prompt: "Original prompt", priority: "high" }); taskId = get(prdStore.tasks)[0].id; }); it("updates the title of the specified task", () => { prdStore.updateTask(taskId, { title: "Updated" }); expect(get(prdStore.tasks)[0].title).toBe("Updated"); }); it("updates the prompt of the specified task", () => { prdStore.updateTask(taskId, { prompt: "New prompt" }); expect(get(prdStore.tasks)[0].prompt).toBe("New prompt"); }); it("updates the priority of the specified task", () => { prdStore.updateTask(taskId, { priority: "low" }); expect(get(prdStore.tasks)[0].priority).toBe("low"); }); it("does not affect other tasks", () => { prdStore.addTask({ title: "Other", prompt: "Other prompt", priority: "medium" }); const otherId = get(prdStore.tasks)[1].id; prdStore.updateTask(taskId, { title: "Changed" }); const otherTask = get(prdStore.tasks).find((t) => t.id === otherId); expect(otherTask?.title).toBe("Other"); }); it("does nothing when id is not found", () => { prdStore.updateTask("nonexistent-id", { title: "Ghost" }); expect(get(prdStore.tasks)[0].title).toBe("Original"); }); }); describe("removeTask", () => { it("removes the task with the given id", () => { prdStore.addTask({ title: "Keep", prompt: "A", priority: "high" }); prdStore.addTask({ title: "Remove", prompt: "B", priority: "low" }); const removeId = get(prdStore.tasks)[1].id; prdStore.removeTask(removeId); const tasks = get(prdStore.tasks); expect(tasks).toHaveLength(1); expect(tasks[0].title).toBe("Keep"); }); it("does nothing when id is not found", () => { prdStore.addTask({ title: "Task", prompt: "A", priority: "high" }); prdStore.removeTask("nonexistent-id"); expect(get(prdStore.tasks)).toHaveLength(1); }); it("results in empty array when removing the only task", () => { prdStore.addTask({ title: "Only", prompt: "A", priority: "high" }); const id = get(prdStore.tasks)[0].id; prdStore.removeTask(id); expect(get(prdStore.tasks)).toHaveLength(0); }); }); describe("moveTaskUp", () => { let tasks: PrdTask[]; beforeEach(() => { prdStore.addTask({ title: "First", prompt: "A", priority: "high" }); prdStore.addTask({ title: "Second", prompt: "B", priority: "medium" }); prdStore.addTask({ title: "Third", prompt: "C", priority: "low" }); tasks = get(prdStore.tasks); }); it("swaps the task with the one above it", () => { prdStore.moveTaskUp(tasks[1].id); const result = get(prdStore.tasks); expect(result[0].title).toBe("Second"); expect(result[1].title).toBe("First"); }); it("does nothing when task is already first", () => { prdStore.moveTaskUp(tasks[0].id); const result = get(prdStore.tasks); expect(result[0].title).toBe("First"); }); it("does nothing when id is not found", () => { prdStore.moveTaskUp("nonexistent"); const result = get(prdStore.tasks); expect(result[0].title).toBe("First"); expect(result[1].title).toBe("Second"); expect(result[2].title).toBe("Third"); }); it("does not change array length", () => { prdStore.moveTaskUp(tasks[2].id); expect(get(prdStore.tasks)).toHaveLength(3); }); }); describe("moveTaskDown", () => { let tasks: PrdTask[]; beforeEach(() => { prdStore.addTask({ title: "First", prompt: "A", priority: "high" }); prdStore.addTask({ title: "Second", prompt: "B", priority: "medium" }); prdStore.addTask({ title: "Third", prompt: "C", priority: "low" }); tasks = get(prdStore.tasks); }); it("swaps the task with the one below it", () => { prdStore.moveTaskDown(tasks[1].id); const result = get(prdStore.tasks); expect(result[1].title).toBe("Third"); expect(result[2].title).toBe("Second"); }); it("does nothing when task is already last", () => { prdStore.moveTaskDown(tasks[2].id); const result = get(prdStore.tasks); expect(result[2].title).toBe("Third"); }); it("does nothing when id is not found", () => { prdStore.moveTaskDown("nonexistent"); const result = get(prdStore.tasks); expect(result[0].title).toBe("First"); expect(result[1].title).toBe("Second"); expect(result[2].title).toBe("Third"); }); it("does not change array length", () => { prdStore.moveTaskDown(tasks[0].id); expect(get(prdStore.tasks)).toHaveLength(3); }); }); describe("reset", () => { it("clears the goal", async () => { setMockInvokeResult("send_prompt", undefined); await prdStore.generatePrd("Some goal", "/wd", "conv-1"); prdStore.reset(); expect(get(prdStore.goal)).toBe(""); }); it("clears all tasks", () => { prdStore.addTask({ title: "Task", prompt: "A", priority: "high" }); prdStore.reset(); expect(get(prdStore.tasks)).toHaveLength(0); }); it("sets isLoaded to false", async () => { const mockPrdFile = JSON.stringify({ version: 1, goal: "goal", tasks: [] }); setMockInvokeResult("read_file_content", mockPrdFile); await prdStore.loadFromFile("/wd"); prdStore.reset(); expect(get(prdStore.isLoaded)).toBe(false); }); it("sets isGenerating to false", () => { prdStore.finishGenerating(); prdStore.reset(); expect(get(prdStore.isGenerating)).toBe(false); }); }); });