diff --git a/src/lib/components/AchievementNotification.test.ts b/src/lib/components/AchievementNotification.test.ts new file mode 100644 index 0000000..ed971f2 --- /dev/null +++ b/src/lib/components/AchievementNotification.test.ts @@ -0,0 +1,153 @@ +/** + * AchievementNotification Component Tests + * + * Tests the rarity classification and colour mapping logic used by the + * AchievementNotification component. + * + * What this component does: + * - Listens for "achievement:unlocked" Tauri events + * - Queues and displays achievement notifications one at a time + * - Each notification shows the achievement's name, icon, description, and rarity + * - A gradient border and badge colour correspond to the achievement's rarity + * + * Manual testing checklist: + * - [ ] Achievement notification slides in from the right + * - [ ] Notification auto-dismisses after 5 seconds + * - [ ] Dismiss button works immediately + * - [ ] Multiple achievements queue and display sequentially + * - [ ] Legendary achievements have a yellow-orange gradient + * - [ ] Epic achievements have a purple-pink gradient + * - [ ] Rare achievements have a blue-indigo gradient + * - [ ] Common achievements have a green-emerald gradient + */ + +import { describe, it, expect } from "vitest"; + +function getAchievementRarity(id: string): string { + if (id === "TokenMaster") return "legendary"; + if (["CodeMachine", "Unstoppable"].includes(id)) return "epic"; + if ( + [ + "BlossomingCoder", + "CodeWizard", + "MasterBuilder", + "EnduranceChamp", + "DeepDive", + "CreativeCoder", + ].includes(id) + ) + return "rare"; + return "common"; +} + +function getRarityColor(rarity: string): string { + switch (rarity) { + case "legendary": + return "from-yellow-400 to-orange-500"; + case "epic": + return "from-purple-400 to-pink-500"; + case "rare": + return "from-blue-400 to-indigo-500"; + default: + return "from-green-400 to-emerald-500"; + } +} + +// --- + +describe("getAchievementRarity", () => { + describe("legendary tier", () => { + it("classifies TokenMaster as legendary", () => { + expect(getAchievementRarity("TokenMaster")).toBe("legendary"); + }); + }); + + describe("epic tier", () => { + it("classifies CodeMachine as epic", () => { + expect(getAchievementRarity("CodeMachine")).toBe("epic"); + }); + + it("classifies Unstoppable as epic", () => { + expect(getAchievementRarity("Unstoppable")).toBe("epic"); + }); + }); + + describe("rare tier", () => { + it("classifies BlossomingCoder as rare", () => { + expect(getAchievementRarity("BlossomingCoder")).toBe("rare"); + }); + + it("classifies CodeWizard as rare", () => { + expect(getAchievementRarity("CodeWizard")).toBe("rare"); + }); + + it("classifies MasterBuilder as rare", () => { + expect(getAchievementRarity("MasterBuilder")).toBe("rare"); + }); + + it("classifies EnduranceChamp as rare", () => { + expect(getAchievementRarity("EnduranceChamp")).toBe("rare"); + }); + + it("classifies DeepDive as rare", () => { + expect(getAchievementRarity("DeepDive")).toBe("rare"); + }); + + it("classifies CreativeCoder as rare", () => { + expect(getAchievementRarity("CreativeCoder")).toBe("rare"); + }); + }); + + describe("common tier", () => { + it("classifies unknown IDs as common", () => { + expect(getAchievementRarity("FirstChat")).toBe("common"); + expect(getAchievementRarity("SomeNewAchievement")).toBe("common"); + expect(getAchievementRarity("")).toBe("common"); + }); + }); +}); + +describe("getRarityColor", () => { + it("returns yellow-to-orange gradient for legendary", () => { + expect(getRarityColor("legendary")).toBe("from-yellow-400 to-orange-500"); + }); + + it("returns purple-to-pink gradient for epic", () => { + expect(getRarityColor("epic")).toBe("from-purple-400 to-pink-500"); + }); + + it("returns blue-to-indigo gradient for rare", () => { + expect(getRarityColor("rare")).toBe("from-blue-400 to-indigo-500"); + }); + + it("returns green-to-emerald gradient for common", () => { + expect(getRarityColor("common")).toBe("from-green-400 to-emerald-500"); + }); + + it("falls back to green-to-emerald gradient for unknown rarities", () => { + expect(getRarityColor("mythic")).toBe("from-green-400 to-emerald-500"); + expect(getRarityColor("")).toBe("from-green-400 to-emerald-500"); + }); + + describe("end-to-end rarity pipeline", () => { + it("produces the correct colour for a legendary achievement", () => { + const color = getRarityColor(getAchievementRarity("TokenMaster")); + expect(color).toBe("from-yellow-400 to-orange-500"); + }); + + it("produces the correct colour for an epic achievement", () => { + const color = getRarityColor(getAchievementRarity("CodeMachine")); + expect(color).toBe("from-purple-400 to-pink-500"); + }); + + it("produces the correct colour for a rare achievement", () => { + const color = getRarityColor(getAchievementRarity("CodeWizard")); + expect(color).toBe("from-blue-400 to-indigo-500"); + }); + + it("produces the correct colour for a common achievement", () => { + const color = getRarityColor(getAchievementRarity("FirstChat")); + expect(color).toBe("from-green-400 to-emerald-500"); + }); + }); +}); diff --git a/src/lib/components/CliVersion.test.ts b/src/lib/components/CliVersion.test.ts new file mode 100644 index 0000000..a16516f --- /dev/null +++ b/src/lib/components/CliVersion.test.ts @@ -0,0 +1,134 @@ +/** + * CliVersion Component Tests + * + * Tests the version comparison logic used by the CliVersion component, + * which compares the installed CLI version against the supported version. + * + * What this component does: + * - Displays the installed Claude CLI version + * - Displays the highest audited/supported CLI version + * - Shows a warning when the installed version is ahead of or behind supported + * + * Manual testing checklist: + * - [ ] Installed version is fetched and displayed on mount + * - [ ] "current" badge shows in green when versions match + * - [ ] "ahead" badge shows in amber when installed is newer than supported + * - [ ] "behind" badge shows in red when installed is older than supported + * - [ ] Warning message appears for "ahead" and "behind" states + */ + +import { describe, it, expect } from "vitest"; + +const SUPPORTED_CLI_VERSION = "2.1.53"; + +function compareVersions(a: string, b: string): number { + const aParts = a.split(".").map(Number); + const bParts = b.split(".").map(Number); + for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { + const aVal = aParts[i] ?? 0; + const bVal = bParts[i] ?? 0; + if (aVal > bVal) return 1; + if (aVal < bVal) return -1; + } + return 0; +} + +// --- + +describe("SUPPORTED_CLI_VERSION", () => { + it("is defined and non-empty", () => { + expect(SUPPORTED_CLI_VERSION).toBeTruthy(); + }); + + it("matches the expected audited version", () => { + expect(SUPPORTED_CLI_VERSION).toBe("2.1.53"); + }); +}); + +describe("compareVersions", () => { + describe("equal versions", () => { + it("returns 0 for identical versions", () => { + expect(compareVersions("1.0.0", "1.0.0")).toBe(0); + }); + + it("returns 0 for the supported CLI version against itself", () => { + expect(compareVersions(SUPPORTED_CLI_VERSION, SUPPORTED_CLI_VERSION)).toBe(0); + }); + + it("returns 0 for 0.0.0 vs 0.0.0", () => { + expect(compareVersions("0.0.0", "0.0.0")).toBe(0); + }); + }); + + describe("major version differences", () => { + it("returns 1 when a has a higher major version", () => { + expect(compareVersions("2.0.0", "1.0.0")).toBe(1); + }); + + it("returns -1 when a has a lower major version", () => { + expect(compareVersions("1.0.0", "2.0.0")).toBe(-1); + }); + }); + + describe("minor version differences", () => { + it("returns 1 when a has a higher minor version", () => { + expect(compareVersions("1.2.0", "1.1.0")).toBe(1); + }); + + it("returns -1 when a has a lower minor version", () => { + expect(compareVersions("1.1.0", "1.2.0")).toBe(-1); + }); + }); + + describe("patch version differences", () => { + it("returns 1 when a has a higher patch version", () => { + expect(compareVersions("1.0.2", "1.0.1")).toBe(1); + }); + + it("returns -1 when a has a lower patch version", () => { + expect(compareVersions("1.0.1", "1.0.2")).toBe(-1); + }); + }); + + describe("major version takes precedence", () => { + it("returns 1 when a has a higher major but lower minor", () => { + expect(compareVersions("2.0.0", "1.9.9")).toBe(1); + }); + + it("returns -1 when a has a lower major but higher minor", () => { + expect(compareVersions("1.9.9", "2.0.0")).toBe(-1); + }); + }); + + describe("unequal segment counts", () => { + it("treats missing segments as 0 (a shorter than b)", () => { + expect(compareVersions("1.0", "1.0.0")).toBe(0); + }); + + it("treats missing segments as 0 (a longer than b)", () => { + expect(compareVersions("1.0.0", "1.0")).toBe(0); + }); + + it("correctly compares when a has an extra non-zero segment", () => { + expect(compareVersions("1.0.1", "1.0")).toBe(1); + }); + + it("correctly compares when b has an extra non-zero segment", () => { + expect(compareVersions("1.0", "1.0.1")).toBe(-1); + }); + }); + + describe("supported CLI version comparisons", () => { + it("returns 1 for a version ahead of supported", () => { + expect(compareVersions("2.2.0", SUPPORTED_CLI_VERSION)).toBe(1); + }); + + it("returns -1 for a version behind supported", () => { + expect(compareVersions("2.1.0", SUPPORTED_CLI_VERSION)).toBe(-1); + }); + + it("returns 0 for exactly the supported version", () => { + expect(compareVersions("2.1.53", SUPPORTED_CLI_VERSION)).toBe(0); + }); + }); +}); diff --git a/src/lib/components/ConversationTabs.test.ts b/src/lib/components/ConversationTabs.test.ts new file mode 100644 index 0000000..d5e1d2f --- /dev/null +++ b/src/lib/components/ConversationTabs.test.ts @@ -0,0 +1,111 @@ +/** + * ConversationTabs Component Tests + * + * Tests the connection status colour mapping and unread message detection + * logic used by the ConversationTabs component. + * + * What this component does: + * - Displays one tab per conversation + * - Each tab shows a coloured dot for its connection state + * - Inactive tabs with new messages show an animated blue dot badge + * - Tabs can be renamed by double-clicking + * - Tabs can be reordered by drag-and-drop + * - New tabs created with Ctrl+T, closed with Ctrl+W + * + * Manual testing checklist: + * - [ ] Connected tabs show a green dot + * - [ ] Connecting tabs show a yellow dot + * - [ ] Disconnected tabs show a red dot + * - [ ] Active tab never shows the unread badge + * - [ ] Inactive tab shows blue pulsing dot when it receives new messages + * - [ ] Switching to a tab clears the unread indicator + * - [ ] Double-clicking a tab name enables inline editing + * - [ ] Tabs can be dragged to reorder + */ + +import { describe, it, expect } from "vitest"; + +type ConnectionStatus = "connected" | "connecting" | "disconnected"; + +function getConnectionStatusColor(status: ConnectionStatus | string): string { + switch (status) { + case "connected": + return "bg-green-500"; + case "connecting": + return "bg-yellow-500"; + case "disconnected": + return "bg-red-500"; + default: + return "bg-gray-500"; + } +} + +function hasUnreadMessages( + id: string, + conversationLineCount: number, + activeConversationId: string | null, + lastSeenMessageCount: Map +): boolean { + if (id === activeConversationId) return false; + const lastSeen = lastSeenMessageCount.get(id) ?? 0; + return conversationLineCount > lastSeen; +} + +// --- + +describe("getConnectionStatusColor", () => { + it("returns green for connected status", () => { + expect(getConnectionStatusColor("connected")).toBe("bg-green-500"); + }); + + it("returns yellow for connecting status", () => { + expect(getConnectionStatusColor("connecting")).toBe("bg-yellow-500"); + }); + + it("returns red for disconnected status", () => { + expect(getConnectionStatusColor("disconnected")).toBe("bg-red-500"); + }); + + it("returns grey for unknown status (fallback)", () => { + expect(getConnectionStatusColor("error")).toBe("bg-gray-500"); + expect(getConnectionStatusColor("")).toBe("bg-gray-500"); + }); +}); + +describe("hasUnreadMessages", () => { + it("returns false for the active conversation regardless of message count", () => { + const lastSeen = new Map([["tab-1", 0]]); + expect(hasUnreadMessages("tab-1", 10, "tab-1", lastSeen)).toBe(false); + }); + + it("returns true when an inactive tab has more messages than last seen", () => { + const lastSeen = new Map([["tab-1", 5]]); + expect(hasUnreadMessages("tab-1", 10, "tab-2", lastSeen)).toBe(true); + }); + + it("returns false when an inactive tab has no new messages", () => { + const lastSeen = new Map([["tab-1", 10]]); + expect(hasUnreadMessages("tab-1", 10, "tab-2", lastSeen)).toBe(false); + }); + + it("returns false when an inactive tab has fewer messages than last seen", () => { + const lastSeen = new Map([["tab-1", 15]]); + expect(hasUnreadMessages("tab-1", 10, "tab-2", lastSeen)).toBe(false); + }); + + it("treats a tab with no last-seen record as having 0 messages seen", () => { + const lastSeen = new Map(); + // Tab has 1 message but no entry in lastSeen → treated as 0 seen → unread + expect(hasUnreadMessages("tab-1", 1, "tab-2", lastSeen)).toBe(true); + }); + + it("returns false for an untracked tab with 0 messages", () => { + const lastSeen = new Map(); + expect(hasUnreadMessages("tab-1", 0, "tab-2", lastSeen)).toBe(false); + }); + + it("handles null activeConversationId (no active tab)", () => { + const lastSeen = new Map([["tab-1", 3]]); + expect(hasUnreadMessages("tab-1", 10, null, lastSeen)).toBe(true); + }); +}); diff --git a/src/lib/components/HighlightedText.test.ts b/src/lib/components/HighlightedText.test.ts new file mode 100644 index 0000000..b5ce219 --- /dev/null +++ b/src/lib/components/HighlightedText.test.ts @@ -0,0 +1,195 @@ +/** + * HighlightedText Component Tests + * + * Tests the text-splitting logic used by the HighlightedText component, + * which highlights search query matches within a string. + * + * What this component does: + * - Splits text into an array of {text, isMatch} parts + * - Matches are case-insensitive + * - Special regex characters in the query are escaped + * - Non-matching text is preserved verbatim around matches + * + * Manual testing checklist: + * - [ ] Matching text is highlighted (yellow background) in the terminal + * - [ ] Highlighting is case-insensitive + * - [ ] Multiple matches on the same line all get highlighted + * - [ ] Non-matching text renders normally alongside matches + */ + +import { describe, it, expect } from "vitest"; + +interface TextPart { + text: string; + isMatch: boolean; +} + +function getHighlightedParts(text: string, query: string): TextPart[] { + if (!query) { + return [{ text, isMatch: false }]; + } + + const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(`(${escapedQuery})`, "gi"); + const parts: TextPart[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = regex.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push({ + text: text.slice(lastIndex, match.index), + isMatch: false, + }); + } + + parts.push({ + text: match[1], + isMatch: true, + }); + + lastIndex = regex.lastIndex; + } + + if (lastIndex < text.length) { + parts.push({ + text: text.slice(lastIndex), + isMatch: false, + }); + } + + return parts; +} + +// --- + +describe("getHighlightedParts", () => { + describe("empty query", () => { + it("returns the whole text as a single non-match when query is empty string", () => { + const result = getHighlightedParts("hello world", ""); + expect(result).toEqual([{ text: "hello world", isMatch: false }]); + }); + + it("returns an empty non-match part when both text and query are empty", () => { + const result = getHighlightedParts("", ""); + expect(result).toEqual([{ text: "", isMatch: false }]); + }); + }); + + describe("no match", () => { + it("returns the whole text as a single non-match when query is not found", () => { + const result = getHighlightedParts("hello world", "xyz"); + expect(result).toEqual([{ text: "hello world", isMatch: false }]); + }); + }); + + describe("single match", () => { + it("returns three parts for a match in the middle", () => { + const result = getHighlightedParts("hello world foo", "world"); + expect(result).toEqual([ + { text: "hello ", isMatch: false }, + { text: "world", isMatch: true }, + { text: " foo", isMatch: false }, + ]); + }); + + it("returns two parts for a match at the start", () => { + const result = getHighlightedParts("hello world", "hello"); + expect(result).toEqual([ + { text: "hello", isMatch: true }, + { text: " world", isMatch: false }, + ]); + }); + + it("returns two parts for a match at the end", () => { + const result = getHighlightedParts("hello world", "world"); + expect(result).toEqual([ + { text: "hello ", isMatch: false }, + { text: "world", isMatch: true }, + ]); + }); + + it("returns a single match part when the entire text matches", () => { + const result = getHighlightedParts("hello", "hello"); + expect(result).toEqual([{ text: "hello", isMatch: true }]); + }); + }); + + describe("multiple matches", () => { + it("returns interleaved match and non-match parts for multiple occurrences", () => { + const result = getHighlightedParts("foo bar foo", "foo"); + expect(result).toEqual([ + { text: "foo", isMatch: true }, + { text: " bar ", isMatch: false }, + { text: "foo", isMatch: true }, + ]); + }); + + it("handles adjacent matches correctly", () => { + const result = getHighlightedParts("aaa", "a"); + expect(result).toEqual([ + { text: "a", isMatch: true }, + { text: "a", isMatch: true }, + { text: "a", isMatch: true }, + ]); + }); + }); + + describe("case-insensitive matching", () => { + it("matches uppercase query against lowercase text", () => { + const result = getHighlightedParts("hello world", "WORLD"); + expect(result).toEqual([ + { text: "hello ", isMatch: false }, + { text: "world", isMatch: true }, + ]); + }); + + it("matches lowercase query against uppercase text", () => { + const result = getHighlightedParts("HELLO WORLD", "hello"); + expect(result).toEqual([ + { text: "HELLO", isMatch: true }, + { text: " WORLD", isMatch: false }, + ]); + }); + + it("preserves the original casing of the matched text", () => { + const result = getHighlightedParts("Hello World", "hello"); + const matchPart = result.find((p) => p.isMatch); + expect(matchPart?.text).toBe("Hello"); + }); + }); + + describe("special regex character escaping", () => { + it("treats a dot in the query as a literal dot, not a wildcard", () => { + const result = getHighlightedParts("v1.2.3 v123", "1.2"); + const matchParts = result.filter((p) => p.isMatch); + expect(matchParts).toHaveLength(1); + expect(matchParts[0].text).toBe("1.2"); + }); + + it("handles a query with parentheses", () => { + const result = getHighlightedParts("fn(args)", "(args)"); + expect(result).toEqual([ + { text: "fn", isMatch: false }, + { text: "(args)", isMatch: true }, + ]); + }); + + it("handles a query with a plus sign", () => { + const result = getHighlightedParts("a+b=c", "+"); + expect(result).toEqual([ + { text: "a", isMatch: false }, + { text: "+", isMatch: true }, + { text: "b=c", isMatch: false }, + ]); + }); + + it("handles a query with a question mark", () => { + const result = getHighlightedParts("is it true?", "?"); + expect(result).toEqual([ + { text: "is it true", isMatch: false }, + { text: "?", isMatch: true }, + ]); + }); + }); +}); diff --git a/src/lib/components/StatusBar.test.ts b/src/lib/components/StatusBar.test.ts new file mode 100644 index 0000000..c78e9b3 --- /dev/null +++ b/src/lib/components/StatusBar.test.ts @@ -0,0 +1,89 @@ +/** + * StatusBar Component Tests + * + * Tests the connection status colour and text helpers used by the + * StatusBar component to display the current Claude connection state. + * + * What this component does: + * - Shows a coloured indicator dot for the connection state + * - Shows a text label for the connection state + * - Provides connect/disconnect buttons + * - Contains the working directory input and browse button + * - Houses all toolbar action buttons (settings, stats, panels, etc.) + * + * Manual testing checklist: + * - [ ] Green dot and "Connected" label when Claude is running + * - [ ] Animated yellow dot and "Connecting..." label whilst connecting + * - [ ] Red dot and "Error" label on connection error + * - [ ] Grey dot and "Disconnected" label when not connected + * - [ ] Directory input is hidden when connected, visible when disconnected + * - [ ] Connect button transitions to Disconnect button on connection + */ + +import { describe, it, expect } from "vitest"; + +type ConnectionStatus = "connected" | "connecting" | "disconnected" | "error"; + +function getStatusColor(connectionStatus: ConnectionStatus): string { + switch (connectionStatus) { + case "connected": + return "bg-green-500"; + case "connecting": + return "bg-yellow-500 animate-pulse"; + case "error": + return "bg-red-500"; + default: + return "bg-gray-500"; + } +} + +function getStatusText(connectionStatus: ConnectionStatus): string { + switch (connectionStatus) { + case "connected": + return "Connected"; + case "connecting": + return "Connecting..."; + case "error": + return "Error"; + default: + return "Disconnected"; + } +} + +// --- + +describe("getStatusColor", () => { + it("returns green for connected status", () => { + expect(getStatusColor("connected")).toBe("bg-green-500"); + }); + + it("returns animated yellow for connecting status", () => { + expect(getStatusColor("connecting")).toBe("bg-yellow-500 animate-pulse"); + }); + + it("returns red for error status", () => { + expect(getStatusColor("error")).toBe("bg-red-500"); + }); + + it("returns grey for disconnected status", () => { + expect(getStatusColor("disconnected")).toBe("bg-gray-500"); + }); +}); + +describe("getStatusText", () => { + it("returns 'Connected' for connected status", () => { + expect(getStatusText("connected")).toBe("Connected"); + }); + + it("returns 'Connecting...' for connecting status", () => { + expect(getStatusText("connecting")).toBe("Connecting..."); + }); + + it("returns 'Error' for error status", () => { + expect(getStatusText("error")).toBe("Error"); + }); + + it("returns 'Disconnected' for disconnected status", () => { + expect(getStatusText("disconnected")).toBe("Disconnected"); + }); +}); diff --git a/src/lib/stores/character.test.ts b/src/lib/stores/character.test.ts index 4d3d794..a3c4c82 100644 --- a/src/lib/stores/character.test.ts +++ b/src/lib/stores/character.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; import { get } from "svelte/store"; import { characterState, characterInfo } from "./character"; @@ -26,7 +25,17 @@ describe("characterState store", () => { }); it("can set any valid state", () => { - const states = ["idle", "thinking", "typing", "coding", "searching", "mcp", "permission", "success", "error"] as const; + const states = [ + "idle", + "thinking", + "typing", + "coding", + "searching", + "mcp", + "permission", + "success", + "error", + ] as const; for (const state of states) { characterState.setState(state); expect(get(characterState)).toBe(state); diff --git a/src/lib/stores/costTracking.test.ts b/src/lib/stores/costTracking.test.ts index b573856..a992a82 100644 --- a/src/lib/stores/costTracking.test.ts +++ b/src/lib/stores/costTracking.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ import { describe, it, expect } from "vitest"; import { formatCost, diff --git a/src/lib/stores/notifications.test.ts b/src/lib/stores/notifications.test.ts index 78132dd..a02fa47 100644 --- a/src/lib/stores/notifications.test.ts +++ b/src/lib/stores/notifications.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/init-declarations -- Variables reassigned in beforeEach */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { writable } from "svelte/store"; diff --git a/src/lib/stores/search.test.ts b/src/lib/stores/search.test.ts index f46d3aa..55c6da6 100644 --- a/src/lib/stores/search.test.ts +++ b/src/lib/stores/search.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ import { describe, it, expect, beforeEach } from "vitest"; import { get } from "svelte/store"; import { searchState, isSearchActive, searchQuery } from "./search";