generated from nhcarrigan/template
feat: add tests and assert coverage (#71)
### Explanation _No response_ ### Issue _No response_ ### Attestations - [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/) - [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/). - [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/). ### Dependencies - [ ] I have pinned the dependencies to a specific patch version. ### Style - [ ] I have run the linter and resolved any errors. - [ ] My pull request uses an appropriate title, matching the conventional commit standards. - [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request. ### Tests - [ ] My contribution adds new code, and I have added tests to cover it. - [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes. - [ ] All new and existing tests pass locally with my changes. - [ ] Code coverage remains at or above the configured threshold. ### Documentation _No response_ ### Versioning _No response_ Co-authored-by: Hikari <hikari@nhcarrigan.com> Reviewed-on: #71 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #71.
This commit is contained in:
@@ -0,0 +1,525 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
// Test the Conversation interface and store behavior
|
||||
describe("Conversation interface", () => {
|
||||
it("defines all required fields", () => {
|
||||
const conversation = {
|
||||
id: "conv-123",
|
||||
name: "Test Conversation",
|
||||
terminalLines: [],
|
||||
sessionId: null,
|
||||
connectionStatus: "disconnected" as const,
|
||||
workingDirectory: "",
|
||||
characterState: "idle" as const,
|
||||
isProcessing: false,
|
||||
grantedTools: new Set<string>(),
|
||||
pendingPermission: null,
|
||||
pendingQuestion: null,
|
||||
scrollPosition: -1,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
attachments: [],
|
||||
};
|
||||
|
||||
expect(conversation.id).toBe("conv-123");
|
||||
expect(conversation.name).toBe("Test Conversation");
|
||||
expect(conversation.terminalLines).toEqual([]);
|
||||
expect(conversation.sessionId).toBeNull();
|
||||
expect(conversation.connectionStatus).toBe("disconnected");
|
||||
expect(conversation.workingDirectory).toBe("");
|
||||
expect(conversation.characterState).toBe("idle");
|
||||
expect(conversation.isProcessing).toBe(false);
|
||||
expect(conversation.grantedTools.size).toBe(0);
|
||||
expect(conversation.pendingPermission).toBeNull();
|
||||
expect(conversation.pendingQuestion).toBeNull();
|
||||
expect(conversation.scrollPosition).toBe(-1);
|
||||
expect(conversation.attachments).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles terminal lines array", () => {
|
||||
const lines = [
|
||||
{
|
||||
id: "line-1",
|
||||
type: "user" as const,
|
||||
content: "Hello",
|
||||
timestamp: new Date(),
|
||||
},
|
||||
{
|
||||
id: "line-2",
|
||||
type: "assistant" as const,
|
||||
content: "Hi there!",
|
||||
timestamp: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines[0].type).toBe("user");
|
||||
expect(lines[1].type).toBe("assistant");
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversation ID generation", () => {
|
||||
it("generates unique IDs with timestamp prefix", () => {
|
||||
let counter = 0;
|
||||
const generateId = () => `conv-${Date.now()}-${counter++}`;
|
||||
|
||||
const id1 = generateId();
|
||||
const id2 = generateId();
|
||||
|
||||
expect(id1).toMatch(/^conv-\d+-\d+$/);
|
||||
expect(id2).toMatch(/^conv-\d+-\d+$/);
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("line ID generation", () => {
|
||||
it("generates unique line IDs", () => {
|
||||
let counter = 0;
|
||||
const generateLineId = () => `line-${Date.now()}-${counter++}`;
|
||||
|
||||
const id1 = generateLineId();
|
||||
const id2 = generateLineId();
|
||||
|
||||
expect(id1).toMatch(/^line-\d+-\d+$/);
|
||||
expect(id2).toMatch(/^line-\d+-\d+$/);
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("connection status types", () => {
|
||||
it("supports all connection status values", () => {
|
||||
const statuses = ["connected", "disconnected", "connecting", "error"] as const;
|
||||
|
||||
statuses.forEach((status) => {
|
||||
expect(typeof status).toBe("string");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("character state types", () => {
|
||||
it("supports all character state values", () => {
|
||||
const states = [
|
||||
"idle",
|
||||
"thinking",
|
||||
"typing",
|
||||
"searching",
|
||||
"coding",
|
||||
"mcp",
|
||||
"permission",
|
||||
"success",
|
||||
"error",
|
||||
] as const;
|
||||
|
||||
states.forEach((state) => {
|
||||
expect(typeof state).toBe("string");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("terminal line types", () => {
|
||||
it("supports all terminal line types", () => {
|
||||
const types = ["user", "assistant", "system", "tool", "error"] as const;
|
||||
|
||||
types.forEach((type) => {
|
||||
expect(typeof type).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
it("creates terminal line with required fields", () => {
|
||||
const line = {
|
||||
id: "line-123",
|
||||
type: "user" as const,
|
||||
content: "Test message",
|
||||
timestamp: new Date(),
|
||||
toolName: undefined,
|
||||
};
|
||||
|
||||
expect(line.id).toBe("line-123");
|
||||
expect(line.type).toBe("user");
|
||||
expect(line.content).toBe("Test message");
|
||||
expect(line.timestamp).toBeInstanceOf(Date);
|
||||
expect(line.toolName).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates terminal line with tool name", () => {
|
||||
const line = {
|
||||
id: "line-456",
|
||||
type: "tool" as const,
|
||||
content: "Tool output",
|
||||
timestamp: new Date(),
|
||||
toolName: "Read",
|
||||
};
|
||||
|
||||
expect(line.toolName).toBe("Read");
|
||||
});
|
||||
});
|
||||
|
||||
describe("permission request structure", () => {
|
||||
it("creates valid permission request", () => {
|
||||
const request = {
|
||||
id: "perm-123",
|
||||
tool: "Bash",
|
||||
description: "Run shell command",
|
||||
input: '{"command": "ls"}',
|
||||
};
|
||||
|
||||
expect(request.id).toBe("perm-123");
|
||||
expect(request.tool).toBe("Bash");
|
||||
expect(request.description).toBe("Run shell command");
|
||||
expect(request.input).toContain("command");
|
||||
});
|
||||
});
|
||||
|
||||
describe("user question structure", () => {
|
||||
it("creates valid question event", () => {
|
||||
const question = {
|
||||
id: "q-123",
|
||||
question: "Which option?",
|
||||
options: [
|
||||
{ label: "A", description: "Option A" },
|
||||
{ label: "B", description: "Option B" },
|
||||
],
|
||||
conversation_id: "conv-123",
|
||||
};
|
||||
|
||||
expect(question.id).toBe("q-123");
|
||||
expect(question.question).toBe("Which option?");
|
||||
expect(question.options).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("attachment structure", () => {
|
||||
it("creates valid file attachment", () => {
|
||||
const attachment = {
|
||||
id: "att-123",
|
||||
type: "file" as const,
|
||||
name: "test.txt",
|
||||
path: "/tmp/test.txt",
|
||||
size: 1024,
|
||||
mimeType: "text/plain",
|
||||
};
|
||||
|
||||
expect(attachment.id).toBe("att-123");
|
||||
expect(attachment.type).toBe("file");
|
||||
expect(attachment.name).toBe("test.txt");
|
||||
});
|
||||
|
||||
it("creates valid image attachment", () => {
|
||||
const attachment = {
|
||||
id: "att-456",
|
||||
type: "image" as const,
|
||||
name: "screenshot.png",
|
||||
path: "/tmp/screenshot.png",
|
||||
size: 50000,
|
||||
mimeType: "image/png",
|
||||
previewUrl: "data:image/png;base64,...",
|
||||
};
|
||||
|
||||
expect(attachment.type).toBe("image");
|
||||
expect(attachment.previewUrl).toContain("data:image");
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversation management operations", () => {
|
||||
it("creates new conversation with default values", () => {
|
||||
let counter = 1;
|
||||
const createNewConversation = (name?: string) => {
|
||||
const id = `conv-${Date.now()}-${counter++}`;
|
||||
return {
|
||||
id,
|
||||
name: name || `Conversation ${counter}`,
|
||||
terminalLines: [],
|
||||
sessionId: null,
|
||||
connectionStatus: "disconnected" as const,
|
||||
workingDirectory: "",
|
||||
characterState: "idle" as const,
|
||||
isProcessing: false,
|
||||
grantedTools: new Set<string>(),
|
||||
pendingPermission: null,
|
||||
pendingQuestion: null,
|
||||
scrollPosition: -1,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
attachments: [],
|
||||
};
|
||||
};
|
||||
|
||||
const conv = createNewConversation("My Chat");
|
||||
expect(conv.name).toBe("My Chat");
|
||||
expect(conv.connectionStatus).toBe("disconnected");
|
||||
expect(conv.characterState).toBe("idle");
|
||||
});
|
||||
|
||||
it("uses default name when not provided", () => {
|
||||
const counter = 5;
|
||||
const createNewConversation = (name?: string) => ({
|
||||
name: name || `Conversation ${counter}`,
|
||||
});
|
||||
|
||||
const conv = createNewConversation();
|
||||
expect(conv.name).toBe("Conversation 5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversation history formatting", () => {
|
||||
it("formats user and assistant messages for history", () => {
|
||||
const lines = [
|
||||
{ type: "user" as const, content: "Hello" },
|
||||
{ type: "assistant" as const, content: "Hi there!" },
|
||||
{ type: "system" as const, content: "Connected" },
|
||||
{ type: "user" as const, content: "How are you?" },
|
||||
];
|
||||
|
||||
const relevantLines = lines.filter((line) => line.type === "user" || line.type === "assistant");
|
||||
|
||||
const history = relevantLines
|
||||
.map((line) => {
|
||||
const role = line.type === "user" ? "User" : "Assistant";
|
||||
return `${role}: ${line.content}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
expect(history).toContain("User: Hello");
|
||||
expect(history).toContain("Assistant: Hi there!");
|
||||
expect(history).toContain("User: How are you?");
|
||||
expect(history).not.toContain("Connected");
|
||||
});
|
||||
|
||||
it("returns empty string for no messages", () => {
|
||||
const lines: Array<{ type: string; content: string }> = [];
|
||||
const relevantLines = lines.filter((line) => line.type === "user" || line.type === "assistant");
|
||||
|
||||
expect(relevantLines.length).toBe(0);
|
||||
const history = relevantLines.length === 0 ? "" : "has content";
|
||||
expect(history).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool granting", () => {
|
||||
it("tracks granted tools with Set", () => {
|
||||
const grantedTools = new Set<string>();
|
||||
|
||||
grantedTools.add("Read");
|
||||
grantedTools.add("Write");
|
||||
grantedTools.add("Bash");
|
||||
|
||||
expect(grantedTools.has("Read")).toBe(true);
|
||||
expect(grantedTools.has("Write")).toBe(true);
|
||||
expect(grantedTools.has("Bash")).toBe(true);
|
||||
expect(grantedTools.has("Edit")).toBe(false);
|
||||
expect(grantedTools.size).toBe(3);
|
||||
});
|
||||
|
||||
it("clears all granted tools", () => {
|
||||
const grantedTools = new Set<string>(["Read", "Write"]);
|
||||
expect(grantedTools.size).toBe(2);
|
||||
|
||||
grantedTools.clear();
|
||||
expect(grantedTools.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scroll position handling", () => {
|
||||
it("uses -1 for auto-scroll (scroll to bottom)", () => {
|
||||
const scrollPosition = -1;
|
||||
const isAutoScroll = scrollPosition === -1;
|
||||
|
||||
expect(isAutoScroll).toBe(true);
|
||||
});
|
||||
|
||||
it("uses positive values for manual scroll position", () => {
|
||||
const scrollPosition: number = 500;
|
||||
const isAutoScroll = scrollPosition === -1;
|
||||
|
||||
expect(isAutoScroll).toBe(false);
|
||||
expect(scrollPosition).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversation deletion rules", () => {
|
||||
it("prevents deletion of last conversation", () => {
|
||||
const conversations = new Map([["conv-1", { id: "conv-1", name: "Main" }]]);
|
||||
|
||||
const canDelete = conversations.size > 1;
|
||||
expect(canDelete).toBe(false);
|
||||
});
|
||||
|
||||
it("allows deletion when multiple conversations exist", () => {
|
||||
const conversations = new Map([
|
||||
["conv-1", { id: "conv-1", name: "Main" }],
|
||||
["conv-2", { id: "conv-2", name: "Second" }],
|
||||
]);
|
||||
|
||||
const canDelete = conversations.size > 1;
|
||||
expect(canDelete).toBe(true);
|
||||
});
|
||||
|
||||
it("switches to remaining conversation after deletion", () => {
|
||||
const conversations = new Map([
|
||||
["conv-1", { id: "conv-1", name: "Main" }],
|
||||
["conv-2", { id: "conv-2", name: "Second" }],
|
||||
]);
|
||||
|
||||
const activeId = "conv-1";
|
||||
conversations.delete(activeId);
|
||||
|
||||
const remaining = Array.from(conversations.keys());
|
||||
expect(remaining).toEqual(["conv-2"]);
|
||||
expect(remaining[0]).toBe("conv-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("activity timestamp tracking", () => {
|
||||
it("updates lastActivityAt on changes", () => {
|
||||
const before = new Date();
|
||||
|
||||
// Simulate a small delay
|
||||
const after = new Date(before.getTime() + 100);
|
||||
|
||||
expect(after.getTime()).toBeGreaterThan(before.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("Map operations for conversations", () => {
|
||||
it("stores and retrieves conversations by ID", () => {
|
||||
const conversations = new Map<string, { id: string; name: string }>();
|
||||
|
||||
conversations.set("conv-1", { id: "conv-1", name: "First" });
|
||||
conversations.set("conv-2", { id: "conv-2", name: "Second" });
|
||||
|
||||
expect(conversations.get("conv-1")?.name).toBe("First");
|
||||
expect(conversations.get("conv-2")?.name).toBe("Second");
|
||||
expect(conversations.get("conv-3")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("checks if conversation exists", () => {
|
||||
const conversations = new Map([["conv-1", { id: "conv-1" }]]);
|
||||
|
||||
expect(conversations.has("conv-1")).toBe(true);
|
||||
expect(conversations.has("conv-2")).toBe(false);
|
||||
});
|
||||
|
||||
it("iterates over all conversations", () => {
|
||||
const conversations = new Map([
|
||||
["conv-1", { id: "conv-1", name: "First" }],
|
||||
["conv-2", { id: "conv-2", name: "Second" }],
|
||||
]);
|
||||
|
||||
const names: string[] = [];
|
||||
conversations.forEach((conv) => names.push(conv.name));
|
||||
|
||||
expect(names).toContain("First");
|
||||
expect(names).toContain("Second");
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversation rename", () => {
|
||||
it("updates conversation name", () => {
|
||||
const conversation = { id: "conv-1", name: "Old Name", lastActivityAt: new Date() };
|
||||
|
||||
conversation.name = "New Name";
|
||||
conversation.lastActivityAt = new Date();
|
||||
|
||||
expect(conversation.name).toBe("New Name");
|
||||
});
|
||||
});
|
||||
|
||||
describe("attachment management", () => {
|
||||
it("adds attachment to array", () => {
|
||||
const attachments: Array<{ id: string; name: string }> = [];
|
||||
|
||||
attachments.push({ id: "att-1", name: "file1.txt" });
|
||||
expect(attachments).toHaveLength(1);
|
||||
|
||||
attachments.push({ id: "att-2", name: "file2.txt" });
|
||||
expect(attachments).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("removes attachment by ID", () => {
|
||||
const attachments = [
|
||||
{ id: "att-1", name: "file1.txt" },
|
||||
{ id: "att-2", name: "file2.txt" },
|
||||
];
|
||||
|
||||
const filtered = attachments.filter((a) => a.id !== "att-1");
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].id).toBe("att-2");
|
||||
});
|
||||
|
||||
it("clears all attachments", () => {
|
||||
const cleared: Array<{ id: string }> = [];
|
||||
expect(cleared).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("derived store behavior", () => {
|
||||
it("derives connection status from active conversation", () => {
|
||||
const activeConv = { connectionStatus: "connected" as const };
|
||||
const derivedStatus = activeConv?.connectionStatus || "disconnected";
|
||||
|
||||
expect(derivedStatus).toBe("connected");
|
||||
});
|
||||
|
||||
it("defaults to disconnected when no active conversation", () => {
|
||||
const activeConv = null as { connectionStatus?: string } | null;
|
||||
const derivedStatus = activeConv?.connectionStatus || "disconnected";
|
||||
|
||||
expect(derivedStatus).toBe("disconnected");
|
||||
});
|
||||
|
||||
it("derives terminal lines from active conversation", () => {
|
||||
const activeConv = {
|
||||
terminalLines: [
|
||||
{ id: "1", content: "Hello" },
|
||||
{ id: "2", content: "World" },
|
||||
],
|
||||
};
|
||||
const derivedLines = activeConv?.terminalLines || [];
|
||||
|
||||
expect(derivedLines).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("defaults to empty array when no active conversation", () => {
|
||||
const activeConv = null as { terminalLines?: Array<{ id: string; content: string }> } | null;
|
||||
const derivedLines = activeConv?.terminalLines || [];
|
||||
|
||||
expect(derivedLines).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("line update operations", () => {
|
||||
it("updates line content by ID", () => {
|
||||
const lines = [
|
||||
{ id: "line-1", content: "Original" },
|
||||
{ id: "line-2", content: "Other" },
|
||||
];
|
||||
|
||||
const line = lines.find((l) => l.id === "line-1");
|
||||
if (line) {
|
||||
line.content = "Updated";
|
||||
}
|
||||
|
||||
expect(lines[0].content).toBe("Updated");
|
||||
expect(lines[1].content).toBe("Other");
|
||||
});
|
||||
|
||||
it("appends to line content", () => {
|
||||
const line = { id: "line-1", content: "Hello" };
|
||||
|
||||
line.content += " World";
|
||||
|
||||
expect(line.content).toBe("Hello World");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pending retry message", () => {
|
||||
it("stores and clears retry message", () => {
|
||||
let pendingRetryMessage: string | null = null;
|
||||
|
||||
pendingRetryMessage = "Retry this message";
|
||||
expect(pendingRetryMessage).toBe("Retry this message");
|
||||
|
||||
pendingRetryMessage = null;
|
||||
expect(pendingRetryMessage).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user