test(playground): cover hooks and streamMetrics

This commit is contained in:
diegosouzapw
2026-05-27 23:59:47 -03:00
parent 06210da48f
commit 2cc0f5bb5b
6 changed files with 1674 additions and 0 deletions

View File

@@ -0,0 +1,241 @@
// tests/unit/playground-stream-metrics.test.ts
// Node native test runner — no React
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { computeMetrics } from "../../src/lib/playground/streamMetrics.js";
describe("computeMetrics", () => {
const BASE_START = 1000;
const BASE_FIRST = 1200;
const BASE_FINISH = 3000;
describe("ttftMs", () => {
it("is firstChunkAt - startedAt when both are set", () => {
const m = computeMetrics({
startedAt: BASE_START,
firstChunkAt: BASE_FIRST,
finishedAt: BASE_FINISH,
tokensIn: 10,
tokensOut: 20,
});
assert.equal(m.ttftMs, BASE_FIRST - BASE_START); // 200
});
it("is null when firstChunkAt is null", () => {
const m = computeMetrics({
startedAt: BASE_START,
firstChunkAt: null,
finishedAt: BASE_FINISH,
tokensIn: 10,
tokensOut: 20,
});
assert.equal(m.ttftMs, null);
});
it("is null when startedAt is null", () => {
const m = computeMetrics({
startedAt: null,
firstChunkAt: BASE_FIRST,
finishedAt: BASE_FINISH,
tokensIn: 10,
tokensOut: 20,
});
assert.equal(m.ttftMs, null);
});
it("is null when both are null", () => {
const m = computeMetrics({
startedAt: null,
firstChunkAt: null,
finishedAt: null,
tokensIn: 0,
tokensOut: 0,
});
assert.equal(m.ttftMs, null);
});
});
describe("totalMs", () => {
it("is finishedAt - startedAt when both are set", () => {
const m = computeMetrics({
startedAt: BASE_START,
firstChunkAt: BASE_FIRST,
finishedAt: BASE_FINISH,
tokensIn: 10,
tokensOut: 20,
});
assert.equal(m.totalMs, BASE_FINISH - BASE_START); // 2000
});
it("is null when finishedAt is null", () => {
const m = computeMetrics({
startedAt: BASE_START,
firstChunkAt: BASE_FIRST,
finishedAt: null,
tokensIn: 10,
tokensOut: 20,
});
assert.equal(m.totalMs, null);
});
it("is null when startedAt is null", () => {
const m = computeMetrics({
startedAt: null,
firstChunkAt: BASE_FIRST,
finishedAt: BASE_FINISH,
tokensIn: 10,
tokensOut: 20,
});
assert.equal(m.totalMs, null);
});
});
describe("tps (tokens per second)", () => {
it("is tokensOut / (totalMs / 1000)", () => {
// totalMs = 2000ms => 2s; tokensOut = 20 => tps = 10
const m = computeMetrics({
startedAt: BASE_START,
firstChunkAt: BASE_FIRST,
finishedAt: BASE_FINISH,
tokensIn: 10,
tokensOut: 20,
});
assert.equal(m.tps, 20 / (2000 / 1000)); // 10
});
it("is null when tokensOut is 0", () => {
const m = computeMetrics({
startedAt: BASE_START,
firstChunkAt: BASE_FIRST,
finishedAt: BASE_FINISH,
tokensIn: 10,
tokensOut: 0,
});
assert.equal(m.tps, null);
});
it("is null when totalMs is null (stream not finished)", () => {
const m = computeMetrics({
startedAt: BASE_START,
firstChunkAt: BASE_FIRST,
finishedAt: null,
tokensIn: 10,
tokensOut: 20,
});
assert.equal(m.tps, null);
});
it("is null when totalMs is 0 (avoid division by zero)", () => {
const m = computeMetrics({
startedAt: BASE_START,
firstChunkAt: BASE_FIRST,
finishedAt: BASE_START, // same as start => 0ms
tokensIn: 10,
tokensOut: 20,
});
assert.equal(m.tps, null);
});
it("computes correct tps for various token counts", () => {
const cases: Array<{ tokensOut: number; totalMs: number; expected: number }> = [
{ tokensOut: 100, totalMs: 1000, expected: 100 },
{ tokensOut: 50, totalMs: 2000, expected: 25 },
{ tokensOut: 1, totalMs: 500, expected: 2 },
];
for (const { tokensOut, totalMs, expected } of cases) {
const m = computeMetrics({
startedAt: 0,
firstChunkAt: 1,
finishedAt: totalMs,
tokensIn: 5,
tokensOut,
});
assert.equal(m.tps, expected, `tps should be ${expected} for ${tokensOut}t/${totalMs}ms`);
}
});
});
describe("costUsd", () => {
it("computes cost: (tokensIn × inPer1k + tokensOut × outPer1k) / 1000", () => {
const m = computeMetrics({
startedAt: BASE_START,
firstChunkAt: BASE_FIRST,
finishedAt: BASE_FINISH,
tokensIn: 1000,
tokensOut: 500,
pricing: { inUsdPer1k: 0.003, outUsdPer1k: 0.015, estimated: true },
});
// cost = (1000 * 0.003 + 500 * 0.015) / 1000 = (3 + 7.5) / 1000 = 0.0105
assert.ok(Math.abs((m.costUsd ?? 0) - 0.0105) < 1e-10);
});
it("is null when pricing is absent", () => {
const m = computeMetrics({
startedAt: BASE_START,
firstChunkAt: BASE_FIRST,
finishedAt: BASE_FINISH,
tokensIn: 1000,
tokensOut: 500,
});
assert.equal(m.costUsd, null);
});
it("handles zero tokens", () => {
const m = computeMetrics({
startedAt: BASE_START,
firstChunkAt: BASE_FIRST,
finishedAt: BASE_FINISH,
tokensIn: 0,
tokensOut: 0,
pricing: { inUsdPer1k: 0.003, outUsdPer1k: 0.015, estimated: true },
});
assert.equal(m.costUsd, 0);
});
it("computes gpt-4o style pricing correctly", () => {
// gpt-4o: in=0.0025, out=0.01 per 1k
const m = computeMetrics({
startedAt: 0,
firstChunkAt: 100,
finishedAt: 2000,
tokensIn: 2000,
tokensOut: 300,
pricing: { inUsdPer1k: 0.0025, outUsdPer1k: 0.01, estimated: true },
});
const expected = (2000 * 0.0025 + 300 * 0.01) / 1000; // (5 + 3) / 1000 = 0.008
assert.ok(Math.abs((m.costUsd ?? 0) - expected) < 1e-10);
});
});
describe("passthrough fields", () => {
it("returns tokensIn and tokensOut unchanged", () => {
const m = computeMetrics({
startedAt: BASE_START,
firstChunkAt: BASE_FIRST,
finishedAt: BASE_FINISH,
tokensIn: 42,
tokensOut: 77,
});
assert.equal(m.tokensIn, 42);
assert.equal(m.tokensOut, 77);
});
});
describe("initial state (all null)", () => {
it("returns all-null metrics when nothing has happened", () => {
const m = computeMetrics({
startedAt: null,
firstChunkAt: null,
finishedAt: null,
tokensIn: 0,
tokensOut: 0,
});
assert.equal(m.ttftMs, null);
assert.equal(m.totalMs, null);
assert.equal(m.tps, null);
assert.equal(m.costUsd, null);
assert.equal(m.tokensIn, 0);
assert.equal(m.tokensOut, 0);
});
});
});

