test(KB-606): add comprehensive test coverage across packages

- Add tests for core board logic (board.test.ts)
- Add tests for dashboard useToast hook (useToast.test.tsx)
- Add tests for dashboard model filter utility (modelFilter.test.ts)
- Add tests for agent heartbeat monitor (agent-heartbeat.test.ts)
This commit is contained in:
gsxdsm
2026-03-31 17:27:28 -07:00
parent 965783db95
commit b042ffb278
4 changed files with 1222 additions and 0 deletions

View File

@@ -0,0 +1,367 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, renderHook, act, screen } from "@testing-library/react";
import { ToastProvider, useToast } from "./useToast";
import type { ReactNode } from "react";
/**
* Toast hook tests
*
* Tests for the Toast context provider and useToast hook.
* Note: The current implementation does not clean up setTimeout timers
* when the provider unmounts, which could lead to memory leaks.
*/
function createWrapper() {
return function Wrapper({ children }: { children: ReactNode }) {
return <ToastProvider>{children}</ToastProvider>;
};
}
describe("ToastProvider", () => {
it("renders children correctly", () => {
const { container } = render(
<ToastProvider>
<div data-testid="child">Test Child</div>
</ToastProvider>
);
expect(screen.getByTestId("child")).toBeDefined();
expect(container.textContent).toContain("Test Child");
});
});
describe("useToast", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
});
it("throws error when used outside provider", () => {
// Suppress console.error for expected error
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => {
renderHook(() => useToast());
}).toThrow("useToast must be used within ToastProvider");
consoleSpy.mockRestore();
});
it("returns correct context value within provider", () => {
const { result } = renderHook(() => useToast(), {
wrapper: createWrapper(),
});
expect(result.current).toHaveProperty("toasts");
expect(result.current).toHaveProperty("addToast");
expect(result.current).toHaveProperty("removeToast");
expect(typeof result.current.addToast).toBe("function");
expect(typeof result.current.removeToast).toBe("function");
});
it("`toasts` array is initially empty", () => {
const { result } = renderHook(() => useToast(), {
wrapper: createWrapper(),
});
expect(result.current.toasts).toEqual([]);
});
describe("addToast", () => {
it("adds a toast to the list with correct message and type", () => {
const { result } = renderHook(() => useToast(), {
wrapper: createWrapper(),
});
act(() => {
result.current.addToast("Test message", "success");
});
expect(result.current.toasts).toHaveLength(1);
expect(result.current.toasts[0].message).toBe("Test message");
expect(result.current.toasts[0].type).toBe("success");
});
it("auto-assigns unique incrementing IDs starting from 0", () => {
const { result } = renderHook(() => useToast(), {
wrapper: createWrapper(),
});
act(() => {
result.current.addToast("First", "info");
result.current.addToast("Second", "info");
result.current.addToast("Third", "info");
});
expect(result.current.toasts[0].id).toBe(0);
expect(result.current.toasts[1].id).toBe(1);
expect(result.current.toasts[2].id).toBe(2);
});
it("defaults type to 'info' when not specified", () => {
const { result } = renderHook(() => useToast(), {
wrapper: createWrapper(),
});
act(() => {
result.current.addToast("Test message");
});
expect(result.current.toasts[0].type).toBe("info");
});
it("accepts 'success' type", () => {
const { result } = renderHook(() => useToast(), {
wrapper: createWrapper(),
});
act(() => {
result.current.addToast("Success!", "success");
});
expect(result.current.toasts[0].type).toBe("success");
});
it("accepts 'error' type", () => {
const { result } = renderHook(() => useToast(), {
wrapper: createWrapper(),
});
act(() => {
result.current.addToast("Error!", "error");
});
expect(result.current.toasts[0].type).toBe("error");
});
it("accepts 'info' type explicitly", () => {
const { result } = renderHook(() => useToast(), {
wrapper: createWrapper(),
});
act(() => {
result.current.addToast("Info", "info");
});
expect(result.current.toasts[0].type).toBe("info");
});
it("toasts auto-remove after 4000ms", () => {
const { result } = renderHook(() => useToast(), {
wrapper: createWrapper(),
});
act(() => {
result.current.addToast("Test message");
});
expect(result.current.toasts).toHaveLength(1);
// Advance time by 4000ms
act(() => {
vi.advanceTimersByTime(4000);
});
expect(result.current.toasts).toHaveLength(0);
});
it("toasts remain visible before 4000ms expires", () => {
const { result } = renderHook(() => useToast(), {
wrapper: createWrapper(),
});
act(() => {
result.current.addToast("Test message");
});
// Advance time by 3999ms - toast should still be there
act(() => {
vi.advanceTimersByTime(3999);
});
expect(result.current.toasts).toHaveLength(1);
});
});
describe("removeToast", () => {
it("manually removes a specific toast by ID", () => {
const { result } = renderHook(() => useToast(), {
wrapper: createWrapper(),
});
act(() => {
result.current.addToast("First", "info");
result.current.addToast("Second", "info");
result.current.addToast("Third", "info");
});
expect(result.current.toasts).toHaveLength(3);
// Remove the second toast (ID: 1)
act(() => {
result.current.removeToast(1);
});
expect(result.current.toasts).toHaveLength(2);
expect(result.current.toasts.map((t) => t.id)).toEqual([0, 2]);
});
it("does nothing when removing non-existent toast ID", () => {
const { result } = renderHook(() => useToast(), {
wrapper: createWrapper(),
});
act(() => {
result.current.addToast("Test", "info");
});
expect(result.current.toasts).toHaveLength(1);
// Try to remove non-existent ID
act(() => {
result.current.removeToast(999);
});
// Toast should still be there
expect(result.current.toasts).toHaveLength(1);
});
});
describe("multiple toasts", () => {
it("can exist simultaneously", () => {
const { result } = renderHook(() => useToast(), {
wrapper: createWrapper(),
});
act(() => {
result.current.addToast("First", "info");
result.current.addToast("Second", "success");
result.current.addToast("Third", "error");
});
expect(result.current.toasts).toHaveLength(3);
expect(result.current.toasts[0].message).toBe("First");
expect(result.current.toasts[1].message).toBe("Second");
expect(result.current.toasts[2].message).toBe("Third");
});
it("auto-remove independently based on their creation time", () => {
const { result } = renderHook(() => useToast(), {
wrapper: createWrapper(),
});
// Add first toast at time 0
act(() => {
result.current.addToast("First", "info");
});
// Advance 2000ms and add second toast
act(() => {
vi.advanceTimersByTime(2000);
});
act(() => {
result.current.addToast("Second", "info");
});
expect(result.current.toasts).toHaveLength(2);
// Advance 2000ms more (total 4000ms from first toast)
act(() => {
vi.advanceTimersByTime(2000);
});
// First toast should be gone, second still there
expect(result.current.toasts).toHaveLength(1);
expect(result.current.toasts[0].message).toBe("Second");
// Advance 2000ms more (total 4000ms from second toast)
act(() => {
vi.advanceTimersByTime(2000);
});
// Both should be gone
expect(result.current.toasts).toHaveLength(0);
});
it("have unique IDs even with same message", () => {
const { result } = renderHook(() => useToast(), {
wrapper: createWrapper(),
});
act(() => {
result.current.addToast("Same message", "info");
result.current.addToast("Same message", "info");
result.current.addToast("Same message", "info");
});
const ids = result.current.toasts.map((t) => t.id);
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(3); // All IDs should be unique
});
});
describe("timer cleanup on unmount (future improvement)", () => {
// Note: The current implementation does not clean up setTimeout timers
// when the provider unmounts. This is a known limitation that could lead
// to memory leaks and React warnings about state updates on unmounted
// components. The following test documents the expected behavior if this
// is ever fixed in the future.
it.skip("should clear pending timers when provider unmounts (not currently implemented)", () => {
// This test is skipped because the current implementation does not
// store timer references for cleanup. To implement this properly,
// the addToast function would need to:
// 1. Store setTimeout handle in a ref
// 2. Return cleanup function or use useEffect cleanup
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
const { result, unmount } = renderHook(() => useToast(), {
wrapper: createWrapper(),
});
act(() => {
result.current.addToast("Test");
});
unmount();
// This expectation would fail with current implementation
expect(clearTimeoutSpy).toHaveBeenCalled();
clearTimeoutSpy.mockRestore();
});
it("continues auto-removing toasts after one is removed early", () => {
const { result } = renderHook(() => useToast(), {
wrapper: createWrapper(),
});
act(() => {
result.current.addToast("First", "info");
result.current.addToast("Second", "info");
});
// Remove first toast immediately
act(() => {
result.current.removeToast(0);
});
expect(result.current.toasts).toHaveLength(1);
expect(result.current.toasts[0].message).toBe("Second");
// Advance full 4000ms from when second toast was created
act(() => {
vi.advanceTimersByTime(4000);
});
// Second toast should still auto-remove
expect(result.current.toasts).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,166 @@
import { describe, it, expect } from "vitest";
import { filterModels } from "./modelFilter";
import type { ModelInfo } from "../api";
/**
* Model filter utility tests
*
* Tests for filtering AI models by provider, ID, or name.
*/
function createModel(
provider: string,
id: string,
name: string,
reasoning = false,
contextWindow = 128000,
): ModelInfo {
return { provider, id, name, reasoning, contextWindow };
}
describe("filterModels", () => {
const models: ModelInfo[] = [
createModel("anthropic", "claude-sonnet-4-5", "Claude Sonnet 4.5"),
createModel("anthropic", "claude-opus-4", "Claude Opus 4", true),
createModel("openai", "gpt-4o", "GPT-4o"),
createModel("openai", "gpt-4o-mini", "GPT-4o Mini"),
createModel("google", "gemini-pro", "Gemini Pro"),
createModel("ollama", "llama3.1", "Llama 3.1"),
];
it("returns all models when filter is empty string", () => {
expect(filterModels(models, "")).toEqual(models);
});
it("returns all models when filter is whitespace-only", () => {
expect(filterModels(models, " ")).toEqual(models);
expect(filterModels(models, " \t \n ")).toEqual(models);
});
it("filters by provider (case-insensitive)", () => {
const result = filterModels(models, "anthropic");
expect(result).toHaveLength(2);
expect(result.map((m) => m.id)).toContain("claude-sonnet-4-5");
expect(result.map((m) => m.id)).toContain("claude-opus-4");
});
it("filters by provider (uppercase)", () => {
const result = filterModels(models, "ANTHROPIC");
expect(result).toHaveLength(2);
});
it("filters by provider (mixed case)", () => {
const result = filterModels(models, "OpenAI");
expect(result).toHaveLength(2);
expect(result.map((m) => m.id)).toContain("gpt-4o");
expect(result.map((m) => m.id)).toContain("gpt-4o-mini");
});
it("filters by model ID (case-insensitive, matches exact ID)", () => {
// Using unique ID "opus" that doesn't appear in other models
const result = filterModels(models, "opus");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("claude-opus-4");
});
it("filters by partial model ID (substring matching)", () => {
const result = filterModels(models, "claude");
expect(result).toHaveLength(2);
expect(result.map((m) => m.provider)).toContain("anthropic");
});
it("filters by model name (case-insensitive)", () => {
const result = filterModels(models, "sonnet");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("claude-sonnet-4-5");
});
it("filters by model name (partial match)", () => {
// "opus" appears in "Claude Opus 4" name
const result = filterModels(models, "opus");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("claude-opus-4");
});
it("handles multi-word filters with AND logic", () => {
// "anthropic" AND "sonnet" should match only Claude Sonnet
const result = filterModels(models, "anthropic sonnet");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("claude-sonnet-4-5");
});
it("handles multi-word filters with multiple matches", () => {
// "gpt" should match both gpt-4o and gpt-4o-mini
const result = filterModels(models, "gpt 4o");
expect(result).toHaveLength(2);
});
it("handles partial matches across multiple fields", () => {
// "pro" matches "Gemini Pro" in name
const result = filterModels(models, "pro");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("gemini-pro");
});
it("returns empty array when no matches", () => {
const result = filterModels(models, "nonexistent");
expect(result).toEqual([]);
});
it("returns empty array for non-matching multi-word filter", () => {
// "anthropic" AND "nonexistent" should match nothing
const result = filterModels(models, "anthropic nonexistent");
expect(result).toEqual([]);
});
it("handles empty model array", () => {
expect(filterModels([], "")).toEqual([]);
expect(filterModels([], "test")).toEqual([]);
});
it("handles single model array", () => {
const singleModel = [models[0]];
expect(filterModels(singleModel, "")).toEqual(singleModel);
expect(filterModels(singleModel, "anthropic")).toEqual(singleModel);
expect(filterModels(singleModel, "openai")).toEqual([]);
});
it("is case-insensitive across all fields", () => {
// Mix of cases should all work
expect(filterModels(models, "CLAUDE")).toHaveLength(2);
expect(filterModels(models, "GPT-4O")).toHaveLength(2);
expect(filterModels(models, "GEMINI")).toHaveLength(1);
expect(filterModels(models, "OPUS")).toHaveLength(1);
});
it("matches model ID with special characters", () => {
const modelsWithSpecial = [
createModel("anthropic", "claude-3.5-sonnet", "Claude 3.5 Sonnet"),
createModel("openai", "gpt-4-turbo-preview", "GPT-4 Turbo"),
];
expect(filterModels(modelsWithSpecial, "3.5")).toHaveLength(1);
expect(filterModels(modelsWithSpecial, "turbo-preview")).toHaveLength(1);
});
it("handles leading and trailing whitespace in filter", () => {
const result = filterModels(models, " anthropic ");
expect(result).toHaveLength(2);
});
it("handles multiple spaces between terms", () => {
const result = filterModels(models, "anthropic sonnet");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("claude-sonnet-4-5");
});
it("matches substring anywhere in provider, id, or name", () => {
// "ai" appears in "openai" provider
const result = filterModels(models, "ai");
expect(result.map((m) => m.provider)).toContain("openai");
// "ll" appears in "ollama" provider and "llama" id
const resultLl = filterModels(models, "ll");
expect(resultLl.map((m) => m.id)).toContain("llama3.1");
});
});