From acebe590c3a71ce251442e4ace7b27a762a019b2 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 3 Mar 2026 14:24:41 -0800 Subject: [PATCH] test: add coverage for messageMode, stateMapper branches, and initStatsListener --- src/lib/stores/clipboard.test.ts | 1 - src/lib/stores/stats.test.ts | 92 +++++++++++++++++++++- src/lib/types/messageMode.test.ts | 125 ++++++++++++++++++++++++++++++ src/lib/utils/stateMapper.test.ts | 24 ++++++ 4 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 src/lib/types/messageMode.test.ts diff --git a/src/lib/stores/clipboard.test.ts b/src/lib/stores/clipboard.test.ts index eb955c1..34d8f69 100644 --- a/src/lib/stores/clipboard.test.ts +++ b/src/lib/stores/clipboard.test.ts @@ -19,7 +19,6 @@ * - [ ] Search query filters by content, language, and source */ -/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // Mirror: detectLanguage from clipboard.ts diff --git a/src/lib/stores/stats.test.ts b/src/lib/stores/stats.test.ts index c1da40b..e2dcce2 100644 --- a/src/lib/stores/stats.test.ts +++ b/src/lib/stores/stats.test.ts @@ -1,5 +1,7 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { get } from "svelte/store"; +import { listen } from "@tauri-apps/api/event"; +import { invoke } from "@tauri-apps/api/core"; import { stats, formattedStats, @@ -13,6 +15,7 @@ import { getBudgetStatusMessage, getRemainingTokenBudget, getRemainingCostBudget, + initStatsListener, } from "./stats"; import type { UsageStats, ToolTokenStats, BudgetStatus } from "./stats"; @@ -34,6 +37,12 @@ vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(), })); +vi.mock("./costTracking", () => ({ + costTrackingStore: { + refresh: vi.fn().mockResolvedValue([]), + }, +})); + describe("stats store", () => { beforeEach(() => { // Reset stats to default before each test @@ -801,4 +810,85 @@ describe("stats store", () => { expect(getRemainingCostBudget(baseStats, 0.2)).toBe(0); }); }); + + describe("initStatsListener", () => { + const emptyStats: UsageStats = { + total_input_tokens: 0, + total_output_tokens: 0, + total_cost_usd: 0, + session_input_tokens: 0, + session_output_tokens: 0, + session_cost_usd: 0, + model: null, + messages_exchanged: 0, + session_messages_exchanged: 0, + code_blocks_generated: 0, + session_code_blocks_generated: 0, + files_edited: 0, + session_files_edited: 0, + files_created: 0, + session_files_created: 0, + tools_usage: {}, + session_tools_usage: {}, + session_duration_seconds: 0, + context_tokens_used: 0, + context_window_limit: 200000, + context_utilisation_percent: 0, + potential_cache_hits: 0, + potential_cache_savings_tokens: 0, + }; + + afterEach(() => { + vi.resetAllMocks(); + }); + + it("registers a listener for the claude:stats event", async () => { + vi.mocked(listen).mockResolvedValue(vi.fn()); + vi.mocked(invoke).mockResolvedValue(emptyStats); + + await initStatsListener(); + + expect(listen).toHaveBeenCalledWith("claude:stats", expect.any(Function)); + }); + + it("updates the stats store when the listener callback fires", async () => { + let capturedCallback: ((event: unknown) => void) | undefined; + vi.mocked(listen).mockImplementation(async (_event, callback) => { + capturedCallback = callback as (event: unknown) => void; + return () => {}; + }); + vi.mocked(invoke).mockResolvedValue(emptyStats); + + await initStatsListener(); + + const newStats = { ...emptyStats, total_input_tokens: 9999 }; + capturedCallback!({ payload: { stats: newStats } }); + + expect(get(stats).total_input_tokens).toBe(9999); + }); + + it("loads persisted stats from the backend on initialisation", async () => { + const persistedStats = { ...emptyStats, total_input_tokens: 5000 }; + vi.mocked(listen).mockResolvedValue(vi.fn()); + vi.mocked(invoke).mockResolvedValue(persistedStats); + + await initStatsListener(); + + expect(invoke).toHaveBeenCalledWith("get_persisted_stats"); + expect(get(stats).total_input_tokens).toBe(5000); + }); + + it("handles a failed invoke gracefully and logs the error", async () => { + const error = new Error("Failed to get stats"); + vi.mocked(listen).mockResolvedValue(vi.fn()); + vi.mocked(invoke).mockRejectedValue(error); + + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await initStatsListener(); + + expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to load initial stats:", error); + consoleErrorSpy.mockRestore(); + }); + }); }); diff --git a/src/lib/types/messageMode.test.ts b/src/lib/types/messageMode.test.ts new file mode 100644 index 0000000..bf257de --- /dev/null +++ b/src/lib/types/messageMode.test.ts @@ -0,0 +1,125 @@ +/** + * MessageMode Type Tests + * + * Tests the helper functions exported from messageMode.ts: + * - getMessageMode: looks up a MessageMode by id + * - formatMessageWithMode: prepends a mode prefix to a message + * + * What this module does: + * - Defines the six conversation modes (chat, architect, code, debug, ask, review) + * - Provides a lookup function to find a mode by its id + * - Provides a formatting function that prepends the mode prefix to messages + * + * Manual testing checklist: + * - [ ] Mode selector shows correct names and icons for all six modes + * - [ ] Selecting a mode prefixes the next message with the correct prefix + * - [ ] Switching back to Chat sends messages without any prefix + * - [ ] Sending a message that already has the prefix does not double-prefix it + */ + +import { describe, it, expect } from "vitest"; +import { MESSAGE_MODES, getMessageMode, formatMessageWithMode } from "./messageMode"; + +describe("MESSAGE_MODES", () => { + it("contains all six expected modes", () => { + const ids = MESSAGE_MODES.map((m) => m.id); + expect(ids).toEqual(["chat", "architect", "code", "debug", "ask", "review"]); + }); + + it("each mode has an id, name, description, and icon", () => { + for (const mode of MESSAGE_MODES) { + expect(mode.id).toBeTruthy(); + expect(mode.name).toBeTruthy(); + expect(mode.description).toBeTruthy(); + expect(mode.icon).toBeTruthy(); + } + }); +}); + +describe("getMessageMode", () => { + it("returns the chat mode with no prefix", () => { + const mode = getMessageMode("chat"); + expect(mode).toBeDefined(); + expect(mode?.id).toBe("chat"); + expect(mode?.prefix).toBeUndefined(); + }); + + it("returns the architect mode with its prefix", () => { + const mode = getMessageMode("architect"); + expect(mode?.id).toBe("architect"); + expect(mode?.prefix).toBe("[Architect Mode] "); + }); + + it("returns the code mode with its prefix", () => { + const mode = getMessageMode("code"); + expect(mode?.id).toBe("code"); + expect(mode?.prefix).toBe("[Code Mode] "); + }); + + it("returns the debug mode with its prefix", () => { + const mode = getMessageMode("debug"); + expect(mode?.id).toBe("debug"); + expect(mode?.prefix).toBe("[Debug Mode] "); + }); + + it("returns the ask mode with its prefix", () => { + const mode = getMessageMode("ask"); + expect(mode?.id).toBe("ask"); + expect(mode?.prefix).toBe("[Ask Mode] "); + }); + + it("returns the review mode with its prefix", () => { + const mode = getMessageMode("review"); + expect(mode?.id).toBe("review"); + expect(mode?.prefix).toBe("[Review Mode] "); + }); + + it("returns undefined for an unknown mode id", () => { + expect(getMessageMode("unknown")).toBeUndefined(); + }); + + it("returns undefined for an empty string", () => { + expect(getMessageMode("")).toBeUndefined(); + }); +}); + +describe("formatMessageWithMode", () => { + it("prepends the prefix for code mode", () => { + expect(formatMessageWithMode("hello", "code")).toBe("[Code Mode] hello"); + }); + + it("prepends the prefix for architect mode", () => { + expect(formatMessageWithMode("design this", "architect")).toBe("[Architect Mode] design this"); + }); + + it("prepends the prefix for debug mode", () => { + expect(formatMessageWithMode("fix this bug", "debug")).toBe("[Debug Mode] fix this bug"); + }); + + it("prepends the prefix for ask mode", () => { + expect(formatMessageWithMode("what is this?", "ask")).toBe("[Ask Mode] what is this?"); + }); + + it("prepends the prefix for review mode", () => { + expect(formatMessageWithMode("review my code", "review")).toBe("[Review Mode] review my code"); + }); + + it("returns the message unchanged in chat mode (no prefix)", () => { + expect(formatMessageWithMode("hello", "chat")).toBe("hello"); + }); + + it("returns the message unchanged for unknown mode ids", () => { + expect(formatMessageWithMode("hello", "unknown")).toBe("hello"); + expect(formatMessageWithMode("hello", "")).toBe("hello"); + }); + + it("does not double-prefix a message already starting with the mode prefix", () => { + expect(formatMessageWithMode("[Code Mode] already prefixed", "code")).toBe( + "[Code Mode] already prefixed" + ); + }); + + it("adds the prefix to an empty string", () => { + expect(formatMessageWithMode("", "code")).toBe("[Code Mode] "); + }); +}); diff --git a/src/lib/utils/stateMapper.test.ts b/src/lib/utils/stateMapper.test.ts index e723330..a985a74 100644 --- a/src/lib/utils/stateMapper.test.ts +++ b/src/lib/utils/stateMapper.test.ts @@ -266,6 +266,30 @@ describe("stateMapper", () => { expect(mapMessageToState(message)).toBeNull(); }); + it("returns null for content_block_start with unrecognised content block type", () => { + const message = { + type: "stream_event", + event: { + type: "content_block_start", + index: 0, + content_block: { type: "input_json_delta" }, + }, + } as unknown as ClaudeStreamMessage; + expect(mapMessageToState(message)).toBeNull(); + }); + + it("returns null for content_block_delta with unrecognised delta type", () => { + const message = { + type: "stream_event", + event: { + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: "{}" }, + }, + } as unknown as ClaudeStreamMessage; + expect(mapMessageToState(message)).toBeNull(); + }); + it("returns null for result with unknown subtype", () => { const message = { type: "result",