View File

@@ -0,0 +1,227 @@
// @vitest-environment jsdom
// tests/unit/ui/use-improve-prompt.test.tsx
// Runs via Vitest (vitest.config.ts)
// Uses React DOM directly (no @testing-library/dom dep required).
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
useImprovePrompt,
} from "../../../src/app/(dashboard)/dashboard/playground/hooks/useImprovePrompt";
import type { ImprovePromptResult } from "../../../src/lib/playground/promptImprover";
// ─── Minimal hook test harness ────────────────────────────────────────────────
interface HookCapture<T> {
current: T;
}
function mountHook<T>(useHook: () => T): {
result: HookCapture<T>;
unmount: () => void;
} {
const result: HookCapture<T> = { current: undefined as unknown as T };
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
function HookComponent() {
result.current = useHook();
return null;
}
act(() => {
root.render(React.createElement(HookComponent));
});
return {
result,
unmount: () => {
act(() => root.unmount());
container.remove();
},
};
}
// ─── Test fixtures ────────────────────────────────────────────────────────────
const MOCK_RESULT: ImprovePromptResult = {
improvedSystem: "You are a concise assistant.",
improvedPrompt: "Summarize this text in 3 bullet points.",
tokensIn: 120,
tokensOut: 80,
};
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("useImprovePrompt", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("starts with loading=false and no error", () => {
const { result, unmount } = mountHook(() => useImprovePrompt());
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
unmount();
});
it("returns result and clears loading on success", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => MOCK_RESULT,
}),
);
const { result, unmount } = mountHook(() => useImprovePrompt());
let returned: ImprovePromptResult | null = null;
await act(async () => {
returned = await result.current.improve({
system: "You are helpful.",
prompt: "Summarize this.",
model: "gpt-4o",
});
});
expect(returned).toEqual(MOCK_RESULT);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
unmount();
});
it("calls POST /api/playground/improve-prompt with correct body", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => MOCK_RESULT,
});
vi.stubGlobal("fetch", mockFetch);
const { result, unmount } = mountHook(() => useImprovePrompt());
const req = {
system: "You are helpful.",
prompt: "Summarize this.",
model: "gpt-4o",
tone: "detailed" as const,
};
await act(async () => {
await result.current.improve(req);
});
expect(mockFetch).toHaveBeenCalledWith(
"/api/playground/improve-prompt",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
}),
);
unmount();
});
it("sets error state and returns null when server returns non-ok", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ error: { message: "At least one field required" } }),
}),
);
const { result, unmount } = mountHook(() => useImprovePrompt());
let returned: ImprovePromptResult | null = null;
await act(async () => {
returned = await result.current.improve({ model: "gpt-4o" });
});
expect(returned).toBeNull();
expect(result.current.error).toBe("At least one field required");
expect(result.current.loading).toBe(false);
unmount();
});
it("sets error state when fetch throws (network error)", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockRejectedValue(new Error("Network failure")),
);
const { result, unmount } = mountHook(() => useImprovePrompt());
let returned: ImprovePromptResult | null = null;
await act(async () => {
returned = await result.current.improve({
prompt: "Fix my prompt",
model: "gpt-4o",
});
});
expect(returned).toBeNull();
expect(result.current.error).toBe("Network failure");
expect(result.current.loading).toBe(false);
unmount();
});
it("clears previous error on a successful subsequent call", async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce({
ok: false,
status: 500,
json: async () => ({ error: { message: "Server error" } }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => MOCK_RESULT,
});
vi.stubGlobal("fetch", mockFetch);
const { result, unmount } = mountHook(() => useImprovePrompt());
// First call — fails
await act(async () => {
await result.current.improve({ prompt: "test", model: "gpt-4o" });
});
expect(result.current.error).toBe("Server error");
// Second call — succeeds
await act(async () => {
await result.current.improve({ prompt: "test", model: "gpt-4o" });
});
expect(result.current.error).toBeNull();
unmount();
});
it("handles missing error message in response body gracefully", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 503,
json: async () => ({}),
}),
);
const { result, unmount } = mountHook(() => useImprovePrompt());
await act(async () => {
await result.current.improve({ prompt: "test", model: "gpt-4o" });
});
expect(result.current.error).toBe("HTTP 503");
unmount();
});
});

