generated from nhcarrigan/template
test: expand frontend unit test coverage to 30%
This commit is contained in:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,8 +9,12 @@ import {
|
|||||||
estimateMessageCost,
|
estimateMessageCost,
|
||||||
formatTokenCount,
|
formatTokenCount,
|
||||||
MODEL_PRICING,
|
MODEL_PRICING,
|
||||||
|
checkBudget,
|
||||||
|
getBudgetStatusMessage,
|
||||||
|
getRemainingTokenBudget,
|
||||||
|
getRemainingCostBudget,
|
||||||
} from "./stats";
|
} from "./stats";
|
||||||
import type { UsageStats, ToolTokenStats } from "./stats";
|
import type { UsageStats, ToolTokenStats, BudgetStatus } from "./stats";
|
||||||
|
|
||||||
// Helper function to create ToolTokenStats for tests
|
// Helper function to create ToolTokenStats for tests
|
||||||
function toolStats(callCount: number, inputTokens = 0, outputTokens = 0): ToolTokenStats {
|
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 });
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user