From 2f92399e2395980d10de11f438164629c50ad2ea Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 23:59:23 -0300 Subject: [PATCH 1/6] feat(playground): add streamMetrics pure function --- src/lib/playground/streamMetrics.ts | 61 +++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/lib/playground/streamMetrics.ts diff --git a/src/lib/playground/streamMetrics.ts b/src/lib/playground/streamMetrics.ts new file mode 100644 index 0000000000..4323862ec3 --- /dev/null +++ b/src/lib/playground/streamMetrics.ts @@ -0,0 +1,61 @@ +// src/lib/playground/streamMetrics.ts +// Pure function — no React dependency. Reusable outside of hooks. + +import type { StreamMetrics } from "@/shared/schemas/playground"; + +export interface ComputeMetricsArgs { + /** ms timestamp of when the request was dispatched. null = not started. */ + startedAt: number | null; + /** ms timestamp of when the first SSE chunk was received. null = not received yet. */ + firstChunkAt: number | null; + /** ms timestamp of when the stream finished. null = not finished yet. */ + finishedAt: number | null; + /** Number of input/prompt tokens. */ + tokensIn: number; + /** Number of output/completion tokens accumulated. */ + tokensOut: number; + /** + * Optional pricing to compute costUsd. + * `estimated: true` marks that this is a client-side estimate (D13). + */ + pricing?: { + inUsdPer1k: number; + outUsdPer1k: number; + estimated: true; + }; +} + +/** + * Computes client-perceived streaming metrics from timing + token counts. + * + * All timing values are "client-perceived" (D12) — measured from the browser's + * perspective, not the server's. UI should label these "(client-side estimate)". + */ +export function computeMetrics(args: ComputeMetricsArgs): StreamMetrics { + const { startedAt, firstChunkAt, finishedAt, tokensIn, tokensOut, pricing } = args; + + const ttftMs = + firstChunkAt != null && startedAt != null ? firstChunkAt - startedAt : null; + + const totalMs = + finishedAt != null && startedAt != null ? finishedAt - startedAt : null; + + const tps = + totalMs != null && totalMs > 0 && tokensOut > 0 + ? tokensOut / (totalMs / 1000) + : null; + + const costUsd = + pricing != null + ? (tokensIn * pricing.inUsdPer1k + tokensOut * pricing.outUsdPer1k) / 1000 + : null; + + return { + ttftMs, + totalMs, + tokensIn, + tokensOut, + tps, + costUsd, + }; +} From c708b1f40c80f9c72bc72c5ff851c295a9f380cf Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 23:59:30 -0300 Subject: [PATCH 2/6] feat(playground): add useStreamMetrics hook --- .../playground/hooks/useStreamMetrics.ts | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 src/app/(dashboard)/dashboard/playground/hooks/useStreamMetrics.ts diff --git a/src/app/(dashboard)/dashboard/playground/hooks/useStreamMetrics.ts b/src/app/(dashboard)/dashboard/playground/hooks/useStreamMetrics.ts new file mode 100644 index 0000000000..243707dc61 --- /dev/null +++ b/src/app/(dashboard)/dashboard/playground/hooks/useStreamMetrics.ts @@ -0,0 +1,146 @@ +// src/app/(dashboard)/dashboard/playground/hooks/useStreamMetrics.ts +"use client"; + +import { useRef, useState } from "react"; +import type { StreamMetrics } from "@/shared/schemas/playground"; +import { computeMetrics } from "@/lib/playground/streamMetrics"; + +/** + * Mede TTFT/TPS client-perceived (D12). `start()` ao disparar request, + * `onFirstChunk()` ao receber 1º chunk SSE, `onChunk(tokens)` ao processar + * cada chunk com tokens, `finish(usage?)` ao stream completar (com optional + * usage do upstream para tokensIn/tokensOut precisos). + */ +export interface UseStreamMetrics { + metrics: StreamMetrics; + start: () => void; + onFirstChunk: () => void; + onChunk: (tokensInThisChunk: number) => void; + finish: (usage?: { prompt_tokens?: number; completion_tokens?: number }) => void; + reset: () => void; +} + +const INITIAL_METRICS: StreamMetrics = { + ttftMs: null, + totalMs: null, + tokensIn: 0, + tokensOut: 0, + tps: null, + costUsd: null, +}; + +interface TimingRefs { + startedAt: number | null; + firstChunkAt: number | null; + finishedAt: number | null; + tokensIn: number; + tokensOut: number; +} + +/** + * React hook for tracking client-perceived stream metrics. + * + * @param modelPricing Optional pricing for cost estimation (D13 — labeled "(estimated)"). + */ +export function useStreamMetrics(modelPricing?: { + inUsdPer1k: number; + outUsdPer1k: number; +}): UseStreamMetrics { + const [metrics, setMetrics] = useState(INITIAL_METRICS); + + const refs = useRef({ + startedAt: null, + firstChunkAt: null, + finishedAt: null, + tokensIn: 0, + tokensOut: 0, + }); + + function start(): void { + const now = Date.now(); + refs.current = { + startedAt: now, + firstChunkAt: null, + finishedAt: null, + tokensIn: 0, + tokensOut: 0, + }; + setMetrics( + computeMetrics({ + startedAt: now, + firstChunkAt: null, + finishedAt: null, + tokensIn: 0, + tokensOut: 0, + pricing: modelPricing + ? { ...modelPricing, estimated: true } + : undefined, + }), + ); + } + + function onFirstChunk(): void { + if (refs.current.firstChunkAt != null) { + // Only record the first chunk once. + return; + } + const now = Date.now(); + refs.current.firstChunkAt = now; + setMetrics( + computeMetrics({ + ...refs.current, + pricing: modelPricing + ? { ...modelPricing, estimated: true } + : undefined, + }), + ); + } + + function onChunk(tokensInThisChunk: number): void { + refs.current.tokensOut += tokensInThisChunk; + // Intentionally no setState here to avoid flooding renders — UI can read + // periodic updates or wait for finish(). Callers that need live tps should + // call setMetrics themselves or use a throttled update strategy. + } + + function finish(usage?: { prompt_tokens?: number; completion_tokens?: number }): void { + const now = Date.now(); + refs.current.finishedAt = now; + if (usage != null) { + if (usage.prompt_tokens != null) { + refs.current.tokensIn = usage.prompt_tokens; + } + if (usage.completion_tokens != null) { + refs.current.tokensOut = usage.completion_tokens; + } + } + setMetrics( + computeMetrics({ + ...refs.current, + pricing: modelPricing + ? { ...modelPricing, estimated: true } + : undefined, + }), + ); + } + + function reset(): void { + refs.current = { + startedAt: null, + firstChunkAt: null, + finishedAt: null, + tokensIn: 0, + tokensOut: 0, + }; + setMetrics(INITIAL_METRICS); + } + + return { + metrics, + start, + onFirstChunk, + onChunk, + finish, + reset, + }; +} From a180725f56384fcc6ac8a72808c045b528a5c922 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 23:59:35 -0300 Subject: [PATCH 3/6] feat(playground): add usePresets/useImprovePrompt hooks --- .../playground/hooks/useImprovePrompt.ts | 69 ++++++++ .../dashboard/playground/hooks/usePresets.ts | 151 ++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 src/app/(dashboard)/dashboard/playground/hooks/useImprovePrompt.ts create mode 100644 src/app/(dashboard)/dashboard/playground/hooks/usePresets.ts diff --git a/src/app/(dashboard)/dashboard/playground/hooks/useImprovePrompt.ts b/src/app/(dashboard)/dashboard/playground/hooks/useImprovePrompt.ts new file mode 100644 index 0000000000..07564d239b --- /dev/null +++ b/src/app/(dashboard)/dashboard/playground/hooks/useImprovePrompt.ts @@ -0,0 +1,69 @@ +// src/app/(dashboard)/dashboard/playground/hooks/useImprovePrompt.ts +"use client"; + +import { useState, useCallback } from "react"; +import type { ImprovePromptResult } from "@/lib/playground/promptImprover"; + +export interface UseImprovePromptState { + loading: boolean; + error: string | null; +} + +export interface UseImprovePrompt extends UseImprovePromptState { + improve: (req: { + system?: string; + prompt?: string; + model: string; + tone?: "concise" | "detailed"; + }) => Promise; +} + +/** + * Hook for calling /api/playground/improve-prompt. + * + * Manages loading / error state; returns the parsed result on success. + * Warning: each call consumes model quota (D8 — uses the model selected by user). + */ +export function useImprovePrompt(): UseImprovePrompt { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const improve = useCallback( + async (req: { + system?: string; + prompt?: string; + model: string; + tone?: "concise" | "detailed"; + }): Promise => { + setLoading(true); + setError(null); + try { + const res = await fetch("/api/playground/improve-prompt", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { + error?: { message?: string }; + }; + throw new Error(body.error?.message ?? `HTTP ${res.status}`); + } + const result = (await res.json()) as ImprovePromptResult; + return result; + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + return null; + } finally { + setLoading(false); + } + }, + [], + ); + + return { + loading, + error, + improve, + }; +} diff --git a/src/app/(dashboard)/dashboard/playground/hooks/usePresets.ts b/src/app/(dashboard)/dashboard/playground/hooks/usePresets.ts new file mode 100644 index 0000000000..b1f57c8ed6 --- /dev/null +++ b/src/app/(dashboard)/dashboard/playground/hooks/usePresets.ts @@ -0,0 +1,151 @@ +// src/app/(dashboard)/dashboard/playground/hooks/usePresets.ts +"use client"; + +import { useState, useCallback } from "react"; +import type { + PlaygroundPresetListItem, + PlaygroundPresetCreateSchema, + PlaygroundPresetUpdateSchema, +} from "@/shared/schemas/playground"; +import type { z } from "zod"; + +type CreateInput = z.infer; +type UpdateInput = z.infer; + +export interface UsePresetsState { + presets: PlaygroundPresetListItem[]; + loading: boolean; + error: string | null; +} + +export interface UsePresets extends UsePresetsState { + list: () => Promise; + create: (input: CreateInput) => Promise; + update: (id: string, patch: UpdateInput) => Promise; + remove: (id: string) => Promise; +} + +/** + * Hook for managing playground presets via REST API. + * Wraps fetch calls to /api/playground/presets with local state cache. + */ +export function usePresets(): UsePresets { + const [presets, setPresets] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const list = useCallback(async (): Promise => { + setLoading(true); + setError(null); + try { + const res = await fetch("/api/playground/presets"); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { + error?: { message?: string }; + }; + throw new Error(body.error?.message ?? `HTTP ${res.status}`); + } + const body = (await res.json()) as { presets: PlaygroundPresetListItem[] }; + setPresets(body.presets ?? []); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, []); + + const create = useCallback( + async (input: CreateInput): Promise => { + setLoading(true); + setError(null); + try { + const res = await fetch("/api/playground/presets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { + error?: { message?: string }; + }; + throw new Error(body.error?.message ?? `HTTP ${res.status}`); + } + const created = (await res.json()) as PlaygroundPresetListItem; + // Refresh the list after mutation. + await list(); + return created; + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + return null; + } finally { + setLoading(false); + } + }, + [list], + ); + + const update = useCallback( + async (id: string, patch: UpdateInput): Promise => { + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/playground/presets/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { + error?: { message?: string }; + }; + throw new Error(body.error?.message ?? `HTTP ${res.status}`); + } + const updated = (await res.json()) as PlaygroundPresetListItem; + // Refresh list after mutation. + await list(); + return updated; + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + return null; + } finally { + setLoading(false); + } + }, + [list], + ); + + const remove = useCallback( + async (id: string): Promise => { + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/playground/presets/${id}`, { + method: "DELETE", + }); + if (!res.ok && res.status !== 204) { + const body = (await res.json().catch(() => ({}))) as { + error?: { message?: string }; + }; + throw new Error(body.error?.message ?? `HTTP ${res.status}`); + } + // Refresh list after mutation. + await list(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, + [list], + ); + + return { + presets, + loading, + error, + list, + create, + update, + remove, + }; +} From 06210da48f2e74fdd2dfc281e6ffa7a08a968b5e Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 23:59:41 -0300 Subject: [PATCH 4/6] feat(playground): add useToolsBuilder/useStructuredOutput hooks --- .../playground/hooks/useStructuredOutput.ts | 124 ++++++++++++++++++ .../playground/hooks/useToolsBuilder.ts | 119 +++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 src/app/(dashboard)/dashboard/playground/hooks/useStructuredOutput.ts create mode 100644 src/app/(dashboard)/dashboard/playground/hooks/useToolsBuilder.ts diff --git a/src/app/(dashboard)/dashboard/playground/hooks/useStructuredOutput.ts b/src/app/(dashboard)/dashboard/playground/hooks/useStructuredOutput.ts new file mode 100644 index 0000000000..7eb621600a --- /dev/null +++ b/src/app/(dashboard)/dashboard/playground/hooks/useStructuredOutput.ts @@ -0,0 +1,124 @@ +// src/app/(dashboard)/dashboard/playground/hooks/useStructuredOutput.ts +"use client"; + +import { useState, useCallback } from "react"; +import { StructuredOutputSchema } from "@/shared/schemas/playground"; + +export interface StructuredOutputSchemaInput { + name: string; + schema: Record; + strict?: boolean; +} + +export interface UseStructuredOutputState { + enabled: boolean; + schema: StructuredOutputSchemaInput | null; + error: string | null; +} + +export interface ValidateResponseResult { + valid: boolean; + error?: string; +} + +export interface UseStructuredOutput extends UseStructuredOutputState { + setEnabled: (enabled: boolean) => void; + /** + * Set and validate the JSON schema via Zod StructuredOutputSchema. + * Clears error on success; sets error on validation failure. + */ + setSchema: (s: StructuredOutputSchemaInput) => void; + /** + * Validate a response body against the current schema. + * - First tries JSON.parse if content is a string. + * - Then checks that the parsed value is a non-null object (basic shape validation). + * Returns { valid: true } on success; { valid: false, error } on failure. + */ + validateResponse: (content: unknown) => ValidateResponseResult; +} + +/** + * Hook for the Structured Output (JSON mode) toggle in the Build tab. + * Manages toggle state + JSON schema + client-side response validation. + */ +export function useStructuredOutput(): UseStructuredOutput { + const [enabled, setEnabledState] = useState(false); + const [schema, setSchemaState] = useState(null); + const [error, setError] = useState(null); + + const setEnabled = useCallback((value: boolean): void => { + setEnabledState(value); + }, []); + + const setSchema = useCallback((s: StructuredOutputSchemaInput): void => { + // Validate via StructuredOutputSchema (wraps the input as if it were a full response_format object). + const parsed = StructuredOutputSchema.safeParse({ + type: "json_schema", + json_schema: s, + }); + if (!parsed.success) { + const message = parsed.error.issues.map((i) => i.message).join("; "); + setError(message); + return; + } + setSchemaState(s); + setError(null); + }, []); + + const validateResponse = useCallback( + (content: unknown): ValidateResponseResult => { + if (schema == null) { + return { valid: false, error: "No schema set" }; + } + + // Step 1: parse JSON if the content is a string. + let parsed: unknown = content; + if (typeof content === "string") { + try { + parsed = JSON.parse(content); + } catch { + return { valid: false, error: "Response is not valid JSON" }; + } + } + + // Step 2: basic shape validation — must be a non-null object. + // Full JSON Schema validation would require ajv (not present in this project). + // We do a structural check: verify it's an object, and for each key in the + // schema's `properties` (if present), check that the key exists in the response. + if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) { + return { valid: false, error: "Response is not a JSON object" }; + } + + const responseObj = parsed as Record; + const schemaObj = schema.schema; + + // If the schema has a "properties" field, validate that required keys are present. + const properties = schemaObj["properties"]; + if (properties != null && typeof properties === "object" && !Array.isArray(properties)) { + const required = schemaObj["required"]; + if (Array.isArray(required)) { + for (const key of required) { + if (typeof key === "string" && !(key in responseObj)) { + return { + valid: false, + error: `Response is missing required field: "${key}"`, + }; + } + } + } + } + + return { valid: true }; + }, + [schema], + ); + + return { + enabled, + schema, + error, + setEnabled, + setSchema, + validateResponse, + }; +} diff --git a/src/app/(dashboard)/dashboard/playground/hooks/useToolsBuilder.ts b/src/app/(dashboard)/dashboard/playground/hooks/useToolsBuilder.ts new file mode 100644 index 0000000000..a41950a3bf --- /dev/null +++ b/src/app/(dashboard)/dashboard/playground/hooks/useToolsBuilder.ts @@ -0,0 +1,119 @@ +// src/app/(dashboard)/dashboard/playground/hooks/useToolsBuilder.ts +"use client"; + +import { useState, useCallback } from "react"; +import { ToolDefinitionSchema } from "@/shared/schemas/playground"; +import type { ToolDefinition } from "@/lib/playground/codeExport"; + +export interface ToolsBuildResult { + ok: true; +} + +export interface ToolsBuildError { + ok: false; + error: string; +} + +export type ToolsBuildOutcome = ToolsBuildResult | ToolsBuildError; + +export interface UseToolsBuilderState { + tools: ToolDefinition[]; + errors: Map; +} + +export interface UseToolsBuilder extends UseToolsBuilderState { + /** + * Add a tool after Zod validation. + * Returns { ok: true } on success; { ok: false, error } if validation fails. + */ + add: (tool: ToolDefinition) => ToolsBuildOutcome; + /** Remove tool at given index. No-op if index out of bounds. */ + remove: (index: number) => void; + /** + * Replace tool at index after Zod validation. + * Returns { ok: true } on success; { ok: false, error } if validation fails. + * Clears previous error for that index on success. + */ + update: (index: number, tool: ToolDefinition) => ToolsBuildOutcome; + /** Remove all tools and clear all errors. */ + clear: () => void; +} + +/** + * Client-side manager for the `tools[]` array (Function Calling / Build tab). + * + * Validates each tool via Zod `ToolDefinitionSchema` before adding/updating. + * Stores per-index validation errors so the UI can show inline hints. + */ +export function useToolsBuilder(): UseToolsBuilder { + const [tools, setTools] = useState([]); + const [errors, setErrors] = useState>(new Map()); + + const add = useCallback((tool: ToolDefinition): ToolsBuildOutcome => { + const parsed = ToolDefinitionSchema.safeParse(tool); + if (!parsed.success) { + const message = parsed.error.issues.map((i) => i.message).join("; "); + return { ok: false, error: message }; + } + setTools((prev) => [...prev, parsed.data as ToolDefinition]); + return { ok: true }; + }, []); + + const remove = useCallback((index: number): void => { + setTools((prev) => { + if (index < 0 || index >= prev.length) return prev; + const next = [...prev]; + next.splice(index, 1); + return next; + }); + setErrors((prev) => { + const next = new Map(prev); + next.delete(index); + // Re-index errors for items after the removed one. + const reindexed = new Map(); + next.forEach((v, k) => { + reindexed.set(k > index ? k - 1 : k, v); + }); + return reindexed; + }); + }, []); + + const update = useCallback((index: number, tool: ToolDefinition): ToolsBuildOutcome => { + const parsed = ToolDefinitionSchema.safeParse(tool); + if (!parsed.success) { + const message = parsed.error.issues.map((i) => i.message).join("; "); + setErrors((prev) => { + const next = new Map(prev); + next.set(index, message); + return next; + }); + return { ok: false, error: message }; + } + setTools((prev) => { + if (index < 0 || index >= prev.length) return prev; + const next = [...prev]; + next[index] = parsed.data as ToolDefinition; + return next; + }); + setErrors((prev) => { + const next = new Map(prev); + next.delete(index); + return next; + }); + return { ok: true }; + }, []); + + const clear = useCallback((): void => { + setTools([]); + setErrors(new Map()); + }, []); + + return { + tools, + errors, + add, + remove, + update, + clear, + }; +} From 2cc0f5bb5bc80fdbb701eeec9dac49232e30df05 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 23:59:47 -0300 Subject: [PATCH 5/6] 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(); + }); + }); +}); From caf68872ecd5315f4e27d3afc9f3821fa4bc0616 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 28 May 2026 01:48:17 -0300 Subject: [PATCH 6/6] test(playground): fix react-hooks/immutability lint in hook test harnesses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add eslint-disable-next-line comment to the outer hookRef assignment inside mountHook in all 5 vitest UI test files — the assignment is intentional (test harness captures hook return via React ref) and safe. --- tests/unit/ui/use-improve-prompt.test.tsx | 32 ++++++------ tests/unit/ui/use-presets.test.tsx | 37 +++++++------- tests/unit/ui/use-stream-metrics.test.tsx | 52 ++++++++++--------- tests/unit/ui/use-structured-output.test.tsx | 54 ++++++++++---------- tests/unit/ui/use-tools-builder.test.tsx | 44 ++++++++-------- 5 files changed, 110 insertions(+), 109 deletions(-) diff --git a/tests/unit/ui/use-improve-prompt.test.tsx b/tests/unit/ui/use-improve-prompt.test.tsx index d81a106dd7..4193623348 100644 --- a/tests/unit/ui/use-improve-prompt.test.tsx +++ b/tests/unit/ui/use-improve-prompt.test.tsx @@ -2,8 +2,7 @@ // 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 React, { act, useRef } from "react"; import { createRoot } from "react-dom/client"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { @@ -13,21 +12,22 @@ import type { ImprovePromptResult } from "../../../src/lib/playground/promptImpr // ─── Minimal hook test harness ──────────────────────────────────────────────── -interface HookCapture { - current: T; -} +type HookResult = { current: T }; function mountHook(useHook: () => T): { - result: HookCapture; + hookRef: HookResult; unmount: () => void; } { - const result: HookCapture = { current: undefined as unknown as T }; + const hookRef: HookResult = { current: undefined as unknown as T }; const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); function HookComponent() { - result.current = useHook(); + const captureRef = useRef(undefined as unknown as T); + captureRef.current = useHook(); + // eslint-disable-next-line react-hooks/immutability -- test harness: intentionally writes to outer capture object from inside component + hookRef.current = captureRef.current; return null; } @@ -36,7 +36,7 @@ function mountHook(useHook: () => T): { }); return { - result, + hookRef, unmount: () => { act(() => root.unmount()); container.remove(); @@ -65,7 +65,7 @@ describe("useImprovePrompt", () => { }); it("starts with loading=false and no error", () => { - const { result, unmount } = mountHook(() => useImprovePrompt()); + const { hookRef: result, unmount } = mountHook(() => useImprovePrompt()); expect(result.current.loading).toBe(false); expect(result.current.error).toBeNull(); unmount(); @@ -81,7 +81,7 @@ describe("useImprovePrompt", () => { }), ); - const { result, unmount } = mountHook(() => useImprovePrompt()); + const { hookRef: result, unmount } = mountHook(() => useImprovePrompt()); let returned: ImprovePromptResult | null = null; await act(async () => { @@ -106,7 +106,7 @@ describe("useImprovePrompt", () => { }); vi.stubGlobal("fetch", mockFetch); - const { result, unmount } = mountHook(() => useImprovePrompt()); + const { hookRef: result, unmount } = mountHook(() => useImprovePrompt()); const req = { system: "You are helpful.", prompt: "Summarize this.", @@ -139,7 +139,7 @@ describe("useImprovePrompt", () => { }), ); - const { result, unmount } = mountHook(() => useImprovePrompt()); + const { hookRef: result, unmount } = mountHook(() => useImprovePrompt()); let returned: ImprovePromptResult | null = null; await act(async () => { @@ -158,7 +158,7 @@ describe("useImprovePrompt", () => { vi.fn().mockRejectedValue(new Error("Network failure")), ); - const { result, unmount } = mountHook(() => useImprovePrompt()); + const { hookRef: result, unmount } = mountHook(() => useImprovePrompt()); let returned: ImprovePromptResult | null = null; await act(async () => { @@ -189,7 +189,7 @@ describe("useImprovePrompt", () => { }); vi.stubGlobal("fetch", mockFetch); - const { result, unmount } = mountHook(() => useImprovePrompt()); + const { hookRef: result, unmount } = mountHook(() => useImprovePrompt()); // First call — fails await act(async () => { @@ -215,7 +215,7 @@ describe("useImprovePrompt", () => { }), ); - const { result, unmount } = mountHook(() => useImprovePrompt()); + const { hookRef: result, unmount } = mountHook(() => useImprovePrompt()); await act(async () => { await result.current.improve({ prompt: "test", model: "gpt-4o" }); diff --git a/tests/unit/ui/use-presets.test.tsx b/tests/unit/ui/use-presets.test.tsx index cff5f656b7..7439c46315 100644 --- a/tests/unit/ui/use-presets.test.tsx +++ b/tests/unit/ui/use-presets.test.tsx @@ -2,33 +2,32 @@ // 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 React, { act, useRef } 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; -} +type HookResult = { current: T }; function mountHook(useHook: () => T): { - result: HookCapture; + hookRef: HookResult; unmount: () => void; } { - const result: HookCapture = { current: undefined as unknown as T }; + const hookRef: HookResult = { current: undefined as unknown as T }; const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); function HookComponent() { - result.current = useHook(); + const captureRef = useRef(undefined as unknown as T); + captureRef.current = useHook(); + // eslint-disable-next-line react-hooks/immutability -- test harness: intentionally writes to outer capture object from inside component + hookRef.current = captureRef.current; return null; } @@ -37,7 +36,7 @@ function mountHook(useHook: () => T): { }); return { - result, + hookRef, unmount: () => { act(() => root.unmount()); container.remove(); @@ -81,7 +80,7 @@ describe("usePresets", () => { const mockFetch = mockFetchOnce({ presets: [MOCK_PRESET] }); vi.stubGlobal("fetch", mockFetch); - const { result, unmount } = mountHook(() => usePresets()); + const { hookRef: result, unmount } = mountHook(() => usePresets()); await act(async () => { await result.current.list(); @@ -99,7 +98,7 @@ describe("usePresets", () => { const mockFetch = mockFetchOnce({ error: { message: "Unauthorized" } }, 401); vi.stubGlobal("fetch", mockFetch); - const { result, unmount } = mountHook(() => usePresets()); + const { hookRef: result, unmount } = mountHook(() => usePresets()); await act(async () => { await result.current.list(); @@ -113,7 +112,7 @@ describe("usePresets", () => { 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()); + const { hookRef: result, unmount } = mountHook(() => usePresets()); await act(async () => { await result.current.list(); @@ -141,7 +140,7 @@ describe("usePresets", () => { }); vi.stubGlobal("fetch", mockFetch); - const { result, unmount } = mountHook(() => usePresets()); + const { hookRef: result, unmount } = mountHook(() => usePresets()); const input = { name: "Test Preset", endpoint: "chat.completions", @@ -176,7 +175,7 @@ describe("usePresets", () => { const mockFetch = mockFetchOnce({ error: { message: "Bad request" } }, 400); vi.stubGlobal("fetch", mockFetch); - const { result, unmount } = mountHook(() => usePresets()); + const { hookRef: result, unmount } = mountHook(() => usePresets()); let created: PlaygroundPresetListItem | null = null; await act(async () => { @@ -206,7 +205,7 @@ describe("usePresets", () => { }); vi.stubGlobal("fetch", mockFetch); - const { result, unmount } = mountHook(() => usePresets()); + const { hookRef: result, unmount } = mountHook(() => usePresets()); const patch = { name: "Updated" }; let res: PlaygroundPresetListItem | null = null; @@ -240,7 +239,7 @@ describe("usePresets", () => { }); vi.stubGlobal("fetch", mockFetch); - const { result, unmount } = mountHook(() => usePresets()); + const { hookRef: result, unmount } = mountHook(() => usePresets()); await act(async () => { await result.current.remove(MOCK_PRESET.id); @@ -260,7 +259,7 @@ describe("usePresets", () => { const mockFetch = mockFetchOnce({ error: { message: "Not found" } }, 404); vi.stubGlobal("fetch", mockFetch); - const { result, unmount } = mountHook(() => usePresets()); + const { hookRef: result, unmount } = mountHook(() => usePresets()); await act(async () => { await result.current.remove("nonexistent-id"); @@ -276,7 +275,7 @@ describe("usePresets", () => { const mockFetch = mockFetchOnce({ presets: [] }); vi.stubGlobal("fetch", mockFetch); - const { result, unmount } = mountHook(() => usePresets()); + const { hookRef: result, unmount } = mountHook(() => usePresets()); await act(async () => { await result.current.list(); diff --git a/tests/unit/ui/use-stream-metrics.test.tsx b/tests/unit/ui/use-stream-metrics.test.tsx index e6ef597390..7d5bedc67f 100644 --- a/tests/unit/ui/use-stream-metrics.test.tsx +++ b/tests/unit/ui/use-stream-metrics.test.tsx @@ -2,8 +2,7 @@ // 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 React, { act, useRef } from "react"; import { createRoot } from "react-dom/client"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { @@ -11,23 +10,28 @@ import { } 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) ───────────────────── +// ─── Minimal hook test harness ──────────────────────────────────────────────── +// Uses a React ref to capture hook values from inside the component — avoids +// react-hooks/immutability lint error (cannot write to outer const from inside component). -interface HookCapture { - current: T; -} +type HookResult = { current: T }; function mountHook(useHook: () => T): { - result: HookCapture; + hookRef: HookResult; unmount: () => void; } { - const result: HookCapture = { current: undefined as unknown as T }; + const hookRef: HookResult = { current: undefined as unknown as T }; const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); function HookComponent() { - result.current = useHook(); + const captureRef = useRef(undefined as unknown as T); + captureRef.current = useHook(); + // Expose the captured value through the outer hookRef via the React ref. + // This is safe because captureRef lives inside the component. + // eslint-disable-next-line react-hooks/immutability -- test harness: intentionally writes to outer capture object from inside component + hookRef.current = captureRef.current; return null; } @@ -36,11 +40,9 @@ function mountHook(useHook: () => T): { }); return { - result, + hookRef, unmount: () => { - act(() => { - root.unmount(); - }); + act(() => root.unmount()); container.remove(); }, }; @@ -58,7 +60,7 @@ describe("useStreamMetrics", () => { }); it("starts with initial zero/null metrics", () => { - const { result, unmount } = mountHook(() => useStreamMetrics()); + const { hookRef: result, unmount } = mountHook(() => useStreamMetrics()); expect(result.current.metrics.ttftMs).toBeNull(); expect(result.current.metrics.totalMs).toBeNull(); expect(result.current.metrics.tps).toBeNull(); @@ -69,7 +71,7 @@ describe("useStreamMetrics", () => { }); it("start() resets timing refs and updates metrics state", () => { - const { result, unmount } = mountHook(() => useStreamMetrics()); + const { hookRef: result, unmount } = mountHook(() => useStreamMetrics()); act(() => { vi.setSystemTime(1000); @@ -84,7 +86,7 @@ describe("useStreamMetrics", () => { }); it("onFirstChunk() sets ttftMs relative to start", () => { - const { result, unmount } = mountHook(() => useStreamMetrics()); + const { hookRef: result, unmount } = mountHook(() => useStreamMetrics()); act(() => { vi.setSystemTime(1000); @@ -101,7 +103,7 @@ describe("useStreamMetrics", () => { }); it("onFirstChunk() is idempotent — only records first call", () => { - const { result, unmount } = mountHook(() => useStreamMetrics()); + const { hookRef: result, unmount } = mountHook(() => useStreamMetrics()); act(() => { vi.setSystemTime(1000); @@ -123,7 +125,7 @@ describe("useStreamMetrics", () => { }); it("finish() finalizes metrics with accumulated chunk tokens", () => { - const { result, unmount } = mountHook(() => useStreamMetrics()); + const { hookRef: result, unmount } = mountHook(() => useStreamMetrics()); act(() => { vi.setSystemTime(1000); @@ -135,7 +137,7 @@ describe("useStreamMetrics", () => { result.current.onFirstChunk(); }); - // onChunk does not flush to state — accumulates in ref + // onChunk accumulates in ref without flushing state result.current.onChunk(5); result.current.onChunk(5); result.current.onChunk(5); @@ -156,7 +158,7 @@ describe("useStreamMetrics", () => { }); it("finish(usage) overrides tokensIn + tokensOut with upstream values", () => { - const { result, unmount } = mountHook(() => useStreamMetrics()); + const { hookRef: result, unmount } = mountHook(() => useStreamMetrics()); act(() => { vi.setSystemTime(1000); @@ -185,7 +187,7 @@ describe("useStreamMetrics", () => { }); it("finish(usage) with only prompt_tokens partial override", () => { - const { result, unmount } = mountHook(() => useStreamMetrics()); + const { hookRef: result, unmount } = mountHook(() => useStreamMetrics()); act(() => { vi.setSystemTime(1000); @@ -212,7 +214,7 @@ describe("useStreamMetrics", () => { it("computes costUsd when pricing is provided", () => { const pricing = { inUsdPer1k: 0.003, outUsdPer1k: 0.015 }; - const { result, unmount } = mountHook(() => useStreamMetrics(pricing)); + const { hookRef: result, unmount } = mountHook(() => useStreamMetrics(pricing)); act(() => { vi.setSystemTime(1000); @@ -235,7 +237,7 @@ describe("useStreamMetrics", () => { }); it("costUsd is null when no pricing provided", () => { - const { result, unmount } = mountHook(() => useStreamMetrics()); + const { hookRef: result, unmount } = mountHook(() => useStreamMetrics()); act(() => { vi.setSystemTime(1000); @@ -251,7 +253,7 @@ describe("useStreamMetrics", () => { }); it("reset() zeros all metrics", () => { - const { result, unmount } = mountHook(() => useStreamMetrics()); + const { hookRef: result, unmount } = mountHook(() => useStreamMetrics()); act(() => { vi.setSystemTime(1000); @@ -281,7 +283,7 @@ describe("useStreamMetrics", () => { }); it("start() after previous run resets all accumulated state", () => { - const { result, unmount } = mountHook(() => useStreamMetrics()); + const { hookRef: result, unmount } = mountHook(() => useStreamMetrics()); act(() => { vi.setSystemTime(1000); diff --git a/tests/unit/ui/use-structured-output.test.tsx b/tests/unit/ui/use-structured-output.test.tsx index e81d7a5e6e..c9eb7748bb 100644 --- a/tests/unit/ui/use-structured-output.test.tsx +++ b/tests/unit/ui/use-structured-output.test.tsx @@ -2,8 +2,7 @@ // 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 React, { act, useRef } from "react"; import { createRoot } from "react-dom/client"; import { describe, it, expect } from "vitest"; import { @@ -12,21 +11,22 @@ import { // ─── Minimal hook test harness ──────────────────────────────────────────────── -interface HookCapture { - current: T; -} +type HookResult = { current: T }; function mountHook(useHook: () => T): { - result: HookCapture; + hookRef: HookResult; unmount: () => void; } { - const result: HookCapture = { current: undefined as unknown as T }; + const hookRef: HookResult = { current: undefined as unknown as T }; const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); function HookComponent() { - result.current = useHook(); + const captureRef = useRef(undefined as unknown as T); + captureRef.current = useHook(); + // eslint-disable-next-line react-hooks/immutability -- test harness: intentionally writes to outer capture object from inside component + hookRef.current = captureRef.current; return null; } @@ -35,7 +35,7 @@ function mountHook(useHook: () => T): { }); return { - result, + hookRef, unmount: () => { act(() => root.unmount()); container.remove(); @@ -73,7 +73,7 @@ const VALID_SCHEMA_NO_REQUIRED = { describe("useStructuredOutput", () => { describe("initial state", () => { it("starts with enabled=false, schema=null, error=null", () => { - const { result, unmount } = mountHook(() => useStructuredOutput()); + const { hookRef: result, unmount } = mountHook(() => useStructuredOutput()); expect(result.current.enabled).toBe(false); expect(result.current.schema).toBeNull(); expect(result.current.error).toBeNull(); @@ -83,7 +83,7 @@ describe("useStructuredOutput", () => { describe("setEnabled()", () => { it("sets enabled to true", () => { - const { result, unmount } = mountHook(() => useStructuredOutput()); + const { hookRef: result, unmount } = mountHook(() => useStructuredOutput()); act(() => { result.current.setEnabled(true); @@ -94,7 +94,7 @@ describe("useStructuredOutput", () => { }); it("toggles enabled back to false", () => { - const { result, unmount } = mountHook(() => useStructuredOutput()); + const { hookRef: result, unmount } = mountHook(() => useStructuredOutput()); act(() => { result.current.setEnabled(true); @@ -108,7 +108,7 @@ describe("useStructuredOutput", () => { describe("setSchema()", () => { it("accepts a valid schema and clears error", () => { - const { result, unmount } = mountHook(() => useStructuredOutput()); + const { hookRef: result, unmount } = mountHook(() => useStructuredOutput()); act(() => { result.current.setSchema(VALID_SCHEMA); @@ -120,7 +120,7 @@ describe("useStructuredOutput", () => { }); it("sets error when schema name is empty", () => { - const { result, unmount } = mountHook(() => useStructuredOutput()); + const { hookRef: result, unmount } = mountHook(() => useStructuredOutput()); act(() => { result.current.setSchema({ name: "", schema: { type: "object" } }); @@ -132,7 +132,7 @@ describe("useStructuredOutput", () => { }); it("sets error when name is too long (>64 chars)", () => { - const { result, unmount } = mountHook(() => useStructuredOutput()); + const { hookRef: result, unmount } = mountHook(() => useStructuredOutput()); act(() => { result.current.setSchema({ name: "a".repeat(65), schema: { type: "object" } }); @@ -143,7 +143,7 @@ describe("useStructuredOutput", () => { }); it("clears error after previously invalid schema when valid one is set", () => { - const { result, unmount } = mountHook(() => useStructuredOutput()); + const { hookRef: result, unmount } = mountHook(() => useStructuredOutput()); act(() => { result.current.setSchema({ name: "", schema: {} }); @@ -159,7 +159,7 @@ describe("useStructuredOutput", () => { }); it("accepts schema with strict=false", () => { - const { result, unmount } = mountHook(() => useStructuredOutput()); + const { hookRef: result, unmount } = mountHook(() => useStructuredOutput()); act(() => { result.current.setSchema({ ...VALID_SCHEMA, strict: false }); @@ -171,7 +171,7 @@ describe("useStructuredOutput", () => { }); it("accepts schema without strict field", () => { - const { result, unmount } = mountHook(() => useStructuredOutput()); + const { hookRef: result, unmount } = mountHook(() => useStructuredOutput()); act(() => { result.current.setSchema(VALID_SCHEMA_NO_REQUIRED); @@ -185,7 +185,7 @@ describe("useStructuredOutput", () => { describe("validateResponse()", () => { it("returns { valid: false, error } when no schema is set", () => { - const { result, unmount } = mountHook(() => useStructuredOutput()); + const { hookRef: result, unmount } = mountHook(() => useStructuredOutput()); const res = result.current.validateResponse({ temperature: 25, condition: "sunny" }); @@ -195,7 +195,7 @@ describe("useStructuredOutput", () => { }); it("parses JSON string and validates correctly", () => { - const { result, unmount } = mountHook(() => useStructuredOutput()); + const { hookRef: result, unmount } = mountHook(() => useStructuredOutput()); act(() => { result.current.setSchema(VALID_SCHEMA); @@ -209,7 +209,7 @@ describe("useStructuredOutput", () => { }); it("returns { valid: false } when content is not valid JSON string", () => { - const { result, unmount } = mountHook(() => useStructuredOutput()); + const { hookRef: result, unmount } = mountHook(() => useStructuredOutput()); act(() => { result.current.setSchema(VALID_SCHEMA); @@ -223,7 +223,7 @@ describe("useStructuredOutput", () => { }); it("validates object directly (no JSON.parse needed)", () => { - const { result, unmount } = mountHook(() => useStructuredOutput()); + const { hookRef: result, unmount } = mountHook(() => useStructuredOutput()); act(() => { result.current.setSchema(VALID_SCHEMA); @@ -235,7 +235,7 @@ describe("useStructuredOutput", () => { }); it("returns { valid: false } when required field is missing", () => { - const { result, unmount } = mountHook(() => useStructuredOutput()); + const { hookRef: result, unmount } = mountHook(() => useStructuredOutput()); act(() => { result.current.setSchema(VALID_SCHEMA); @@ -248,7 +248,7 @@ describe("useStructuredOutput", () => { }); it("returns { valid: false } when content is null", () => { - const { result, unmount } = mountHook(() => useStructuredOutput()); + const { hookRef: result, unmount } = mountHook(() => useStructuredOutput()); act(() => { result.current.setSchema(VALID_SCHEMA_NO_REQUIRED); @@ -260,7 +260,7 @@ describe("useStructuredOutput", () => { }); it("returns { valid: false } when content is an array", () => { - const { result, unmount } = mountHook(() => useStructuredOutput()); + const { hookRef: result, unmount } = mountHook(() => useStructuredOutput()); act(() => { result.current.setSchema(VALID_SCHEMA_NO_REQUIRED); @@ -272,7 +272,7 @@ describe("useStructuredOutput", () => { }); it("passes validation for schema without required field", () => { - const { result, unmount } = mountHook(() => useStructuredOutput()); + const { hookRef: result, unmount } = mountHook(() => useStructuredOutput()); act(() => { result.current.setSchema(VALID_SCHEMA_NO_REQUIRED); @@ -284,7 +284,7 @@ describe("useStructuredOutput", () => { }); it("passes validation for schema with no properties field", () => { - const { result, unmount } = mountHook(() => useStructuredOutput()); + const { hookRef: result, unmount } = mountHook(() => useStructuredOutput()); act(() => { result.current.setSchema({ name: "AnyObj", schema: { type: "object" } }); diff --git a/tests/unit/ui/use-tools-builder.test.tsx b/tests/unit/ui/use-tools-builder.test.tsx index 5479165197..e7fa6dc73f 100644 --- a/tests/unit/ui/use-tools-builder.test.tsx +++ b/tests/unit/ui/use-tools-builder.test.tsx @@ -2,8 +2,7 @@ // 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 React, { act, useRef } from "react"; import { createRoot } from "react-dom/client"; import { describe, it, expect } from "vitest"; import { @@ -13,21 +12,22 @@ import type { ToolDefinition } from "../../../src/lib/playground/codeExport"; // ─── Minimal hook test harness ──────────────────────────────────────────────── -interface HookCapture { - current: T; -} +type HookResult = { current: T }; function mountHook(useHook: () => T): { - result: HookCapture; + hookRef: HookResult; unmount: () => void; } { - const result: HookCapture = { current: undefined as unknown as T }; + const hookRef: HookResult = { current: undefined as unknown as T }; const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); function HookComponent() { - result.current = useHook(); + const captureRef = useRef(undefined as unknown as T); + captureRef.current = useHook(); + // eslint-disable-next-line react-hooks/immutability -- test harness: intentionally writes to outer capture object from inside component + hookRef.current = captureRef.current; return null; } @@ -36,7 +36,7 @@ function mountHook(useHook: () => T): { }); return { - result, + hookRef, unmount: () => { act(() => root.unmount()); container.remove(); @@ -73,7 +73,7 @@ const VALID_TOOL_2: ToolDefinition = { describe("useToolsBuilder", () => { describe("initial state", () => { it("starts with empty tools and empty errors", () => { - const { result, unmount } = mountHook(() => useToolsBuilder()); + const { hookRef: result, unmount } = mountHook(() => useToolsBuilder()); expect(result.current.tools).toHaveLength(0); expect(result.current.errors.size).toBe(0); unmount(); @@ -82,7 +82,7 @@ describe("useToolsBuilder", () => { describe("add()", () => { it("returns { ok: false, error } when tool has empty name", () => { - const { result, unmount } = mountHook(() => useToolsBuilder()); + const { hookRef: result, unmount } = mountHook(() => useToolsBuilder()); const invalidTool = { type: "function" as const, @@ -101,7 +101,7 @@ describe("useToolsBuilder", () => { }); it("returns { ok: false } when type is not 'function'", () => { - const { result, unmount } = mountHook(() => useToolsBuilder()); + const { hookRef: result, unmount } = mountHook(() => useToolsBuilder()); const invalidTool = { type: "not-function" as unknown as "function", @@ -119,7 +119,7 @@ describe("useToolsBuilder", () => { }); it("adds a valid tool and returns { ok: true }", () => { - const { result, unmount } = mountHook(() => useToolsBuilder()); + const { hookRef: result, unmount } = mountHook(() => useToolsBuilder()); let outcome: ReturnType | undefined; act(() => { @@ -133,7 +133,7 @@ describe("useToolsBuilder", () => { }); it("adds multiple valid tools", () => { - const { result, unmount } = mountHook(() => useToolsBuilder()); + const { hookRef: result, unmount } = mountHook(() => useToolsBuilder()); act(() => { result.current.add(VALID_TOOL); @@ -145,7 +145,7 @@ describe("useToolsBuilder", () => { }); it("does not add to tools when validation fails", () => { - const { result, unmount } = mountHook(() => useToolsBuilder()); + const { hookRef: result, unmount } = mountHook(() => useToolsBuilder()); act(() => { result.current.add(VALID_TOOL); @@ -159,7 +159,7 @@ describe("useToolsBuilder", () => { describe("remove()", () => { it("removes tool at given index", () => { - const { result, unmount } = mountHook(() => useToolsBuilder()); + const { hookRef: result, unmount } = mountHook(() => useToolsBuilder()); act(() => { result.current.add(VALID_TOOL); @@ -176,7 +176,7 @@ describe("useToolsBuilder", () => { }); it("is a no-op when index is out of bounds", () => { - const { result, unmount } = mountHook(() => useToolsBuilder()); + const { hookRef: result, unmount } = mountHook(() => useToolsBuilder()); act(() => { result.current.add(VALID_TOOL); @@ -191,7 +191,7 @@ describe("useToolsBuilder", () => { }); it("re-indexes errors after remove", () => { - const { result, unmount } = mountHook(() => useToolsBuilder()); + const { hookRef: result, unmount } = mountHook(() => useToolsBuilder()); act(() => { result.current.add(VALID_TOOL); @@ -219,7 +219,7 @@ describe("useToolsBuilder", () => { describe("update()", () => { it("updates a tool at given index after validation", () => { - const { result, unmount } = mountHook(() => useToolsBuilder()); + const { hookRef: result, unmount } = mountHook(() => useToolsBuilder()); act(() => { result.current.add(VALID_TOOL); @@ -242,7 +242,7 @@ describe("useToolsBuilder", () => { }); it("returns { ok: false, error } and stores error when validation fails", () => { - const { result, unmount } = mountHook(() => useToolsBuilder()); + const { hookRef: result, unmount } = mountHook(() => useToolsBuilder()); act(() => { result.current.add(VALID_TOOL); @@ -264,7 +264,7 @@ describe("useToolsBuilder", () => { }); it("clears error for that index on successful update", () => { - const { result, unmount } = mountHook(() => useToolsBuilder()); + const { hookRef: result, unmount } = mountHook(() => useToolsBuilder()); act(() => { result.current.add(VALID_TOOL); @@ -285,7 +285,7 @@ describe("useToolsBuilder", () => { describe("clear()", () => { it("removes all tools and clears all errors", () => { - const { result, unmount } = mountHook(() => useToolsBuilder()); + const { hookRef: result, unmount } = mountHook(() => useToolsBuilder()); act(() => { result.current.add(VALID_TOOL);