View File

@@ -0,0 +1,289 @@
// @vitest-environment jsdom
// tests/unit/ui/use-presets.test.tsx
// Runs via Vitest (vitest.config.ts)
// Uses React DOM directly (no @testing-library/dom dep required).
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
usePresets,
} from "../../../src/app/(dashboard)/dashboard/playground/hooks/usePresets";
import type { UsePresets } from "../../../src/app/(dashboard)/dashboard/playground/hooks/usePresets";
import type { PlaygroundPresetListItem } from "../../../src/shared/schemas/playground";
// ─── Minimal hook test harness ────────────────────────────────────────────────
interface HookCapture<T> {
current: T;
}
function mountHook<T>(useHook: () => T): {
result: HookCapture<T>;
unmount: () => void;
} {
const result: HookCapture<T> = { current: undefined as unknown as T };
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
function HookComponent() {
result.current = useHook();
return null;
}
act(() => {
root.render(React.createElement(HookComponent));
});
return {
result,
unmount: () => {
act(() => root.unmount());
container.remove();
},
};
}
// ─── Test fixtures ────────────────────────────────────────────────────────────
const MOCK_PRESET: PlaygroundPresetListItem = {
id: "550e8400-e29b-41d4-a716-446655440000",
name: "Test Preset",
endpoint: "chat.completions",
model: "gpt-4o",
system: "You are helpful.",
params: { temperature: 0.7 },
created_at: "2026-01-01T00:00:00.000Z",
};
function mockFetchOnce(response: unknown, status = 200): ReturnType<typeof vi.fn> {
return vi.fn().mockResolvedValue({
ok: status >= 200 && status < 300,
status,
json: async () => response,
});
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("usePresets", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("list()", () => {
it("fetches GET /api/playground/presets and populates presets", async () => {
const mockFetch = mockFetchOnce({ presets: [MOCK_PRESET] });
vi.stubGlobal("fetch", mockFetch);
const { result, unmount } = mountHook(() => usePresets());
await act(async () => {
await result.current.list();
});
expect(mockFetch).toHaveBeenCalledWith("/api/playground/presets");
expect(result.current.presets).toHaveLength(1);
expect(result.current.presets[0].id).toBe(MOCK_PRESET.id);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
unmount();
});
it("sets error when fetch returns non-ok", async () => {
const mockFetch = mockFetchOnce({ error: { message: "Unauthorized" } }, 401);
vi.stubGlobal("fetch", mockFetch);
const { result, unmount } = mountHook(() => usePresets());
await act(async () => {
await result.current.list();
});
expect(result.current.error).toBe("Unauthorized");
expect(result.current.presets).toHaveLength(0);
unmount();
});
it("sets error when fetch throws a network error", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("Network error")));
const { result, unmount } = mountHook(() => usePresets());
await act(async () => {
await result.current.list();
});
expect(result.current.error).toBe("Network error");
unmount();
});
});
describe("create()", () => {
it("calls POST /api/playground/presets with correct body", async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce({
ok: true,
status: 201,
json: async () => MOCK_PRESET,
})
// list() is called after create
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ presets: [MOCK_PRESET] }),
});
vi.stubGlobal("fetch", mockFetch);
const { result, unmount } = mountHook(() => usePresets());
const input = {
name: "Test Preset",
endpoint: "chat.completions",
model: "gpt-4o",
system: "You are helpful.",
params: { temperature: 0.7 },
};
let created: PlaygroundPresetListItem | null = null;
await act(async () => {
created = await result.current.create(input);
});
// First call: POST to presets
expect(mockFetch).toHaveBeenNthCalledWith(
1,
"/api/playground/presets",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
}),
);
// Second call: GET list to refresh
expect(mockFetch).toHaveBeenNthCalledWith(2, "/api/playground/presets");
expect(created).not.toBeNull();
expect((created as PlaygroundPresetListItem | null)?.id).toBe(MOCK_PRESET.id);
unmount();
});
it("returns null and sets error on failure", async () => {
const mockFetch = mockFetchOnce({ error: { message: "Bad request" } }, 400);
vi.stubGlobal("fetch", mockFetch);
const { result, unmount } = mountHook(() => usePresets());
let created: PlaygroundPresetListItem | null = null;
await act(async () => {
created = await result.current.create({
name: "x",
endpoint: "chat.completions",
model: "gpt-4o",
});
});
expect(created).toBeNull();
expect(result.current.error).toBe("Bad request");
unmount();
});
});
describe("update()", () => {
it("calls PUT /api/playground/presets/:id with correct body", async () => {
const updated = { ...MOCK_PRESET, name: "Updated" };
const mockFetch = vi
.fn()
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => updated })
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ presets: [updated] }),
});
vi.stubGlobal("fetch", mockFetch);
const { result, unmount } = mountHook(() => usePresets());
const patch = { name: "Updated" };
let res: PlaygroundPresetListItem | null = null;
await act(async () => {
res = await result.current.update(MOCK_PRESET.id, patch);
});
expect(mockFetch).toHaveBeenNthCalledWith(
1,
`/api/playground/presets/${MOCK_PRESET.id}`,
expect.objectContaining({
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
}),
);
expect((res as PlaygroundPresetListItem | null)?.name).toBe("Updated");
unmount();
});
});
describe("remove()", () => {
it("calls DELETE /api/playground/presets/:id", async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce({ ok: true, status: 204, json: async () => ({}) })
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ presets: [] }),
});
vi.stubGlobal("fetch", mockFetch);
const { result, unmount } = mountHook(() => usePresets());
await act(async () => {
await result.current.remove(MOCK_PRESET.id);
});
expect(mockFetch).toHaveBeenNthCalledWith(
1,
`/api/playground/presets/${MOCK_PRESET.id}`,
expect.objectContaining({ method: "DELETE" }),
);
// After remove, list is refetched => presets = []
expect(result.current.presets).toHaveLength(0);
unmount();
});
it("sets error on failure", async () => {
const mockFetch = mockFetchOnce({ error: { message: "Not found" } }, 404);
vi.stubGlobal("fetch", mockFetch);
const { result, unmount } = mountHook(() => usePresets());
await act(async () => {
await result.current.remove("nonexistent-id");
});
expect(result.current.error).toBe("Not found");
unmount();
});
});
describe("loading state", () => {
it("loading is false after list() completes", async () => {
const mockFetch = mockFetchOnce({ presets: [] });
vi.stubGlobal("fetch", mockFetch);
const { result, unmount } = mountHook(() => usePresets());
await act(async () => {
await result.current.list();
});
expect(result.current.loading).toBe(false);
unmount();
});
});
});

