From c819adc9ea9a410cafa47a87318074932d22395c Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 3 Mar 2026 14:58:49 -0800 Subject: [PATCH] test: add coverage for drafts, soundPlayer, wslNotificationHelper, and costTrackingStore --- src/lib/notifications/notifications.test.ts | 47 +++++ .../wslNotificationHelper.test.ts | 40 ++++ src/lib/stores/costTracking.test.ts | 197 +++++++++++++++++- src/lib/stores/drafts.test.ts | 10 + 4 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 src/lib/notifications/wslNotificationHelper.test.ts diff --git a/src/lib/notifications/notifications.test.ts b/src/lib/notifications/notifications.test.ts index 61152a5..0b5dcd3 100644 --- a/src/lib/notifications/notifications.test.ts +++ b/src/lib/notifications/notifications.test.ts @@ -232,6 +232,53 @@ describe("notifications", () => { // Should not throw await expect(soundPlayer.play(NotificationType.SUCCESS)).resolves.toBeUndefined(); }); + + it("play warns when audio type is not in the cache", async () => { + const { soundPlayer } = await import("./soundPlayer"); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + soundPlayer.setEnabled(true); + await soundPlayer.play("nonexistent" as NotificationType); + + expect(warnSpy).toHaveBeenCalledWith("No audio found for notification type: nonexistent"); + warnSpy.mockRestore(); + }); + + it("play catches errors from audio playback", async () => { + vi.resetModules(); + + class FailingAudio { + volume = 1; + preload = "auto"; + + cloneNode() { + const clone = new FailingAudio(); + clone.volume = this.volume; + return clone; + } + + async play(): Promise { + throw new Error("Playback blocked by browser"); + } + } + + const originalAudio = globalThis.Audio; + globalThis.Audio = FailingAudio as unknown as typeof Audio; + + const { soundPlayer: freshPlayer } = await import("./soundPlayer"); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + freshPlayer.setEnabled(true); + await freshPlayer.play(NotificationType.SUCCESS); + + expect(errorSpy).toHaveBeenCalledWith( + "Failed to play notification sound:", + expect.any(Error) + ); + + errorSpy.mockRestore(); + globalThis.Audio = originalAudio; + }); }); describe("NotificationManager class", () => { diff --git a/src/lib/notifications/wslNotificationHelper.test.ts b/src/lib/notifications/wslNotificationHelper.test.ts new file mode 100644 index 0000000..fe872b4 --- /dev/null +++ b/src/lib/notifications/wslNotificationHelper.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, vi } from "vitest"; +import { platform } from "@tauri-apps/plugin-os"; +import { invoke } from "@tauri-apps/api/core"; +import { sendWSLNotification } from "./wslNotificationHelper"; + +// platform() is mocked in vitest.setup.ts to return "linux" by default + +describe("sendWSLNotification", () => { + it("invokes send_windows_notification when platform is windows", async () => { + vi.mocked(platform).mockResolvedValueOnce("windows" as Awaited>); + + await sendWSLNotification("Test Title", "Test body"); + + expect(invoke).toHaveBeenCalledWith("send_windows_notification", { + title: "Test Title", + body: "Test body", + }); + }); + + it("does not invoke on non-Windows platforms", async () => { + // Default mock returns "linux" + await sendWSLNotification("Test Title", "Test body"); + + expect(invoke).not.toHaveBeenCalled(); + }); + + it("handles invoke errors gracefully and logs them", async () => { + vi.mocked(platform).mockResolvedValueOnce("windows" as Awaited>); + vi.mocked(invoke).mockRejectedValueOnce(new Error("Windows notification failed")); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await sendWSLNotification("Title", "Body"); + + expect(errorSpy).toHaveBeenCalledWith( + "Failed to send Windows notification:", + expect.any(Error) + ); + errorSpy.mockRestore(); + }); +}); diff --git a/src/lib/stores/costTracking.test.ts b/src/lib/stores/costTracking.test.ts index a992a82..5ca17e7 100644 --- a/src/lib/stores/costTracking.test.ts +++ b/src/lib/stores/costTracking.test.ts @@ -1,12 +1,23 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { get } from "svelte/store"; +import { invoke } from "@tauri-apps/api/core"; +import { setMockInvokeResult } from "../../../vitest.setup"; import { formatCost, formatAlertType, getAlertMessage, + costTrackingStore, + formattedCosts, type AlertType, type CostAlert, } from "./costTracking"; +vi.mock("$lib/notifications/notificationManager", () => ({ + notificationManager: { + notifyCostAlert: vi.fn(), + }, +})); + describe("formatCost", () => { it("formats amounts below $0.01 to 4 decimal places", () => { expect(formatCost(0)).toBe("$0.0000"); @@ -95,3 +106,187 @@ describe("getAlertMessage", () => { expect(message).toContain("$0.0050"); }); }); + +describe("costTrackingStore", () => { + beforeEach(() => { + vi.clearAllMocks(); + costTrackingStore.reset(); + }); + + describe("refresh", () => { + it("loads cost data from the backend", async () => { + setMockInvokeResult("get_today_cost", 1.5); + setMockInvokeResult("get_week_cost", 5.25); + setMockInvokeResult("get_month_cost", 20.0); + setMockInvokeResult("get_cost_alerts", []); + + await costTrackingStore.refresh(); + + const state = get(costTrackingStore); + expect(state.todayCost).toBe(1.5); + expect(state.weekCost).toBe(5.25); + expect(state.monthCost).toBe(20.0); + expect(state.isLoading).toBe(false); + }); + + it("triggers notifications for any returned alerts", async () => { + const { notificationManager } = await import("$lib/notifications/notificationManager"); + const alert: CostAlert = { alert_type: "Daily", threshold: 1.0, current_cost: 1.5 }; + setMockInvokeResult("get_today_cost", 1.5); + setMockInvokeResult("get_week_cost", 0); + setMockInvokeResult("get_month_cost", 0); + setMockInvokeResult("get_cost_alerts", [alert]); + + await costTrackingStore.refresh(); + + expect(notificationManager.notifyCostAlert).toHaveBeenCalledWith( + expect.stringContaining("Today") + ); + }); + + it("returns alerts from refresh", async () => { + const alert: CostAlert = { alert_type: "Weekly", threshold: 5.0, current_cost: 6.0 }; + setMockInvokeResult("get_today_cost", 0); + setMockInvokeResult("get_week_cost", 6.0); + setMockInvokeResult("get_month_cost", 0); + setMockInvokeResult("get_cost_alerts", [alert]); + + const result = await costTrackingStore.refresh(); + + expect(result).toEqual([alert]); + }); + + it("handles refresh errors gracefully and returns empty array", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + setMockInvokeResult("get_today_cost", new Error("Backend error")); + + const result = await costTrackingStore.refresh(); + + expect(result).toEqual([]); + expect(errorSpy).toHaveBeenCalledWith("Failed to refresh cost tracking:", expect.any(Error)); + errorSpy.mockRestore(); + }); + }); + + describe("getSummary", () => { + it("fetches and stores a cost summary", async () => { + const mockSummary = { + period_days: 7, + total_input_tokens: 1000, + total_output_tokens: 500, + total_cost: 0.05, + total_messages: 20, + total_sessions: 3, + average_daily_cost: 0.007, + daily_breakdown: [], + }; + setMockInvokeResult("get_cost_summary", mockSummary); + + const result = await costTrackingStore.getSummary(7); + + expect(result).toEqual(mockSummary); + expect(invoke).toHaveBeenCalledWith("get_cost_summary", { days: 7 }); + }); + + it("returns null and logs error on failure", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + setMockInvokeResult("get_cost_summary", new Error("Summary unavailable")); + + const result = await costTrackingStore.getSummary(30); + + expect(result).toBeNull(); + expect(errorSpy).toHaveBeenCalledWith("Failed to get cost summary:", expect.any(Error)); + errorSpy.mockRestore(); + }); + }); + + describe("setAlertThresholds", () => { + it("persists new thresholds to the backend", async () => { + const thresholds = { daily: 2.0, weekly: 10.0, monthly: 40.0 }; + + await costTrackingStore.setAlertThresholds(thresholds); + + expect(invoke).toHaveBeenCalledWith("set_cost_alert_thresholds", { + daily: 2.0, + weekly: 10.0, + monthly: 40.0, + }); + }); + + it("logs an error when the backend call fails", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + setMockInvokeResult("set_cost_alert_thresholds", new Error("Failed")); + + await costTrackingStore.setAlertThresholds({ daily: 1.0, weekly: null, monthly: null }); + + expect(errorSpy).toHaveBeenCalledWith("Failed to set alert thresholds:", expect.any(Error)); + errorSpy.mockRestore(); + }); + }); + + describe("exportCsv", () => { + it("returns the CSV string from the backend", async () => { + setMockInvokeResult("export_cost_csv", "date,cost\n2026-01-01,1.50"); + + const result = await costTrackingStore.exportCsv(7); + + expect(result).toBe("date,cost\n2026-01-01,1.50"); + expect(invoke).toHaveBeenCalledWith("export_cost_csv", { days: 7 }); + }); + + it("returns null and logs error on failure", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + setMockInvokeResult("export_cost_csv", new Error("Export failed")); + + const result = await costTrackingStore.exportCsv(30); + + expect(result).toBeNull(); + expect(errorSpy).toHaveBeenCalledWith("Failed to export CSV:", expect.any(Error)); + errorSpy.mockRestore(); + }); + }); + + describe("reset", () => { + it("resets costs back to zero", async () => { + setMockInvokeResult("get_today_cost", 5.0); + setMockInvokeResult("get_week_cost", 15.0); + setMockInvokeResult("get_month_cost", 50.0); + setMockInvokeResult("get_cost_alerts", []); + await costTrackingStore.refresh(); + + costTrackingStore.reset(); + + const state = get(costTrackingStore); + expect(state.todayCost).toBe(0); + expect(state.weekCost).toBe(0); + expect(state.monthCost).toBe(0); + }); + }); +}); + +describe("formattedCosts", () => { + beforeEach(() => { + costTrackingStore.reset(); + }); + + it("formats the initial zero costs correctly", () => { + const costs = get(formattedCosts); + expect(costs.today).toBe("$0.0000"); + expect(costs.week).toBe("$0.0000"); + expect(costs.month).toBe("$0.0000"); + }); + + it("reflects updated costs after a refresh", async () => { + setMockInvokeResult("get_today_cost", 1.5); + setMockInvokeResult("get_week_cost", 5.0); + setMockInvokeResult("get_month_cost", 20.0); + setMockInvokeResult("get_cost_alerts", []); + await costTrackingStore.refresh(); + + const costs = get(formattedCosts); + expect(costs.today).toBe("$1.50"); + expect(costs.week).toBe("$5.00"); + expect(costs.month).toBe("$20.00"); + expect(costs.todayRaw).toBe(1.5); + }); +}); diff --git a/src/lib/stores/drafts.test.ts b/src/lib/stores/drafts.test.ts index 4771ad6..1790f1f 100644 --- a/src/lib/stores/drafts.test.ts +++ b/src/lib/stores/drafts.test.ts @@ -180,6 +180,16 @@ describe("draftsStore", () => { const result = draftsStore.formatTimestamp(invalid); expect(result).toBe(invalid); }); + + it("falls back to raw string when toLocaleString throws", () => { + const spy = vi.spyOn(Date.prototype, "toLocaleString").mockImplementation(() => { + throw new Error("Locale not supported"); + }); + const ts = "2026-01-15T14:30:00.000Z"; + const result = draftsStore.formatTimestamp(ts); + expect(result).toBe(ts); + spy.mockRestore(); + }); }); });