test: add component logic tests and clean up eslint directives
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 56s
CI / Lint & Test (pull_request) Successful in 16m12s
CI / Build Linux (pull_request) Successful in 20m14s
CI / Build Windows (cross-compile) (pull_request) Successful in 32m19s

Adds mirror-function tests for five Svelte components (HighlightedText,
CliVersion, AchievementNotification, StatusBar, ConversationTabs) and
removes stale eslint-disable comments from existing store test files.
This commit is contained in:
2026-03-03 12:21:47 -08:00
committed by Naomi Carrigan
parent fd3122e080
commit 514e137590
9 changed files with 693 additions and 5 deletions
@@ -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");
});
});
});
+134
View File
@@ -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);
});
});
});
+111
View File
@@ -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<string, number>
): 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<string, number>();
// 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<string, number>();
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);
});
});
+195
View File
@@ -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 },
]);
});
});
});
+89
View File
@@ -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");
});
});
+11 -2
View File
@@ -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);
-1
View File
@@ -1,4 +1,3 @@
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
import { describe, it, expect } from "vitest";
import {
formatCost,
-1
View File
@@ -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";
-1
View File
@@ -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";