View File

@@ -0,0 +1,311 @@
// @vitest-environment jsdom
// tests/unit/ui/use-stream-metrics.test.tsx
// Runs via Vitest (vitest.config.ts — includes tests/unit/**/*.test.tsx)
// Uses React DOM directly (no @testing-library/dom dep required).
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
useStreamMetrics,
} from "../../../src/app/(dashboard)/dashboard/playground/hooks/useStreamMetrics";
import type { UseStreamMetrics } from "../../../src/app/(dashboard)/dashboard/playground/hooks/useStreamMetrics";
// ─── Minimal hook test harness (no @testing-library/dom) ─────────────────────
interface HookCapture<T> {
current: T;
}
function mountHook<T>(useHook: () => T): {
result: HookCapture<T>;
unmount: () => void;
} {
const result: HookCapture<T> = { current: undefined as unknown as T };
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
function HookComponent() {
result.current = useHook();
return null;
}
act(() => {
root.render(React.createElement(HookComponent));
});
return {
result,
unmount: () => {
act(() => {
root.unmount();
});
container.remove();
},
};
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("useStreamMetrics", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("starts with initial zero/null metrics", () => {
const { result, unmount } = mountHook(() => useStreamMetrics());
expect(result.current.metrics.ttftMs).toBeNull();
expect(result.current.metrics.totalMs).toBeNull();
expect(result.current.metrics.tps).toBeNull();
expect(result.current.metrics.costUsd).toBeNull();
expect(result.current.metrics.tokensIn).toBe(0);
expect(result.current.metrics.tokensOut).toBe(0);
unmount();
});
it("start() resets timing refs and updates metrics state", () => {
const { result, unmount } = mountHook(() => useStreamMetrics());
act(() => {
vi.setSystemTime(1000);
result.current.start();
});
expect(result.current.metrics.ttftMs).toBeNull();
expect(result.current.metrics.totalMs).toBeNull();
expect(result.current.metrics.tokensIn).toBe(0);
expect(result.current.metrics.tokensOut).toBe(0);
unmount();
});
it("onFirstChunk() sets ttftMs relative to start", () => {
const { result, unmount } = mountHook(() => useStreamMetrics());
act(() => {
vi.setSystemTime(1000);
result.current.start();
});
act(() => {
vi.setSystemTime(1200);
result.current.onFirstChunk();
});
expect(result.current.metrics.ttftMs).toBe(200);
unmount();
});
it("onFirstChunk() is idempotent — only records first call", () => {
const { result, unmount } = mountHook(() => useStreamMetrics());
act(() => {
vi.setSystemTime(1000);
result.current.start();
});
act(() => {
vi.setSystemTime(1200);
result.current.onFirstChunk();
});
act(() => {
vi.setSystemTime(1500);
result.current.onFirstChunk(); // should be ignored
});
expect(result.current.metrics.ttftMs).toBe(200);
unmount();
});
it("finish() finalizes metrics with accumulated chunk tokens", () => {
const { result, unmount } = mountHook(() => useStreamMetrics());
act(() => {
vi.setSystemTime(1000);
result.current.start();
});
act(() => {
vi.setSystemTime(1200);
result.current.onFirstChunk();
});
// onChunk does not flush to state — accumulates in ref
result.current.onChunk(5);
result.current.onChunk(5);
result.current.onChunk(5);
act(() => {
vi.setSystemTime(3000);
result.current.finish();
});
const m = result.current.metrics;
expect(m.ttftMs).toBe(200);
expect(m.totalMs).toBe(2000);
expect(m.tokensOut).toBe(15);
expect(m.tokensIn).toBe(0);
// tps = 15 / (2000/1000) = 7.5
expect(m.tps).toBeCloseTo(7.5);
unmount();
});
it("finish(usage) overrides tokensIn + tokensOut with upstream values", () => {
const { result, unmount } = mountHook(() => useStreamMetrics());
act(() => {
vi.setSystemTime(1000);
result.current.start();
});
act(() => {
vi.setSystemTime(1200);
result.current.onFirstChunk();
});
result.current.onChunk(5);
result.current.onChunk(5);
result.current.onChunk(5);
act(() => {
vi.setSystemTime(3000);
result.current.finish({ prompt_tokens: 10, completion_tokens: 15 });
});
const m = result.current.metrics;
expect(m.tokensIn).toBe(10);
expect(m.tokensOut).toBe(15);
expect(m.tps).toBeCloseTo(7.5);
unmount();
});
it("finish(usage) with only prompt_tokens partial override", () => {
const { result, unmount } = mountHook(() => useStreamMetrics());
act(() => {
vi.setSystemTime(1000);
result.current.start();
});
act(() => {
vi.setSystemTime(1200);
result.current.onFirstChunk();
});
result.current.onChunk(8);
act(() => {
vi.setSystemTime(3000);
result.current.finish({ prompt_tokens: 20 });
});
const m = result.current.metrics;
expect(m.tokensIn).toBe(20);
expect(m.tokensOut).toBe(8);
unmount();
});
it("computes costUsd when pricing is provided", () => {
const pricing = { inUsdPer1k: 0.003, outUsdPer1k: 0.015 };
const { result, unmount } = mountHook(() => useStreamMetrics(pricing));
act(() => {
vi.setSystemTime(1000);
result.current.start();
});
act(() => {
vi.setSystemTime(1100);
result.current.onFirstChunk();
});
act(() => {
vi.setSystemTime(3000);
result.current.finish({ prompt_tokens: 1000, completion_tokens: 500 });
});
// cost = (1000 * 0.003 + 500 * 0.015) / 1000 = 0.0105
expect(result.current.metrics.costUsd).toBeCloseTo(0.0105);
unmount();
});
it("costUsd is null when no pricing provided", () => {
const { result, unmount } = mountHook(() => useStreamMetrics());
act(() => {
vi.setSystemTime(1000);
result.current.start();
});
act(() => {
vi.setSystemTime(1200);
result.current.finish({ prompt_tokens: 100, completion_tokens: 50 });
});
expect(result.current.metrics.costUsd).toBeNull();
unmount();
});
it("reset() zeros all metrics", () => {
const { result, unmount } = mountHook(() => useStreamMetrics());
act(() => {
vi.setSystemTime(1000);
result.current.start();
});
act(() => {
vi.setSystemTime(1200);
result.current.onFirstChunk();
});
act(() => {
vi.setSystemTime(3000);
result.current.finish({ prompt_tokens: 10, completion_tokens: 20 });
});
act(() => {
result.current.reset();
});
const m = result.current.metrics;
expect(m.ttftMs).toBeNull();
expect(m.totalMs).toBeNull();
expect(m.tps).toBeNull();
expect(m.costUsd).toBeNull();
expect(m.tokensIn).toBe(0);
expect(m.tokensOut).toBe(0);
unmount();
});
it("start() after previous run resets all accumulated state", () => {
const { result, unmount } = mountHook(() => useStreamMetrics());
act(() => {
vi.setSystemTime(1000);
result.current.start();
});
act(() => {
vi.setSystemTime(1200);
result.current.onFirstChunk();
});
result.current.onChunk(10);
act(() => {
vi.setSystemTime(2000);
result.current.finish({ prompt_tokens: 5, completion_tokens: 10 });
});
// Second run — fresh start
act(() => {
vi.setSystemTime(5000);
result.current.start();
});
expect(result.current.metrics.ttftMs).toBeNull();
expect(result.current.metrics.tokensIn).toBe(0);
expect(result.current.metrics.tokensOut).toBe(0);
unmount();
});
});

