From 9c3f0f4d83e1365b0125127e9dd6d10f19ceca8e Mon Sep 17 00:00:00 2001 From: AndyTechnologies Date: Sun, 6 Sep 2026 12:11:22 -0400 Subject: [PATCH 1/9] feat(ui-svelte): injectable store factories + consolidated throttled refresh Task 3.1 (MINOR-B): editor + dashboard stores created via injectable factories (fresh instances per test/view, no module singletons); every mutation delegates to the pure graph-model port. Dashboard store owns the five REST refresh domains (pipelines/models/executions/agents/config) with a throttled, deduped scheduleRefresh so bursts of SSE events collapse into single fetches. rest-service wraps all /api/ui/* endpoints with a typed ApiError envelope. --- src/ui-svelte/services/rest-service.test.ts | 147 ++++++++++++++ src/ui-svelte/services/rest-service.ts | 91 +++++++++ src/ui-svelte/stores/dashboard-store.test.ts | 193 ++++++++++++++++++ src/ui-svelte/stores/dashboard-store.ts | 178 ++++++++++++++++ src/ui-svelte/stores/editor-store.test.ts | 201 +++++++++++++++++++ src/ui-svelte/stores/editor-store.ts | 182 +++++++++++++++++ src/ui-svelte/stores/types.ts | 119 +++++++++++ 7 files changed, 1111 insertions(+) create mode 100644 src/ui-svelte/services/rest-service.test.ts create mode 100644 src/ui-svelte/services/rest-service.ts create mode 100644 src/ui-svelte/stores/dashboard-store.test.ts create mode 100644 src/ui-svelte/stores/dashboard-store.ts create mode 100644 src/ui-svelte/stores/editor-store.test.ts create mode 100644 src/ui-svelte/stores/editor-store.ts create mode 100644 src/ui-svelte/stores/types.ts diff --git a/src/ui-svelte/services/rest-service.test.ts b/src/ui-svelte/services/rest-service.test.ts new file mode 100644 index 0000000..046e6ec --- /dev/null +++ b/src/ui-svelte/services/rest-service.test.ts @@ -0,0 +1,147 @@ +/** + * RED→GREEN tests for the REST service (svelte-ui task 3.1). + * + * `rest-service` wraps every `/api/ui/*` endpoint the dashboard views need. + * The global fetch is stubbed per test; URLs, methods, JSON bodies and the + * error envelope are all asserted — the browser never sees a raw URL. + */ +import { describe, it, expect, mock } from "bun:test"; +import { createRestService, ApiError } from "./rest-service.js"; +import type { RestService } from "./rest-service.js"; + +/** Stub global fetch and capture (url, init). */ +function stubFetch(status = 200, body: unknown = {}): () => Array<{ url: string; init: RequestInit }> { + const calls: Array<{ url: string; init: RequestInit }> = []; + const fn = mock(async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init: init ?? {} }); + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); + }); + (globalThis as { fetch: unknown }).fetch = fn; + return () => calls; +} + +function service(): RestService { + return createRestService("http://test.local"); +} + +describe("rest service (api/ui contract)", () => { + it("listPipelines GETs /api/ui/pipelines and parses rows", async () => { + const calls = stubFetch(200, [{ id: "p1" }]); + const rows = await service().listPipelines(); + expect(rows).toEqual([{ id: "p1" }]); + const [{ url, init }] = calls(); + expect(url).toBe("http://test.local/api/ui/pipelines"); + expect(init.method ?? "GET").toBe("GET"); + }); + + it("getPipeline encodes the id segment", async () => { + const calls = stubFetch(200, { id: "a/b c", nodes: [], edges: [] }); + await service().getPipeline("a/b c"); + expect(calls()[0]!.url).toBe("http://test.local/api/ui/pipelines/a%2Fb%20c"); + }); + + it("listModels GETs /api/ui/models", async () => { + const calls = stubFetch(200, { models: [], modelsDir: "/m" }); + const res = await service().listModels(); + expect(res.modelsDir).toBe("/m"); + expect(calls()[0]!.url).toBe("http://test.local/api/ui/models"); + }); + + it("getConfig GETs /api/ui/config", async () => { + const calls = stubFetch(200, { llama: {} }); + await service().getConfig(); + expect(calls()[0]!.url).toBe("http://test.local/api/ui/config"); + }); + + it("applyConfig POSTs { config } with JSON content-type", async () => { + const calls = stubFetch(200, { ok: true }); + const config = { llama: { lifecycle: { ttl: 300 } } }; + await service().applyConfig(config); + const [{ url, init }] = calls(); + expect(url).toBe("http://test.local/api/ui/apply"); + expect(init.method).toBe("POST"); + expect((init.headers as Record)["content-type"]).toMatch(/application\/json/); + expect(JSON.parse(init.body as string)).toEqual({ config }); + }); + + it("unloadModel POSTs the model-specific endpoint", async () => { + const calls = stubFetch(200, { unloaded: true, modelId: "qwen:7b" }); + await service().unloadModel("qwen:7b"); + const [{ url, init }] = calls(); + expect(url).toBe("http://test.local/api/ui/models/qwen%3A7b/unload"); + expect(init.method).toBe("POST"); + }); + + it("unloadAllModels POSTs /api/ui/models/unload-all", async () => { + const calls = stubFetch(200, { unloaded: 2 }); + await service().unloadAllModels(); + expect(calls()[0]!.url).toBe("http://test.local/api/ui/models/unload-all"); + expect(calls()[0]!.init.method).toBe("POST"); + }); + + it("listExecutions forwards the limit query param", async () => { + const calls = stubFetch(200, []); + await service().listExecutions(25); + expect(calls()[0]!.url).toBe("http://test.local/api/ui/executions?limit=25"); + }); + + it("validatePipeline POSTs the payload to /validate", async () => { + const calls = stubFetch(200, { valid: true }); + const payload = { nodes: [], edges: [] }; + await service().validatePipeline("demo", payload); + const [{ url, init }] = calls(); + expect(url).toBe("http://test.local/api/ui/pipelines/demo/validate"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual(payload); + }); + + it("retryStep POSTs the step-retry endpoint", async () => { + const calls = stubFetch(200, { success: true, retryExecutionId: "e2" }); + const res = await service().retryStep("e1", "n7"); + expect(res.retryExecutionId).toBe("e2"); + const [{ url, init }] = calls(); + expect(url).toBe("http://test.local/api/ui/executions/e1/steps/n7/retry"); + expect(init.method).toBe("POST"); + }); + + it("agentsStatus GETs /api/ui/agents/status", async () => { + const calls = stubFetch(200, []); + await service().agentsStatus(); + expect(calls()[0]!.url).toBe("http://test.local/api/ui/agents/status"); + }); + + it("configureAgent POSTs /api/ui/agents/configure", async () => { + const calls = stubFetch(200, { configured: true }); + const cfg = { id: "a1" }; + await service().configureAgent(cfg); + const [{ url, init }] = calls(); + expect(url).toBe("http://test.local/api/ui/agents/configure"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual(cfg); + }); + + it("throws a typed ApiError with status and code on non-2xx", async () => { + stubFetch(404, { error: { message: "Pipeline not found", type: "not_found_error", code: null } }); + try { + await service().getPipeline("nope"); + expect.unreachable("expected ApiError"); + } catch (err) { + expect(err).toBeInstanceOf(ApiError); + const apiErr = err as ApiError; + expect(apiErr.status).toBe(404); + expect(apiErr.message).toMatch(/Pipeline not found/); + expect(apiErr.type).toBe("not_found_error"); + } + }); + + it("throws a generic ApiError when the body is not an error envelope", async () => { + stubFetch(500, { oops: true }); + try { + await service().listPipelines(); + expect.unreachable("expected ApiError"); + } catch (err) { + expect(err).toBeInstanceOf(ApiError); + expect((err as ApiError).status).toBe(500); + } + }); +}); \ No newline at end of file diff --git a/src/ui-svelte/services/rest-service.ts b/src/ui-svelte/services/rest-service.ts new file mode 100644 index 0000000..81ed7cc --- /dev/null +++ b/src/ui-svelte/services/rest-service.ts @@ -0,0 +1,91 @@ +/** + * REST service for the dashboard views (svelte-ui task 3.1). + * + * Thin typed wrappers around every `/api/ui/*` endpoint the views consume. + * Non-2xx responses surface as a typed `ApiError` carrying the backend's + * error envelope (`{ error: { message, type, code } }`) when present. + */ +import type { PipelineSummary, PipelineDetail, ModelsResponse, LifecycleConfig, ExecutionEntry, AgentEntry } from "../stores/types.js"; +import type { GraphNode, GraphEdge } from "../lib/graph-model.js"; + +/** Typed error from a non-2xx /api/ui/* response. */ +export class ApiError extends Error { + status: number; + type: string | null; + + constructor(status: number, message: string, type: string | null = null) { + super(message); + this.name = "ApiError"; + this.status = status; + this.type = type; + } +} + +export interface RetryStepResult { + success: boolean; + retryExecutionId?: string; +} + +export interface RestService { + listPipelines(): Promise; + getPipeline(id: string): Promise; + listModels(): Promise; + getConfig(): Promise; + applyConfig(config: unknown): Promise; + unloadModel(id: string): Promise<{ unloaded: boolean; modelId: string }>; + unloadAllModels(): Promise<{ unloaded: number }>; + listExecutions(limit?: number): Promise; + validatePipeline(id: string, payload: { nodes: GraphNode[]; edges: GraphEdge[] }): Promise<{ valid: boolean; errors?: string[] }>; + retryStep(executionId: string, nodeId: string): Promise; + agentsStatus(): Promise; + configureAgent(config: unknown): Promise; +} + +export function createRestService(base: string): RestService { + const api = async (path: string, init?: RequestInit): Promise => { + const res = await fetch(`${base}${path}`, { + ...init, + headers: { + ...(init?.body ? { "content-type": "application/json" } : {}), + ...(init?.headers ?? {}), + }, + }); + if (!res.ok) { + let message = `Request failed (${res.status})`; + let type: string | null = null; + try { + const body = (await res.json()) as { error?: { message?: string; type?: string } }; + if (body.error?.message) message = body.error.message; + if (body.error?.type) type = body.error.type; + } catch { + // Body is not a JSON envelope — keep the generic message. + } + throw new ApiError(res.status, message, type); + } + return (await res.json()) as T; + }; + + const post = (path: string, body?: unknown): Promise => + api(path, { method: "POST", body: body !== undefined ? JSON.stringify(body) : undefined }); + + const encode = (segment: string): string => encodeURIComponent(segment); + + return { + listPipelines: () => api("/api/ui/pipelines"), + getPipeline: (id) => api(`/api/ui/pipelines/${encode(id)}`), + listModels: () => api("/api/ui/models"), + getConfig: () => api("/api/ui/config"), + applyConfig: (config) => post("/api/ui/apply", { config }), + unloadModel: (id) => post(`/api/ui/models/${encode(id)}/unload`), + unloadAllModels: () => post("/api/ui/models/unload-all"), + listExecutions: (limit) => { + const qs = limit !== undefined ? `?limit=${limit}` : ""; + return api(`/api/ui/executions${qs}`); + }, + validatePipeline: (id, payload) => post(`/api/ui/pipelines/${encode(id)}/validate`, payload), + retryStep: (executionId, nodeId) => + post(`/api/ui/executions/${encode(executionId)}/steps/${encode(nodeId)}/retry`), + agentsStatus: () => api("/api/ui/agents/status"), + configureAgent: (config) => post("/api/ui/agents/configure", config), + }; +} \ No newline at end of file diff --git a/src/ui-svelte/stores/dashboard-store.test.ts b/src/ui-svelte/stores/dashboard-store.test.ts new file mode 100644 index 0000000..1ff6949 --- /dev/null +++ b/src/ui-svelte/stores/dashboard-store.test.ts @@ -0,0 +1,193 @@ +/** + * RED→GREEN tests for the consolidated dashboard store (svelte-ui task 3.1, + * MINOR-B). + * + * One store layer owns the five REST refresh domains (pipelines, models, + * executions, agents, config) with a **consolidated throttled refresh**: N + * triggers inside the refresh window collapse into a single fetch. Fetchers + * and the scheduler are injected, so tests stay deterministic (no real + * timers, no network). + */ +import { describe, it, expect } from "bun:test"; +import { createDashboardStore } from "./dashboard-store.js"; +import type { DashboardDeps } from "./dashboard-store.js"; + +const rows = { + pipelines: [{ id: "p1" }, { id: "p2" }], + models: { + models: [{ id: "qwen:7b", loaded: true }], + modelsDir: "/models", + lifecycle: { vramPolicyActive: true }, + }, + executions: [{ id: "e1", pipelineId: "p1", status: "ok" }], + agents: [{ id: "a1", label: "A", configPath: "/cfg" }], + config: { llama: { lifecycle: { ttl: 300 } } }, +}; + +function fakeApi() { + const calls: Record = { + pipelines: 0, + models: 0, + executions: 0, + agents: 0, + config: 0, + }; + return { + calls, + api: { + pipelines: async () => ((calls.pipelines += 1), rows.pipelines), + models: async () => ((calls.models += 1), rows.models), + executions: async () => ((calls.executions += 1), rows.executions), + agents: async () => ((calls.agents += 1), rows.agents), + config: async () => ((calls.config += 1), rows.config), + }, + }; +} + +/** Manual scheduler: records callbacks, the test flushes them. */ +function manualScheduler() { + const queue: Array<() => void> = []; + return { + schedule: ((fn: () => void, _ms: number) => { + queue.push(fn); + return () => { + const i = queue.indexOf(fn); + if (i >= 0) queue.splice(i, 1); + }; + }) as DashboardDeps["schedule"], + flush: () => { + while (queue.length > 0) queue.shift()!(); + }, + drain: () => { + queue.length = 0; + }, + }; +} + +function deps(overrides: Partial = {}): DashboardDeps { + const { api } = fakeApi(); + return { + api, + schedule: manualScheduler().schedule, + onError: () => {}, + ...overrides, + }; +} + +describe("dashboard store (consolidated domains)", () => { + it("factory returns fresh, isolated stores", async () => { + const a = createDashboardStore(deps()); + const b = createDashboardStore(deps()); + expect(a).not.toBe(b); + await a.actions.loadPipelines(); + expect(a.getSnapshot().pipelines).toHaveLength(2); + expect(b.getSnapshot().pipelines).toHaveLength(0); + }); + + it("starts empty with no errors", () => { + const s = createDashboardStore(deps()).getSnapshot(); + expect(s.pipelines).toEqual([]); + expect(s.models).toEqual([]); + expect(s.executions).toEqual([]); + expect(s.agents).toEqual([]); + expect(s.config).toBeNull(); + expect(s.errors).toEqual({}); + }); + + it("loadPipelines stores the summary rows", async () => { + const store = createDashboardStore(deps()); + await store.actions.loadPipelines(); + expect(store.getSnapshot().pipelines).toEqual(rows.pipelines); + }); + + it("loadModels stores models, modelsDir and lifecycle", async () => { + const store = createDashboardStore(deps()); + await store.actions.loadModels(); + const s = store.getSnapshot(); + expect(s.models).toEqual(rows.models.models); + expect(s.modelsDir).toBe("/models"); + expect(s.lifecycle).toEqual({ vramPolicyActive: true }); + }); + + it("loadExecutions stores the rows", async () => { + const store = createDashboardStore(deps()); + await store.actions.loadExecutions(); + expect(store.getSnapshot().executions).toEqual(rows.executions); + }); + + it("loadAgents stores the rows", async () => { + const store = createDashboardStore(deps()); + await store.actions.loadAgents(); + expect(store.getSnapshot().agents).toEqual(rows.agents); + }); + + it("loadConfig stores the live config", async () => { + const store = createDashboardStore(deps()); + await store.actions.loadConfig(); + expect(store.getSnapshot().config).toEqual(rows.config); + }); + + it("a failed refresh records a per-domain error and stays usable", async () => { + const store = createDashboardStore( + deps({ + api: { + ...deps().api, + agents: async () => { + throw new Error("boom"); + }, + }, + }), + ); + await store.actions.loadAgents(); + const s = store.getSnapshot(); + expect(s.errors.agents).toMatch(/boom/); + expect(s.agents).toEqual([]); + // other domains still work + await store.actions.loadPipelines(); + expect(store.getSnapshot().pipelines).toHaveLength(2); + }); + + it("scheduleRefresh batches N triggers into a single fetch per domain", async () => { + const { api, calls } = fakeApi(); + const sched = manualScheduler(); + const store = createDashboardStore({ api, schedule: sched.schedule, onError: () => {} }); + + store.actions.scheduleRefresh("pipelines"); + store.actions.scheduleRefresh("pipelines"); + store.actions.scheduleRefresh("models"); + store.actions.scheduleRefresh("pipelines"); + expect(calls.pipelines).toBe(0); + expect(calls.models).toBe(0); + + sched.flush(); // the throttled window elapses once + // The triggered fetches resolve on microtasks after the flush. + await new Promise((r) => setTimeout(r, 0)); + expect(calls.pipelines).toBe(1); + expect(calls.models).toBe(1); + expect(store.getSnapshot().pipelines).toHaveLength(2); + }); + + it("refreshAll reloads the five interval domains", async () => { + const { api, calls } = fakeApi(); + const store = createDashboardStore({ api, schedule: manualScheduler().schedule, onError: () => {} }); + await store.actions.refreshAll(); + expect(calls.pipelines).toBe(1); + expect(calls.models).toBe(1); + expect(calls.executions).toBe(1); + expect(calls.agents).toBe(1); + expect(calls.config).toBe(1); + }); + + it("reset clears every domain and error", async () => { + const store = createDashboardStore(deps()); + await store.actions.refreshAll(); + store.actions.reset(); + const s = store.getSnapshot(); + expect(s.pipelines).toEqual([]); + expect(s.models).toEqual([]); + expect(s.executions).toEqual([]); + expect(s.agents).toEqual([]); + expect(s.config).toBeNull(); + expect(s.errors).toEqual({}); + }); +}); \ No newline at end of file diff --git a/src/ui-svelte/stores/dashboard-store.ts b/src/ui-svelte/stores/dashboard-store.ts new file mode 100644 index 0000000..7022873 --- /dev/null +++ b/src/ui-svelte/stores/dashboard-store.ts @@ -0,0 +1,178 @@ +/** + * Consolidated dashboard store factory (svelte-ui task 3.1, MINOR-B). + * + * One store layer owns the five REST refresh domains (pipelines, models, + * executions, agents, config). SSE events and intervals all funnel through + * `scheduleRefresh`, which **throttles/dedupes triggers inside a window**: + * N events arriving in 500 ms produce ONE fetch per domain (the spec's + * "batched rather than per-event refetch" — no jank under SSE load). + * + * Everything is injected (fetchers + scheduler), so tests run deterministic + * and offline; the browser wiring passes real rest-service fetchers and + * setTimeout/clearTimeout-backed scheduling. + */ +import { writable, get } from "svelte/store"; +import type { PipelineSummary, ModelEntry, ExecutionEntry, AgentEntry, LifecycleConfig, LifecycleData } from "./types.js"; + +export interface DashboardApi { + pipelines(): Promise; + models(): Promise<{ models: ModelEntry[]; modelsDir: string; lifecycle: LifecycleData | null }>; + executions(limit?: number): Promise; + agents(): Promise; + config(): Promise; +} + +export type RefreshDomain = "pipelines" | "models" | "executions" | "agents" | "config"; +export type DomainErrors = Partial>; + +/** Scheduler contract: run `fn` after `ms`, return a cancel function. */ +export type Scheduler = (fn: () => void, ms: number) => () => void; + +export interface DashboardState { + pipelines: PipelineSummary[]; + models: ModelEntry[]; + modelsDir: string | null; + lifecycle: LifecycleData | null; + executions: ExecutionEntry[]; + agents: AgentEntry[]; + config: LifecycleConfig | null; + errors: DomainErrors; +} + +export interface DashboardActions { + loadPipelines(): Promise; + loadModels(): Promise; + loadExecutions(limit?: number): Promise; + loadAgents(): Promise; + loadConfig(): Promise; + /** Throttled/deduped single-domain refresh (SSE + interval path). */ + scheduleRefresh(domain: RefreshDomain): void; + /** Immediate reload of every interval domain (boot path). */ + refreshAll(): Promise; + reset(): void; +} + +export interface DashboardDeps { + api: DashboardApi; + /** Scheduling primitive; default set in the browser wiring. In tests a + * manual scheduler keeps the throttle deterministic. */ + schedule?: Scheduler; + /** Throttle window in ms; triggers inside it collapse into one fetch. */ + refreshWindowMs?: number; + /** Called with (domain, error) instead of throwing into the UI. */ + onError?: (domain: RefreshDomain, error: unknown) => void; +} + +export interface DashboardStore { + subscribe: (run: (value: DashboardState) => void) => () => void; + getSnapshot(): DashboardState; + actions: DashboardActions; +} + +const DEFAULT_WINDOW_MS = 500; + +const INITIAL: DashboardState = { + pipelines: [], + models: [], + modelsDir: null, + lifecycle: null, + executions: [], + agents: [], + config: null, + errors: {}, +}; + +export function createDashboardStore(deps: DashboardDeps): DashboardStore { + const windowMs = deps.refreshWindowMs ?? DEFAULT_WINDOW_MS; + const schedule: Scheduler = + deps.schedule ?? + ((fn, ms) => { + const handle = setTimeout(fn, ms); + return () => clearTimeout(handle); + }); + const onError = deps.onError ?? (() => {}); + + const store = writable({ ...INITIAL }); + + /** One pending throttled trigger per domain (dedupe set). */ + const pending = new Set(); + + function run(domain: RefreshDomain, loader: () => Promise): Promise { + return loader().catch((err: unknown) => { + store.update((s) => ({ + ...s, + errors: { ...s.errors, [domain]: err instanceof Error ? err.message : String(err) }, + })); + onError(domain, err); + }); + } + + const loaders: Record Promise> = { + pipelines: () => + run("pipelines", async () => { + const rows = await deps.api.pipelines(); + store.update((s) => ({ ...s, pipelines: rows })); + }), + models: () => + run("models", async () => { + const res = await deps.api.models(); + store.update((s) => ({ ...s, models: res.models, modelsDir: res.modelsDir, lifecycle: res.lifecycle })); + }), + executions: () => + run("executions", async () => { + const rows = await deps.api.executions(); + store.update((s) => ({ ...s, executions: rows })); + }), + agents: () => + run("agents", async () => { + const rows = await deps.api.agents(); + store.update((s) => ({ ...s, agents: rows })); + }), + config: () => + run("config", async () => { + const cfg = await deps.api.config(); + store.update((s) => ({ ...s, config: cfg })); + }), + }; + + const actions: DashboardActions = { + loadPipelines: loaders.pipelines, + loadModels: loaders.models, + loadExecutions: (limit) => run("executions", async () => { + const rows = await deps.api.executions(limit); + store.update((s) => ({ ...s, executions: rows })); + }), + loadAgents: loaders.agents, + loadConfig: loaders.config, + + scheduleRefresh(domain) { + if (pending.has(domain)) return; + pending.add(domain); + schedule(() => { + pending.delete(domain); + void loaders[domain](); + }, windowMs); + }, + + async refreshAll() { + await Promise.all([ + actions.loadPipelines(), + actions.loadModels(), + actions.loadExecutions(), + actions.loadAgents(), + actions.loadConfig(), + ]); + }, + + reset() { + pending.clear(); + store.update(() => ({ ...INITIAL })); + }, + }; + + return { + subscribe: store.subscribe, + getSnapshot: () => get(store), + actions, + }; +} \ No newline at end of file diff --git a/src/ui-svelte/stores/editor-store.test.ts b/src/ui-svelte/stores/editor-store.test.ts new file mode 100644 index 0000000..beb044d --- /dev/null +++ b/src/ui-svelte/stores/editor-store.test.ts @@ -0,0 +1,201 @@ +/** + * RED→GREEN tests for the editor store factory (svelte-ui task 3.1, MINOR-B). + * + * The editor store is created through an **injectable factory**: two calls + * yield two independent stores, so tests always start from a clean state and + * no module-level singleton leaks between cases. All mutations delegate to + * the pure `graph-model.ts` port (geometry/guards never live here). + */ +import { describe, it, expect } from "bun:test"; +import { createEditorStore } from "./editor-store.js"; +import type { EditorDeps } from "./editor-store.js"; +import type { GraphState } from "../lib/graph-model.js"; + +const pipeline = { + id: "demo", + name: "Demo", + // Positions are client-side only (the backend never sends them): layout + // fills `pos` at render, the store leaves it undefined until a move. + nodes: [ + { id: "n1", type: "start" as const }, + { id: "n2", type: "llm_call" as const, model: "qwen:7b", prompt: "" }, + { id: "n3", type: "end" as const }, + ], + edges: [{ from: "n1", to: "n2" }, { from: "n2", to: "n3" }], +}; + +function deps(overrides: Partial = {}): EditorDeps { + return { + api: { + getPipeline: async () => pipeline, + validate: async () => ({ valid: true }), + apply: async () => ({}), + }, + ...overrides, + }; +} + +function snapshot(s: GraphState): void { + void s; +} + +describe("editor store (injectable factory)", () => { + it("returns fresh, isolated stores per factory call", () => { + const a = createEditorStore(deps()); + const b = createEditorStore(deps()); + expect(a).not.toBe(b); + + a.subscribe((s) => expect(s.nodes).toHaveLength(0))(); + b.subscribe((s) => expect(s.nodes).toHaveLength(0))(); + + a.actions.addNode("llm_call"); + const aNodes = a.getSnapshot().nodes; + expect(aNodes).toHaveLength(1); + expect(b.getSnapshot().nodes).toHaveLength(0); + snapshot(aNodes[0] as never); + }); + + it("starts empty with no selection, not dirty, unvalidated", () => { + const s = createEditorStore(deps()).getSnapshot(); + expect(s.nodes).toEqual([]); + expect(s.edges).toEqual([]); + expect(s.selection).toEqual([]); + expect(s.dirty).toBe(false); + expect(s.validation).toBeNull(); + expect(s.pipelineId).toBeNull(); + }); + + it("loadPipeline populates the graph and clears selection", async () => { + const store = createEditorStore(deps()); + await store.actions.loadPipeline("demo"); + const s = store.getSnapshot(); + expect(s.pipelineId).toBe("demo"); + expect(s.name).toBe("Demo"); + expect(s.nodes.map((n) => n.id)).toEqual(["n1", "n2", "n3"]); + expect(s.edges).toHaveLength(2); + expect(s.selection).toEqual([]); + expect(s.dirty).toBe(false); + }); + + it("addNode appends a fresh node of the given type through the model", () => { + const store = createEditorStore(deps()); + store.actions.addNode("condition"); + const s = store.getSnapshot(); + expect(s.nodes).toHaveLength(1); + expect(s.nodes[0]!.type).toBe("condition"); + expect(s.nodes[0]!.id).toMatch(/^node-/); + expect(s.dirty).toBe(true); + }); + + it("moveNode updates only the target node's position", async () => { + const store = createEditorStore(deps()); + await store.actions.loadPipeline("demo"); + store.actions.moveNode("n1", 120, 200); + const s = store.getSnapshot(); + const n1 = s.nodes.find((n) => n.id === "n1")!; + expect(n1.pos).toEqual({ x: 120, y: 200 }); + // untouched node keeps no position (load never assigns one) + const n2 = s.nodes.find((n) => n.id === "n2")!; + expect(n2.pos).toBeUndefined(); + }); + + it("deleteNode removes the node and its incident edges", async () => { + const store = createEditorStore(deps()); + await store.actions.loadPipeline("demo"); + store.actions.deleteNode("n2"); + const s = store.getSnapshot(); + expect(s.nodes.map((n) => n.id)).toEqual(["n1", "n3"]); + expect(s.edges).toEqual([]); + expect(s.selection).toEqual([]); + expect(s.dirty).toBe(true); + }); + + it("connect adds an edge between two nodes", async () => { + const store = createEditorStore(deps()); + await store.actions.loadPipeline("demo"); + store.actions.deleteNode("n3"); + store.actions.connect("n2", "n1"); + const s = store.getSnapshot(); + expect(s.edges).toContainEqual({ from: "n2", to: "n1" }); + }); + + it("rejects a self-edge (graph-model guard preserved)", async () => { + const store = createEditorStore(deps()); + await store.actions.loadPipeline("demo"); + const before = store.getSnapshot().edges.length; + store.actions.connect("n1", "n1"); + expect(store.getSnapshot().edges).toHaveLength(before); + }); + + it("select replaces the selection set", async () => { + const store = createEditorStore(deps()); + await store.actions.loadPipeline("demo"); + store.actions.select(["n1", "n2"]); + expect(store.getSnapshot().selection).toEqual(["n1", "n2"]); + store.actions.select(["n9"]); + expect(store.getSnapshot().selection).toEqual(["n9"]); + store.actions.select([]); + expect(store.getSnapshot().selection).toEqual([]); + }); + + it("validate posts the built payload and records the verdict", async () => { + let posted: unknown = null; + const store = createEditorStore( + deps({ + api: { + ...deps().api, + validate: async (payload: unknown) => { + posted = payload; + return { valid: false, errors: ["Node n2 is incomplete"] }; + }, + }, + }), + ); + await store.actions.loadPipeline("demo"); + await store.actions.validate(); + expect(posted).not.toBeNull(); + expect(store.getSnapshot().validation).toEqual({ + valid: false, + errors: ["Node n2 is incomplete"], + }); + }); + + it("apply calls the apply endpoint and surfaces failures", async () => { + let applied = false; + const ok = createEditorStore( + deps({ + api: { ...deps().api, apply: async () => ((applied = true), {}) }, + }), + ); + await ok.actions.apply(); + expect(applied).toBe(true); + expect(ok.getSnapshot().applyError).toBeNull(); + + const failing = createEditorStore( + deps({ + api: { + ...deps().api, + apply: async () => { + throw new Error("apply exploded"); + }, + }, + }), + ); + await failing.actions.apply(); + expect(failing.getSnapshot().applyError).toMatch(/apply exploded/); + }); + + it("reset returns to the pristine initial state", async () => { + const store = createEditorStore(deps()); + await store.actions.loadPipeline("demo"); + store.actions.addNode("loop"); + store.actions.connect("n1", "n3"); + store.actions.reset(); + const s = store.getSnapshot(); + expect(s.nodes).toEqual([]); + expect(s.edges).toEqual([]); + expect(s.selection).toEqual([]); + expect(s.pipelineId).toBeNull(); + expect(s.dirty).toBe(false); + }); +}); \ No newline at end of file diff --git a/src/ui-svelte/stores/editor-store.ts b/src/ui-svelte/stores/editor-store.ts new file mode 100644 index 0000000..b72da22 --- /dev/null +++ b/src/ui-svelte/stores/editor-store.ts @@ -0,0 +1,182 @@ +/** + * Editor store factory (svelte-ui task 3.1, MINOR-B). + * + * Created via an **injectable factory** — never a module singleton — so every + * test and every view instance gets a fresh store with no hidden cross-state. + * All mutations delegate to the pure `graph-model.ts` port; geometry and + * connection guards never live in this module. The history layer (task 3.5) + * plugs in on top of these same actions. + */ +import { writable, get } from "svelte/store"; +import { + createNode, + moveNode as modelMoveNode, + connectNodes, + deleteNode as modelDeleteNode, + buildPayload, + type GraphNode, + type GraphEdge, + type GraphState, + type NodeType, +} from "../lib/graph-model.js"; + +/** Backend access the editor store needs (injected, faked in tests). */ +export interface EditorApi { + getPipeline(id: string): Promise<{ id: string; name: string | null; nodes: GraphNode[]; edges: GraphEdge[] }>; + validate(payload: { nodes: GraphNode[]; edges: GraphEdge[] }): Promise<{ + valid: boolean; + errors?: string[]; + }>; + apply(config?: unknown): Promise; +} + +export interface EditorValidation { + valid: boolean; + errors?: string[]; +} + +export interface EditorState extends GraphState { + pipelineId: string | null; + name: string | null; + selection: string[]; + dirty: boolean; + loading: boolean; + validation: EditorValidation | null; + applyError: string | null; +} + +export interface EditorActions { + loadPipeline(id: string): Promise; + addNode(type: NodeType): void; + moveNode(id: string, x: number, y: number): void; + deleteNode(id: string): void; + connect(from: string, to: string, guard?: string): void; + select(ids: string[]): void; + validate(): Promise; + apply(): Promise; + reset(): void; +} + +export interface EditorDeps { + api: EditorApi; +} + +export interface EditorStore { + subscribe: (run: (value: EditorState) => void) => () => void; + getSnapshot(): EditorState; + actions: EditorActions; +} + +const INITIAL: EditorState = { + pipelineId: null, + name: null, + nodes: [], + edges: [], + selection: [], + dirty: false, + loading: false, + validation: null, + applyError: null, +}; + +/** Monotonic id source for freshly added nodes (per-store counter not needed: + * ids only need to be unique within one graph session). */ +let nodeCounter = 0; + +export function createEditorStore(deps: EditorDeps): EditorStore { + const store = writable({ ...INITIAL }); + + const actions: EditorActions = { + async loadPipeline(id) { + store.update((s) => ({ ...s, loading: true })); + try { + const detail = await deps.api.getPipeline(id); + store.update((s) => ({ + ...s, + pipelineId: detail.id, + name: detail.name ?? null, + nodes: detail.nodes, + edges: detail.edges, + selection: [], + dirty: false, + validation: null, + applyError: null, + })); + } finally { + store.update((s) => ({ ...s, loading: false })); + } + }, + + addNode(type) { + const id = `node-${++nodeCounter}`; + store.update((s) => ({ + ...s, + nodes: [...s.nodes, createNode(type, id)], + selection: [id], + dirty: true, + })); + }, + + moveNode(id, x, y) { + store.update((s) => ({ + ...s, + nodes: modelMoveNode(s.nodes, id, x, y), + dirty: true, + })); + }, + + deleteNode(id) { + store.update((s) => { + const { nodes, edges } = modelDeleteNode(s.nodes, s.edges, id); + return { + ...s, + nodes, + edges, + selection: s.selection.filter((sel) => sel !== id), + dirty: true, + }; + }); + }, + + connect(from, to, guard) { + store.update((s) => ({ + ...s, + edges: connectNodes(s.edges, from, to, guard), + dirty: true, + })); + }, + + select(ids) { + store.update((s) => ({ ...s, selection: [...ids] })); + }, + + async validate() { + const s = get(store); + const payload = buildPayload({ nodes: s.nodes, edges: s.edges }); + const result = await deps.api.validate(payload); + store.update((prev) => ({ ...prev, validation: result })); + }, + + async apply() { + try { + const result = await deps.api.apply(); + store.update((s) => ({ ...s, applyError: null })); + return result; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + store.update((s) => ({ ...s, applyError: message })); + return null; + } + }, + + reset() { + store.update(() => ({ ...INITIAL })); + }, + }; + + return { + subscribe: store.subscribe, + getSnapshot: () => get(store), + actions, + }; +} \ No newline at end of file diff --git a/src/ui-svelte/stores/types.ts b/src/ui-svelte/stores/types.ts new file mode 100644 index 0000000..dd34cb0 --- /dev/null +++ b/src/ui-svelte/stores/types.ts @@ -0,0 +1,119 @@ +/** + * Shared types for the Svelte 5 UI stores and services. + * Framework-free: no svelte imports. + */ + +/** Pipeline summary from GET /api/ui/pipelines. */ +export interface PipelineSummary { + id: string; + displayName?: string; + nodeCount?: number; +} + +/** Pipeline detail including nodes and edges. */ +export interface PipelineDetail { + id: string; + nodes: import("../lib/graph-model.js").GraphNode[]; + edges: import("../lib/graph-model.js").GraphEdge[]; +} + +/** Model entry from GET /api/ui/models. */ +export interface ModelEntry { + id: string; + loaded?: boolean; + processLoaded?: boolean; + ctx?: number; + effectiveCtx?: number; + ggufContextLength?: number; + hardwareMaxCtx?: number; + lastUsed?: string; +} + +/** Lifecycle data from GET /api/ui/models (lifecycle field). */ +export interface LifecycleData { + vramPolicyActive?: boolean; + lastVramSample?: { + usedMiB: number; + totalMiB: number; + at: string; + }; + recentUnloads?: Array<{ + modelId: string; + ok: boolean; + reason: string; + pid: number; + at: string; + }>; +} + +/** Models response from GET /api/ui/models. */ +export interface ModelsResponse { + models: ModelEntry[]; + modelsDir: string; + lifecycle?: LifecycleData; +} + +/** Execution entry from GET /api/ui/executions. */ +export interface ExecutionEntry { + id: string; + pipelineId: string; + status: string; + totalLatencyMs?: number; +} + +/** Agent entry from GET /api/ui/agents/status. */ +export interface AgentEntry { + id: string; + label: string; + configPath: string; + exists?: boolean; + providerPresent?: boolean; + modelCount?: number; +} + +/** Config for the lifecycle backend panel from GET /api/ui/config. */ +export interface LifecycleConfig { + llama?: { + lifecycle?: { + ttl?: number; + vram?: { + mode?: string; + freeGb?: number; + capGb?: number; + }; + }; + }; + [key: string]: unknown; +} + +/** SSE connection state. */ +export const SSE_STATE = { + CONNECTING: "connecting", + CONNECTED: "connected", + DISCONNECTED: "disconnected", + ERROR: "error", +} as const; + +export type SSEState = (typeof SSE_STATE)[keyof typeof SSE_STATE]; + +/** All eight SSE event names. */ +export const SSE_EVENTS = [ + "execution:started", + "step:started", + "step:completed", + "step:failed", + "execution:completed", + "execution:failed", + "pipeline:reloaded", + "models:changed", +] as const; + +export type SSEEventName = (typeof SSE_EVENTS)[number]; + +/** Trace entry for the debug/verbose panel. */ +export interface TraceEntry { + ts: number; + kind: "sse" | "store" | "fetch" | "editor" | "error"; + message: string; + detail?: unknown; +} From 28e765a3c63537ff9e267096193345ebcf424d11 Mon Sep 17 00:00:00 2001 From: AndyTechnologies Date: Sun, 6 Sep 2026 12:11:48 -0400 Subject: [PATCH 2/9] feat(ui-svelte): SSE service (8 events, reconnect, teardown) + trace service Task 3.2: sse-service subscribes all eight dashboard events incl. execution:failed, reconnects after drops, and stop() closes the source so no reconnect is scheduled post-teardown. EventSource injected for deterministic tests. trace-service keeps a bounded, presentation-only ring buffer for the debug/verbose panel. --- src/ui-svelte/services/sse-service.test.ts | 203 +++++++++++++++++++ src/ui-svelte/services/sse-service.ts | 140 +++++++++++++ src/ui-svelte/services/trace-service.test.ts | 82 ++++++++ src/ui-svelte/services/trace-service.ts | 44 ++++ 4 files changed, 469 insertions(+) create mode 100644 src/ui-svelte/services/sse-service.test.ts create mode 100644 src/ui-svelte/services/sse-service.ts create mode 100644 src/ui-svelte/services/trace-service.test.ts create mode 100644 src/ui-svelte/services/trace-service.ts diff --git a/src/ui-svelte/services/sse-service.test.ts b/src/ui-svelte/services/sse-service.test.ts new file mode 100644 index 0000000..48cc64c --- /dev/null +++ b/src/ui-svelte/services/sse-service.test.ts @@ -0,0 +1,203 @@ +/** + * RED→GREEN tests for the SSE service (svelte-ui task 3.2). + * + * Covers the full eight-event contract (incl. `execution:failed`), automatic + * reconnect after a drop, and teardown unsubscription. EventSource is + * injected as a factory so tests run with a deterministic fake — no network, + * no real EventSource (jsdom does not implement it). + */ +import { describe, it, expect } from "bun:test"; +import { createSseService } from "./sse-service.js"; +import type { SSEClientLike, SSEServiceDeps } from "./sse-service.js"; +import { SSE_STATE, type SSEEventName, type SSEState } from "../stores/types.js"; + +/** Minimal controllable EventSource double. */ +class FakeSource implements SSEClientLike { + url: string; + listeners = new Map void>>(); + closed = false; + onopen: ((ev: unknown) => void) | null = null; + onerror: ((ev: unknown) => void) | null = null; + + constructor(url: string) { + this.url = url; + } + + addEventListener(type: string, listener: (ev: { data?: string }) => void): void { + const list = this.listeners.get(type) ?? []; + list.push(listener); + this.listeners.set(type, list); + } + + removeEventListener(type: string, listener: (ev: { data?: string }) => void): void { + const list = this.listeners.get(type) ?? []; + this.listeners.set(type, list.filter((l) => l !== listener)); + } + + close(): void { + this.closed = true; + this.listeners.clear(); + } + + /** Test helper: fire a named event with JSON data. */ + emit(type: string, data: unknown): void { + for (const listener of this.listeners.get(type) ?? []) { + listener({ data: JSON.stringify(data) }); + } + } + + /** Test helper: simulate an error (drop). */ + fail(): void { + if (this.onerror) this.onerror({ type: "error" }); + } +} + +const EIGHT_EVENTS: SSEEventName[] = [ + "execution:started", + "step:started", + "step:completed", + "step:failed", + "execution:completed", + "execution:failed", + "pipeline:reloaded", + "models:changed", +]; + +function harness() { + const sources: FakeSource[] = []; + const events: Array<{ type: string; data: unknown }> = []; + const states: SSEState[] = []; + let sourceIndex = 0; + + const deps: SSEServiceDeps = { + url: "/api/ui/events", + sourceFactory: ((url: string) => { + const src = new FakeSource(url); + sources.push(src); + return src; + }) as SSEServiceDeps["sourceFactory"], + reconnectDelayMs: 5, + onEvent: (type: SSEEventName, data: unknown) => { + events.push({ type, data }); + }, + onStateChange: (state: SSEState) => { + states.push(state); + }, + }; + void sourceIndex; + + const service = createSseService(deps); + return { sources, events, states, service }; +} + +describe("sse service (eight-event contract)", () => { + it("connects to the events URL on start", () => { + const { sources, service } = harness(); + service.start(); + expect(sources).toHaveLength(1); + expect(sources[0]!.url).toBe("/api/ui/events"); + service.stop(); + }); + + it("registers a listener for every one of the eight event types", () => { + const { sources, service } = harness(); + service.start(); + const src = sources[0]!; + for (const type of EIGHT_EVENTS) { + expect(src.listeners.has(type)).toBe(true); + } + service.stop(); + }); + + it("parses payloads and forwards each event type to onEvent", () => { + const { sources, events, service } = harness(); + service.start(); + const src = sources[0]!; + src.emit("execution:started", { type: "execution:started", executionId: "e1", pipelineId: "p1" }); + src.emit("step:started", { type: "step:started", executionId: "e1", nodeId: "n2" }); + src.emit("step:completed", { type: "step:completed", executionId: "e1", nodeId: "n2" }); + src.emit("step:failed", { type: "step:failed", executionId: "e1", nodeId: "n3" }); + src.emit("execution:completed", { type: "execution:completed", executionId: "e1" }); + src.emit("execution:failed", { type: "execution:failed", executionId: "e1" }); + src.emit("pipeline:reloaded", { type: "pipeline:reloaded", chains: ["p1"] }); + src.emit("models:changed", { type: "models:changed", candidates: ["m1"] }); + + expect(events.map((e) => e.type)).toEqual(EIGHT_EVENTS); + expect(events[0]!.data).toEqual({ type: "execution:started", executionId: "e1", pipelineId: "p1" }); + expect(events[7]!.data).toEqual({ type: "models:changed", candidates: ["m1"] }); + service.stop(); + }); + + it("drops a malformed frame without crashing", () => { + const { sources, events, service } = harness(); + service.start(); + const src = sources[0]!; + // A raw non-JSON frame on a named channel must be ignored safely. + for (const listener of src.listeners.get("execution:failed") ?? []) { + listener({ data: "{not json" }); + } + expect(events).toHaveLength(0); + service.stop(); + }); + + it("reconnects automatically after an error (drop)", async () => { + const { sources, service } = harness(); + service.start(); + expect(sources).toHaveLength(1); + sources[0]!.fail(); + await new Promise((r) => setTimeout(r, 20)); + expect(sources.length).toBeGreaterThanOrEqual(2); + // The second source keeps handling events. + sources[1]!.emit("execution:failed", { type: "execution:failed", executionId: "e9" }); + const state = service.getState(); + const accepted: SSEState[] = [SSE_STATE.CONNECTING, SSE_STATE.CONNECTED]; + expect(accepted.includes(state)).toBe(true); + service.stop(); + }); + + it("stop() closes the source and halts reconnection", async () => { + const { sources, service } = harness(); + service.start(); + expect(sources[0]!.closed).toBe(false); + service.stop(); + expect(sources[0]!.closed).toBe(true); + + // An error after stop must NOT schedule another source. + const sourcesAtStop = sources.length; + sources[0]!.fail(); + await new Promise((r) => setTimeout(r, 20)); + expect(sources.length).toBe(sourcesAtStop); + }); + + it("reports state transitions (connecting → connected → stopped)", () => { + const { sources, states, service } = harness(); + service.start(); + // Fake does not fire onopen automatically; simulate it. + sources[0]!.onopen?.({}); + expect(states.includes(SSE_STATE.CONNECTING)).toBe(true); + expect(states.includes(SSE_STATE.CONNECTED)).toBe(true); + service.stop(); + expect(service.getState()).toBe(SSE_STATE.DISCONNECTED); + }); + + it("resubscribes to every event after a reconnect", async () => { + const { sources, service } = harness(); + service.start(); + sources[0]!.fail(); + await new Promise((r) => setTimeout(r, 20)); + const second = sources[1]!; + for (const type of EIGHT_EVENTS) { + expect(second.listeners.has(type)).toBe(true); + } + second.emit("models:changed", { type: "models:changed", candidates: ["m2"] }); + service.stop(); + }); + + it("start() is idempotent while a source is open", () => { + const { sources, service } = harness(); + service.start(); + service.start(); + expect(sources).toHaveLength(1); + service.stop(); + }); +}); \ No newline at end of file diff --git a/src/ui-svelte/services/sse-service.ts b/src/ui-svelte/services/sse-service.ts new file mode 100644 index 0000000..f15e666 --- /dev/null +++ b/src/ui-svelte/services/sse-service.ts @@ -0,0 +1,140 @@ +/** + * SSE service for live dashboard updates (svelte-ui task 3.2). + * + * Subscribes to all eight dashboard events (`execution:started`, + * `step:started`, `step:completed`, `step:failed`, `execution:completed`, + * `execution:failed`, `pipeline:reloaded`, `models:changed`), reconnects + * automatically after a drop, and tears down cleanly (`stop()` closes the + * source so no reconnect is ever scheduled afterwards — no leak across view + * unmounts/tests). + * + * EventSource is injected as a factory so tests run on a deterministic fake. + */ +import { writable, get } from "svelte/store"; +import { SSE_STATE, type SSEEventName, type SSEState } from "../stores/types.js"; + +/** The EventSource surface the service needs (a structural supertype — the + * real one satisfies it, so fakes stay tiny). */ +export interface SSEClientLike { + addEventListener(type: string, listener: (ev: { data?: string }) => void): void; + removeEventListener?(type: string, listener: (ev: { data?: string }) => void): void; + close(): void; + onopen?: ((ev: unknown) => void) | null; + onerror?: ((ev: unknown) => void) | null; +} + +export interface SSEServiceDeps { + /** Events endpoint; defaults to `/api/ui/events`. */ + url?: string; + /** Inject an EventSource constructor/factory (tests use a fake). */ + sourceFactory?: (url: string) => SSEClientLike; + /** Delay before reconnecting after a drop. */ + reconnectDelayMs?: number; + /** Called for every parsed dashboard event. */ + onEvent(type: SSEEventName, data: unknown): void; + /** Optional state observer (e.g. trace/status UI). */ + onStateChange?(state: SSEState): void; +} + +export interface SseService { + start(): void; + stop(): void; + getState(): SSEState; + subscribe(run: (state: SSEState) => void): () => void; +} + +const EVENT_TYPES: SSEEventName[] = [ + "execution:started", + "step:started", + "step:completed", + "step:failed", + "execution:completed", + "execution:failed", + "pipeline:reloaded", + "models:changed", +]; + +/** Constructor shape of the platform EventSource (client runtime). Typed + * locally because the ambient global is only `new () => EventSource` when the + * root tsconfig (lib ES2022, no DOM) is used for typecheck. */ +type EventSourceCtor = new (url: string) => SSEClientLike; +const DEFAULT_SOURCE_FACTORY = (u: string): SSEClientLike => + new (EventSource as unknown as EventSourceCtor)(u); + +export function createSseService(deps: SSEServiceDeps): SseService { + const url = deps.url ?? "/api/ui/events"; + const sourceFactory = deps.sourceFactory ?? DEFAULT_SOURCE_FACTORY; + const reconnectDelayMs = deps.reconnectDelayMs ?? 1500; + + const state = writable(SSE_STATE.DISCONNECTED); + let source: SSEClientLike | null = null; + let reconnectTimer: ReturnType | null = null; + let stopping = false; + + function setState(next: SSEState): void { + state.set(next); + deps.onStateChange?.(next); + } + + function scheduleReconnect(): void { + if (stopping || reconnectTimer !== null) return; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + open(); + }, reconnectDelayMs); + } + + function onEventFrame(type: SSEEventName): (ev: { data?: string }) => void { + return (ev) => { + if (stopping) return; + try { + const data = JSON.parse(ev.data ?? "null") as unknown; + deps.onEvent(type, data); + } catch { + // Malformed frame — ignore rather than crash the dashboard. + } + }; + } + + function attach(sourceToAttach: SSEClientLike): void { + for (const type of EVENT_TYPES) { + sourceToAttach.addEventListener(type, onEventFrame(type)); + } + sourceToAttach.onopen = () => { + if (!stopping) setState(SSE_STATE.CONNECTED); + }; + sourceToAttach.onerror = () => { + setState(SSE_STATE.ERROR); + sourceToAttach.close(); + source = null; + scheduleReconnect(); + }; + } + + function open(): void { + if (stopping || source !== null) return; + setState(SSE_STATE.CONNECTING); + const next = sourceFactory(url); + source = next; + attach(next); + } + + return { + start() { + stopping = false; + open(); + }, + stop() { + stopping = true; + if (reconnectTimer !== null) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + source?.close(); + source = null; + setState(SSE_STATE.DISCONNECTED); + }, + getState: () => get(state), + subscribe: state.subscribe, + }; +} \ No newline at end of file diff --git a/src/ui-svelte/services/trace-service.test.ts b/src/ui-svelte/services/trace-service.test.ts new file mode 100644 index 0000000..569d216 --- /dev/null +++ b/src/ui-svelte/services/trace-service.test.ts @@ -0,0 +1,82 @@ +/** + * RED→GREEN tests for the trace service (svelte-ui task 3.2). + * + * Trace is **presentation-only**: it records local events (SSE/store/fetch/ + * editor/error) for the debug/verbose panel. It never fabricates fields that + * are not already in REST/SSE payloads, keeps a bounded ring buffer, and its + * subscribers can unsubscribe cleanly (teardown-safe). + */ +import { describe, it, expect } from "bun:test"; +import { createTraceService } from "./trace-service.js"; +import type { TraceEntry } from "../stores/types.js"; + +describe("trace service (bounded debug log)", () => { + it("records a typed entry with a timestamp", () => { + const trace = createTraceService(); + trace.log("sse", 'received "execution:failed"'); + const entries = trace.getEntries(); + expect(entries).toHaveLength(1); + const [e] = entries as [TraceEntry]; + expect(e.kind).toBe("sse"); + expect(e.message).toBe('received "execution:failed"'); + expect(typeof e.ts).toBe("number"); + }); + + it("keeps only presentation-safe fields (no fabricated data)", () => { + const trace = createTraceService(); + trace.log("store", "refresh pipelines", { rows: 3 }); + const [e] = trace.getEntries() as [TraceEntry]; + // detail is the original payload, untouched by the service + expect(e.detail).toEqual({ rows: 3 }); + expect(Object.keys(e).sort()).toEqual(["detail", "kind", "message", "ts"]); + }); + + it("enforces a bounded ring buffer (oldest dropped)", () => { + const trace = createTraceService({ cap: 5 }); + for (let i = 0; i < 10; i += 1) trace.log("editor", `op ${i}`); + const entries = trace.getEntries(); + expect(entries).toHaveLength(5); + expect(entries[0]!.message).toBe("op 5"); + expect(entries[4]!.message).toBe("op 9"); + }); + + it("notifies subscribers of new entries", () => { + const trace = createTraceService(); + const seen: TraceEntry[] = []; + const unsub = trace.subscribe((entries) => { + if (entries.length > 0) seen.push(entries[entries.length - 1]!); + }); + trace.log("error", "apply exploded"); + expect(seen).toHaveLength(1); + expect(seen[0]!.kind).toBe("error"); + unsub(); + }); + + it("unsubscribe stops delivery (teardown-safe)", () => { + const trace = createTraceService(); + let count = 0; + const unsub = trace.subscribe(() => { + count += 1; + }); + // Subscribing delivers the current list once, synchronously. + expect(count).toBe(1); + unsub(); + trace.log("fetch", "GET /api/ui/models"); + expect(count).toBe(1); + }); + + it("clear() empties the log", () => { + const trace = createTraceService(); + trace.log("store", "boot"); + trace.clear(); + expect(trace.getEntries()).toEqual([]); + }); + + it("factory instances are isolated (no shared buffer)", () => { + const a = createTraceService(); + const b = createTraceService(); + a.log("sse", "one"); + expect(a.getEntries()).toHaveLength(1); + expect(b.getEntries()).toHaveLength(0); + }); +}); \ No newline at end of file diff --git a/src/ui-svelte/services/trace-service.ts b/src/ui-svelte/services/trace-service.ts new file mode 100644 index 0000000..90e26a4 --- /dev/null +++ b/src/ui-svelte/services/trace-service.ts @@ -0,0 +1,44 @@ +/** + * Trace service for the debug/verbose panel (svelte-ui task 3.2). + * + * **Presentation-only**: it records local events (SSE arrived, store refresh, + * fetch round-trips, editor ops, errors) into a bounded ring buffer for the + * verbose debug view. It never invents fields that are not already present in + * REST/SSE payloads, and keeps the last `cap` entries (oldest evicted). + */ +import { writable, get } from "svelte/store"; +import type { TraceEntry } from "../stores/types.js"; + +export interface TraceService { + log(kind: TraceEntry["kind"], message: string, detail?: unknown): void; + subscribe(run: (entries: TraceEntry[]) => void): () => void; + getEntries(): TraceEntry[]; + clear(): void; +} + +export interface TraceDeps { + /** Ring buffer size (default 500). */ + cap?: number; +} + +const DEFAULT_CAP = 500; + +export function createTraceService(deps: TraceDeps = {}): TraceService { + const cap = deps.cap ?? DEFAULT_CAP; + const entries: TraceEntry[] = []; + const store = writable(entries); + + return { + log(kind, message, detail) { + entries.push({ ts: Date.now(), kind, message, detail }); + if (entries.length > cap) entries.splice(0, entries.length - cap); + store.set([...entries]); + }, + subscribe: store.subscribe, + getEntries: () => get(store), + clear() { + entries.length = 0; + store.set([]); + }, + }; +} \ No newline at end of file From 3262703ff8adbc27433095b486216f066eaea98f Mon Sep 17 00:00:00 2001 From: AndyTechnologies Date: Sun, 6 Sep 2026 14:23:31 -0400 Subject: [PATCH 3/9] test(ui-svelte): jsdom + svelte-loader preloads and shared fakes (task 3.7) --- bun.lock | 17 +- bunfig.toml | 11 + package.json | 1 + src/ui-svelte/test-setup/dom.ts | 72 ++++++ src/ui-svelte/test-setup/fakes.ts | 256 ++++++++++++++++++++++ src/ui-svelte/test-setup/jest-dom.d.ts | 21 ++ src/ui-svelte/test-setup/svelte-loader.ts | 50 +++++ 7 files changed, 425 insertions(+), 3 deletions(-) create mode 100644 src/ui-svelte/test-setup/dom.ts create mode 100644 src/ui-svelte/test-setup/fakes.ts create mode 100644 src/ui-svelte/test-setup/jest-dom.d.ts create mode 100644 src/ui-svelte/test-setup/svelte-loader.ts diff --git a/bun.lock b/bun.lock index fd2672c..bc607f5 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "@testing-library/svelte": "^5.2.4", "@types/bun": "^1.4.0", "@types/express": "^5.0.0", + "@types/jsdom": "^30.0.0", "@typescript-eslint/eslint-plugin": "^8.69.0", "@typescript-eslint/parser": "^8.69.0", "eslint": "^10.9.1", @@ -233,6 +234,8 @@ "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="], + "@types/jsdom": ["@types/jsdom@30.0.0", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^8.0.0", "undici-types": "^8.9.0" } }, "sha512-uAHGxujGE0cDaKGdK28zgDotFtNA7MKq5DXl8LrfdxdCI8VHcg15oJz+amHTChPNI5JpgEPQWc2xFdrw3em/nQ=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="], @@ -245,6 +248,8 @@ "@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "2.0.5", "@types/node": "22.20.1" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="], + "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.69.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.69.0", "@typescript-eslint/type-utils": "8.69.0", "@typescript-eslint/utils": "8.69.0", "@typescript-eslint/visitor-keys": "8.69.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.69.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.69.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.69.0", "@typescript-eslint/types": "8.69.0", "@typescript-eslint/typescript-estree": "8.69.0", "@typescript-eslint/visitor-keys": "8.69.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw=="], @@ -349,7 +354,7 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], - "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], @@ -531,7 +536,7 @@ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "3.1.0" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], - "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], @@ -633,7 +638,7 @@ "undici": ["undici@7.29.1", "", {}, "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q=="], - "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "undici-types": ["undici-types@8.10.2", "", {}, "sha512-7/+aSjzkUoLc92hV22bTW4aGanXf800zbwguhcICs0OAoCF9wDOE4wkopQ+SqfhXZm8mCK8gHpdTs7pZUWzK3w=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], @@ -679,6 +684,8 @@ "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], "body-parser/iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": "2.1.2" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], @@ -689,6 +696,8 @@ "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "jsdom/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], @@ -700,5 +709,7 @@ "type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "jsdom/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], } } diff --git a/bunfig.toml b/bunfig.toml index a2c1f02..2d46372 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -5,3 +5,14 @@ # unit/integration runner. Unit tests live next to code under `src/`. [test] root = "./src" + +# Svelte component-test infrastructure (Phase 3, task 3.7): bun cannot parse +# `.svelte`/`.svelte.js` natively, so a Bun.plugin preload compiles them with +# `svelte/compiler`, and a jsdom preload provides browser globals. Both are +# no-ops for the backend suites (no matching imports; extra globals unused). +# The leading "./" is required — bun resolves these relative to the project +# root only in that form. +preload = [ + "./src/ui-svelte/test-setup/svelte-loader.ts", + "./src/ui-svelte/test-setup/dom.ts", +] \ No newline at end of file diff --git a/package.json b/package.json index 0ca3497..b11ea0c 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@testing-library/svelte": "^5.2.4", "@types/bun": "^1.4.0", "@types/express": "^5.0.0", + "@types/jsdom": "^30.0.0", "@typescript-eslint/eslint-plugin": "^8.69.0", "@typescript-eslint/parser": "^8.69.0", "eslint": "^10.9.1", diff --git a/src/ui-svelte/test-setup/dom.ts b/src/ui-svelte/test-setup/dom.ts new file mode 100644 index 0000000..10d472c --- /dev/null +++ b/src/ui-svelte/test-setup/dom.ts @@ -0,0 +1,72 @@ +/** + * Bun test preload: jsdom browser environment for component tests + * (svelte-ui task 3.7 infra). + * + * Creates one jsdom instance and mirrors its window keys onto globalThis so + * Svelte client components mount normally (document, Text, SVGElement, …). + * Existing Bun globals are never overwritten (the `key in globalThis` guard), + * so the backend suites keep bun's fetch/Response/navigator. Testing Library + * cleanup runs after every test so mounted components never leak DOM between + * cases. + * + * Loaded via `[test] preload` in bunfig.toml for every `bun test` run; a + * no-op for suites that never touch the DOM. + */ +import { JSDOM } from "jsdom"; +import { afterEach, expect } from "bun:test"; +import * as jestDom from "@testing-library/jest-dom/matchers"; + +// @testing-library/svelte registers its own auto beforeEach/afterEach at +// module-eval time (src/index.js). Eager suites (src/ui-svelte) evaluate it +// at file scope, which is legal — but backend suites only touch it through +// the lazy import in afterEach below, where module-eval would land INSIDE a +// test lifecycle and bun rejects "Cannot call beforeEach() inside a test". +// The library's documented opt-out (STL_SKIP_AUTO_CLEANUP) keeps our manual +// afterEach below as the single cleanup path for every suite. +process.env.STL_SKIP_AUTO_CLEANUP = "1"; + +// jest-dom matchers (toBeVisible, toHaveAttribute, …) on bun:test's expect. +// The jest-dom module shape (default + named) does not match bun's +// ExpectExtendMatchers contract directly, so adapt it through `unknown`. +expect.extend(jestDom as unknown as Parameters[0]); + +const dom = new JSDOM("", { + url: "http://localhost/", +}); + +const g = globalThis as Record; +g.window = dom.window; +g.document = dom.window.document; +g.navigator = dom.window.navigator; +for (const key of Object.getOwnPropertyNames(dom.window)) { + if (key === "window" || key === "document" || key === "navigator") continue; + if (!(key in g)) { + g[key] = dom.window[key as keyof typeof dom.window]; + } +} + +// jsdom does not implement HTMLDialogElement; the editor views call +// showModal()/close() for the validate/apply dialogs. Real browsers are +// unaffected (the polyfill is guarded), jsdom tests keep working. +if (typeof HTMLDialogElement !== "undefined") { + if (typeof HTMLDialogElement.prototype.showModal !== "function") { + HTMLDialogElement.prototype.showModal = function (this: HTMLDialogElement) { + this.setAttribute("open", ""); + }; + } + if (typeof HTMLDialogElement.prototype.close !== "function") { + HTMLDialogElement.prototype.close = function (this: HTMLDialogElement) { + this.removeAttribute("open"); + }; + } +} + +// cleanup is imported LAZILY (dynamic) — importing @testing-library/svelte +// here at top level would evaluate testing-library's raw `.svelte.js` runes +// modules BEFORE the svelte-loader preload registers its compiler plugin, +// and bun caches modules per process, so the raw versions would poison every +// later render. By the time afterEach runs, the loader is registered and the +// document global exists, so the import compiles cleanly. +afterEach(() => { + void import("@testing-library/svelte").then(({ cleanup }) => cleanup()); +}); \ No newline at end of file diff --git a/src/ui-svelte/test-setup/fakes.ts b/src/ui-svelte/test-setup/fakes.ts new file mode 100644 index 0000000..5b8cf5b --- /dev/null +++ b/src/ui-svelte/test-setup/fakes.ts @@ -0,0 +1,256 @@ +/** + * Shared fakes for svelte-ui component tests (tasks 3.3+). + * + * Pure TS, framework-free, no DOM access — only store/service fakes wired + * through the same injectable seams the browser uses (dashboard api, + * editor api, scheduler, EventSource factory). Component tests import these + * to render views/App deterministically and offline. + */ +import { createDashboardStore, type DashboardApi, type DashboardStore } from "../stores/dashboard-store.js"; +import { createEditorStore, type EditorApi, type EditorStore } from "../stores/editor-store.js"; +import { createSseService, type SseService } from "../services/sse-service.js"; +import { createTraceService, type TraceService } from "../services/trace-service.js"; +import { SSE_STATE, type SSEEventName, type SSEState } from "../stores/types.js"; +import type { AppDeps, AppStores } from "../app-types.js"; +import type { GraphNode, GraphEdge } from "../lib/graph-model.js"; + +/* ------------------------------------------------------------------ */ +/* Editor */ +/* ------------------------------------------------------------------ */ + +export interface FakeEditorApiOptions { + pipeline?: { id: string; name: string | null; nodes: GraphNode[]; edges: GraphEdge[] }; + validate?: { valid: boolean; errors?: string[] }; +} + +export function makeFakeEditorApi(options: FakeEditorApiOptions = {}): EditorApi & { + calls: { getPipeline: number; validate: number; apply: number }; +} { + const calls = { getPipeline: 0, validate: 0, apply: 0 }; + return { + calls, + async getPipeline(id) { + calls.getPipeline += 1; + return ( + options.pipeline ?? { + id, + name: null, + nodes: [ + { id: "n1", type: "start" as const, pos: { x: 40, y: 40 } }, + { id: "n2", type: "end" as const, pos: { x: 400, y: 40 } }, + ], + edges: [{ id: "e1", from: "n1", to: "n2" }], + } + ); + }, + async validate(_payload) { + calls.validate += 1; + return options.validate ?? { valid: true }; + }, + async apply() { + calls.apply += 1; + return { ok: true }; + }, + }; +} + +export function makeEditorStore(options: FakeEditorApiOptions = {}): EditorStore { + return createEditorStore({ api: makeFakeEditorApi(options) }); +} + +/* ------------------------------------------------------------------ */ +/* Dashboard */ +/* ------------------------------------------------------------------ */ + +export interface FakeDashboardData { + pipelines: import("../stores/types.js").PipelineSummary[]; + models: import("../stores/types.js").ModelEntry[]; + modelsDir: string; + lifecycle: import("../stores/types.js").LifecycleData | null; + executions: import("../stores/types.js").ExecutionEntry[]; + agents: import("../stores/types.js").AgentEntry[]; + config: import("../stores/types.js").LifecycleConfig; +} + +export function makeFakeDashboardApi( + data: Partial = {}, +): DashboardApi & { calls: { [K in keyof DashboardApi]: number } } { + const calls = { pipelines: 0, models: 0, executions: 0, agents: 0, config: 0 }; + return { + calls, + async pipelines() { + calls.pipelines += 1; + return data.pipelines ?? []; + }, + async models() { + calls.models += 1; + return { + models: data.models ?? [], + modelsDir: data.modelsDir ?? "", + lifecycle: data.lifecycle ?? null, + }; + }, + async executions() { + calls.executions += 1; + return data.executions ?? []; + }, + async agents() { + calls.agents += 1; + return data.agents ?? []; + }, + async config() { + calls.config += 1; + return data.config ?? {}; + }, + }; +} + +/** Component-test scheduler: real timer with a short window so the store's + * throttle/dedupe semantics stay observable (immediate scheduling would + * turn N events into N fetches). */ +export function makeDashboardStore(data: Partial = {}): DashboardStore { + return createDashboardStore({ + api: makeFakeDashboardApi(data), + refreshWindowMs: 50, + }); +} + +/* ------------------------------------------------------------------ */ +/* Trace + SSE */ +/* ------------------------------------------------------------------ */ + +export function makeTrace(): TraceService { + return createTraceService(); +} + +/** Deterministic EventSource fake for SSE tests. */ +export class FakeSource { + listeners = new Map void>>(); + private _onopen: ((ev: unknown) => void) | null = null; + onerror: ((ev: unknown) => void) | null = null; + closed = false; + + /** Fires synchronously on assignment — mimics a browser that is already + * connected when the service attaches its handler. */ + set onopen(handler: ((ev: unknown) => void) | null) { + this._onopen = handler; + handler?.({}); + } + + get onopen(): ((ev: unknown) => void) | null { + return this._onopen; + } + + addEventListener(type: string, listener: (ev: { data?: string }) => void): void { + if (!this.listeners.has(type)) this.listeners.set(type, new Set()); + this.listeners.get(type)!.add(listener); + } + + removeEventListener(type: string, listener: (ev: { data?: string }) => void): void { + this.listeners.get(type)?.delete(listener); + } + + close(): void { + this.closed = true; + } + + emit(type: string, data: unknown): void { + const ev = { data: JSON.stringify(data) }; + for (const listener of this.listeners.get(type) ?? []) listener(ev); + } +} + +export interface FakeSseOptions { + onEvent?: (type: SSEEventName, data: unknown) => void; + onStateChange?: (state: SSEState) => void; +} + +/** Injectable sse-service factory backed by FakeSource instances. */ +export function makeFakeSse(options: FakeSseOptions = {}): { + sse: SseService; + sources: FakeSource[]; +} { + const sources: FakeSource[] = []; + const sse = createSseService({ + sourceFactory: () => { + const source = new FakeSource(); + sources.push(source); + return source; + }, + reconnectDelayMs: 5, + onEvent: options.onEvent ?? (() => {}), + onStateChange: options.onStateChange, + }); + return { sse, sources }; +} + +/* ------------------------------------------------------------------ */ +/* App */ +/* ------------------------------------------------------------------ */ + +export interface MakeAppDepsResult { + deps: AppDeps; + /** Populated once App mounts (makeStores runs during instantiation). */ + stores: AppStores | null; + /** Populated once App mounts (makeSse runs during instantiation). */ + sse: SseService | null; + editorApi: ReturnType; + dashboardApi: ReturnType; + sources: FakeSource[]; +} + +/** + * AppDeps wired to fakes, plus handles so tests can assert call counts, + * emit SSE events, and observe store state. + */ +export function makeAppDeps(data: Partial = {}): MakeAppDepsResult { + const editorApi = makeFakeEditorApi(); + const dashboardApi = makeFakeDashboardApi(data); + const result: MakeAppDepsResult = { + deps: null as unknown as AppDeps, + stores: null, + sse: null, + editorApi, + dashboardApi, + sources: [], + }; + + result.deps = { + makeStores() { + const stores: AppStores = { + editor: createEditorStore({ api: editorApi }), + dashboard: createDashboardStore({ + api: dashboardApi, + refreshWindowMs: 50, + }), + trace: createTraceService(), + }; + result.stores = stores; + return stores; + }, + makeSse(created) { + const sse = createSseService({ + sourceFactory: () => { + const source = new FakeSource(); + result.sources.push(source); + return source; + }, + reconnectDelayMs: 5, + onEvent: (type) => { + created.trace.log("sse", type); + if (type === "pipeline:reloaded") created.dashboard.actions.scheduleRefresh("pipelines"); + else if (type === "models:changed") created.dashboard.actions.scheduleRefresh("models"); + else created.dashboard.actions.scheduleRefresh("executions"); + }, + }); + result.sse = sse; + return sse; + }, + }; + + return result; +} + +// Re-export for tests that only need the state constant. +export { SSE_STATE }; +export type { SSEState }; \ No newline at end of file diff --git a/src/ui-svelte/test-setup/jest-dom.d.ts b/src/ui-svelte/test-setup/jest-dom.d.ts new file mode 100644 index 0000000..b8e2308 --- /dev/null +++ b/src/ui-svelte/test-setup/jest-dom.d.ts @@ -0,0 +1,21 @@ +/** + * bun:test matcher typings for @testing-library/jest-dom (svelte-ui, task 3.7). + * + * dom.ts extends bun:test's `expect` with jest-dom's matchers at runtime; + * this declaration teaches tsc about the subset used across the component + * suites so `bun run typecheck` stays green without dragging in jest's + * global `expect` types. + */ +declare module "bun:test" { + interface Matchers { + toBeDisabled(): T; + toBeEnabled(): T; + toBeVisible(): T; + toHaveAttribute(attr: string, value?: string): T; + toHaveTextContent(text: string | RegExp): T; + toBeInTheDocument(): T; + toBeEmptyDOMElement(): T; + } +} + +export {}; \ No newline at end of file diff --git a/src/ui-svelte/test-setup/svelte-loader.ts b/src/ui-svelte/test-setup/svelte-loader.ts new file mode 100644 index 0000000..51fd6a3 --- /dev/null +++ b/src/ui-svelte/test-setup/svelte-loader.ts @@ -0,0 +1,50 @@ +/** + * Bun test preload: Svelte 5 compiler plugin (svelte-ui task 3.7 infra). + * + * `bun test` cannot parse `.svelte` files natively (it treats the import as + * plain text), so this Bun.plugin compiles them through `svelte/compiler`: + * - `.svelte` components → `compile(source, { generate: "client" })` + * - `.svelte.js` / `.svelte.ts` → `compileModule(...)` (runes modules) + * + * It also redirects the root `svelte` package to the CLIENT build + * (`index-client.js`): bun test is not a browser, so the default exports + * condition resolves to `index-server.js`, whose `mount()` throws "not + * available on the server". Only the one entry file is overridden; internal + * imports (`svelte/internal/client`, …) resolve unchanged. + * + * Loaded via `[test] preload` in bunfig.toml — applies to every `bun test` + * run, but the hooks only fire for imports that match (backend suites never + * import `.svelte`/`svelte`), so they are no-ops there. + */ +import { compile, compileModule } from "svelte/compiler"; +import { dirname, join } from "node:path"; + +Bun.plugin({ + name: "svelte-loader", + setup(builder) { + builder.onLoad( + { filter: /node_modules[\\/]svelte[\\/]src[\\/]index-server\.js$/ }, + async ({ path }) => { + // Root "svelte" import → client build (see header comment). + const clientEntry = join(dirname(path), "index-client.js"); + return { contents: `export * from ${JSON.stringify(clientEntry)};`, loader: "js" }; + }, + ); + + // One filter for every svelte-suffixed path. bun's onLoad filter engine + // silently drops anchored alternatives like `/\.svelte(\.(js|ts))?$/` + // and `/\.svelte\.js$/` (observed with .svelte.js files), so match the + // common `.svelte` prefix and branch in the handler. + const isRunesModule = (path: string): boolean => /\.svelte\.(js|ts)$/.test(path); + const compileSource = async ( + path: string, + ): Promise<{ contents: string; loader: "js" }> => { + const source = await Bun.file(path).text(); + const options = { generate: "client" as const, runes: true, filename: path }; + const js = isRunesModule(path) ? compileModule(source, options).js : compile(source, options).js; + return { contents: js.code, loader: "js" }; + }; + + builder.onLoad({ filter: /\.svelte/ }, ({ path }) => compileSource(path)); + }, +}); \ No newline at end of file From a485e3d53760ddb04dab689ccdda7156dc891200 Mon Sep 17 00:00:00 2001 From: AndyTechnologies Date: Sun, 6 Sep 2026 14:24:15 -0400 Subject: [PATCH 4/9] feat(ui-svelte): App shell with hash routing and five views (task 3.3) --- src/dashboard/e2e-smoke.test.ts | 17 +- src/ui-svelte/App.svelte | 126 ++ src/ui-svelte/App.test.ts | 123 ++ src/ui-svelte/app-types.ts | 28 + src/ui-svelte/index.html | 12 +- src/ui-svelte/main.ts | 19 +- src/ui-svelte/styles.css | 1625 ++++++++++++++++++++++- src/ui-svelte/svelte-shim.d.ts | 15 + src/ui-svelte/views/Agentes.svelte | 34 + src/ui-svelte/views/Agentes.test.ts | 63 + src/ui-svelte/views/Editor.svelte | 123 ++ src/ui-svelte/views/Editor.test.ts | 110 ++ src/ui-svelte/views/Ejecuciones.svelte | 40 + src/ui-svelte/views/Ejecuciones.test.ts | 51 + src/ui-svelte/views/Modelos.svelte | 105 ++ src/ui-svelte/views/Modelos.test.ts | 94 ++ src/ui-svelte/views/Pipelines.svelte | 48 + src/ui-svelte/views/Pipelines.test.ts | 67 + 18 files changed, 2672 insertions(+), 28 deletions(-) create mode 100644 src/ui-svelte/App.svelte create mode 100644 src/ui-svelte/App.test.ts create mode 100644 src/ui-svelte/app-types.ts create mode 100644 src/ui-svelte/svelte-shim.d.ts create mode 100644 src/ui-svelte/views/Agentes.svelte create mode 100644 src/ui-svelte/views/Agentes.test.ts create mode 100644 src/ui-svelte/views/Editor.svelte create mode 100644 src/ui-svelte/views/Editor.test.ts create mode 100644 src/ui-svelte/views/Ejecuciones.svelte create mode 100644 src/ui-svelte/views/Ejecuciones.test.ts create mode 100644 src/ui-svelte/views/Modelos.svelte create mode 100644 src/ui-svelte/views/Modelos.test.ts create mode 100644 src/ui-svelte/views/Pipelines.svelte create mode 100644 src/ui-svelte/views/Pipelines.test.ts diff --git a/src/dashboard/e2e-smoke.test.ts b/src/dashboard/e2e-smoke.test.ts index b46bf92..9c1ba51 100644 --- a/src/dashboard/e2e-smoke.test.ts +++ b/src/dashboard/e2e-smoke.test.ts @@ -75,15 +75,24 @@ async function get(path: string): Promise { } describe.skipIf(!BUILT)("compiled /ui E2E smoke (svelte-ui 2.2)", () => { - it("serves index.html with the SPA shell landmarks and Spanish label", async () => { + it("serves index.html with the Spanish document title", async () => { const res = await get("/ui"); const html = await res.text(); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toContain("text/html"); - // a11y shell the SPA must expose (carried by index.html). expect(html).toContain("llm-proxy Panel de control"); - expect(html).toContain("role=\"banner\""); - expect(html).toContain("aria-label=\"Principal\""); + }); + + it("compiles the SPA shell landmarks into the JS bundle (task 3.3)", async () => { + // Phase 3 moved the a11y shell out of index.html INTO App.svelte, so the + // banner landmark and Spanish nav label now live in the hashed JS chunk. + const index = await (await get("/ui")).text(); + const asset = index.match(/src="(\/?assets\/[^"]+\.js)"/); + expect(asset).not.toBeNull(); + const js = await (await get(`/ui/${asset![1].replace(/^\//, "")}`)).text(); + expect(js).toContain("role=\"banner\""); + expect(js).toContain("aria-label=\"Principal\""); + expect(js).toContain("Saltar al contenido principal"); }); it("serves a hashed JS asset referenced by index.html with application/javascript", async () => { diff --git a/src/ui-svelte/App.svelte b/src/ui-svelte/App.svelte new file mode 100644 index 0000000..6a9b927 --- /dev/null +++ b/src/ui-svelte/App.svelte @@ -0,0 +1,126 @@ + + + + + + +
+
\ No newline at end of file diff --git a/src/ui-svelte/App.test.ts b/src/ui-svelte/App.test.ts new file mode 100644 index 0000000..c772fc3 --- /dev/null +++ b/src/ui-svelte/App.test.ts @@ -0,0 +1,123 @@ +/** + * App shell + hash-routing component tests (svelte-ui task 3.3). + * + * The App is the composition root: it creates the store layer through + * injected factories (fakes here), starts/tears down the SSE service, and + * renders the a11y shell (skip link, banner, Spanish nav) around the five + * hash-routed views. The static header/nav of the placeholder build moved + * INTO this component — this test pins the a11y contract that supersedes it. + * + * Queries come from `render(...)` (container-bound) rather than the global + * `screen` — bun test evaluates helper modules in a shared cache, so + * screen's module-eval-time binding can race the jsdom preload. + */ +import { describe, it, expect, afterEach } from "bun:test"; +import { render, within, waitFor, fireEvent } from "@testing-library/svelte"; +import App from "./App.svelte"; +import { makeAppDeps } from "./test-setup/fakes.js"; +import type { PipelineSummary, ModelEntry, ExecutionEntry, AgentEntry } from "./stores/types.js"; + +const DATA = { + pipelines: [ + { id: "rag", displayName: "RAG", nodeCount: 4 }, + { id: "chat", displayName: "Chat", nodeCount: 3 }, + ] as PipelineSummary[], + models: [ + { id: "qwen2.5:7b", loaded: true, processLoaded: true, ctx: 8192 }, + { id: "llama3.2:3b", loaded: true, ctx: 4096 }, + { id: "mistral:7b" }, + ] as ModelEntry[], + executions: [{ id: "ex1", pipelineId: "rag", status: "completed", totalLatencyMs: 1234 }] as ExecutionEntry[], + agents: [ + { id: "opencode", label: "OpenCode", configPath: "~/.config/opencode/opencode.json", providerPresent: true, modelCount: 2 }, + ] as AgentEntry[], +}; + +afterEach(() => { + window.location.hash = ""; +}); + +function renderApp() { + const fakes = makeAppDeps(DATA); + const utils = render(App, { props: { deps: fakes.deps } }); + return { ...utils, ...fakes }; +} + +describe("App shell (task 3.3)", () => { + it("renders skip link, banner title and Spanish nav with the five views", () => { + const { getByText, getByRole } = renderApp(); + expect(getByText("Saltar al contenido principal")).toHaveAttribute("href", "#main"); + const banner = getByRole("banner"); + expect(within(banner).getByRole("heading", { level: 1, name: "llm-proxy" })).toBeTruthy(); + const nav = within(banner).getByRole("navigation", { name: "Principal" }); + const links = within(nav).getAllByRole("link"); + expect(links.map((l) => l.getAttribute("data-view"))).toEqual([ + "editor", + "pipelines", + "models", + "executions", + "agents", + ]); + expect(links.map((l) => l.textContent)).toEqual(["Editor", "Pipelines", "Modelos", "Ejecuciones", "Agentes"]); + }); + + it("marks the active view link with aria-current", () => { + const { getByRole } = renderApp(); + expect(getByRole("link", { name: "Editor" })).toHaveAttribute("aria-current", "true"); + expect(getByRole("link", { name: "Modelos" }).getAttribute("aria-current")).toBeNull(); + }); + + it("shows only the editor view initially", () => { + const { getByTestId } = renderApp(); + expect(getByTestId("view-editor")).toBeVisible(); + expect(getByTestId("view-pipelines")).not.toBeVisible(); + expect(getByTestId("view-models")).not.toBeVisible(); + }); + + it("switches views on hash navigation and moves aria-current", async () => { + const { getByRole, getByTestId } = renderApp(); + fireEvent.click(getByRole("link", { name: "Modelos" })); + window.location.hash = "#models"; + window.dispatchEvent(new HashChangeEvent("hashchange")); + // Svelte flushes the hidden-attr update in a microtask after dispatch. + await waitFor(() => expect(getByTestId("view-models")).toBeVisible()); + expect(getByTestId("view-editor")).not.toBeVisible(); + expect(getByRole("link", { name: "Modelos" })).toHaveAttribute("aria-current", "true"); + expect(getByRole("link", { name: "Editor" }).getAttribute("aria-current")).toBeNull(); + }); + + it("boots: refreshAll fired and trace records the boot", async () => { + const f = renderApp(); + await waitFor(() => expect(f.dashboardApi.calls.pipelines).toBe(1)); + expect(f.dashboardApi.calls.models).toBe(1); + expect(f.dashboardApi.calls.executions).toBe(1); + expect(f.dashboardApi.calls.agents).toBe(1); + expect(f.dashboardApi.calls.config).toBe(1); + expect(f.stores!.trace.getEntries().some((t) => t.message === "boot")).toBe(true); + }); + + it("starts SSE on mount and stops it on destroy", () => { + const f = renderApp(); + expect(f.sse!.getState()).toBe("connected"); + f.unmount(); + expect(f.sse!.getState()).toBe("disconnected"); + expect(f.sources.every((s) => s.closed === true)).toBe(true); + }); + + it("surfaces the SSE connection state in a polite status region", async () => { + const { getByRole } = renderApp(); + const status = getByRole("status"); + await waitFor(() => expect(status.textContent).toBe("En línea")); + }); + + it("funnels SSE events into a single throttled refresh per domain", async () => { + const f = renderApp(); + await waitFor(() => expect(f.dashboardApi.calls.executions).toBe(1)); + const before = f.dashboardApi.calls.executions; + f.sources[0]!.emit("execution:started", { executionId: "ex2", pipelineId: "rag" }); + f.sources[0]!.emit("step:completed", { executionId: "ex2", nodeId: "n1" }); + f.sources[0]!.emit("execution:completed", { executionId: "ex2" }); + await waitFor(() => expect(f.dashboardApi.calls.executions).toBe(before + 1)); + expect(f.stores!.trace.getEntries().some((t) => t.kind === "sse")).toBe(true); + }); +}); \ No newline at end of file diff --git a/src/ui-svelte/app-types.ts b/src/ui-svelte/app-types.ts new file mode 100644 index 0000000..552abec --- /dev/null +++ b/src/ui-svelte/app-types.ts @@ -0,0 +1,28 @@ +/** + * App composition-root contracts (svelte-ui task 3.3). + * + * Kept in a plain .ts module (no svelte imports) so tests and fakes can + * reference AppDeps/AppStores without importing a `.svelte` file through + * the root-tsc typecheck gate (see svelte-shim.d.ts). + */ +import type { DashboardStore } from "./stores/dashboard-store.js"; +import type { EditorStore } from "./stores/editor-store.js"; +import type { SseService } from "./services/sse-service.js"; +import type { TraceService } from "./services/trace-service.js"; + +/** The store layer App owns and shares with the five views. */ +export interface AppStores { + editor: EditorStore; + dashboard: DashboardStore; + trace: TraceService; +} + +/** Injectable App dependencies (tests wire fakes; browser uses rest/SSE). */ +export interface AppDeps { + makeStores(): AppStores; + makeSse(stores: AppStores): SseService; +} + +/** Hash-route identifiers used by the app nav. */ +export const NAV_VIEWS = ["editor", "pipelines", "models", "executions", "agents"] as const; +export type ViewId = (typeof NAV_VIEWS)[number]; \ No newline at end of file diff --git a/src/ui-svelte/index.html b/src/ui-svelte/index.html index 7914d6d..e7febb8 100644 --- a/src/ui-svelte/index.html +++ b/src/ui-svelte/index.html @@ -7,16 +7,6 @@ - -
+
\ No newline at end of file diff --git a/src/ui-svelte/main.ts b/src/ui-svelte/main.ts index 988ab46..b119dc1 100644 --- a/src/ui-svelte/main.ts +++ b/src/ui-svelte/main.ts @@ -1,12 +1,13 @@ /** - * Svelte 5 UI entry — build placeholder (Unit 1, svelte-ui). + * Svelte 5 UI entry (svelte-ui task 3.3). * - * Phase 2 (`svelte-ui`) wires the toolchain end-to-end: `bun run build:ui` - * must produce a real, servable bundle under `dist/ui` with hashed - * `assets/*` chunks so the static-serving work is verifiable. This module - * gives the build a minimal entry now; the full app shell (App.svelte and - * the five views) lands in Phase 3 and mounts here in place of the styles - * import. Nothing in this file is runtime behavior — it exists so the - * pipeline is real. + * Phase 2 left a styles-only placeholder so the build pipeline was real; + * Phase 3 replaces it: App.svelte owns the shell + five views and mounts + * here into `#app`. Everything else (stores, SSE, routing) is composed + * inside App with injectable factories. */ -import "./styles.css"; \ No newline at end of file +import { mount } from "svelte"; +import App from "./App.svelte"; +import "./styles.css"; + +mount(App, { target: document.getElementById("app")! }); \ No newline at end of file diff --git a/src/ui-svelte/styles.css b/src/ui-svelte/styles.css index 3930a91..61f092a 100644 --- a/src/ui-svelte/styles.css +++ b/src/ui-svelte/styles.css @@ -1,7 +1,1624 @@ -/* Build placeholder stylesheet (Unit 1, svelte-ui). - Phase 3 replaces this with the real design tokens. A single rule keeps the - hashed CSS asset non-empty through the Vite build. */ +/** + * Dashboard SPA styles — Dark Technical direction. + * + * GitHub-dark neutrals, `#58a6ff` accent, global mono typography, flat + * texture. All text pairs kept >= WCAG AA contrast (4.5:1, 3:1 for large/UI). + * Tokens on :root; the editor uses free-form bezier edges + socket ports. + * The loop accent and connection sockets resolve through the --status-* chain. + */ +:root { + --bg-base: #0d1117; + --bg-surface: #161b22; + --bg-card: #1c2128; + --bg-hover: #21262d; + --text: #c9d1d9; + --text-muted: #8b949e; + --text-faint: #7d8590; + --border: rgba(240, 246, 252, 0.1); + --border-strong: rgba(240, 246, 252, 0.18); + --accent: #58a6ff; + --accent-bright: #79c0ff; + --accent-soft: rgba(88, 166, 255, 0.12); + --accent-glow: rgba(88, 166, 255, 0.3); + --accent-on: #0d1117; + --status-success: #3fb950; + --status-danger: #f85149; + --success: var(--status-success); + --success-soft: rgba(63, 185, 80, 0.12); + --danger: var(--status-danger); + --danger-soft: rgba(248, 81, 73, 0.12); + --focus: #58a6ff; + + /* Connection sockets: input = success (receiving), output = danger (emitting). */ + --socket-in: var(--status-success); + --socket-in-hover: #56d364; + --socket-out: var(--status-danger); + --socket-out-hover: #ff7b72; + + --radius: 6px; + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 24px; + --font: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, monospace; + --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + /* Loop accent: resolves through the status-success chain (was phantom fallback-only). */ + --accent-2: var(--status-success); +} + +* { + box-sizing: border-box; +} + +html { + font-size: 14px; +} + +html, body { - font-family: system-ui, sans-serif; margin: 0; + padding: 0; +} + +body { + font-family: var(--font); + font-size: 14px; + line-height: 1.6; + color: var(--text); + background: var(--bg-base); + position: relative; +} + +/* ── Minimal global scrollbars ────────────────────────────────────── */ +/* Thin, unobtrusive scrollbars everywhere: a slim thumb over a + transparent track. The node-switcher carousel overrides these to + hide its bar entirely. */ +* { + scrollbar-width: thin; + scrollbar-color: var(--bg-hover) transparent; +} + +*::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +*::-webkit-scrollbar-thumb { + background: var(--bg-hover); + border: 2px solid transparent; + background-clip: padding-box; + border-radius: 999px; +} + +*::-webkit-scrollbar-thumb:hover { + background: var(--border-strong); + border: 2px solid transparent; + background-clip: padding-box; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +h1, +h2 { + line-height: 1.25; +} + +h1 { + font-size: 1.4rem; +} + +h2 { + font-size: 1.05rem; + margin: 0 0 var(--space-3); +} + +/* ── Visible focus states ─────────────────────────────────────────── */ +a:focus-visible, +button:focus-visible, +input:focus-visible, +select:focus-visible, +#graph-canvas:focus-visible, +.palette-item:focus-visible, +[tabindex]:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 1px; +} + +/* Skip link */ +.skip-link { + position: absolute; + left: -9999px; + top: 0; + background: var(--accent); + color: var(--accent-on); + padding: var(--space-2) var(--space-3); + z-index: 50; + font-weight: 600; + border-radius: 0 0 var(--radius) 0; +} + +.skip-link:focus { + left: 0; +} + +/* ── Header ───────────────────────────────────────────────────────── */ +.app-header { + display: flex; + align-items: center; + gap: var(--space-5); + padding: 2px 16px; + background: var(--bg-base); + border-bottom: 1px solid var(--border); + flex-wrap: wrap; + position: sticky; + top: 0; + z-index: 10; +} + +.app-title { + margin: 0; + font-weight: 800; + letter-spacing: -0.01em; +} + +.app-nav { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; +} + +.nav-link { + color: var(--accent-bright); + text-decoration: none; + padding: var(--space-1) 16px; + border-radius: var(--radius); + font-weight: 600; + border: 1px solid transparent; +} + +.nav-link:hover { + border-color: var(--border); + background: var(--bg-card); +} + +.nav-link[aria-current="true"] { + background: var(--accent); + color: var(--accent-on); + border-color: var(--accent); +} + +.conn-status { + margin-left: auto; + font-size: 0.9rem; + color: var(--text-muted); + font-family: var(--mono); +} + +.conn-status[data-state="connected"] { + color: var(--success); +} + +.conn-status[data-state="disconnected"] { + color: var(--danger); +} + +/* ── Main + views ─────────────────────────────────────────────────── */ +.app-main { + padding: var(--space-5); + max-width: 1500px; + margin: 0 auto; +} + +.view[hidden] { + display: none; +} + +/* ── Editor layout ────────────────────────────────────────────────── */ +/* The editor fills the viewport height: the palette and inspector scroll + internally while the canvas stays fixed and always visible. */ +.editor-layout { + display: grid; + grid-template-columns: 220px 1fr 280px; + gap: var(--space-4); + align-items: stretch; + /* Tracks the rendered header: 37.39px (.app-header incl. border) + 24px + .app-main padding — measured in headless Chromium. */ + height: calc(100vh - 61.39px); + min-height: 480px; +} + +@media (max-width: 900px) { + .editor-layout { + grid-template-columns: 1fr; + height: auto; + min-height: 0; + } +} + +.palette, +.inspector { + overflow-y: auto; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--space-4); + box-shadow: inset 0 1px 0 rgba(240, 246, 252, 0.03), 0 4px 24px rgba(0, 0, 0, 0.5); +} + +.canvas-wrap { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--space-4); + box-shadow: inset 0 1px 0 rgba(240, 246, 252, 0.03), 0 4px 24px rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; + gap: var(--space-3); + min-height: 0; +} + +.panel-title { + margin-top: 0; + font-weight: 700; +} + +.hint { + font-size: 0.85rem; + color: var(--text-muted); +} + +/* ── Palette sections ─────────────────────────────────────────────── */ +.palette-section { + margin-top: var(--space-3); +} + +.palette-section:first-of-type { + margin-top: 0; +} + +.palette-section-title { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-faint); + margin: 0 0 var(--space-2); +} + +.palette-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.palette-list--presets .palette-item { + font-size: 0.85rem; + padding: 5px var(--space-2); + display: flex; + align-items: center; + gap: var(--space-2); +} + +.palette-list--presets .palette-item::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.palette-list--presets .palette-item[data-preset="llm"]::before { + background: var(--success); +} + +.palette-list--presets .palette-item[data-preset="pipeline"]::before { + background: var(--accent); +} + +.palette-item, +.btn { + font-family: var(--font); + font-size: 0.95rem; + padding: var(--space-2) var(--space-3); + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg-card); + color: var(--text); + cursor: pointer; +} + +.palette-item:hover, +.btn:hover { + background: var(--bg-hover); +} + +.btn-small { + padding: var(--space-1) var(--space-2); + font-size: 0.85rem; +} + +.btn-primary { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-on); + font-weight: 700; +} + +.btn-primary:hover { + background: var(--accent-bright); + border-color: var(--accent-bright); +} + +/* Feedback de validacion: boton en verde cuando el grafo es valido. */ +.btn.is-valid { + background: var(--success); + border-color: var(--success); + color: var(--accent-on); + font-weight: 700; +} + +.btn.is-valid:hover { + background: var(--success); + border-color: var(--success); + filter: brightness(1.08); +} + +/* ── Canvas ───────────────────────────────────────────────────────── */ +.canvas-toolbar { + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; +} + +.field-label { + font-size: 0.85rem; + color: var(--text-muted); +} + +/* ── Switcher de bloques (encima del canvas) ──────────────────────────── */ +.node-switcher-wrap { + position: relative; +} + +/* Carrusel: una sola fila con scroll interno SIN barra visible; los fades + * (::before/::after) desvanecen los chips contra el fondo del card cuando + * hay mas contenido y las flechas aparecen solo cuando sobra hacia ese lado. */ +.node-switcher { + display: flex; + flex-wrap: nowrap; + gap: var(--space-1); + padding: var(--space-2) 30px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + min-height: 42px; + align-items: center; + overflow-x: auto; + scrollbar-width: none; + scrollbar-color: transparent transparent; + scroll-padding-inline: 30px; +} + +.node-switcher::-webkit-scrollbar { + display: none; +} + +.node-switcher-wrap::before, +.node-switcher-wrap::after { + content: ""; + position: absolute; + top: 7px; + bottom: 7px; + width: 28px; + z-index: 2; + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease; + border-radius: 0; +} + +.node-switcher-wrap::before { + left: 2px; + background: linear-gradient(to right, var(--bg-card), transparent); +} + +.node-switcher-wrap::after { + right: 2px; + background: linear-gradient(to left, var(--bg-card), transparent); +} + +.node-switcher-wrap.has-left::before { + opacity: 1; +} + +.node-switcher-wrap.has-right::after { + opacity: 1; +} + +.node-switcher:empty { + display: none; +} + +.switcher-arrow { + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: 3; + width: 22px; + height: 22px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + background: var(--bg-surface); + border: 1px solid var(--border); + color: var(--text-muted); + font-size: 1rem; + line-height: 1; + cursor: pointer; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease, color 0.15s ease, border-color 0.15s ease; +} + +.switcher-arrow--left { + left: 6px; +} + +.switcher-arrow--right { + right: 6px; +} + +.switcher-arrow:hover { + color: var(--accent-bright); + border-color: var(--accent); +} + +.switcher-arrow:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.node-switcher-wrap.has-left .switcher-arrow--left, +.node-switcher-wrap.has-right .switcher-arrow--right { + opacity: 1; + pointer-events: auto; +} + +.node-chip { + appearance: none; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg-base); + color: var(--text-muted); + font-size: 0.78rem; + padding: 4px 12px; + cursor: pointer; + transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease; + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.node-chip:hover { + color: var(--accent-bright); + border-color: var(--accent); +} + +.node-chip.active { + background: var(--accent); + border-color: var(--accent); + color: var(--bg-base); + font-weight: 600; +} + +.text-input, +select, +input[type="text"] { + font-family: var(--font); + font-size: 0.95rem; + padding: var(--space-1) var(--space-2); + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-card); + color: var(--text); +} + +.text-input:focus, +select:focus, +input[type="text"]:focus { + border-color: var(--accent); +} + +.graph-canvas { + position: relative; + flex: 1; + min-height: 360px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: + linear-gradient(var(--border-strong) 1px, transparent 1px) 0 0 / 24px 24px, + linear-gradient(90deg, var(--border-strong) 1px, transparent 1px) 0 0 / 24px 24px, + linear-gradient(var(--bg-surface), var(--bg-surface)); + overflow: hidden; +} + +#graph-svg { + width: 100%; + height: 100%; + display: block; + touch-action: none; + cursor: default; +} + +#graph-svg.dragging { + cursor: grabbing; +} + +#graph-svg.panning { + cursor: grabbing; +} + +/* Utility: hide an element (used by the custom context-window input). */ +.hidden { + display: none !important; +} + +.canvas-empty { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: var(--text-muted); + text-align: center; + pointer-events: none; + padding: var(--space-3); + font-family: var(--mono); + font-size: 0.9rem; +} + +/* SVG nodes + edges */ +.graph-node { + cursor: grab; +} + +.graph-node.dragging { + cursor: grabbing; +} + +.graph-node.selected rect.node-box { + stroke: var(--accent); + stroke-width: 2.5; + filter: drop-shadow(0 0 8px var(--accent-glow)); +} + +.node-box { + fill: var(--bg-card); + stroke: var(--border-strong); + stroke-width: 1.5; + rx: 4; +} + +.node-box[data-type="start"], +.node-box[data-type="end"] { + fill: var(--accent-soft); + stroke: rgba(88, 166, 255, 0.35); +} + +.node-box[data-type="llm_call"] { + fill: #152319; + stroke: rgba(76, 195, 138, 0.35); +} + +.node-box[data-type="condition"] { + fill: #251d12; + stroke: rgba(224, 143, 74, 0.35); +} + +.node-box[data-type="loop"] { + fill: #1a231d; + stroke: rgba(63, 185, 80, 0.35); +} + +.node-box[data-type="pipeline"] { + fill: #12202e; + stroke: rgba(88, 166, 255, 0.35); +} + +/* Contenedor del bucle: caja punteada que envuelve los bloques internos y + crece hacia abajo; el header dice cuantos bloques lleva y si tiene + condicion de salida. */ +.loop-container { + fill: rgba(63, 185, 80, 0.06); + stroke: rgba(63, 185, 80, 0.45); + stroke-width: 1.5; + stroke-dasharray: 7 5; + pointer-events: fill; +} + +.loop-container-group { + cursor: grab; +} + +.loop-container-group:hover .loop-container { + stroke: rgba(63, 185, 80, 0.8); + fill: rgba(63, 185, 80, 0.1); +} + +.loop-container-title { + fill: var(--accent-2); + font-size: 11px; + font-family: var(--mono); + font-weight: 600; + pointer-events: none; +} + +.loop-container-hint { + fill: var(--text-faint); + font-size: 10px; + font-family: var(--mono); + pointer-events: none; +} + +/* Lista de miembros en el inspector del bucle */ +.loop-members { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); + margin-top: var(--space-2); +} + +.loop-member { + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg-base); + color: var(--accent-2); + font-family: var(--mono); + font-size: 0.72rem; + padding: 2px 10px; +} + +.loop-members-empty { + color: var(--text-faint); + font-size: 0.8rem; + font-style: italic; +} + +/* Rows de miembros en el inspector: indice, nombre y flechas de orden */ +.loop-member-row { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 4px 6px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-base); + margin-top: 4px; +} + +.loop-member-index { + flex: 0 0 auto; + min-width: 20px; + text-align: center; + font-family: var(--mono); + font-size: 0.72rem; + color: var(--accent-2); + background: rgba(63, 185, 80, 0.12); + border-radius: 999px; + padding: 1px 0; +} + +.loop-member-name { + flex: 1 1 auto; + font-family: var(--mono); + font-size: 0.75rem; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.icon-btn { + flex: 0 0 auto; + width: 22px; + height: 22px; + line-height: 1; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-card); + color: var(--text-muted); + cursor: pointer; + font-size: 0.8rem; +} + +.icon-btn:hover { + color: var(--text); + border-color: var(--accent-2); +} + +/* Boton "+ Agregar bloque" dentro del contenedor del loop */ +.loop-add { + cursor: pointer; +} + +.loop-add rect { + fill: rgba(63, 185, 80, 0.1); + stroke: rgba(63, 185, 80, 0.4); + stroke-width: 1; + stroke-dasharray: 4 3; +} + +.loop-add text { + fill: var(--accent-2); + font-size: 11px; + font-family: var(--mono); + pointer-events: none; +} + +.loop-add:hover rect { + fill: rgba(63, 185, 80, 0.22); + stroke: rgba(63, 185, 80, 0.9); +} + +/* Destacar el contenedor cuando se arrastra un bloque de la paleta encima */ +.loop-container-group.loop-drop-target .loop-container { + stroke: var(--accent-2); + stroke-width: 2.5; + fill: rgba(63, 185, 80, 0.18); +} + +/* Badge de orden de los miembros del loop en el canvas */ +.node-order-badge { + fill: rgba(63, 185, 80, 0.22); + stroke: rgba(63, 185, 80, 0.8); + stroke-width: 1; +} + +.node-order-label { + fill: var(--accent-2); + font-size: 9px; + font-family: var(--mono); + font-weight: 700; +} + +/* Ver flujo: bloque actual pulsa, los ya visitados quedan atenuados */ +.graph-node.flow-active .node-box { + stroke: var(--accent-2); + stroke-width: 2.5; + filter: drop-shadow(0 0 6px rgba(63, 185, 80, 0.75)); +} + +.graph-node.flow-past .node-box { + stroke: rgba(63, 185, 80, 0.55); + stroke-width: 1.5; + opacity: 0.85; +} + +.edge-line.flow-past { + stroke: var(--accent-2); + stroke-width: 2.5; + opacity: 0.9; +} + +.loop-container-group.flow-past .loop-container { + stroke: rgba(63, 185, 80, 0.95); + fill: rgba(63, 185, 80, 0.14); +} + +.node-label { + fill: var(--text); + font-size: 12px; + font-family: var(--mono); + pointer-events: none; + font-weight: 600; +} + +.node-sub { + fill: var(--text-muted); + font-size: 10px; + font-family: var(--mono); + pointer-events: none; +} + +/* Connection sockets (Blender/Godot-style ports). Input = green (receiving), + output = red (emitting) per the requested UX. */ +.socket { + stroke: none; + fill: var(--socket-neutral, var(--accent)); + cursor: crosshair; + transition: fill 0.15s ease, stroke 0.15s ease; +} + +.socket.socket-in { + fill: var(--socket-in); +} + +.socket.socket-out { + fill: var(--socket-out); +} + +/* Rama de condicion: true arriba = exito (verde), false abajo = peligro + (rojo). El usuario entiende la rama solo por el color, sin selector. */ +.socket.socket-out--true { + fill: var(--success); +} + +.socket.socket-out--false { + fill: var(--danger); +} + +.socket.socket-out--true:hover { + fill: var(--socket-in-hover); +} + +.socket.socket-out--false:hover { + fill: var(--socket-out-hover); +} + +.socket:hover { + stroke: var(--accent-on); + stroke-width: 2; + fill: var(--socket-in-hover); +} + +.socket.socket-out:hover { + fill: var(--socket-out-hover); +} + +/* Puerto de entrada candidato mientras se arrastra una conexion: brilla para + indicar que soltar ahi conecta (el area de acierto es mas grande que el + circulo visible). */ +.socket.socket-hot { + fill: var(--socket-in-hover); + stroke: var(--accent-on); + stroke-width: 2.5; + filter: drop-shadow(0 0 5px var(--socket-in-hover)); +} + +.graph-node.selected .socket { + stroke: var(--accent-on); + stroke-width: 1.5; +} + +/* Node delete button (small cross on the body) */ +.node-delete { + fill: var(--bg-card); + stroke: var(--text-faint); + stroke-width: 1.5; + cursor: pointer; +} + +.node-delete-glyph { + stroke: var(--text-faint); + stroke-width: 1.5; + stroke-linecap: round; + pointer-events: none; +} + +.node-delete:hover, +.node-delete:hover + .node-delete-glyph { + fill: var(--danger); + stroke: var(--danger); +} + +.node-delete:hover + .node-label { + /* placeholder for any future glyph; kept for clarity */ +} + +/* Edges (free-form beziers) */ +.edge-line { + stroke: var(--text-faint); + stroke-width: 2; + fill: none; +} + +.edge-line:hover { + stroke: var(--accent-bright); +} + +.edge-draft { + stroke: var(--accent-bright); + stroke-width: 2; + stroke-dasharray: 6 4; + pointer-events: none; +} + +/* Aristas de rama de condicion: misma logica de color que los sockets — true + verde, false rojo — para que el recorrido se lea de un vistazo. */ +.edge-line--true, +.edge-draft.edge-line--true { + stroke: var(--success); +} + +.edge-line--false, +.edge-draft.edge-line--false { + stroke: var(--danger); +} + +.edge-line--true:hover { + stroke: var(--socket-in-hover); +} + +.edge-line--false:hover { + stroke: var(--socket-out-hover); +} + +.edge-dot { + fill: var(--accent-bright); + pointer-events: none; +} + +.edge-label { + fill: var(--accent-bright); + font-size: 11px; + font-family: var(--mono); + pointer-events: none; +} + +/* Edge midpoint delete affordance */ +.edge-delete { + fill: var(--bg-card); + stroke: var(--border-strong); + stroke-width: 1.5; + cursor: pointer; + opacity: 0; +} + +.edge-line:hover + .edge-delete, +.edge-delete:hover, +.graph-node:hover ~ .edge-delete { + opacity: 1; +} + +.edge-delete:hover { + fill: var(--danger); + stroke: var(--danger); +} + +/* ── Validation result ────────────────────────────────────────────── */ +.validation-result { + min-height: 1.4em; + font-size: 0.95rem; +} + +.validation-result[data-state="valid"] { + color: var(--success); +} + +.validation-result[data-state="invalid"] { + color: var(--danger); +} + +/* ── Inspector ────────────────────────────────────────────────────── */ +.inspector .field { + margin-bottom: var(--space-3); +} + +.inspector .field label, +.inspector .field .field-label { + display: block; + margin-bottom: var(--space-1); + font-size: 0.85rem; + color: var(--text-muted); +} + +.inspector select, +.inspector input[type="text"] { + width: 100%; +} + +.inspector .primary { + font-family: var(--mono); + color: var(--accent-bright); +} + +/* ── Constructor de condiciones ("pildoras") ────────────────────────── + Frase viviente arriba y una fila por condicion simple (campo + + operador + valor). Texto legible en espanol, validacion inline y + combinacion Y/O explicita. */ +.cond-preview { + margin: 0 0 var(--space-2); + color: var(--accent-bright); + font-size: 0.9rem; + line-height: 1.5; + min-height: 1.5em; +} + +.cond-rows { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.cond-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); + padding: var(--space-2); + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-base); + min-height: 40px; +} + +.cond-row--invalid { + border-color: var(--danger); + background: var(--danger-soft); +} + +.cond-row select, +.cond-row .cond-value { + width: auto; + min-height: 32px; +} + +.cond-row .cond-field { + flex: 1 1 170px; + min-width: 150px; +} + +.cond-row .cond-op { + flex: 1 1 150px; + min-width: 130px; +} + +.cond-row .cond-value { + flex: 1 1 120px; + min-width: 90px; +} + +.cond-row--exists .cond-value { + display: none; +} + +.cond-row-error { + flex: 1 1 100%; + font-size: 0.78rem; + color: var(--danger); +} + +.cond-row:not(.cond-row--invalid) .cond-row-error { + display: none; +} + +.cond-negate { + flex: 0 0 auto; + min-width: 42px; + min-height: 32px; + padding: var(--space-1) var(--space-2); + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-card); + color: var(--text-muted); + font-family: var(--font); + font-size: 0.8rem; + font-weight: 700; + cursor: pointer; +} + +.cond-negate:hover { + color: var(--text); + border-color: var(--accent); +} + +.cond-negate.is-active { + background: var(--danger-soft); + border-color: var(--danger); + color: var(--danger); +} + +.cond-remove { + flex: 0 0 auto; + width: 32px; + height: 32px; + padding: 0; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-card); + color: var(--text-muted); + font-family: var(--font); + font-size: 1rem; + line-height: 1; + cursor: pointer; +} + +.cond-remove:hover { + color: var(--danger); + border-color: var(--danger); +} + +.cond-add { + margin-top: var(--space-2); +} + +.cond-combine-wrap { + display: none; + align-items: center; + gap: var(--space-2); + margin-top: var(--space-2); +} + +.cond-combine-wrap--visible { + display: flex; +} + +.cond-combine { + min-height: 32px; + width: auto; +} + +/* Subtitulo de condicion en el canvas (preview legible del AST). */ +.node-sub--cond { + fill: var(--accent-bright); + font-size: 9px; +} + +/* Subtitulos vivientes de llm_call / pipeline / loop (lenguaje del nodo). */ +.node-sub--llm, +.node-sub--pipeline, +.node-sub--loop { + fill: var(--accent-bright); + font-size: 9px; +} + +/* ── Inspector: pestanas (llm_call) ────────────────────────────────── + Tres pestanas (Config. basica / Prompt / Avanzado) con rol ARIA tabs. + El Map de pestana activa vive en app.js (no puede ir al nodo: schema + estricto); aca solo el aspecto visual. */ +.inspector-tabs { + display: flex; + gap: var(--space-1); + border-bottom: 1px solid var(--border); + margin-bottom: var(--space-3); +} + +.tab-btn { + background: none; + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + padding: var(--space-2) var(--space-3); + color: var(--text-muted); + font-family: var(--font); + font-size: 0.9rem; + cursor: pointer; +} + +.tab-btn:hover { + color: var(--text); +} + +.tab-btn.is-active, +.tab-btn[aria-selected="true"] { + color: var(--accent-bright); + border-bottom-color: var(--accent); +} + +.tab-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.inspector-tab-panel[hidden] { + display: none !important; +} + +/* ── Inspector: pildoras de modo ──────────────────────────────────── + Misma familia visual que el constructor de condiciones (pildoras). */ +.mode-pills { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.mode-pill { + min-height: 32px; + padding: var(--space-1) var(--space-3); + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg-card); + color: var(--text-muted); + font-family: var(--font); + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; +} + +.mode-pill:hover { + color: var(--text); + border-color: var(--accent); +} + +.mode-pill.is-active, +.mode-pill[aria-checked="true"] { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--accent-bright); +} + +.mode-pill:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* ── Inspector: textareas de prompt + contadores ─────────────────── */ +.inspector textarea { + width: 100%; + font-family: var(--font); + font-size: 0.95rem; + line-height: 1.5; + padding: var(--space-1) var(--space-2); + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-card); + color: var(--text); + resize: vertical; +} + +.inspector textarea:focus { + border-color: var(--accent); +} + +.char-counter { + margin-top: 2px; + font-size: 0.78rem; + color: var(--text-faint); + text-align: right; +} + +/* ── Inspector: filas clave=valor de parametros ──────────────────── + Cada fila es una pildora editable; la clave vacia marca la fila en + rojo y se descarta al serializar (nunca se escribe un string vacio). */ +.param-row { + display: grid; + grid-template-columns: 1fr 1fr auto; + gap: var(--space-2); + align-items: center; + padding: var(--space-2); + margin-bottom: var(--space-2); + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-base); +} + +.param-row--invalid { + border-color: var(--danger); + background: var(--danger-soft); +} + +.param-row--invalid .param-key { + border-color: var(--danger); +} + +.param-rows-error { + flex: 1 1 100%; + font-size: 0.78rem; + color: var(--danger); +} + +.param-row-remove { + flex: 0 0 auto; + width: 32px; + height: 32px; + padding: 0; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-card); + color: var(--text-muted); + font-family: var(--font); + font-size: 1rem; + line-height: 1; + cursor: pointer; +} + +.param-row-remove:hover { + color: var(--danger); + border-color: var(--danger); +} + +.btn.btn-ghost { + background: transparent; + border-color: var(--border); + color: var(--accent-bright); +} + +.btn.btn-ghost:hover { + border-color: var(--accent); + background: var(--bg-hover); +} + +/* Etiquetas Si/No junto a los sockets de rama del nodo condicion. */ +.node-branch-label { + font-family: var(--mono); + font-size: 10px; + font-weight: 700; + pointer-events: none; +} + +.node-branch-label--true { + fill: var(--success); +} + +.node-branch-label--false { + fill: var(--danger); +} + +/* ── Lists (pipelines/models/executions) ─────────────────────────── */ +.list { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.list-item { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--space-3) var(--space-4); + display: flex; + align-items: center; + gap: var(--space-4); + flex-wrap: wrap; + box-shadow: inset 0 1px 0 rgba(240, 246, 252, 0.03); +} + +.list-item .primary { + font-weight: 700; +} + +.list-item .secondary { + color: var(--text-muted); + font-size: 0.9rem; +} + +.chip { + font-family: var(--mono); + font-size: 0.8rem; + padding: 2px var(--space-2); + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent-bright); +} + +.chip[data-status="running"] { color: var(--accent-bright); background: var(--accent-soft); } +.chip[data-status="completed"] { color: var(--success); background: var(--success-soft); } +.chip[data-status="failed"] { color: var(--danger); background: var(--danger-soft); } +.chip[data-status="retrying"] { color: #e0a34e; background: rgba(224, 163, 78, 0.12); } + +/* F2 — modelo cargado/descargado (worker real) en la lista de modelos. */ +.chip[data-live="loaded"] { color: var(--success); background: var(--success-soft); } +.chip[data-live="unloaded"] { color: var(--text-muted); background: var(--bg-hover); } + +/* F2 — panel Backend (ciclo de vida) en la vista de modelos. */ +.lc-panel { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-surface); + padding: var(--space-4); + margin-bottom: var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.lc-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: var(--space-3); +} + +.lc-field { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.lc-label { + font-size: 0.85rem; + color: var(--text-muted); +} + +.lc-actions { + display: flex; + align-items: center; + gap: var(--space-3); + flex-wrap: wrap; +} + +.btn-danger { + background: var(--danger-soft); + border-color: var(--danger); + color: var(--danger); +} + +.btn-danger:hover { + background: var(--danger); + color: #fff; +} + +/* ── Dialog ───────────────────────────────────────────────────────── */ +dialog { + border: 1px solid var(--border-strong); + border-radius: var(--radius); + padding: var(--space-5); + background: var(--bg-surface); + color: var(--text); + max-width: 480px; + width: 90%; + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(240, 246, 252, 0.04); +} + +dialog::backdrop { + background: rgba(4, 5, 8, 0.7); +} + +.dialog-title { + margin-top: 0; +} + +.dialog-actions { + display: flex; + gap: var(--space-2); + justify-content: flex-end; + margin-top: var(--space-4); +} + +#apply-dialog-message { + color: var(--danger); +} + +.validate-errors { + margin: var(--space-2) 0 0; + padding-left: var(--space-4); + color: var(--danger); +} + +.validate-errors li { + margin: var(--space-1) 0; +} + +/* ── Agents ─────────────────────────────────────────────────────────── */ +/* Cards reuse the .list-item look; only the sync button pushes right. */ +.agents-card .btn { + margin-left: auto; +} + +#agent-token-row { + margin: 0 0 var(--space-3); + max-width: 480px; +} + +#agent-token-row .field-label { + display: block; + margin-bottom: var(--space-2); +} + +#agent-token-row .text-input { + width: 100%; +} + +#agents-result { + margin-top: var(--space-3); + font-size: 0.9rem; +} + +#agents-result[data-state="valid"] { + color: var(--success); +} + +#agents-result[data-state="invalid"] { + color: var(--danger); +} +/* ──────────────────────────────────────────────────────────────────────── + * svelte-ui ADDENDUM (Phase 3, task 3.3) + * + * Small supplement over the verbatim legacy token set: the Svelte shell + * reuses every legacy id/class (app-header, nav-link, palette, lc-panel, + * graph-canvas, list, …), so only genuinely new structures need rules — + * native dialog framing, toolbar, status badges for list rows, palette + * shortcut hints, and a button reset for .list-item (legacy rows were divs). + * ──────────────────────────────────────────────────────────────────────── */ + +/* Native dialog (validate / apply) — lightbox framing over the legacy theme. + .dialog-title and .dialog-actions already exist in the token set. */ +.dialog { + border: 1px solid var(--border, #30363d); + border-radius: 8px; + background: var(--panel-bg, #161b22); + color: var(--fg, #e6edf3); + padding: 0; + max-width: 480px; + width: calc(100vw - 48px); + box-shadow: 0 16px 48px rgb(0 0 0 / 0.5); +} +.dialog::backdrop { + background: rgb(1 4 9 / 0.6); +} +.dialog-content { + display: grid; + gap: 12px; + padding: 20px; +} + +/* Toolbar under the canvas (undo / redo / validate / apply). */ +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-top: 12px; +} + +/* Palette shortcut hint (data-key chips). */ +.palette-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; +} +.shortcut { + font-family: var(--mono, "JetBrains Mono", ui-monospace, monospace); + font-size: 11px; + color: var(--fg-muted, #8b949e); + border: 1px solid var(--border, #30363d); + border-radius: 4px; + padding: 1px 6px; +} + +/* Status badges in list rows (models / executions). */ +.status { + font-size: 12px; + font-weight: 600; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid var(--border, #30363d); + color: var(--fg-muted, #8b949e); + text-transform: lowercase; +} +.status--active { + color: #3fb950; + border-color: rgb(63 185 80 / 0.4); + background: rgb(63 185 80 / 0.08); +} +.status--inactive { + color: #f85149; + border-color: rgb(248 81 73 / 0.4); + background: rgb(248 81 73 / 0.08); +} + +/* Validation dialog results reuse the legacy .validation-result tokens. */ +.validation-errors { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 6px; +} + +/* .list-item rows are interactive buttons in the Svelte views — reset. */ +.list-item { + font: inherit; + text-align: left; + background: none; + border: none; + cursor: pointer; + width: 100%; +} +.list-item:focus-visible, +.palette-item:focus-visible { + outline: 2px solid var(--accent, #58a6ff); + outline-offset: 2px; } \ No newline at end of file diff --git a/src/ui-svelte/svelte-shim.d.ts b/src/ui-svelte/svelte-shim.d.ts new file mode 100644 index 0000000..83c9cbd --- /dev/null +++ b/src/ui-svelte/svelte-shim.d.ts @@ -0,0 +1,15 @@ +/** + * Root-tsc shim for Svelte 5 components (svelte-ui, task 3.3). + * + * `bun run typecheck` runs the ROOT tsconfig (lib ES2022, includes + * src/**\/*.ts) — and from task 3.3 on, component tests under + * src/ui-svelte import `.svelte` files. Without this declaration tsc would + * fail with "Cannot find module './App.svelte'". The runtime compilation is + * handled by the Bun.plugin preload (test-setup/svelte-loader.ts); this shim + * only satisfies the typecheck gate. + */ +declare module "*.svelte" { + import type { Component } from "svelte"; + const component: Component; + export default component; +} \ No newline at end of file diff --git a/src/ui-svelte/views/Agentes.svelte b/src/ui-svelte/views/Agentes.svelte new file mode 100644 index 0000000..28ce503 --- /dev/null +++ b/src/ui-svelte/views/Agentes.svelte @@ -0,0 +1,34 @@ + + + \ No newline at end of file diff --git a/src/ui-svelte/views/Agentes.test.ts b/src/ui-svelte/views/Agentes.test.ts new file mode 100644 index 0000000..6b20ea6 --- /dev/null +++ b/src/ui-svelte/views/Agentes.test.ts @@ -0,0 +1,63 @@ +/** + * Agentes view component tests (svelte-ui task 3.3). + * + * Renders the agent status rows (OpenCode, Pi) from the dashboard store. + * + * Queries come from `render(...)` (container-bound) rather than the global + * `screen` — bun test evaluates helper modules in a shared cache, so + * screen's module-eval-time binding can race the jsdom preload. + */ +import { describe, it, expect } from "bun:test"; +import { render, within } from "@testing-library/svelte"; +import Agentes from "./Agentes.svelte"; +import { makeDashboardStore } from "../test-setup/fakes.js"; +import type { AgentEntry } from "../stores/types.js"; + +describe("Agentes view (task 3.3)", () => { + it("renders the section with its title", () => { + const { getByRole } = render(Agentes, { props: { store: makeDashboardStore() } }); + expect(getByRole("heading", { level: 2, name: "Agentes" })).toBeTruthy(); + }); + + it("explains how agents are configured when none are present", () => { + const { getByText } = render(Agentes, { props: { store: makeDashboardStore() } }); + expect(getByText(/Configurá tus agentes/i)).toBeTruthy(); + }); + + it("lists one row per agent with provider and model count", async () => { + const store = makeDashboardStore({ + agents: [ + { + id: "opencode", + label: "OpenCode", + configPath: "~/.config/opencode/opencode.json", + providerPresent: true, + modelCount: 2, + }, + { + id: "pi", + label: "Pi", + configPath: "pi/config.json", + providerPresent: false, + modelCount: 0, + }, + ] as AgentEntry[], + }); + await store.actions.loadAgents(); + const { getByTestId } = render(Agentes, { props: { store } }); + const rows = within(getByTestId("agents-list")).getAllByRole("listitem"); + expect(rows).toHaveLength(2); + expect(rows[0]!.textContent).toContain("OpenCode"); + expect(rows[0]!.textContent).toContain("2"); + expect(rows[0]!.textContent).toContain("proveedor presente"); + expect(rows[1]!.textContent).toContain("Pi"); + expect(rows[1]!.textContent).not.toContain("proveedor presente"); + }); + + it("respects the hidden prop", () => { + const { container } = render(Agentes, { + props: { store: makeDashboardStore(), hidden: true }, + }); + expect(container.querySelector("#agents")!.hasAttribute("hidden")).toBe(true); + }); +}); \ No newline at end of file diff --git a/src/ui-svelte/views/Editor.svelte b/src/ui-svelte/views/Editor.svelte new file mode 100644 index 0000000..c83ea0a --- /dev/null +++ b/src/ui-svelte/views/Editor.svelte @@ -0,0 +1,123 @@ + + + \ No newline at end of file diff --git a/src/ui-svelte/views/Editor.test.ts b/src/ui-svelte/views/Editor.test.ts new file mode 100644 index 0000000..317ea8e --- /dev/null +++ b/src/ui-svelte/views/Editor.test.ts @@ -0,0 +1,110 @@ +/** + * Editor view component tests (svelte-ui task 3.3). + * + * Locks the editor shell contract: 6-node palette, focusable canvas, + * toolbar (undo/redo/validate/apply) and the native validate/apply dialogs. + * Palette clicks add nodes through the editor store; interactions (drag, + * keys, connect, zoom) build on this in task 3.4 and history in 3.5. + * + * Queries come from `render(...)` (container-bound) rather than the global + * `screen` — bun test evaluates helper modules in a shared cache, so + * screen's module-eval-time binding can race the jsdom preload. + */ +import { describe, it, expect } from "bun:test"; +import { render, within, fireEvent, waitFor } from "@testing-library/svelte"; +import Editor from "./Editor.svelte"; +import { makeEditorStore } from "../test-setup/fakes.js"; + +const PALETTE_TYPES = ["start", "llm_call", "condition", "loop", "pipeline", "end"]; + +describe("Editor view (task 3.3)", () => { + it("renders the section with its title", () => { + const { getByRole } = render(Editor, { props: { store: makeEditorStore() } }); + // Legacy layout: the editor section is a labelled region; the only + // heading inside is the palette's secondary "Paleta de nodos". + expect(getByRole("region", { name: "Editor de pipelines" })).toBeTruthy(); + expect(getByRole("heading", { level: 2, name: "Paleta de nodos" })).toBeTruthy(); + }); + + it("exposes the six node types with keys 1-6 in the palette", () => { + const { getByTestId } = render(Editor, { props: { store: makeEditorStore() } }); + const items = within(getByTestId("palette-list")).getAllByRole("listitem"); + expect(items).toHaveLength(6); + items.forEach((item, i) => { + const button = item.querySelector("button"); + expect(button!.getAttribute("data-node-type")).toBe(PALETTE_TYPES[i]); + expect(button!.getAttribute("data-key")).toBe(String(i + 1)); + }); + }); + + it("renders a focusable graph canvas", () => { + const { getByTestId } = render(Editor, { props: { store: makeEditorStore() } }); + const canvas = getByTestId("graph-canvas"); + expect(canvas.getAttribute("tabindex")).toBe("0"); + expect(canvas.getAttribute("aria-label")).toBeTruthy(); + }); + + it("renders the toolbar with undo/redo disabled and validate/apply enabled", () => { + const { getByTestId } = render(Editor, { props: { store: makeEditorStore() } }); + expect(getByTestId("btn-undo")).toBeDisabled(); + expect(getByTestId("btn-redo")).toBeDisabled(); + expect(getByTestId("btn-validate")).toHaveTextContent("Validar"); + expect(getByTestId("btn-apply")).toBeTruthy(); + }); + + it("adds a node through the editor store when a palette item is clicked", () => { + const store = makeEditorStore(); + const { getAllByTestId } = render(Editor, { props: { store } }); + const items = getAllByTestId("palette-item"); + const condition = items.find((item) => item.getAttribute("data-node-type") === "condition"); + fireEvent.click(condition!); + const state = store.getSnapshot(); + expect(state.nodes).toHaveLength(1); + expect(state.nodes[0]!.type).toBe("condition"); + expect(state.dirty).toBe(true); + }); + + it("opens the validate dialog and reports a valid graph", async () => { + const store = makeEditorStore({ validate: { valid: true } }); + const { getByTestId, findByTestId } = render(Editor, { props: { store } }); + fireEvent.click(getByTestId("btn-validate")); + // The dialog element is always in the DOM; validation is async, so wait + // for it to actually open (runValidate awaits the store's validate before + // showModal). Without this, the assertions race the resolved validation. + const dialog = await findByTestId("validate-dialog"); + await waitFor(() => expect(dialog.hasAttribute("open")).toBe(true)); + expect(within(dialog).getByText(/El pipeline es válido/)).toBeTruthy(); + expect(within(dialog).queryAllByTestId("validation-error")).toHaveLength(0); + }); + + it("lists each error on an invalid graph", async () => { + const store = makeEditorStore({ + validate: { valid: false, errors: ["Falta el nodo de inicio", "Conexión inválida"] }, + }); + const { getByTestId, findByTestId } = render(Editor, { props: { store } }); + fireEvent.click(getByTestId("btn-validate")); + const dialog = await findByTestId("validate-dialog"); + await waitFor(() => expect(dialog.hasAttribute("open")).toBe(true)); + const errors = within(dialog).getAllByTestId("validation-error"); + expect(errors).toHaveLength(2); + expect(errors[0]!.textContent).toContain("Falta el nodo de inicio"); + }); + + it("applies the pipeline from the apply dialog and closes it", async () => { + const store = makeEditorStore(); + const { getByTestId, findByTestId } = render(Editor, { props: { store } }); + fireEvent.click(getByTestId("btn-apply")); + const dialog = await findByTestId("apply-dialog"); + expect(dialog.hasAttribute("open")).toBe(true); + fireEvent.click(within(dialog).getByTestId("btn-confirm-apply")); + await waitFor(() => expect(dialog.hasAttribute("open")).toBe(false)); + expect(store.getSnapshot().applyError).toBeNull(); + }); + + it("respects the hidden prop", () => { + const { container } = render(Editor, { + props: { store: makeEditorStore(), hidden: true }, + }); + expect(container.querySelector("#editor")!.hasAttribute("hidden")).toBe(true); + }); +}); \ No newline at end of file diff --git a/src/ui-svelte/views/Ejecuciones.svelte b/src/ui-svelte/views/Ejecuciones.svelte new file mode 100644 index 0000000..fc0e651 --- /dev/null +++ b/src/ui-svelte/views/Ejecuciones.svelte @@ -0,0 +1,40 @@ + + + \ No newline at end of file diff --git a/src/ui-svelte/views/Ejecuciones.test.ts b/src/ui-svelte/views/Ejecuciones.test.ts new file mode 100644 index 0000000..61ec5dc --- /dev/null +++ b/src/ui-svelte/views/Ejecuciones.test.ts @@ -0,0 +1,51 @@ +/** + * Ejecuciones (executions) view component tests (svelte-ui task 3.3). + * + * Renders the execution history rows; the failed-execution retry action + * lands with task 3.6. + * + * Queries come from `render(...)` (container-bound) rather than the global + * `screen` — bun test evaluates helper modules in a shared cache, so + * screen's module-eval-time binding can race the jsdom preload. + */ +import { describe, it, expect } from "bun:test"; +import { render, within } from "@testing-library/svelte"; +import Ejecuciones from "./Ejecuciones.svelte"; +import { makeDashboardStore } from "../test-setup/fakes.js"; +import type { ExecutionEntry } from "../stores/types.js"; + +describe("Ejecuciones view (task 3.3)", () => { + it("renders the section with its title", () => { + const { getByRole } = render(Ejecuciones, { props: { store: makeDashboardStore() } }); + expect(getByRole("heading", { level: 2, name: "Ejecuciones" })).toBeTruthy(); + }); + + it("shows a hint when there are no executions", () => { + const { getByText } = render(Ejecuciones, { props: { store: makeDashboardStore() } }); + expect(getByText(/No hay ejecuciones/i)).toBeTruthy(); + }); + + it("lists one row per execution with pipeline, status and latency", async () => { + const store = makeDashboardStore({ + executions: [ + { id: "ex1", pipelineId: "rag", status: "completed", totalLatencyMs: 1234 }, + { id: "ex2", pipelineId: "chat", status: "failed", totalLatencyMs: 987 }, + ] as ExecutionEntry[], + }); + await store.actions.loadExecutions(); + const { getByTestId } = render(Ejecuciones, { props: { store } }); + const rows = within(getByTestId("executions-list")).getAllByRole("listitem"); + expect(rows).toHaveLength(2); + expect(rows[0]!.textContent).toContain("rag"); + expect(rows[0]!.textContent).toContain("completed"); + expect(rows[0]!.textContent).toContain("1234 ms"); + expect(rows[1]!.textContent).toContain("failed"); + }); + + it("respects the hidden prop", () => { + const { container } = render(Ejecuciones, { + props: { store: makeDashboardStore(), hidden: true }, + }); + expect(container.querySelector("#executions")!.hasAttribute("hidden")).toBe(true); + }); +}); \ No newline at end of file diff --git a/src/ui-svelte/views/Modelos.svelte b/src/ui-svelte/views/Modelos.svelte new file mode 100644 index 0000000..0c97fa1 --- /dev/null +++ b/src/ui-svelte/views/Modelos.svelte @@ -0,0 +1,105 @@ + + + \ No newline at end of file diff --git a/src/ui-svelte/views/Modelos.test.ts b/src/ui-svelte/views/Modelos.test.ts new file mode 100644 index 0000000..4aa11aa --- /dev/null +++ b/src/ui-svelte/views/Modelos.test.ts @@ -0,0 +1,94 @@ +/** + * Modelos (models) view component tests (svelte-ui task 3.3). + * + * Renders the lifecycle backend panel (config + VRAM state, wired in 3.6) + * and the model registry rows from the dashboard store. + * + * Queries come from `render(...)` (container-bound) rather than the global + * `screen` — bun test evaluates helper modules in a shared cache, so + * screen's module-eval-time binding can race the jsdom preload. + */ +import { describe, it, expect } from "bun:test"; +import { render, within } from "@testing-library/svelte"; +import Modelos from "./Modelos.svelte"; +import { makeDashboardStore } from "../test-setup/fakes.js"; +import type { ModelEntry } from "../stores/types.js"; + +const MODELS = [ + { id: "qwen2.5:7b", loaded: true, processLoaded: true, ctx: 8192 }, + { id: "llama3.2:3b", loaded: true, ctx: 4096 }, + { id: "mistral:7b" }, +] as ModelEntry[]; + +describe("Modelos view (task 3.3)", () => { + it("renders the section with its title", () => { + const { getByRole } = render(Modelos, { props: { store: makeDashboardStore() } }); + expect(getByRole("heading", { level: 2, name: "Modelos" })).toBeTruthy(); + }); + + it("shows the models directory hint", async () => { + const store = makeDashboardStore({ modelsDir: "/models/gguf" }); + await store.actions.loadModels(); + const { getByText } = render(Modelos, { props: { store } }); + expect(getByText(/Modelos en \/models\/gguf/)).toBeTruthy(); + }); + + it("renders the backend lifecycle panel (fields wired in 3.6)", async () => { + const store = makeDashboardStore({ + config: { + llama: { + lifecycle: { ttl: 30, vram: { mode: "auto", freeGb: 4, capGb: 12 } }, + }, + }, + }); + await store.actions.loadConfig(); + const { getByTestId } = render(Modelos, { props: { store } }); + const panel = getByTestId("backend-panel"); + const panelQueries = within(panel); + expect(panelQueries.getByTestId("lc-ttl")).toBeTruthy(); + expect(panelQueries.getByTestId("lc-vram-mode")).toBeTruthy(); + expect(panelQueries.getByTestId("lc-freegb")).toBeTruthy(); + expect(panelQueries.getByTestId("lc-capgb")).toBeTruthy(); + expect(panelQueries.getByTestId("lc-status")).toBeTruthy(); + expect(panelQueries.getByTestId("lc-save")).toBeTruthy(); + expect(panelQueries.getByTestId("lc-unload-all")).toBeTruthy(); + expect((panelQueries.getByTestId("lc-ttl") as HTMLInputElement).value).toBe("30"); + }); + + it("shows VRAM lifecycle state in the panel status", async () => { + const store = makeDashboardStore({ + lifecycle: { + vramPolicyActive: true, + lastVramSample: { usedMiB: 512, totalMiB: 8192, at: "2026-09-06T10:00:00Z" }, + }, + }); + await store.actions.loadModels(); + const { getByTestId } = render(Modelos, { props: { store } }); + const status = getByTestId("lc-status"); + expect(status.textContent).toContain("VRAM"); + expect(status.textContent).toContain("512"); + }); + + it("lists one row per model with status and context", async () => { + const store = makeDashboardStore({ models: MODELS, modelsDir: "/models/gguf" }); + await store.actions.loadModels(); + const { getByTestId } = render(Modelos, { props: { store } }); + const rows = within(getByTestId("models-list")).getAllByRole("listitem"); + expect(rows).toHaveLength(3); + // process-loaded model shows the "banner" badge + ctx + expect(rows[0]!.textContent).toContain("qwen2.5:7b"); + expect(rows[0]!.textContent).toContain("banner"); + expect(rows[0]!.textContent).toContain("8192"); + // plain loaded model + expect(rows[1]!.textContent).toContain("cargado"); + // unloaded model + expect(rows[2]!.textContent).toContain("descargado"); + }); + + it("shows a hint when no models are registered", async () => { + const store = makeDashboardStore({ models: [] }); + await store.actions.loadModels(); + const { getByText } = render(Modelos, { props: { store } }); + expect(getByText(/No hay modelos/i)).toBeTruthy(); + }); +}); \ No newline at end of file diff --git a/src/ui-svelte/views/Pipelines.svelte b/src/ui-svelte/views/Pipelines.svelte new file mode 100644 index 0000000..e4c73bd --- /dev/null +++ b/src/ui-svelte/views/Pipelines.svelte @@ -0,0 +1,48 @@ + + + \ No newline at end of file diff --git a/src/ui-svelte/views/Pipelines.test.ts b/src/ui-svelte/views/Pipelines.test.ts new file mode 100644 index 0000000..6026fc3 --- /dev/null +++ b/src/ui-svelte/views/Pipelines.test.ts @@ -0,0 +1,67 @@ +/** + * Pipelines view component tests (svelte-ui task 3.3). + * + * Renders the pipeline list from the dashboard store; clicking a row loads + * it into the editor store and routes to the editor view. + * + * Queries come from `render(...)` (container-bound) rather than the global + * `screen` — bun test evaluates helper modules in a shared cache, so + * screen's module-eval-time binding can race the jsdom preload. + */ +import { describe, it, expect } from "bun:test"; +import { render, within, fireEvent, waitFor } from "@testing-library/svelte"; +import Pipelines from "./Pipelines.svelte"; +import { makeDashboardStore, makeEditorStore } from "../test-setup/fakes.js"; +import type { PipelineSummary } from "../stores/types.js"; + +describe("Pipelines view (task 3.3)", () => { + it("renders the section with its title", () => { + const { getByRole } = render(Pipelines, { + props: { store: makeDashboardStore(), editor: makeEditorStore() }, + }); + expect(getByRole("heading", { level: 2, name: "Pipelines" })).toBeTruthy(); + }); + + it("shows a hint when there are no pipelines", () => { + const { getByText, queryByTestId } = render(Pipelines, { + props: { store: makeDashboardStore(), editor: makeEditorStore() }, + }); + expect(getByText("No hay pipelines registrados.")).toBeTruthy(); + expect(queryByTestId("pipelines-list")).toBeNull(); + }); + + it("lists one row per pipeline with id and node count", async () => { + const store = makeDashboardStore({ + pipelines: [ + { id: "rag", displayName: "RAG", nodeCount: 4 }, + { id: "chat", displayName: "Chat", nodeCount: 3 }, + ] as PipelineSummary[], + }); + await store.actions.loadPipelines(); + const { getByTestId } = render(Pipelines, { props: { store, editor: makeEditorStore() } }); + const rows = within(getByTestId("pipelines-list")).getAllByRole("listitem"); + expect(rows).toHaveLength(2); + expect(rows[0]!.textContent).toContain("RAG"); + expect(rows[0]!.textContent).toContain("4"); + expect(rows[0]!.getAttribute("data-pipeline-id")).toBe("rag"); + expect(rows[1]!.getAttribute("data-pipeline-id")).toBe("chat"); + }); + + it("loads the pipeline into the editor and routes to #editor on click", async () => { + const store = makeDashboardStore({ + pipelines: [{ id: "rag", displayName: "RAG" }] as PipelineSummary[], + }); + await store.actions.loadPipelines(); + const editor = makeEditorStore(); + const { getByTestId } = render(Pipelines, { props: { store, editor } }); + fireEvent.click(getByTestId("pipeline-row-rag")); + await waitFor(() => expect(editor.getSnapshot().pipelineId).toBe("rag")); + }); + + it("respects the hidden prop", () => { + const { container } = render(Pipelines, { + props: { store: makeDashboardStore(), editor: makeEditorStore(), hidden: true }, + }); + expect(container.querySelector("#pipelines")!.hasAttribute("hidden")).toBe(true); + }); +}); \ No newline at end of file From 8de47d3b333067dfcf00732ba524084007c6dbe1 Mon Sep 17 00:00:00 2001 From: AndyTechnologies Date: Sun, 6 Sep 2026 15:46:45 -0400 Subject: [PATCH 5/9] feat(ui-svelte): editor geometry helpers + drag/reorder store transactions (tasks 3.4, 3.5) --- src/ui-svelte/lib/editor-geometry.test.ts | 294 ++++++++++++++++++++++ src/ui-svelte/lib/editor-geometry.ts | 184 ++++++++++++++ src/ui-svelte/stores/editor-store.test.ts | 167 ++++++++++++ src/ui-svelte/stores/editor-store.ts | 123 +++++++-- 4 files changed, 750 insertions(+), 18 deletions(-) create mode 100644 src/ui-svelte/lib/editor-geometry.test.ts create mode 100644 src/ui-svelte/lib/editor-geometry.ts diff --git a/src/ui-svelte/lib/editor-geometry.test.ts b/src/ui-svelte/lib/editor-geometry.test.ts new file mode 100644 index 0000000..5540684 --- /dev/null +++ b/src/ui-svelte/lib/editor-geometry.test.ts @@ -0,0 +1,294 @@ +/** + * RED→GREEN tests for the editor geometry helpers (svelte-ui task 3.4). + * + * All functions live in the framework-free `lib/` layer: they convert between + * client and graph coordinates, resolve the nearest input socket for + * port-to-port connections, re-bucket dropped nodes into loop bodies, reorder + * loop members, and compute the animated flow order — all pure, with no DOM + * or store access. `Editor.svelte` consumes these; graph-model.ts supplies + * the socket/loop geometry primitives. + */ +import { describe, it, expect } from "bun:test"; +import type { GraphNode, GraphEdge, Point } from "./graph-model.js"; +import { + MIN_ZOOM, + MAX_ZOOM, + SOCKET_HIT_RADIUS, + clampZoom, + clientToGraph, + graphToClient, + nearestInputSocket, + bucketDroppedNode, + reorderLoopMember, + computeFlowOrder, + type EditorView, + type FlowStep, +} from "./editor-geometry.js"; + +function node(id: string, type: GraphNode["type"], pos?: Point): GraphNode { + return pos ? { id, type, pos } : { id, type }; +} + +/** Bounding rect used by the coordinate conversion tests (client space). */ +const RECT = { left: 40, top: 20, width: 800, height: 600 } as DOMRect; + +const VIEW: EditorView = { zoom: 1.4, panX: 120, panY: 80 }; + +describe("clampZoom (0.2x–3x)", () => { + it("clamps below the minimum to MIN_ZOOM", () => { + expect(clampZoom(0.05)).toBe(MIN_ZOOM); + expect(clampZoom(0.199)).toBe(MIN_ZOOM); + }); + + it("clamps above the maximum to MAX_ZOOM", () => { + expect(clampZoom(4)).toBe(MAX_ZOOM); + expect(clampZoom(3.001)).toBe(MAX_ZOOM); + }); + + it("keeps in-range values unchanged (boundaries inclusive)", () => { + expect(clampZoom(1)).toBe(1); + expect(clampZoom(2.5)).toBe(2.5); + expect(clampZoom(MIN_ZOOM)).toBe(MIN_ZOOM); + expect(clampZoom(MAX_ZOOM)).toBe(MAX_ZOOM); + }); +}); + +describe("clientToGraph / graphToClient", () => { + it("round-trips: graphToClient(clientToGraph(p)) returns p", () => { + const p: Point = { x: 210, y: 97 }; + const back = graphToClient(clientToGraph(p.x, p.y, RECT, VIEW), RECT, VIEW); + expect(back.x).toBeCloseTo(p.x, 6); + expect(back.y).toBeCloseTo(p.y, 6); + }); + + it("maps a client point into graph space (minus rect origin and pan, divided by zoom)", () => { + // client (120, 100) − rect (40, 20) − pan (120, 80) = (−40, 0); / 1.4 + expect(clientToGraph(120, 100, RECT, VIEW)).toEqual({ x: -40 / 1.4, y: 0 }); + }); + + it("maps a graph point into client space (times zoom, plus rect origin and pan)", () => { + // graph (50, 30) × 1.4 = (70, 42); + rect (40, 20) + pan (120, 80) + expect(graphToClient({ x: 50, y: 30 }, RECT, VIEW)).toEqual({ x: 230, y: 142 }); + }); + + it("handles zoom 1 and zero pan as the identity offset by the rect origin", () => { + const identity: EditorView = { zoom: 1, panX: 0, panY: 0 }; + expect(clientToGraph(340, 220, RECT, identity)).toEqual({ x: 300, y: 200 }); + expect(graphToClient({ x: 300, y: 200 }, RECT, identity)).toEqual({ x: 340, y: 220 }); + }); +}); + +describe("nearestInputSocket (24px screen hit radius)", () => { + it("returns the node whose input socket is closest to the point, within maxDist", () => { + // a at (100,100): input socket {100, 128}; b at (300,100): input {300, 128} + const nodes = [node("a", "llm_call", { x: 100, y: 100 }), node("b", "llm_call", { x: 300, y: 100 })]; + expect(nearestInputSocket(nodes, { x: 96, y: 128 }, SOCKET_HIT_RADIUS)).toBe("a"); + expect(nearestInputSocket(nodes, { x: 304, y: 128 }, SOCKET_HIT_RADIUS)).toBe("b"); + }); + + it("returns null when no input socket is within maxDist", () => { + const nodes = [node("a", "llm_call", { x: 100, y: 100 })]; + // input socket {100, 128}; probe 30px away on the x axis + expect(nearestInputSocket(nodes, { x: 131, y: 128 }, SOCKET_HIT_RADIUS)).toBeNull(); + expect(nearestInputSocket(nodes, { x: 100, y: 300 }, SOCKET_HIT_RADIUS)).toBeNull(); + }); + + it("never targets start nodes (no input port)", () => { + // probe sits within the hit radius of BOTH the start block's (excluded) + // socket position and b's — only b can win. + const nodes = [node("start", "start", { x: 100, y: 100 }), node("b", "llm_call", { x: 110, y: 100 })]; + expect(nearestInputSocket(nodes, { x: 104, y: 128 }, SOCKET_HIT_RADIUS)).toBe("b"); + }); + + it("picks the nearest socket among several candidates", () => { + const nodes = [ + node("a", "llm_call", { x: 100, y: 100 }), + node("c", "condition", { x: 200, y: 100 }), + node("b", "llm_call", { x: 300, y: 100 }), + ]; + // probe closer to b (304 vs 204 vs 404) — prefers b even though c exists + expect(nearestInputSocket(nodes, { x: 300, y: 128 }, SOCKET_HIT_RADIUS)).toBe("b"); + }); +}); + +describe("bucketDroppedNode (loop membership by position)", () => { + // loop at (0,0) with one member at (0, 70) → body rect spans the header + // and stack; a node dropped at (40, 200) lands inside the container. + const loopAt = (body: string[] = []): GraphNode => + node("loop", "loop", { x: 0, y: 0 }) as GraphNode & { body?: string[] }; + + it("adds a node whose center falls inside a loop container to its body", () => { + const loop = { ...loopAt(), body: [] as string[] }; + const inner = node("inner", "llm_call", { x: 40, y: 80 }); + const next = bucketDroppedNode([loop, inner], "inner"); + expect(next.find((n) => n.id === "loop")?.body).toContain("inner"); + }); + + it("keeps a node out of every loop body when its center lands outside all containers", () => { + const loop = { ...loopAt(), body: [] as string[] }; + // dropped far from the container — center never intersects any loop rect + const inner = node("inner", "llm_call", { x: 600, y: 600 }); + const next = bucketDroppedNode([loop, inner], "inner"); + expect(next.find((n) => n.id === "loop")?.body).toEqual([]); + }); + + it("enforces the single-body invariant when loop containers overlap", () => { + // Two overlapping containers both list the node; the first in node order + // owns it and the other sheds it (position alone cannot re-home a node + // out of its own container — its rect always wraps its members). + const loopA = { ...node("a", "loop", { x: 0, y: 0 }), body: ["inner"] as string[] }; + const loopB = { ...node("b", "loop", { x: 0, y: 0 }), body: ["inner"] as string[] }; + const inner = node("inner", "llm_call", { x: 0, y: 70 }); + const next = bucketDroppedNode([loopA, loopB, inner], "inner"); + expect(next.find((n) => n.id === "a")?.body).toContain("inner"); + expect(next.find((n) => n.id === "b")?.body).toEqual([]); + }); + + it("never buckets loop or start nodes", () => { + const loopA = { ...node("a", "loop", { x: 0, y: 0 }), body: [] as string[] }; + const loopB = { ...node("b", "loop", { x: 100, y: 0 }), body: [] as string[] }; + const start = node("s", "start", { x: 150, y: 60 }); + const next = bucketDroppedNode([loopA, loopB, start], "b"); + expect(next.find((n) => n.id === "a")?.body).toEqual([]); + expect(next.find((n) => n.id === "b")?.body).toEqual([]); + }); + + it("is pure: returns a new array and never mutates the input", () => { + const loop = { ...loopAt(), body: [] as string[] }; + const inner = node("inner", "llm_call", { x: 40, y: 80 }); + const copy = [loop, inner]; + const next = bucketDroppedNode(copy, "inner"); + expect(next).not.toBe(copy); + expect(loop.body).toEqual([]); + }); +}); + +describe("reorderLoopMember (inspector/loop body up-down)", () => { + const body = ["m1", "m2", "m3"]; + + it("moves a member one position up", () => { + const loop = { ...node("loop", "loop", { x: 0, y: 0 }), body: [...body] }; + const next = reorderLoopMember([loop], "loop", "m2", -1); + expect(next[0]?.body).toEqual(["m2", "m1", "m3"]); + }); + + it("moves a member one position down", () => { + const loop = { ...node("loop", "loop", { x: 0, y: 0 }), body: [...body] }; + const next = reorderLoopMember([loop], "loop", "m2", 1); + expect(next[0]?.body).toEqual(["m1", "m3", "m2"]); + }); + + it("is a no-op at array boundaries", () => { + const loop = { ...node("loop", "loop", { x: 0, y: 0 }), body: [...body] }; + expect(reorderLoopMember([loop], "loop", "m1", -1)[0]?.body).toEqual(body); + expect(reorderLoopMember([loop], "loop", "m3", 1)[0]?.body).toEqual(body); + }); + + it("leaves the graph untouched for unknown loop or member ids", () => { + const loop = { ...node("loop", "loop", { x: 0, y: 0 }), body: [...body] }; + expect(reorderLoopMember([loop], "ghost", "m1", -1)[0]?.body).toEqual(body); + expect(reorderLoopMember([loop], "loop", "ghost", -1)[0]?.body).toEqual(body); + }); +}); + +describe("computeFlowOrder (Ver flujo animation)", () => { + const chain = (ids: string[], extra?: Partial): GraphNode[] => + ids.map((id, i) => { + const base = node(id, (["start", "llm_call", "condition", "loop", "pipeline", "end"] as const)[i % 6]); + return { ...base, ...extra }; + }); + + it("walks a linear start→a→b→end chain with kind 'node'", () => { + const nodes = [node("start", "start"), node("a", "llm_call"), node("b", "llm_call"), node("end", "end")]; + const edges: GraphEdge[] = [ + { from: "start", to: "a" }, + { from: "a", to: "b" }, + { from: "b", to: "end" }, + ]; + expect(computeFlowOrder(nodes, edges)).toEqual([ + { nodeId: "start", kind: "node" }, + { nodeId: "a", kind: "node" }, + { nodeId: "b", kind: "node" }, + { nodeId: "end", kind: "node" }, + ]); + }); + + it("a condition follows its true-branch edge", () => { + const nodes = [node("start", "start"), node("c", "condition"), node("yes", "llm_call"), node("no", "llm_call"), node("end", "end")]; + const edges: GraphEdge[] = [ + { from: "start", to: "c" }, + { from: "c", to: "yes", guard: "true" }, + { from: "c", to: "no", guard: "false" }, + { from: "yes", to: "end" }, + ]; + const order = computeFlowOrder(nodes, edges); + expect(order.map((s) => s.nodeId)).toEqual(["start", "c", "yes", "end"]); + }); + + it("a condition without a true edge falls back to its first edge", () => { + const nodes = [node("start", "start"), node("c", "condition"), node("a", "llm_call"), node("end", "end")]; + const edges: GraphEdge[] = [ + { from: "start", to: "c" }, + { from: "c", to: "a", guard: "false" }, + { from: "a", to: "end" }, + ]; + const order = computeFlowOrder(nodes, edges); + expect(order.map((s) => s.nodeId)).toEqual(["start", "c", "a", "end"]); + }); + + it("a loop emits the loop step, then its body members, then exits", () => { + const loop = { ...node("loop", "loop"), body: ["m1", "m2"] }; + const nodes = [node("start", "start"), loop, node("m1", "llm_call"), node("m2", "llm_call"), node("end", "end")]; + const edges: GraphEdge[] = [ + { from: "start", to: "loop" }, + { from: "loop", to: "end" }, + ]; + expect(computeFlowOrder(nodes, edges)).toEqual([ + { nodeId: "start", kind: "node" }, + { nodeId: "loop", kind: "loop" }, + { nodeId: "m1", kind: "member" }, + { nodeId: "m2", kind: "member" }, + { nodeId: "end", kind: "node" }, + ]); + }); + + it("a loop with no exit edge stops after its members", () => { + const loop = { ...node("loop", "loop"), body: ["m1"] }; + const nodes = [node("start", "start"), loop, node("m1", "llm_call")]; + const edges: GraphEdge[] = [{ from: "start", to: "loop" }]; + expect(computeFlowOrder(nodes, edges)).toEqual([ + { nodeId: "start", kind: "node" }, + { nodeId: "loop", kind: "loop" }, + { nodeId: "m1", kind: "member" }, + ]); + }); + + it("stops at an end node (does not follow further edges)", () => { + const nodes = [node("start", "start"), node("end", "end"), node("tail", "llm_call")]; + const edges: GraphEdge[] = [ + { from: "start", to: "end" }, + { from: "end", to: "tail" }, + ]; + expect(computeFlowOrder(nodes, edges)).toEqual([ + { nodeId: "start", kind: "node" }, + { nodeId: "end", kind: "node" }, + ]); + }); + + it("breaks cycles via the seen guard instead of looping forever", () => { + const nodes = [node("start", "start"), node("a", "llm_call")]; + const edges: GraphEdge[] = [ + { from: "start", to: "a" }, + { from: "a", to: "start" }, + ]; + const order = computeFlowOrder(nodes, edges); + expect(order.length).toBeLessThanOrEqual(3); + expect(order[0]?.nodeId).toBe("start"); + }); + + it("returns an empty list when there is no start node", () => { + const nodes = [node("a", "llm_call"), node("end", "end")]; + const edges: GraphEdge[] = [{ from: "a", to: "end" }]; + expect(computeFlowOrder(nodes, edges)).toEqual([]); + }); +}); \ No newline at end of file diff --git a/src/ui-svelte/lib/editor-geometry.ts b/src/ui-svelte/lib/editor-geometry.ts new file mode 100644 index 0000000..f71f936 --- /dev/null +++ b/src/ui-svelte/lib/editor-geometry.ts @@ -0,0 +1,184 @@ +/** + * Editor geometry helpers (svelte-ui task 3.4, framework-free lib layer). + * + * Everything here is **pure**: coordinate conversion (client ↔ graph space), + * connection hit-testing (nearest input socket within the screen hit radius), + * loop membership re-bucketing after a drop/drag, loop-body reordering, and + * the `computeFlowOrder` walk used by the "Ver flujo" animation. No DOM, no + * stores — `Editor.svelte` composes these with the graph-model port. + */ +import { + NODE_W, + NODE_H, + socketPositions, + loopContainsPoint, + type GraphNode, + type GraphEdge, + type Point, +} from "./graph-model.js"; + +/** Zoom limits (0.2×–3×) applied by wheel/gesture zoom. */ +export const MIN_ZOOM = 0.2; +export const MAX_ZOOM = 3; + +/** Max distance (graph units) for a port-to-port connection drop. */ +export const SOCKET_HIT_RADIUS = 24; + +/** Camera state of the editor canvas: zoom and pan offsets. */ +export interface EditorView { + zoom: number; + panX: number; + panY: number; +} + +/** One step of the flow-order animation. */ +export interface FlowStep { + nodeId: string; + kind: "node" | "loop" | "member"; +} + +export function clampZoom(zoom: number): number { + return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom)); +} + +/** Map a client (viewport) point into graph coordinates. */ +export function clientToGraph( + clientX: number, + clientY: number, + rect: DOMRect, + view: EditorView, +): Point { + return { + x: (clientX - rect.left - view.panX) / view.zoom, + y: (clientY - rect.top - view.panY) / view.zoom, + }; +} + +/** Map a graph point into client (viewport) coordinates. */ +export function graphToClient(p: Point, rect: DOMRect, view: EditorView): Point { + return { + x: p.x * view.zoom + rect.left + view.panX, + y: p.y * view.zoom + rect.top + view.panY, + }; +} + +/** + * Id of the node whose input socket is nearest to `p` (graph coordinates) + * within `maxDist` (graph units), or null. Start blocks have no input port + * and are never targeted. The caller converts the screen hit radius by the + * zoom: `SOCKET_HIT_RADIUS / view.zoom`. + */ +export function nearestInputSocket( + nodes: GraphNode[], + p: Point, + maxDist: number, +): string | null { + let best: string | null = null; + let bestD = maxDist; + for (const n of nodes) { + if (n.type === "start" || !n.pos) continue; // sin puerto de entrada + const sp = socketPositions(n.pos); + const d = Math.hypot(sp.in.x - p.x, sp.in.y - p.y); + if (d <= bestD) { + best = n.id; + bestD = d; + } + } + return best; +} + +/** + * After dragging/dropping a node: if its center now falls inside a loop + * container it becomes a member of that loop's body (and leaves every other + * body); if it falls outside all containers it leaves every loop body. Pure — + * returns a new nodes array, never mutates the input. Loop and start blocks + * are never bucketed. + */ +export function bucketDroppedNode(nodes: GraphNode[], id: string): GraphNode[] { + const node = nodes.find((n) => n.id === id); + if (!node || node.type === "loop" || node.type === "start") return nodes; + const cx = (node.pos?.x ?? 0) + NODE_W / 2; + const cy = (node.pos?.y ?? 0) + NODE_H / 2; + const host = nodes.find( + (n) => n.type === "loop" && loopContainsPoint(n, nodes, { x: cx, y: cy }), + ); + const changed = nodes.some((n) => n.type === "loop" && (n.body ?? []).includes(id)); + if (!host && !changed) return nodes; + return nodes.map((n) => { + if (n.type !== "loop") return n; + let body = n.body ?? []; + if (host && n.id === host.id) { + if (!body.includes(id)) body = [...body, id]; + } else if (body.includes(id)) { + body = body.filter((b) => b !== id); + } + return body === n.body ? n : { ...n, body }; + }); +} + +/** + * Swap one loop member one position up (−1) or down (+1) in its body array, + * ignoring out-of-bounds moves. Returns a new nodes array only when something + * actually changed (callers rely on the reference to detect no-ops); pure. + */ +export function reorderLoopMember( + nodes: GraphNode[], + loopId: string, + memberId: string, + dir: -1 | 1, +): GraphNode[] { + let changed = false; + const out = nodes.map((n) => { + if (n.type !== "loop" || n.id !== loopId || !Array.isArray(n.body)) return n; + const i = n.body.indexOf(memberId); + const j = i + dir; + if (i < 0 || j < 0 || j >= n.body.length) return n; + const body = [...n.body]; + [body[i], body[j]] = [body[j], body[i]]; + changed = true; + return { ...n, body }; + }); + return changed ? out : nodes; +} + +/** + * Walk the graph the way the engine does, for the "Ver flujo" animation: start + * at the start block, follow the first available edge; a condition takes its + * true-branch edge (or falls back to the first); a loop emits a loop step + * followed by its body members in order and exits through the first edge that + * leaves the body. Never executes providers — a local preview only. Cycles + * break via a seen-guard (plus a 100-step safety). + */ +export function computeFlowOrder(nodes: GraphNode[], edges: GraphEdge[]): FlowStep[] { + const steps: FlowStep[] = []; + const seen = new Set(); + const byId = new Map(nodes.map((n) => [n.id, n])); + const nextEdges = (n: GraphNode): GraphEdge[] => { + const direct = edges.filter((e) => e.from === n.id); + if (n.type === "condition") { + const t = direct.find((e) => e.guard === "true"); + return t ? [t] : direct.slice(0, 1); + } + return direct.slice(0, 1); + }; + let cur = nodes.find((n) => n.type === "start") ?? null; + let guard = 0; + while (cur && guard++ < 100) { + if (seen.has(cur.id)) break; + seen.add(cur.id); + if (cur.type === "loop") { + steps.push({ nodeId: cur.id, kind: "loop" }); + for (const m of cur.body ?? []) steps.push({ nodeId: m, kind: "member" }); + const exit = nextEdges(cur).find((e) => !(cur.body ?? []).includes(e.to)); + if (!exit) break; + cur = byId.get(exit.to) ?? null; + continue; + } + steps.push({ nodeId: cur.id, kind: "node" }); + if (cur.type === "end") break; + const next = nextEdges(cur)[0]; + if (!next) break; + cur = byId.get(next.to) ?? null; + } + return steps; +} \ No newline at end of file diff --git a/src/ui-svelte/stores/editor-store.test.ts b/src/ui-svelte/stores/editor-store.test.ts index beb044d..93075b2 100644 --- a/src/ui-svelte/stores/editor-store.test.ts +++ b/src/ui-svelte/stores/editor-store.test.ts @@ -197,5 +197,172 @@ describe("editor store (injectable factory)", () => { expect(s.selection).toEqual([]); expect(s.pipelineId).toBeNull(); expect(s.dirty).toBe(false); + expect(s.canUndo).toBe(false); + }); +}); + +describe("editor store undo/redo (task 3.5)", () => { + it("starts with empty stacks and no undo/redo available", () => { + const store = createEditorStore(deps()); + expect(store.getSnapshot().canUndo).toBe(false); + expect(store.getSnapshot().canRedo).toBe(false); + }); + + it("records mutations and undoes/redoes them in reverse order", () => { + const store = createEditorStore(deps()); + store.actions.addNode("llm_call"); + const firstId = store.getSnapshot().nodes[0]!.id; + store.actions.addNode("end"); + + store.actions.undo(); + expect(store.getSnapshot().nodes.map((n) => n.id)).toEqual([firstId]); + expect(store.getSnapshot().canRedo).toBe(true); + + store.actions.redo(); + expect(store.getSnapshot().nodes.map((n) => n.id)).toEqual([firstId, store.getSnapshot().nodes[1]!.id]); + expect(store.getSnapshot().nodes[1]!.type).toBe("end"); + expect(store.getSnapshot().canUndo).toBe(true); + }); + + it("spec: undo restores a deleted node and its edges; redo reapplies the deletion", async () => { + const store = createEditorStore(deps()); + await store.actions.loadPipeline("demo"); + store.actions.deleteNode("n2"); + expect(store.getSnapshot().edges).toEqual([]); + + store.actions.undo(); + const restored = store.getSnapshot(); + expect(restored.nodes.some((n) => n.id === "n2")).toBe(true); + expect(restored.nodes).toHaveLength(3); + expect(restored.edges).toEqual(pipeline.edges); + + store.actions.redo(); + const reapplied = store.getSnapshot(); + expect(reapplied.nodes.some((n) => n.id === "n2")).toBe(false); + expect(reapplied.nodes).toHaveLength(2); + expect(reapplied.edges).toEqual([]); + }); + + it("undo of a move restores the previous position (one entry per drag)", async () => { + const store = createEditorStore(deps()); + await store.actions.loadPipeline("demo"); + store.actions.moveNode("n1", 400, 300); + store.actions.undo(); + const n1 = store.getSnapshot().nodes.find((n) => n.id === "n1"); + expect(n1?.pos).toBeUndefined(); + }); + + it("selection changes are NOT recorded in history", async () => { + const store = createEditorStore(deps()); + await store.actions.loadPipeline("demo"); + store.actions.select(["n2"]); + expect(store.getSnapshot().canUndo).toBe(false); + expect(store.getSnapshot().selection).toEqual(["n2"]); + }); + + it("loadPipeline and reset clear the history", async () => { + const store = createEditorStore(deps()); + store.actions.addNode("condition"); + expect(store.getSnapshot().canUndo).toBe(true); + + await store.actions.loadPipeline("demo"); + expect(store.getSnapshot().canUndo).toBe(false); + + store.actions.addNode("start"); + expect(store.getSnapshot().canUndo).toBe(true); + store.actions.reset(); + expect(store.getSnapshot().canUndo).toBe(false); + expect(store.getSnapshot().nodes).toEqual([]); + }); +}); + +describe("editor store interactions (task 3.4)", () => { + it("addNode with a position places the node there in one history entry", () => { + const store = createEditorStore(deps()); + store.actions.addNode("llm_call", { x: 320, y: 180 }); + const s = store.getSnapshot(); + expect(s.nodes[0]!.pos).toEqual({ x: 320, y: 180 }); + expect(s.dirty).toBe(true); + store.actions.undo(); + expect(store.getSnapshot().nodes).toEqual([]); + }); + + it("a drag transaction (beginMove…endMove) records ONE history entry", () => { + const store = createEditorStore(deps()); + store.actions.addNode("llm_call"); + const id = store.getSnapshot().nodes[0]!.id; + // simulate a pointer drag: begin, then many per-frame moves, then end + store.actions.beginMove(); + store.actions.moveNode(id, 10, 10); + store.actions.moveNode(id, 20, 20); + store.actions.moveNode(id, 30, 30); + store.actions.endMove(); + const moved = store.getSnapshot().nodes[0]!; + expect(moved.pos).toEqual({ x: 30, y: 30 }); + expect(store.getSnapshot().canUndo).toBe(true); + + // ONE undo → back before the drag started (no intermediate frames) + store.actions.undo(); + expect(store.getSnapshot().nodes[0]!.pos).toBeUndefined(); + expect(store.getSnapshot().canRedo).toBe(true); + + // ONE redo → the final drag position, not a mid-drag frame + store.actions.redo(); + expect(store.getSnapshot().nodes[0]!.pos).toEqual({ x: 30, y: 30 }); + }); + + it("ending a drag without moves records nothing", () => { + const store = createEditorStore(deps()); + store.actions.addNode("llm_call"); + store.actions.beginMove(); + store.actions.endMove(); + expect(store.getSnapshot().canUndo).toBe(true); // only the addNode entry + store.actions.undo(); + expect(store.getSnapshot().nodes).toEqual([]); + }); + + it("reorderLoopMember moves a member up/down and is undoable", async () => { + // load a graph whose loop already has an ordered body + const loopPipeline = { + id: "demo", + name: "Demo", + nodes: [ + { id: "loop", type: "loop" as const, body: ["m1", "m2", "m3"] }, + { id: "m1", type: "llm_call" as const, model: "a", prompt: "" }, + { id: "m2", type: "llm_call" as const, model: "b", prompt: "" }, + { id: "m3", type: "llm_call" as const, model: "c", prompt: "" }, + ], + edges: [], + }; + const store2 = createEditorStore(deps({ api: { ...deps().api, getPipeline: async () => loopPipeline } })); + await store2.actions.loadPipeline("demo"); + store2.actions.reorderLoopMember("loop", "m2", -1); + expect(store2.getSnapshot().nodes.find((n) => n.id === "loop")!.body).toEqual(["m2", "m1", "m3"]); + expect(store2.getSnapshot().dirty).toBe(true); + + store2.actions.undo(); + expect(store2.getSnapshot().nodes.find((n) => n.id === "loop")!.body).toEqual(["m1", "m2", "m3"]); + }); + + it("reorderLoopMember ignores unknown loops/members and boundary moves", async () => { + const loopPipeline = { + id: "demo", + name: "Demo", + nodes: [ + { id: "loop", type: "loop" as const, body: ["m1", "m2"] }, + { id: "m1", type: "llm_call" as const, model: "a", prompt: "" }, + { id: "m2", type: "llm_call" as const, model: "b", prompt: "" }, + ], + edges: [], + }; + const store = createEditorStore(deps({ api: { ...deps().api, getPipeline: async () => loopPipeline } })); + await store.actions.loadPipeline("demo"); + expect(store.getSnapshot().canUndo).toBe(false); + store.actions.reorderLoopMember("ghost", "m1", -1); + store.actions.reorderLoopMember("loop", "ghost", -1); + store.actions.reorderLoopMember("loop", "m1", -1); // first member: no-op up + store.actions.reorderLoopMember("loop", "m2", 1); // last member: no-op down + expect(store.getSnapshot().canUndo).toBe(false); + expect(store.getSnapshot().nodes.find((n) => n.id === "loop")!.body).toEqual(["m1", "m2"]); }); }); \ No newline at end of file diff --git a/src/ui-svelte/stores/editor-store.ts b/src/ui-svelte/stores/editor-store.ts index b72da22..6f63693 100644 --- a/src/ui-svelte/stores/editor-store.ts +++ b/src/ui-svelte/stores/editor-store.ts @@ -1,11 +1,16 @@ /** - * Editor store factory (svelte-ui task 3.1, MINOR-B). + * Editor store factory (svelte-ui task 3.1, MINOR-B; history in 3.5). * * Created via an **injectable factory** — never a module singleton — so every * test and every view instance gets a fresh store with no hidden cross-state. * All mutations delegate to the pure `graph-model.ts` port; geometry and - * connection guards never live in this module. The history layer (task 3.5) - * plugs in on top of these same actions. + * connection guards never live in this module. + * + * History (task 3.5): every graph mutation (add/move/delete/connect) flows + * through the bounded `createHistory` buffer — the store state and the + * history present are updated together, so undo/redo restore exact + * pre/post-mutation snapshots. Selection changes and load/reset never record + * history entries (load/reset clear the stacks). */ import { writable, get } from "svelte/store"; import { @@ -18,7 +23,10 @@ import { type GraphEdge, type GraphState, type NodeType, + type Point, } from "../lib/graph-model.js"; +import { reorderLoopMember as modelReorderMember } from "../lib/editor-geometry.js"; +import { createHistory } from "../lib/history.js"; /** Backend access the editor store needs (injected, faked in tests). */ export interface EditorApi { @@ -43,15 +51,22 @@ export interface EditorState extends GraphState { loading: boolean; validation: EditorValidation | null; applyError: string | null; + canUndo: boolean; + canRedo: boolean; } export interface EditorActions { loadPipeline(id: string): Promise; - addNode(type: NodeType): void; + addNode(type: NodeType, pos?: Point): void; moveNode(id: string, x: number, y: number): void; deleteNode(id: string): void; connect(from: string, to: string, guard?: string): void; + reorderLoopMember(loopId: string, memberId: string, dir: -1 | 1): void; select(ids: string[]): void; + beginMove(): void; + endMove(): void; + undo(): void; + redo(): void; validate(): Promise; apply(): Promise; reset(): void; @@ -77,6 +92,8 @@ const INITIAL: EditorState = { loading: false, validation: null, applyError: null, + canUndo: false, + canRedo: false, }; /** Monotonic id source for freshly added nodes (per-store counter not needed: @@ -85,14 +102,40 @@ let nodeCounter = 0; export function createEditorStore(deps: EditorDeps): EditorStore { const store = writable({ ...INITIAL }); + const history = createHistory({ ...INITIAL }); + /** Open drag transaction: per-frame moveNode calls patch without recording; + * endMove commits ONE history entry so a whole drag undoes as a unit. */ + let dragOpen = false; + let dragMoved = false; + + /** Commit `next` through the history buffer so undo/redo can restore it. + * The store state and the history present always move together. */ + function commit(next: EditorState): void { + history.push(next); + store.set({ ...next, canUndo: history.canUndo, canRedo: history.canRedo }); + } + + /** Apply a pure mutation over the current state through the history. The + * model returns the same array reference for no-ops (unknown ids, boundary + * moves, self-edges), so those never record a history entry. */ + function mutate(fn: (s: EditorState) => EditorState): void { + const current = get(store); + const next = fn(current); + if (next.nodes === current.nodes && next.edges === current.edges) return; + commit(next); + } + + /** Non-recorded state change (selection, validation, load flags). */ + function patch(fn: (s: EditorState) => EditorState): void { + store.update((s) => ({ ...fn(s), canUndo: history.canUndo, canRedo: history.canRedo })); + } const actions: EditorActions = { async loadPipeline(id) { store.update((s) => ({ ...s, loading: true })); try { const detail = await deps.api.getPipeline(id); - store.update((s) => ({ - ...s, + const loaded: EditorState = { pipelineId: detail.id, name: detail.name ?? null, nodes: detail.nodes, @@ -101,24 +144,38 @@ export function createEditorStore(deps: EditorDeps): EditorStore { dirty: false, validation: null, applyError: null, - })); + canUndo: false, + canRedo: false, + }; + history.clear(loaded); + store.set(loaded); } finally { store.update((s) => ({ ...s, loading: false })); } }, - addNode(type) { + addNode(type, pos) { const id = `node-${++nodeCounter}`; - store.update((s) => ({ + const fresh = createNode(type, id); + mutate((s) => ({ ...s, - nodes: [...s.nodes, createNode(type, id)], + nodes: [...s.nodes, pos ? { ...fresh, pos: { ...pos } } : fresh], selection: [id], dirty: true, })); }, moveNode(id, x, y) { - store.update((s) => ({ + if (dragOpen) { + dragMoved = true; + patch((s) => ({ + ...s, + nodes: modelMoveNode(s.nodes, id, x, y), + dirty: true, + })); + return; + } + mutate((s) => ({ ...s, nodes: modelMoveNode(s.nodes, id, x, y), dirty: true, @@ -126,7 +183,7 @@ export function createEditorStore(deps: EditorDeps): EditorStore { }, deleteNode(id) { - store.update((s) => { + mutate((s) => { const { nodes, edges } = modelDeleteNode(s.nodes, s.edges, id); return { ...s, @@ -139,38 +196,68 @@ export function createEditorStore(deps: EditorDeps): EditorStore { }, connect(from, to, guard) { - store.update((s) => ({ + mutate((s) => ({ ...s, edges: connectNodes(s.edges, from, to, guard), dirty: true, })); }, + reorderLoopMember(loopId, memberId, dir) { + mutate((s) => ({ + ...s, + nodes: modelReorderMember(s.nodes, loopId, memberId, dir), + dirty: true, + })); + }, + + beginMove() { + dragOpen = true; + dragMoved = false; + }, + + endMove() { + if (!dragOpen) return; + dragOpen = false; + if (dragMoved) commit(get(store)); + }, + select(ids) { - store.update((s) => ({ ...s, selection: [...ids] })); + patch((s) => ({ ...s, selection: [...ids] })); + }, + + undo() { + const previous = history.undo(); + if (previous !== null) store.set({ ...previous, canUndo: history.canUndo, canRedo: history.canRedo }); + }, + + redo() { + const next = history.redo(); + if (next !== null) store.set({ ...next, canUndo: history.canUndo, canRedo: history.canRedo }); }, async validate() { const s = get(store); const payload = buildPayload({ nodes: s.nodes, edges: s.edges }); const result = await deps.api.validate(payload); - store.update((prev) => ({ ...prev, validation: result })); + patch((prev) => ({ ...prev, validation: result })); }, async apply() { try { const result = await deps.api.apply(); - store.update((s) => ({ ...s, applyError: null })); + patch((s) => ({ ...s, applyError: null })); return result; } catch (err) { const message = err instanceof Error ? err.message : String(err); - store.update((s) => ({ ...s, applyError: message })); + patch((s) => ({ ...s, applyError: message })); return null; } }, reset() { - store.update(() => ({ ...INITIAL })); + history.clear(); + store.update(() => ({ ...INITIAL, canUndo: false, canRedo: false })); }, }; From 4e7c6b04a2942df04aa19f6a96081cad78196454 Mon Sep 17 00:00:00 2001 From: AndyTechnologies Date: Sun, 6 Sep 2026 15:49:44 -0400 Subject: [PATCH 6/9] feat(ui-svelte): bounded undo/redo history buffer (task 3.5) --- src/ui-svelte/lib/history.test.ts | 99 +++++++++++++++++++++++++++++++ src/ui-svelte/lib/history.ts | 83 ++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 src/ui-svelte/lib/history.test.ts create mode 100644 src/ui-svelte/lib/history.ts diff --git a/src/ui-svelte/lib/history.test.ts b/src/ui-svelte/lib/history.test.ts new file mode 100644 index 0000000..efe6a29 --- /dev/null +++ b/src/ui-svelte/lib/history.test.ts @@ -0,0 +1,99 @@ +/** + * Bounded undo/redo history tests (svelte-ui task 3.5, RED). + * + * The spec scenario "Undo restores, redo reapplies" is exercised with real + * graph-model shapes: deleting a node pushes the post-delete state, undo + * restores the node + its edges, redo reapplies the deletion. + */ +import { describe, it, expect } from "bun:test"; +import { createHistory, HISTORY_CAP } from "./history.js"; +import type { GraphNode, GraphEdge } from "./graph-model.js"; + +// Real graph-model shapes for the spec scenario. +const NODE_A: GraphNode = { id: "a", type: "start" }; +const NODE_B: GraphNode = { id: "b", type: "llm_call" }; +const EDGE: GraphEdge = { from: "a", to: "b" }; + +interface GraphFixture { + nodes: GraphNode[]; + edges: GraphEdge[]; +} + +describe("history (task 3.5)", () => { + it("undo walks back through pushed states in LIFO order", () => { + const h = createHistory(0); + h.push(1); + h.push(2); + h.push(3); + expect(h.canUndo).toBe(true); + expect(h.undo()).toBe(2); + expect(h.undo()).toBe(1); + expect(h.undo()).toBe(0); + expect(h.undo()).toBeNull(); + expect(h.canUndo).toBe(false); + }); + + it("redo re-applies states that were undone, in order", () => { + const h = createHistory(0); + h.push(1); + h.push(2); + expect(h.undo()).toBe(1); + expect(h.canRedo).toBe(true); + expect(h.redo()).toBe(2); + expect(h.redo()).toBeNull(); + expect(h.canRedo).toBe(false); + }); + + it("pushing after an undo clears the redo future", () => { + const h = createHistory(0); + h.push(1); + h.push(2); + h.undo(); + h.push(9); + expect(h.canRedo).toBe(false); + expect(h.redo()).toBeNull(); + expect(h.undo()).toBe(1); + }); + + it("bounded: only HISTORY_CAP past states survive, oldest dropped", () => { + const h = createHistory(0); + for (let i = 1; i <= HISTORY_CAP + 5; i++) h.push(i); + expect(h.canUndo).toBe(true); + // Only the last HISTORY_CAP pushes are recoverable: undoing walks + // present back through 104..5; the four oldest states are dropped. + const recovered: number[] = []; + let next: number | null; + while ((next = h.undo()) !== null) recovered.push(next); + expect(recovered).toHaveLength(HISTORY_CAP); + expect(recovered[0]).toBe(104); + expect(recovered[recovered.length - 1]).toBe(5); + }); + + it("spec: undo restores a deleted node and its edges, redo reapplies the deletion", () => { + const h = createHistory({ nodes: [], edges: [] }); + const withNode: GraphFixture = { nodes: [NODE_A, NODE_B], edges: [EDGE] }; + const afterDelete: GraphFixture = { nodes: [NODE_A], edges: [] }; + h.push(withNode); + h.push(afterDelete); + + const restored = h.undo(); + expect(restored?.nodes).toEqual([NODE_A, NODE_B]); + expect(restored?.edges).toEqual([EDGE]); + + const reapplied = h.redo(); + expect(reapplied?.nodes).toEqual([NODE_A]); + expect(reapplied?.edges).toEqual([]); + }); + + it("clear resets present state and drops both stacks", () => { + const h = createHistory(0); + h.push(1); + h.push(2); + h.undo(); + h.clear(); + expect(h.canUndo).toBe(false); + expect(h.canRedo).toBe(false); + expect(h.undo()).toBeNull(); + expect(h.redo()).toBeNull(); + }); +}); \ No newline at end of file diff --git a/src/ui-svelte/lib/history.ts b/src/ui-svelte/lib/history.ts new file mode 100644 index 0000000..9fd1dbb --- /dev/null +++ b/src/ui-svelte/lib/history.ts @@ -0,0 +1,83 @@ +/** + * Bounded undo/redo history (svelte-ui task 3.5). + * + * Framework-free. Each graph mutation pushes the resulting state; undo/redo + * walk past/future stacks. The history is bounded: only the most recent + * HISTORY_CAP past states are kept, so long editing sessions cannot grow + * without limit. The editor store (task 3.4) feeds snapshots on every + * mutation (add/move/delete/connect/reorder) and restores them on undo/redo. + */ + +/** Maximum number of past states retained by default. */ +export const HISTORY_CAP = 100; + +export interface History { + /** Current state — the result of the last push or of undo/redo. */ + readonly present: T; + /** True when at least one undo step is available. */ + readonly canUndo: boolean; + /** True when at least one redo step is available. */ + readonly canRedo: boolean; + /** Record `next` as the new present; drops any redo future. */ + push(next: T): void; + /** Move back one state; returns the previous present, or null at the start. */ + undo(): T | null; + /** Move forward one state; returns the reapplied present, or null when + * there is nothing to redo. */ + redo(): T | null; + /** Reset to `initial` and drop both stacks. */ + clear(): void; + /** Replace the current present (clearing stacks) so the history + * re-anchors at the given state (used after loadPipeline/reset). */ + clear(nextPresent: T): void; +} + +export function createHistory(initial: T, cap: number = HISTORY_CAP): History { + const past: T[] = []; + const future: T[] = []; + let present = initial; + + function trim(): void { + if (past.length > cap) past.splice(0, past.length - cap); + } + + return { + get present() { + return present; + }, + get canUndo() { + return past.length > 0; + }, + get canRedo() { + return future.length > 0; + }, + push(next: T): void { + past.push(present); + trim(); + present = next; + future.length = 0; + }, + undo(): T | null { + const previous = past.pop(); + if (previous === undefined) return null; + future.push(present); + present = previous; + return present; + }, + redo(): T | null { + const next = future.pop(); + if (next === undefined) return null; + past.push(present); + trim(); + present = next; + return present; + }, + /** Reset history stacks. When `nextPresent` is provided it also replaces + * the current present (used by load/reset to re-anchor the history). */ + clear(nextPresent?: T): void { + past.length = 0; + future.length = 0; + present = nextPresent !== undefined ? nextPresent : initial; + }, + }; +} \ No newline at end of file From ef6cf73716c541d7c485f265f05b22c6a2a973ee Mon Sep 17 00:00:00 2001 From: AndyTechnologies Date: Sun, 6 Sep 2026 16:05:00 -0400 Subject: [PATCH 7/9] feat(ui-svelte): loop bucketing for palette drops and drag end (task 3.4) --- src/ui-svelte/lib/editor-geometry.ts | 21 ++++--- src/ui-svelte/stores/editor-store.test.ts | 67 +++++++++++++++++++++++ src/ui-svelte/stores/editor-store.ts | 36 +++++++++--- 3 files changed, 109 insertions(+), 15 deletions(-) diff --git a/src/ui-svelte/lib/editor-geometry.ts b/src/ui-svelte/lib/editor-geometry.ts index f71f936..3c78972 100644 --- a/src/ui-svelte/lib/editor-geometry.ts +++ b/src/ui-svelte/lib/editor-geometry.ts @@ -102,18 +102,25 @@ export function bucketDroppedNode(nodes: GraphNode[], id: string): GraphNode[] { const host = nodes.find( (n) => n.type === "loop" && loopContainsPoint(n, nodes, { x: cx, y: cy }), ); - const changed = nodes.some((n) => n.type === "loop" && (n.body ?? []).includes(id)); - if (!host && !changed) return nodes; - return nodes.map((n) => { + let changed = false; + const out = nodes.map((n) => { if (n.type !== "loop") return n; - let body = n.body ?? []; + const body = n.body ?? []; + let next = body; if (host && n.id === host.id) { - if (!body.includes(id)) body = [...body, id]; + if (!body.includes(id)) { + next = [...body, id]; + changed = true; + } } else if (body.includes(id)) { - body = body.filter((b) => b !== id); + next = body.filter((b) => b !== id); + changed = true; } - return body === n.body ? n : { ...n, body }; + return next === body ? n : { ...n, body: next }; }); + // Return the input reference for no-ops (callers rely on it to detect + // unchanged graphs and skip history entries). + return changed ? out : nodes; } /** diff --git a/src/ui-svelte/stores/editor-store.test.ts b/src/ui-svelte/stores/editor-store.test.ts index 93075b2..7fc2676 100644 --- a/src/ui-svelte/stores/editor-store.test.ts +++ b/src/ui-svelte/stores/editor-store.test.ts @@ -344,6 +344,73 @@ describe("editor store interactions (task 3.4)", () => { expect(store2.getSnapshot().nodes.find((n) => n.id === "loop")!.body).toEqual(["m1", "m2", "m3"]); }); + /** A loop at (0,0) with an empty body: its container covers + * x∈[−18,98], y∈[0,110], so a node added/moved to (40,40) — center + * (80,68) — lands inside and must be bucketed into the body. */ + const loopPipeline = { + id: "demo", + name: "Demo", + nodes: [{ id: "loop", type: "loop" as const, pos: { x: 0, y: 0 }, body: [] }], + edges: [], + }; + + it("buckets a positioned addNode that lands inside a loop container", async () => { + const store = createEditorStore(deps({ api: { ...deps().api, getPipeline: async () => loopPipeline } })); + await store.actions.loadPipeline("demo"); + store.actions.addNode("llm_call", { x: 40, y: 40 }); + const s = store.getSnapshot(); + const added = s.nodes.find((n) => n.type === "llm_call")!; + expect(s.nodes.find((n) => n.id === "loop")!.body).toEqual([added.id]); + + // one undo removes the node and the membership together + store.actions.undo(); + expect(store.getSnapshot().nodes).toHaveLength(1); + expect(store.getSnapshot().nodes[0]!.id).toBe("loop"); + }); + + it("buckets a drag that ends inside a loop container as ONE history entry", async () => { + const withCall = { + ...loopPipeline, + nodes: [ + { id: "loop", type: "loop" as const, pos: { x: 0, y: 0 }, body: [] }, + { id: "n1", type: "llm_call" as const, pos: { x: 400, y: 400 } }, + ], + }; + const store = createEditorStore(deps({ api: { ...deps().api, getPipeline: async () => withCall } })); + await store.actions.loadPipeline("demo"); + store.actions.beginMove(); + store.actions.moveNode("n1", 40, 40); // center (80,68) inside the loop + store.actions.endMove(); + const s = store.getSnapshot(); + expect(s.nodes.find((n) => n.id === "loop")!.body).toEqual(["n1"]); + expect(s.canUndo).toBe(true); + + store.actions.undo(); + const after = store.getSnapshot(); + expect(after.nodes.find((n) => n.id === "loop")!.body).toEqual([]); + expect(after.nodes.find((n) => n.id === "n1")!.pos).toEqual({ x: 400, y: 400 }); + }); + + it("keeps a member in its loop body when dragged far away (no re-bucket)", async () => { + const withMember = { + ...loopPipeline, + nodes: [ + { id: "loop", type: "loop" as const, pos: { x: 0, y: 0 }, body: ["n1"] }, + { id: "n1", type: "llm_call" as const, pos: { x: 40, y: 40 } }, + ], + }; + const store = createEditorStore(deps({ api: { ...deps().api, getPipeline: async () => withMember } })); + await store.actions.loadPipeline("demo"); + store.actions.beginMove(); + store.actions.moveNode("n1", 2000, 2000); // far outside any other container + store.actions.endMove(); + expect(store.getSnapshot().nodes.find((n) => n.id === "loop")!.body).toEqual(["n1"]); + + // the move itself must still be undoable as a single entry + store.actions.undo(); + expect(store.getSnapshot().nodes.find((n) => n.id === "n1")!.pos).toEqual({ x: 40, y: 40 }); + }); + it("reorderLoopMember ignores unknown loops/members and boundary moves", async () => { const loopPipeline = { id: "demo", diff --git a/src/ui-svelte/stores/editor-store.ts b/src/ui-svelte/stores/editor-store.ts index 6f63693..8b6ef61 100644 --- a/src/ui-svelte/stores/editor-store.ts +++ b/src/ui-svelte/stores/editor-store.ts @@ -25,7 +25,7 @@ import { type NodeType, type Point, } from "../lib/graph-model.js"; -import { reorderLoopMember as modelReorderMember } from "../lib/editor-geometry.js"; +import { reorderLoopMember as modelReorderMember, bucketDroppedNode } from "../lib/editor-geometry.js"; import { createHistory } from "../lib/history.js"; /** Backend access the editor store needs (injected, faked in tests). */ @@ -107,6 +107,9 @@ export function createEditorStore(deps: EditorDeps): EditorStore { * endMove commits ONE history entry so a whole drag undoes as a unit. */ let dragOpen = false; let dragMoved = false; + /** Node being dragged; endMove buckets it into whatever loop container its + * final position lands in (legacy bucketDroppedNode semantics). */ + let dragId: string | null = null; /** Commit `next` through the history buffer so undo/redo can restore it. * The store state and the history present always move together. */ @@ -157,17 +160,23 @@ export function createEditorStore(deps: EditorDeps): EditorStore { addNode(type, pos) { const id = `node-${++nodeCounter}`; const fresh = createNode(type, id); - mutate((s) => ({ - ...s, - nodes: [...s.nodes, pos ? { ...fresh, pos: { ...pos } } : fresh], - selection: [id], - dirty: true, - })); + mutate((s) => { + const withNode = [...s.nodes, pos ? { ...fresh, pos: { ...pos } } : fresh]; + return { + ...s, + // a palette drop carries a position; if that position lands inside + // a loop container the new node joins its body + nodes: pos ? bucketDroppedNode(withNode, id) : withNode, + selection: [id], + dirty: true, + }; + }); }, moveNode(id, x, y) { if (dragOpen) { dragMoved = true; + dragId = id; patch((s) => ({ ...s, nodes: modelMoveNode(s.nodes, id, x, y), @@ -214,12 +223,23 @@ export function createEditorStore(deps: EditorDeps): EditorStore { beginMove() { dragOpen = true; dragMoved = false; + dragId = null; }, endMove() { if (!dragOpen) return; dragOpen = false; - if (dragMoved) commit(get(store)); + if (!dragMoved) return; + const final = get(store); + // Bucket the dragged node by its final position: if it now lands inside + // a loop container it joins that body (or sheds any accidental one); + // a no-op returns the same array reference and is skipped. + let next = final; + if (dragId !== null) { + const bucketed = bucketDroppedNode(final.nodes, dragId); + if (bucketed !== final.nodes) next = { ...final, nodes: bucketed }; + } + commit(next); }, select(ids) { From 262c021341553a5f33e95cb76e8aa693ce89f672 Mon Sep 17 00:00:00 2001 From: AndyTechnologies Date: Sun, 6 Sep 2026 17:50:34 -0400 Subject: [PATCH 8/9] feat(ui-svelte): complete editor canvas interactions Drag-to-move with move/select/delete/connect/flow/zoom interactions wired to the history store; pointer-event polyfill and jsdom fallbacks so the component tests run headless. Undo/redo buttons get explicit handlers (inline), rename applies without recording a history entry, and flow walk fixes TS narrowing for loop-body exits. Part of svelte-ui task 3.4 (editor geometry + interactions) and 3.7 (interaction tests). --- src/ui-svelte/lib/editor-geometry.test.ts | 10 +- src/ui-svelte/lib/editor-geometry.ts | 7 +- src/ui-svelte/stores/editor-store.test.ts | 7 + src/ui-svelte/stores/editor-store.ts | 8 + src/ui-svelte/test-setup/dom.ts | 26 + .../views/Editor.interactions.test.ts | 325 +++++++++ src/ui-svelte/views/Editor.svelte | 621 +++++++++++++++++- 7 files changed, 980 insertions(+), 24 deletions(-) create mode 100644 src/ui-svelte/views/Editor.interactions.test.ts diff --git a/src/ui-svelte/lib/editor-geometry.test.ts b/src/ui-svelte/lib/editor-geometry.test.ts index 5540684..6f021c6 100644 --- a/src/ui-svelte/lib/editor-geometry.test.ts +++ b/src/ui-svelte/lib/editor-geometry.test.ts @@ -22,7 +22,6 @@ import { reorderLoopMember, computeFlowOrder, type EditorView, - type FlowStep, } from "./editor-geometry.js"; function node(id: string, type: GraphNode["type"], pos?: Point): GraphNode { @@ -114,8 +113,7 @@ describe("nearestInputSocket (24px screen hit radius)", () => { describe("bucketDroppedNode (loop membership by position)", () => { // loop at (0,0) with one member at (0, 70) → body rect spans the header // and stack; a node dropped at (40, 200) lands inside the container. - const loopAt = (body: string[] = []): GraphNode => - node("loop", "loop", { x: 0, y: 0 }) as GraphNode & { body?: string[] }; + const loopAt = (): GraphNode => node("loop", "loop", { x: 0, y: 0 }) as GraphNode & { body?: string[] }; it("adds a node whose center falls inside a loop container to its body", () => { const loop = { ...loopAt(), body: [] as string[] }; @@ -192,12 +190,6 @@ describe("reorderLoopMember (inspector/loop body up-down)", () => { }); describe("computeFlowOrder (Ver flujo animation)", () => { - const chain = (ids: string[], extra?: Partial): GraphNode[] => - ids.map((id, i) => { - const base = node(id, (["start", "llm_call", "condition", "loop", "pipeline", "end"] as const)[i % 6]); - return { ...base, ...extra }; - }); - it("walks a linear start→a→b→end chain with kind 'node'", () => { const nodes = [node("start", "start"), node("a", "llm_call"), node("b", "llm_call"), node("end", "end")]; const edges: GraphEdge[] = [ diff --git a/src/ui-svelte/lib/editor-geometry.ts b/src/ui-svelte/lib/editor-geometry.ts index 3c78972..49aa296 100644 --- a/src/ui-svelte/lib/editor-geometry.ts +++ b/src/ui-svelte/lib/editor-geometry.ts @@ -174,9 +174,10 @@ export function computeFlowOrder(nodes: GraphNode[], edges: GraphEdge[]): FlowSt if (seen.has(cur.id)) break; seen.add(cur.id); if (cur.type === "loop") { - steps.push({ nodeId: cur.id, kind: "loop" }); - for (const m of cur.body ?? []) steps.push({ nodeId: m, kind: "member" }); - const exit = nextEdges(cur).find((e) => !(cur.body ?? []).includes(e.to)); + const loopNode = cur; + steps.push({ nodeId: loopNode.id, kind: "loop" }); + for (const m of loopNode.body ?? []) steps.push({ nodeId: m, kind: "member" }); + const exit = nextEdges(loopNode).find((e) => !(loopNode.body ?? []).includes(e.to)); if (!exit) break; cur = byId.get(exit.to) ?? null; continue; diff --git a/src/ui-svelte/stores/editor-store.test.ts b/src/ui-svelte/stores/editor-store.test.ts index 7fc2676..05be603 100644 --- a/src/ui-svelte/stores/editor-store.test.ts +++ b/src/ui-svelte/stores/editor-store.test.ts @@ -411,6 +411,13 @@ describe("editor store interactions (task 3.4)", () => { expect(store.getSnapshot().nodes.find((n) => n.id === "n1")!.pos).toEqual({ x: 40, y: 40 }); }); + it("rename updates the name without recording a history entry", async () => { + const store = createEditorStore(deps()); + store.actions.rename("Mi pipeline"); + expect(store.getSnapshot().name).toBe("Mi pipeline"); + expect(store.getSnapshot().canUndo).toBe(false); + }); + it("reorderLoopMember ignores unknown loops/members and boundary moves", async () => { const loopPipeline = { id: "demo", diff --git a/src/ui-svelte/stores/editor-store.ts b/src/ui-svelte/stores/editor-store.ts index 8b6ef61..ddca2ca 100644 --- a/src/ui-svelte/stores/editor-store.ts +++ b/src/ui-svelte/stores/editor-store.ts @@ -62,6 +62,7 @@ export interface EditorActions { deleteNode(id: string): void; connect(from: string, to: string, guard?: string): void; reorderLoopMember(loopId: string, memberId: string, dir: -1 | 1): void; + rename(name: string | null): void; select(ids: string[]): void; beginMove(): void; endMove(): void; @@ -145,6 +146,7 @@ export function createEditorStore(deps: EditorDeps): EditorStore { edges: detail.edges, selection: [], dirty: false, + loading: false, validation: null, applyError: null, canUndo: false, @@ -220,6 +222,12 @@ export function createEditorStore(deps: EditorDeps): EditorStore { })); }, + /** Rename the pipeline (editor credential, not a graph mutation: naming + * never records a history entry). */ + rename(name) { + patch((s) => ({ ...s, name })); + }, + beginMove() { dragOpen = true; dragMoved = false; diff --git a/src/ui-svelte/test-setup/dom.ts b/src/ui-svelte/test-setup/dom.ts index 10d472c..d0b3aa7 100644 --- a/src/ui-svelte/test-setup/dom.ts +++ b/src/ui-svelte/test-setup/dom.ts @@ -61,6 +61,32 @@ if (typeof HTMLDialogElement !== "undefined") { } } +// jsdom does not implement PointerEvent or Element#setPointerCapture; the +// editor canvas interacts through pointer events (drag/connect/pan), so the +// tests need a MouseEvent-based stand-in. Real browsers are unaffected. +if (typeof PointerEvent === "undefined") { + class PointerEventPolyfill extends MouseEvent { + readonly pointerId: number; + readonly pointerType: string; + readonly isPrimary: boolean; + constructor(type: string, init: PointerEventInit = {}) { + super(type, init); + this.pointerId = init.pointerId ?? 0; + this.pointerType = init.pointerType ?? "mouse"; + this.isPrimary = init.isPrimary ?? true; + } + } + g.PointerEvent = PointerEventPolyfill; +} +if (typeof Element !== "undefined" && !Element.prototype.setPointerCapture) { + Element.prototype.setPointerCapture = function (this: Element, _pointerId: number): void { + // no-op: jsdom has no pointer-capture model; handlers live on window + }; + Element.prototype.releasePointerCapture = function (this: Element): void { + // no-op + }; +} + // cleanup is imported LAZILY (dynamic) — importing @testing-library/svelte // here at top level would evaluate testing-library's raw `.svelte.js` runes // modules BEFORE the svelte-loader preload registers its compiler plugin, diff --git a/src/ui-svelte/views/Editor.interactions.test.ts b/src/ui-svelte/views/Editor.interactions.test.ts new file mode 100644 index 0000000..60bee27 --- /dev/null +++ b/src/ui-svelte/views/Editor.interactions.test.ts @@ -0,0 +1,325 @@ +/** + * RED→GREEN component tests for the editor interaction set (svelte-ui task + * 3.4): declarative SVG graph, palette drag/click, keys 1–6, Delete, select, + * node drag with a single history entry, 24 px socket connect with self-edge + * rejection, loop containers (bucket, add-block, member reorder), carousel + * and the Ver flujo flow animation. + * + * jsdom gives every element a zero getBoundingClientRect, so with the default + * view (zoom 1, pan 0) client coordinates equal graph coordinates — synthetic + * pointer/wheel events can target known socket positions directly. Pointer + * events are provided by the test-setup polyfill (dom.ts). + */ +import { describe, it, expect, afterEach } from "bun:test"; +import { render, fireEvent, waitFor } from "@testing-library/svelte"; +import Editor from "./Editor.svelte"; +import { makeEditorStore } from "../test-setup/fakes.js"; +import type { GraphNode } from "../lib/graph-model.js"; + +/** DataTransfer stand-in: jsdom does not implement the HTML5 DnD API. */ +class MemDataTransfer { + private data = new Map(); + setData(k: string, v: string): void { + this.data.set(k, v); + } + getData(k: string): string { + return this.data.get(k) ?? ""; + } +} + +/** + * jsdom's fireEvent drops the coordinate init for pointer events (the browser + * API it maps to ignores unknown fields), so pointer interactions dispatch + * native events through the test-setup PointerEvent polyfill instead. + */ +function pointer(el: EventTarget, type: "pointerdown" | "pointermove" | "pointerup", x: number, y: number, id = 1): void { + const ev = new PointerEvent(type, { + bubbles: true, + cancelable: true, + clientX: x, + clientY: y, + pointerId: id, + buttons: type === "pointerup" ? 0 : 1, + }); + el.dispatchEvent(ev); +} + +/** Render the editor with a pre-loaded pipeline (await load BEFORE render + * keeps the first paint deterministic). */ +async function renderWith(nodes: GraphNode[], edges: { from: string; to: string; guard?: string }[] = []) { + const store = makeEditorStore({ + pipeline: { id: "p1", name: "Demo", nodes, edges } as never, + }); + await store.actions.loadPipeline("p1"); + const view = render(Editor, { props: { store } }); + return { store, ...view }; +} + +const start = { id: "start", type: "start" as const, pos: { x: 40, y: 40 } }; +const call = { id: "call", type: "llm_call" as const, pos: { x: 400, y: 40 }, model: "qwen:7b", prompt: "" }; +const endUsed = { id: "end", type: "end" as const, pos: { x: 760, y: 40 } }; + +describe("Editor interactions (task 3.4)", () => { + afterEach(() => { + // the reduced-motion test stubs matchMedia; restore the original + (window as { matchMedia?: unknown }).matchMedia = globalThis.matchMedia as unknown as typeof window.matchMedia; + }); + + it("renders loaded nodes as SVG graph nodes with ports", async () => { + const { container } = await renderWith([start, call]); + const svg = container.querySelector("#graph-svg"); + expect(svg).toBeTruthy(); + expect(svg!.querySelectorAll('.graph-node[data-type="start"]')).toHaveLength(1); + expect(svg!.querySelectorAll('.graph-node[data-type="llm_call"]')).toHaveLength(1); + const node = svg!.querySelector('.graph-node[data-type="start"]')!; + expect(node.querySelector(".port--output")).toBeTruthy(); + expect(node.querySelector(".port--input")).toBeTruthy(); + }); + + it("renders conditional outputs with their guards", async () => { + const cond = { id: "c", type: "condition" as const, pos: { x: 40, y: 200 } }; + const { container } = await renderWith([cond]); + const outputs = container.querySelectorAll('.graph-node[data-type="condition"] .port--output'); + expect(outputs).toHaveLength(2); + expect(outputs[0]!.getAttribute("data-guard")).toBe("true"); + expect(outputs[1]!.getAttribute("data-guard")).toBe("false"); + }); + + it("renders edges as bezier paths", async () => { + const { container } = await renderWith([start, call], [{ from: "start", to: "call" }]); + const edges = container.querySelectorAll("#graph-svg .graph-edge"); + expect(edges).toHaveLength(1); + expect(edges[0]!.getAttribute("d")).toContain("C"); + }); + + it("adds nodes with the numeric keys 1-6", async () => { + const store = makeEditorStore(); + const { getByTestId } = render(Editor, { props: { store } }); + const canvas = getByTestId("graph-canvas"); + fireEvent.keyDown(canvas, { key: "2" }); + fireEvent.keyDown(canvas, { key: "4" }); + await waitFor(() => expect(store.getSnapshot().nodes.map((n) => n.type)).toEqual(["llm_call", "loop"])); + }); + + it("deletes the selected node on Delete", async () => { + const { store, getByTestId, container } = await renderWith([start]); + const canvas = getByTestId("graph-canvas"); + expect(store.getSnapshot().nodes).toHaveLength(1); + const node = container.querySelector('.graph-node[data-type="start"]')!; + pointer(node, "pointerdown", 100, 80); + pointer(window, "pointerup", 100, 80); + await waitFor(() => expect(store.getSnapshot().selection).toEqual(["start"])); + fireEvent.keyDown(canvas, { key: "Delete" }); + await waitFor(() => expect(store.getSnapshot().nodes).toHaveLength(0)); + }); + + it("selects a node when clicked and clears on background", async () => { + const { store, container } = await renderWith([start, call]); + const node = container.querySelector('.graph-node[data-type="start"]')!; + pointer(node, "pointerdown", 100, 80); + pointer(window, "pointerup", 100, 80); + await waitFor(() => expect(store.getSnapshot().selection).toEqual(["start"])); + + const bg = container.querySelector('[data-testid="canvas-background"]')!; + pointer(bg, "pointerdown", 5, 5, 2); + pointer(window, "pointerup", 5, 5, 2); + await waitFor(() => expect(store.getSnapshot().selection).toEqual([])); + }); + + it("drops a palette item onto the canvas to insert a node at that position", async () => { + const { store, getAllByTestId, getByTestId } = await renderWith([]); + const dt = new MemDataTransfer(); + const item = getAllByTestId("palette-item").find( + (el) => el.getAttribute("data-node-type") === "llm_call", + )!; + fireEvent.dragStart(item, { dataTransfer: dt }); + // jsdom zeroes the drop's clientX/clientY; the component falls back to + // the last pointer position, so move the pointer to the drop target first + // (a browser always delivers drag moves before the drop). + pointer(window, "pointermove", 300, 200); + fireEvent.drop(getByTestId("graph-canvas"), { dataTransfer: dt, clientX: 300, clientY: 200 }); + await waitFor(() => expect(store.getSnapshot().nodes).toHaveLength(1)); + expect(store.getSnapshot().nodes[0]!.type).toBe("llm_call"); + expect(store.getSnapshot().nodes[0]!.pos).toEqual({ x: 300, y: 200 }); + }); + + it("connects output→input ports within the 24 px hit radius", async () => { + const { store, container } = await renderWith([start, call]); + const out = container.querySelector('.graph-node[data-type="start"] .port--output')!; + // start out socket = (200, 68); move to call in socket (400, 68) + pointer(out, "pointerdown", 200, 68); + pointer(window, "pointermove", 300, 68); + pointer(window, "pointerup", 400, 68); + await waitFor(() => expect(store.getSnapshot().edges).toHaveLength(1)); + expect(store.getSnapshot().edges[0]).toMatchObject({ from: "start", to: "call" }); + }); + + it("rejects a self-loop connection without invalid styling", async () => { + const { store, container } = await renderWith([start, call]); + const out = container.querySelector('.graph-node[data-type="start"] .port--output')!; + const input = container.querySelector('.graph-node[data-type="start"] .port--input')!; + pointer(out, "pointerdown", 200, 68); + pointer(window, "pointermove", 100, 68); + expect(input).toBeTruthy(); // own input socket exists + pointer(window, "pointerup", 40, 68); // own input (40, 68) + await waitFor(() => expect(store.getSnapshot().edges).toHaveLength(0)); + expect(container.querySelectorAll("#graph-svg .graph-edge")).toHaveLength(0); + expect(container.querySelectorAll(".graph-node.edge-invalid")).toHaveLength(0); + }); + + it("connects a condition's false branch with its guard", async () => { + const cond = { id: "c", type: "condition" as const, pos: { x: 40, y: 200 } }; + const { store, container } = await renderWith([cond, call]); + const outFalse = container.querySelector('.port--output[data-guard="false"]')!; + pointer(outFalse, "pointerdown", 200, 228); + pointer(window, "pointerup", 400, 68); + await waitFor(() => expect(store.getSnapshot().edges).toHaveLength(1)); + expect(store.getSnapshot().edges[0]).toMatchObject({ from: "c", to: "call", guard: "false" }); + }); + + it("drags a node as ONE undoable history entry", async () => { + const { store, container, getByTestId } = await renderWith([start]); + const node = container.querySelector('.graph-node[data-type="start"]')!; + pointer(node, "pointerdown", 100, 80); + pointer(window, "pointermove", 140, 110); + pointer(window, "pointerup", 140, 110); + await waitFor(() => expect(store.getSnapshot().nodes[0]!.pos).toEqual({ x: 80, y: 70 })); + expect(getByTestId("btn-undo")).toBeEnabled(); + + fireEvent.click(getByTestId("btn-undo")); + // undo restores the exact pre-drag position (history pops to prior state) + await waitFor(() => expect(store.getSnapshot().nodes[0]!.pos).toEqual({ x: 40, y: 40 })); + expect(store.getSnapshot().canRedo).toBe(true); + }); + + it("unmounts cleanly with a frame pending (rAF or timer fallback)", async () => { + const { store, container, unmount } = await renderWith([start]); + const node = container.querySelector('.graph-node[data-type="start"]')!; + pointer(node, "pointerdown", 100, 80); + pointer(window, "pointermove", 140, 110); + await waitFor(() => expect(store.getSnapshot().nodes[0]!.pos).toEqual({ x: 80, y: 70 })); + expect(() => unmount()).not.toThrow(); + }); + + it("pans the viewport when dragging the background", async () => { + const { container } = await renderWith([start]); + const bg = container.querySelector('[data-testid="canvas-background"]')!; + const viewport = container.querySelector('[data-testid="viewport"]')!; + expect(viewport.getAttribute("transform")).toContain("translate(0 0)"); + pointer(bg, "pointerdown", 10, 10); + pointer(window, "pointermove", 40, 30); + pointer(window, "pointerup", 40, 30); + await waitFor(() => + expect(viewport.getAttribute("transform")).toContain("translate(30 20)"), + ); + }); + + it("zooms at the cursor and clamps between 0.2 and 3", async () => { + const { container, getByTestId } = await renderWith([start]); + const canvas = getByTestId("graph-canvas"); + const viewport = container.querySelector('[data-testid="viewport"]')!; + fireEvent.wheel(canvas, { deltaY: -100, clientX: 300, clientY: 200 }); + await waitFor(() => expect(viewport.getAttribute("transform")).toContain("scale(1.1)")); + // zoom-out clamp + for (let i = 0; i < 40; i++) { + fireEvent.wheel(canvas, { deltaY: 100, clientX: 300, clientY: 200 }); + } + await waitFor(() => expect(viewport.getAttribute("transform")).toContain("scale(0.2)")); + // zoom-in clamp + for (let i = 0; i < 60; i++) { + fireEvent.wheel(canvas, { deltaY: -100, clientX: 300, clientY: 200 }); + } + await waitFor(() => expect(viewport.getAttribute("transform")).toContain("scale(3)")); + }); + + it("renders a loop container with its add-block button", async () => { + const loop = { id: "loop", type: "loop" as const, pos: { x: 0, y: 0 }, body: ["m1", "m2"] }; + const m1 = { id: "m1", type: "llm_call" as const, pos: { x: 0, y: 70 }, model: "a", prompt: "" }; + const m2 = { id: "m2", type: "llm_call" as const, pos: { x: 0, y: 140 }, model: "b", prompt: "" }; + const { container } = await renderWith([loop, m1, m2]); + const group = container.querySelector('.loop-container-group[data-id="loop"]'); + expect(group).toBeTruthy(); + expect(group!.textContent).toContain("2 bloque"); + expect(group!.querySelector(".loop-add")).toBeTruthy(); + // members stay in the SVG as graph nodes (stacked under the header) + expect(container.querySelectorAll('#graph-svg .graph-node[data-type="llm_call"]')).toHaveLength(2); + }); + + it("adds a block from the loop's button as a bucketed member", async () => { + const loop = { id: "loop", type: "loop" as const, pos: { x: 0, y: 0 }, body: [] as string[] }; + const { store, container } = await renderWith([loop]); + const add = container.querySelector(".loop-add") as HTMLElement; + fireEvent.click(add); + await waitFor(() => + expect(store.getSnapshot().nodes.find((n) => n.id === "loop")!.body).toHaveLength(1), + ); + }); + + it("reorders a loop member with its in-canvas arrows", async () => { + const loop = { id: "loop", type: "loop" as const, pos: { x: 0, y: 0 }, body: ["m1", "m2"] }; + const m1 = { id: "m1", type: "llm_call" as const, pos: { x: 0, y: 70 }, model: "a", prompt: "" }; + const m2 = { id: "m2", type: "llm_call" as const, pos: { x: 0, y: 140 }, model: "b", prompt: "" }; + const { store, container } = await renderWith([loop, m1, m2]); + const up = container.querySelector('[data-testid="member-move-up"][data-node-id="m2"]')!; + fireEvent.click(up); + await waitFor(() => + expect(store.getSnapshot().nodes.find((n) => n.id === "loop")!.body).toEqual(["m2", "m1"]), + ); + }); + + it("lists nodes in the carousel and selects from a chip", async () => { + const { store, container } = await renderWith([start, call]); + const switcher = container.querySelector("#node-switcher"); + expect(switcher).toBeTruthy(); + const chips = switcher!.querySelectorAll(".node-chip"); + expect(chips).toHaveLength(2); + fireEvent.click(chips[1]!); + await waitFor(() => expect(store.getSnapshot().selection).toEqual(["call"])); + }); + + it("highlights flow steps in order on Ver flujo", async () => { + const { container, getByTestId } = await renderWith( + [start, call, endUsed], + [{ from: "start", to: "call" }, { from: "call", to: "end" }], + ); + fireEvent.click(getByTestId("btn-flow")); + const startNode = container.querySelector('.graph-node[data-type="start"]')!; + await waitFor(() => expect(startNode.classList.contains("flow-active")).toBe(true), { + timeout: 1200, + }); + }); + + it("reveals the flow statically when reduced motion is preferred", async () => { + const reduced = { + matches: true, + media: "(prefers-reduced-motion: reduce)", + onchange: null, + addEventListener() {}, + removeEventListener() {}, + addListener() {}, + removeListener() {}, + dispatchEvent() { + return false; + }, + }; + (window as { matchMedia?: unknown }).matchMedia = (() => reduced) as unknown as typeof window.matchMedia; + const { container, getByTestId } = await renderWith( + [start, call], + [{ from: "start", to: "call" }], + ); + fireEvent.click(getByTestId("btn-flow")); + const startNode = container.querySelector('.graph-node[data-type="start"]')!; + const callNode = container.querySelector('.graph-node[data-type="llm_call"]')!; + // no animation: every step is revealed immediately as flow-past + await waitFor(() => expect(startNode.classList.contains("flow-past")).toBe(true)); + expect(callNode.classList.contains("flow-past")).toBe(true); + }); + + it("exposes the legacy toolbar buttons Nuevo, Ver flujo and Limpiar", async () => { + const { getByTestId, getByLabelText } = await renderWith([]); + expect(getByTestId("btn-new")).toBeTruthy(); + expect(getByTestId("btn-flow")).toBeTruthy(); + expect(getByTestId("btn-clear")).toBeTruthy(); + expect(getByLabelText("Nombre del pipeline")).toBeTruthy(); + }); +}); \ No newline at end of file diff --git a/src/ui-svelte/views/Editor.svelte b/src/ui-svelte/views/Editor.svelte index c83ea0a..8a51db2 100644 --- a/src/ui-svelte/views/Editor.svelte +++ b/src/ui-svelte/views/Editor.svelte @@ -1,13 +1,46 @@