diff --git a/src/lib/sounds/achievement.test.ts b/src/lib/sounds/achievement.test.ts new file mode 100644 index 0000000..5a023f5 --- /dev/null +++ b/src/lib/sounds/achievement.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NotificationType } from "$lib/notifications/types"; + +const mockPlay = vi.fn(); + +vi.mock("$lib/notifications", () => ({ + soundPlayer: { + play: mockPlay, + }, +})); + +describe("achievement sounds", () => { + beforeEach(() => { + mockPlay.mockReset(); + }); + + describe("playAchievementSound", () => { + it("plays the achievement notification sound", async () => { + const { playAchievementSound } = await import("./achievement"); + playAchievementSound(); + expect(mockPlay).toHaveBeenCalledWith(NotificationType.ACHIEVEMENT); + }); + }); + + describe("testAchievementSound", () => { + it("calls playAchievementSound without throwing", async () => { + const { testAchievementSound } = await import("./achievement"); + expect(() => testAchievementSound()).not.toThrow(); + expect(mockPlay).toHaveBeenCalledWith(NotificationType.ACHIEVEMENT); + }); + + it("catches errors from the sound player gracefully", async () => { + mockPlay.mockImplementation(() => { + throw new Error("Audio not available"); + }); + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const { testAchievementSound } = await import("./achievement"); + expect(() => testAchievementSound()).not.toThrow(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Error playing achievement sound:", + expect.any(Error) + ); + + consoleErrorSpy.mockRestore(); + }); + }); +}); diff --git a/src/lib/stores/character.test.ts b/src/lib/stores/character.test.ts new file mode 100644 index 0000000..4d3d794 --- /dev/null +++ b/src/lib/stores/character.test.ts @@ -0,0 +1,115 @@ +/* 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"; + +describe("characterState store", () => { + beforeEach(() => { + vi.useFakeTimers(); + characterState.reset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("initial state", () => { + it("starts in idle state", () => { + expect(get(characterState)).toBe("idle"); + }); + }); + + describe("setState", () => { + it("sets the character state", () => { + characterState.setState("thinking"); + expect(get(characterState)).toBe("thinking"); + }); + + it("can set any valid state", () => { + 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); + } + }); + + it("cancels any active temporary state timer", () => { + characterState.setTemporaryState("success", 5000); + characterState.setState("thinking"); + + // Advance past the temporary state duration — should stay as thinking + vi.advanceTimersByTime(6000); + expect(get(characterState)).toBe("thinking"); + }); + }); + + describe("setTemporaryState", () => { + it("sets the character state immediately", () => { + characterState.setTemporaryState("success", 2000); + expect(get(characterState)).toBe("success"); + }); + + it("reverts to idle after the specified duration", () => { + characterState.setTemporaryState("success", 2000); + vi.advanceTimersByTime(2000); + expect(get(characterState)).toBe("idle"); + }); + + it("uses 2000ms as the default duration", () => { + characterState.setTemporaryState("error"); + vi.advanceTimersByTime(1999); + expect(get(characterState)).toBe("error"); + vi.advanceTimersByTime(1); + expect(get(characterState)).toBe("idle"); + }); + + it("cancels a previous temporary state timer when a new one is set", () => { + characterState.setTemporaryState("success", 5000); + characterState.setTemporaryState("error", 1000); + + // First timer would have fired at 5000ms but was cancelled + vi.advanceTimersByTime(1000); + expect(get(characterState)).toBe("idle"); + }); + }); + + describe("reset", () => { + it("resets the state to idle", () => { + characterState.setState("thinking"); + characterState.reset(); + expect(get(characterState)).toBe("idle"); + }); + + it("cancels any pending temporary state timer", () => { + characterState.setTemporaryState("success", 5000); + characterState.reset(); + + // Should now be idle and should NOT revert again after timer fires + expect(get(characterState)).toBe("idle"); + vi.advanceTimersByTime(5000); + expect(get(characterState)).toBe("idle"); + }); + }); +}); + +describe("characterInfo derived store", () => { + beforeEach(() => { + characterState.reset(); + }); + + it("returns the info object for the current state", () => { + const info = get(characterInfo); + expect(info).toBeDefined(); + expect(typeof info.label).toBe("string"); + }); + + it("updates when the character state changes", () => { + characterState.setState("thinking"); + const thinkingInfo = get(characterInfo); + + characterState.setState("idle"); + const idleInfo = get(characterInfo); + + expect(thinkingInfo.label).not.toBe(idleInfo.label); + }); +}); diff --git a/src/lib/stores/costTracking.test.ts b/src/lib/stores/costTracking.test.ts new file mode 100644 index 0000000..b573856 --- /dev/null +++ b/src/lib/stores/costTracking.test.ts @@ -0,0 +1,98 @@ +/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ +import { describe, it, expect } from "vitest"; +import { + formatCost, + formatAlertType, + getAlertMessage, + type AlertType, + type CostAlert, +} from "./costTracking"; + +describe("formatCost", () => { + it("formats amounts below $0.01 to 4 decimal places", () => { + expect(formatCost(0)).toBe("$0.0000"); + expect(formatCost(0.001)).toBe("$0.0010"); + expect(formatCost(0.0099)).toBe("$0.0099"); + }); + + it("formats amounts between $0.01 and $1 to 3 decimal places", () => { + expect(formatCost(0.01)).toBe("$0.010"); + expect(formatCost(0.123)).toBe("$0.123"); + expect(formatCost(0.999)).toBe("$0.999"); + }); + + it("formats amounts $1 and above to 2 decimal places", () => { + expect(formatCost(1)).toBe("$1.00"); + expect(formatCost(1.5)).toBe("$1.50"); + expect(formatCost(100.99)).toBe("$100.99"); + }); +}); + +describe("formatAlertType", () => { + it("formats Daily as Today", () => { + expect(formatAlertType("Daily")).toBe("Today"); + }); + + it("formats Weekly as This Week", () => { + expect(formatAlertType("Weekly")).toBe("This Week"); + }); + + it("formats Monthly as This Month", () => { + expect(formatAlertType("Monthly")).toBe("This Month"); + }); + + it("handles all AlertType values", () => { + const types: AlertType[] = ["Daily", "Weekly", "Monthly"]; + const results = types.map(formatAlertType); + expect(results).toEqual(["Today", "This Week", "This Month"]); + }); +}); + +describe("getAlertMessage", () => { + it("generates a message for a Daily alert", () => { + const alert: CostAlert = { + alert_type: "Daily", + threshold: 1.0, + current_cost: 1.5, + }; + const message = getAlertMessage(alert); + expect(message).toContain("Today"); + expect(message).toContain("$1.50"); + expect(message).toContain("$1.00"); + }); + + it("generates a message for a Weekly alert", () => { + const alert: CostAlert = { + alert_type: "Weekly", + threshold: 5.0, + current_cost: 6.0, + }; + const message = getAlertMessage(alert); + expect(message).toContain("This Week"); + expect(message).toContain("$6.00"); + expect(message).toContain("$5.00"); + }); + + it("generates a message for a Monthly alert", () => { + const alert: CostAlert = { + alert_type: "Monthly", + threshold: 20.0, + current_cost: 25.0, + }; + const message = getAlertMessage(alert); + expect(message).toContain("This Month"); + expect(message).toContain("$25.00"); + expect(message).toContain("$20.00"); + }); + + it("includes threshold and current cost in the message", () => { + const alert: CostAlert = { + alert_type: "Daily", + threshold: 0.005, + current_cost: 0.007, + }; + const message = getAlertMessage(alert); + expect(message).toContain("$0.0070"); + expect(message).toContain("$0.0050"); + }); +}); diff --git a/src/lib/stores/historyRestore.test.ts b/src/lib/stores/historyRestore.test.ts new file mode 100644 index 0000000..e298a64 --- /dev/null +++ b/src/lib/stores/historyRestore.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + setShouldRestoreHistory, + setSavedHistory, + getShouldRestoreHistory, + getSavedHistory, + clearHistoryRestore, +} from "./historyRestore"; + +describe("historyRestore module", () => { + beforeEach(() => { + clearHistoryRestore(); + }); + + describe("initial state", () => { + it("shouldRestoreHistory is false by default", () => { + expect(getShouldRestoreHistory()).toBe(false); + }); + + it("savedHistory is null by default", () => { + expect(getSavedHistory()).toBeNull(); + }); + }); + + describe("setShouldRestoreHistory", () => { + it("sets shouldRestoreHistory to true", () => { + setShouldRestoreHistory(true); + expect(getShouldRestoreHistory()).toBe(true); + }); + + it("sets shouldRestoreHistory to false", () => { + setShouldRestoreHistory(true); + setShouldRestoreHistory(false); + expect(getShouldRestoreHistory()).toBe(false); + }); + }); + + describe("setSavedHistory", () => { + it("sets the saved history string", () => { + setSavedHistory("some history content"); + expect(getSavedHistory()).toBe("some history content"); + }); + + it("sets the saved history to null", () => { + setSavedHistory("some history content"); + setSavedHistory(null); + expect(getSavedHistory()).toBeNull(); + }); + }); + + describe("clearHistoryRestore", () => { + it("resets shouldRestoreHistory to false", () => { + setShouldRestoreHistory(true); + clearHistoryRestore(); + expect(getShouldRestoreHistory()).toBe(false); + }); + + it("resets savedHistory to null", () => { + setSavedHistory("some content"); + clearHistoryRestore(); + expect(getSavedHistory()).toBeNull(); + }); + + it("clears both values at once", () => { + setShouldRestoreHistory(true); + setSavedHistory("history"); + clearHistoryRestore(); + expect(getShouldRestoreHistory()).toBe(false); + expect(getSavedHistory()).toBeNull(); + }); + }); +}); diff --git a/src/lib/stores/messageMode.test.ts b/src/lib/stores/messageMode.test.ts new file mode 100644 index 0000000..7b3a703 --- /dev/null +++ b/src/lib/stores/messageMode.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { get } from "svelte/store"; +import { messageMode, getCurrentMode } from "./messageMode"; + +describe("messageMode store", () => { + beforeEach(() => { + messageMode.reset(); + }); + + describe("initial state", () => { + it("defaults to chat mode", () => { + expect(get(messageMode)).toBe("chat"); + }); + }); + + describe("set", () => { + it("sets the mode to the given value", () => { + messageMode.set("plan"); + expect(get(messageMode)).toBe("plan"); + }); + + it("can set any arbitrary mode string", () => { + messageMode.set("auto"); + expect(get(messageMode)).toBe("auto"); + }); + }); + + describe("reset", () => { + it("resets mode back to chat", () => { + messageMode.set("plan"); + messageMode.reset(); + expect(get(messageMode)).toBe("chat"); + }); + }); +}); + +describe("getCurrentMode", () => { + beforeEach(() => { + messageMode.reset(); + }); + + it("returns chat when in default state", () => { + expect(getCurrentMode()).toBe("chat"); + }); + + it("returns the currently set mode", () => { + messageMode.set("plan"); + expect(getCurrentMode()).toBe("plan"); + }); + + it("returns chat after a reset", () => { + messageMode.set("auto"); + messageMode.reset(); + expect(getCurrentMode()).toBe("chat"); + }); +}); diff --git a/src/lib/stores/notifications.test.ts b/src/lib/stores/notifications.test.ts new file mode 100644 index 0000000..78132dd --- /dev/null +++ b/src/lib/stores/notifications.test.ts @@ -0,0 +1,105 @@ +/* eslint-disable @typescript-eslint/init-declarations -- Variables reassigned in beforeEach */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { writable } from "svelte/store"; + +const mockSetEnabled = vi.fn(); +const mockSetGlobalVolume = vi.fn(); + +vi.mock("$lib/notifications", () => ({ + soundPlayer: { + setEnabled: mockSetEnabled, + setGlobalVolume: mockSetGlobalVolume, + }, +})); + +// We need to control the config store's emitted values +const configWritable = writable({ + notifications_enabled: true, + notification_volume: 0.7, +}); + +vi.mock("./config", () => ({ + configStore: { + config: { subscribe: configWritable.subscribe }, + }, +})); + +describe("notifications sync store", () => { + beforeEach(async () => { + vi.resetModules(); + mockSetEnabled.mockReset(); + mockSetGlobalVolume.mockReset(); + configWritable.set({ notifications_enabled: true, notification_volume: 0.7 }); + }); + + afterEach(async () => { + // Re-import to clean up any lingering subscriptions + const { cleanupNotificationSync } = await import("./notifications"); + cleanupNotificationSync(); + }); + + describe("initNotificationSync", () => { + it("syncs soundPlayer enabled state from config on init", async () => { + const { initNotificationSync } = await import("./notifications"); + initNotificationSync(); + expect(mockSetEnabled).toHaveBeenCalledWith(true); + }); + + it("syncs soundPlayer volume from config on init", async () => { + const { initNotificationSync } = await import("./notifications"); + initNotificationSync(); + expect(mockSetGlobalVolume).toHaveBeenCalledWith(0.7); + }); + + it("updates soundPlayer when config changes", async () => { + const { initNotificationSync } = await import("./notifications"); + initNotificationSync(); + + mockSetEnabled.mockReset(); + mockSetGlobalVolume.mockReset(); + + configWritable.set({ notifications_enabled: false, notification_volume: 0.3 }); + + expect(mockSetEnabled).toHaveBeenCalledWith(false); + expect(mockSetGlobalVolume).toHaveBeenCalledWith(0.3); + }); + + it("does not register a duplicate subscription when called twice", async () => { + const { initNotificationSync } = await import("./notifications"); + initNotificationSync(); + initNotificationSync(); + + // Both calls should only produce one subscription (one initial sync) + expect(mockSetEnabled).toHaveBeenCalledTimes(1); + }); + }); + + describe("cleanupNotificationSync", () => { + it("stops reacting to config changes after cleanup", async () => { + const { initNotificationSync, cleanupNotificationSync } = await import("./notifications"); + initNotificationSync(); + cleanupNotificationSync(); + + mockSetEnabled.mockReset(); + configWritable.set({ notifications_enabled: false, notification_volume: 0.5 }); + + expect(mockSetEnabled).not.toHaveBeenCalled(); + }); + + it("is safe to call when not initialised", async () => { + const { cleanupNotificationSync } = await import("./notifications"); + expect(() => cleanupNotificationSync()).not.toThrow(); + }); + + it("allows re-initialisation after cleanup", async () => { + const { initNotificationSync, cleanupNotificationSync } = await import("./notifications"); + initNotificationSync(); + cleanupNotificationSync(); + + mockSetEnabled.mockReset(); + initNotificationSync(); + + expect(mockSetEnabled).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/lib/stores/search.test.ts b/src/lib/stores/search.test.ts new file mode 100644 index 0000000..f46d3aa --- /dev/null +++ b/src/lib/stores/search.test.ts @@ -0,0 +1,188 @@ +/* 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"; + +describe("searchState store", () => { + beforeEach(() => { + searchState.clear(); + }); + + describe("initial state", () => { + it("starts with empty query", () => { + const state = get(searchState); + expect(state.query).toBe(""); + }); + + it("starts inactive", () => { + const state = get(searchState); + expect(state.isActive).toBe(false); + }); + + it("starts with zero match count", () => { + const state = get(searchState); + expect(state.matchCount).toBe(0); + }); + + it("starts with zero current match index", () => { + const state = get(searchState); + expect(state.currentMatchIndex).toBe(0); + }); + }); + + describe("setQuery", () => { + it("sets the query string", () => { + searchState.setQuery("hello"); + expect(get(searchState).query).toBe("hello"); + }); + + it("activates the store when query is non-empty", () => { + searchState.setQuery("hello"); + expect(get(searchState).isActive).toBe(true); + }); + + it("deactivates the store when query is empty", () => { + searchState.setQuery("hello"); + searchState.setQuery(""); + expect(get(searchState).isActive).toBe(false); + }); + + it("resets currentMatchIndex to 0 on each query change", () => { + searchState.setQuery("hello"); + searchState.setMatchCount(5); + searchState.nextMatch(); + searchState.nextMatch(); + searchState.setQuery("world"); + expect(get(searchState).currentMatchIndex).toBe(0); + }); + }); + + describe("setMatchCount", () => { + it("updates the match count", () => { + searchState.setMatchCount(7); + expect(get(searchState).matchCount).toBe(7); + }); + + it("does not reset currentMatchIndex", () => { + searchState.setQuery("test"); + searchState.setMatchCount(5); + searchState.nextMatch(); + searchState.setMatchCount(10); + expect(get(searchState).currentMatchIndex).toBe(1); + }); + }); + + describe("nextMatch", () => { + it("advances to the next match index", () => { + searchState.setMatchCount(5); + searchState.nextMatch(); + expect(get(searchState).currentMatchIndex).toBe(1); + }); + + it("wraps around to 0 after the last match", () => { + searchState.setMatchCount(3); + searchState.nextMatch(); + searchState.nextMatch(); + searchState.nextMatch(); + expect(get(searchState).currentMatchIndex).toBe(0); + }); + + it("stays at 0 when match count is 0", () => { + searchState.setMatchCount(0); + searchState.nextMatch(); + expect(get(searchState).currentMatchIndex).toBe(0); + }); + }); + + describe("previousMatch", () => { + it("goes to the previous match index", () => { + searchState.setMatchCount(5); + searchState.nextMatch(); + searchState.nextMatch(); + searchState.previousMatch(); + expect(get(searchState).currentMatchIndex).toBe(1); + }); + + it("wraps around to last match when at index 0", () => { + searchState.setMatchCount(5); + searchState.previousMatch(); + expect(get(searchState).currentMatchIndex).toBe(4); + }); + + it("stays at 0 when match count is 0", () => { + searchState.setMatchCount(0); + searchState.previousMatch(); + expect(get(searchState).currentMatchIndex).toBe(0); + }); + }); + + describe("clear", () => { + it("resets query to empty string", () => { + searchState.setQuery("hello"); + searchState.clear(); + expect(get(searchState).query).toBe(""); + }); + + it("resets isActive to false", () => { + searchState.setQuery("hello"); + searchState.clear(); + expect(get(searchState).isActive).toBe(false); + }); + + it("resets matchCount to 0", () => { + searchState.setMatchCount(10); + searchState.clear(); + expect(get(searchState).matchCount).toBe(0); + }); + + it("resets currentMatchIndex to 0", () => { + searchState.setMatchCount(5); + searchState.nextMatch(); + searchState.nextMatch(); + searchState.clear(); + expect(get(searchState).currentMatchIndex).toBe(0); + }); + }); +}); + +describe("isSearchActive derived store", () => { + beforeEach(() => { + searchState.clear(); + }); + + it("is false initially", () => { + expect(get(isSearchActive)).toBe(false); + }); + + it("is true when query is set", () => { + searchState.setQuery("test"); + expect(get(isSearchActive)).toBe(true); + }); + + it("is false after clearing", () => { + searchState.setQuery("test"); + searchState.clear(); + expect(get(isSearchActive)).toBe(false); + }); +}); + +describe("searchQuery derived store", () => { + beforeEach(() => { + searchState.clear(); + }); + + it("is empty string initially", () => { + expect(get(searchQuery)).toBe(""); + }); + + it("reflects the current query", () => { + searchState.setQuery("my search"); + expect(get(searchQuery)).toBe("my search"); + }); + + it("is empty after clearing", () => { + searchState.setQuery("test"); + searchState.clear(); + expect(get(searchQuery)).toBe(""); + }); +}); diff --git a/src/lib/stores/stats.test.ts b/src/lib/stores/stats.test.ts index 37259b7..c1da40b 100644 --- a/src/lib/stores/stats.test.ts +++ b/src/lib/stores/stats.test.ts @@ -9,8 +9,12 @@ import { estimateMessageCost, formatTokenCount, MODEL_PRICING, + checkBudget, + getBudgetStatusMessage, + getRemainingTokenBudget, + getRemainingCostBudget, } from "./stats"; -import type { UsageStats, ToolTokenStats } from "./stats"; +import type { UsageStats, ToolTokenStats, BudgetStatus } from "./stats"; // Helper function to create ToolTokenStats for tests function toolStats(callCount: number, inputTokens = 0, outputTokens = 0): ToolTokenStats { @@ -600,4 +604,201 @@ describe("stats store", () => { expect(MODEL_PRICING["claude-3-haiku-20240307"]).toEqual({ input: 0.25, output: 1.25 }); }); }); + + describe("checkBudget", () => { + const baseStats: UsageStats = { + total_input_tokens: 0, + total_output_tokens: 0, + total_cost_usd: 0, + session_input_tokens: 500, + session_output_tokens: 500, + session_cost_usd: 0.5, + 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, + }; + + it("returns ok when budget is disabled", () => { + const result = checkBudget(baseStats, false, 100, 1.0, 0.8); + expect(result).toEqual({ type: "ok" }); + }); + + it("returns ok when under all budgets", () => { + const result = checkBudget(baseStats, true, 10000, 10.0, 0.8); + expect(result).toEqual({ type: "ok" }); + }); + + it("returns exceeded when token budget is reached", () => { + const result = checkBudget(baseStats, true, 1000, null, 0.8); + expect(result).toEqual({ type: "exceeded", budget_type: "token" }); + }); + + it("returns warning when token usage is above threshold", () => { + // session tokens = 1000, budget = 1100, threshold = 0.8 → 1000/1100 ≈ 0.909 > 0.8 + const result = checkBudget(baseStats, true, 1100, null, 0.8); + expect(result.type).toBe("warning"); + if (result.type === "warning") { + expect(result.budget_type).toBe("token"); + expect(result.percent_used).toBeGreaterThan(80); + } + }); + + it("returns exceeded when cost budget is reached", () => { + const result = checkBudget(baseStats, true, null, 0.5, 0.8); + expect(result).toEqual({ type: "exceeded", budget_type: "cost" }); + }); + + it("returns warning when cost usage is above threshold", () => { + // session cost = 0.5, budget = 0.55, threshold = 0.8 → 0.5/0.55 ≈ 0.909 > 0.8 + const result = checkBudget(baseStats, true, null, 0.55, 0.8); + expect(result.type).toBe("warning"); + if (result.type === "warning") { + expect(result.budget_type).toBe("cost"); + } + }); + + it("checks token budget before cost budget", () => { + // Both budgets exceeded, but token should be reported first + const result = checkBudget(baseStats, true, 500, 0.1, 0.8); + expect(result).toEqual({ type: "exceeded", budget_type: "token" }); + }); + + it("returns ok when no budgets are set", () => { + const result = checkBudget(baseStats, true, null, null, 0.8); + expect(result).toEqual({ type: "ok" }); + }); + }); + + describe("getBudgetStatusMessage", () => { + it("returns null for ok status", () => { + const status: BudgetStatus = { type: "ok" }; + expect(getBudgetStatusMessage(status)).toBeNull(); + }); + + it("returns exceeded message for token budget", () => { + const status: BudgetStatus = { type: "exceeded", budget_type: "token" }; + const message = getBudgetStatusMessage(status); + expect(message).toContain("token"); + expect(message).toContain("exceeded"); + }); + + it("returns exceeded message for cost budget", () => { + const status: BudgetStatus = { type: "exceeded", budget_type: "cost" }; + const message = getBudgetStatusMessage(status); + expect(message).toContain("cost"); + expect(message).toContain("exceeded"); + }); + + it("returns warning message with percentage for token budget", () => { + const status: BudgetStatus = { type: "warning", budget_type: "token", percent_used: 85.5 }; + const message = getBudgetStatusMessage(status); + expect(message).toContain("token"); + expect(message).toContain("86%"); + }); + + it("returns warning message with percentage for cost budget", () => { + const status: BudgetStatus = { type: "warning", budget_type: "cost", percent_used: 90 }; + const message = getBudgetStatusMessage(status); + expect(message).toContain("cost"); + expect(message).toContain("90%"); + }); + }); + + describe("getRemainingTokenBudget", () => { + const baseStats: UsageStats = { + total_input_tokens: 0, + total_output_tokens: 0, + total_cost_usd: 0, + session_input_tokens: 300, + session_output_tokens: 200, + 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, + }; + + it("returns null when no token budget is set", () => { + expect(getRemainingTokenBudget(baseStats, null)).toBeNull(); + }); + + it("returns the remaining tokens when under budget", () => { + // session tokens = 500, budget = 1000 → remaining = 500 + expect(getRemainingTokenBudget(baseStats, 1000)).toBe(500); + }); + + it("returns 0 when at or over budget", () => { + expect(getRemainingTokenBudget(baseStats, 500)).toBe(0); + expect(getRemainingTokenBudget(baseStats, 400)).toBe(0); + }); + }); + + describe("getRemainingCostBudget", () => { + const baseStats: 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.3, + 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, + }; + + it("returns null when no cost budget is set", () => { + expect(getRemainingCostBudget(baseStats, null)).toBeNull(); + }); + + it("returns the remaining cost when under budget", () => { + // session cost = 0.3, budget = 1.0 → remaining = 0.7 + expect(getRemainingCostBudget(baseStats, 1.0)).toBeCloseTo(0.7, 5); + }); + + it("returns 0 when at or over budget", () => { + expect(getRemainingCostBudget(baseStats, 0.3)).toBe(0); + expect(getRemainingCostBudget(baseStats, 0.2)).toBe(0); + }); + }); }); diff --git a/src/lib/stores/todos.test.ts b/src/lib/stores/todos.test.ts new file mode 100644 index 0000000..0c65886 --- /dev/null +++ b/src/lib/stores/todos.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { get } from "svelte/store"; +import { emitMockEvent } from "../../../vitest.setup"; +import { todos, initializeTodoListener, cleanupTodoListener } from "./todos"; +import type { TodoItem } from "./todos"; + +describe("todos store", () => { + beforeEach(() => { + cleanupTodoListener(); + todos.clear(); + }); + + describe("initial state", () => { + it("starts with an empty list", () => { + expect(get(todos)).toEqual([]); + }); + }); + + describe("set and clear", () => { + it("can set todos directly", () => { + const items: TodoItem[] = [ + { content: "Task 1", status: "pending", activeForm: "Doing task 1" }, + { content: "Task 2", status: "in_progress", activeForm: "Doing task 2" }, + ]; + todos.set(items); + expect(get(todos)).toEqual(items); + }); + + it("clear resets to empty array", () => { + todos.set([{ content: "Task", status: "completed", activeForm: "Doing task" }]); + todos.clear(); + expect(get(todos)).toEqual([]); + }); + }); + + describe("update", () => { + it("can update todos with a callback", () => { + const initial: TodoItem[] = [ + { content: "Task 1", status: "pending", activeForm: "Doing task 1" }, + ]; + todos.set(initial); + todos.update((current) => [ + ...current, + { content: "Task 2", status: "completed", activeForm: "Doing task 2" }, + ]); + expect(get(todos)).toHaveLength(2); + }); + }); + + describe("initializeTodoListener", () => { + it("listens for claude:todo-update events", async () => { + await initializeTodoListener(); + + const newTodos: TodoItem[] = [ + { content: "Backend task", status: "in_progress", activeForm: "Running backend task" }, + ]; + emitMockEvent("claude:todo-update", { todos: newTodos }); + + expect(get(todos)).toEqual(newTodos); + }); + + it("does not register a duplicate listener when called twice", async () => { + await initializeTodoListener(); + await initializeTodoListener(); + + const newTodos: TodoItem[] = [ + { content: "Task", status: "pending", activeForm: "Doing task" }, + ]; + emitMockEvent("claude:todo-update", { todos: newTodos }); + + // Should only have been set once (not doubled) + expect(get(todos)).toEqual(newTodos); + }); + }); + + describe("cleanupTodoListener", () => { + it("stops listening for events after cleanup", async () => { + await initializeTodoListener(); + cleanupTodoListener(); + + emitMockEvent("claude:todo-update", { + todos: [{ content: "Task", status: "pending", activeForm: "Doing task" }], + }); + + // Should not have been updated since we cleaned up + expect(get(todos)).toEqual([]); + }); + + it("is safe to call when not initialised", () => { + expect(() => cleanupTodoListener()).not.toThrow(); + }); + }); +});