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
2 changed files with 509 additions and 0 deletions
Showing only changes of commit 7e45c685d3 - Show all commits
+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();
});
});
+359
View File
@@ -0,0 +1,359 @@
/**
* Clipboard Store Tests
*
* Tests the pure helper functions from the clipboard store:
* - detectLanguage: identifies programming language from code content
* - formatTimestamp: converts an ISO timestamp to a relative time string
*
* What this store does:
* - Maintains a history of clipboard entries (code snippets)
* - Auto-detects the language of captured content
* - Supports filtering by language and search query
* - Pinned entries persist across clear operations
*
* Manual testing checklist:
* - [ ] Clipboard entries appear when code is copied during a session
* - [ ] Language is detected and labelled correctly
* - [ ] Pinned entries survive "Clear history"
* - [ ] Language filter dropdown shows only languages present in history
* - [ ] Search query filters by content, language, and source
*/
/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mirror: detectLanguage from clipboard.ts
function detectLanguage(content: string): string | null {
const patterns: [RegExp, string][] = [
[/^(import|export|const|let|var|function|class|interface|type)\s/m, "typescript"],
[/^(def|class|import|from|if __name__|async def)\s/m, "python"],
[/^(fn|let|mut|impl|struct|enum|use|mod|pub)\s/m, "rust"],
[/^(package|import|func|type|var|const)\s/m, "go"],
[/<\?php/m, "php"],
[/^(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP)\s/im, "sql"],
[/^<!DOCTYPE|^<html|^<div|^<span/im, "html"],
[/^\s*\{[\s\S]*"[\w-]+":/m, "json"],
[/^---\s*\n/m, "yaml"],
[/^\s*#\s*(include|define|ifdef)/m, "c"],
[/^(public|private|protected)\s+(class|interface|static)/m, "java"],
[/^\$[\w_]+\s*=/m, "bash"],
];
for (const [pattern, lang] of patterns) {
if (pattern.test(content)) {
return lang;
}
}
return null;
}
// Mirror: formatTimestamp from clipboard.ts
function formatTimestamp(timestamp: string): string {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "Just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
// ---
describe("detectLanguage", () => {
describe("TypeScript detection", () => {
it("detects import statements", () => {
expect(detectLanguage("import React from 'react';")).toBe("typescript");
});
it("detects export statements", () => {
expect(detectLanguage("export function foo() {}")).toBe("typescript");
});
it("detects const declarations", () => {
expect(detectLanguage("const x = 1;")).toBe("typescript");
});
it("detects interface declarations", () => {
expect(detectLanguage("interface Foo {\n bar: string;\n}")).toBe("typescript");
});
it("detects type aliases", () => {
expect(detectLanguage("type MyType = string | number;")).toBe("typescript");
});
});
describe("Python detection", () => {
it("detects def statements", () => {
expect(detectLanguage("def foo():\n pass")).toBe("python");
});
it("detects async def statements", () => {
expect(detectLanguage("async def bar():\n pass")).toBe("python");
});
it("detects from imports", () => {
expect(detectLanguage("from os import path")).toBe("python");
});
it("detects the __name__ guard", () => {
expect(detectLanguage("if __name__ == '__main__':\n main()")).toBe("python");
});
});
describe("Rust detection", () => {
it("detects fn declarations", () => {
expect(detectLanguage('fn main() {\n println!("hello");\n}')).toBe("rust");
});
it("detects impl blocks", () => {
expect(detectLanguage("impl Foo {\n pub fn new() -> Self {}\n}")).toBe("rust");
});
it("detects struct declarations", () => {
expect(detectLanguage("struct Point {\n x: f64,\n y: f64,\n}")).toBe("rust");
});
it("detects enum declarations", () => {
expect(detectLanguage("enum Direction {\n North,\n South,\n}")).toBe("rust");
});
it("detects mod declarations", () => {
expect(detectLanguage("mod utils;")).toBe("rust");
});
it("detects pub visibility", () => {
expect(detectLanguage("pub fn exported() {}")).toBe("rust");
});
});
describe("Go detection", () => {
it("detects package declarations", () => {
expect(detectLanguage("package main")).toBe("go");
});
it("detects func declarations", () => {
expect(detectLanguage("func main() {}")).toBe("go");
});
});
describe("PHP detection", () => {
it("detects the PHP open tag", () => {
expect(detectLanguage("<?php\necho 'hello';")).toBe("php");
});
});
describe("SQL detection", () => {
it("detects SELECT statements", () => {
expect(detectLanguage("SELECT * FROM users WHERE id = 1")).toBe("sql");
});
it("detects INSERT statements", () => {
expect(detectLanguage("INSERT INTO users (name) VALUES ('Alice')")).toBe("sql");
});
it("detects CREATE statements", () => {
expect(detectLanguage("CREATE TABLE users (id INT PRIMARY KEY)")).toBe("sql");
});
it("detects SQL case-insensitively", () => {
expect(detectLanguage("select * from users")).toBe("sql");
});
});
describe("HTML detection", () => {
it("detects DOCTYPE declarations", () => {
expect(detectLanguage("<!DOCTYPE html>")).toBe("html");
});
it("detects html tags", () => {
expect(detectLanguage("<html><body></body></html>")).toBe("html");
});
it("detects div tags", () => {
expect(detectLanguage("<div class='foo'>bar</div>")).toBe("html");
});
it("detects span tags", () => {
expect(detectLanguage("<span>text</span>")).toBe("html");
});
});
describe("JSON detection", () => {
it("detects JSON object syntax", () => {
expect(detectLanguage('{"name": "test", "value": 42}')).toBe("json");
});
it("detects JSON with hyphenated keys", () => {
expect(detectLanguage('{"my-key": "value"}')).toBe("json");
});
});
describe("YAML detection", () => {
it("detects YAML document separator", () => {
expect(detectLanguage("---\nkey: value\nother: 123")).toBe("yaml");
});
});
describe("C detection", () => {
it("detects #include directives", () => {
expect(detectLanguage("#include <stdio.h>\nint main() {}")).toBe("c");
});
it("detects #define directives", () => {
expect(detectLanguage("#define MAX 100")).toBe("c");
});
it("detects #ifdef directives", () => {
expect(detectLanguage("#ifdef DEBUG\n// debug code\n#endif")).toBe("c");
});
});
describe("Java detection", () => {
it("detects public class declarations", () => {
expect(detectLanguage("public class Foo {\n // ...\n}")).toBe("java");
});
it("detects private static methods", () => {
expect(detectLanguage("private static void helper() {}")).toBe("java");
});
it("detects protected interface declarations", () => {
expect(detectLanguage("protected interface Bar {}")).toBe("java");
});
});
describe("Bash detection", () => {
it("detects shell variable assignments", () => {
expect(detectLanguage("$HOME=/usr/local")).toBe("bash");
});
it("detects variable assignments with underscores", () => {
expect(detectLanguage("$MY_VAR=some_value")).toBe("bash");
});
});
describe("unknown content", () => {
it("returns null for plain text", () => {
expect(detectLanguage("Hello, world!")).toBeNull();
});
it("returns null for empty string", () => {
expect(detectLanguage("")).toBeNull();
});
it("returns null for mathematical expressions", () => {
expect(detectLanguage("1 + 1 = 2")).toBeNull();
});
it("returns null for a markdown heading", () => {
expect(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(formatTimestamp(ts)).toBe("Just now");
});
it("returns 'Just now' for the current moment", () => {
const ts = NOW.toISOString();
expect(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(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(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(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(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(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(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(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(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(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(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 = 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 = formatTimestamp(ts);
expect(result).not.toContain("ago");
});
});
});