View File

@@ -0,0 +1,298 @@
// @vitest-environment jsdom
// tests/unit/ui/use-structured-output.test.tsx
// Runs via Vitest (vitest.config.ts)
// Uses React DOM directly (no @testing-library/dom dep required).
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect } from "vitest";
import {
useStructuredOutput,
} from "../../../src/app/(dashboard)/dashboard/playground/hooks/useStructuredOutput";
// ─── Minimal hook test harness ────────────────────────────────────────────────
interface HookCapture<T> {
current: T;
}
function mountHook<T>(useHook: () => T): {
result: HookCapture<T>;
unmount: () => void;
} {
const result: HookCapture<T> = { current: undefined as unknown as T };
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
function HookComponent() {
result.current = useHook();
return null;
}
act(() => {
root.render(React.createElement(HookComponent));
});
return {
result,
unmount: () => {
act(() => root.unmount());
container.remove();
},
};
}
// ─── Test fixtures ────────────────────────────────────────────────────────────
const VALID_SCHEMA = {
name: "WeatherResponse",
schema: {
type: "object",
properties: {
temperature: { type: "number" },
condition: { type: "string" },
},
required: ["temperature", "condition"],
},
strict: true,
};
const VALID_SCHEMA_NO_REQUIRED = {
name: "FreeForm",
schema: {
type: "object",
properties: {
result: { type: "string" },
},
},
};
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("useStructuredOutput", () => {
describe("initial state", () => {
it("starts with enabled=false, schema=null, error=null", () => {
const { result, unmount } = mountHook(() => useStructuredOutput());
expect(result.current.enabled).toBe(false);
expect(result.current.schema).toBeNull();
expect(result.current.error).toBeNull();
unmount();
});
});
describe("setEnabled()", () => {
it("sets enabled to true", () => {
const { result, unmount } = mountHook(() => useStructuredOutput());
act(() => {
result.current.setEnabled(true);
});
expect(result.current.enabled).toBe(true);
unmount();
});
it("toggles enabled back to false", () => {
const { result, unmount } = mountHook(() => useStructuredOutput());
act(() => {
result.current.setEnabled(true);
result.current.setEnabled(false);
});
expect(result.current.enabled).toBe(false);
unmount();
});
});
describe("setSchema()", () => {
it("accepts a valid schema and clears error", () => {
const { result, unmount } = mountHook(() => useStructuredOutput());
act(() => {
result.current.setSchema(VALID_SCHEMA);
});
expect(result.current.schema).toEqual(VALID_SCHEMA);
expect(result.current.error).toBeNull();
unmount();
});
it("sets error when schema name is empty", () => {
const { result, unmount } = mountHook(() => useStructuredOutput());
act(() => {
result.current.setSchema({ name: "", schema: { type: "object" } });
});
expect(result.current.error).toBeTruthy();
expect(result.current.schema).toBeNull();
unmount();
});
it("sets error when name is too long (>64 chars)", () => {
const { result, unmount } = mountHook(() => useStructuredOutput());
act(() => {
result.current.setSchema({ name: "a".repeat(65), schema: { type: "object" } });
});
expect(result.current.error).toBeTruthy();
unmount();
});
it("clears error after previously invalid schema when valid one is set", () => {
const { result, unmount } = mountHook(() => useStructuredOutput());
act(() => {
result.current.setSchema({ name: "", schema: {} });
});
expect(result.current.error).toBeTruthy();
act(() => {
result.current.setSchema(VALID_SCHEMA);
});
expect(result.current.error).toBeNull();
expect(result.current.schema).toEqual(VALID_SCHEMA);
unmount();
});
it("accepts schema with strict=false", () => {
const { result, unmount } = mountHook(() => useStructuredOutput());
act(() => {
result.current.setSchema({ ...VALID_SCHEMA, strict: false });
});
expect(result.current.schema?.strict).toBe(false);
expect(result.current.error).toBeNull();
unmount();
});
it("accepts schema without strict field", () => {
const { result, unmount } = mountHook(() => useStructuredOutput());
act(() => {
result.current.setSchema(VALID_SCHEMA_NO_REQUIRED);
});
expect(result.current.schema?.strict).toBeUndefined();
expect(result.current.error).toBeNull();
unmount();
});
});
describe("validateResponse()", () => {
it("returns { valid: false, error } when no schema is set", () => {
const { result, unmount } = mountHook(() => useStructuredOutput());
const res = result.current.validateResponse({ temperature: 25, condition: "sunny" });
expect(res.valid).toBe(false);
expect(res.error).toBeTruthy();
unmount();
});
it("parses JSON string and validates correctly", () => {
const { result, unmount } = mountHook(() => useStructuredOutput());
act(() => {
result.current.setSchema(VALID_SCHEMA);
});
const json = JSON.stringify({ temperature: 25, condition: "sunny" });
const res = result.current.validateResponse(json);
expect(res.valid).toBe(true);
unmount();
});
it("returns { valid: false } when content is not valid JSON string", () => {
const { result, unmount } = mountHook(() => useStructuredOutput());
act(() => {
result.current.setSchema(VALID_SCHEMA);
});
const res = result.current.validateResponse("not-valid-json{{");
expect(res.valid).toBe(false);
expect(res.error).toContain("JSON");
unmount();
});
it("validates object directly (no JSON.parse needed)", () => {
const { result, unmount } = mountHook(() => useStructuredOutput());
act(() => {
result.current.setSchema(VALID_SCHEMA);
});
const res = result.current.validateResponse({ temperature: 20, condition: "cloudy" });
expect(res.valid).toBe(true);
unmount();
});
it("returns { valid: false } when required field is missing", () => {
const { result, unmount } = mountHook(() => useStructuredOutput());
act(() => {
result.current.setSchema(VALID_SCHEMA);
});
const res = result.current.validateResponse({ temperature: 25 }); // missing "condition"
expect(res.valid).toBe(false);
expect(res.error).toContain("condition");
unmount();
});
it("returns { valid: false } when content is null", () => {
const { result, unmount } = mountHook(() => useStructuredOutput());
act(() => {
result.current.setSchema(VALID_SCHEMA_NO_REQUIRED);
});
const res = result.current.validateResponse(null);
expect(res.valid).toBe(false);
unmount();
});
it("returns { valid: false } when content is an array", () => {
const { result, unmount } = mountHook(() => useStructuredOutput());
act(() => {
result.current.setSchema(VALID_SCHEMA_NO_REQUIRED);
});
const res = result.current.validateResponse([1, 2, 3]);
expect(res.valid).toBe(false);
unmount();
});
it("passes validation for schema without required field", () => {
const { result, unmount } = mountHook(() => useStructuredOutput());
act(() => {
result.current.setSchema(VALID_SCHEMA_NO_REQUIRED);
});
const res = result.current.validateResponse({});
expect(res.valid).toBe(true);
unmount();
});
it("passes validation for schema with no properties field", () => {
const { result, unmount } = mountHook(() => useStructuredOutput());
act(() => {
result.current.setSchema({ name: "AnyObj", schema: { type: "object" } });
});
const res = result.current.validateResponse({ foo: "bar" });
expect(res.valid).toBe(true);
unmount();
});
});
});

