import { describe, it, expect, beforeEach, vi } from "vitest"; import { configStore, maskPaths, clampFontSize, applyFontSize, applyTheme, applyCustomThemeColors, clearCustomThemeColors, MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE, type HikariConfig, type Theme, type CustomThemeColors, } from "./config"; import { invoke } from "@tauri-apps/api/core"; // Mock Tauri APIs vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(), })); describe("config store", () => { describe("font size constants", () => { it("has correct MIN_FONT_SIZE", () => { expect(MIN_FONT_SIZE).toBe(10); }); it("has correct MAX_FONT_SIZE", () => { expect(MAX_FONT_SIZE).toBe(24); }); it("has correct DEFAULT_FONT_SIZE", () => { expect(DEFAULT_FONT_SIZE).toBe(14); }); }); describe("clampFontSize", () => { it("returns the same value when within range", () => { expect(clampFontSize(14)).toBe(14); expect(clampFontSize(10)).toBe(10); expect(clampFontSize(24)).toBe(24); expect(clampFontSize(18)).toBe(18); }); it("clamps values below minimum", () => { expect(clampFontSize(5)).toBe(MIN_FONT_SIZE); expect(clampFontSize(0)).toBe(MIN_FONT_SIZE); expect(clampFontSize(-10)).toBe(MIN_FONT_SIZE); expect(clampFontSize(9)).toBe(MIN_FONT_SIZE); }); it("clamps values above maximum", () => { expect(clampFontSize(30)).toBe(MAX_FONT_SIZE); expect(clampFontSize(100)).toBe(MAX_FONT_SIZE); expect(clampFontSize(25)).toBe(MAX_FONT_SIZE); }); }); describe("maskPaths", () => { it("returns text unchanged when hidePaths is false", () => { const text = "/home/naomi/code/project/file.ts"; expect(maskPaths(text, false)).toBe(text); }); it("masks Unix home paths", () => { const text = "/home/naomi/code/project/file.ts"; expect(maskPaths(text, true)).toBe("/home/****/code/project/file.ts"); }); it("masks macOS user paths", () => { const text = "/Users/naomi/Documents/project/file.ts"; expect(maskPaths(text, true)).toBe("/Users/****/Documents/project/file.ts"); }); it("masks Windows user paths", () => { const text = "C:\\Users\\naomi\\Documents\\project\\file.ts"; expect(maskPaths(text, true)).toBe("C:\\Users\\****\\Documents\\project\\file.ts"); }); it("masks tilde paths", () => { const text = "~/code/project/file.ts"; expect(maskPaths(text, true)).toBe("****/code/project/file.ts"); }); it("masks multiple paths in the same text", () => { const text = "Editing /home/naomi/file1.ts and /home/naomi/file2.ts"; expect(maskPaths(text, true)).toBe("Editing /home/****/file1.ts and /home/****/file2.ts"); }); it("handles mixed path types", () => { const text = "Unix: /home/user/file, Mac: /Users/user/file, Win: C:\\Users\\user\\file"; const expected = "Unix: /home/****/file, Mac: /Users/****/file, Win: C:\\Users\\****\\file"; expect(maskPaths(text, true)).toBe(expected); }); it("handles paths with special characters in username", () => { const text = "/home/user-name_123/project"; expect(maskPaths(text, true)).toBe("/home/****/project"); }); it("does not mask non-path text", () => { const text = "This is just regular text without any paths"; expect(maskPaths(text, true)).toBe(text); }); it("handles empty string", () => { expect(maskPaths("", true)).toBe(""); expect(maskPaths("", false)).toBe(""); }); }); describe("Theme type", () => { it("accepts valid theme values", () => { const themes: Theme[] = ["dark", "light", "high-contrast", "custom"]; themes.forEach((theme) => { expect(["dark", "light", "high-contrast", "custom"]).toContain(theme); }); }); }); describe("CustomThemeColors interface", () => { it("can create a valid custom theme colors object", () => { const colors: CustomThemeColors = { bg_primary: "#1a1a2e", bg_secondary: "#16213e", bg_terminal: "#0f0f23", accent_primary: "#e94560", accent_secondary: "#533483", text_primary: "#eaeaea", text_secondary: "#a0a0a0", border_color: "#333355", }; expect(colors.bg_primary).toBe("#1a1a2e"); expect(colors.accent_primary).toBe("#e94560"); }); it("allows null values for optional colors", () => { const colors: CustomThemeColors = { bg_primary: null, bg_secondary: null, bg_terminal: null, accent_primary: "#e94560", accent_secondary: null, text_primary: null, text_secondary: null, border_color: null, }; expect(colors.bg_primary).toBeNull(); expect(colors.accent_primary).toBe("#e94560"); }); }); describe("HikariConfig interface", () => { it("can create a valid config object with all fields", () => { const config: HikariConfig = { model: "claude-sonnet-4", api_key: "test-key", custom_instructions: "Be helpful", mcp_servers_json: "{}", auto_granted_tools: ["Read", "Write"], theme: "dark", greeting_enabled: true, greeting_custom_prompt: "Hello!", notifications_enabled: true, notification_volume: 0.7, always_on_top: false, update_checks_enabled: true, character_panel_width: 300, font_size: 14, streamer_mode: false, streamer_hide_paths: false, compact_mode: false, profile_name: "Naomi", profile_avatar_path: "/path/to/avatar.png", profile_bio: "Developer", custom_theme_colors: { bg_primary: null, bg_secondary: null, bg_terminal: null, accent_primary: null, accent_secondary: null, text_primary: null, text_secondary: null, border_color: null, }, budget_enabled: false, session_token_budget: null, session_cost_budget: null, budget_action: "warn", budget_warning_threshold: 0.8, discord_rpc_enabled: true, show_thinking_blocks: true, }; expect(config.model).toBe("claude-sonnet-4"); expect(config.auto_granted_tools).toEqual(["Read", "Write"]); expect(config.theme).toBe("dark"); }); it("allows null values for optional fields", () => { const config: HikariConfig = { model: null, api_key: null, custom_instructions: null, mcp_servers_json: null, auto_granted_tools: [], theme: "dark", greeting_enabled: true, greeting_custom_prompt: null, notifications_enabled: true, notification_volume: 0.7, always_on_top: false, update_checks_enabled: true, character_panel_width: null, font_size: 14, streamer_mode: false, streamer_hide_paths: false, compact_mode: false, profile_name: null, profile_avatar_path: null, profile_bio: null, custom_theme_colors: { bg_primary: null, bg_secondary: null, bg_terminal: null, accent_primary: null, accent_secondary: null, text_primary: null, text_secondary: null, border_color: null, }, budget_enabled: false, session_token_budget: null, session_cost_budget: null, budget_action: "warn", budget_warning_threshold: 0.8, discord_rpc_enabled: true, show_thinking_blocks: true, }; expect(config.model).toBeNull(); expect(config.api_key).toBeNull(); expect(config.character_panel_width).toBeNull(); }); }); describe("applyFontSize", () => { beforeEach(() => { // Reset document state if (typeof document !== "undefined") { document.documentElement.style.removeProperty("--terminal-font-size"); } }); it("sets CSS variable for valid font size", () => { applyFontSize(16); const value = document.documentElement.style.getPropertyValue("--terminal-font-size"); expect(value).toBe("16px"); }); it("clamps font size below minimum", () => { applyFontSize(5); const value = document.documentElement.style.getPropertyValue("--terminal-font-size"); expect(value).toBe(`${MIN_FONT_SIZE}px`); }); it("clamps font size above maximum", () => { applyFontSize(50); const value = document.documentElement.style.getPropertyValue("--terminal-font-size"); expect(value).toBe(`${MAX_FONT_SIZE}px`); }); }); describe("applyTheme", () => { beforeEach(() => { // Reset document state if (typeof document !== "undefined") { document.documentElement.removeAttribute("data-theme"); clearCustomThemeColors(); } }); it("sets data-theme attribute for dark theme", () => { applyTheme("dark"); expect(document.documentElement.getAttribute("data-theme")).toBe("dark"); }); it("sets data-theme attribute for light theme", () => { applyTheme("light"); expect(document.documentElement.getAttribute("data-theme")).toBe("light"); }); it("sets data-theme attribute for high-contrast theme", () => { applyTheme("high-contrast"); expect(document.documentElement.getAttribute("data-theme")).toBe("high-contrast"); }); it("uses dark as base for custom theme", () => { applyTheme("custom"); expect(document.documentElement.getAttribute("data-theme")).toBe("dark"); }); it("applies custom colors when theme is custom", () => { const colors: CustomThemeColors = { bg_primary: "#1a1a2e", bg_secondary: null, bg_terminal: null, accent_primary: "#e94560", accent_secondary: null, text_primary: null, text_secondary: null, border_color: null, }; applyTheme("custom", colors); expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("#1a1a2e"); expect(document.documentElement.style.getPropertyValue("--accent-primary")).toBe("#e94560"); }); it("does not apply custom colors for non-custom themes", () => { const colors: CustomThemeColors = { bg_primary: "#1a1a2e", bg_secondary: null, bg_terminal: null, accent_primary: null, accent_secondary: null, text_primary: null, text_secondary: null, border_color: null, }; applyTheme("dark", colors); expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe(""); }); }); describe("applyCustomThemeColors", () => { beforeEach(() => { clearCustomThemeColors(); }); it("applies all provided colors", () => { const colors: CustomThemeColors = { bg_primary: "#111111", bg_secondary: "#222222", bg_terminal: "#333333", accent_primary: "#444444", accent_secondary: "#555555", text_primary: "#666666", text_secondary: "#777777", border_color: "#888888", }; applyCustomThemeColors(colors); expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("#111111"); expect(document.documentElement.style.getPropertyValue("--bg-secondary")).toBe("#222222"); expect(document.documentElement.style.getPropertyValue("--bg-terminal")).toBe("#333333"); expect(document.documentElement.style.getPropertyValue("--accent-primary")).toBe("#444444"); expect(document.documentElement.style.getPropertyValue("--accent-secondary")).toBe("#555555"); expect(document.documentElement.style.getPropertyValue("--text-primary")).toBe("#666666"); expect(document.documentElement.style.getPropertyValue("--text-secondary")).toBe("#777777"); expect(document.documentElement.style.getPropertyValue("--border-color")).toBe("#888888"); }); it("skips null values", () => { const colors: CustomThemeColors = { bg_primary: "#111111", bg_secondary: null, bg_terminal: null, accent_primary: null, accent_secondary: null, text_primary: null, text_secondary: null, border_color: null, }; applyCustomThemeColors(colors); expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("#111111"); expect(document.documentElement.style.getPropertyValue("--bg-secondary")).toBe(""); }); }); describe("clearCustomThemeColors", () => { it("removes all custom theme CSS properties", () => { // First apply some colors const colors: CustomThemeColors = { bg_primary: "#111111", bg_secondary: "#222222", bg_terminal: "#333333", accent_primary: "#444444", accent_secondary: "#555555", text_primary: "#666666", text_secondary: "#777777", border_color: "#888888", }; applyCustomThemeColors(colors); // Then clear them clearCustomThemeColors(); expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe(""); expect(document.documentElement.style.getPropertyValue("--bg-secondary")).toBe(""); expect(document.documentElement.style.getPropertyValue("--bg-terminal")).toBe(""); expect(document.documentElement.style.getPropertyValue("--accent-primary")).toBe(""); expect(document.documentElement.style.getPropertyValue("--accent-secondary")).toBe(""); expect(document.documentElement.style.getPropertyValue("--text-primary")).toBe(""); expect(document.documentElement.style.getPropertyValue("--text-secondary")).toBe(""); expect(document.documentElement.style.getPropertyValue("--border-color")).toBe(""); }); }); describe("derived stores", () => { // Note: These tests verify the derived store logic by testing the derivation functions // The actual stores depend on configStore which requires Tauri invoke mocking it("isDarkTheme returns true for dark theme config", () => { // Test the derivation logic const darkConfig = { theme: "dark" as Theme }; expect(darkConfig.theme === "dark").toBe(true); }); it("isDarkTheme returns false for light theme config", () => { const lightConfig = { theme: "light" as Theme }; expect(lightConfig.theme === "dark").toBe(false); }); it("isStreamerMode derives from streamer_mode config", () => { const configWithStreamerMode = { streamer_mode: true }; const configWithoutStreamerMode = { streamer_mode: false }; expect(configWithStreamerMode.streamer_mode).toBe(true); expect(configWithoutStreamerMode.streamer_mode).toBe(false); }); it("isCompactMode derives from compact_mode config", () => { const configWithCompactMode = { compact_mode: true }; const configWithoutCompactMode = { compact_mode: false }; expect(configWithCompactMode.compact_mode).toBe(true); expect(configWithoutCompactMode.compact_mode).toBe(false); }); it("shouldHidePaths requires both streamer_mode and streamer_hide_paths", () => { const config1 = { streamer_mode: true, streamer_hide_paths: true }; const config2 = { streamer_mode: true, streamer_hide_paths: false }; const config3 = { streamer_mode: false, streamer_hide_paths: true }; const config4 = { streamer_mode: false, streamer_hide_paths: false }; expect(config1.streamer_mode && config1.streamer_hide_paths).toBe(true); expect(config2.streamer_mode && config2.streamer_hide_paths).toBe(false); expect(config3.streamer_mode && config3.streamer_hide_paths).toBe(false); expect(config4.streamer_mode && config4.streamer_hide_paths).toBe(false); }); }); describe("configStore methods", () => { it("has all expected methods", () => { expect(typeof configStore.loadConfig).toBe("function"); expect(typeof configStore.saveConfig).toBe("function"); expect(typeof configStore.updateConfig).toBe("function"); expect(typeof configStore.openSidebar).toBe("function"); expect(typeof configStore.closeSidebar).toBe("function"); expect(typeof configStore.toggleSidebar).toBe("function"); expect(typeof configStore.setTheme).toBe("function"); expect(typeof configStore.setCustomThemeColors).toBe("function"); expect(typeof configStore.setFontSize).toBe("function"); expect(typeof configStore.increaseFontSize).toBe("function"); expect(typeof configStore.decreaseFontSize).toBe("function"); expect(typeof configStore.resetFontSize).toBe("function"); expect(typeof configStore.addAutoGrantedTool).toBe("function"); expect(typeof configStore.removeAutoGrantedTool).toBe("function"); expect(typeof configStore.getConfig).toBe("function"); expect(typeof configStore.toggleStreamerMode).toBe("function"); expect(typeof configStore.toggleCompactMode).toBe("function"); expect(typeof configStore.setCompactMode).toBe("function"); }); it("has subscribable stores", () => { expect(typeof configStore.config.subscribe).toBe("function"); expect(typeof configStore.isLoading.subscribe).toBe("function"); expect(typeof configStore.isSidebarOpen.subscribe).toBe("function"); expect(typeof configStore.saveError.subscribe).toBe("function"); }); }); describe("Race Condition Tests", () => { beforeEach(async () => { // Setup mock to return a default config for load_config const mockInvokeImpl = vi.mocked(invoke); mockInvokeImpl.mockResolvedValue({ model: null, api_key: null, custom_instructions: null, mcp_servers_json: null, auto_granted_tools: [], theme: "dark", greeting_enabled: false, greeting_custom_prompt: null, notifications_enabled: false, notification_volume: 0.7, always_on_top: false, update_checks_enabled: false, character_panel_width: null, font_size: 14, streamer_mode: false, streamer_hide_paths: false, compact_mode: false, profile_name: null, profile_avatar_path: null, profile_bio: null, custom_theme_colors: { bg_primary: null, bg_secondary: null, bg_terminal: null, accent_primary: null, accent_secondary: null, text_primary: null, text_secondary: null, border_color: null, }, budget_enabled: false, session_token_budget: null, session_cost_budget: null, budget_action: "warn", budget_warning_threshold: 0.8, discord_rpc_enabled: false, }); // Load initial config await configStore.loadConfig(); vi.clearAllMocks(); }); it("handles rapid sequential config updates correctly", async () => { // This test validates the fix for the config race condition that caused data loss const mockInvokeImpl = vi.mocked(invoke); const invokeCalls: Array<{ command: string; config: HikariConfig }> = []; mockInvokeImpl.mockImplementation(async (command: string, args?: unknown) => { if (command === "save_config" && args && typeof args === "object" && "config" in args) { invokeCalls.push({ command, config: args.config as HikariConfig }); // Simulate small delay in saving await new Promise((resolve) => setTimeout(resolve, 10)); } return null; }); // Perform rapid updates await Promise.all([ configStore.updateConfig({ font_size: 16 }), configStore.updateConfig({ theme: "light" }), configStore.updateConfig({ compact_mode: true }), ]); // All three updates should have been saved expect(invokeCalls.length).toBe(3); // Get final config const finalConfig = configStore.getConfig(); // Final config should have all updates // Note: The last update wins for each field, but all fields should be preserved expect(finalConfig.compact_mode).toBe(true); }); it("preserves previous field values during concurrent updates", async () => { // Set initial values await configStore.updateConfig({ font_size: 16, theme: "dark", compact_mode: false, streamer_mode: false, }); vi.clearAllMocks(); const mockInvokeImpl = vi.mocked(invoke); const invokeCalls: Array<{ command: string; config: HikariConfig }> = []; mockInvokeImpl.mockImplementation(async (command: string, args?: unknown) => { if (command === "save_config" && args && typeof args === "object" && "config" in args) { invokeCalls.push({ command, config: args.config as HikariConfig }); await new Promise((resolve) => setTimeout(resolve, 5)); } return null; }); // Update different fields concurrently await Promise.all([ configStore.updateConfig({ font_size: 18 }), configStore.updateConfig({ theme: "light" }), configStore.updateConfig({ compact_mode: true }), ]); // Check that each save included all previous config values invokeCalls.forEach((call) => { // Each save should have a complete config, not just the updated field expect(call.config).toHaveProperty("font_size"); expect(call.config).toHaveProperty("theme"); expect(call.config).toHaveProperty("compact_mode"); expect(call.config).toHaveProperty("streamer_mode"); expect(call.config).toHaveProperty("model"); expect(call.config).toHaveProperty("api_key"); }); }); it("handles update during save operation", async () => { const mockInvokeImpl = vi.mocked(invoke); let firstSaveStarted = false; let firstSaveCompleted = false; mockInvokeImpl.mockImplementation(async (command: string) => { if (command === "save_config") { if (!firstSaveStarted) { firstSaveStarted = true; // Simulate slow save await new Promise((resolve) => setTimeout(resolve, 50)); firstSaveCompleted = true; } else { // Second save starts while first is in progress expect(firstSaveStarted).toBe(true); // First save might not be complete yet (race condition scenario) } } return null; }); // Start first update const firstUpdate = configStore.updateConfig({ font_size: 16 }); // Wait a bit then start second update whilst first is still saving await new Promise((resolve) => setTimeout(resolve, 10)); const secondUpdate = configStore.updateConfig({ theme: "light" }); // Wait for both to complete await Promise.all([firstUpdate, secondUpdate]); // Both should complete successfully without errors expect(firstSaveCompleted).toBe(true); }); it("getConfig returns most recently set configuration", async () => { await configStore.updateConfig({ font_size: 14 }); expect(configStore.getConfig().font_size).toBe(14); await configStore.updateConfig({ font_size: 16 }); expect(configStore.getConfig().font_size).toBe(16); await configStore.updateConfig({ font_size: 18 }); expect(configStore.getConfig().font_size).toBe(18); }); it("updates do not lose data from previous operations", async () => { // Set multiple fields await configStore.updateConfig({ font_size: 16, theme: "dark", compact_mode: true, streamer_mode: true, model: "claude-sonnet-4", }); // Update just one field await configStore.updateConfig({ theme: "light" }); // Other fields should be preserved const config = configStore.getConfig(); expect(config.theme).toBe("light"); expect(config.font_size).toBe(16); expect(config.compact_mode).toBe(true); expect(config.streamer_mode).toBe(true); expect(config.model).toBe("claude-sonnet-4"); }); it("auto granted tools are not lost during other updates", async () => { // Add some tools await configStore.addAutoGrantedTool("Read"); await configStore.addAutoGrantedTool("Write"); expect(configStore.getConfig().auto_granted_tools).toContain("Read"); expect(configStore.getConfig().auto_granted_tools).toContain("Write"); // Update another field await configStore.updateConfig({ theme: "light" }); // Tools should still be there expect(configStore.getConfig().auto_granted_tools).toContain("Read"); expect(configStore.getConfig().auto_granted_tools).toContain("Write"); }); it("custom theme colors persist across other config updates", async () => { const customColors: CustomThemeColors = { bg_primary: "#1a1a2e", bg_secondary: "#16213e", bg_terminal: "#0f0f23", accent_primary: "#e94560", accent_secondary: "#533483", text_primary: "#eaeaea", text_secondary: "#a0a0a0", border_color: "#333355", }; await configStore.setCustomThemeColors(customColors); // Update another field await configStore.updateConfig({ font_size: 18 }); // Colors should still be there const config = configStore.getConfig(); expect(config.custom_theme_colors.bg_primary).toBe("#1a1a2e"); expect(config.custom_theme_colors.accent_primary).toBe("#e94560"); }); it("handles save errors gracefully without losing data", async () => { const mockInvokeImpl = vi.mocked(invoke); // Set initial config await configStore.updateConfig({ font_size: 14 }); // Make next save fail mockInvokeImpl.mockRejectedValueOnce(new Error("Save failed")); // Try to update - should throw await expect(configStore.updateConfig({ theme: "light" })).rejects.toThrow(); // Original config should still be accessible expect(configStore.getConfig().font_size).toBe(14); }); }); describe("Config Persistence Tests", () => { it("loadConfig retrieves saved configuration", async () => { const mockConfig: HikariConfig = { model: "claude-sonnet-4", api_key: "test-key", custom_instructions: "Be helpful", mcp_servers_json: "{}", auto_granted_tools: ["Read", "Write"], theme: "light", greeting_enabled: false, greeting_custom_prompt: null, notifications_enabled: false, notification_volume: 0.5, always_on_top: true, update_checks_enabled: false, character_panel_width: 400, font_size: 18, streamer_mode: true, streamer_hide_paths: true, compact_mode: true, profile_name: "Test User", profile_avatar_path: "/test/avatar.png", profile_bio: "Test bio", custom_theme_colors: { bg_primary: null, bg_secondary: null, bg_terminal: null, accent_primary: null, accent_secondary: null, text_primary: null, text_secondary: null, border_color: null, }, budget_enabled: true, session_token_budget: 100000, session_cost_budget: 1.5, budget_action: "block", budget_warning_threshold: 0.9, discord_rpc_enabled: false, show_thinking_blocks: true, }; const mockInvokeImpl = vi.mocked(invoke); mockInvokeImpl.mockResolvedValueOnce(mockConfig); await configStore.loadConfig(); const loadedConfig = configStore.getConfig(); expect(loadedConfig.model).toBe("claude-sonnet-4"); expect(loadedConfig.theme).toBe("light"); expect(loadedConfig.font_size).toBe(18); expect(loadedConfig.auto_granted_tools).toEqual(["Read", "Write"]); }); it("saveConfig persists configuration to backend", async () => { const mockInvokeImpl = vi.mocked(invoke); const savedConfigs: HikariConfig[] = []; mockInvokeImpl.mockImplementation(async (command: string, args?: unknown) => { if (command === "save_config" && args && typeof args === "object" && "config" in args) { savedConfigs.push(args.config as HikariConfig); } return null; }); const configToSave: Partial = { model: "claude-sonnet-4", theme: "dark", font_size: 16, }; await configStore.updateConfig(configToSave); expect(savedConfigs.length).toBeGreaterThan(0); const lastSaved = savedConfigs[savedConfigs.length - 1]; expect(lastSaved.model).toBe("claude-sonnet-4"); expect(lastSaved.theme).toBe("dark"); expect(lastSaved.font_size).toBe(16); }); }); });