From 2cc0f5bb5bc80fdbb701eeec9dac49232e30df05 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 23:59:47 -0300 Subject: [PATCH] test(playground): cover hooks and streamMetrics --- tests/unit/playground-stream-metrics.test.ts | 241 ++++++++++++++ tests/unit/ui/use-improve-prompt.test.tsx | 227 ++++++++++++++ tests/unit/ui/use-presets.test.tsx | 289 +++++++++++++++++ tests/unit/ui/use-stream-metrics.test.tsx | 311 +++++++++++++++++++ tests/unit/ui/use-structured-output.test.tsx | 298 ++++++++++++++++++ tests/unit/ui/use-tools-builder.test.tsx | 308 ++++++++++++++++++ 6 files changed, 1674 insertions(+) create mode 100644 tests/unit/playground-stream-metrics.test.ts create mode 100644 tests/unit/ui/use-improve-prompt.test.tsx create mode 100644 tests/unit/ui/use-presets.test.tsx create mode 100644 tests/unit/ui/use-stream-metrics.test.tsx create mode 100644 tests/unit/ui/use-structured-output.test.tsx create mode 100644 tests/unit/ui/use-tools-builder.test.tsx diff --git a/tests/unit/playground-stream-metrics.test.ts b/tests/unit/playground-stream-metrics.test.ts new file mode 100644 index 0000000000..b81d8a3d23 --- /dev/null +++ b/tests/unit/playground-stream-metrics.test.ts @@ -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); + }); + }); +}); diff --git a/tests/unit/ui/use-improve-prompt.test.tsx b/tests/unit/ui/use-improve-prompt.test.tsx new file mode 100644 index 0000000000..d81a106dd7 --- /dev/null +++ b/tests/unit/ui/use-improve-prompt.test.tsx @@ -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 { + current: T; +} + +function mountHook(useHook: () => T): { + result: HookCapture; + unmount: () => void; +} { + const result: HookCapture = { 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(); + }); +}); diff --git a/tests/unit/ui/use-presets.test.tsx b/tests/unit/ui/use-presets.test.tsx new file mode 100644 index 0000000000..cff5f656b7 --- /dev/null +++ b/tests/unit/ui/use-presets.test.tsx @@ -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 { + current: T; +} + +function mountHook(useHook: () => T): { + result: HookCapture; + unmount: () => void; +} { + const result: HookCapture = { 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 { + 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(); + }); + }); +}); diff --git a/tests/unit/ui/use-stream-metrics.test.tsx b/tests/unit/ui/use-stream-metrics.test.tsx new file mode 100644 index 0000000000..e6ef597390 --- /dev/null +++ b/tests/unit/ui/use-stream-metrics.test.tsx @@ -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 { + current: T; +} + +function mountHook(useHook: () => T): { + result: HookCapture; + unmount: () => void; +} { + const result: HookCapture = { 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(); + }); +}); diff --git a/tests/unit/ui/use-structured-output.test.tsx b/tests/unit/ui/use-structured-output.test.tsx new file mode 100644 index 0000000000..e81d7a5e6e --- /dev/null +++ b/tests/unit/ui/use-structured-output.test.tsx @@ -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 { + current: T; +} + +function mountHook(useHook: () => T): { + result: HookCapture; + unmount: () => void; +} { + const result: HookCapture = { 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(); + }); + }); +}); diff --git a/tests/unit/ui/use-tools-builder.test.tsx b/tests/unit/ui/use-tools-builder.test.tsx new file mode 100644 index 0000000000..5479165197 --- /dev/null +++ b/tests/unit/ui/use-tools-builder.test.tsx @@ -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 { + current: T; +} + +function mountHook(useHook: () => T): { + result: HookCapture; + unmount: () => void; +} { + const result: HookCapture = { 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 | 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 | 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 | 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 | 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 | 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(); + }); + }); +});