feat: multiple UI improvements, font settings, and memory file display names #175

Merged
naomi merged 22 commits from fix/tweaks into main 2026-03-03 20:21:58 -08:00
48 changed files with 7148 additions and 101 deletions
+13 -6
View File
@@ -20,19 +20,26 @@ When working with issues, pull requests, or other repository operations for this
When asked to commit changes for this project:
- **Always commit as Hikari** using: `--author="Hikari <hikari@nhcarrigan.com>"`
- **Always use `--no-gpg-sign`** since Hikari doesn't have GPG signing set up
- **Always sign commits** with Hikari's GPG key: `--gpg-sign=5380E4EE7307C808`
- **Never add `Co-Authored-By` lines** for Gitea commits
- **Always ask for confirmation** before committing
- **Always ask for confirmation** before pushing
Example commit command:
```bash
git commit --author="Hikari <hikari@nhcarrigan.com>" --no-gpg-sign -m "your commit message"
git commit --author="Hikari <hikari@nhcarrigan.com>" --gpg-sign=5380E4EE7307C808 -m "your commit message"
```
Example push command:
```bash
git push https://hikari:TOKEN@git.nhcarrigan.com/nhcarrigan/hikari-desktop.git <branch>
```
## Testing Requirements
All new features, fixes, and significant changes should include tests whenever possible:
**All changes MUST include tests.** This is non-negotiable — no feature, bug fix, or refactor should be committed without corresponding test coverage. If a change cannot be tested (e.g. pure UI layout, Tauri IPC calls that are impossible to mock), document why in a comment.
- **Frontend tests**: Use Vitest with `@testing-library/svelte` for component tests
- **Test files**: Place test files next to the code they test with `.test.ts` or `.spec.ts` extension
@@ -130,16 +137,16 @@ describe("FeatureName", () => {
});
```
### Adding Tests for New Features
### Adding Tests for All Changes
When developing new features, always add corresponding tests:
Every change — features, bug fixes, refactors — must include tests:
1. **Before implementing**: Consider what needs testing (happy path, edge cases, errors)
2. **During implementation**: Write tests alongside the code
3. **After implementation**: Run `pnpm test:coverage` to verify coverage remains high
4. **Before committing**: Ensure `check-all.sh` passes (includes all tests)
The goal is to maintain our near-100% coverage as the codebase grows, so future refactoring and changes can be made with confidence!
**Do not commit changes without tests.** The goal is to maintain near-100% coverage as the codebase grows, so future refactoring can be done with confidence!
## Quality Assurance
+129 -9
View File
@@ -862,6 +862,26 @@ pub async fn read_file_content(path: String) -> Result<String, String> {
.map_err(|e| format!("Failed to read file: {}", e))
}
/// Read the first `# Heading` from a WSL file path (for Windows).
/// Returns `None` if the file cannot be read or has no top-level heading.
#[cfg(target_os = "windows")]
fn read_wsl_file_first_heading(path: &str) -> Option<String> {
use std::process::Command;
let output = Command::new("wsl")
.hide_window()
.args(["-e", "bash", "-c", &format!("head -20 '{}'", path)])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let content = String::from_utf8_lossy(&output.stdout);
extract_first_heading(&content)
}
/// Read file content via WSL (for Windows with WSL paths)
#[allow(dead_code)]
async fn read_file_via_wsl(path: &str) -> Result<String, String> {
@@ -1353,9 +1373,29 @@ pub async fn close_application(app_handle: AppHandle) -> Result<(), String> {
Ok(())
}
#[derive(serde::Serialize)]
pub struct MemoryFileInfo {
pub path: String,
pub heading: Option<String>,
}
#[derive(serde::Serialize)]
pub struct MemoryFilesResponse {
pub files: Vec<String>,
pub files: Vec<MemoryFileInfo>,
}
/// Extract the first `# Heading` from a string of file content.
fn extract_first_heading(content: &str) -> Option<String> {
content.lines().find_map(|line| {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("# ") {
let heading = rest.trim().to_string();
if !heading.is_empty() {
return Some(heading);
}
}
None
})
}
#[tauri::command]
@@ -1398,12 +1438,19 @@ async fn list_memory_files_via_wsl() -> Result<MemoryFilesResponse, String> {
}
let stdout = String::from_utf8_lossy(&output.stdout);
let files: Vec<String> = stdout
let paths: Vec<String> = stdout
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| line.trim().to_string())
.collect();
// Read first heading from each file via WSL
let mut files = Vec::new();
for path in paths {
let heading = read_wsl_file_first_heading(&path);
files.push(MemoryFileInfo { path, heading });
}
Ok(MemoryFilesResponse { files })
}
@@ -1425,10 +1472,13 @@ async fn list_memory_files_native() -> Result<MemoryFilesResponse, String> {
return Ok(MemoryFilesResponse { files: Vec::new() });
}
let mut memory_files = Vec::new();
let mut memory_paths = Vec::new();
// Recursively find all memory directories
fn find_memory_files(dir: &std::path::Path, files: &mut Vec<String>) -> std::io::Result<()> {
fn find_memory_files(
dir: &std::path::Path,
files: &mut Vec<String>,
) -> std::io::Result<()> {
if !dir.is_dir() {
return Ok(());
}
@@ -1461,16 +1511,25 @@ async fn list_memory_files_native() -> Result<MemoryFilesResponse, String> {
Ok(())
}
if let Err(e) = find_memory_files(&projects_dir, &mut memory_files) {
if let Err(e) = find_memory_files(&projects_dir, &mut memory_paths) {
return Err(format!("Failed to list memory files: {}", e));
}
// Sort files alphabetically
memory_files.sort();
memory_paths.sort();
Ok(MemoryFilesResponse {
files: memory_files,
})
// Read first heading from each file
let files = memory_paths
.into_iter()
.map(|path| {
let heading = fs::read_to_string(&path)
.ok()
.and_then(|content| extract_first_heading(&content));
MemoryFileInfo { path, heading }
})
.collect();
Ok(MemoryFilesResponse { files })
}
#[tauri::command]
@@ -2902,4 +2961,65 @@ gitea: gitea-mcp -t stdio (STDIO) - ✓ Connected"#;
assert_eq!(servers[0].name, "asana");
assert_eq!(servers[1].name, "gitea");
}
// ==================== extract_first_heading tests ====================
#[test]
fn test_extract_first_heading_returns_heading() {
let content = "# My Memory File\n\nSome content here.";
assert_eq!(
extract_first_heading(content),
Some("My Memory File".to_string())
);
}
#[test]
fn test_extract_first_heading_ignores_non_h1() {
let content = "## Section Header\n### Sub-section\nSome content.";
assert_eq!(extract_first_heading(content), None);
}
#[test]
fn test_extract_first_heading_finds_first_h1_after_other_lines() {
let content = "Some intro text\n\n# The Real Title\n\nMore content.";
assert_eq!(
extract_first_heading(content),
Some("The Real Title".to_string())
);
}
#[test]
fn test_extract_first_heading_trims_whitespace() {
let content = "# Trimmed Heading \n\nContent.";
assert_eq!(
extract_first_heading(content),
Some("Trimmed Heading".to_string())
);
}
#[test]
fn test_extract_first_heading_returns_none_for_empty_content() {
assert_eq!(extract_first_heading(""), None);
}
#[test]
fn test_extract_first_heading_returns_none_for_empty_heading() {
let content = "# \n\nContent after empty heading.";
assert_eq!(extract_first_heading(content), None);
}
#[test]
fn test_extract_first_heading_returns_none_when_no_headings() {
let content = "Just some plain text.\nNo headings here at all.";
assert_eq!(extract_first_heading(content), None);
}
#[test]
fn test_extract_first_heading_handles_leading_whitespace_on_line() {
let content = " # Indented Heading\n\nContent.";
assert_eq!(
extract_first_heading(content),
Some("Indented Heading".to_string())
);
}
}
+40
View File
@@ -31,6 +31,9 @@ pub struct ClaudeStartOptions {
#[serde(default)]
pub disable_1m_context: bool,
#[serde(default)]
pub max_output_tokens: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -126,6 +129,9 @@ pub struct HikariConfig {
#[serde(default)]
pub disable_1m_context: bool,
#[serde(default)]
pub max_output_tokens: Option<u64>,
#[serde(default)]
pub trusted_workspaces: Vec<String>,
@@ -135,6 +141,23 @@ pub struct HikariConfig {
#[serde(default = "default_background_image_opacity")]
pub background_image_opacity: f32,
#[serde(default)]
pub show_thinking_blocks: bool,
// Custom terminal font settings
#[serde(default)]
pub custom_font_path: Option<String>,
#[serde(default)]
pub custom_font_family: Option<String>,
// Custom UI font settings
#[serde(default)]
pub custom_ui_font_path: Option<String>,
#[serde(default)]
pub custom_ui_font_family: Option<String>,
}
impl Default for HikariConfig {
@@ -169,9 +192,15 @@ impl Default for HikariConfig {
discord_rpc_enabled: true,
use_worktree: false,
disable_1m_context: false,
max_output_tokens: None,
trusted_workspaces: Vec::new(),
background_image_path: None,
background_image_opacity: 0.3,
show_thinking_blocks: false,
custom_font_path: None,
custom_font_family: None,
custom_ui_font_path: None,
custom_ui_font_family: None,
}
}
}
@@ -286,6 +315,11 @@ mod tests {
assert!(!config.use_worktree);
assert!(!config.disable_1m_context);
assert!(config.trusted_workspaces.is_empty());
assert!(!config.show_thinking_blocks);
assert!(config.custom_font_path.is_none());
assert!(config.custom_font_family.is_none());
assert!(config.custom_ui_font_path.is_none());
assert!(config.custom_ui_font_family.is_none());
}
#[test]
@@ -320,9 +354,15 @@ mod tests {
discord_rpc_enabled: true,
use_worktree: true,
disable_1m_context: false,
max_output_tokens: Some(32000),
trusted_workspaces: vec!["/home/naomi/projects/trusted".to_string()],
background_image_path: Some("/home/naomi/bg.png".to_string()),
background_image_opacity: 0.25,
show_thinking_blocks: true,
custom_font_path: Some("/home/naomi/.fonts/MyFont.ttf".to_string()),
custom_font_family: Some("MyFont".to_string()),
custom_ui_font_path: None,
custom_ui_font_family: None,
};
let json = serde_json::to_string(&config).unwrap();
+10
View File
@@ -296,6 +296,11 @@ impl WslBridge {
cmd.env("CLAUDE_CODE_DISABLE_1M_CONTEXT", "1");
}
// Set max output tokens if specified
if let Some(max_tokens) = options.max_output_tokens {
cmd.env("CLAUDE_CODE_MAX_OUTPUT_TOKENS", max_tokens.to_string());
}
cmd
} else {
// Running on Windows - use wsl with bash login shell to ensure PATH is loaded
@@ -343,6 +348,11 @@ impl WslBridge {
claude_cmd.push_str("CLAUDE_CODE_DISABLE_1M_CONTEXT=1 ");
}
// Set max output tokens if specified
if let Some(max_tokens) = options.max_output_tokens {
claude_cmd.push_str(&format!("CLAUDE_CODE_MAX_OUTPUT_TOKENS={} ", max_tokens));
}
claude_cmd.push_str(
"claude --output-format stream-json --input-format stream-json --verbose",
);
+1 -5
View File
@@ -154,11 +154,7 @@ body {
padding: 0;
height: 100%;
overflow: hidden;
font-family:
"Segoe UI",
system-ui,
-apple-system,
sans-serif;
font-family: var(--ui-font-family, "Segoe UI", system-ui, -apple-system, sans-serif);
background: var(--bg-primary);
color: var(--text-primary);
}
+449 -1
View File
@@ -1,4 +1,9 @@
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { get } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
import { claudeStore } from "$lib/stores/claude";
import { searchState } from "$lib/stores/search";
import { characterState } from "$lib/stores/character";
import {
slashCommands,
parseSlashCommand,
@@ -40,6 +45,28 @@ vi.mock("$lib/stores/character", () => ({
vi.mock("$lib/tauri", () => ({
setSkipNextGreeting: vi.fn(),
updateDiscordRpc: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("$lib/stores/conversations", () => ({
conversationsStore: {
activeConversation: { subscribe: vi.fn() },
},
}));
vi.mock("$lib/stores/config", () => ({
configStore: {
getConfig: vi.fn().mockReturnValue({
auto_granted_tools: [],
model: "claude-sonnet",
api_key: null,
custom_instructions: null,
mcp_servers_json: null,
use_worktree: false,
disable_1m_context: false,
max_output_tokens: null,
}),
},
}));
vi.mock("$lib/stores/search", () => ({
@@ -415,4 +442,425 @@ describe("slashCommands", () => {
expect(result).toBeUndefined();
});
});
describe("command execute functions", () => {
let getMock: ReturnType<typeof vi.fn>;
let invokeMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
getMock = vi.mocked(get);
invokeMock = vi.mocked(invoke);
});
describe("/clear execute", () => {
it("clears terminal and shows confirmation message", () => {
const clearCmd = slashCommands.find((cmd) => cmd.name === "clear")!;
clearCmd.execute("");
expect(claudeStore.clearTerminal).toHaveBeenCalledWith();
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "Terminal cleared");
});
});
describe("/help execute", () => {
it("shows available commands header", () => {
const helpCmd = slashCommands.find((cmd) => cmd.name === "help")!;
helpCmd.execute("");
expect(claudeStore.addLine).toHaveBeenCalledWith(
"system",
expect.stringContaining("Available commands:")
);
});
it("includes all command usages in help text", () => {
const helpCmd = slashCommands.find((cmd) => cmd.name === "help")!;
helpCmd.execute("");
const callArgs = vi.mocked(claudeStore.addLine).mock.calls[0];
const helpText = callArgs[1] as string;
expect(helpText).toContain("/cd");
expect(helpText).toContain("/clear");
expect(helpText).toContain("/help");
expect(helpText).toContain("/search");
expect(helpText).toContain("/new");
expect(helpText).toContain("/summarise");
expect(helpText).toContain("/skill");
});
it("includes command descriptions in help text", () => {
const helpCmd = slashCommands.find((cmd) => cmd.name === "help")!;
helpCmd.execute("");
const callArgs = vi.mocked(claudeStore.addLine).mock.calls[0];
const helpText = callArgs[1] as string;
expect(helpText).toContain("Change the working directory");
expect(helpText).toContain("Show available slash commands");
});
});
describe("/search execute", () => {
it("clears search when called with empty args", () => {
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
searchCmd.execute("");
expect(searchState.clear).toHaveBeenCalledWith();
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "Search cleared");
});
it("clears search when called with whitespace-only args", () => {
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
searchCmd.execute(" ");
expect(searchState.clear).toHaveBeenCalledWith();
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "Search cleared");
});
it("sets query when called with a search term", () => {
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
searchCmd.execute("hello world");
expect(searchState.setQuery).toHaveBeenCalledWith("hello world");
expect(claudeStore.addLine).toHaveBeenCalledWith("system", 'Searching for: "hello world"');
});
it("trims whitespace from query before setting", () => {
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
searchCmd.execute(" hello ");
expect(searchState.setQuery).toHaveBeenCalledWith("hello");
expect(claudeStore.addLine).toHaveBeenCalledWith("system", 'Searching for: "hello"');
});
});
describe("/cd execute", () => {
it("shows error when no active conversation", async () => {
getMock.mockReturnValue(null);
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
await cdCmd.execute("/some/path");
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
});
it("shows current directory when called with empty args", async () => {
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce("/home/naomi/code");
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
await cdCmd.execute("");
expect(claudeStore.addLine).toHaveBeenCalledWith(
"system",
"Current directory: /home/naomi/code"
);
});
it("shows current directory when called with whitespace-only args", async () => {
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce("/home/naomi/code");
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
await cdCmd.execute(" ");
expect(claudeStore.addLine).toHaveBeenCalledWith(
"system",
"Current directory: /home/naomi/code"
);
});
it("validates path and changes directory on success", async () => {
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce(null);
invokeMock.mockResolvedValue("/validated/path");
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
await cdCmd.execute("/new/path");
expect(invokeMock).toHaveBeenCalledWith(
"validate_directory",
expect.objectContaining({ path: "/new/path" })
);
});
it("shows error when directory change fails", async () => {
getMock.mockReturnValueOnce("conv-123");
invokeMock.mockRejectedValueOnce(new Error("invalid path"));
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
await cdCmd.execute("/bad/path");
expect(claudeStore.addLine).toHaveBeenCalledWith(
"error",
expect.stringContaining("Failed to change directory:")
);
expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000);
});
});
describe("/new execute", () => {
it("shows error when no active conversation", async () => {
getMock.mockReturnValue(null);
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
await newCmd.execute("");
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
});
it("shows error when starting new conversation fails", async () => {
getMock.mockReturnValueOnce("conv-123");
invokeMock.mockRejectedValueOnce(new Error("invoke failed"));
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
await newCmd.execute("");
expect(claudeStore.addLine).toHaveBeenCalledWith(
"error",
expect.stringContaining("Failed to start new conversation:")
);
expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000);
});
});
describe("/summarise execute", () => {
it("shows error when no active conversation", async () => {
getMock.mockReturnValue(null);
const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise")!;
await summariseCmd.execute("");
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
});
it("sends a summary prompt when there is an active conversation", async () => {
getMock.mockReturnValue("conv-123");
invokeMock.mockResolvedValue(undefined);
const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise")!;
await summariseCmd.execute("");
expect(claudeStore.addLine).toHaveBeenCalledWith(
"system",
"Requesting conversation summary..."
);
expect(invokeMock).toHaveBeenCalledWith(
"send_prompt",
expect.objectContaining({ conversationId: "conv-123" })
);
});
it("shows error when send_prompt invoke fails", async () => {
getMock.mockReturnValue("conv-123");
invokeMock.mockRejectedValue(new Error("network error"));
const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise")!;
await summariseCmd.execute("");
expect(claudeStore.addLine).toHaveBeenCalledWith(
"error",
expect.stringContaining("Failed to request summary:")
);
});
});
describe("/skill execute", () => {
it("shows error when no active conversation", async () => {
getMock.mockReturnValue(null);
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
await skillCmd.execute("onboard-mentee");
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
});
it("lists available skills when called with no name", async () => {
getMock.mockReturnValue("conv-123");
invokeMock.mockResolvedValue(["onboard-mentee", "other-skill"]);
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
await skillCmd.execute("");
expect(invokeMock).toHaveBeenCalledWith("list_skills");
expect(claudeStore.addLine).toHaveBeenCalledWith(
"system",
expect.stringContaining("onboard-mentee")
);
});
it("shows empty message when no skills are found", async () => {
getMock.mockReturnValue("conv-123");
invokeMock.mockResolvedValue([]);
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
await skillCmd.execute("");
expect(claudeStore.addLine).toHaveBeenCalledWith(
"system",
expect.stringContaining("No skills found")
);
});
it("invokes skill when called with a name and no data", async () => {
getMock.mockReturnValue("conv-123");
invokeMock.mockResolvedValue(undefined);
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
await skillCmd.execute("onboard-mentee");
expect(claudeStore.addLine).toHaveBeenCalledWith(
"system",
"Invoking skill: onboard-mentee"
);
expect(invokeMock).toHaveBeenCalledWith(
"send_prompt",
expect.objectContaining({ conversationId: "conv-123" })
);
});
it("invokes skill with additional data in the prompt", async () => {
getMock.mockReturnValue("conv-123");
invokeMock.mockResolvedValue(undefined);
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
await skillCmd.execute("onboard-mentee some extra data");
expect(invokeMock).toHaveBeenCalledWith("send_prompt", {
conversationId: "conv-123",
message: expect.stringContaining("some extra data"),
});
});
it("shows error when listing skills fails", async () => {
getMock.mockReturnValue("conv-123");
invokeMock.mockRejectedValue(new Error("list error"));
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
await skillCmd.execute("");
expect(claudeStore.addLine).toHaveBeenCalledWith(
"error",
expect.stringContaining("Failed to list skills:")
);
});
it("shows error and resets character state when invoking skill fails", async () => {
getMock.mockReturnValue("conv-123");
invokeMock.mockRejectedValue(new Error("invoke error"));
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
await skillCmd.execute("onboard-mentee");
expect(claudeStore.addLine).toHaveBeenCalledWith(
"error",
expect.stringContaining("Failed to invoke skill:")
);
expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000);
});
});
describe("/cd success path", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("changes directory and shows success message", async () => {
getMock
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
.mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory)
.mockReturnValueOnce(null); // get(conversationsStore.activeConversation)
vi.mocked(claudeStore.getConversationHistory).mockReturnValue("");
invokeMock
.mockResolvedValueOnce("/new/path") // validate_directory
.mockResolvedValueOnce(undefined) // stop_claude
.mockResolvedValueOnce(undefined); // start_claude
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
const promise = cdCmd.execute("/new/path");
await vi.runAllTimersAsync();
await promise;
expect(claudeStore.addLine).toHaveBeenCalledWith(
"system",
"Changed directory to: /new/path"
);
expect(characterState.setState).toHaveBeenCalledWith("idle");
});
it("sends context restoration message when conversation history exists", async () => {
getMock
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
.mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory)
.mockReturnValueOnce(null); // get(conversationsStore.activeConversation)
vi.mocked(claudeStore.getConversationHistory).mockReturnValue(
"previous conversation history"
);
invokeMock
.mockResolvedValueOnce("/new/path") // validate_directory
.mockResolvedValueOnce(undefined) // stop_claude
.mockResolvedValueOnce(undefined) // start_claude
.mockResolvedValueOnce(undefined); // send_prompt
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
const promise = cdCmd.execute("/new/path");
await vi.runAllTimersAsync();
await promise;
expect(invokeMock).toHaveBeenCalledWith(
"send_prompt",
expect.objectContaining({
message: expect.stringContaining("previous conversation history"),
})
);
expect(claudeStore.addLine).toHaveBeenCalledWith(
"system",
"Changed directory to: /new/path"
);
});
it("calls updateDiscordRpc when activeConversation is available", async () => {
const activeConv = {
name: "Test Conversation",
model: "claude-sonnet",
startedAt: new Date("2026-03-03T12:00:00Z"),
grantedTools: new Set<string>(),
};
getMock
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
.mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory)
.mockReturnValueOnce(activeConv); // get(conversationsStore.activeConversation)
vi.mocked(claudeStore.getConversationHistory).mockReturnValue("");
invokeMock
.mockResolvedValueOnce("/new/path")
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined);
const { updateDiscordRpc } = await import("$lib/tauri");
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
const promise = cdCmd.execute("/new/path");
await vi.runAllTimersAsync();
await promise;
expect(updateDiscordRpc).toHaveBeenCalledWith(
"Test Conversation",
expect.any(String),
expect.any(Date)
);
});
});
describe("/new success path", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("starts a new conversation and shows success message", async () => {
getMock
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
.mockReturnValueOnce(null); // get(conversationsStore.activeConversation)
invokeMock
.mockResolvedValueOnce("/working/dir") // get_working_directory
.mockResolvedValueOnce(undefined) // interrupt_claude
.mockResolvedValueOnce(undefined); // start_claude
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
const promise = newCmd.execute("");
await vi.runAllTimersAsync();
await promise;
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "New conversation started!");
expect(characterState.setState).toHaveBeenCalledWith("idle");
});
it("calls updateDiscordRpc when activeConversation is available", async () => {
const activeConv = {
name: "My Conv",
model: "claude-sonnet",
startedAt: new Date("2026-03-03T12:00:00Z"),
grantedTools: new Set<string>(["tool1"]),
};
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce(activeConv);
invokeMock
.mockResolvedValueOnce("/working/dir")
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined);
const { updateDiscordRpc } = await import("$lib/tauri");
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
const promise = newCmd.execute("");
await vi.runAllTimersAsync();
await promise;
expect(updateDiscordRpc).toHaveBeenCalledWith(
"My Conv",
expect.any(String),
expect.any(Date)
);
});
});
});
});
+2
View File
@@ -63,6 +63,7 @@ async function changeDirectory(path: string): Promise<void> {
allowed_tools: allAllowedTools,
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
max_output_tokens: config.max_output_tokens ?? null,
},
});
@@ -139,6 +140,7 @@ async function startNewConversation(): Promise<void> {
allowed_tools: allAllowedTools,
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
max_output_tokens: config.max_output_tokens ?? null,
},
});
@@ -0,0 +1,153 @@
/**
* AchievementNotification Component Tests
*
* Tests the rarity classification and colour mapping logic used by the
* AchievementNotification component.
*
* What this component does:
* - Listens for "achievement:unlocked" Tauri events
* - Queues and displays achievement notifications one at a time
* - Each notification shows the achievement's name, icon, description, and rarity
* - A gradient border and badge colour correspond to the achievement's rarity
*
* Manual testing checklist:
* - [ ] Achievement notification slides in from the right
* - [ ] Notification auto-dismisses after 5 seconds
* - [ ] Dismiss button works immediately
* - [ ] Multiple achievements queue and display sequentially
* - [ ] Legendary achievements have a yellow-orange gradient
* - [ ] Epic achievements have a purple-pink gradient
* - [ ] Rare achievements have a blue-indigo gradient
* - [ ] Common achievements have a green-emerald gradient
*/
import { describe, it, expect } from "vitest";
function getAchievementRarity(id: string): string {
if (id === "TokenMaster") return "legendary";
if (["CodeMachine", "Unstoppable"].includes(id)) return "epic";
if (
[
"BlossomingCoder",
"CodeWizard",
"MasterBuilder",
"EnduranceChamp",
"DeepDive",
"CreativeCoder",
].includes(id)
)
return "rare";
return "common";
}
function getRarityColor(rarity: string): string {
switch (rarity) {
case "legendary":
return "from-yellow-400 to-orange-500";
case "epic":
return "from-purple-400 to-pink-500";
case "rare":
return "from-blue-400 to-indigo-500";
default:
return "from-green-400 to-emerald-500";
}
}
// ---
describe("getAchievementRarity", () => {
describe("legendary tier", () => {
it("classifies TokenMaster as legendary", () => {
expect(getAchievementRarity("TokenMaster")).toBe("legendary");
});
});
describe("epic tier", () => {
it("classifies CodeMachine as epic", () => {
expect(getAchievementRarity("CodeMachine")).toBe("epic");
});
it("classifies Unstoppable as epic", () => {
expect(getAchievementRarity("Unstoppable")).toBe("epic");
});
});
describe("rare tier", () => {
it("classifies BlossomingCoder as rare", () => {
expect(getAchievementRarity("BlossomingCoder")).toBe("rare");
});
it("classifies CodeWizard as rare", () => {
expect(getAchievementRarity("CodeWizard")).toBe("rare");
});
it("classifies MasterBuilder as rare", () => {
expect(getAchievementRarity("MasterBuilder")).toBe("rare");
});
it("classifies EnduranceChamp as rare", () => {
expect(getAchievementRarity("EnduranceChamp")).toBe("rare");
});
it("classifies DeepDive as rare", () => {
expect(getAchievementRarity("DeepDive")).toBe("rare");
});
it("classifies CreativeCoder as rare", () => {
expect(getAchievementRarity("CreativeCoder")).toBe("rare");
});
});
describe("common tier", () => {
it("classifies unknown IDs as common", () => {
expect(getAchievementRarity("FirstChat")).toBe("common");
expect(getAchievementRarity("SomeNewAchievement")).toBe("common");
expect(getAchievementRarity("")).toBe("common");
});
});
});
describe("getRarityColor", () => {
it("returns yellow-to-orange gradient for legendary", () => {
expect(getRarityColor("legendary")).toBe("from-yellow-400 to-orange-500");
});
it("returns purple-to-pink gradient for epic", () => {
expect(getRarityColor("epic")).toBe("from-purple-400 to-pink-500");
});
it("returns blue-to-indigo gradient for rare", () => {
expect(getRarityColor("rare")).toBe("from-blue-400 to-indigo-500");
});
it("returns green-to-emerald gradient for common", () => {
expect(getRarityColor("common")).toBe("from-green-400 to-emerald-500");
});
it("falls back to green-to-emerald gradient for unknown rarities", () => {
expect(getRarityColor("mythic")).toBe("from-green-400 to-emerald-500");
expect(getRarityColor("")).toBe("from-green-400 to-emerald-500");
});
describe("end-to-end rarity pipeline", () => {
it("produces the correct colour for a legendary achievement", () => {
const color = getRarityColor(getAchievementRarity("TokenMaster"));
expect(color).toBe("from-yellow-400 to-orange-500");
});
it("produces the correct colour for an epic achievement", () => {
const color = getRarityColor(getAchievementRarity("CodeMachine"));
expect(color).toBe("from-purple-400 to-pink-500");
});
it("produces the correct colour for a rare achievement", () => {
const color = getRarityColor(getAchievementRarity("CodeWizard"));
expect(color).toBe("from-blue-400 to-indigo-500");
});
it("produces the correct colour for a common achievement", () => {
const color = getRarityColor(getAchievementRarity("FirstChat"));
expect(color).toBe("from-green-400 to-emerald-500");
});
});
});
+134
View File
@@ -0,0 +1,134 @@
/**
* CliVersion Component Tests
*
* Tests the version comparison logic used by the CliVersion component,
* which compares the installed CLI version against the supported version.
*
* What this component does:
* - Displays the installed Claude CLI version
* - Displays the highest audited/supported CLI version
* - Shows a warning when the installed version is ahead of or behind supported
*
* Manual testing checklist:
* - [ ] Installed version is fetched and displayed on mount
* - [ ] "current" badge shows in green when versions match
* - [ ] "ahead" badge shows in amber when installed is newer than supported
* - [ ] "behind" badge shows in red when installed is older than supported
* - [ ] Warning message appears for "ahead" and "behind" states
*/
import { describe, it, expect } from "vitest";
const SUPPORTED_CLI_VERSION = "2.1.53";
function compareVersions(a: string, b: string): number {
const aParts = a.split(".").map(Number);
const bParts = b.split(".").map(Number);
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
const aVal = aParts[i] ?? 0;
const bVal = bParts[i] ?? 0;
if (aVal > bVal) return 1;
if (aVal < bVal) return -1;
}
return 0;
}
// ---
describe("SUPPORTED_CLI_VERSION", () => {
it("is defined and non-empty", () => {
expect(SUPPORTED_CLI_VERSION).toBeTruthy();
});
it("matches the expected audited version", () => {
expect(SUPPORTED_CLI_VERSION).toBe("2.1.53");
});
});
describe("compareVersions", () => {
describe("equal versions", () => {
it("returns 0 for identical versions", () => {
expect(compareVersions("1.0.0", "1.0.0")).toBe(0);
});
it("returns 0 for the supported CLI version against itself", () => {
expect(compareVersions(SUPPORTED_CLI_VERSION, SUPPORTED_CLI_VERSION)).toBe(0);
});
it("returns 0 for 0.0.0 vs 0.0.0", () => {
expect(compareVersions("0.0.0", "0.0.0")).toBe(0);
});
});
describe("major version differences", () => {
it("returns 1 when a has a higher major version", () => {
expect(compareVersions("2.0.0", "1.0.0")).toBe(1);
});
it("returns -1 when a has a lower major version", () => {
expect(compareVersions("1.0.0", "2.0.0")).toBe(-1);
});
});
describe("minor version differences", () => {
it("returns 1 when a has a higher minor version", () => {
expect(compareVersions("1.2.0", "1.1.0")).toBe(1);
});
it("returns -1 when a has a lower minor version", () => {
expect(compareVersions("1.1.0", "1.2.0")).toBe(-1);
});
});
describe("patch version differences", () => {
it("returns 1 when a has a higher patch version", () => {
expect(compareVersions("1.0.2", "1.0.1")).toBe(1);
});
it("returns -1 when a has a lower patch version", () => {
expect(compareVersions("1.0.1", "1.0.2")).toBe(-1);
});
});
describe("major version takes precedence", () => {
it("returns 1 when a has a higher major but lower minor", () => {
expect(compareVersions("2.0.0", "1.9.9")).toBe(1);
});
it("returns -1 when a has a lower major but higher minor", () => {
expect(compareVersions("1.9.9", "2.0.0")).toBe(-1);
});
});
describe("unequal segment counts", () => {
it("treats missing segments as 0 (a shorter than b)", () => {
expect(compareVersions("1.0", "1.0.0")).toBe(0);
});
it("treats missing segments as 0 (a longer than b)", () => {
expect(compareVersions("1.0.0", "1.0")).toBe(0);
});
it("correctly compares when a has an extra non-zero segment", () => {
expect(compareVersions("1.0.1", "1.0")).toBe(1);
});
it("correctly compares when b has an extra non-zero segment", () => {
expect(compareVersions("1.0", "1.0.1")).toBe(-1);
});
});
describe("supported CLI version comparisons", () => {
it("returns 1 for a version ahead of supported", () => {
expect(compareVersions("2.2.0", SUPPORTED_CLI_VERSION)).toBe(1);
});
it("returns -1 for a version behind supported", () => {
expect(compareVersions("2.1.0", SUPPORTED_CLI_VERSION)).toBe(-1);
});
it("returns 0 for exactly the supported version", () => {
expect(compareVersions("2.1.53", SUPPORTED_CLI_VERSION)).toBe(0);
});
});
});
+160
View File
@@ -5,6 +5,8 @@
type Theme,
type CustomThemeColors,
applyFontSize,
applyCustomFont,
applyCustomUiFont,
applyCustomThemeColors,
MIN_FONT_SIZE,
MAX_FONT_SIZE,
@@ -56,12 +58,23 @@
show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
max_output_tokens: null,
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
custom_font_path: null,
custom_font_family: null,
custom_ui_font_path: null,
custom_ui_font_family: null,
});
let showCustomThemeEditor = $state(false);
let customFontPathInput = $state("");
let customFontFamilyInput = $state("");
let customFontStatus: string | null = $state(null);
let customUiFontPathInput = $state("");
let customUiFontFamilyInput = $state("");
let customUiFontStatus: string | null = $state(null);
interface AuthStatus {
is_logged_in: boolean;
@@ -87,6 +100,10 @@
configStore.config.subscribe((c) => {
config = { ...c };
customFontPathInput = c.custom_font_path ?? "";
customFontFamilyInput = c.custom_font_family ?? "";
customUiFontPathInput = c.custom_ui_font_path ?? "";
customUiFontFamilyInput = c.custom_ui_font_family ?? "";
});
configStore.isSidebarOpen.subscribe((open) => {
@@ -533,6 +550,25 @@
context window
</p>
</div>
<!-- Max Output Tokens -->
<div class="mb-4">
<label class="block text-sm text-[var(--text-primary)] mb-1" for="max-output-tokens">
Max output tokens
</label>
<input
id="max-output-tokens"
type="number"
min="1"
placeholder="Default (32000)"
bind:value={config.max_output_tokens}
class="w-full px-3 py-2 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)]"
/>
<p class="text-xs text-[var(--text-tertiary)] mt-1">
Sets <code class="font-mono">CLAUDE_CODE_MAX_OUTPUT_TOKENS</code> — increase if responses are
being cut off mid-reply
</p>
</div>
</section>
<!-- Greeting Section -->
@@ -917,6 +953,130 @@
</p>
</div>
<!-- Custom Terminal Font -->
<div class="mb-4">
<span class="block text-sm text-[var(--text-secondary)] mb-2">Custom Terminal Font</span>
<div class="flex flex-col gap-2">
<input
type="text"
bind:value={customFontPathInput}
placeholder="URL or local file path (e.g. /path/to/font.ttf)"
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
/>
<input
type="text"
bind:value={customFontFamilyInput}
placeholder="Font family name (e.g. FiraCode)"
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
/>
<div class="flex gap-2">
<button
onclick={async () => {
customFontStatus = null;
try {
await configStore.setCustomFont(
customFontPathInput || null,
customFontFamilyInput || null
);
customFontStatus = "Font applied!";
} catch (e) {
customFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
}
}}
class="flex-1 px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] transition-colors"
>
Apply
</button>
<button
onclick={async () => {
customFontStatus = null;
customFontPathInput = "";
customFontFamilyInput = "";
try {
await configStore.setCustomFont(null, null);
await applyCustomFont(null, null);
customFontStatus = "Font reset to default.";
} catch (e) {
customFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
}
}}
class="px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-red-400 hover:text-red-400 transition-colors"
>
Reset
</button>
</div>
{#if customFontStatus}
<p class="text-xs text-[var(--text-tertiary)]">{customFontStatus}</p>
{/if}
</div>
<p class="text-xs text-[var(--text-tertiary)] mt-1">
Supports Google Fonts URLs, direct font file URLs, or local file paths. Family name is
required to apply the font.
</p>
</div>
<!-- Custom UI Font -->
<div class="mb-4">
<span class="block text-sm text-[var(--text-secondary)] mb-2">Custom UI Font</span>
<div class="flex flex-col gap-2">
<input
type="text"
bind:value={customUiFontPathInput}
placeholder="URL or local file path (e.g. /path/to/font.ttf)"
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
/>
<input
type="text"
bind:value={customUiFontFamilyInput}
placeholder="Font family name (e.g. Inter)"
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
/>
<div class="flex gap-2">
<button
onclick={async () => {
customUiFontStatus = null;
try {
await configStore.setCustomUiFont(
customUiFontPathInput || null,
customUiFontFamilyInput || null
);
customUiFontStatus = "Font applied!";
} catch (e) {
customUiFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
}
}}
class="flex-1 px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] transition-colors"
>
Apply UI Font
</button>
<button
onclick={async () => {
customUiFontStatus = null;
customUiFontPathInput = "";
customUiFontFamilyInput = "";
try {
await configStore.setCustomUiFont(null, null);
await applyCustomUiFont(null, null);
customUiFontStatus = "Font reset to default.";
} catch (e) {
customUiFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
}
}}
class="px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-red-400 hover:text-red-400 transition-colors"
>
Reset
</button>
</div>
{#if customUiFontStatus}
<p class="text-xs text-[var(--text-tertiary)]">{customUiFontStatus}</p>
{/if}
</div>
<p class="text-xs text-[var(--text-tertiary)] mt-1">
Applies to the entire app interface (menus, labels, buttons). Supports Google Fonts URLs,
direct font file URLs, or local file paths.
</p>
</div>
<!-- Show Thinking Blocks Toggle -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
+111
View File
@@ -0,0 +1,111 @@
/**
* ConversationTabs Component Tests
*
* Tests the connection status colour mapping and unread message detection
* logic used by the ConversationTabs component.
*
* What this component does:
* - Displays one tab per conversation
* - Each tab shows a coloured dot for its connection state
* - Inactive tabs with new messages show an animated blue dot badge
* - Tabs can be renamed by double-clicking
* - Tabs can be reordered by drag-and-drop
* - New tabs created with Ctrl+T, closed with Ctrl+W
*
* Manual testing checklist:
* - [ ] Connected tabs show a green dot
* - [ ] Connecting tabs show a yellow dot
* - [ ] Disconnected tabs show a red dot
* - [ ] Active tab never shows the unread badge
* - [ ] Inactive tab shows blue pulsing dot when it receives new messages
* - [ ] Switching to a tab clears the unread indicator
* - [ ] Double-clicking a tab name enables inline editing
* - [ ] Tabs can be dragged to reorder
*/
import { describe, it, expect } from "vitest";
type ConnectionStatus = "connected" | "connecting" | "disconnected";
function getConnectionStatusColor(status: ConnectionStatus | string): string {
switch (status) {
case "connected":
return "bg-green-500";
case "connecting":
return "bg-yellow-500";
case "disconnected":
return "bg-red-500";
default:
return "bg-gray-500";
}
}
function hasUnreadMessages(
id: string,
conversationLineCount: number,
activeConversationId: string | null,
lastSeenMessageCount: Map<string, number>
): boolean {
if (id === activeConversationId) return false;
const lastSeen = lastSeenMessageCount.get(id) ?? 0;
return conversationLineCount > lastSeen;
}
// ---
describe("getConnectionStatusColor", () => {
it("returns green for connected status", () => {
expect(getConnectionStatusColor("connected")).toBe("bg-green-500");
});
it("returns yellow for connecting status", () => {
expect(getConnectionStatusColor("connecting")).toBe("bg-yellow-500");
});
it("returns red for disconnected status", () => {
expect(getConnectionStatusColor("disconnected")).toBe("bg-red-500");
});
it("returns grey for unknown status (fallback)", () => {
expect(getConnectionStatusColor("error")).toBe("bg-gray-500");
expect(getConnectionStatusColor("")).toBe("bg-gray-500");
});
});
describe("hasUnreadMessages", () => {
it("returns false for the active conversation regardless of message count", () => {
const lastSeen = new Map([["tab-1", 0]]);
expect(hasUnreadMessages("tab-1", 10, "tab-1", lastSeen)).toBe(false);
});
it("returns true when an inactive tab has more messages than last seen", () => {
const lastSeen = new Map([["tab-1", 5]]);
expect(hasUnreadMessages("tab-1", 10, "tab-2", lastSeen)).toBe(true);
});
it("returns false when an inactive tab has no new messages", () => {
const lastSeen = new Map([["tab-1", 10]]);
expect(hasUnreadMessages("tab-1", 10, "tab-2", lastSeen)).toBe(false);
});
it("returns false when an inactive tab has fewer messages than last seen", () => {
const lastSeen = new Map([["tab-1", 15]]);
expect(hasUnreadMessages("tab-1", 10, "tab-2", lastSeen)).toBe(false);
});
it("treats a tab with no last-seen record as having 0 messages seen", () => {
const lastSeen = new Map<string, number>();
// Tab has 1 message but no entry in lastSeen → treated as 0 seen → unread
expect(hasUnreadMessages("tab-1", 1, "tab-2", lastSeen)).toBe(true);
});
it("returns false for an untracked tab with 0 messages", () => {
const lastSeen = new Map<string, number>();
expect(hasUnreadMessages("tab-1", 0, "tab-2", lastSeen)).toBe(false);
});
it("handles null activeConversationId (no active tab)", () => {
const lastSeen = new Map([["tab-1", 3]]);
expect(hasUnreadMessages("tab-1", 10, null, lastSeen)).toBe(true);
});
});
+195
View File
@@ -0,0 +1,195 @@
/**
* HighlightedText Component Tests
*
* Tests the text-splitting logic used by the HighlightedText component,
* which highlights search query matches within a string.
*
* What this component does:
* - Splits text into an array of {text, isMatch} parts
* - Matches are case-insensitive
* - Special regex characters in the query are escaped
* - Non-matching text is preserved verbatim around matches
*
* Manual testing checklist:
* - [ ] Matching text is highlighted (yellow background) in the terminal
* - [ ] Highlighting is case-insensitive
* - [ ] Multiple matches on the same line all get highlighted
* - [ ] Non-matching text renders normally alongside matches
*/
import { describe, it, expect } from "vitest";
interface TextPart {
text: string;
isMatch: boolean;
}
function getHighlightedParts(text: string, query: string): TextPart[] {
if (!query) {
return [{ text, isMatch: false }];
}
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`(${escapedQuery})`, "gi");
const parts: TextPart[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push({
text: text.slice(lastIndex, match.index),
isMatch: false,
});
}
parts.push({
text: match[1],
isMatch: true,
});
lastIndex = regex.lastIndex;
}
if (lastIndex < text.length) {
parts.push({
text: text.slice(lastIndex),
isMatch: false,
});
}
return parts;
}
// ---
describe("getHighlightedParts", () => {
describe("empty query", () => {
it("returns the whole text as a single non-match when query is empty string", () => {
const result = getHighlightedParts("hello world", "");
expect(result).toEqual([{ text: "hello world", isMatch: false }]);
});
it("returns an empty non-match part when both text and query are empty", () => {
const result = getHighlightedParts("", "");
expect(result).toEqual([{ text: "", isMatch: false }]);
});
});
describe("no match", () => {
it("returns the whole text as a single non-match when query is not found", () => {
const result = getHighlightedParts("hello world", "xyz");
expect(result).toEqual([{ text: "hello world", isMatch: false }]);
});
});
describe("single match", () => {
it("returns three parts for a match in the middle", () => {
const result = getHighlightedParts("hello world foo", "world");
expect(result).toEqual([
{ text: "hello ", isMatch: false },
{ text: "world", isMatch: true },
{ text: " foo", isMatch: false },
]);
});
it("returns two parts for a match at the start", () => {
const result = getHighlightedParts("hello world", "hello");
expect(result).toEqual([
{ text: "hello", isMatch: true },
{ text: " world", isMatch: false },
]);
});
it("returns two parts for a match at the end", () => {
const result = getHighlightedParts("hello world", "world");
expect(result).toEqual([
{ text: "hello ", isMatch: false },
{ text: "world", isMatch: true },
]);
});
it("returns a single match part when the entire text matches", () => {
const result = getHighlightedParts("hello", "hello");
expect(result).toEqual([{ text: "hello", isMatch: true }]);
});
});
describe("multiple matches", () => {
it("returns interleaved match and non-match parts for multiple occurrences", () => {
const result = getHighlightedParts("foo bar foo", "foo");
expect(result).toEqual([
{ text: "foo", isMatch: true },
{ text: " bar ", isMatch: false },
{ text: "foo", isMatch: true },
]);
});
it("handles adjacent matches correctly", () => {
const result = getHighlightedParts("aaa", "a");
expect(result).toEqual([
{ text: "a", isMatch: true },
{ text: "a", isMatch: true },
{ text: "a", isMatch: true },
]);
});
});
describe("case-insensitive matching", () => {
it("matches uppercase query against lowercase text", () => {
const result = getHighlightedParts("hello world", "WORLD");
expect(result).toEqual([
{ text: "hello ", isMatch: false },
{ text: "world", isMatch: true },
]);
});
it("matches lowercase query against uppercase text", () => {
const result = getHighlightedParts("HELLO WORLD", "hello");
expect(result).toEqual([
{ text: "HELLO", isMatch: true },
{ text: " WORLD", isMatch: false },
]);
});
it("preserves the original casing of the matched text", () => {
const result = getHighlightedParts("Hello World", "hello");
const matchPart = result.find((p) => p.isMatch);
expect(matchPart?.text).toBe("Hello");
});
});
describe("special regex character escaping", () => {
it("treats a dot in the query as a literal dot, not a wildcard", () => {
const result = getHighlightedParts("v1.2.3 v123", "1.2");
const matchParts = result.filter((p) => p.isMatch);
expect(matchParts).toHaveLength(1);
expect(matchParts[0].text).toBe("1.2");
});
it("handles a query with parentheses", () => {
const result = getHighlightedParts("fn(args)", "(args)");
expect(result).toEqual([
{ text: "fn", isMatch: false },
{ text: "(args)", isMatch: true },
]);
});
it("handles a query with a plus sign", () => {
const result = getHighlightedParts("a+b=c", "+");
expect(result).toEqual([
{ text: "a", isMatch: false },
{ text: "+", isMatch: true },
{ text: "b=c", isMatch: false },
]);
});
it("handles a query with a question mark", () => {
const result = getHighlightedParts("is it true?", "?");
expect(result).toEqual([
{ text: "is it true", isMatch: false },
{ text: "?", isMatch: true },
]);
});
});
});
+1 -1
View File
@@ -1011,7 +1011,7 @@ User: ${formattedMessage}`;
: "Connect to Claude first..."}
disabled={isSubmitting}
rows={1}
style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px);"
style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px); font-family: var(--terminal-font-family, monospace);"
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none
input-trans-focus disabled:opacity-50 disabled:cursor-not-allowed"
+32 -10
View File
@@ -3,17 +3,22 @@
import { invoke } from "@tauri-apps/api/core";
import Markdown from "./Markdown.svelte";
let memoryFiles: string[] = $state([]);
interface MemoryFileInfo {
path: string;
heading: string | null;
}
interface MemoryFilesResponse {
files: MemoryFileInfo[];
}
let memoryFiles: MemoryFileInfo[] = $state([]);
let selectedFile: string | null = $state(null);
let fileContent: string = $state("");
let isLoading = $state(false);
let error: string | null = $state(null);
let isPanelOpen = $state(false);
interface MemoryFilesResponse {
files: string[];
}
async function loadMemoryFiles() {
isLoading = true;
error = null;
@@ -49,6 +54,10 @@
return path.split("/").pop() || path;
}
function getDisplayName(file: MemoryFileInfo): string {
return file.heading ?? getFileName(file.path);
}
function togglePanel() {
isPanelOpen = !isPanelOpen;
if (isPanelOpen && memoryFiles.length === 0) {
@@ -151,11 +160,12 @@
{:else}
<div class="panel-layout">
<div class="file-list">
{#each memoryFiles as file (file)}
{#each memoryFiles as file (file.path)}
<button
class="file-item"
class:active={selectedFile === file}
onclick={() => loadFileContent(file)}
class:active={selectedFile === file.path}
onclick={() => loadFileContent(file.path)}
title={getFileName(file.path)}
>
<svg
class="file-icon"
@@ -171,7 +181,7 @@
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<span class="file-name">{getFileName(file)}</span>
<span class="file-name">{getDisplayName(file)}</span>
</button>
{/each}
</div>
@@ -179,7 +189,12 @@
<div class="file-viewer">
{#if selectedFile && fileContent}
<div class="viewer-header">
<h4>{getFileName(selectedFile)}</h4>
{#each memoryFiles.filter((f) => f.path === selectedFile) as activeFile (activeFile.path)}
<h4>{getDisplayName(activeFile)}</h4>
{#if activeFile.heading}
<p class="viewer-filename">{getFileName(activeFile.path)}</p>
{/if}
{/each}
</div>
<div class="viewer-content">
<Markdown content={fileContent} />
@@ -438,6 +453,13 @@
color: var(--text-primary);
}
.viewer-filename {
margin: 0.25rem 0 0;
font-size: 0.75rem;
color: var(--text-tertiary);
font-family: monospace;
}
.viewer-content {
flex: 1;
padding: 1.5rem;
@@ -0,0 +1,98 @@
import { describe, it, expect } from "vitest";
// Mirror pure logic functions from MemoryBrowserPanel.svelte
interface MemoryFileInfo {
path: string;
heading: string | null;
}
function getFileName(path: string): string {
return path.split("/").pop() || path;
}
function getDisplayName(file: MemoryFileInfo): string {
return file.heading ?? getFileName(file.path);
}
describe("getFileName", () => {
it("extracts the filename from an absolute Unix path", () => {
expect(getFileName("/home/naomi/.claude/projects/foo/memory/MEMORY.md")).toBe("MEMORY.md");
});
it("extracts the filename from a nested path", () => {
expect(getFileName("/home/naomi/.claude/projects/foo/memory/debugging.md")).toBe(
"debugging.md"
);
});
it("returns the path itself when there is no slash", () => {
expect(getFileName("MEMORY.md")).toBe("MEMORY.md");
});
it("returns the path when the path ends with a slash (empty filename)", () => {
// split('/').pop() returns '' for trailing slash → falls back to full path
expect(getFileName("/some/dir/")).toBe("/some/dir/");
});
it("handles single-component paths", () => {
expect(getFileName("notes.md")).toBe("notes.md");
});
it("handles Windows-style paths passed as Unix strings", () => {
// If the path contains no forward slashes, the whole string is the filename
expect(getFileName("C:\\Users\\naomi\\memory\\file.md")).toBe(
"C:\\Users\\naomi\\memory\\file.md"
);
});
});
describe("getDisplayName", () => {
it("returns the heading when the file has one", () => {
const file: MemoryFileInfo = {
path: "/home/naomi/.claude/projects/foo/memory/MEMORY.md",
heading: "Hikari Desktop - Memory",
};
expect(getDisplayName(file)).toBe("Hikari Desktop - Memory");
});
it("falls back to the filename when heading is null", () => {
const file: MemoryFileInfo = {
path: "/home/naomi/.claude/projects/foo/memory/debugging.md",
heading: null,
};
expect(getDisplayName(file)).toBe("debugging.md");
});
it("falls back to the filename when heading is an empty string stored as null", () => {
const file: MemoryFileInfo = {
path: "/home/naomi/.claude/projects/foo/memory/patterns.md",
heading: null,
};
expect(getDisplayName(file)).toBe("patterns.md");
});
it("returns the heading even when it matches the filename", () => {
const file: MemoryFileInfo = {
path: "/home/naomi/.claude/projects/foo/memory/MEMORY.md",
heading: "MEMORY",
};
expect(getDisplayName(file)).toBe("MEMORY");
});
it("returns a multi-word heading verbatim", () => {
const file: MemoryFileInfo = {
path: "/some/path/foo.md",
heading: "My Detailed Debugging Notes",
};
expect(getDisplayName(file)).toBe("My Detailed Debugging Notes");
});
it("falls back gracefully when path has no directory separators", () => {
const file: MemoryFileInfo = {
path: "lonely-file.md",
heading: null,
};
expect(getDisplayName(file)).toBe("lonely-file.md");
});
});
+7
View File
@@ -107,9 +107,14 @@
show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
max_output_tokens: null,
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
custom_font_path: null,
custom_font_family: null,
custom_ui_font_path: null,
custom_ui_font_family: null,
});
let streamerModeActive = $state(false);
@@ -185,6 +190,7 @@
allowed_tools: allAllowedTools,
use_worktree: currentConfig.use_worktree ?? false,
disable_1m_context: currentConfig.disable_1m_context ?? false,
max_output_tokens: currentConfig.max_output_tokens ?? null,
},
});
@@ -344,6 +350,7 @@
allowed_tools: allAllowedTools,
use_worktree: currentConfig.use_worktree ?? false,
disable_1m_context: currentConfig.disable_1m_context ?? false,
max_output_tokens: currentConfig.max_output_tokens ?? null,
},
});
+89
View File
@@ -0,0 +1,89 @@
/**
* StatusBar Component Tests
*
* Tests the connection status colour and text helpers used by the
* StatusBar component to display the current Claude connection state.
*
* What this component does:
* - Shows a coloured indicator dot for the connection state
* - Shows a text label for the connection state
* - Provides connect/disconnect buttons
* - Contains the working directory input and browse button
* - Houses all toolbar action buttons (settings, stats, panels, etc.)
*
* Manual testing checklist:
* - [ ] Green dot and "Connected" label when Claude is running
* - [ ] Animated yellow dot and "Connecting..." label whilst connecting
* - [ ] Red dot and "Error" label on connection error
* - [ ] Grey dot and "Disconnected" label when not connected
* - [ ] Directory input is hidden when connected, visible when disconnected
* - [ ] Connect button transitions to Disconnect button on connection
*/
import { describe, it, expect } from "vitest";
type ConnectionStatus = "connected" | "connecting" | "disconnected" | "error";
function getStatusColor(connectionStatus: ConnectionStatus): string {
switch (connectionStatus) {
case "connected":
return "bg-green-500";
case "connecting":
return "bg-yellow-500 animate-pulse";
case "error":
return "bg-red-500";
default:
return "bg-gray-500";
}
}
function getStatusText(connectionStatus: ConnectionStatus): string {
switch (connectionStatus) {
case "connected":
return "Connected";
case "connecting":
return "Connecting...";
case "error":
return "Error";
default:
return "Disconnected";
}
}
// ---
describe("getStatusColor", () => {
it("returns green for connected status", () => {
expect(getStatusColor("connected")).toBe("bg-green-500");
});
it("returns animated yellow for connecting status", () => {
expect(getStatusColor("connecting")).toBe("bg-yellow-500 animate-pulse");
});
it("returns red for error status", () => {
expect(getStatusColor("error")).toBe("bg-red-500");
});
it("returns grey for disconnected status", () => {
expect(getStatusColor("disconnected")).toBe("bg-gray-500");
});
});
describe("getStatusText", () => {
it("returns 'Connected' for connected status", () => {
expect(getStatusText("connected")).toBe("Connected");
});
it("returns 'Connecting...' for connecting status", () => {
expect(getStatusText("connecting")).toBe("Connecting...");
});
it("returns 'Error' for error status", () => {
expect(getStatusText("error")).toBe("Error");
});
it("returns 'Disconnected' for disconnected status", () => {
expect(getStatusText("disconnected")).toBe("Disconnected");
});
});
+14 -60
View File
@@ -7,6 +7,7 @@
import Markdown from "./Markdown.svelte";
import HighlightedText from "./HighlightedText.svelte";
import ThinkingBlock from "./ThinkingBlock.svelte";
import ToolCallBlock from "./ToolCallBlock.svelte";
import { searchState, searchQuery } from "$lib/stores/search";
import { clipboardStore } from "$lib/stores/clipboard";
import { shouldHidePaths, maskPaths, showThinkingBlocks } from "$lib/stores/config";
@@ -208,22 +209,6 @@
if (!currentConversationId) return;
await invoke("send_prompt", { conversationId: currentConversationId, message: "/compact" });
}
// Collapsible tool lines
const TOOL_COLLAPSE_THRESHOLD = 60;
let expandedToolLines: Record<string, boolean> = {};
function isToolContentLong(content: string): boolean {
return content.length > TOOL_COLLAPSE_THRESHOLD;
}
function truncateToolContent(content: string): string {
return content.slice(0, TOOL_COLLAPSE_THRESHOLD) + "…";
}
function toggleToolLine(id: string) {
expandedToolLines = { ...expandedToolLines, [id]: !expandedToolLines[id] };
}
</script>
<div
@@ -246,7 +231,7 @@
bind:this={terminalElement}
onscroll={handleScroll}
class="terminal-content h-[calc(100%-76px)] overflow-y-auto p-4 font-mono"
style="font-size: var(--terminal-font-size, 14px);"
style="font-size: var(--terminal-font-size, 14px); font-family: var(--terminal-font-family, monospace);"
>
{#if lines.length === 0}
<div class="terminal-waiting italic">
@@ -258,6 +243,18 @@
{#if showThinking}
<ThinkingBlock content={line.content} timestamp={line.timestamp} />
{/if}
{:else if line.type === "tool"}
<div
style={line.parentToolUseId
? "margin-left: 16px; padding-left: 8px; border-left: 2px solid var(--accent-primary);"
: ""}
>
<ToolCallBlock
toolName={line.toolName ?? null}
content={maskPaths(line.content, hidePaths)}
timestamp={line.timestamp}
/>
</div>
{:else}
<div
class="terminal-line mb-2 {getLineClass(line.type)} relative group"
@@ -296,9 +293,6 @@
{#if getLinePrefix(line.type)}
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
{/if}
{#if line.toolName}
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
{/if}
{#if line.type === "compact-prompt"}
<button class="compact-action-btn" onclick={handleCompact}>
⚡ Compact Conversation
@@ -330,22 +324,6 @@
<span class="copy-text">{copiedMessageId === line.id ? "Copied!" : "Copy"}</span>
</button>
</div>
{:else if line.type === "tool" && isToolContentLong(maskPaths(line.content, hidePaths))}
<span class="tool-collapsible">
<HighlightedText
content={expandedToolLines[line.id]
? maskPaths(line.content, hidePaths)
: truncateToolContent(maskPaths(line.content, hidePaths))}
searchQuery={currentSearchQuery}
/>
<button
class="tool-toggle-btn"
onclick={() => toggleToolLine(line.id)}
title={expandedToolLines[line.id] ? "Collapse" : "Expand to see full content"}
>
{expandedToolLines[line.id] ? "▲" : "▼"}
</button>
</span>
{:else}
<HighlightedText
content={maskPaths(line.content, hidePaths)}
@@ -501,28 +479,4 @@
.terminal-line {
position: relative;
}
.tool-collapsible {
display: inline-flex;
align-items: baseline;
gap: 0.4em;
}
.tool-toggle-btn {
background: none;
border: none;
color: var(--text-tertiary, #6b7280);
cursor: pointer;
font-size: 0.7em;
padding: 0;
line-height: 1;
opacity: 0.7;
transition: opacity 0.15s ease;
font-family: inherit;
}
.tool-toggle-btn:hover {
opacity: 1;
color: var(--terminal-tool, #c084fc);
}
</style>
+141
View File
@@ -0,0 +1,141 @@
<script lang="ts">
interface Props {
toolName: string | null;
content: string;
timestamp: Date;
}
let { toolName, content, timestamp }: Props = $props();
let isExpanded = $state(false);
function toggleExpanded() {
isExpanded = !isExpanded;
}
function formatTime(date: Date): string {
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
});
}
</script>
<div class="tool-block">
<button class="tool-header" onclick={toggleExpanded} type="button">
<span class="tool-timestamp">{formatTime(timestamp)}</span>
<svg
class="tool-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
width="16"
height="16"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
/>
</svg>
{#if toolName}
<span class="tool-name">[{toolName}]</span>
{/if}
<span class="tool-label">{content}</span>
<svg
class="chevron"
class:expanded={isExpanded}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
width="14"
height="14"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if isExpanded}
<div class="tool-content">
{content}
</div>
{/if}
</div>
<style>
.tool-block {
margin-bottom: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 0.375rem;
background: var(--bg-secondary);
opacity: 0.85;
}
.tool-header {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
background: transparent;
cursor: pointer;
color: var(--terminal-tool, #c084fc);
font-size: 0.875rem;
transition: all 0.2s;
}
.tool-header:hover {
background: var(--bg-primary);
color: var(--text-primary);
}
.tool-timestamp {
font-family: monospace;
font-size: 0.75rem;
opacity: 0.7;
color: var(--text-secondary);
flex-shrink: 0;
}
.tool-icon {
flex-shrink: 0;
}
.tool-name {
font-family: monospace;
font-weight: 600;
flex-shrink: 0;
color: var(--terminal-tool-name, #ddd6fe);
}
.tool-label {
flex: 1;
text-align: left;
font-style: italic;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
flex-shrink: 0;
transition: transform 0.2s;
}
.chevron.expanded {
transform: rotate(180deg);
}
.tool-content {
padding: 0.75rem;
border-top: 1px solid var(--border-color);
color: var(--terminal-tool, #c084fc);
font-family: monospace;
font-size: 0.875rem;
font-style: italic;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
</style>
@@ -0,0 +1,242 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { invoke } from "@tauri-apps/api/core";
import { isPermissionGranted } from "@tauri-apps/plugin-notification";
import { setMockInvokeResult } from "../../../vitest.setup";
import { notificationManager } from "./notificationManager";
import { sendTerminalNotification } from "./terminalNotifier";
import { NotificationType, NOTIFICATION_SOUNDS } from "./types";
vi.mock("./soundPlayer", () => ({
soundPlayer: { play: vi.fn().mockResolvedValue(undefined) },
}));
vi.mock("./terminalNotifier", () => ({
sendTerminalNotification: vi.fn(),
}));
describe("NotificationManager", () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
consoleSpy.mockRestore();
consoleWarnSpy.mockRestore();
consoleErrorSpy.mockRestore();
});
describe("notify()", () => {
it("calls the first notification method by default", async () => {
await notificationManager.notify(NotificationType.SUCCESS);
expect(invoke).toHaveBeenCalledWith(
"send_windows_notification",
expect.objectContaining({
title: NOTIFICATION_SOUNDS[NotificationType.SUCCESS].phrase,
})
);
});
it("passes a custom message to the notification", async () => {
await notificationManager.notify(NotificationType.ERROR, "Custom error message");
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
title: NOTIFICATION_SOUNDS[NotificationType.ERROR].phrase,
body: "Custom error message",
});
});
it("falls back to the next method when the first fails", async () => {
setMockInvokeResult("send_windows_notification", new Error("Method 1 failed"));
await notificationManager.notify(NotificationType.SUCCESS);
expect(invoke).toHaveBeenCalledWith("send_windows_toast", expect.any(Object));
});
it("falls back to terminal notification when all methods fail", async () => {
setMockInvokeResult("send_windows_notification", new Error("failed"));
setMockInvokeResult("send_windows_toast", new Error("failed"));
setMockInvokeResult("send_wsl_notification", new Error("failed"));
setMockInvokeResult("send_notify_send", new Error("failed"));
vi.mocked(isPermissionGranted).mockRejectedValueOnce(new Error("Permission check failed"));
await notificationManager.notify(NotificationType.SUCCESS);
expect(sendTerminalNotification).toHaveBeenCalledWith(
NotificationType.SUCCESS,
"Task completed successfully!"
);
});
it("uses the default SUCCESS message when none is provided", async () => {
await notificationManager.notify(NotificationType.SUCCESS);
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
title: expect.any(String),
body: "Task completed successfully!",
});
});
it("uses the default ERROR message", async () => {
await notificationManager.notify(NotificationType.ERROR);
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
title: expect.any(String),
body: "Something went wrong...",
});
});
it("uses the default PERMISSION message", async () => {
await notificationManager.notify(NotificationType.PERMISSION);
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
title: expect.any(String),
body: "Permission needed to continue",
});
});
it("uses the default CONNECTION message", async () => {
await notificationManager.notify(NotificationType.CONNECTION);
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
title: expect.any(String),
body: "Successfully connected to Claude Code",
});
});
it("uses the default TASK_START message", async () => {
await notificationManager.notify(NotificationType.TASK_START);
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
title: expect.any(String),
body: "Starting task...",
});
});
it("uses the default COST_ALERT message", async () => {
await notificationManager.notify(NotificationType.COST_ALERT);
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
title: expect.any(String),
body: "You've exceeded your cost threshold!",
});
});
it("uses the fallback default message for unhandled types", async () => {
await notificationManager.notify(NotificationType.ACHIEVEMENT);
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
title: expect.any(String),
body: "Notification",
});
});
});
describe("helper methods", () => {
it("notifySuccess calls notify with SUCCESS type and default message", async () => {
await notificationManager.notifySuccess();
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
title: NOTIFICATION_SOUNDS[NotificationType.SUCCESS].phrase,
body: "Task completed successfully!",
});
});
it("notifySuccess passes a custom message", async () => {
await notificationManager.notifySuccess("All done!");
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
title: expect.any(String),
body: "All done!",
});
});
it("notifyError calls notify with ERROR type", async () => {
await notificationManager.notifyError();
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
title: NOTIFICATION_SOUNDS[NotificationType.ERROR].phrase,
body: "Something went wrong...",
});
});
it("notifyPermission calls notify with PERMISSION type", async () => {
await notificationManager.notifyPermission();
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
title: NOTIFICATION_SOUNDS[NotificationType.PERMISSION].phrase,
body: "Permission needed to continue",
});
});
it("notifyConnection calls notify with CONNECTION type", async () => {
await notificationManager.notifyConnection();
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
title: NOTIFICATION_SOUNDS[NotificationType.CONNECTION].phrase,
body: "Successfully connected to Claude Code",
});
});
it("notifyTaskStart calls notify with TASK_START type", async () => {
await notificationManager.notifyTaskStart();
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
title: NOTIFICATION_SOUNDS[NotificationType.TASK_START].phrase,
body: "Starting task...",
});
});
it("notifyCostAlert calls notify with COST_ALERT type", async () => {
await notificationManager.notifyCostAlert();
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
title: NOTIFICATION_SOUNDS[NotificationType.COST_ALERT].phrase,
body: "You've exceeded your cost threshold!",
});
});
});
describe("Tauri plugin notification method (Method 4)", () => {
it("sends notification when permission is already granted", async () => {
const { isPermissionGranted, sendNotification } =
await import("@tauri-apps/plugin-notification");
setMockInvokeResult("send_windows_notification", new Error("failed"));
setMockInvokeResult("send_windows_toast", new Error("failed"));
setMockInvokeResult("send_wsl_notification", new Error("failed"));
vi.mocked(isPermissionGranted).mockResolvedValueOnce(true);
await notificationManager.notify(NotificationType.SUCCESS);
expect(sendNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: NOTIFICATION_SOUNDS[NotificationType.SUCCESS].phrase,
})
);
});
it("requests permission and sends notification when not yet granted", async () => {
const { isPermissionGranted, requestPermission, sendNotification } =
await import("@tauri-apps/plugin-notification");
setMockInvokeResult("send_windows_notification", new Error("failed"));
setMockInvokeResult("send_windows_toast", new Error("failed"));
setMockInvokeResult("send_wsl_notification", new Error("failed"));
vi.mocked(isPermissionGranted).mockResolvedValueOnce(false);
vi.mocked(requestPermission).mockResolvedValueOnce("granted");
await notificationManager.notify(NotificationType.SUCCESS);
expect(requestPermission).toHaveBeenCalledWith();
expect(sendNotification).toHaveBeenCalledWith(
expect.objectContaining({ body: "Task completed successfully!" })
);
});
it("falls through to next method when permission is denied", async () => {
const { isPermissionGranted, requestPermission } =
await import("@tauri-apps/plugin-notification");
setMockInvokeResult("send_windows_notification", new Error("failed"));
setMockInvokeResult("send_windows_toast", new Error("failed"));
setMockInvokeResult("send_wsl_notification", new Error("failed"));
vi.mocked(isPermissionGranted).mockResolvedValueOnce(false);
vi.mocked(requestPermission).mockResolvedValueOnce("denied");
setMockInvokeResult("send_notify_send", new Error("failed"));
await notificationManager.notify(NotificationType.SUCCESS);
expect(sendTerminalNotification).toHaveBeenCalledWith(
NotificationType.SUCCESS,
"Task completed successfully!"
);
});
});
});
@@ -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<void> {
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", () => {
+150
View File
@@ -0,0 +1,150 @@
/**
* Notification Rules Tests
*
* Tests the connection status change handler, which fires a connection
* notification sound exactly once per reconnect cycle.
*
* What this module does:
* - Tracks the previous connection status in module-level state
* - Fires a notification only when transitioning from a non-connected
* state (disconnected/connecting) to "connected"
* - Ignores the initial connection (null connected) to avoid noisy
* notifications on app start
* - Provides no-op handlers for tool execution and user messages
* (reserved for future notification rules)
* - cleanupNotificationRules() resets tracking state on teardown
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const { mockNotifyConnection } = vi.hoisted(() => ({
mockNotifyConnection: vi.fn(),
}));
vi.mock("./notificationManager", () => ({
notificationManager: {
notifyConnection: mockNotifyConnection,
},
}));
import {
handleConnectionStatusChange,
handleToolExecution,
handleNewUserMessage,
initializeNotificationRules,
cleanupNotificationRules,
} from "./rules";
// ---
describe("handleConnectionStatusChange", () => {
beforeEach(() => {
mockNotifyConnection.mockReset();
cleanupNotificationRules(); // Reset module-level previousConnectionStatus to null
});
describe("initial connection (null → status)", () => {
it("does not notify on first connection (null → connected)", () => {
// previousConnectionStatus is null (falsy), so condition is not met
handleConnectionStatusChange("connected");
expect(mockNotifyConnection).not.toHaveBeenCalled();
});
it("does not notify when disconnecting from initial state (null → disconnected)", () => {
handleConnectionStatusChange("disconnected");
expect(mockNotifyConnection).not.toHaveBeenCalled();
});
it("does not notify when entering connecting from initial state (null → connecting)", () => {
handleConnectionStatusChange("connecting");
expect(mockNotifyConnection).not.toHaveBeenCalled();
});
});
describe("reconnection (disconnected → connected)", () => {
it("notifies when reconnecting after a disconnection", () => {
handleConnectionStatusChange("disconnected");
handleConnectionStatusChange("connected");
expect(mockNotifyConnection).toHaveBeenCalledWith();
});
it("notifies exactly once per reconnect", () => {
handleConnectionStatusChange("disconnected");
handleConnectionStatusChange("connected");
expect(mockNotifyConnection).toHaveBeenCalledTimes(1);
});
});
describe("reconnection (connecting → connected)", () => {
it("notifies when transitioning from connecting to connected", () => {
handleConnectionStatusChange("connecting");
handleConnectionStatusChange("connected");
expect(mockNotifyConnection).toHaveBeenCalledWith();
});
});
describe("already connected (connected → connected)", () => {
it("does not notify when already connected", () => {
handleConnectionStatusChange("disconnected");
handleConnectionStatusChange("connected"); // First connection — notifies
mockNotifyConnection.mockReset();
handleConnectionStatusChange("connected"); // Second — same status, no notify
expect(mockNotifyConnection).not.toHaveBeenCalled();
});
});
describe("disconnecting (connected → disconnected)", () => {
it("does not notify when disconnecting", () => {
handleConnectionStatusChange("disconnected");
handleConnectionStatusChange("connected");
mockNotifyConnection.mockReset();
handleConnectionStatusChange("disconnected");
expect(mockNotifyConnection).not.toHaveBeenCalled();
});
});
describe("multiple reconnect cycles", () => {
it("notifies once per reconnect cycle", () => {
// First cycle
handleConnectionStatusChange("disconnected");
handleConnectionStatusChange("connected");
expect(mockNotifyConnection).toHaveBeenCalledTimes(1);
mockNotifyConnection.mockReset();
// Second cycle
handleConnectionStatusChange("disconnected");
handleConnectionStatusChange("connected");
expect(mockNotifyConnection).toHaveBeenCalledTimes(1);
});
});
});
describe("cleanupNotificationRules", () => {
it("resets state so the next connection is treated as the first", () => {
// Establish a known previous status
handleConnectionStatusChange("disconnected");
// Now cleanup
cleanupNotificationRules();
// After cleanup, previousConnectionStatus is null again
// So the next "connected" should NOT notify (treated as initial connection)
handleConnectionStatusChange("connected");
expect(mockNotifyConnection).not.toHaveBeenCalled();
});
});
describe("no-op handlers", () => {
it("handleToolExecution does not throw", () => {
expect(() => handleToolExecution("Bash")).not.toThrow();
});
it("handleNewUserMessage does not throw", () => {
expect(() => handleNewUserMessage()).not.toThrow();
});
it("initializeNotificationRules does not throw", () => {
expect(() => initializeNotificationRules()).not.toThrow();
});
});
@@ -0,0 +1,64 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NotificationType } from "./types";
import { claudeStore } from "$lib/stores/claude";
import { sendTerminalNotification } from "./terminalNotifier";
vi.mock("$lib/stores/claude", () => ({
claudeStore: {
addLine: vi.fn(),
},
}));
describe("sendTerminalNotification", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("adds a system line for success type with sparkle emoji", () => {
sendTerminalNotification(NotificationType.SUCCESS);
expect(claudeStore.addLine).toHaveBeenCalledWith("system", expect.stringContaining("✨"));
});
it("adds a system line for error type with cross emoji", () => {
sendTerminalNotification(NotificationType.ERROR);
expect(claudeStore.addLine).toHaveBeenCalledWith("system", expect.stringContaining("❌"));
});
it("adds a system line for permission type with lock emoji", () => {
sendTerminalNotification(NotificationType.PERMISSION);
expect(claudeStore.addLine).toHaveBeenCalledWith("system", expect.stringContaining("🔐"));
});
it("adds a system line for connection type with link emoji", () => {
sendTerminalNotification(NotificationType.CONNECTION);
expect(claudeStore.addLine).toHaveBeenCalledWith("system", expect.stringContaining("🔗"));
});
it("adds a system line for task_start type with rocket emoji", () => {
sendTerminalNotification(NotificationType.TASK_START);
expect(claudeStore.addLine).toHaveBeenCalledWith("system", expect.stringContaining("🚀"));
});
it("includes the optional message in the notification", () => {
sendTerminalNotification(NotificationType.SUCCESS, "Custom message text");
expect(claudeStore.addLine).toHaveBeenCalledWith(
"system",
expect.stringContaining("Custom message text")
);
});
it("includes the sound phrase as the notification title", () => {
sendTerminalNotification(NotificationType.SUCCESS);
expect(claudeStore.addLine).toHaveBeenCalledWith(
"system",
expect.stringContaining("I'm done!")
);
});
});
@@ -0,0 +1,58 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { testAllNotifications } from "./testNotifications";
vi.mock("./notificationManager", () => ({
notificationManager: {
notifySuccess: vi.fn().mockResolvedValue(undefined),
notifyError: vi.fn().mockResolvedValue(undefined),
notifyPermission: vi.fn().mockResolvedValue(undefined),
notifyConnection: vi.fn().mockResolvedValue(undefined),
notifyTaskStart: vi.fn().mockResolvedValue(undefined),
},
}));
describe("testNotifications", () => {
describe("window assignment", () => {
it("assigns testAllNotifications to window.testNotifications", async () => {
// The module-level if block runs on import — reimport to ensure it ran
await import("./testNotifications");
expect((window as unknown as { testNotifications: unknown }).testNotifications).toBe(
testAllNotifications
);
});
});
describe("testAllNotifications", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("is an async function", () => {
expect(typeof testAllNotifications).toBe("function");
});
it("schedules all five notification type calls", async () => {
const { notificationManager } = await import("./notificationManager");
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await testAllNotifications();
await vi.runAllTimersAsync();
expect(notificationManager.notifySuccess).toHaveBeenCalledWith("Test task completed!");
expect(notificationManager.notifyError).toHaveBeenCalledWith("Test error occurred!");
expect(notificationManager.notifyPermission).toHaveBeenCalledWith("Test permission request!");
expect(notificationManager.notifyConnection).toHaveBeenCalledWith(
"Test connection established!"
);
expect(notificationManager.notifyTaskStart).toHaveBeenCalledWith("Test task starting!");
consoleLogSpy.mockRestore();
});
});
});
@@ -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<ReturnType<typeof platform>>);
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<ReturnType<typeof platform>>);
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();
});
});
+48
View File
@@ -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();
});
});
});
+185
View File
@@ -0,0 +1,185 @@
import { describe, it, expect, vi } from "vitest";
import { get } from "svelte/store";
import { setMockInvokeResult, emitMockEvent } from "../../../vitest.setup";
import {
achievementsStore,
unlockedAchievements,
lockedAchievements,
achievementsByRarity,
achievementProgress,
initAchievementsListener,
} from "./achievements";
import type { AchievementUnlockedEvent } from "$lib/types/achievements";
import { playAchievementSound } from "$lib/sounds/achievement";
vi.mock("$lib/sounds/achievement", () => ({
playAchievementSound: vi.fn(),
}));
// Helper to build a minimal unlock event
function makeEvent(id: AchievementUnlockedEvent["achievement"]["id"]): AchievementUnlockedEvent {
return {
achievement: {
id,
name: "Test",
description: "Test achievement",
icon: "🏆",
unlocked_at: null,
},
};
}
describe("achievementsStore initial state", () => {
it("all achievements start as locked", () => {
const state = get(achievementsStore);
expect(state.achievements["FirstSteps"].unlocked).toBe(false);
expect(state.achievements["GrowingStrong"].unlocked).toBe(false);
});
it("totalUnlocked starts at 0", () => {
expect(get(achievementsStore).totalUnlocked).toBe(0);
});
it("lastUnlocked starts as null", () => {
expect(get(achievementsStore).lastUnlocked).toBeNull();
});
});
describe("derived stores initial state", () => {
it("unlockedAchievements is initially empty", () => {
expect(get(unlockedAchievements)).toEqual([]);
});
it("lockedAchievements contains all achievements initially", () => {
const locked = get(lockedAchievements);
const total = Object.keys(get(achievementsStore).achievements).length;
expect(locked.length).toBe(total);
});
it("achievementsByRarity groups achievements into rarity buckets", () => {
const byRarity = get(achievementsByRarity);
expect(byRarity.common).toBeInstanceOf(Array);
expect(byRarity.rare).toBeInstanceOf(Array);
expect(byRarity.epic).toBeInstanceOf(Array);
expect(byRarity.legendary).toBeInstanceOf(Array);
expect(byRarity.common.length).toBeGreaterThan(0);
});
it("achievementProgress shows zero unlocked initially", () => {
const progress = get(achievementProgress);
expect(progress.unlocked).toBe(0);
expect(progress.total).toBeGreaterThan(0);
expect(progress.percentage).toBe(0);
});
});
describe("achievementsStore.unlockAchievement", () => {
it("marks the achievement as unlocked and updates totalUnlocked", () => {
achievementsStore.unlockAchievement(makeEvent("GrowingStrong"));
const state = get(achievementsStore);
expect(state.achievements["GrowingStrong"].unlocked).toBe(true);
expect(state.totalUnlocked).toBe(1);
expect(state.lastUnlocked?.id).toBe("GrowingStrong");
});
it("sets unlockedAt from the event's unlocked_at timestamp", () => {
achievementsStore.unlockAchievement({
achievement: {
id: "BlossomingCoder",
name: "Blossoming Coder",
description: "100k tokens",
icon: "🌸",
unlocked_at: "2026-01-15T12:00:00.000Z",
},
});
const state = get(achievementsStore);
expect(state.achievements["BlossomingCoder"].unlockedAt).toBeInstanceOf(Date);
});
it("does nothing when the achievement is already unlocked", () => {
achievementsStore.unlockAchievement(makeEvent("TokenMaster"));
const firstTotal = get(achievementsStore).totalUnlocked;
achievementsStore.unlockAchievement(makeEvent("TokenMaster"));
expect(get(achievementsStore).totalUnlocked).toBe(firstTotal);
});
it("calls playAchievementSound when playSound is true (default)", () => {
achievementsStore.unlockAchievement(makeEvent("TokenBillionaire"));
expect(playAchievementSound).toHaveBeenCalled();
});
it("does not call playAchievementSound when playSound is false", () => {
achievementsStore.unlockAchievement(makeEvent("TokenTreasure"), false);
expect(playAchievementSound).not.toHaveBeenCalled();
});
it("logs an error when playAchievementSound throws", () => {
vi.mocked(playAchievementSound).mockImplementationOnce(() => {
throw new Error("Sound failed");
});
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
achievementsStore.unlockAchievement(makeEvent("HelloWorld"));
expect(consoleSpy).toHaveBeenCalledWith("Failed to play achievement sound:", expect.any(Error));
consoleSpy.mockRestore();
});
});
describe("derived stores after unlocks", () => {
it("unlockedAchievements includes previously unlocked achievements", () => {
const unlocked = get(unlockedAchievements);
expect(unlocked.some((a) => a.id === "GrowingStrong")).toBe(true);
});
it("lockedAchievements excludes previously unlocked achievements", () => {
const locked = get(lockedAchievements);
expect(locked.some((a) => a.id === "GrowingStrong")).toBe(false);
});
it("achievementProgress reflects the current unlocked count", () => {
const progress = get(achievementProgress);
expect(progress.unlocked).toBeGreaterThan(0);
expect(progress.percentage).toBeGreaterThan(0);
});
});
describe("achievementsStore.updateProgress", () => {
it("updates the progress value for an achievement", () => {
achievementsStore.updateProgress("FirstMessage", 50);
expect(get(achievementsStore).achievements["FirstMessage"].progress).toBe(50);
});
});
describe("achievementsStore.reset", () => {
it("resets totalUnlocked to 0 and lastUnlocked to null", () => {
achievementsStore.reset();
const state = get(achievementsStore);
expect(state.totalUnlocked).toBe(0);
expect(state.lastUnlocked).toBeNull();
});
});
describe("initAchievementsListener", () => {
it("unlocks an achievement when the achievement:unlocked event fires", async () => {
await initAchievementsListener();
emitMockEvent("achievement:unlocked", makeEvent("FirstSteps"));
expect(get(achievementsStore).achievements["FirstSteps"].unlocked).toBe(true);
});
it("loads saved achievements from the backend without playing sounds", async () => {
setMockInvokeResult("load_saved_achievements", [makeEvent("ConversationStarter")]);
await initAchievementsListener();
expect(get(achievementsStore).achievements["ConversationStarter"].unlocked).toBe(true);
expect(playAchievementSound).not.toHaveBeenCalled();
});
it("logs an error when loading saved achievements fails", async () => {
setMockInvokeResult("load_saved_achievements", new Error("Storage unavailable"));
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await initAchievementsListener();
expect(consoleSpy).toHaveBeenCalledWith(
"Failed to load saved achievements:",
expect.any(Error)
);
consoleSpy.mockRestore();
});
});
+124
View File
@@ -0,0 +1,124 @@
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);
});
});
+139
View File
@@ -0,0 +1,139 @@
import { describe, it, expect, afterEach } from "vitest";
import { get } from "svelte/store";
import {
claudeStore,
hasPermissionPending,
hasQuestionPending,
isClaudeProcessing,
} from "./claude";
import { conversationsStore } from "./conversations";
import { characterState } from "$lib/stores/character";
import type { PermissionRequest, UserQuestionEvent } from "$lib/types/messages";
describe("claudeStore (compatibility wrapper)", () => {
afterEach(() => {
conversationsStore.revokeAllTools();
conversationsStore.clearPermission();
conversationsStore.clearQuestion();
conversationsStore.setConnectionStatus("disconnected");
characterState.setState("idle");
});
describe("getGrantedTools", () => {
it("returns an empty array when no tools are granted", () => {
expect(claudeStore.getGrantedTools()).toEqual([]);
});
it("returns granted tools as an array", () => {
conversationsStore.grantTool("Read");
conversationsStore.grantTool("Write");
const tools = claudeStore.getGrantedTools();
expect(tools).toContain("Read");
expect(tools).toContain("Write");
});
});
describe("reset", () => {
it("clears terminal lines and resets processing state", () => {
conversationsStore.setProcessing(true);
conversationsStore.grantTool("Edit");
claudeStore.reset();
expect(get(conversationsStore.isProcessing)).toBe(false);
expect(claudeStore.getGrantedTools()).toEqual([]);
});
});
});
describe("hasPermissionPending derived store", () => {
afterEach(() => {
conversationsStore.clearPermission();
});
it("is false when there are no pending permissions", () => {
expect(get(hasPermissionPending)).toBe(false);
});
it("is true when there is a pending permission request", () => {
const request: PermissionRequest = {
id: "perm-1",
tool: "Bash",
description: "Run a shell command",
input: { command: "ls" },
};
conversationsStore.requestPermission(request);
expect(get(hasPermissionPending)).toBe(true);
conversationsStore.clearPermission();
});
});
describe("hasQuestionPending derived store", () => {
afterEach(() => {
conversationsStore.clearQuestion();
});
it("is false when there is no pending question", () => {
expect(get(hasQuestionPending)).toBe(false);
});
it("is true when there is a pending question", () => {
const question: UserQuestionEvent = {
id: "q-1",
question: "Which approach do you prefer?",
options: [{ label: "A" }, { label: "B" }],
multi_select: false,
};
conversationsStore.requestQuestion(question);
expect(get(hasQuestionPending)).toBe(true);
});
});
describe("isClaudeProcessing derived store", () => {
afterEach(() => {
conversationsStore.setConnectionStatus("disconnected");
characterState.setState("idle");
});
it("is false when disconnected regardless of character state", () => {
conversationsStore.setConnectionStatus("disconnected");
characterState.setState("thinking");
expect(get(isClaudeProcessing)).toBe(false);
});
it("is false when connected but in idle state", () => {
conversationsStore.setConnectionStatus("connected");
characterState.setState("idle");
expect(get(isClaudeProcessing)).toBe(false);
});
it("is true when connected and in thinking state", () => {
conversationsStore.setConnectionStatus("connected");
characterState.setState("thinking");
expect(get(isClaudeProcessing)).toBe(true);
});
it("is true when connected and in coding state", () => {
conversationsStore.setConnectionStatus("connected");
characterState.setState("coding");
expect(get(isClaudeProcessing)).toBe(true);
});
it("is true when connected and in searching state", () => {
conversationsStore.setConnectionStatus("connected");
characterState.setState("searching");
expect(get(isClaudeProcessing)).toBe(true);
});
});
+675
View File
@@ -0,0 +1,675 @@
/**
* Clipboard Store Tests
*
* Tests the pure helper functions and store actions from the clipboard store:
* - detectLanguage: identifies programming language from code content
* - formatTimestamp: converts an ISO timestamp to a relative time string
* - Store actions: loadEntries, captureClipboard, deleteEntry, togglePin, etc.
* - Derived stores: filteredEntries (language + search filtering), languages
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { get } from "svelte/store";
import { clipboardStore } from "$lib/stores/clipboard";
import { setMockInvokeResult } from "../../../vitest.setup";
describe("detectLanguage", () => {
describe("TypeScript detection", () => {
it("detects import statements", () => {
expect(clipboardStore.detectLanguage("import React from 'react';")).toBe("typescript");
});
it("detects export statements", () => {
expect(clipboardStore.detectLanguage("export function foo() {}")).toBe("typescript");
});
it("detects const declarations", () => {
expect(clipboardStore.detectLanguage("const x = 1;")).toBe("typescript");
});
it("detects interface declarations", () => {
expect(clipboardStore.detectLanguage("interface Foo {\n bar: string;\n}")).toBe(
"typescript"
);
});
it("detects type aliases", () => {
expect(clipboardStore.detectLanguage("type MyType = string | number;")).toBe("typescript");
});
});
describe("Python detection", () => {
it("detects def statements", () => {
expect(clipboardStore.detectLanguage("def foo():\n pass")).toBe("python");
});
it("detects async def statements", () => {
expect(clipboardStore.detectLanguage("async def bar():\n pass")).toBe("python");
});
it("detects from imports", () => {
expect(clipboardStore.detectLanguage("from os import path")).toBe("python");
});
it("detects the __name__ guard", () => {
expect(clipboardStore.detectLanguage("if __name__ == '__main__':\n main()")).toBe("python");
});
});
describe("Rust detection", () => {
it("detects fn declarations", () => {
expect(clipboardStore.detectLanguage('fn main() {\n println!("hello");\n}')).toBe("rust");
});
it("detects impl blocks", () => {
expect(clipboardStore.detectLanguage("impl Foo {\n pub fn new() -> Self {}\n}")).toBe(
"rust"
);
});
it("detects struct declarations", () => {
expect(clipboardStore.detectLanguage("struct Point {\n x: f64,\n y: f64,\n}")).toBe("rust");
});
it("detects enum declarations", () => {
expect(clipboardStore.detectLanguage("enum Direction {\n North,\n South,\n}")).toBe("rust");
});
it("detects mod declarations", () => {
expect(clipboardStore.detectLanguage("mod utils;")).toBe("rust");
});
it("detects pub visibility", () => {
expect(clipboardStore.detectLanguage("pub fn exported() {}")).toBe("rust");
});
});
describe("Go detection", () => {
it("detects package declarations", () => {
expect(clipboardStore.detectLanguage("package main")).toBe("go");
});
it("detects func declarations", () => {
expect(clipboardStore.detectLanguage("func main() {}")).toBe("go");
});
});
describe("PHP detection", () => {
it("detects the PHP open tag", () => {
expect(clipboardStore.detectLanguage("<?php\necho 'hello';")).toBe("php");
});
});
describe("SQL detection", () => {
it("detects SELECT statements", () => {
expect(clipboardStore.detectLanguage("SELECT * FROM users WHERE id = 1")).toBe("sql");
});
it("detects INSERT statements", () => {
expect(clipboardStore.detectLanguage("INSERT INTO users (name) VALUES ('Alice')")).toBe(
"sql"
);
});
it("detects CREATE statements", () => {
expect(clipboardStore.detectLanguage("CREATE TABLE users (id INT PRIMARY KEY)")).toBe("sql");
});
it("detects SQL case-insensitively", () => {
expect(clipboardStore.detectLanguage("select * from users")).toBe("sql");
});
});
describe("HTML detection", () => {
it("detects DOCTYPE declarations", () => {
expect(clipboardStore.detectLanguage("<!DOCTYPE html>")).toBe("html");
});
it("detects html tags", () => {
expect(clipboardStore.detectLanguage("<html><body></body></html>")).toBe("html");
});
it("detects div tags", () => {
expect(clipboardStore.detectLanguage("<div class='foo'>bar</div>")).toBe("html");
});
it("detects span tags", () => {
expect(clipboardStore.detectLanguage("<span>text</span>")).toBe("html");
});
});
describe("JSON detection", () => {
it("detects JSON object syntax", () => {
expect(clipboardStore.detectLanguage('{"name": "test", "value": 42}')).toBe("json");
});
it("detects JSON with hyphenated keys", () => {
expect(clipboardStore.detectLanguage('{"my-key": "value"}')).toBe("json");
});
});
describe("YAML detection", () => {
it("detects YAML document separator", () => {
expect(clipboardStore.detectLanguage("---\nkey: value\nother: 123")).toBe("yaml");
});
});
describe("C detection", () => {
it("detects #include directives", () => {
expect(clipboardStore.detectLanguage("#include <stdio.h>\nint main() {}")).toBe("c");
});
it("detects #define directives", () => {
expect(clipboardStore.detectLanguage("#define MAX 100")).toBe("c");
});
it("detects #ifdef directives", () => {
expect(clipboardStore.detectLanguage("#ifdef DEBUG\n// debug code\n#endif")).toBe("c");
});
});
describe("Java detection", () => {
it("detects public class declarations", () => {
expect(clipboardStore.detectLanguage("public class Foo {\n // ...\n}")).toBe("java");
});
it("detects private static methods", () => {
expect(clipboardStore.detectLanguage("private static void helper() {}")).toBe("java");
});
it("detects protected interface declarations", () => {
expect(clipboardStore.detectLanguage("protected interface Bar {}")).toBe("java");
});
});
describe("Bash detection", () => {
it("detects shell variable assignments", () => {
expect(clipboardStore.detectLanguage("$HOME=/usr/local")).toBe("bash");
});
it("detects variable assignments with underscores", () => {
expect(clipboardStore.detectLanguage("$MY_VAR=some_value")).toBe("bash");
});
});
describe("unknown content", () => {
it("returns null for plain text", () => {
expect(clipboardStore.detectLanguage("Hello, world!")).toBeNull();
});
it("returns null for empty string", () => {
expect(clipboardStore.detectLanguage("")).toBeNull();
});
it("returns null for mathematical expressions", () => {
expect(clipboardStore.detectLanguage("1 + 1 = 2")).toBeNull();
});
it("returns null for a markdown heading", () => {
expect(clipboardStore.detectLanguage("# My Heading\nSome text")).toBeNull();
});
});
});
describe("formatTimestamp", () => {
const NOW = new Date("2026-03-03T12:00:00.000Z");
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
describe("'Just now' threshold (< 1 minute)", () => {
it("returns 'Just now' for a timestamp 30 seconds ago", () => {
const ts = new Date("2026-03-03T11:59:30.000Z").toISOString();
expect(clipboardStore.formatTimestamp(ts)).toBe("Just now");
});
it("returns 'Just now' for the current moment", () => {
const ts = NOW.toISOString();
expect(clipboardStore.formatTimestamp(ts)).toBe("Just now");
});
it("returns 'Just now' for a timestamp 59 seconds ago", () => {
const ts = new Date("2026-03-03T11:59:01.000Z").toISOString();
expect(clipboardStore.formatTimestamp(ts)).toBe("Just now");
});
});
describe("'Xm ago' threshold (159 minutes)", () => {
it("returns '1m ago' at exactly 1 minute", () => {
const ts = new Date("2026-03-03T11:59:00.000Z").toISOString();
expect(clipboardStore.formatTimestamp(ts)).toBe("1m ago");
});
it("returns '5m ago' for a timestamp 5 minutes ago", () => {
const ts = new Date("2026-03-03T11:55:00.000Z").toISOString();
expect(clipboardStore.formatTimestamp(ts)).toBe("5m ago");
});
it("returns '59m ago' just before the 1-hour threshold", () => {
const ts = new Date("2026-03-03T11:01:00.000Z").toISOString();
expect(clipboardStore.formatTimestamp(ts)).toBe("59m ago");
});
});
describe("'Xh ago' threshold (123 hours)", () => {
it("returns '1h ago' at exactly 1 hour", () => {
const ts = new Date("2026-03-03T11:00:00.000Z").toISOString();
expect(clipboardStore.formatTimestamp(ts)).toBe("1h ago");
});
it("returns '2h ago' for a timestamp 2 hours ago", () => {
const ts = new Date("2026-03-03T10:00:00.000Z").toISOString();
expect(clipboardStore.formatTimestamp(ts)).toBe("2h ago");
});
it("returns '23h ago' just before the 1-day threshold", () => {
const ts = new Date("2026-03-02T13:00:00.000Z").toISOString();
expect(clipboardStore.formatTimestamp(ts)).toBe("23h ago");
});
});
describe("'Xd ago' threshold (16 days)", () => {
it("returns '1d ago' at exactly 1 day", () => {
const ts = new Date("2026-03-02T12:00:00.000Z").toISOString();
expect(clipboardStore.formatTimestamp(ts)).toBe("1d ago");
});
it("returns '3d ago' for a timestamp 3 days ago", () => {
const ts = new Date("2026-02-28T12:00:00.000Z").toISOString();
expect(clipboardStore.formatTimestamp(ts)).toBe("3d ago");
});
it("returns '6d ago' just before the 7-day threshold", () => {
const ts = new Date("2026-02-25T12:00:00.000Z").toISOString();
expect(clipboardStore.formatTimestamp(ts)).toBe("6d ago");
});
});
describe("locale date string (7+ days ago)", () => {
it("returns a locale date string for a 2-week-old timestamp", () => {
const ts = new Date("2026-02-17T12:00:00.000Z").toISOString();
const result = clipboardStore.formatTimestamp(ts);
// Should not be a relative time string
expect(result).not.toContain("m ago");
expect(result).not.toContain("h ago");
expect(result).not.toContain("d ago");
expect(result).not.toBe("Just now");
});
it("returns a locale date string for a 1-month-old timestamp", () => {
const ts = new Date("2026-02-03T12:00:00.000Z").toISOString();
const result = clipboardStore.formatTimestamp(ts);
expect(result).not.toContain("ago");
});
});
});
describe("clipboardStore - derived stores", () => {
const makeEntry = (overrides: Record<string, unknown> = {}) => ({
id: "entry-1",
content: "const x = 1;",
language: "typescript",
source: "test.ts",
timestamp: "2026-03-03T12:00:00.000Z",
is_pinned: false,
...overrides,
});
beforeEach(() => {
clipboardStore.entries.set([]);
clipboardStore.searchQuery.set("");
clipboardStore.languageFilter.set(null);
});
describe("filteredEntries - language filter", () => {
it("returns all entries when no language filter is set", () => {
clipboardStore.entries.set([
makeEntry({ id: "1", language: "typescript" }),
makeEntry({ id: "2", language: "python" }),
]);
const filtered = get(clipboardStore.filteredEntries);
expect(filtered).toHaveLength(2);
});
it("filters entries by language", () => {
clipboardStore.entries.set([
makeEntry({ id: "1", language: "typescript" }),
makeEntry({ id: "2", language: "python" }),
makeEntry({ id: "3", language: "typescript" }),
]);
clipboardStore.languageFilter.set("typescript");
const filtered = get(clipboardStore.filteredEntries);
expect(filtered).toHaveLength(2);
expect(filtered.every((e) => e.language === "typescript")).toBe(true);
});
it("returns empty array when filter matches nothing", () => {
clipboardStore.entries.set([makeEntry({ language: "typescript" })]);
clipboardStore.languageFilter.set("rust");
const filtered = get(clipboardStore.filteredEntries);
expect(filtered).toHaveLength(0);
});
});
describe("filteredEntries - search query", () => {
it("filters by content (case-insensitive)", () => {
clipboardStore.entries.set([
makeEntry({ id: "1", content: "const HELLO = 1;" }),
makeEntry({ id: "2", content: "let world = 2;" }),
]);
clipboardStore.searchQuery.set("hello");
const filtered = get(clipboardStore.filteredEntries);
expect(filtered).toHaveLength(1);
expect(filtered[0].id).toBe("1");
});
it("filters by language field", () => {
clipboardStore.entries.set([
makeEntry({ id: "1", language: "typescript", content: "unrelated" }),
makeEntry({ id: "2", language: "python", content: "also unrelated" }),
]);
clipboardStore.searchQuery.set("python");
const filtered = get(clipboardStore.filteredEntries);
expect(filtered).toHaveLength(1);
expect(filtered[0].id).toBe("2");
});
it("filters by source field", () => {
clipboardStore.entries.set([
makeEntry({ id: "1", source: "main.rs", content: "fn main() {}" }),
makeEntry({ id: "2", source: "app.ts", content: "const x = 1;" }),
]);
clipboardStore.searchQuery.set("main.rs");
const filtered = get(clipboardStore.filteredEntries);
expect(filtered).toHaveLength(1);
expect(filtered[0].id).toBe("1");
});
it("returns all entries when search query is empty", () => {
clipboardStore.entries.set([makeEntry({ id: "1" }), makeEntry({ id: "2" })]);
clipboardStore.searchQuery.set("");
const filtered = get(clipboardStore.filteredEntries);
expect(filtered).toHaveLength(2);
});
});
describe("languages derived store", () => {
it("returns empty array when no entries", () => {
clipboardStore.entries.set([]);
expect(get(clipboardStore.languages)).toHaveLength(0);
});
it("returns unique sorted languages", () => {
clipboardStore.entries.set([
makeEntry({ language: "typescript" }),
makeEntry({ language: "python" }),
makeEntry({ language: "typescript" }),
makeEntry({ language: "rust" }),
]);
const langs = get(clipboardStore.languages);
expect(langs).toEqual(["python", "rust", "typescript"]);
});
it("excludes entries with null language", () => {
clipboardStore.entries.set([
makeEntry({ language: "typescript" }),
makeEntry({ language: null }),
]);
const langs = get(clipboardStore.languages);
expect(langs).toEqual(["typescript"]);
});
});
});
describe("clipboardStore - setSearchQuery and setLanguageFilter", () => {
it("setSearchQuery updates the searchQuery store", () => {
clipboardStore.setSearchQuery("hello world");
expect(get(clipboardStore.searchQuery)).toBe("hello world");
clipboardStore.setSearchQuery("");
});
it("setLanguageFilter updates the languageFilter store", () => {
clipboardStore.setLanguageFilter("python");
expect(get(clipboardStore.languageFilter)).toBe("python");
clipboardStore.setLanguageFilter(null);
});
it("setLanguageFilter can be reset to null", () => {
clipboardStore.setLanguageFilter("rust");
clipboardStore.setLanguageFilter(null);
expect(get(clipboardStore.languageFilter)).toBeNull();
});
});
describe("clipboardStore - store actions", () => {
const mockEntry = {
id: "entry-1",
content: "const x = 1;",
language: "typescript",
source: "test.ts",
timestamp: "2026-03-03T12:00:00.000Z",
is_pinned: false,
};
beforeEach(() => {
vi.clearAllMocks();
clipboardStore.entries.set([]);
});
describe("loadEntries", () => {
it("loads entries from backend and updates the store", async () => {
setMockInvokeResult("list_clipboard_entries", [mockEntry]);
await clipboardStore.loadEntries();
expect(get(clipboardStore.entries)).toEqual([mockEntry]);
});
it("handles errors gracefully without crashing", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("list_clipboard_entries", new Error("Backend unavailable"));
await clipboardStore.loadEntries();
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Failed to load clipboard entries:",
expect.any(Error)
);
consoleErrorSpy.mockRestore();
});
it("sets isLoading to false after completion", async () => {
setMockInvokeResult("list_clipboard_entries", []);
await clipboardStore.loadEntries();
expect(get(clipboardStore.isLoading)).toBe(false);
});
});
describe("captureClipboard", () => {
it("captures clipboard entry with provided language", async () => {
setMockInvokeResult("capture_clipboard", mockEntry);
setMockInvokeResult("list_clipboard_entries", [mockEntry]);
const result = await clipboardStore.captureClipboard("const x = 1;", "typescript", "test.ts");
expect(result).toEqual(mockEntry);
});
it("auto-detects language when none provided", async () => {
setMockInvokeResult("capture_clipboard", mockEntry);
setMockInvokeResult("list_clipboard_entries", [mockEntry]);
const result = await clipboardStore.captureClipboard("const x = 1;");
expect(result).toEqual(mockEntry);
});
it("returns null on error", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("capture_clipboard", new Error("Failed"));
const result = await clipboardStore.captureClipboard("const x = 1;");
expect(result).toBeNull();
consoleErrorSpy.mockRestore();
});
});
describe("deleteEntry", () => {
it("removes entry from store on success", async () => {
clipboardStore.entries.set([mockEntry, { ...mockEntry, id: "entry-2" }]);
setMockInvokeResult("delete_clipboard_entry", undefined);
await clipboardStore.deleteEntry("entry-1");
expect(get(clipboardStore.entries)).toHaveLength(1);
expect(get(clipboardStore.entries)[0].id).toBe("entry-2");
});
it("handles errors gracefully", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("delete_clipboard_entry", new Error("Failed"));
await clipboardStore.deleteEntry("entry-1");
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Failed to delete clipboard entry:",
expect.any(Error)
);
consoleErrorSpy.mockRestore();
});
});
describe("togglePin", () => {
it("updates entry pin status and sorts pinned first", async () => {
const unpinned1 = { ...mockEntry, id: "entry-1", is_pinned: false };
const unpinned2 = { ...mockEntry, id: "entry-2", is_pinned: false };
clipboardStore.entries.set([unpinned1, unpinned2]);
const pinned = { ...unpinned2, is_pinned: true };
setMockInvokeResult("toggle_pin_clipboard_entry", pinned);
await clipboardStore.togglePin("entry-2");
const entries = get(clipboardStore.entries);
expect(entries[0].id).toBe("entry-2");
expect(entries[0].is_pinned).toBe(true);
});
it("handles errors gracefully", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("toggle_pin_clipboard_entry", new Error("Failed"));
await clipboardStore.togglePin("entry-1");
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to toggle pin:", expect.any(Error));
consoleErrorSpy.mockRestore();
});
it("exercises the unpinned-before-pinned sort branch", async () => {
// Three entries so sort compares (entry-1 unpinned, entry-2 pinned) — hits line 144
const e1 = { ...mockEntry, id: "e1", is_pinned: false };
const e2 = { ...mockEntry, id: "e2", is_pinned: false };
const e3 = { ...mockEntry, id: "e3", is_pinned: false };
clipboardStore.entries.set([e1, e2, e3]);
const pinned = { ...e2, is_pinned: true };
setMockInvokeResult("toggle_pin_clipboard_entry", pinned);
await clipboardStore.togglePin("e2");
expect(get(clipboardStore.entries)[0].id).toBe("e2");
});
it("sorts same-pin-status entries by timestamp descending", async () => {
// Toggle entry to pinned while two others remain unpinned — sort compares unpinned pair by timestamp
const older = {
...mockEntry,
id: "older",
is_pinned: false,
timestamp: "2026-01-01T00:00:00.000Z",
};
const newer = {
...mockEntry,
id: "newer",
is_pinned: false,
timestamp: "2026-01-02T00:00:00.000Z",
};
const pinned = {
...mockEntry,
id: "pinned",
is_pinned: false,
timestamp: "2026-01-03T00:00:00.000Z",
};
clipboardStore.entries.set([older, newer, pinned]);
const pinnedResult = { ...pinned, is_pinned: true };
setMockInvokeResult("toggle_pin_clipboard_entry", pinnedResult);
await clipboardStore.togglePin("pinned");
const entries = get(clipboardStore.entries);
expect(entries[0].id).toBe("pinned");
expect(entries[1].id).toBe("newer");
expect(entries[2].id).toBe("older");
});
});
describe("clearHistory", () => {
it("removes unpinned entries and keeps pinned ones", async () => {
const pinned = { ...mockEntry, id: "pinned", is_pinned: true };
const unpinned = { ...mockEntry, id: "unpinned", is_pinned: false };
clipboardStore.entries.set([pinned, unpinned]);
setMockInvokeResult("clear_clipboard_history", undefined);
await clipboardStore.clearHistory();
const entries = get(clipboardStore.entries);
expect(entries).toHaveLength(1);
expect(entries[0].id).toBe("pinned");
});
it("handles errors gracefully", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("clear_clipboard_history", new Error("Failed"));
await clipboardStore.clearHistory();
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Failed to clear clipboard history:",
expect.any(Error)
);
consoleErrorSpy.mockRestore();
});
});
describe("updateLanguage", () => {
it("updates entry language in the store", async () => {
clipboardStore.entries.set([mockEntry]);
const updated = { ...mockEntry, language: "javascript" };
setMockInvokeResult("update_clipboard_language", updated);
await clipboardStore.updateLanguage("entry-1", "javascript");
expect(get(clipboardStore.entries)[0].language).toBe("javascript");
});
it("leaves non-matching entries unchanged", async () => {
const other = { ...mockEntry, id: "other", language: "python" };
clipboardStore.entries.set([mockEntry, other]);
const updated = { ...mockEntry, language: "javascript" };
setMockInvokeResult("update_clipboard_language", updated);
await clipboardStore.updateLanguage("entry-1", "javascript");
const entries = get(clipboardStore.entries);
expect(entries[1].language).toBe("python");
});
it("handles errors gracefully", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("update_clipboard_language", new Error("Failed"));
await clipboardStore.updateLanguage("entry-1", "javascript");
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to update language:", expect.any(Error));
consoleErrorSpy.mockRestore();
});
});
describe("copyToClipboard", () => {
it("returns true and copies text on success", async () => {
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
const result = await clipboardStore.copyToClipboard("hello world");
expect(result).toBe(true);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("hello world");
});
it("returns false and logs error on failure", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockRejectedValue(new Error("Clipboard denied")) },
});
const result = await clipboardStore.copyToClipboard("hello world");
expect(result).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Failed to copy to clipboard:",
expect.any(Error)
);
consoleErrorSpy.mockRestore();
});
});
});
+632 -1
View File
@@ -1,12 +1,20 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { get } from "svelte/store";
import {
configStore,
maskPaths,
clampFontSize,
applyFontSize,
applyCustomFont,
applyCustomUiFont,
applyTheme,
applyCustomThemeColors,
clearCustomThemeColors,
isDarkTheme,
isStreamerMode,
isCompactMode,
shouldHidePaths,
showThinkingBlocks,
MIN_FONT_SIZE,
MAX_FONT_SIZE,
DEFAULT_FONT_SIZE,
@@ -15,12 +23,17 @@ import {
type CustomThemeColors,
} from "./config";
import { invoke } from "@tauri-apps/api/core";
import { readFile } from "@tauri-apps/plugin-fs";
// Mock Tauri APIs
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
vi.mock("@tauri-apps/plugin-fs", () => ({
readFile: vi.fn(),
}));
describe("config store", () => {
describe("font size constants", () => {
it("has correct MIN_FONT_SIZE", () => {
@@ -196,9 +209,14 @@ describe("config store", () => {
show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
max_output_tokens: null,
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
custom_font_path: null,
custom_font_family: null,
custom_ui_font_path: null,
custom_ui_font_family: null,
};
expect(config.model).toBe("claude-sonnet-4");
@@ -247,9 +265,14 @@ describe("config store", () => {
show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
max_output_tokens: null,
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
custom_font_path: null,
custom_font_family: null,
custom_ui_font_path: null,
custom_ui_font_family: null,
};
expect(config.model).toBeNull();
@@ -797,9 +820,14 @@ describe("config store", () => {
show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
max_output_tokens: null,
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
custom_font_path: null,
custom_font_family: null,
custom_ui_font_path: null,
custom_ui_font_family: null,
};
const mockInvokeImpl = vi.mocked(invoke);
@@ -840,4 +868,607 @@ describe("config store", () => {
expect(lastSaved.font_size).toBe(16);
});
});
describe("loadConfig error path", () => {
it("resets to default config and logs error when loadConfig fails", async () => {
vi.mocked(invoke).mockResolvedValue(null);
await configStore.updateConfig({ theme: "light" });
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.mocked(invoke).mockRejectedValue(new Error("Backend unavailable"));
await configStore.loadConfig();
expect(configStore.getConfig().theme).toBe("dark");
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to load config:", expect.any(Error));
consoleErrorSpy.mockRestore();
});
});
describe("configStore sidebar methods", () => {
it("openSidebar sets isSidebarOpen to true", () => {
configStore.closeSidebar();
configStore.openSidebar();
expect(get(configStore.isSidebarOpen)).toBe(true);
});
it("closeSidebar sets isSidebarOpen to false", () => {
configStore.openSidebar();
configStore.closeSidebar();
expect(get(configStore.isSidebarOpen)).toBe(false);
});
it("toggleSidebar switches from false to true", () => {
configStore.closeSidebar();
configStore.toggleSidebar();
expect(get(configStore.isSidebarOpen)).toBe(true);
});
it("toggleSidebar switches from true to false", () => {
configStore.openSidebar();
configStore.toggleSidebar();
expect(get(configStore.isSidebarOpen)).toBe(false);
});
});
describe("configStore setTheme method", () => {
beforeEach(async () => {
vi.mocked(invoke).mockResolvedValue(null);
});
afterEach(() => {
vi.resetAllMocks();
});
it("setTheme updates the theme via invoke", async () => {
await configStore.setTheme("light");
expect(configStore.getConfig().theme).toBe("light");
});
it("setTheme with custom colors updates custom_theme_colors", async () => {
const colors: CustomThemeColors = {
bg_primary: "#001122",
bg_secondary: null,
bg_terminal: null,
accent_primary: null,
accent_secondary: null,
text_primary: null,
text_secondary: null,
border_color: null,
};
await configStore.setTheme("custom", colors);
expect(configStore.getConfig().theme).toBe("custom");
expect(configStore.getConfig().custom_theme_colors.bg_primary).toBe("#001122");
});
});
describe("configStore setCustomThemeColors with custom theme active", () => {
beforeEach(async () => {
vi.mocked(invoke).mockResolvedValue(null);
await configStore.setTheme("custom");
});
afterEach(() => {
vi.resetAllMocks();
clearCustomThemeColors();
});
it("applies custom colors to DOM when current theme is custom", async () => {
const colors: CustomThemeColors = {
bg_primary: "#aabbcc",
bg_secondary: null,
bg_terminal: null,
accent_primary: null,
accent_secondary: null,
text_primary: null,
text_secondary: null,
border_color: null,
};
await configStore.setCustomThemeColors(colors);
expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("#aabbcc");
});
});
describe("configStore font size methods", () => {
beforeEach(async () => {
vi.mocked(invoke).mockResolvedValue(null);
await configStore.updateConfig({ font_size: DEFAULT_FONT_SIZE });
vi.resetAllMocks();
vi.mocked(invoke).mockResolvedValue(null);
});
afterEach(() => {
vi.resetAllMocks();
});
it("setFontSize updates to the given value", async () => {
await configStore.setFontSize(18);
expect(configStore.getConfig().font_size).toBe(18);
});
it("setFontSize clamps to minimum", async () => {
await configStore.setFontSize(1);
expect(configStore.getConfig().font_size).toBe(MIN_FONT_SIZE);
});
it("setFontSize clamps to maximum", async () => {
await configStore.setFontSize(99);
expect(configStore.getConfig().font_size).toBe(MAX_FONT_SIZE);
});
it("increaseFontSize increases font size by 2", async () => {
await configStore.increaseFontSize();
expect(configStore.getConfig().font_size).toBe(DEFAULT_FONT_SIZE + 2);
});
it("increaseFontSize does not exceed maximum", async () => {
await configStore.setFontSize(MAX_FONT_SIZE);
await configStore.increaseFontSize();
expect(configStore.getConfig().font_size).toBe(MAX_FONT_SIZE);
});
it("decreaseFontSize decreases font size by 2", async () => {
await configStore.decreaseFontSize();
expect(configStore.getConfig().font_size).toBe(DEFAULT_FONT_SIZE - 2);
});
it("decreaseFontSize does not go below minimum", async () => {
await configStore.setFontSize(MIN_FONT_SIZE);
await configStore.decreaseFontSize();
expect(configStore.getConfig().font_size).toBe(MIN_FONT_SIZE);
});
it("resetFontSize restores the default font size", async () => {
await configStore.setFontSize(20);
await configStore.resetFontSize();
expect(configStore.getConfig().font_size).toBe(DEFAULT_FONT_SIZE);
});
});
describe("configStore removeAutoGrantedTool", () => {
beforeEach(async () => {
vi.mocked(invoke).mockResolvedValue(null);
await configStore.updateConfig({ auto_granted_tools: [] });
vi.resetAllMocks();
vi.mocked(invoke).mockResolvedValue(null);
});
afterEach(() => {
vi.resetAllMocks();
});
it("removes an existing tool", async () => {
await configStore.addAutoGrantedTool("Bash");
await configStore.removeAutoGrantedTool("Bash");
expect(configStore.getConfig().auto_granted_tools).not.toContain("Bash");
});
it("is a no-op when the tool is not in the list", async () => {
await configStore.removeAutoGrantedTool("NonExistentTool");
expect(configStore.getConfig().auto_granted_tools).toEqual([]);
});
});
describe("configStore toggle methods", () => {
beforeEach(async () => {
vi.mocked(invoke).mockResolvedValue(null);
await configStore.updateConfig({ streamer_mode: false, compact_mode: false });
vi.resetAllMocks();
vi.mocked(invoke).mockResolvedValue(null);
});
afterEach(() => {
vi.resetAllMocks();
});
it("toggleStreamerMode flips streamer_mode from false to true", async () => {
await configStore.toggleStreamerMode();
expect(configStore.getConfig().streamer_mode).toBe(true);
});
it("toggleStreamerMode flips streamer_mode from true to false", async () => {
await configStore.updateConfig({ streamer_mode: true });
await configStore.toggleStreamerMode();
expect(configStore.getConfig().streamer_mode).toBe(false);
});
it("toggleCompactMode flips compact_mode from false to true", async () => {
await configStore.toggleCompactMode();
expect(configStore.getConfig().compact_mode).toBe(true);
});
it("toggleCompactMode flips compact_mode from true to false", async () => {
await configStore.updateConfig({ compact_mode: true });
await configStore.toggleCompactMode();
expect(configStore.getConfig().compact_mode).toBe(false);
});
it("setCompactMode enables compact mode", async () => {
await configStore.setCompactMode(true);
expect(configStore.getConfig().compact_mode).toBe(true);
});
it("setCompactMode disables compact mode", async () => {
await configStore.updateConfig({ compact_mode: true });
await configStore.setCompactMode(false);
expect(configStore.getConfig().compact_mode).toBe(false);
});
});
describe("derived stores (live subscriptions)", () => {
beforeEach(async () => {
vi.mocked(invoke).mockResolvedValue(null);
});
afterEach(() => {
vi.resetAllMocks();
});
it("isDarkTheme is true when theme is dark", async () => {
await configStore.updateConfig({ theme: "dark" });
expect(get(isDarkTheme)).toBe(true);
});
it("isDarkTheme is false when theme is not dark", async () => {
await configStore.updateConfig({ theme: "light" });
expect(get(isDarkTheme)).toBe(false);
});
it("isStreamerMode reflects streamer_mode config", async () => {
await configStore.updateConfig({ streamer_mode: true });
expect(get(isStreamerMode)).toBe(true);
await configStore.updateConfig({ streamer_mode: false });
expect(get(isStreamerMode)).toBe(false);
});
it("isCompactMode reflects compact_mode config", async () => {
await configStore.updateConfig({ compact_mode: true });
expect(get(isCompactMode)).toBe(true);
await configStore.updateConfig({ compact_mode: false });
expect(get(isCompactMode)).toBe(false);
});
it("shouldHidePaths is true when both streamer flags are enabled", async () => {
await configStore.updateConfig({ streamer_mode: true, streamer_hide_paths: true });
expect(get(shouldHidePaths)).toBe(true);
});
it("shouldHidePaths is false when streamer_mode is disabled", async () => {
await configStore.updateConfig({ streamer_mode: false, streamer_hide_paths: true });
expect(get(shouldHidePaths)).toBe(false);
});
it("showThinkingBlocks is true when show_thinking_blocks is enabled", async () => {
await configStore.updateConfig({ show_thinking_blocks: true });
expect(get(showThinkingBlocks)).toBe(true);
});
it("showThinkingBlocks is false when show_thinking_blocks is disabled", async () => {
await configStore.updateConfig({ show_thinking_blocks: false });
expect(get(showThinkingBlocks)).toBe(false);
});
});
describe("applyCustomFont", () => {
const readFileMock = vi.mocked(readFile);
beforeEach(() => {
// Remove any style element left by previous tests
document.getElementById("hikari-custom-font")?.remove();
document.documentElement.style.removeProperty("--terminal-font-family");
readFileMock.mockReset();
});
it("removes CSS variable when both path and family are null", async () => {
document.documentElement.style.setProperty("--terminal-font-family", "'SomeFont', monospace");
await applyCustomFont(null, null);
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe("");
});
it("removes CSS variable when both path and family are empty strings", async () => {
document.documentElement.style.setProperty("--terminal-font-family", "'SomeFont', monospace");
await applyCustomFont("", "");
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe("");
expect(document.getElementById("hikari-custom-font")).toBeNull();
});
it("injects @import for a CSS stylesheet URL", async () => {
await applyCustomFont("https://fonts.googleapis.com/css2?family=Fira+Code", "Fira Code");
const style = document.getElementById("hikari-custom-font");
expect(style).not.toBeNull();
expect(style?.textContent).toContain(
"@import url('https://fonts.googleapis.com/css2?family=Fira+Code')"
);
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
"'Fira Code', monospace"
);
});
it("injects @font-face for a direct font file URL (.woff2)", async () => {
await applyCustomFont("https://example.com/fonts/myfont.woff2", "MyFont");
const style = document.getElementById("hikari-custom-font");
expect(style).not.toBeNull();
expect(style?.textContent).toContain("@font-face");
expect(style?.textContent).toContain("url('https://example.com/fonts/myfont.woff2')");
expect(style?.textContent).toContain("'MyFont'");
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
"'MyFont', monospace"
);
});
it("injects @font-face for a direct font file URL (.ttf)", async () => {
await applyCustomFont("https://example.com/fonts/myfont.ttf", "MyTtfFont");
const style = document.getElementById("hikari-custom-font");
expect(style).not.toBeNull();
expect(style?.textContent).toContain("@font-face");
expect(style?.textContent).toContain("url('https://example.com/fonts/myfont.ttf')");
expect(style?.textContent).toContain("'MyTtfFont'");
});
it("uses HikariCustomFont as fallback family for direct font URLs when family is empty", async () => {
await applyCustomFont("https://example.com/fonts/myfont.woff2", "");
const style = document.getElementById("hikari-custom-font");
expect(style?.textContent).toContain("'HikariCustomFont'");
});
it("reads local file and injects @font-face with data URL", async () => {
const fakeData = new Uint8Array([72, 101, 108, 108, 111]); // "Hello"
readFileMock.mockResolvedValueOnce(fakeData);
await applyCustomFont("/home/naomi/.fonts/MyFont.ttf", "MyFont");
expect(readFileMock).toHaveBeenCalledWith("/home/naomi/.fonts/MyFont.ttf");
const style = document.getElementById("hikari-custom-font");
expect(style).not.toBeNull();
expect(style?.textContent).toContain("@font-face");
expect(style?.textContent).toContain("data:font/ttf;base64,");
expect(style?.textContent).toContain("'MyFont'");
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
"'MyFont', monospace"
);
});
it("uses HikariCustomFont as fallback family for local files when family is empty", async () => {
const fakeData = new Uint8Array([1, 2, 3]);
readFileMock.mockResolvedValueOnce(fakeData);
await applyCustomFont("/home/naomi/.fonts/MyFont.woff2", "");
const style = document.getElementById("hikari-custom-font");
expect(style?.textContent).toContain("'HikariCustomFont'");
expect(style?.textContent).toContain("font/woff2");
});
it("sets CSS variable when only family is provided (no path)", async () => {
await applyCustomFont("", "SystemFont");
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
"'SystemFont', monospace"
);
expect(document.getElementById("hikari-custom-font")).toBeNull();
});
it("replaces a previously injected style element", async () => {
await applyCustomFont("https://fonts.googleapis.com/css2?family=Fira+Code", "Fira Code");
expect(document.getElementById("hikari-custom-font")).not.toBeNull();
await applyCustomFont("https://fonts.googleapis.com/css2?family=Roboto+Mono", "Roboto Mono");
const styles = document.querySelectorAll("#hikari-custom-font");
expect(styles.length).toBe(1);
expect(styles[0].textContent).toContain("Roboto+Mono");
});
it("uses correct MIME type for .otf local files", async () => {
readFileMock.mockResolvedValueOnce(new Uint8Array([1]));
await applyCustomFont("/fonts/MyFont.otf", "OtfFont");
const style = document.getElementById("hikari-custom-font");
expect(style?.textContent).toContain("font/otf");
});
it("falls back to font/ttf MIME for unknown extension local files", async () => {
readFileMock.mockResolvedValueOnce(new Uint8Array([1]));
await applyCustomFont("/fonts/MyFont.xyz", "XyzFont");
const style = document.getElementById("hikari-custom-font");
expect(style?.textContent).toContain("font/ttf");
});
});
describe("setCustomFont", () => {
const readFileMock = vi.mocked(readFile);
const invokeMock = vi.mocked(invoke);
beforeEach(() => {
document.getElementById("hikari-custom-font")?.remove();
document.documentElement.style.removeProperty("--terminal-font-family");
readFileMock.mockReset();
invokeMock.mockResolvedValue(undefined);
});
it("saves config and applies the font", async () => {
await configStore.setCustomFont(null, null);
await configStore.setCustomFont(
"https://fonts.googleapis.com/css2?family=Fira+Code",
"Fira Code"
);
expect(invokeMock).toHaveBeenCalledWith(
"save_config",
expect.objectContaining({
config: expect.objectContaining({
custom_font_path: "https://fonts.googleapis.com/css2?family=Fira+Code",
custom_font_family: "Fira Code",
}),
})
);
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
"'Fira Code', monospace"
);
});
it("clears font when called with nulls", async () => {
document.documentElement.style.setProperty("--terminal-font-family", "'SomeFont', monospace");
await configStore.setCustomFont(null, null);
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe("");
expect(invokeMock).toHaveBeenCalledWith(
"save_config",
expect.objectContaining({
config: expect.objectContaining({
custom_font_path: null,
custom_font_family: null,
}),
})
);
});
});
describe("applyCustomUiFont", () => {
const readFileMock = vi.mocked(readFile);
beforeEach(() => {
document.getElementById("hikari-custom-ui-font")?.remove();
document.documentElement.style.removeProperty("--ui-font-family");
document.body.style.removeProperty("font-family");
readFileMock.mockReset();
});
it("removes CSS variable and body font-family when both path and family are null", async () => {
document.documentElement.style.setProperty("--ui-font-family", "'SomeFont', sans-serif");
document.body.style.setProperty("font-family", "'SomeFont', sans-serif");
await applyCustomUiFont(null, null);
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe("");
expect(document.body.style.getPropertyValue("font-family")).toBe("");
});
it("removes CSS variable and body font-family when both path and family are empty strings", async () => {
document.documentElement.style.setProperty("--ui-font-family", "'SomeFont', sans-serif");
document.body.style.setProperty("font-family", "'SomeFont', sans-serif");
await applyCustomUiFont("", "");
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe("");
expect(document.body.style.getPropertyValue("font-family")).toBe("");
expect(document.getElementById("hikari-custom-ui-font")).toBeNull();
});
it("injects @import for a CSS stylesheet URL and applies font to body", async () => {
await applyCustomUiFont("https://fonts.googleapis.com/css2?family=Inter", "Inter");
const style = document.getElementById("hikari-custom-ui-font");
expect(style).not.toBeNull();
expect(style?.textContent).toContain(
"@import url('https://fonts.googleapis.com/css2?family=Inter')"
);
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
"'Inter', sans-serif"
);
expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif');
});
it("injects @font-face for a direct font file URL (.woff2) and applies font to body", async () => {
await applyCustomUiFont("https://example.com/fonts/Inter.woff2", "Inter");
const style = document.getElementById("hikari-custom-ui-font");
expect(style).not.toBeNull();
expect(style?.textContent).toContain("@font-face");
expect(style?.textContent).toContain("url('https://example.com/fonts/Inter.woff2')");
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
"'Inter', sans-serif"
);
expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif');
});
it("uses HikariCustomUiFont as fallback family and applies it to body", async () => {
await applyCustomUiFont("https://example.com/fonts/Inter.woff2", "");
const style = document.getElementById("hikari-custom-ui-font");
expect(style?.textContent).toContain("'HikariCustomUiFont'");
expect(document.body.style.getPropertyValue("font-family")).toBe(
'"HikariCustomUiFont", sans-serif'
);
});
it("reads local file and embeds as data URL, applies font to body", async () => {
const fakeData = new Uint8Array([104, 101, 108, 108, 111]);
readFileMock.mockResolvedValueOnce(fakeData);
await applyCustomUiFont("/home/naomi/.fonts/Inter.ttf", "Inter");
expect(readFileMock).toHaveBeenCalledWith("/home/naomi/.fonts/Inter.ttf");
const style = document.getElementById("hikari-custom-ui-font");
expect(style?.textContent).toContain("data:font/ttf;base64,");
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
"'Inter', sans-serif"
);
expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif');
});
it("sets CSS variable and body font-family when only family is provided (no path)", async () => {
await applyCustomUiFont("", "SystemUiFont");
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
"'SystemUiFont', sans-serif"
);
expect(document.body.style.getPropertyValue("font-family")).toBe(
'"SystemUiFont", sans-serif'
);
});
it("replaces a previously injected style element", async () => {
await applyCustomUiFont("https://fonts.googleapis.com/css2?family=Inter", "Inter");
expect(document.getElementById("hikari-custom-ui-font")).not.toBeNull();
await applyCustomUiFont("https://fonts.googleapis.com/css2?family=Roboto", "Roboto");
const styles = document.querySelectorAll("#hikari-custom-ui-font");
expect(styles.length).toBe(1);
expect(styles[0].textContent).toContain("Roboto");
});
});
describe("setCustomUiFont", () => {
const readFileMock = vi.mocked(readFile);
const invokeMock = vi.mocked(invoke);
beforeEach(() => {
document.getElementById("hikari-custom-ui-font")?.remove();
document.documentElement.style.removeProperty("--ui-font-family");
document.body.style.removeProperty("font-family");
readFileMock.mockReset();
invokeMock.mockResolvedValue(undefined);
});
it("saves config and applies the UI font", async () => {
await configStore.setCustomUiFont(null, null);
await configStore.setCustomUiFont("https://fonts.googleapis.com/css2?family=Inter", "Inter");
expect(invokeMock).toHaveBeenCalledWith(
"save_config",
expect.objectContaining({
config: expect.objectContaining({
custom_ui_font_path: "https://fonts.googleapis.com/css2?family=Inter",
custom_ui_font_family: "Inter",
}),
})
);
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
"'Inter', sans-serif"
);
expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif');
});
it("clears UI font when called with nulls", async () => {
document.documentElement.style.setProperty("--ui-font-family", "'SomeFont', sans-serif");
document.body.style.setProperty("font-family", "'SomeFont', sans-serif");
await configStore.setCustomUiFont(null, null);
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe("");
expect(document.body.style.getPropertyValue("font-family")).toBe("");
expect(invokeMock).toHaveBeenCalledWith(
"save_config",
expect.objectContaining({
config: expect.objectContaining({
custom_ui_font_path: null,
custom_ui_font_family: null,
}),
})
);
});
});
});
+117
View File
@@ -1,5 +1,6 @@
import { writable, derived } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
import { readFile } from "@tauri-apps/plugin-fs";
export type Theme = "dark" | "light" | "high-contrast" | "custom";
export type BudgetAction = "warn" | "block";
@@ -51,11 +52,19 @@ export interface HikariConfig {
use_worktree: boolean;
// Disable 1M context window
disable_1m_context: boolean;
// Max output tokens for Claude Code responses
max_output_tokens: number | null;
// Workspaces the user has explicitly trusted
trusted_workspaces: string[];
// Background image settings
background_image_path: string | null;
background_image_opacity: number;
// Custom terminal font settings
custom_font_path: string | null;
custom_font_family: string | null;
// Custom UI font settings
custom_ui_font_path: string | null;
custom_ui_font_family: string | null;
}
const defaultConfig: HikariConfig = {
@@ -98,9 +107,14 @@ const defaultConfig: HikariConfig = {
show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
max_output_tokens: null,
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
custom_font_path: null,
custom_font_family: null,
custom_ui_font_path: null,
custom_ui_font_family: null,
};
function createConfigStore() {
@@ -237,6 +251,16 @@ function createConfigStore() {
setCompactMode: async (enabled: boolean) => {
await updateConfig({ compact_mode: enabled });
},
setCustomFont: async (path: string | null, family: string | null) => {
await updateConfig({ custom_font_path: path, custom_font_family: family });
await applyCustomFont(path, family);
},
setCustomUiFont: async (path: string | null, family: string | null) => {
await updateConfig({ custom_ui_font_path: path, custom_ui_font_family: family });
await applyCustomUiFont(path, family);
},
};
}
@@ -302,6 +326,99 @@ export function clampFontSize(size: number): number {
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
}
const DIRECT_FONT_EXTENSIONS = new Set(["woff", "woff2", "ttf", "otf", "eot"]);
const FONT_MIME_MAP: Record<string, string> = {
woff: "font/woff",
woff2: "font/woff2",
ttf: "font/ttf",
otf: "font/otf",
eot: "application/vnd.ms-fontobject",
};
async function applyFontFromSource(path: string, family: string, styleId: string): Promise<void> {
const style = document.createElement("style");
style.id = styleId;
if (path.startsWith("http://") || path.startsWith("https://")) {
const ext = path.split(".").pop()?.toLowerCase() ?? "";
if (DIRECT_FONT_EXTENSIONS.has(ext)) {
style.textContent = `@font-face { font-family: '${family}'; src: url('${path}'); }`;
} else {
style.textContent = `@import url('${path}');`;
}
} else {
const data = await readFile(path);
const chunks: string[] = [];
const chunkSize = 8192;
for (let i = 0; i < data.length; i += chunkSize) {
chunks.push(String.fromCharCode(...data.slice(i, i + chunkSize)));
}
const ext = path.split(".").pop()?.toLowerCase() ?? "ttf";
const mime = FONT_MIME_MAP[ext] ?? "font/ttf";
const dataUrl = `data:${mime};base64,${btoa(chunks.join(""))}`;
style.textContent = `@font-face { font-family: '${family}'; src: url('${dataUrl}'); }`;
}
document.head.appendChild(style);
}
export async function applyCustomFont(path: string | null, family: string | null): Promise<void> {
if (typeof document === "undefined") return;
const styleId = "hikari-custom-font";
const cssVar = "--terminal-font-family";
const fallbackFamily = "HikariCustomFont";
document.getElementById(styleId)?.remove();
const trimmedPath = path?.trim() ?? "";
const trimmedFamily = family?.trim() ?? "";
if (!trimmedPath && !trimmedFamily) {
document.documentElement.style.removeProperty(cssVar);
return;
}
if (trimmedPath) {
await applyFontFromSource(trimmedPath, trimmedFamily || fallbackFamily, styleId);
}
if (trimmedFamily) {
document.documentElement.style.setProperty(cssVar, `'${trimmedFamily}', monospace`);
}
}
export async function applyCustomUiFont(path: string | null, family: string | null): Promise<void> {
if (typeof document === "undefined") return;
const styleId = "hikari-custom-ui-font";
const cssVar = "--ui-font-family";
const fallbackFamily = "HikariCustomUiFont";
document.getElementById(styleId)?.remove();
const trimmedPath = path?.trim() ?? "";
const trimmedFamily = family?.trim() ?? "";
if (!trimmedPath && !trimmedFamily) {
document.documentElement.style.removeProperty(cssVar);
document.body?.style.removeProperty("font-family");
return;
}
const effectiveFamily = trimmedFamily || fallbackFamily;
if (trimmedPath) {
await applyFontFromSource(trimmedPath, effectiveFamily, styleId);
}
const fontValue = `'${effectiveFamily}', sans-serif`;
document.documentElement.style.setProperty(cssVar, fontValue);
document.body?.style.setProperty("font-family", fontValue);
}
export { MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE };
export const configStore = createConfigStore();
+84 -2
View File
@@ -43,6 +43,88 @@ export interface Conversation {
draftText: string;
}
const TAB_NAMES = [
// Cosmic & celestial
"Starfall",
"Moonbeam",
"Nebula",
"Aurora",
"Stardust",
"Solstice",
"Comet",
"Eclipse",
"Zenith",
"Celestia",
"Nova",
"Quasar",
"Lyra",
"Andromeda",
"Twilight",
// Magical & fantastical
"Camelot",
"Reverie",
"Arcane",
"Spellbound",
"Mirage",
"Oracle",
"Seraphim",
"Ethereal",
"Labyrinth",
"Enchantment",
// Nature & cosy
"Sakura",
"Ember",
"Cascade",
"Zephyr",
"Serendipity",
"Solace",
"Blossom",
"Whisper",
"Dewdrop",
"Sunbeam",
"Willow",
"Clover",
"Honeybee",
"Buttercup",
"Dandelion",
// Japanese/anime-inspired
"Tsukimi",
"Hanami",
"Yozora",
"Hoshi",
"Koharu",
"Akari",
"Midori",
// Adventure & epic
"Odyssey",
"Wanderer",
"Horizon",
"Voyage",
"Pathfinder",
"Frontier",
// Whimsical & sweet
"Bubblegum",
"Marshmallow",
"Daydream",
"Whimsy",
"Jellybean",
"Sprinkle",
"Cupcake",
// Dreamy & poetic
"Revenant",
"Elysium",
"Halcyon",
"Ephemera",
"Serenade",
"Lullaby",
"Nocturne",
"Rhapsody",
];
function pickRandomTabName(): string {
return TAB_NAMES[Math.floor(Math.random() * TAB_NAMES.length)];
}
function createConversationsStore() {
const conversations = writable<Map<string, Conversation>>(new Map());
const activeConversationId = writable<string | null>(null);
@@ -63,7 +145,7 @@ function createConversationsStore() {
const id = generateConversationId();
return {
id,
name: name || `Conversation ${conversationCounter}`,
name: name ?? pickRandomTabName(),
terminalLines: [],
sessionId: null,
connectionStatus: "disconnected",
@@ -91,7 +173,7 @@ function createConversationsStore() {
function ensureInitialized() {
if (!initialized) {
initialized = true;
const initialConversation = createNewConversation("Main");
const initialConversation = createNewConversation();
conversations.update((convs) => {
convs.set(initialConversation.id, initialConversation);
return convs;
+292
View File
@@ -0,0 +1,292 @@
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");
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");
});
});
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);
});
});
+282
View File
@@ -0,0 +1,282 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { get } from "svelte/store";
import { emitMockEvent } from "../../../vitest.setup";
import { debugConsoleStore, filteredLogs } from "./debugConsole";
describe("debugConsoleStore", () => {
beforeEach(() => {
debugConsoleStore.clear();
debugConsoleStore.close();
debugConsoleStore.setFilterLevel("all");
debugConsoleStore.setAutoScroll(true);
});
afterEach(() => {
debugConsoleStore.restoreConsole();
debugConsoleStore.clear();
});
it("initializes with correct default state", () => {
const state = get(debugConsoleStore);
expect(state.logs).toEqual([]);
expect(state.isOpen).toBe(false);
expect(state.maxLogs).toBe(1000);
expect(state.filterLevel).toBe("all");
expect(state.autoScroll).toBe(true);
});
describe("toggle", () => {
it("opens when currently closed", () => {
debugConsoleStore.toggle();
expect(get(debugConsoleStore).isOpen).toBe(true);
});
it("closes when currently open", () => {
debugConsoleStore.open();
debugConsoleStore.toggle();
expect(get(debugConsoleStore).isOpen).toBe(false);
});
});
describe("open", () => {
it("sets isOpen to true", () => {
debugConsoleStore.open();
expect(get(debugConsoleStore).isOpen).toBe(true);
});
});
describe("close", () => {
it("sets isOpen to false", () => {
debugConsoleStore.open();
debugConsoleStore.close();
expect(get(debugConsoleStore).isOpen).toBe(false);
});
});
describe("clear", () => {
it("removes all log entries", async () => {
await debugConsoleStore.setupBackendLogsListener();
emitMockEvent("debug:log", { level: "info", message: "test entry" });
expect(get(debugConsoleStore).logs.length).toBe(1);
debugConsoleStore.clear();
expect(get(debugConsoleStore).logs).toEqual([]);
});
});
describe("setFilterLevel", () => {
it("updates filterLevel to the specified level", () => {
debugConsoleStore.setFilterLevel("error");
expect(get(debugConsoleStore).filterLevel).toBe("error");
});
it("can reset filterLevel back to all", () => {
debugConsoleStore.setFilterLevel("warn");
debugConsoleStore.setFilterLevel("all");
expect(get(debugConsoleStore).filterLevel).toBe("all");
});
});
describe("setAutoScroll", () => {
it("disables autoScroll", () => {
debugConsoleStore.setAutoScroll(false);
expect(get(debugConsoleStore).autoScroll).toBe(false);
});
it("re-enables autoScroll", () => {
debugConsoleStore.setAutoScroll(false);
debugConsoleStore.setAutoScroll(true);
expect(get(debugConsoleStore).autoScroll).toBe(true);
});
});
describe("setupConsoleCapture", () => {
afterEach(() => {
debugConsoleStore.restoreConsole();
});
it("captures console.log calls as info-level frontend logs", () => {
debugConsoleStore.setupConsoleCapture();
console.log("hello world");
const logs = get(debugConsoleStore).logs;
const captured = logs.find((l) => l.message === "hello world");
expect(captured).toBeDefined();
expect(captured?.level).toBe("info");
expect(captured?.source).toBe("frontend");
});
it("captures console.info calls as info-level logs", () => {
debugConsoleStore.setupConsoleCapture();
console.info("info message");
const logs = get(debugConsoleStore).logs;
const captured = logs.find((l) => l.message === "info message");
expect(captured?.level).toBe("info");
});
it("captures console.warn calls as warn-level logs", () => {
debugConsoleStore.setupConsoleCapture();
console.warn("warning message");
const logs = get(debugConsoleStore).logs;
const captured = logs.find((l) => l.message === "warning message");
expect(captured?.level).toBe("warn");
});
it("captures console.error calls as error-level logs", () => {
debugConsoleStore.setupConsoleCapture();
console.error("error message");
const logs = get(debugConsoleStore).logs;
const captured = logs.find((l) => l.message === "error message");
expect(captured?.level).toBe("error");
});
it("captures console.debug calls as debug-level logs", () => {
debugConsoleStore.setupConsoleCapture();
console.debug("debug message");
const logs = get(debugConsoleStore).logs;
const captured = logs.find((l) => l.message === "debug message");
expect(captured?.level).toBe("debug");
});
it("joins multiple console arguments with spaces", () => {
debugConsoleStore.setupConsoleCapture();
console.log("hello", "world", 42);
const logs = get(debugConsoleStore).logs;
const captured = logs.find((l) => l.message === "hello world 42");
expect(captured).toBeDefined();
});
it("captures unhandled window error events", () => {
debugConsoleStore.setupConsoleCapture();
const errorEvent = new ErrorEvent("error", {
message: "Test unhandled error",
filename: "test.js",
lineno: 10,
colno: 5,
});
window.dispatchEvent(errorEvent);
const logs = get(debugConsoleStore).logs;
const captured = logs.find(
(l) => l.level === "error" && l.message.includes("[Unhandled Error]")
);
expect(captured).toBeDefined();
expect(captured?.message).toContain("Test unhandled error");
});
it("captures unhandled promise rejection events", () => {
debugConsoleStore.setupConsoleCapture();
const rejectionEvent = Object.assign(new Event("unhandledrejection"), {
reason: "test rejection reason",
});
window.dispatchEvent(rejectionEvent);
const logs = get(debugConsoleStore).logs;
const captured = logs.find(
(l) => l.level === "error" && l.message.includes("[Unhandled Promise Rejection]")
);
expect(captured).toBeDefined();
expect(captured?.message).toContain("test rejection reason");
});
});
describe("restoreConsole", () => {
it("stops capturing console output after restore", () => {
debugConsoleStore.setupConsoleCapture();
debugConsoleStore.restoreConsole();
const countBefore = get(debugConsoleStore).logs.length;
console.log("this should not be captured");
expect(get(debugConsoleStore).logs.length).toBe(countBefore);
});
});
describe("setupBackendLogsListener", () => {
it("captures backend logs emitted via debug:log event", async () => {
await debugConsoleStore.setupBackendLogsListener();
emitMockEvent("debug:log", { level: "info", message: "backend message" });
const logs = get(debugConsoleStore).logs;
expect(logs.length).toBe(1);
expect(logs[0].level).toBe("info");
expect(logs[0].message).toBe("backend message");
expect(logs[0].source).toBe("backend");
});
it("handles different log levels from backend", async () => {
await debugConsoleStore.setupBackendLogsListener();
emitMockEvent("debug:log", { level: "error", message: "backend error" });
const logs = get(debugConsoleStore).logs;
expect(logs[0].level).toBe("error");
});
});
describe("circular buffer", () => {
it("drops oldest log when exceeding 1000-entry limit", async () => {
await debugConsoleStore.setupBackendLogsListener();
for (let i = 0; i < 1001; i++) {
emitMockEvent("debug:log", { level: "info", message: `log ${i}` });
}
const logs = get(debugConsoleStore).logs;
expect(logs.length).toBe(1000);
expect(logs[0].message).toBe("log 1");
expect(logs[999].message).toBe("log 1000");
});
});
});
describe("filteredLogs derived store", () => {
beforeEach(async () => {
debugConsoleStore.clear();
debugConsoleStore.setFilterLevel("all");
await debugConsoleStore.setupBackendLogsListener();
});
afterEach(() => {
debugConsoleStore.clear();
});
it("returns all logs when filterLevel is all", () => {
emitMockEvent("debug:log", { level: "debug", message: "d" });
emitMockEvent("debug:log", { level: "info", message: "i" });
emitMockEvent("debug:log", { level: "warn", message: "w" });
emitMockEvent("debug:log", { level: "error", message: "e" });
expect(get(filteredLogs).length).toBe(4);
});
it("returns only error logs when filterLevel is error", () => {
emitMockEvent("debug:log", { level: "debug", message: "d" });
emitMockEvent("debug:log", { level: "info", message: "i" });
emitMockEvent("debug:log", { level: "warn", message: "w" });
emitMockEvent("debug:log", { level: "error", message: "e" });
debugConsoleStore.setFilterLevel("error");
const logs = get(filteredLogs);
expect(logs.length).toBe(1);
expect(logs[0].level).toBe("error");
});
it("returns warn and error logs when filterLevel is warn", () => {
emitMockEvent("debug:log", { level: "debug", message: "d" });
emitMockEvent("debug:log", { level: "info", message: "i" });
emitMockEvent("debug:log", { level: "warn", message: "w" });
emitMockEvent("debug:log", { level: "error", message: "e" });
debugConsoleStore.setFilterLevel("warn");
const logs = get(filteredLogs);
expect(logs.length).toBe(2);
expect(logs.every((l) => l.level === "warn" || l.level === "error")).toBe(true);
});
it("excludes debug logs when filterLevel is info", () => {
emitMockEvent("debug:log", { level: "debug", message: "d" });
emitMockEvent("debug:log", { level: "info", message: "i" });
emitMockEvent("debug:log", { level: "warn", message: "w" });
emitMockEvent("debug:log", { level: "error", message: "e" });
debugConsoleStore.setFilterLevel("info");
const logs = get(filteredLogs);
expect(logs.length).toBe(3);
expect(logs.some((l) => l.level === "debug")).toBe(false);
});
it("returns all log levels when filterLevel is debug", () => {
emitMockEvent("debug:log", { level: "debug", message: "d" });
emitMockEvent("debug:log", { level: "info", message: "i" });
emitMockEvent("debug:log", { level: "warn", message: "w" });
emitMockEvent("debug:log", { level: "error", message: "e" });
debugConsoleStore.setFilterLevel("debug");
expect(get(filteredLogs).length).toBe(4);
});
});
+10
View File
@@ -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();
});
});
});
+639
View File
@@ -0,0 +1,639 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { get } from "svelte/store";
import { editorStore } from "$lib/stores/editor";
import { setMockInvokeResult } from "../../../vitest.setup";
const FILE_CONTENT = "// test file content";
// Reset tabs between tests
afterEach(async () => {
const tabs = get(editorStore.tabs);
for (const tab of tabs) {
editorStore.closeTab(tab.id);
}
editorStore.hideEditor();
});
describe("editorStore - openFile (tests getLanguageFromPath)", () => {
const testCases: [string, string][] = [
["file.ts", "typescript"],
["file.tsx", "typescript"],
["file.js", "javascript"],
["file.jsx", "javascript"],
["file.py", "python"],
["file.rs", "rust"],
["file.go", "go"],
["file.java", "java"],
["file.c", "c"],
["file.cpp", "cpp"],
["file.h", "c"],
["file.hpp", "cpp"],
["file.cs", "csharp"],
["file.rb", "ruby"],
["file.php", "php"],
["file.swift", "swift"],
["file.kt", "kotlin"],
["file.scala", "scala"],
["file.html", "html"],
["file.htm", "html"],
["file.css", "css"],
["file.scss", "scss"],
["file.sass", "sass"],
["file.less", "less"],
["file.json", "json"],
["file.xml", "xml"],
["file.yaml", "yaml"],
["file.yml", "yaml"],
["file.toml", "toml"],
["file.md", "markdown"],
["file.markdown", "markdown"],
["file.sql", "sql"],
["file.sh", "shell"],
["file.bash", "shell"],
["file.zsh", "shell"],
["file.ps1", "powershell"],
["file.svelte", "svelte"],
["file.vue", "vue"],
["file.graphql", "graphql"],
["file.gql", "graphql"],
["file.lua", "lua"],
["file.r", "r"],
["file.dart", "dart"],
["file.elm", "elm"],
["file.ex", "elixir"],
["file.exs", "elixir"],
["file.erl", "erlang"],
["file.hs", "haskell"],
["file.clj", "clojure"],
["file.lisp", "lisp"],
["file.ml", "ocaml"],
["file.fs", "fsharp"],
["file.zig", "zig"],
["file.nim", "nim"],
["file.v", "v"],
["file.wasm", "wasm"],
["file.wat", "wasm"],
["dockerfile", "plaintext"],
["file.unknown", "plaintext"],
["file", "plaintext"],
];
it.each(testCases)("maps %s to language %s", async (filename, expectedLanguage) => {
setMockInvokeResult("read_file_content", FILE_CONTENT);
await editorStore.openFile(`/path/to/${filename}`);
const activeTab = get(editorStore.activeTab);
expect(activeTab?.language).toBe(expectedLanguage);
if (activeTab) editorStore.closeTab(activeTab.id);
});
it("opens a new tab and makes it active", async () => {
setMockInvokeResult("read_file_content", FILE_CONTENT);
await editorStore.openFile("/path/to/file.ts");
const tabs = get(editorStore.tabs);
const activeTab = get(editorStore.activeTab);
expect(tabs).toHaveLength(1);
expect(activeTab?.fileName).toBe("file.ts");
expect(activeTab?.content).toBe(FILE_CONTENT);
expect(activeTab?.isDirty).toBe(false);
});
it("switches to existing tab instead of opening a duplicate", async () => {
setMockInvokeResult("read_file_content", FILE_CONTENT);
await editorStore.openFile("/path/to/file.ts");
const firstTabId = get(editorStore.activeTabId);
// Open another file to change active tab
await editorStore.openFile("/path/to/other.ts");
const secondTabId = get(editorStore.activeTabId);
expect(secondTabId).not.toBe(firstTabId);
// Re-open first file — should switch to existing tab
await editorStore.openFile("/path/to/file.ts");
expect(get(editorStore.activeTabId)).toBe(firstTabId);
expect(get(editorStore.tabs)).toHaveLength(2);
});
it("handles open file errors gracefully", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("read_file_content", new Error("File not found"));
await editorStore.openFile("/path/to/missing.ts");
expect(get(editorStore.tabs)).toHaveLength(0);
expect(get(editorStore.saveError)).toContain("Failed to open file");
consoleErrorSpy.mockRestore();
});
it("extracts filename from path correctly", async () => {
setMockInvokeResult("read_file_content", FILE_CONTENT);
await editorStore.openFile("/deep/nested/path/component.svelte");
expect(get(editorStore.activeTab)?.fileName).toBe("component.svelte");
});
});
describe("editorStore - saveFile", () => {
beforeEach(async () => {
setMockInvokeResult("read_file_content", FILE_CONTENT);
await editorStore.openFile("/path/to/file.ts");
// Make it dirty first
editorStore.updateTabContent(get(editorStore.activeTabId)!, "modified content");
});
it("saves the active tab successfully", async () => {
setMockInvokeResult("write_file_content", undefined);
await editorStore.saveFile();
expect(get(editorStore.activeTab)?.isDirty).toBe(false);
expect(get(editorStore.activeTab)?.originalContent).toBe("modified content");
});
it("saves a specific tab by ID", async () => {
setMockInvokeResult("write_file_content", undefined);
const tabId = get(editorStore.activeTabId)!;
await editorStore.saveFile(tabId);
expect(get(editorStore.activeTab)?.isDirty).toBe(false);
});
it("does nothing when no tab ID matches", async () => {
await editorStore.saveFile("non-existent-tab");
// No error = pass
});
});
describe("editorStore - saveFile error", () => {
it("sets saveError when write fails", async () => {
setMockInvokeResult("read_file_content", FILE_CONTENT);
await editorStore.openFile("/path/to/file-err.ts");
editorStore.updateTabContent(get(editorStore.activeTabId)!, "modified content");
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("write_file_content", new Error("Permission denied"));
await editorStore.saveFile().catch(() => {});
expect(get(editorStore.saveError)).toContain("Failed to save file");
consoleErrorSpy.mockRestore();
});
});
describe("editorStore - updateTabContent", () => {
beforeEach(async () => {
setMockInvokeResult("read_file_content", FILE_CONTENT);
await editorStore.openFile("/path/to/file.ts");
});
it("updates content and marks tab as dirty", () => {
const tabId = get(editorStore.activeTabId)!;
editorStore.updateTabContent(tabId, "new content");
const tab = get(editorStore.activeTab);
expect(tab?.content).toBe("new content");
expect(tab?.isDirty).toBe(true);
});
it("marks tab as clean when content matches original", () => {
const tabId = get(editorStore.activeTabId)!;
editorStore.updateTabContent(tabId, "modified");
expect(get(editorStore.activeTab)?.isDirty).toBe(true);
editorStore.updateTabContent(tabId, FILE_CONTENT);
expect(get(editorStore.activeTab)?.isDirty).toBe(false);
});
it("hasDirtyTabs is true when any tab is dirty", () => {
const tabId = get(editorStore.activeTabId)!;
expect(get(editorStore.hasDirtyTabs)).toBe(false);
editorStore.updateTabContent(tabId, "dirty content");
expect(get(editorStore.hasDirtyTabs)).toBe(true);
});
});
describe("editorStore - closeTab", () => {
beforeEach(async () => {
setMockInvokeResult("read_file_content", FILE_CONTENT);
await editorStore.openFile("/path/to/file1.ts");
await editorStore.openFile("/path/to/file2.ts");
});
it("removes the specified tab", () => {
const tabs = get(editorStore.tabs);
editorStore.closeTab(tabs[0].id);
expect(get(editorStore.tabs)).toHaveLength(1);
expect(get(editorStore.tabs)[0].filePath).toBe("/path/to/file2.ts");
});
it("sets active tab to remaining tab when active tab is closed", () => {
const activeTabId = get(editorStore.activeTabId)!;
editorStore.closeTab(activeTabId);
expect(get(editorStore.activeTabId)).not.toBe(activeTabId);
expect(get(editorStore.tabs)).toHaveLength(1);
});
});
describe("editorStore - setActiveTab", () => {
beforeEach(async () => {
setMockInvokeResult("read_file_content", FILE_CONTENT);
await editorStore.openFile("/path/to/file1.ts");
await editorStore.openFile("/path/to/file2.ts");
});
it("sets the active tab to the specified ID", () => {
const tabs = get(editorStore.tabs);
editorStore.setActiveTab(tabs[0].id);
expect(get(editorStore.activeTabId)).toBe(tabs[0].id);
});
});
describe("editorStore - visibility controls", () => {
it("showEditor sets isEditorVisible to true", () => {
expect(get(editorStore.isEditorVisible)).toBe(false);
editorStore.showEditor();
expect(get(editorStore.isEditorVisible)).toBe(true);
});
it("hideEditor sets isEditorVisible to false", () => {
editorStore.showEditor();
editorStore.hideEditor();
expect(get(editorStore.isEditorVisible)).toBe(false);
});
it("toggleEditor toggles isEditorVisible", () => {
expect(get(editorStore.isEditorVisible)).toBe(false);
editorStore.toggleEditor();
expect(get(editorStore.isEditorVisible)).toBe(true);
editorStore.toggleEditor();
expect(get(editorStore.isEditorVisible)).toBe(false);
});
});
describe("editorStore - toggleFileBrowser", () => {
it("toggles the file browser visibility", () => {
const initial = get(editorStore.isFileBrowserOpen);
editorStore.toggleFileBrowser();
expect(get(editorStore.isFileBrowserOpen)).toBe(!initial);
editorStore.toggleFileBrowser();
expect(get(editorStore.isFileBrowserOpen)).toBe(initial);
});
});
describe("editorStore - file system operations", () => {
it("createFile invokes backend and refreshes directory", async () => {
setMockInvokeResult("create_file", undefined);
setMockInvokeResult("list_directory", []);
await editorStore.createFile("/path/to", "new-file.ts");
// No error = success
});
it("createFile handles errors gracefully", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("create_file", new Error("Permission denied"));
await editorStore.createFile("/path/to", "new-file.ts");
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to create file:", expect.any(Error));
consoleErrorSpy.mockRestore();
});
it("createDirectory invokes backend and refreshes", async () => {
setMockInvokeResult("create_directory", undefined);
setMockInvokeResult("list_directory", []);
await editorStore.createDirectory("/path/to", "new-dir");
});
it("createDirectory handles errors gracefully", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("create_directory", new Error("Failed"));
await editorStore.createDirectory("/path/to", "new-dir");
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to create directory:", expect.any(Error));
consoleErrorSpy.mockRestore();
});
it("deleteFile invokes backend and refreshes", async () => {
setMockInvokeResult("delete_file", undefined);
setMockInvokeResult("list_directory", []);
await editorStore.deleteFile("/path/to/file.ts");
});
it("deleteFile handles errors gracefully", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("delete_file", new Error("Failed"));
await editorStore.deleteFile("/path/to/file.ts");
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to delete file:", expect.any(Error));
consoleErrorSpy.mockRestore();
});
it("deleteDirectory invokes backend and refreshes", async () => {
setMockInvokeResult("delete_directory", undefined);
setMockInvokeResult("list_directory", []);
await editorStore.deleteDirectory("/path/to/dir");
});
it("deleteDirectory handles errors gracefully", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("delete_directory", new Error("Failed"));
await editorStore.deleteDirectory("/path/to/dir");
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to delete directory:", expect.any(Error));
consoleErrorSpy.mockRestore();
});
it("renamePath invokes backend and refreshes", async () => {
setMockInvokeResult("rename_path", undefined);
setMockInvokeResult("list_directory", []);
await editorStore.renamePath("/path/to/old-name.ts", "new-name.ts");
});
it("renamePath handles errors gracefully", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("rename_path", new Error("Failed"));
await editorStore.renamePath("/path/to/old-name.ts", "new-name.ts");
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to rename:", expect.any(Error));
consoleErrorSpy.mockRestore();
});
});
describe("editorStore - initializeFileTree", () => {
it("loads directory entries and updates file tree", async () => {
const entries = [
{ path: "/path/file.ts", name: "file.ts", isDirectory: false, children: undefined },
];
setMockInvokeResult("list_directory", entries);
await editorStore.initializeFileTree("/path");
expect(get(editorStore.fileTree)).toEqual(entries);
expect(get(editorStore.currentDirectory)).toBe("/path");
});
it("handles errors gracefully", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("list_directory", new Error("Failed"));
await editorStore.initializeFileTree("/path");
expect(get(editorStore.isLoadingTree)).toBe(false);
consoleErrorSpy.mockRestore();
});
});
describe("editorStore - toggleDirectory", () => {
beforeEach(async () => {
const entries = [
{
path: "/path/dir",
name: "dir",
isDirectory: true,
isExpanded: false,
children: undefined,
},
];
setMockInvokeResult("list_directory", entries);
await editorStore.initializeFileTree("/path");
});
it("does nothing when entry is not a directory", async () => {
const nonDirEntry = {
path: "/path/file.ts",
name: "file.ts",
isDirectory: false,
};
const treeBefore = get(editorStore.fileTree);
await editorStore.toggleDirectory(nonDirEntry);
expect(get(editorStore.fileTree)).toEqual(treeBefore);
});
it("expands a collapsed directory and loads children asynchronously", async () => {
const children = [
{ path: "/path/dir/child.ts", name: "child.ts", isDirectory: false, children: undefined },
];
setMockInvokeResult("list_directory", children);
const dirEntry = get(editorStore.fileTree)[0];
await editorStore.toggleDirectory(dirEntry);
const updatedEntry = get(editorStore.fileTree)[0];
expect(updatedEntry.isExpanded).toBe(true);
expect(updatedEntry.children).toEqual(children);
expect(updatedEntry.isLoading).toBe(false);
});
it("collapses an expanded directory", async () => {
const children = [
{ path: "/path/dir/child.ts", name: "child.ts", isDirectory: false, children: undefined },
];
setMockInvokeResult("list_directory", children);
// Expand first
const dirEntry = get(editorStore.fileTree)[0];
await editorStore.toggleDirectory(dirEntry);
expect(get(editorStore.fileTree)[0].isExpanded).toBe(true);
// Now collapse
const expandedEntry = get(editorStore.fileTree)[0];
await editorStore.toggleDirectory(expandedEntry);
expect(get(editorStore.fileTree)[0].isExpanded).toBe(false);
});
it("expands a directory that already has children without reloading", async () => {
// Re-init with a directory that already has children
const entries = [
{
path: "/path/dir",
name: "dir",
isDirectory: true,
isExpanded: false,
children: [
{
path: "/path/dir/child.ts",
name: "child.ts",
isDirectory: false,
children: undefined,
},
],
},
];
setMockInvokeResult("list_directory", entries);
await editorStore.initializeFileTree("/path");
// Toggle should expand without loading (children already present)
const dirEntry = get(editorStore.fileTree)[0];
await editorStore.toggleDirectory(dirEntry);
const updatedEntry = get(editorStore.fileTree)[0];
expect(updatedEntry.isExpanded).toBe(true);
expect(updatedEntry.children).toHaveLength(1);
});
it("handles tree with multiple entries when toggling — exercises non-matching leaf fallback", async () => {
// Tree has dir + a sibling file; toggling dir exercises the `return e` branch for the file
const entries = [
{
path: "/path/dir",
name: "dir",
isDirectory: true,
isExpanded: false,
children: undefined,
},
{ path: "/path/file.ts", name: "file.ts", isDirectory: false, children: undefined },
];
setMockInvokeResult("list_directory", entries);
await editorStore.initializeFileTree("/path");
const children: never[] = [];
setMockInvokeResult("list_directory", children);
const dirEntry = get(editorStore.fileTree)[0];
await editorStore.toggleDirectory(dirEntry);
// The sibling file should remain unchanged
expect(get(editorStore.fileTree)[1].path).toBe("/path/file.ts");
expect(get(editorStore.fileTree)[0].isExpanded).toBe(true);
});
it("handles nested directory toggle — exercises recursive updateTree branches", async () => {
// Tree: outer_dir → [inner_dir (no children), sibling_file]
// Toggling inner_dir covers the recursive path through outer's children
const entries = [
{
path: "/path/outer",
name: "outer",
isDirectory: true,
isExpanded: true,
children: [
{
path: "/path/outer/inner",
name: "inner",
isDirectory: true,
isExpanded: false,
children: undefined,
},
{
path: "/path/outer/sibling.ts",
name: "sibling.ts",
isDirectory: false,
children: undefined,
},
],
},
];
setMockInvokeResult("list_directory", entries);
await editorStore.initializeFileTree("/path");
const innerChildren = [
{
path: "/path/outer/inner/deep.ts",
name: "deep.ts",
isDirectory: false,
children: undefined,
},
];
setMockInvokeResult("list_directory", innerChildren);
const innerEntry = get(editorStore.fileTree)[0].children![0];
await editorStore.toggleDirectory(innerEntry);
const outer = get(editorStore.fileTree)[0];
expect(outer.children![0].isExpanded).toBe(true);
expect(outer.children![0].children).toEqual(innerChildren);
});
});
describe("editorStore - deleteFile closes open tab", () => {
it("closes the tab when the deleted file is open", async () => {
setMockInvokeResult("read_file_content", FILE_CONTENT);
await editorStore.openFile("/path/to/file.ts");
expect(get(editorStore.tabs)).toHaveLength(1);
setMockInvokeResult("delete_file", undefined);
setMockInvokeResult("list_directory", []);
await editorStore.deleteFile("/path/to/file.ts");
expect(get(editorStore.tabs)).toHaveLength(0);
});
});
describe("editorStore - deleteDirectory closes open tabs", () => {
it("closes all tabs inside the deleted directory", async () => {
setMockInvokeResult("read_file_content", FILE_CONTENT);
await editorStore.openFile("/path/to/dir/file1.ts");
await editorStore.openFile("/path/to/dir/nested/file2.ts");
await editorStore.openFile("/path/to/other.ts");
expect(get(editorStore.tabs)).toHaveLength(3);
setMockInvokeResult("delete_directory", undefined);
setMockInvokeResult("list_directory", []);
await editorStore.deleteDirectory("/path/to/dir");
const remainingTabs = get(editorStore.tabs);
expect(remainingTabs).toHaveLength(1);
expect(remainingTabs[0].filePath).toBe("/path/to/other.ts");
});
});
describe("editorStore - renamePath updates open tabs", () => {
it("updates filePath and fileName when the renamed file is open in a tab", async () => {
setMockInvokeResult("read_file_content", FILE_CONTENT);
await editorStore.openFile("/path/to/old-name.ts");
setMockInvokeResult("rename_path", undefined);
setMockInvokeResult("list_directory", []);
await editorStore.renamePath("/path/to/old-name.ts", "new-name.ts");
const tab = get(editorStore.activeTab);
expect(tab?.filePath).toBe("/path/to/new-name.ts");
expect(tab?.fileName).toBe("new-name.ts");
});
it("updates filePaths for tabs inside a renamed directory", async () => {
setMockInvokeResult("read_file_content", FILE_CONTENT);
await editorStore.openFile("/path/to/old-dir/file.ts");
setMockInvokeResult("rename_path", undefined);
setMockInvokeResult("list_directory", []);
await editorStore.renamePath("/path/to/old-dir", "new-dir");
const tab = get(editorStore.activeTab);
expect(tab?.filePath).toBe("/path/to/new-dir/file.ts");
});
it("leaves unrelated tabs unchanged when renaming — exercises return-t fallback", async () => {
setMockInvokeResult("read_file_content", FILE_CONTENT);
await editorStore.openFile("/path/to/unrelated.ts");
await editorStore.openFile("/path/to/old-name.ts");
setMockInvokeResult("rename_path", undefined);
setMockInvokeResult("list_directory", []);
await editorStore.renamePath("/path/to/old-name.ts", "new-name.ts");
const tabs = get(editorStore.tabs);
const unrelated = tabs.find((t) => t.filePath === "/path/to/unrelated.ts");
expect(unrelated).toBeDefined();
expect(unrelated?.fileName).toBe("unrelated.ts");
});
});
describe("editorStore - refreshDirectory", () => {
it("reloads the entire tree when refreshing the root directory", async () => {
const initialEntries = [
{ path: "/root/file.ts", name: "file.ts", isDirectory: false, children: undefined },
];
setMockInvokeResult("list_directory", initialEntries);
await editorStore.initializeFileTree("/root");
expect(get(editorStore.fileTree)).toHaveLength(1);
const updatedEntries = [
{ path: "/root/file.ts", name: "file.ts", isDirectory: false, children: undefined },
{ path: "/root/new.ts", name: "new.ts", isDirectory: false, children: undefined },
];
setMockInvokeResult("list_directory", updatedEntries);
await editorStore.refreshDirectory("/root");
expect(get(editorStore.fileTree)).toHaveLength(2);
});
it("updates a subdirectory entry in the tree", async () => {
const initialEntries = [
{
path: "/root/subdir",
name: "subdir",
isDirectory: true,
isExpanded: true,
children: [],
},
];
setMockInvokeResult("list_directory", initialEntries);
await editorStore.initializeFileTree("/root");
const subChildren = [
{ path: "/root/subdir/file.ts", name: "file.ts", isDirectory: false, children: undefined },
];
setMockInvokeResult("list_directory", subChildren);
await editorStore.refreshDirectory("/root/subdir");
const tree = get(editorStore.fileTree);
expect(tree[0].children).toEqual(subChildren);
expect(tree[0].isExpanded).toBe(true);
});
});
+72
View File
@@ -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();
});
});
});
+56
View File
@@ -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");
});
});
+104
View File
@@ -0,0 +1,104 @@
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);
});
});
});
+187
View File
@@ -0,0 +1,187 @@
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("");
});
});
+569
View File
@@ -0,0 +1,569 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { get } from "svelte/store";
import { sessionsStore } from "$lib/stores/sessions";
import { setMockInvokeResult } from "../../../vitest.setup";
import type { SavedSession } from "$lib/stores/sessions";
vi.mock("@tauri-apps/plugin-dialog", () => ({
save: vi.fn(),
open: vi.fn(),
}));
vi.mock("@tauri-apps/plugin-fs", () => ({
writeTextFile: vi.fn().mockResolvedValue(undefined),
readTextFile: vi.fn(),
}));
vi.mock("@tauri-apps/plugin-opener", () => ({
openPath: vi.fn().mockResolvedValue(undefined),
}));
const makeSavedSession = (overrides: Partial<SavedSession> = {}): SavedSession => ({
id: "session-1",
name: "Test Session",
created_at: "2026-03-03T10:00:00.000Z",
last_activity_at: "2026-03-03T11:00:00.000Z",
working_directory: "/home/naomi/code",
message_count: 2,
preview: "Hello world",
messages: [
{ id: "msg-1", type: "user", content: "Hello world", timestamp: "2026-03-03T10:00:00.000Z" },
{ id: "msg-2", type: "assistant", content: "Hi there!", timestamp: "2026-03-03T10:01:00.000Z" },
],
...overrides,
});
const makeConversation = () => ({
id: "conv-1",
name: "Test Conversation",
workingDirectory: "/home/naomi/code",
createdAt: new Date("2026-03-03T10:00:00.000Z"),
lastActivityAt: new Date("2026-03-03T11:00:00.000Z"),
terminalLines: [
{
id: "line-1",
type: "user",
content: "Hello",
timestamp: new Date("2026-03-03T10:00:00.000Z"),
toolName: undefined,
},
{
id: "line-2",
type: "assistant",
content: "Hi",
timestamp: new Date("2026-03-03T10:01:00.000Z"),
toolName: undefined,
},
],
});
describe("sessionsStore - loadSessions", () => {
it("loads sessions from backend and updates the store", async () => {
const sessionList = [{ id: "session-1", name: "Test", message_count: 1, preview: "..." }];
setMockInvokeResult("list_sessions", sessionList);
await sessionsStore.loadSessions();
expect(get(sessionsStore.sessions)).toEqual(sessionList);
});
it("handles errors gracefully", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("list_sessions", new Error("Backend error"));
await sessionsStore.loadSessions();
expect(spy).toHaveBeenCalledWith("Failed to load sessions:", expect.any(Error));
spy.mockRestore();
});
it("sets isLoading to false after completion", async () => {
setMockInvokeResult("list_sessions", []);
await sessionsStore.loadSessions();
expect(get(sessionsStore.isLoading)).toBe(false);
});
});
describe("sessionsStore - loadSession", () => {
it("returns session data on success", async () => {
const session = makeSavedSession();
setMockInvokeResult("load_session", session);
const result = await sessionsStore.loadSession("session-1");
expect(result).toEqual(session);
});
it("returns null on error", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("load_session", new Error("Not found"));
const result = await sessionsStore.loadSession("session-1");
expect(result).toBeNull();
spy.mockRestore();
});
});
describe("sessionsStore - deleteSession", () => {
it("deletes session and reloads the session list", async () => {
setMockInvokeResult("delete_session", undefined);
setMockInvokeResult("list_sessions", []);
await sessionsStore.deleteSession("session-1");
expect(get(sessionsStore.sessions)).toEqual([]);
});
it("handles errors gracefully", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("delete_session", new Error("Failed"));
await sessionsStore.deleteSession("session-1");
expect(spy).toHaveBeenCalledWith("Failed to delete session:", expect.any(Error));
spy.mockRestore();
});
});
describe("sessionsStore - searchSessions", () => {
it("calls loadSessions when query is empty", async () => {
setMockInvokeResult("list_sessions", []);
await sessionsStore.searchSessions("");
expect(get(sessionsStore.sessions)).toEqual([]);
});
it("calls loadSessions when query is whitespace-only", async () => {
setMockInvokeResult("list_sessions", []);
await sessionsStore.searchSessions(" ");
expect(get(sessionsStore.sessions)).toEqual([]);
});
it("searches with the given query", async () => {
const results = [{ id: "session-1", name: "Test", message_count: 1, preview: "..." }];
setMockInvokeResult("search_sessions", results);
await sessionsStore.searchSessions("test");
expect(get(sessionsStore.sessions)).toEqual(results);
});
it("updates searchQuery store", async () => {
setMockInvokeResult("search_sessions", []);
await sessionsStore.searchSessions("hello");
expect(get(sessionsStore.searchQuery)).toBe("hello");
await sessionsStore.searchSessions("");
});
it("handles search errors gracefully", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("search_sessions", new Error("Search failed"));
await sessionsStore.searchSessions("error-query");
expect(spy).toHaveBeenCalledWith("Failed to search sessions:", expect.any(Error));
spy.mockRestore();
});
});
describe("sessionsStore - clearAllSessions", () => {
it("clears all sessions", async () => {
setMockInvokeResult("clear_all_sessions", undefined);
await sessionsStore.clearAllSessions();
expect(get(sessionsStore.sessions)).toEqual([]);
});
it("handles errors gracefully", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("clear_all_sessions", new Error("Failed"));
await sessionsStore.clearAllSessions();
expect(spy).toHaveBeenCalledWith("Failed to clear sessions:", expect.any(Error));
spy.mockRestore();
});
});
describe("sessionsStore - saveConversation", () => {
it("saves conversation to backend", async () => {
setMockInvokeResult("save_session", undefined);
setMockInvokeResult("list_sessions", []);
await sessionsStore.saveConversation(makeConversation() as never);
});
it("handles save errors gracefully", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("save_session", new Error("Save failed"));
await sessionsStore.saveConversation(makeConversation() as never);
expect(spy).toHaveBeenCalledWith("Failed to save session:", expect.any(Error));
spy.mockRestore();
});
it("handles empty conversation", async () => {
setMockInvokeResult("save_session", undefined);
setMockInvokeResult("list_sessions", []);
const conv = { ...makeConversation(), terminalLines: [] };
await sessionsStore.saveConversation(conv as never);
});
});
describe("sessionsStore - scheduleAutoSave and cancelAutoSave", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("schedules an auto-save after the debounce delay", async () => {
setMockInvokeResult("save_session", undefined);
setMockInvokeResult("list_sessions", []);
sessionsStore.scheduleAutoSave(makeConversation() as never);
await vi.advanceTimersByTimeAsync(2001);
});
it("does not auto-save empty conversations", async () => {
const conv = { ...makeConversation(), terminalLines: [] };
sessionsStore.scheduleAutoSave(conv as never);
await vi.advanceTimersByTimeAsync(3000);
});
it("cancels a pending auto-save", () => {
const conv = makeConversation();
sessionsStore.scheduleAutoSave(conv as never);
sessionsStore.cancelAutoSave(conv.id);
});
it("handles cancel when no auto-save is pending", () => {
sessionsStore.cancelAutoSave("non-existent-id");
});
it("clears an existing pending auto-save when rescheduled for the same conversation", async () => {
setMockInvokeResult("save_session", undefined);
setMockInvokeResult("list_sessions", []);
const conv = makeConversation();
sessionsStore.scheduleAutoSave(conv as never);
// Schedule again before timer fires — hits clearTimeout branch (line 487)
sessionsStore.scheduleAutoSave(conv as never);
await vi.advanceTimersByTimeAsync(2001);
});
});
describe("sessionsStore - exportSessionAsJson", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns true on successful export", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
setMockInvokeResult("load_session", makeSavedSession());
vi.mocked(save).mockResolvedValue("/output/session.json");
expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(true);
});
it("returns false when session not found", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("load_session", null);
expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(false);
spy.mockRestore();
});
it("returns false when user cancels save dialog", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
setMockInvokeResult("load_session", makeSavedSession());
vi.mocked(save).mockResolvedValue(null);
expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(false);
});
it("returns false on error", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("load_session", new Error("Load failed"));
expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(false);
spy.mockRestore();
});
it("returns false when writeTextFile throws", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("load_session", makeSavedSession());
vi.mocked(save).mockResolvedValue("/output/session.json");
vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full"));
expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(false);
spy.mockRestore();
});
});
describe("sessionsStore - exportSessionAsMarkdown", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("generates markdown with all message types", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
const session = makeSavedSession({
messages: [
{ id: "1", type: "user", content: "User message", timestamp: "2026-03-03T10:00:00Z" },
{
id: "2",
type: "assistant",
content: "Assistant reply",
timestamp: "2026-03-03T10:01:00Z",
},
{
id: "3",
type: "tool_use",
content: "Tool input",
timestamp: "2026-03-03T10:02:00Z",
tool_name: "bash",
},
{ id: "4", type: "tool_result", content: "Tool output", timestamp: "2026-03-03T10:03:00Z" },
{ id: "5", type: "system", content: "System event", timestamp: "2026-03-03T10:04:00Z" },
{ id: "6", type: "error", content: "Error message", timestamp: "2026-03-03T10:05:00Z" },
],
});
setMockInvokeResult("load_session", session);
vi.mocked(save).mockResolvedValue("/output/session.md");
expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(true);
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
expect(content).toContain("User message");
expect(content).toContain("Assistant reply");
expect(content).toContain("Tool: bash");
expect(content).toContain("Tool input");
expect(content).toContain("Tool output");
expect(content).toContain("System event");
expect(content).toContain("Error message");
});
it("returns false when session not found", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("load_session", null);
expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(false);
spy.mockRestore();
});
it("returns false when user cancels dialog", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
setMockInvokeResult("load_session", makeSavedSession());
vi.mocked(save).mockResolvedValue(null);
expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(false);
});
it("returns false on error", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("load_session", new Error("Load failed"));
expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(false);
spy.mockRestore();
});
it("returns false when writeTextFile throws", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("load_session", makeSavedSession());
vi.mocked(save).mockResolvedValue("/output/session.md");
vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full"));
expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(false);
spy.mockRestore();
});
});
describe("sessionsStore - exportSessionAsHtml (tests escapeHtml + generateHtmlExport)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("generates HTML and returns true on success", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
setMockInvokeResult("load_session", makeSavedSession());
vi.mocked(save).mockResolvedValue("/output/session.html");
expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(true);
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
expect(content).toContain("<!DOCTYPE html>");
expect(content).toContain("Test Session");
});
it("escapes HTML characters in session name", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
setMockInvokeResult("load_session", makeSavedSession({ name: "<Test & 'Session'>" }));
vi.mocked(save).mockResolvedValue("/output/session.html");
await sessionsStore.exportSessionAsHtml("session-1");
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
expect(content).toContain("&lt;Test &amp; &#039;Session&#039;&gt;");
expect(content).not.toContain("<Test & 'Session'>");
});
it("escapes HTML characters in message content", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
setMockInvokeResult(
"load_session",
makeSavedSession({
messages: [
{
id: "1",
type: "user",
content: '<script>alert("xss")</script>',
timestamp: "2026-03-03T10:00:00Z",
},
],
})
);
vi.mocked(save).mockResolvedValue("/output/session.html");
await sessionsStore.exportSessionAsHtml("session-1");
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
expect(content).toContain("&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;");
expect(content).not.toContain("<script>");
});
it("escapes HTML in tool names", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
setMockInvokeResult(
"load_session",
makeSavedSession({
messages: [
{
id: "1",
type: "tool_use",
content: "input",
timestamp: "2026-03-03T10:00:00Z",
tool_name: "<dangerous>",
},
],
})
);
vi.mocked(save).mockResolvedValue("/output/session.html");
await sessionsStore.exportSessionAsHtml("session-1");
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
expect(content).toContain("&lt;dangerous&gt;");
expect(content).not.toContain("<dangerous>");
});
it("excludes metadata when includeMetadata is false", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
setMockInvokeResult("load_session", makeSavedSession());
vi.mocked(save).mockResolvedValue("/output/session.html");
await sessionsStore.exportSessionAsHtml("session-1", false);
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
expect(content).not.toContain('class="metadata"');
});
it("returns false when session not found", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("load_session", null);
expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(false);
spy.mockRestore();
});
it("returns false when user cancels dialog", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
setMockInvokeResult("load_session", makeSavedSession());
vi.mocked(save).mockResolvedValue(null);
expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(false);
});
it("returns false on error", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("load_session", new Error("Load failed"));
expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(false);
spy.mockRestore();
});
it("returns false when writeTextFile throws", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("load_session", makeSavedSession());
vi.mocked(save).mockResolvedValue("/output/session.html");
vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full"));
expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(false);
spy.mockRestore();
});
});
describe("sessionsStore - exportSessionAsPdf (tests generatePrintableHtml)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("generates printable HTML and opens it in the browser", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
const { openPath } = await import("@tauri-apps/plugin-opener");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
setMockInvokeResult("load_session", makeSavedSession());
vi.mocked(save).mockResolvedValue("/output/session-print.html");
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(true);
expect(openPath).toHaveBeenCalledWith("/output/session-print.html");
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
expect(content).toContain("<!DOCTYPE html>");
});
it("excludes metadata when includeMetadata is false", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
setMockInvokeResult("load_session", makeSavedSession());
vi.mocked(save).mockResolvedValue("/output/session-print.html");
await sessionsStore.exportSessionAsPdf("session-1", false);
expect(vi.mocked(writeTextFile).mock.calls[0][1]).toBeDefined();
});
it("returns false when session not found", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("load_session", null);
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(false);
spy.mockRestore();
});
it("returns false when user cancels dialog", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
setMockInvokeResult("load_session", makeSavedSession());
vi.mocked(save).mockResolvedValue(null);
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(false);
});
it("returns false on error", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("load_session", new Error("Load failed"));
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(false);
spy.mockRestore();
});
it("returns false when writeTextFile throws", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("load_session", makeSavedSession());
vi.mocked(save).mockResolvedValue("/output/session-print.html");
vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full"));
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(false);
spy.mockRestore();
});
});
describe("sessionsStore - importSession", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("imports session from JSON file and returns true", async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const { readTextFile } = await import("@tauri-apps/plugin-fs");
vi.mocked(open).mockResolvedValue("/input/session.json");
vi.mocked(readTextFile).mockResolvedValue(JSON.stringify({ session: makeSavedSession() }));
setMockInvokeResult("save_session", undefined);
setMockInvokeResult("list_sessions", []);
expect(await sessionsStore.importSession()).toBe(true);
});
it("returns false when user cancels file dialog", async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
vi.mocked(open).mockResolvedValue(null);
expect(await sessionsStore.importSession()).toBe(false);
});
it("returns false when file has invalid format", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
const { open } = await import("@tauri-apps/plugin-dialog");
const { readTextFile } = await import("@tauri-apps/plugin-fs");
vi.mocked(open).mockResolvedValue("/input/bad.json");
vi.mocked(readTextFile).mockResolvedValue(JSON.stringify({ not_a_session: true }));
expect(await sessionsStore.importSession()).toBe(false);
spy.mockRestore();
});
it("returns false on error", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
const { open } = await import("@tauri-apps/plugin-dialog");
vi.mocked(open).mockRejectedValue(new Error("Dialog failed"));
expect(await sessionsStore.importSession()).toBe(false);
spy.mockRestore();
});
});
+293 -2
View File
@@ -1,5 +1,7 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { get } from "svelte/store";
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import {
stats,
formattedStats,
@@ -9,8 +11,13 @@ import {
estimateMessageCost,
formatTokenCount,
MODEL_PRICING,
checkBudget,
getBudgetStatusMessage,
getRemainingTokenBudget,
getRemainingCostBudget,
initStatsListener,
} 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 {
@@ -30,6 +37,12 @@ vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
vi.mock("./costTracking", () => ({
costTrackingStore: {
refresh: vi.fn().mockResolvedValue([]),
},
}));
describe("stats store", () => {
beforeEach(() => {
// Reset stats to default before each test
@@ -600,4 +613,282 @@ 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);
});
});
describe("initStatsListener", () => {
const emptyStats: UsageStats = {
total_input_tokens: 0,
total_output_tokens: 0,
total_cost_usd: 0,
session_input_tokens: 0,
session_output_tokens: 0,
session_cost_usd: 0,
model: null,
messages_exchanged: 0,
session_messages_exchanged: 0,
code_blocks_generated: 0,
session_code_blocks_generated: 0,
files_edited: 0,
session_files_edited: 0,
files_created: 0,
session_files_created: 0,
tools_usage: {},
session_tools_usage: {},
session_duration_seconds: 0,
context_tokens_used: 0,
context_window_limit: 200000,
context_utilisation_percent: 0,
potential_cache_hits: 0,
potential_cache_savings_tokens: 0,
};
afterEach(() => {
vi.resetAllMocks();
});
it("registers a listener for the claude:stats event", async () => {
vi.mocked(listen).mockResolvedValue(vi.fn());
vi.mocked(invoke).mockResolvedValue(emptyStats);
await initStatsListener();
expect(listen).toHaveBeenCalledWith("claude:stats", expect.any(Function));
});
it("updates the stats store when the listener callback fires", async () => {
let capturedCallback: ((event: unknown) => void) | undefined;
vi.mocked(listen).mockImplementation(async (_event, callback) => {
capturedCallback = callback as (event: unknown) => void;
return () => {};
});
vi.mocked(invoke).mockResolvedValue(emptyStats);
await initStatsListener();
const newStats = { ...emptyStats, total_input_tokens: 9999 };
capturedCallback!({ payload: { stats: newStats } });
expect(get(stats).total_input_tokens).toBe(9999);
});
it("loads persisted stats from the backend on initialisation", async () => {
const persistedStats = { ...emptyStats, total_input_tokens: 5000 };
vi.mocked(listen).mockResolvedValue(vi.fn());
vi.mocked(invoke).mockResolvedValue(persistedStats);
await initStatsListener();
expect(invoke).toHaveBeenCalledWith("get_persisted_stats");
expect(get(stats).total_input_tokens).toBe(5000);
});
it("handles a failed invoke gracefully and logs the error", async () => {
const error = new Error("Failed to get stats");
vi.mocked(listen).mockResolvedValue(vi.fn());
vi.mocked(invoke).mockRejectedValue(error);
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await initStatsListener();
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to load initial stats:", error);
consoleErrorSpy.mockRestore();
});
});
});
+93
View File
@@ -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();
});
});
});
+125
View File
@@ -0,0 +1,125 @@
/**
* MessageMode Type Tests
*
* Tests the helper functions exported from messageMode.ts:
* - getMessageMode: looks up a MessageMode by id
* - formatMessageWithMode: prepends a mode prefix to a message
*
* What this module does:
* - Defines the six conversation modes (chat, architect, code, debug, ask, review)
* - Provides a lookup function to find a mode by its id
* - Provides a formatting function that prepends the mode prefix to messages
*
* Manual testing checklist:
* - [ ] Mode selector shows correct names and icons for all six modes
* - [ ] Selecting a mode prefixes the next message with the correct prefix
* - [ ] Switching back to Chat sends messages without any prefix
* - [ ] Sending a message that already has the prefix does not double-prefix it
*/
import { describe, it, expect } from "vitest";
import { MESSAGE_MODES, getMessageMode, formatMessageWithMode } from "./messageMode";
describe("MESSAGE_MODES", () => {
it("contains all six expected modes", () => {
const ids = MESSAGE_MODES.map((m) => m.id);
expect(ids).toEqual(["chat", "architect", "code", "debug", "ask", "review"]);
});
it("each mode has an id, name, description, and icon", () => {
for (const mode of MESSAGE_MODES) {
expect(mode.id).toBeTruthy();
expect(mode.name).toBeTruthy();
expect(mode.description).toBeTruthy();
expect(mode.icon).toBeTruthy();
}
});
});
describe("getMessageMode", () => {
it("returns the chat mode with no prefix", () => {
const mode = getMessageMode("chat");
expect(mode).toBeDefined();
expect(mode?.id).toBe("chat");
expect(mode?.prefix).toBeUndefined();
});
it("returns the architect mode with its prefix", () => {
const mode = getMessageMode("architect");
expect(mode?.id).toBe("architect");
expect(mode?.prefix).toBe("[Architect Mode] ");
});
it("returns the code mode with its prefix", () => {
const mode = getMessageMode("code");
expect(mode?.id).toBe("code");
expect(mode?.prefix).toBe("[Code Mode] ");
});
it("returns the debug mode with its prefix", () => {
const mode = getMessageMode("debug");
expect(mode?.id).toBe("debug");
expect(mode?.prefix).toBe("[Debug Mode] ");
});
it("returns the ask mode with its prefix", () => {
const mode = getMessageMode("ask");
expect(mode?.id).toBe("ask");
expect(mode?.prefix).toBe("[Ask Mode] ");
});
it("returns the review mode with its prefix", () => {
const mode = getMessageMode("review");
expect(mode?.id).toBe("review");
expect(mode?.prefix).toBe("[Review Mode] ");
});
it("returns undefined for an unknown mode id", () => {
expect(getMessageMode("unknown")).toBeUndefined();
});
it("returns undefined for an empty string", () => {
expect(getMessageMode("")).toBeUndefined();
});
});
describe("formatMessageWithMode", () => {
it("prepends the prefix for code mode", () => {
expect(formatMessageWithMode("hello", "code")).toBe("[Code Mode] hello");
});
it("prepends the prefix for architect mode", () => {
expect(formatMessageWithMode("design this", "architect")).toBe("[Architect Mode] design this");
});
it("prepends the prefix for debug mode", () => {
expect(formatMessageWithMode("fix this bug", "debug")).toBe("[Debug Mode] fix this bug");
});
it("prepends the prefix for ask mode", () => {
expect(formatMessageWithMode("what is this?", "ask")).toBe("[Ask Mode] what is this?");
});
it("prepends the prefix for review mode", () => {
expect(formatMessageWithMode("review my code", "review")).toBe("[Review Mode] review my code");
});
it("returns the message unchanged in chat mode (no prefix)", () => {
expect(formatMessageWithMode("hello", "chat")).toBe("hello");
});
it("returns the message unchanged for unknown mode ids", () => {
expect(formatMessageWithMode("hello", "unknown")).toBe("hello");
expect(formatMessageWithMode("hello", "")).toBe("hello");
});
it("does not double-prefix a message already starting with the mode prefix", () => {
expect(formatMessageWithMode("[Code Mode] already prefixed", "code")).toBe(
"[Code Mode] already prefixed"
);
});
it("adds the prefix to an empty string", () => {
expect(formatMessageWithMode("", "code")).toBe("[Code Mode] ");
});
});
+24
View File
@@ -266,6 +266,30 @@ describe("stateMapper", () => {
expect(mapMessageToState(message)).toBeNull();
});
it("returns null for content_block_start with unrecognised content block type", () => {
const message = {
type: "stream_event",
event: {
type: "content_block_start",
index: 0,
content_block: { type: "input_json_delta" },
},
} as unknown as ClaudeStreamMessage;
expect(mapMessageToState(message)).toBeNull();
});
it("returns null for content_block_delta with unrecognised delta type", () => {
const message = {
type: "stream_event",
event: {
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: "{}" },
},
} as unknown as ClaudeStreamMessage;
expect(mapMessageToState(message)).toBeNull();
});
it("returns null for result with unknown subtype", () => {
const message = {
type: "result",
+14 -3
View File
@@ -11,7 +11,14 @@
updateDiscordRpc,
setSkipNextGreeting,
} from "$lib/tauri";
import { configStore, applyTheme, applyFontSize, isCompactMode } from "$lib/stores/config";
import {
configStore,
applyTheme,
applyFontSize,
applyCustomFont,
applyCustomUiFont,
isCompactMode,
} from "$lib/stores/config";
import { readFile } from "@tauri-apps/plugin-fs";
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
import { conversationsStore } from "$lib/stores/conversations";
@@ -454,6 +461,8 @@
const config = configStore.getConfig();
applyTheme(config.theme, config.custom_theme_colors);
applyFontSize(config.font_size);
await applyCustomFont(config.custom_font_path, config.custom_font_family);
await applyCustomUiFont(config.custom_ui_font_path, config.custom_ui_font_family);
// Apply always-on-top setting
if (config.always_on_top) {
@@ -593,13 +602,15 @@
<style>
.app-container {
font-family:
font-family: var(
--ui-font-family,
"Inter",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
sans-serif;
sans-serif
);
}
.character-panel {
+4 -1
View File
@@ -17,7 +17,10 @@ vi.mock("@tauri-apps/api/core", () => ({
if (command in mockInvokeResults) {
const result = mockInvokeResults[command];
if (result instanceof Error) {
return Promise.reject(result);
const err = result;
return Promise.resolve().then(() => {
throw err;
});
}
return Promise.resolve(result);
}