import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { resolveToolEmoji, createStatusReactionController, DEFAULT_EMOJIS, DEFAULT_TIMING, CODING_TOOL_TOKENS, WEB_TOOL_TOKENS, type StatusReactionAdapter, } from "./status-reactions.js"; // ───────────────────────────────────────────────────────────────────────────── // Mock Adapter // ───────────────────────────────────────────────────────────────────────────── const createMockAdapter = () => { const calls: { method: string; emoji: string }[] = []; return { adapter: { setReaction: vi.fn(async (emoji: string) => { calls.push({ method: "set", emoji }); }), removeReaction: vi.fn(async (emoji: string) => { calls.push({ method: "remove", emoji }); }), } as StatusReactionAdapter, calls, }; }; const createEnabledController = ( overrides: Partial[0]> = {}, ) => { const { adapter, calls } = createMockAdapter(); const controller = createStatusReactionController({ enabled: true, adapter, initialEmoji: "👀", ...overrides, }); return { adapter, calls, controller }; }; const createSetOnlyController = () => { const calls: { method: string; emoji: string }[] = []; const adapter: StatusReactionAdapter = { setReaction: vi.fn(async (emoji: string) => { calls.push({ method: "set", emoji }); }), }; const controller = createStatusReactionController({ enabled: true, adapter, initialEmoji: "👀", }); return { calls, controller }; }; // ───────────────────────────────────────────────────────────────────────────── // Tests // ───────────────────────────────────────────────────────────────────────────── describe("resolveToolEmoji", () => { const cases: Array<{ name: string; tool: string | undefined; expected: string; }> = [ { name: "returns coding emoji for exec tool", tool: "exec", expected: DEFAULT_EMOJIS.coding }, { name: "returns coding emoji for process tool", tool: "process", expected: DEFAULT_EMOJIS.coding, }, { name: "returns web emoji for web_search tool", tool: "web_search", expected: DEFAULT_EMOJIS.web, }, { name: "returns web emoji for browser tool", tool: "browser", expected: DEFAULT_EMOJIS.web }, { name: "returns tool emoji for unknown tool", tool: "unknown_tool", expected: DEFAULT_EMOJIS.tool, }, { name: "returns tool emoji for empty string", tool: "", expected: DEFAULT_EMOJIS.tool }, { name: "returns tool emoji for undefined", tool: undefined, expected: DEFAULT_EMOJIS.tool }, { name: "is case-insensitive", tool: "EXEC", expected: DEFAULT_EMOJIS.coding }, { name: "matches tokens within tool names", tool: "my_exec_wrapper", expected: DEFAULT_EMOJIS.coding, }, ]; for (const testCase of cases) { it(`should ${testCase.name}`, () => { expect(resolveToolEmoji(testCase.tool, DEFAULT_EMOJIS)).toBe(testCase.expected); }); } }); describe("createStatusReactionController", () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); }); it("should not call adapter when disabled", async () => { const { adapter, calls } = createMockAdapter(); const controller = createStatusReactionController({ enabled: false, adapter, initialEmoji: "👀", }); void controller.setQueued(); void controller.setThinking(); await vi.advanceTimersByTimeAsync(1000); expect(calls).toHaveLength(0); }); it("should call setReaction with initialEmoji for setQueued immediately", async () => { const { calls, controller } = createEnabledController(); void controller.setQueued(); await vi.runAllTimersAsync(); expect(calls).toContainEqual({ method: "set", emoji: "👀" }); }); it("should debounce setThinking and eventually call adapter", async () => { const { calls, controller } = createEnabledController(); void controller.setThinking(); // Before debounce period await vi.advanceTimersByTimeAsync(500); expect(calls).toHaveLength(0); // After debounce period await vi.advanceTimersByTimeAsync(300); expect(calls).toContainEqual({ method: "set", emoji: DEFAULT_EMOJIS.thinking }); }); it("should debounce setCompacting and eventually call adapter", async () => { const { calls, controller } = createEnabledController(); void controller.setCompacting(); await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); expect(calls).toContainEqual({ method: "set", emoji: DEFAULT_EMOJIS.compacting }); }); it("should classify tool name and debounce", async () => { const { calls, controller } = createEnabledController(); void controller.setTool("exec"); await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); expect(calls).toContainEqual({ method: "set", emoji: DEFAULT_EMOJIS.coding }); }); const immediateTerminalCases = [ { name: "setDone", run: (controller: ReturnType) => controller.setDone(), expected: DEFAULT_EMOJIS.done, }, { name: "setError", run: (controller: ReturnType) => controller.setError(), expected: DEFAULT_EMOJIS.error, }, ] as const; for (const testCase of immediateTerminalCases) { it(`should execute ${testCase.name} immediately without debounce`, async () => { const { calls, controller } = createEnabledController(); await testCase.run(controller); await vi.runAllTimersAsync(); expect(calls).toContainEqual({ method: "set", emoji: testCase.expected }); }); } const terminalIgnoreCases = [ { name: "ignore setThinking after setDone (terminal state)", terminal: (controller: ReturnType) => controller.setDone(), followup: (controller: ReturnType) => { void controller.setThinking(); }, }, { name: "ignore setTool after setError (terminal state)", terminal: (controller: ReturnType) => controller.setError(), followup: (controller: ReturnType) => { void controller.setTool("exec"); }, }, ] as const; for (const testCase of terminalIgnoreCases) { it(`should ${testCase.name}`, async () => { const { calls, controller } = createEnabledController(); await testCase.terminal(controller); const callsAfterTerminal = calls.length; testCase.followup(controller); await vi.advanceTimersByTimeAsync(1000); expect(calls.length).toBe(callsAfterTerminal); }); } it("should only fire last state when rapidly changing (debounce)", async () => { const { calls, controller } = createEnabledController(); void controller.setThinking(); await vi.advanceTimersByTimeAsync(100); void controller.setTool("web_search"); await vi.advanceTimersByTimeAsync(100); void controller.setTool("exec"); await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); // Should only have the last one (exec → coding) const setEmojis = calls.filter((c) => c.method === "set").map((c) => c.emoji); expect(setEmojis).toEqual([DEFAULT_EMOJIS.coding]); }); it("should deduplicate same emoji calls", async () => { const { calls, controller } = createEnabledController(); void controller.setThinking(); await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); const callsAfterFirst = calls.length; void controller.setThinking(); await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); // Should not add another call expect(calls.length).toBe(callsAfterFirst); }); it("should cancel a pending compacting emoji before resuming thinking", async () => { const { calls, controller } = createEnabledController(); void controller.setCompacting(); await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs - 1); controller.cancelPending(); void controller.setThinking(); await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); const setEmojis = calls.filter((call) => call.method === "set").map((call) => call.emoji); expect(setEmojis).toEqual([DEFAULT_EMOJIS.thinking]); }); it("should call removeReaction when adapter supports it and emoji changes", async () => { const { calls, controller } = createEnabledController(); void controller.setQueued(); await vi.runAllTimersAsync(); void controller.setThinking(); await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); // Should set thinking, then remove queued expect(calls).toContainEqual({ method: "set", emoji: DEFAULT_EMOJIS.thinking }); expect(calls).toContainEqual({ method: "remove", emoji: "👀" }); }); it("should only call setReaction when adapter lacks removeReaction", async () => { const { calls, controller } = createSetOnlyController(); void controller.setQueued(); await vi.runAllTimersAsync(); void controller.setThinking(); await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); // Should only have set calls, no remove const removeCalls = calls.filter((c) => c.method === "remove"); expect(removeCalls).toHaveLength(0); expect(calls.filter((c) => c.method === "set").length).toBeGreaterThan(0); }); it("should clear all known emojis when adapter supports removeReaction", async () => { const { calls, controller } = createEnabledController(); void controller.setQueued(); await vi.runAllTimersAsync(); await controller.clear(); // Should have removed multiple emojis const removeCalls = calls.filter((c) => c.method === "remove"); expect(removeCalls.length).toBeGreaterThan(0); }); it("should handle clear gracefully when adapter lacks removeReaction", async () => { const { calls, controller } = createSetOnlyController(); await controller.clear(); // Should not throw, no remove calls const removeCalls = calls.filter((c) => c.method === "remove"); expect(removeCalls).toHaveLength(0); }); it("should restore initial emoji", async () => { const { calls, controller } = createEnabledController(); void controller.setThinking(); await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); await controller.restoreInitial(); expect(calls).toContainEqual({ method: "set", emoji: "👀" }); }); it("should use custom emojis when provided", async () => { const { calls, controller } = createEnabledController({ emojis: { thinking: "🤔", done: "🎉", }, }); void controller.setThinking(); await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); expect(calls).toContainEqual({ method: "set", emoji: "🤔" }); await controller.setDone(); await vi.runAllTimersAsync(); expect(calls).toContainEqual({ method: "set", emoji: "🎉" }); }); it("should use custom timing when provided", async () => { const { calls, controller } = createEnabledController({ timing: { debounceMs: 100, }, }); void controller.setThinking(); // Should not fire at 50ms await vi.advanceTimersByTimeAsync(50); expect(calls).toHaveLength(0); // Should fire at 100ms await vi.advanceTimersByTimeAsync(60); expect(calls).toContainEqual({ method: "set", emoji: DEFAULT_EMOJIS.thinking }); }); const stallCases = [ { name: "soft stall timer after stallSoftMs", delayMs: DEFAULT_TIMING.stallSoftMs, expected: DEFAULT_EMOJIS.stallSoft, }, { name: "hard stall timer after stallHardMs", delayMs: DEFAULT_TIMING.stallHardMs, expected: DEFAULT_EMOJIS.stallHard, }, ] as const; const createControllerAfterThinking = async () => { const state = createEnabledController(); void state.controller.setThinking(); await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); return state; }; for (const testCase of stallCases) { it(`should trigger ${testCase.name}`, async () => { const { calls } = await createControllerAfterThinking(); await vi.advanceTimersByTimeAsync(testCase.delayMs); expect(calls).toContainEqual({ method: "set", emoji: testCase.expected }); }); } const stallResetCases = [ { name: "phase change", runUpdate: (controller: ReturnType) => { void controller.setTool("exec"); return vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); }, }, { name: "repeated same-phase updates", runUpdate: (controller: ReturnType) => { void controller.setThinking(); return Promise.resolve(); }, }, ] as const; for (const testCase of stallResetCases) { it(`should reset stall timers on ${testCase.name}`, async () => { const { calls, controller } = await createControllerAfterThinking(); // Advance halfway to soft stall. await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.stallSoftMs / 2); await testCase.runUpdate(controller); // Advance another halfway - should not trigger stall yet. await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.stallSoftMs / 2); const stallCalls = calls.filter((c) => c.emoji === DEFAULT_EMOJIS.stallSoft); expect(stallCalls).toHaveLength(0); }); } it("should call onError callback when adapter throws", async () => { const onError = vi.fn(); const adapter: StatusReactionAdapter = { setReaction: vi.fn(async () => { throw new Error("Network error"); }), }; const controller = createStatusReactionController({ enabled: true, adapter, initialEmoji: "👀", onError, }); void controller.setQueued(); await vi.runAllTimersAsync(); expect(onError).toHaveBeenCalled(); }); }); describe("constants", () => { it("should export CODING_TOOL_TOKENS", () => { for (const token of ["exec", "read", "write"]) { expect(CODING_TOOL_TOKENS).toContain(token); } }); it("should export WEB_TOOL_TOKENS", () => { for (const token of ["web_search", "browser"]) { expect(WEB_TOOL_TOKENS).toContain(token); } }); it("should export DEFAULT_EMOJIS with all required keys", () => { const emojiKeys = [ "queued", "thinking", "compacting", "tool", "coding", "web", "done", "error", "stallSoft", "stallHard", ] as const; for (const key of emojiKeys) { expect(DEFAULT_EMOJIS).toHaveProperty(key); } }); it("should export DEFAULT_TIMING with all required keys", () => { for (const key of ["debounceMs", "stallSoftMs", "stallHardMs", "doneHoldMs", "errorHoldMs"]) { expect(DEFAULT_TIMING).toHaveProperty(key); } }); });