diff --git a/src/lib/notifications/rules.test.ts b/src/lib/notifications/rules.test.ts new file mode 100644 index 0000000..40b0205 --- /dev/null +++ b/src/lib/notifications/rules.test.ts @@ -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(); + }); +}); diff --git a/src/lib/stores/clipboard.test.ts b/src/lib/stores/clipboard.test.ts new file mode 100644 index 0000000..eb955c1 --- /dev/null +++ b/src/lib/stores/clipboard.test.ts @@ -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"], + [/^ { + 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(" { + 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("")).toBe("html"); + }); + + it("detects html tags", () => { + expect(detectLanguage("")).toBe("html"); + }); + + it("detects div tags", () => { + expect(detectLanguage("
bar
")).toBe("html"); + }); + + it("detects span tags", () => { + expect(detectLanguage("text")).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 \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 (1–59 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 (1–23 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 (1–6 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"); + }); + }); +});