View File

@@ -0,0 +1,308 @@
// @vitest-environment jsdom
// tests/unit/ui/use-tools-builder.test.tsx
// Runs via Vitest (vitest.config.ts)
// Uses React DOM directly (no @testing-library/dom dep required).
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect } from "vitest";
import {
useToolsBuilder,
} from "../../../src/app/(dashboard)/dashboard/playground/hooks/useToolsBuilder";
import type { ToolDefinition } from "../../../src/lib/playground/codeExport";
// ─── Minimal hook test harness ────────────────────────────────────────────────
interface HookCapture<T> {
current: T;
}
function mountHook<T>(useHook: () => T): {
result: HookCapture<T>;
unmount: () => void;
} {
const result: HookCapture<T> = { current: undefined as unknown as T };
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
function HookComponent() {
result.current = useHook();
return null;
}
act(() => {
root.render(React.createElement(HookComponent));
});
return {
result,
unmount: () => {
act(() => root.unmount());
container.remove();
},
};
}
// ─── Test fixtures ────────────────────────────────────────────────────────────
const VALID_TOOL: ToolDefinition = {
type: "function",
function: {
name: "get_weather",
description: "Get the current weather",
parameters: {
type: "object",
properties: {
location: { type: "string" },
},
},
},
};
const VALID_TOOL_2: ToolDefinition = {
type: "function",
function: {
name: "search_web",
parameters: {},
},
};
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("useToolsBuilder", () => {
describe("initial state", () => {
it("starts with empty tools and empty errors", () => {
const { result, unmount } = mountHook(() => useToolsBuilder());
expect(result.current.tools).toHaveLength(0);
expect(result.current.errors.size).toBe(0);
unmount();
});
});
describe("add()", () => {
it("returns { ok: false, error } when tool has empty name", () => {
const { result, unmount } = mountHook(() => useToolsBuilder());
const invalidTool = {
type: "function" as const,
function: { name: "", parameters: {} },
};
let outcome: ReturnType<typeof result.current.add> | undefined;
act(() => {
outcome = result.current.add(invalidTool as ToolDefinition);
});
expect(outcome).toMatchObject({ ok: false });
expect((outcome as { ok: false; error: string }).error).toBeTruthy();
expect(result.current.tools).toHaveLength(0);
unmount();
});
it("returns { ok: false } when type is not 'function'", () => {
const { result, unmount } = mountHook(() => useToolsBuilder());
const invalidTool = {
type: "not-function" as unknown as "function",
function: { name: "valid_name", parameters: {} },
};
let outcome: ReturnType<typeof result.current.add> | undefined;
act(() => {
outcome = result.current.add(invalidTool as ToolDefinition);
});
expect(outcome).toMatchObject({ ok: false });
expect(result.current.tools).toHaveLength(0);
unmount();
});
it("adds a valid tool and returns { ok: true }", () => {
const { result, unmount } = mountHook(() => useToolsBuilder());
let outcome: ReturnType<typeof result.current.add> | undefined;
act(() => {
outcome = result.current.add(VALID_TOOL);
});
expect(outcome).toMatchObject({ ok: true });
expect(result.current.tools).toHaveLength(1);
expect(result.current.tools[0].function.name).toBe("get_weather");
unmount();
});
it("adds multiple valid tools", () => {
const { result, unmount } = mountHook(() => useToolsBuilder());
act(() => {
result.current.add(VALID_TOOL);
result.current.add(VALID_TOOL_2);
});
expect(result.current.tools).toHaveLength(2);
unmount();
});
it("does not add to tools when validation fails", () => {
const { result, unmount } = mountHook(() => useToolsBuilder());
act(() => {
result.current.add(VALID_TOOL);
result.current.add({ type: "function", function: { name: "", parameters: {} } } as ToolDefinition);
});
expect(result.current.tools).toHaveLength(1);
unmount();
});
});
describe("remove()", () => {
it("removes tool at given index", () => {
const { result, unmount } = mountHook(() => useToolsBuilder());
act(() => {
result.current.add(VALID_TOOL);
result.current.add(VALID_TOOL_2);
});
act(() => {
result.current.remove(0);
});
expect(result.current.tools).toHaveLength(1);
expect(result.current.tools[0].function.name).toBe("search_web");
unmount();
});
it("is a no-op when index is out of bounds", () => {
const { result, unmount } = mountHook(() => useToolsBuilder());
act(() => {
result.current.add(VALID_TOOL);
});
act(() => {
result.current.remove(99);
});
expect(result.current.tools).toHaveLength(1);
unmount();
});
it("re-indexes errors after remove", () => {
const { result, unmount } = mountHook(() => useToolsBuilder());
act(() => {
result.current.add(VALID_TOOL);
result.current.add(VALID_TOOL_2);
// Trigger an error on index 1 via update with invalid tool
result.current.update(1, {
type: "function",
function: { name: "", parameters: {} },
} as ToolDefinition);
});
expect(result.current.errors.has(1)).toBe(true);
// Remove item at index 0
act(() => {
result.current.remove(0);
});
// Error for what was index 1 is now at index 0
expect(result.current.errors.has(0)).toBe(true);
expect(result.current.errors.has(1)).toBe(false);
unmount();
});
});
describe("update()", () => {
it("updates a tool at given index after validation", () => {
const { result, unmount } = mountHook(() => useToolsBuilder());
act(() => {
result.current.add(VALID_TOOL);
});
const updated: ToolDefinition = {
type: "function",
function: { name: "updated_fn", parameters: {} },
};
let outcome: ReturnType<typeof result.current.update> | undefined;
act(() => {
outcome = result.current.update(0, updated);
});
expect(outcome).toMatchObject({ ok: true });
expect(result.current.tools[0].function.name).toBe("updated_fn");
expect(result.current.errors.has(0)).toBe(false);
unmount();
});
it("returns { ok: false, error } and stores error when validation fails", () => {
const { result, unmount } = mountHook(() => useToolsBuilder());
act(() => {
result.current.add(VALID_TOOL);
});
let outcome: ReturnType<typeof result.current.update> | undefined;
act(() => {
outcome = result.current.update(0, {
type: "function",
function: { name: "", parameters: {} },
} as ToolDefinition);
});
expect(outcome).toMatchObject({ ok: false });
expect(result.current.errors.has(0)).toBe(true);
// Tool should not be changed
expect(result.current.tools[0].function.name).toBe("get_weather");
unmount();
});
it("clears error for that index on successful update", () => {
const { result, unmount } = mountHook(() => useToolsBuilder());
act(() => {
result.current.add(VALID_TOOL);
result.current.update(0, {
type: "function",
function: { name: "", parameters: {} },
} as ToolDefinition);
});
expect(result.current.errors.has(0)).toBe(true);
act(() => {
result.current.update(0, VALID_TOOL_2);
});
expect(result.current.errors.has(0)).toBe(false);
unmount();
});
});
describe("clear()", () => {
it("removes all tools and clears all errors", () => {
const { result, unmount } = mountHook(() => useToolsBuilder());
act(() => {
result.current.add(VALID_TOOL);
result.current.add(VALID_TOOL_2);
result.current.update(0, {
type: "function",
function: { name: "", parameters: {} },
} as ToolDefinition);
});
act(() => {
result.current.clear();
});
expect(result.current.tools).toHaveLength(0);
expect(result.current.errors.size).toBe(0);
unmount();
});
});
});