mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 12:22:14 +03:00
Merge branch 'feat/playground-hooks-F5' into feat/playground-ui-advanced-F7
This commit is contained in:
@@ -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<ImprovePromptResult | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string | null>(null);
|
||||
|
||||
const improve = useCallback(
|
||||
async (req: {
|
||||
system?: string;
|
||||
prompt?: string;
|
||||
model: string;
|
||||
tone?: "concise" | "detailed";
|
||||
}): Promise<ImprovePromptResult | null> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
151
src/app/(dashboard)/dashboard/playground/hooks/usePresets.ts
Normal file
151
src/app/(dashboard)/dashboard/playground/hooks/usePresets.ts
Normal file
@@ -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<typeof PlaygroundPresetCreateSchema>;
|
||||
type UpdateInput = z.infer<typeof PlaygroundPresetUpdateSchema>;
|
||||
|
||||
export interface UsePresetsState {
|
||||
presets: PlaygroundPresetListItem[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface UsePresets extends UsePresetsState {
|
||||
list: () => Promise<void>;
|
||||
create: (input: CreateInput) => Promise<PlaygroundPresetListItem | null>;
|
||||
update: (id: string, patch: UpdateInput) => Promise<PlaygroundPresetListItem | null>;
|
||||
remove: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<PlaygroundPresetListItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const list = useCallback(async (): Promise<void> => {
|
||||
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<PlaygroundPresetListItem | null> => {
|
||||
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<PlaygroundPresetListItem | null> => {
|
||||
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<void> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<StreamMetrics>(INITIAL_METRICS);
|
||||
|
||||
const refs = useRef<TimingRefs>({
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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<StructuredOutputSchemaInput | null>(null);
|
||||
const [error, setError] = useState<string | null>(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<string, unknown>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<number, string>;
|
||||
}
|
||||
|
||||
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<ToolDefinition[]>([]);
|
||||
const [errors, setErrors] = useState<Map<number, string>>(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<number, string>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
61
src/lib/playground/streamMetrics.ts
Normal file
61
src/lib/playground/streamMetrics.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
241
tests/unit/playground-stream-metrics.test.ts
Normal file
241
tests/unit/playground-stream-metrics.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
// tests/unit/playground-stream-metrics.test.ts
|
||||
// Node native test runner — no React
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { computeMetrics } from "../../src/lib/playground/streamMetrics.js";
|
||||
|
||||
describe("computeMetrics", () => {
|
||||
const BASE_START = 1000;
|
||||
const BASE_FIRST = 1200;
|
||||
const BASE_FINISH = 3000;
|
||||
|
||||
describe("ttftMs", () => {
|
||||
it("is firstChunkAt - startedAt when both are set", () => {
|
||||
const m = computeMetrics({
|
||||
startedAt: BASE_START,
|
||||
firstChunkAt: BASE_FIRST,
|
||||
finishedAt: BASE_FINISH,
|
||||
tokensIn: 10,
|
||||
tokensOut: 20,
|
||||
});
|
||||
assert.equal(m.ttftMs, BASE_FIRST - BASE_START); // 200
|
||||
});
|
||||
|
||||
it("is null when firstChunkAt is null", () => {
|
||||
const m = computeMetrics({
|
||||
startedAt: BASE_START,
|
||||
firstChunkAt: null,
|
||||
finishedAt: BASE_FINISH,
|
||||
tokensIn: 10,
|
||||
tokensOut: 20,
|
||||
});
|
||||
assert.equal(m.ttftMs, null);
|
||||
});
|
||||
|
||||
it("is null when startedAt is null", () => {
|
||||
const m = computeMetrics({
|
||||
startedAt: null,
|
||||
firstChunkAt: BASE_FIRST,
|
||||
finishedAt: BASE_FINISH,
|
||||
tokensIn: 10,
|
||||
tokensOut: 20,
|
||||
});
|
||||
assert.equal(m.ttftMs, null);
|
||||
});
|
||||
|
||||
it("is null when both are null", () => {
|
||||
const m = computeMetrics({
|
||||
startedAt: null,
|
||||
firstChunkAt: null,
|
||||
finishedAt: null,
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
});
|
||||
assert.equal(m.ttftMs, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("totalMs", () => {
|
||||
it("is finishedAt - startedAt when both are set", () => {
|
||||
const m = computeMetrics({
|
||||
startedAt: BASE_START,
|
||||
firstChunkAt: BASE_FIRST,
|
||||
finishedAt: BASE_FINISH,
|
||||
tokensIn: 10,
|
||||
tokensOut: 20,
|
||||
});
|
||||
assert.equal(m.totalMs, BASE_FINISH - BASE_START); // 2000
|
||||
});
|
||||
|
||||
it("is null when finishedAt is null", () => {
|
||||
const m = computeMetrics({
|
||||
startedAt: BASE_START,
|
||||
firstChunkAt: BASE_FIRST,
|
||||
finishedAt: null,
|
||||
tokensIn: 10,
|
||||
tokensOut: 20,
|
||||
});
|
||||
assert.equal(m.totalMs, null);
|
||||
});
|
||||
|
||||
it("is null when startedAt is null", () => {
|
||||
const m = computeMetrics({
|
||||
startedAt: null,
|
||||
firstChunkAt: BASE_FIRST,
|
||||
finishedAt: BASE_FINISH,
|
||||
tokensIn: 10,
|
||||
tokensOut: 20,
|
||||
});
|
||||
assert.equal(m.totalMs, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tps (tokens per second)", () => {
|
||||
it("is tokensOut / (totalMs / 1000)", () => {
|
||||
// totalMs = 2000ms => 2s; tokensOut = 20 => tps = 10
|
||||
const m = computeMetrics({
|
||||
startedAt: BASE_START,
|
||||
firstChunkAt: BASE_FIRST,
|
||||
finishedAt: BASE_FINISH,
|
||||
tokensIn: 10,
|
||||
tokensOut: 20,
|
||||
});
|
||||
assert.equal(m.tps, 20 / (2000 / 1000)); // 10
|
||||
});
|
||||
|
||||
it("is null when tokensOut is 0", () => {
|
||||
const m = computeMetrics({
|
||||
startedAt: BASE_START,
|
||||
firstChunkAt: BASE_FIRST,
|
||||
finishedAt: BASE_FINISH,
|
||||
tokensIn: 10,
|
||||
tokensOut: 0,
|
||||
});
|
||||
assert.equal(m.tps, null);
|
||||
});
|
||||
|
||||
it("is null when totalMs is null (stream not finished)", () => {
|
||||
const m = computeMetrics({
|
||||
startedAt: BASE_START,
|
||||
firstChunkAt: BASE_FIRST,
|
||||
finishedAt: null,
|
||||
tokensIn: 10,
|
||||
tokensOut: 20,
|
||||
});
|
||||
assert.equal(m.tps, null);
|
||||
});
|
||||
|
||||
it("is null when totalMs is 0 (avoid division by zero)", () => {
|
||||
const m = computeMetrics({
|
||||
startedAt: BASE_START,
|
||||
firstChunkAt: BASE_FIRST,
|
||||
finishedAt: BASE_START, // same as start => 0ms
|
||||
tokensIn: 10,
|
||||
tokensOut: 20,
|
||||
});
|
||||
assert.equal(m.tps, null);
|
||||
});
|
||||
|
||||
it("computes correct tps for various token counts", () => {
|
||||
const cases: Array<{ tokensOut: number; totalMs: number; expected: number }> = [
|
||||
{ tokensOut: 100, totalMs: 1000, expected: 100 },
|
||||
{ tokensOut: 50, totalMs: 2000, expected: 25 },
|
||||
{ tokensOut: 1, totalMs: 500, expected: 2 },
|
||||
];
|
||||
for (const { tokensOut, totalMs, expected } of cases) {
|
||||
const m = computeMetrics({
|
||||
startedAt: 0,
|
||||
firstChunkAt: 1,
|
||||
finishedAt: totalMs,
|
||||
tokensIn: 5,
|
||||
tokensOut,
|
||||
});
|
||||
assert.equal(m.tps, expected, `tps should be ${expected} for ${tokensOut}t/${totalMs}ms`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("costUsd", () => {
|
||||
it("computes cost: (tokensIn × inPer1k + tokensOut × outPer1k) / 1000", () => {
|
||||
const m = computeMetrics({
|
||||
startedAt: BASE_START,
|
||||
firstChunkAt: BASE_FIRST,
|
||||
finishedAt: BASE_FINISH,
|
||||
tokensIn: 1000,
|
||||
tokensOut: 500,
|
||||
pricing: { inUsdPer1k: 0.003, outUsdPer1k: 0.015, estimated: true },
|
||||
});
|
||||
// cost = (1000 * 0.003 + 500 * 0.015) / 1000 = (3 + 7.5) / 1000 = 0.0105
|
||||
assert.ok(Math.abs((m.costUsd ?? 0) - 0.0105) < 1e-10);
|
||||
});
|
||||
|
||||
it("is null when pricing is absent", () => {
|
||||
const m = computeMetrics({
|
||||
startedAt: BASE_START,
|
||||
firstChunkAt: BASE_FIRST,
|
||||
finishedAt: BASE_FINISH,
|
||||
tokensIn: 1000,
|
||||
tokensOut: 500,
|
||||
});
|
||||
assert.equal(m.costUsd, null);
|
||||
});
|
||||
|
||||
it("handles zero tokens", () => {
|
||||
const m = computeMetrics({
|
||||
startedAt: BASE_START,
|
||||
firstChunkAt: BASE_FIRST,
|
||||
finishedAt: BASE_FINISH,
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
pricing: { inUsdPer1k: 0.003, outUsdPer1k: 0.015, estimated: true },
|
||||
});
|
||||
assert.equal(m.costUsd, 0);
|
||||
});
|
||||
|
||||
it("computes gpt-4o style pricing correctly", () => {
|
||||
// gpt-4o: in=0.0025, out=0.01 per 1k
|
||||
const m = computeMetrics({
|
||||
startedAt: 0,
|
||||
firstChunkAt: 100,
|
||||
finishedAt: 2000,
|
||||
tokensIn: 2000,
|
||||
tokensOut: 300,
|
||||
pricing: { inUsdPer1k: 0.0025, outUsdPer1k: 0.01, estimated: true },
|
||||
});
|
||||
const expected = (2000 * 0.0025 + 300 * 0.01) / 1000; // (5 + 3) / 1000 = 0.008
|
||||
assert.ok(Math.abs((m.costUsd ?? 0) - expected) < 1e-10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("passthrough fields", () => {
|
||||
it("returns tokensIn and tokensOut unchanged", () => {
|
||||
const m = computeMetrics({
|
||||
startedAt: BASE_START,
|
||||
firstChunkAt: BASE_FIRST,
|
||||
finishedAt: BASE_FINISH,
|
||||
tokensIn: 42,
|
||||
tokensOut: 77,
|
||||
});
|
||||
assert.equal(m.tokensIn, 42);
|
||||
assert.equal(m.tokensOut, 77);
|
||||
});
|
||||
});
|
||||
|
||||
describe("initial state (all null)", () => {
|
||||
it("returns all-null metrics when nothing has happened", () => {
|
||||
const m = computeMetrics({
|
||||
startedAt: null,
|
||||
firstChunkAt: null,
|
||||
finishedAt: null,
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
});
|
||||
assert.equal(m.ttftMs, null);
|
||||
assert.equal(m.totalMs, null);
|
||||
assert.equal(m.tps, null);
|
||||
assert.equal(m.costUsd, null);
|
||||
assert.equal(m.tokensIn, 0);
|
||||
assert.equal(m.tokensOut, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
227
tests/unit/ui/use-improve-prompt.test.tsx
Normal file
227
tests/unit/ui/use-improve-prompt.test.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
// @vitest-environment jsdom
|
||||
// tests/unit/ui/use-improve-prompt.test.tsx
|
||||
// Runs via Vitest (vitest.config.ts)
|
||||
// Uses React DOM directly (no @testing-library/dom dep required).
|
||||
import React, { act, useRef } 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 ────────────────────────────────────────────────
|
||||
|
||||
type HookResult<T> = { current: T };
|
||||
|
||||
function mountHook<T>(useHook: () => T): {
|
||||
hookRef: HookResult<T>;
|
||||
unmount: () => void;
|
||||
} {
|
||||
const hookRef: HookResult<T> = { current: undefined as unknown as T };
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
function HookComponent() {
|
||||
const captureRef = useRef<T>(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;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(React.createElement(HookComponent));
|
||||
});
|
||||
|
||||
return {
|
||||
hookRef,
|
||||
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 { hookRef: 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 { hookRef: 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 { hookRef: 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 { hookRef: 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 { hookRef: 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 { hookRef: 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 { hookRef: result, unmount } = mountHook(() => useImprovePrompt());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.improve({ prompt: "test", model: "gpt-4o" });
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe("HTTP 503");
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
288
tests/unit/ui/use-presets.test.tsx
Normal file
288
tests/unit/ui/use-presets.test.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
// @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, { 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 { PlaygroundPresetListItem } from "../../../src/shared/schemas/playground";
|
||||
|
||||
// ─── Minimal hook test harness ────────────────────────────────────────────────
|
||||
|
||||
type HookResult<T> = { current: T };
|
||||
|
||||
function mountHook<T>(useHook: () => T): {
|
||||
hookRef: HookResult<T>;
|
||||
unmount: () => void;
|
||||
} {
|
||||
const hookRef: HookResult<T> = { current: undefined as unknown as T };
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
function HookComponent() {
|
||||
const captureRef = useRef<T>(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;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(React.createElement(HookComponent));
|
||||
});
|
||||
|
||||
return {
|
||||
hookRef,
|
||||
unmount: () => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Test fixtures ────────────────────────────────────────────────────────────
|
||||
|
||||
const MOCK_PRESET: PlaygroundPresetListItem = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
name: "Test Preset",
|
||||
endpoint: "chat.completions",
|
||||
model: "gpt-4o",
|
||||
system: "You are helpful.",
|
||||
params: { temperature: 0.7 },
|
||||
created_at: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
function mockFetchOnce(response: unknown, status = 200): ReturnType<typeof vi.fn> {
|
||||
return vi.fn().mockResolvedValue({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => response,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("usePresets", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("list()", () => {
|
||||
it("fetches GET /api/playground/presets and populates presets", async () => {
|
||||
const mockFetch = mockFetchOnce({ presets: [MOCK_PRESET] });
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const { hookRef: 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 { hookRef: 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 { hookRef: 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 { hookRef: 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 { hookRef: 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 { hookRef: 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 { hookRef: 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 { hookRef: 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 { hookRef: result, unmount } = mountHook(() => usePresets());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.list();
|
||||
});
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
313
tests/unit/ui/use-stream-metrics.test.tsx
Normal file
313
tests/unit/ui/use-stream-metrics.test.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
// @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, { act, useRef } 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 ────────────────────────────────────────────────
|
||||
// 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).
|
||||
|
||||
type HookResult<T> = { current: T };
|
||||
|
||||
function mountHook<T>(useHook: () => T): {
|
||||
hookRef: HookResult<T>;
|
||||
unmount: () => void;
|
||||
} {
|
||||
const hookRef: HookResult<T> = { current: undefined as unknown as T };
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
function HookComponent() {
|
||||
const captureRef = useRef<T>(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;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(React.createElement(HookComponent));
|
||||
});
|
||||
|
||||
return {
|
||||
hookRef,
|
||||
unmount: () => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("useStreamMetrics", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("starts with initial zero/null metrics", () => {
|
||||
const { hookRef: 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 { hookRef: 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 { hookRef: 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 { hookRef: 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 { hookRef: result, unmount } = mountHook(() => useStreamMetrics());
|
||||
|
||||
act(() => {
|
||||
vi.setSystemTime(1000);
|
||||
result.current.start();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.setSystemTime(1200);
|
||||
result.current.onFirstChunk();
|
||||
});
|
||||
|
||||
// onChunk accumulates in ref without flushing state
|
||||
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 { hookRef: 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 { hookRef: 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 { hookRef: 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 { hookRef: 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 { hookRef: 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 { hookRef: 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();
|
||||
});
|
||||
});
|
||||
298
tests/unit/ui/use-structured-output.test.tsx
Normal file
298
tests/unit/ui/use-structured-output.test.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
// @vitest-environment jsdom
|
||||
// tests/unit/ui/use-structured-output.test.tsx
|
||||
// Runs via Vitest (vitest.config.ts)
|
||||
// Uses React DOM directly (no @testing-library/dom dep required).
|
||||
import React, { act, useRef } 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 ────────────────────────────────────────────────
|
||||
|
||||
type HookResult<T> = { current: T };
|
||||
|
||||
function mountHook<T>(useHook: () => T): {
|
||||
hookRef: HookResult<T>;
|
||||
unmount: () => void;
|
||||
} {
|
||||
const hookRef: HookResult<T> = { current: undefined as unknown as T };
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
function HookComponent() {
|
||||
const captureRef = useRef<T>(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;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(React.createElement(HookComponent));
|
||||
});
|
||||
|
||||
return {
|
||||
hookRef,
|
||||
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 { hookRef: 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 { hookRef: result, unmount } = mountHook(() => useStructuredOutput());
|
||||
|
||||
act(() => {
|
||||
result.current.setEnabled(true);
|
||||
});
|
||||
|
||||
expect(result.current.enabled).toBe(true);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("toggles enabled back to false", () => {
|
||||
const { hookRef: 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 { hookRef: 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 { hookRef: 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 { hookRef: 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 { hookRef: 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 { hookRef: 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 { hookRef: 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 { hookRef: 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 { hookRef: 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 { hookRef: 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 { hookRef: 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 { hookRef: 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 { hookRef: 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 { hookRef: 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 { hookRef: 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 { hookRef: 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
308
tests/unit/ui/use-tools-builder.test.tsx
Normal file
308
tests/unit/ui/use-tools-builder.test.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
// @vitest-environment jsdom
|
||||
// tests/unit/ui/use-tools-builder.test.tsx
|
||||
// Runs via Vitest (vitest.config.ts)
|
||||
// Uses React DOM directly (no @testing-library/dom dep required).
|
||||
import React, { act, useRef } 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 ────────────────────────────────────────────────
|
||||
|
||||
type HookResult<T> = { current: T };
|
||||
|
||||
function mountHook<T>(useHook: () => T): {
|
||||
hookRef: HookResult<T>;
|
||||
unmount: () => void;
|
||||
} {
|
||||
const hookRef: HookResult<T> = { current: undefined as unknown as T };
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
function HookComponent() {
|
||||
const captureRef = useRef<T>(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;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(React.createElement(HookComponent));
|
||||
});
|
||||
|
||||
return {
|
||||
hookRef,
|
||||
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 { hookRef: 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 { hookRef: result, unmount } = mountHook(() => useToolsBuilder());
|
||||
|
||||
const invalidTool = {
|
||||
type: "function" as const,
|
||||
function: { name: "", parameters: {} },
|
||||
};
|
||||
|
||||
let outcome: ReturnType<typeof result.current.add> | undefined;
|
||||
act(() => {
|
||||
outcome = result.current.add(invalidTool as ToolDefinition);
|
||||
});
|
||||
|
||||
expect(outcome).toMatchObject({ ok: false });
|
||||
expect((outcome as { ok: false; error: string }).error).toBeTruthy();
|
||||
expect(result.current.tools).toHaveLength(0);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("returns { ok: false } when type is not 'function'", () => {
|
||||
const { hookRef: result, unmount } = mountHook(() => useToolsBuilder());
|
||||
|
||||
const invalidTool = {
|
||||
type: "not-function" as unknown as "function",
|
||||
function: { name: "valid_name", parameters: {} },
|
||||
};
|
||||
|
||||
let outcome: ReturnType<typeof result.current.add> | undefined;
|
||||
act(() => {
|
||||
outcome = result.current.add(invalidTool as ToolDefinition);
|
||||
});
|
||||
|
||||
expect(outcome).toMatchObject({ ok: false });
|
||||
expect(result.current.tools).toHaveLength(0);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("adds a valid tool and returns { ok: true }", () => {
|
||||
const { hookRef: result, unmount } = mountHook(() => useToolsBuilder());
|
||||
|
||||
let outcome: ReturnType<typeof result.current.add> | undefined;
|
||||
act(() => {
|
||||
outcome = result.current.add(VALID_TOOL);
|
||||
});
|
||||
|
||||
expect(outcome).toMatchObject({ ok: true });
|
||||
expect(result.current.tools).toHaveLength(1);
|
||||
expect(result.current.tools[0].function.name).toBe("get_weather");
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("adds multiple valid tools", () => {
|
||||
const { hookRef: 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 { hookRef: 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 { hookRef: 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 { hookRef: 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 { hookRef: 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 { hookRef: result, unmount } = mountHook(() => useToolsBuilder());
|
||||
|
||||
act(() => {
|
||||
result.current.add(VALID_TOOL);
|
||||
});
|
||||
|
||||
const updated: ToolDefinition = {
|
||||
type: "function",
|
||||
function: { name: "updated_fn", parameters: {} },
|
||||
};
|
||||
|
||||
let outcome: ReturnType<typeof result.current.update> | undefined;
|
||||
act(() => {
|
||||
outcome = result.current.update(0, updated);
|
||||
});
|
||||
|
||||
expect(outcome).toMatchObject({ ok: true });
|
||||
expect(result.current.tools[0].function.name).toBe("updated_fn");
|
||||
expect(result.current.errors.has(0)).toBe(false);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("returns { ok: false, error } and stores error when validation fails", () => {
|
||||
const { hookRef: result, unmount } = mountHook(() => useToolsBuilder());
|
||||
|
||||
act(() => {
|
||||
result.current.add(VALID_TOOL);
|
||||
});
|
||||
|
||||
let outcome: ReturnType<typeof result.current.update> | undefined;
|
||||
act(() => {
|
||||
outcome = result.current.update(0, {
|
||||
type: "function",
|
||||
function: { name: "", parameters: {} },
|
||||
} as ToolDefinition);
|
||||
});
|
||||
|
||||
expect(outcome).toMatchObject({ ok: false });
|
||||
expect(result.current.errors.has(0)).toBe(true);
|
||||
// Tool should not be changed
|
||||
expect(result.current.tools[0].function.name).toBe("get_weather");
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("clears error for that index on successful update", () => {
|
||||
const { hookRef: 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 { hookRef: 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user