feat(FN-3394): add evals dashboard view with API routes and client hooks
Adds a full evals dashboard view (FN-3394) with a client hook, server-side read routes, and navigation integration across the Header and mobile nav, backed by comprehensive tests. Wires Hermes chat message tools with mailbox behavior documentation (FN-3620), and extends the agent heartbeat and tools Fusion-Task-Id: FN-3394
This commit is contained in:
108
packages/dashboard/app/hooks/__tests__/useEvals.test.ts
Normal file
108
packages/dashboard/app/hooks/__tests__/useEvals.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useEvals } from "../useEvals";
|
||||
|
||||
const mockListEvals = vi.fn();
|
||||
const mockGetEval = vi.fn();
|
||||
const mockListEvalRuns = vi.fn();
|
||||
|
||||
vi.mock("../../api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../api")>();
|
||||
return {
|
||||
...actual,
|
||||
listEvals: (...args: unknown[]) => mockListEvals(...args),
|
||||
getEval: (...args: unknown[]) => mockGetEval(...args),
|
||||
listEvalRuns: (...args: unknown[]) => mockListEvalRuns(...args),
|
||||
};
|
||||
});
|
||||
|
||||
describe("useEvals", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
mockListEvals.mockResolvedValue({ results: [], count: 0 });
|
||||
mockListEvalRuns.mockResolvedValue({ runs: [] });
|
||||
mockGetEval.mockResolvedValue({ result: { id: "ER-1", runId: "RUN-1", taskId: "FN-1", taskSnapshot: { title: "Task" }, categoryScores: [], evidence: [], followUps: [], createdAt: new Date().toISOString() } });
|
||||
});
|
||||
|
||||
it("propagates filters and normalizes list/detail payloads", async () => {
|
||||
mockListEvals.mockResolvedValue({
|
||||
items: [{ evalId: "ER-1", batchId: "RUN-1", taskSnapshot: { taskId: "FN-1", title: "Task One" }, overallScore: 0.72, maxScore: 1, scores: { categories: [{ category: "quality", score: 0.8, maxScore: 1 }] }, timestamp: "2026-05-01T00:00:00.000Z" }],
|
||||
count: 1,
|
||||
});
|
||||
mockGetEval.mockResolvedValue({
|
||||
result: { id: "ER-1", runId: "RUN-1", taskId: "FN-1", taskSnapshot: { title: "Task One" }, categoryScores: [], details: { evidence: [{ label: "Task", source: "task" }] }, recommendations: { followUps: [{ id: "FU-1", title: "Follow up", description: "d", reason: "r", suggestedState: "suggested", evidence: [] }] }, rationale: "because", createdAt: "2026-05-01T00:00:00.000Z" },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useEvals({ projectId: "p1" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.results[0]).toMatchObject({ id: "ER-1", runId: "RUN-1", taskId: "FN-1", taskTitle: "Task One" });
|
||||
});
|
||||
|
||||
act(() => result.current.setFilters((prev) => ({ ...prev, q: "fn-1", runId: "RUN-1", scoreMin: "0.5", scoreMax: "1" })));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockListEvals).toHaveBeenLastCalledWith({ q: "fn-1", runId: "RUN-1", scoreMin: 0.5, scoreMax: 1, limit: 100, offset: 0 }, "p1");
|
||||
});
|
||||
|
||||
act(() => result.current.setSelectedEvalId("ER-1"));
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedEval).toMatchObject({ id: "ER-1", rationale: "because" });
|
||||
expect(result.current.selectedEval?.evidence).toHaveLength(1);
|
||||
expect(result.current.selectedEval?.followUps).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("clears selection when refreshed list no longer contains selected eval", async () => {
|
||||
mockListEvals
|
||||
.mockResolvedValueOnce({ results: [{ id: "ER-1", runId: "RUN-1", taskId: "FN-1", taskSnapshot: { title: "A" }, categoryScores: [], evidence: [], followUps: [], createdAt: "2026-05-01T00:00:00.000Z" }], count: 1 })
|
||||
.mockResolvedValueOnce({ results: [], count: 0 });
|
||||
|
||||
const { result } = renderHook(() => useEvals({ projectId: "p1" }));
|
||||
|
||||
await waitFor(() => expect(result.current.results).toHaveLength(1));
|
||||
act(() => result.current.setSelectedEvalId("ER-1"));
|
||||
await act(async () => {
|
||||
await result.current.refresh();
|
||||
});
|
||||
|
||||
expect(result.current.selectedEvalId).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores stale responses from older requests", async () => {
|
||||
let firstResolve: ((value: unknown) => void) | null = null;
|
||||
mockListEvals
|
||||
.mockReturnValueOnce(new Promise((resolve) => { firstResolve = resolve; }))
|
||||
.mockResolvedValueOnce({ results: [{ id: "ER-NEW", runId: "RUN-2", taskId: "FN-2", taskSnapshot: { title: "New" }, categoryScores: [], evidence: [], followUps: [], createdAt: "2026-05-02T00:00:00.000Z" }], count: 1 });
|
||||
|
||||
const { result } = renderHook(() => useEvals({ projectId: "p1" }));
|
||||
|
||||
act(() => {
|
||||
void result.current.refresh();
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.refresh();
|
||||
});
|
||||
await act(async () => {
|
||||
firstResolve?.({ results: [{ id: "ER-OLD", runId: "RUN-1", taskId: "FN-1", taskSnapshot: { title: "Old" }, categoryScores: [], evidence: [], followUps: [], createdAt: "2026-05-01T00:00:00.000Z" }], count: 1 });
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.results[0]?.id).toBe("ER-NEW");
|
||||
});
|
||||
|
||||
it("registers 15-second polling interval", () => {
|
||||
const setIntervalSpy = vi.spyOn(window, "setInterval");
|
||||
const clearIntervalSpy = vi.spyOn(window, "clearInterval");
|
||||
|
||||
const { unmount } = renderHook(() => useEvals({ projectId: "p1" }));
|
||||
|
||||
expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 15_000);
|
||||
unmount();
|
||||
expect(clearIntervalSpy).toHaveBeenCalled();
|
||||
|
||||
setIntervalSpy.mockRestore();
|
||||
clearIntervalSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user