feat(canvas): finalize mixer reconnect swap and related updates
This commit is contained in:
@@ -32,6 +32,7 @@ const translations: Record<string, string> = {
|
||||
"agentOutputNode.sectionsLabel": "Sections",
|
||||
"agentOutputNode.metadataLabel": "Metadata",
|
||||
"agentOutputNode.qualityChecksLabel": "Quality checks",
|
||||
"agentOutputNode.detailsLabel": "Details",
|
||||
"agentOutputNode.previewLabel": "Preview",
|
||||
"agentOutputNode.previewFallback": "No preview available",
|
||||
"agentOutputNode.emptyValue": "-",
|
||||
@@ -76,7 +77,7 @@ describe("AgentOutputNode", () => {
|
||||
root = null;
|
||||
});
|
||||
|
||||
it("renders structured output with artifact meta, sections, metadata, quality checks, and preview fallback", async () => {
|
||||
it("renders structured output with deliverable first and default-collapsed details", async () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
@@ -134,6 +135,65 @@ describe("AgentOutputNode", () => {
|
||||
expect(container.querySelector('[data-testid="agent-output-metadata"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="agent-output-quality-checks"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="agent-output-preview"]')).not.toBeNull();
|
||||
const details = container.querySelector('[data-testid="agent-output-details"]') as
|
||||
| HTMLDetailsElement
|
||||
| null;
|
||||
expect(details).not.toBeNull();
|
||||
expect(details?.open).toBe(false);
|
||||
});
|
||||
|
||||
it("prioritizes social caption sections and moves secondary notes into details", async () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(AgentOutputNode, {
|
||||
id: "agent-output-caption-pack",
|
||||
selected: false,
|
||||
dragging: false,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
deletable: true,
|
||||
zIndex: 1,
|
||||
isConnectable: true,
|
||||
type: "agent-output",
|
||||
data: {
|
||||
title: "Caption Pack",
|
||||
channel: "instagram-feed",
|
||||
artifactType: "social-caption-pack",
|
||||
sections: [
|
||||
{ id: "hook", label: "Hook", content: "Start strong" },
|
||||
{ id: "format", label: "Format note", content: "Best as 4:5" },
|
||||
{ id: "cta", label: "CTA", content: "Save this post" },
|
||||
{ id: "hashtags", label: "Hashtags", content: "#buildinpublic #launch" },
|
||||
{ id: "caption", label: "Caption", content: "Launch day is here" },
|
||||
{ id: "assumptions", label: "Assumptions", content: "Audience is founder-led" },
|
||||
],
|
||||
qualityChecks: ["channel-fit"],
|
||||
} as Record<string, unknown>,
|
||||
positionAbsoluteX: 0,
|
||||
positionAbsoluteY: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const primarySections = container.querySelector('[data-testid="agent-output-sections"]');
|
||||
expect(primarySections).not.toBeNull();
|
||||
const primaryText = primarySections?.textContent ?? "";
|
||||
expect(primaryText).toContain("Caption");
|
||||
expect(primaryText).toContain("Hashtags");
|
||||
expect(primaryText).toContain("CTA");
|
||||
expect(primaryText.indexOf("Caption")).toBeLessThan(primaryText.indexOf("Hashtags"));
|
||||
expect(primaryText.indexOf("Hashtags")).toBeLessThan(primaryText.indexOf("CTA"));
|
||||
expect(primaryText).not.toContain("Format note");
|
||||
expect(primaryText).not.toContain("Assumptions");
|
||||
|
||||
const secondarySections = container.querySelector('[data-testid="agent-output-secondary-sections"]');
|
||||
expect(secondarySections).not.toBeNull();
|
||||
expect(secondarySections?.textContent).toContain("Format note");
|
||||
expect(secondarySections?.textContent).toContain("Assumptions");
|
||||
});
|
||||
|
||||
it("renders parseable json body in a pretty-printed code block", async () => {
|
||||
|
||||
@@ -223,4 +223,16 @@ describe("canvas connection policy", () => {
|
||||
getCanvasConnectionValidationMessage("agent-output-source-invalid"),
|
||||
).toBe("Agent-Ausgabe akzeptiert nur Eingaben von Agent-Nodes.");
|
||||
});
|
||||
|
||||
it("treats legacy mixer handles 'null' and empty string as base occupancy", () => {
|
||||
expect(
|
||||
validateCanvasConnectionPolicy({
|
||||
sourceType: "asset",
|
||||
targetType: "mixer",
|
||||
targetIncomingCount: 1,
|
||||
targetHandle: "base",
|
||||
targetIncomingHandles: ["null", ""],
|
||||
}),
|
||||
).toBe("mixer-handle-incoming-limit");
|
||||
});
|
||||
});
|
||||
|
||||
31
tests/canvas-connection-validation.test.ts
Normal file
31
tests/canvas-connection-validation.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { validateCanvasEdgeSplit } from "@/components/canvas/canvas-connection-validation";
|
||||
|
||||
describe("validateCanvasEdgeSplit", () => {
|
||||
it("uses middle-node target handle for first split leg", () => {
|
||||
const reason = validateCanvasEdgeSplit({
|
||||
nodes: [
|
||||
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: "node-target", type: "compare", position: { x: 400, y: 0 }, data: {} },
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: "edge-source-target",
|
||||
source: "node-source",
|
||||
target: "node-target",
|
||||
targetHandle: "left",
|
||||
},
|
||||
],
|
||||
splitEdge: {
|
||||
id: "edge-source-target",
|
||||
source: "node-source",
|
||||
target: "node-target",
|
||||
targetHandle: "left",
|
||||
},
|
||||
middleNode: { id: "node-middle", type: "mixer", position: { x: 200, y: 0 }, data: {} },
|
||||
});
|
||||
|
||||
expect(reason).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { __testables } from "@/convex/agents";
|
||||
import { __testables as openrouterTestables } from "@/convex/openrouter";
|
||||
|
||||
describe("agent orchestration contract helpers", () => {
|
||||
it("builds skeleton output data with rich execution-plan metadata", () => {
|
||||
@@ -59,7 +60,8 @@ describe("agent orchestration contract helpers", () => {
|
||||
{ id: "hook", label: "Hook", content: "Lead with proof." },
|
||||
{ id: "cta", label: "CTA", content: "Invite comments." },
|
||||
],
|
||||
metadata: { audience: "SaaS founders" },
|
||||
metadata: { tonalitaet: "freundlich", audience: "SaaS founders" },
|
||||
metadataLabels: { tonalitaet: "tonalität", audience: "audience" },
|
||||
qualityChecks: [],
|
||||
body: "",
|
||||
},
|
||||
@@ -69,6 +71,7 @@ describe("agent orchestration contract helpers", () => {
|
||||
expect(data.body).toBe("Hook:\nLead with proof.\n\nCTA:\nInvite comments.");
|
||||
expect(data.previewText).toBe("Lead with proof.");
|
||||
expect(data.qualityChecks).toEqual(["channel-fit", "clear-cta"]);
|
||||
expect(data.metadataLabels).toEqual({ tonalitaet: "tonalität", audience: "audience" });
|
||||
});
|
||||
|
||||
it("requires rich execution-step fields in analyze schema", () => {
|
||||
@@ -87,6 +90,25 @@ describe("agent orchestration contract helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("builds provider-safe execute schema without dynamic metadata maps", () => {
|
||||
const schema = __testables.buildExecuteSchema(["step-1"]);
|
||||
const diagnostics = openrouterTestables.getStructuredSchemaDiagnostics({
|
||||
schema,
|
||||
messages: [
|
||||
{ role: "system", content: "system" },
|
||||
{ role: "user", content: "user" },
|
||||
],
|
||||
});
|
||||
|
||||
const stepOne = (((schema.properties as Record<string, unknown>).stepOutputs as Record<string, unknown>)
|
||||
.properties as Record<string, unknown>)["step-1"] as Record<string, unknown>;
|
||||
|
||||
expect(stepOne.required).toContain("metadataEntries");
|
||||
expect(stepOne.required).not.toContain("metadata");
|
||||
expect(diagnostics.hasAnyOf).toBe(false);
|
||||
expect(diagnostics.hasDynamicAdditionalProperties).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves persisted summaries consistently across analyze and execute", () => {
|
||||
const promptSummary = __testables.resolveExecutionPlanSummary({
|
||||
executionPlanSummary: "",
|
||||
|
||||
@@ -60,6 +60,19 @@ describe("ai error helpers", () => {
|
||||
).toBe("Provider: OpenRouter API error 503: Upstream timeout");
|
||||
});
|
||||
|
||||
it("formats structured-output http error with extracted provider details from JSON payload", () => {
|
||||
expect(
|
||||
formatTerminalStatusMessage(
|
||||
new ConvexError({
|
||||
code: "OPENROUTER_STRUCTURED_OUTPUT_HTTP_ERROR",
|
||||
status: 502,
|
||||
message:
|
||||
'{"error":{"message":"Provider returned error","code":"provider_error","type":"upstream_error"}}',
|
||||
}),
|
||||
),
|
||||
).toBe("Provider: OpenRouter 502: Provider returned error [code=provider_error, type=upstream_error]");
|
||||
});
|
||||
|
||||
it("formats structured-output http error without falling back to raw code", () => {
|
||||
expect(
|
||||
formatTerminalStatusMessage(
|
||||
|
||||
383
tests/convex/edges-create.test.ts
Normal file
383
tests/convex/edges-create.test.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/convex/helpers", () => ({
|
||||
requireAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { create, swapMixerInputs } from "@/convex/edges";
|
||||
import { requireAuth } from "@/convex/helpers";
|
||||
|
||||
type MockCanvas = {
|
||||
_id: Id<"canvases">;
|
||||
ownerId: string;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
type MockNode = {
|
||||
_id: Id<"nodes">;
|
||||
canvasId: Id<"canvases">;
|
||||
type: string;
|
||||
};
|
||||
|
||||
type MockEdge = {
|
||||
_id: Id<"edges">;
|
||||
canvasId: Id<"canvases">;
|
||||
sourceNodeId: Id<"nodes">;
|
||||
targetNodeId: Id<"nodes">;
|
||||
sourceHandle?: string;
|
||||
targetHandle?: string;
|
||||
};
|
||||
|
||||
function createMockCtx(args?: {
|
||||
canvases?: MockCanvas[];
|
||||
nodes?: MockNode[];
|
||||
edges?: MockEdge[];
|
||||
}) {
|
||||
const canvases = new Map((args?.canvases ?? []).map((canvas) => [canvas._id, { ...canvas }]));
|
||||
const nodes = new Map((args?.nodes ?? []).map((node) => [node._id, { ...node }]));
|
||||
const edges = new Map((args?.edges ?? []).map((edge) => [edge._id, { ...edge }]));
|
||||
const deletes: Id<"edges">[] = [];
|
||||
let insertedEdgeCount = 0;
|
||||
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (canvases.has(id as Id<"canvases">)) {
|
||||
return canvases.get(id as Id<"canvases">) ?? null;
|
||||
}
|
||||
if (nodes.has(id as Id<"nodes">)) {
|
||||
return nodes.get(id as Id<"nodes">) ?? null;
|
||||
}
|
||||
if (edges.has(id as Id<"edges">)) {
|
||||
return edges.get(id as Id<"edges">) ?? null;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: "mutationRequests" | "edges") => {
|
||||
if (table === "mutationRequests") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
first: vi.fn(async () => null),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
if (table === "edges") {
|
||||
return {
|
||||
withIndex: vi.fn(
|
||||
(
|
||||
_index: "by_target",
|
||||
apply: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
|
||||
) => {
|
||||
const clauses: Array<{ field: string; value: unknown }> = [];
|
||||
const queryBuilder = {
|
||||
eq(field: string, value: unknown) {
|
||||
clauses.push({ field, value });
|
||||
return this;
|
||||
},
|
||||
};
|
||||
apply(queryBuilder);
|
||||
|
||||
const targetNodeId = clauses.find((clause) => clause.field === "targetNodeId")
|
||||
?.value as Id<"nodes"> | undefined;
|
||||
const incoming = Array.from(edges.values()).filter(
|
||||
(edge) => edge.targetNodeId === targetNodeId,
|
||||
);
|
||||
|
||||
return {
|
||||
take: vi.fn(async (count: number) => incoming.slice(0, count)),
|
||||
};
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected query table: ${table}`);
|
||||
}),
|
||||
insert: vi.fn(async (table: "edges" | "mutationRequests", value: Record<string, unknown>) => {
|
||||
if (table === "mutationRequests") {
|
||||
return "mutation_request_1";
|
||||
}
|
||||
insertedEdgeCount += 1;
|
||||
const edgeId = `edge-new-${insertedEdgeCount}` as Id<"edges">;
|
||||
edges.set(edgeId, {
|
||||
_id: edgeId,
|
||||
canvasId: value.canvasId as Id<"canvases">,
|
||||
sourceNodeId: value.sourceNodeId as Id<"nodes">,
|
||||
targetNodeId: value.targetNodeId as Id<"nodes">,
|
||||
sourceHandle: value.sourceHandle as string | undefined,
|
||||
targetHandle: value.targetHandle as string | undefined,
|
||||
});
|
||||
return edgeId;
|
||||
}),
|
||||
patch: vi.fn(async (id: string, patch: Record<string, unknown>) => {
|
||||
if (canvases.has(id as Id<"canvases">)) {
|
||||
const canvas = canvases.get(id as Id<"canvases">);
|
||||
if (!canvas) {
|
||||
throw new Error("Canvas missing");
|
||||
}
|
||||
canvases.set(id as Id<"canvases">, { ...canvas, ...patch });
|
||||
return;
|
||||
}
|
||||
|
||||
if (edges.has(id as Id<"edges">)) {
|
||||
const edge = edges.get(id as Id<"edges">);
|
||||
if (!edge) {
|
||||
throw new Error("Edge missing");
|
||||
}
|
||||
edges.set(id as Id<"edges">, { ...edge, ...patch });
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("Record missing");
|
||||
}),
|
||||
delete: vi.fn(async (edgeId: Id<"edges">) => {
|
||||
deletes.push(edgeId);
|
||||
edges.delete(edgeId);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
ctx,
|
||||
listEdges: () => Array.from(edges.values()),
|
||||
listCanvases: () => Array.from(canvases.values()),
|
||||
deletes,
|
||||
};
|
||||
}
|
||||
|
||||
describe("edges.create", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("treats edgeIdToIgnore as replacement and removes ignored edge", async () => {
|
||||
vi.mocked(requireAuth).mockResolvedValue({ userId: "user-1" } as never);
|
||||
const canvasId = "canvas-1" as Id<"canvases">;
|
||||
const targetNodeId = "node-target" as Id<"nodes">;
|
||||
const oldEdgeId = "edge-old" as Id<"edges">;
|
||||
const mock = createMockCtx({
|
||||
canvases: [{ _id: canvasId, ownerId: "user-1", updatedAt: 1 }],
|
||||
nodes: [
|
||||
{ _id: "node-source-1" as Id<"nodes">, canvasId, type: "image" },
|
||||
{ _id: "node-source-2" as Id<"nodes">, canvasId, type: "image" },
|
||||
{ _id: targetNodeId, canvasId, type: "mixer" },
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
_id: oldEdgeId,
|
||||
canvasId,
|
||||
sourceNodeId: "node-source-1" as Id<"nodes">,
|
||||
targetNodeId,
|
||||
targetHandle: "base",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await (create as unknown as {
|
||||
_handler: (
|
||||
ctx: unknown,
|
||||
args: {
|
||||
canvasId: Id<"canvases">;
|
||||
sourceNodeId: Id<"nodes">;
|
||||
targetNodeId: Id<"nodes">;
|
||||
targetHandle?: string;
|
||||
edgeIdToIgnore?: Id<"edges">;
|
||||
},
|
||||
) => Promise<Id<"edges">>;
|
||||
})._handler(mock.ctx, {
|
||||
canvasId,
|
||||
sourceNodeId: "node-source-2" as Id<"nodes">,
|
||||
targetNodeId,
|
||||
targetHandle: "base",
|
||||
edgeIdToIgnore: oldEdgeId,
|
||||
});
|
||||
|
||||
const incomingToTarget = mock
|
||||
.listEdges()
|
||||
.filter((edge) => edge.targetNodeId === targetNodeId && edge.targetHandle === "base");
|
||||
|
||||
expect(incomingToTarget).toHaveLength(1);
|
||||
expect(incomingToTarget[0]?._id).not.toBe(oldEdgeId);
|
||||
expect(mock.deletes).toEqual([oldEdgeId]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edges.swapMixerInputs", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("swaps handles between two mixer input edges and updates canvas timestamp", async () => {
|
||||
vi.mocked(requireAuth).mockResolvedValue({ userId: "user-1" } as never);
|
||||
const canvasId = "canvas-1" as Id<"canvases">;
|
||||
const mixerNodeId = "node-mixer" as Id<"nodes">;
|
||||
const mock = createMockCtx({
|
||||
canvases: [{ _id: canvasId, ownerId: "user-1", updatedAt: 1 }],
|
||||
nodes: [{ _id: mixerNodeId, canvasId, type: "mixer" }],
|
||||
edges: [
|
||||
{
|
||||
_id: "edge-base" as Id<"edges">,
|
||||
canvasId,
|
||||
sourceNodeId: "source-1" as Id<"nodes">,
|
||||
targetNodeId: mixerNodeId,
|
||||
targetHandle: "base",
|
||||
},
|
||||
{
|
||||
_id: "edge-overlay" as Id<"edges">,
|
||||
canvasId,
|
||||
sourceNodeId: "source-2" as Id<"nodes">,
|
||||
targetNodeId: mixerNodeId,
|
||||
targetHandle: "overlay",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await (swapMixerInputs as unknown as {
|
||||
_handler: (
|
||||
ctx: unknown,
|
||||
args: {
|
||||
canvasId: Id<"canvases">;
|
||||
edgeId: Id<"edges">;
|
||||
otherEdgeId: Id<"edges">;
|
||||
},
|
||||
) => Promise<void>;
|
||||
})._handler(mock.ctx, {
|
||||
canvasId,
|
||||
edgeId: "edge-base" as Id<"edges">,
|
||||
otherEdgeId: "edge-overlay" as Id<"edges">,
|
||||
});
|
||||
|
||||
const swappedEdges = mock.listEdges();
|
||||
expect(swappedEdges.find((edge) => edge._id === ("edge-base" as Id<"edges">))?.targetHandle).toBe(
|
||||
"overlay",
|
||||
);
|
||||
expect(
|
||||
swappedEdges.find((edge) => edge._id === ("edge-overlay" as Id<"edges">))?.targetHandle,
|
||||
).toBe("base");
|
||||
expect(mock.listCanvases()[0]?.updatedAt).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("fails when one of the edge IDs does not exist", async () => {
|
||||
vi.mocked(requireAuth).mockResolvedValue({ userId: "user-1" } as never);
|
||||
const canvasId = "canvas-1" as Id<"canvases">;
|
||||
const mock = createMockCtx({
|
||||
canvases: [{ _id: canvasId, ownerId: "user-1", updatedAt: 1 }],
|
||||
edges: [
|
||||
{
|
||||
_id: "edge-base" as Id<"edges">,
|
||||
canvasId,
|
||||
sourceNodeId: "source-1" as Id<"nodes">,
|
||||
targetNodeId: "node-mixer" as Id<"nodes">,
|
||||
targetHandle: "base",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
(swapMixerInputs as unknown as {
|
||||
_handler: (
|
||||
ctx: unknown,
|
||||
args: {
|
||||
canvasId: Id<"canvases">;
|
||||
edgeId: Id<"edges">;
|
||||
otherEdgeId: Id<"edges">;
|
||||
},
|
||||
) => Promise<void>;
|
||||
})._handler(mock.ctx, {
|
||||
canvasId,
|
||||
edgeId: "edge-base" as Id<"edges">,
|
||||
otherEdgeId: "edge-missing" as Id<"edges">,
|
||||
}),
|
||||
).rejects.toThrow("Edge not found");
|
||||
});
|
||||
|
||||
it("fails when edges are not exactly one base and one overlay handle", async () => {
|
||||
vi.mocked(requireAuth).mockResolvedValue({ userId: "user-1" } as never);
|
||||
const canvasId = "canvas-1" as Id<"canvases">;
|
||||
const mixerNodeId = "node-mixer" as Id<"nodes">;
|
||||
const mock = createMockCtx({
|
||||
canvases: [{ _id: canvasId, ownerId: "user-1", updatedAt: 1 }],
|
||||
nodes: [{ _id: mixerNodeId, canvasId, type: "mixer" }],
|
||||
edges: [
|
||||
{
|
||||
_id: "edge-1" as Id<"edges">,
|
||||
canvasId,
|
||||
sourceNodeId: "source-1" as Id<"nodes">,
|
||||
targetNodeId: mixerNodeId,
|
||||
targetHandle: "base",
|
||||
},
|
||||
{
|
||||
_id: "edge-2" as Id<"edges">,
|
||||
canvasId,
|
||||
sourceNodeId: "source-2" as Id<"nodes">,
|
||||
targetNodeId: mixerNodeId,
|
||||
targetHandle: "base",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
(swapMixerInputs as unknown as {
|
||||
_handler: (
|
||||
ctx: unknown,
|
||||
args: {
|
||||
canvasId: Id<"canvases">;
|
||||
edgeId: Id<"edges">;
|
||||
otherEdgeId: Id<"edges">;
|
||||
},
|
||||
) => Promise<void>;
|
||||
})._handler(mock.ctx, {
|
||||
canvasId,
|
||||
edgeId: "edge-1" as Id<"edges">,
|
||||
otherEdgeId: "edge-2" as Id<"edges">,
|
||||
}),
|
||||
).rejects.toThrow("Mixer swap requires one base and one overlay edge");
|
||||
});
|
||||
|
||||
it("fails when edges do not belong to the same mixer target on the same canvas", async () => {
|
||||
vi.mocked(requireAuth).mockResolvedValue({ userId: "user-1" } as never);
|
||||
const canvasId = "canvas-1" as Id<"canvases">;
|
||||
const mock = createMockCtx({
|
||||
canvases: [{ _id: canvasId, ownerId: "user-1", updatedAt: 1 }],
|
||||
nodes: [
|
||||
{ _id: "node-mixer-a" as Id<"nodes">, canvasId, type: "mixer" },
|
||||
{ _id: "node-mixer-b" as Id<"nodes">, canvasId, type: "mixer" },
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
_id: "edge-base" as Id<"edges">,
|
||||
canvasId,
|
||||
sourceNodeId: "source-1" as Id<"nodes">,
|
||||
targetNodeId: "node-mixer-a" as Id<"nodes">,
|
||||
targetHandle: "base",
|
||||
},
|
||||
{
|
||||
_id: "edge-overlay" as Id<"edges">,
|
||||
canvasId,
|
||||
sourceNodeId: "source-2" as Id<"nodes">,
|
||||
targetNodeId: "node-mixer-b" as Id<"nodes">,
|
||||
targetHandle: "overlay",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
(swapMixerInputs as unknown as {
|
||||
_handler: (
|
||||
ctx: unknown,
|
||||
args: {
|
||||
canvasId: Id<"canvases">;
|
||||
edgeId: Id<"edges">;
|
||||
otherEdgeId: Id<"edges">;
|
||||
},
|
||||
) => Promise<void>;
|
||||
})._handler(mock.ctx, {
|
||||
canvasId,
|
||||
edgeId: "edge-base" as Id<"edges">,
|
||||
otherEdgeId: "edge-overlay" as Id<"edges">,
|
||||
}),
|
||||
).rejects.toThrow("Edges must target the same mixer node");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { generateStructuredObjectViaOpenRouter } from "@/convex/openrouter";
|
||||
import {
|
||||
__testables,
|
||||
generateStructuredObjectViaOpenRouter,
|
||||
} from "@/convex/openrouter";
|
||||
|
||||
type MockResponseInit = {
|
||||
ok: boolean;
|
||||
@@ -285,4 +288,65 @@ describe("generateStructuredObjectViaOpenRouter", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts provider details from OpenRouter JSON error payload", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: false,
|
||||
status: 502,
|
||||
text: '{"error":{"message":"Provider returned error","code":"provider_error","type":"upstream_error"}}',
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
generateStructuredObjectViaOpenRouter("test-api-key", {
|
||||
model: "openai/gpt-5-mini",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
schemaName: "test_schema",
|
||||
schema: { type: "object" },
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
data: {
|
||||
code: "OPENROUTER_STRUCTURED_OUTPUT_HTTP_ERROR",
|
||||
status: 502,
|
||||
providerCode: "provider_error",
|
||||
providerType: "upstream_error",
|
||||
providerMessage: "Provider returned error",
|
||||
message: "OpenRouter 502: Provider returned error [code=provider_error, type=upstream_error]",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("detects schema features that are likely to matter for structured-output debugging", () => {
|
||||
const diagnostics = __testables.getStructuredSchemaDiagnostics({
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["summary", "metadata"],
|
||||
properties: {
|
||||
summary: { type: "string" },
|
||||
metadata: {
|
||||
type: "object",
|
||||
additionalProperties: {
|
||||
anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
messages: [
|
||||
{ role: "system", content: "system prompt" },
|
||||
{ role: "user", content: "user prompt" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(diagnostics).toMatchObject({
|
||||
topLevelType: "object",
|
||||
topLevelRequiredCount: 2,
|
||||
topLevelPropertyCount: 2,
|
||||
messageCount: 2,
|
||||
messageLengths: [13, 11],
|
||||
hasAnyOf: true,
|
||||
hasDynamicAdditionalProperties: true,
|
||||
hasPatternProperties: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,6 +136,10 @@ describe("agent prompting helpers", () => {
|
||||
const user = messages[1]?.content ?? "";
|
||||
|
||||
expect(system).toContain("execution rules");
|
||||
expect(system).toContain("deliverable-first rules");
|
||||
expect(system).toContain("Prioritize publishable, user-facing deliverables");
|
||||
expect(system).toContain("Do not produce reasoning-dominant output");
|
||||
expect(system).toContain("For Campaign Distributor steps, output channel-ready publishable copy first");
|
||||
expect(system).toContain("channel-notes");
|
||||
expect(system).toContain("German (de-DE)");
|
||||
expect(user).toContain("Execution plan summary: Ship launch content");
|
||||
|
||||
@@ -47,6 +47,10 @@ describe("normalizeAgentStructuredOutput", () => {
|
||||
language: "en",
|
||||
tags: ["launch", "saas"],
|
||||
},
|
||||
metadataLabels: {
|
||||
language: "language",
|
||||
tags: "tags",
|
||||
},
|
||||
qualityChecks: ["concise", "channel-fit"],
|
||||
body: "Legacy flat content",
|
||||
});
|
||||
@@ -145,4 +149,38 @@ describe("normalizeAgentStructuredOutput", () => {
|
||||
"Hook:\nLead with a bold claim.\n\nCTA:\nInvite replies with a concrete question.",
|
||||
);
|
||||
});
|
||||
|
||||
it("slugifies non-ascii metadata keys and preserves original labels", () => {
|
||||
const normalized = normalizeAgentStructuredOutput(
|
||||
{
|
||||
sections: [
|
||||
{
|
||||
id: "caption",
|
||||
label: "Caption",
|
||||
content: "Publish-ready caption.",
|
||||
},
|
||||
],
|
||||
metadataEntries: [
|
||||
{ key: "tonalität", values: ["freundlich"] },
|
||||
{ key: "hashtags", values: ["dogs", "berner-sennenhund"] },
|
||||
{ key: "empty", values: [] },
|
||||
{ key: " ", values: ["ignored"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Fallback Title",
|
||||
channel: "fallback-channel",
|
||||
artifactType: "fallback-artifact",
|
||||
},
|
||||
);
|
||||
|
||||
expect(normalized.metadata).toEqual({
|
||||
tonalitaet: "freundlich",
|
||||
hashtags: ["dogs", "berner-sennenhund"],
|
||||
});
|
||||
expect(normalized.metadataLabels).toEqual({
|
||||
tonalitaet: "tonalität",
|
||||
hashtags: "hashtags",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,12 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { nodeTypes } from "@/components/canvas/node-types";
|
||||
import { CANVAS_NODE_TEMPLATES } from "@/lib/canvas-node-templates";
|
||||
import { NODE_CATALOG, isNodePaletteEnabled } from "@/lib/canvas-node-catalog";
|
||||
import {
|
||||
NODE_CATALOG,
|
||||
NODE_CATEGORY_META,
|
||||
catalogEntriesByCategory,
|
||||
isNodePaletteEnabled,
|
||||
} from "@/lib/canvas-node-catalog";
|
||||
import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
|
||||
|
||||
describe("canvas agent config", () => {
|
||||
@@ -22,6 +27,27 @@ describe("canvas agent config", () => {
|
||||
expect(entry && isNodePaletteEnabled(entry)).toBe(true);
|
||||
});
|
||||
|
||||
it("moves agent nodes into an Agents category", () => {
|
||||
expect(NODE_CATEGORY_META.agents.label).toBe("Agents");
|
||||
|
||||
const byCategory = catalogEntriesByCategory();
|
||||
const agentsEntries = byCategory.get("agents") ?? [];
|
||||
const aiOutputEntries = byCategory.get("ai-output") ?? [];
|
||||
|
||||
expect(agentsEntries.map((entry) => entry.type)).toEqual(["agent", "agent-output"]);
|
||||
expect(agentsEntries[0]).toMatchObject({
|
||||
label: "Campaign Orchestrator",
|
||||
category: "agents",
|
||||
});
|
||||
|
||||
expect(aiOutputEntries.map((entry) => entry.type)).toEqual([
|
||||
"prompt",
|
||||
"video-prompt",
|
||||
"ai-text",
|
||||
]);
|
||||
expect(NODE_CATALOG.find((entry) => entry.type === "ai-video")?.category).toBe("source");
|
||||
});
|
||||
|
||||
it("keeps the agent input-only in MVP", () => {
|
||||
expect(NODE_HANDLE_MAP.agent?.target).toBe("agent-in");
|
||||
expect(NODE_HANDLE_MAP.agent?.source).toBeUndefined();
|
||||
|
||||
198
tests/lib/canvas-mixer-preview.test.ts
Normal file
198
tests/lib/canvas-mixer-preview.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildGraphSnapshot } from "@/lib/canvas-render-preview";
|
||||
import { resolveMixerPreviewFromGraph } from "@/lib/canvas-mixer-preview";
|
||||
|
||||
describe("resolveMixerPreviewFromGraph", () => {
|
||||
it("resolves base and overlay URLs by target handle", () => {
|
||||
const graph = buildGraphSnapshot(
|
||||
[
|
||||
{
|
||||
id: "image-base",
|
||||
type: "image",
|
||||
data: { url: "https://cdn.example.com/base.png" },
|
||||
},
|
||||
{
|
||||
id: "asset-source",
|
||||
type: "asset",
|
||||
data: { url: "https://cdn.example.com/overlay.png" },
|
||||
},
|
||||
{
|
||||
id: "render-overlay",
|
||||
type: "render",
|
||||
data: {},
|
||||
},
|
||||
{
|
||||
id: "mixer-1",
|
||||
type: "mixer",
|
||||
data: { blendMode: "screen", opacity: 70, offsetX: 12, offsetY: -8 },
|
||||
},
|
||||
],
|
||||
[
|
||||
{ source: "asset-source", target: "render-overlay" },
|
||||
{ source: "image-base", target: "mixer-1", targetHandle: "base" },
|
||||
{ source: "render-overlay", target: "mixer-1", targetHandle: "overlay" },
|
||||
],
|
||||
);
|
||||
|
||||
expect(resolveMixerPreviewFromGraph({ nodeId: "mixer-1", graph })).toEqual({
|
||||
status: "ready",
|
||||
baseUrl: "https://cdn.example.com/base.png",
|
||||
overlayUrl: "https://cdn.example.com/overlay.png",
|
||||
blendMode: "screen",
|
||||
opacity: 70,
|
||||
offsetX: 12,
|
||||
offsetY: -8,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers render output URL over upstream preview source when available", () => {
|
||||
const graph = buildGraphSnapshot(
|
||||
[
|
||||
{
|
||||
id: "image-base",
|
||||
type: "image",
|
||||
data: { url: "https://cdn.example.com/base.png" },
|
||||
},
|
||||
{
|
||||
id: "image-upstream",
|
||||
type: "image",
|
||||
data: { url: "https://cdn.example.com/upstream.png" },
|
||||
},
|
||||
{
|
||||
id: "render-overlay",
|
||||
type: "render",
|
||||
data: {
|
||||
lastUploadUrl: "https://cdn.example.com/render-output.png",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "mixer-1",
|
||||
type: "mixer",
|
||||
data: {},
|
||||
},
|
||||
],
|
||||
[
|
||||
{ source: "image-upstream", target: "render-overlay" },
|
||||
{ source: "image-base", target: "mixer-1", targetHandle: "base" },
|
||||
{ source: "render-overlay", target: "mixer-1", targetHandle: "overlay" },
|
||||
],
|
||||
);
|
||||
|
||||
expect(resolveMixerPreviewFromGraph({ nodeId: "mixer-1", graph })).toEqual({
|
||||
status: "ready",
|
||||
baseUrl: "https://cdn.example.com/base.png",
|
||||
overlayUrl: "https://cdn.example.com/render-output.png",
|
||||
blendMode: "normal",
|
||||
opacity: 100,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns partial when one input is missing", () => {
|
||||
const graph = buildGraphSnapshot(
|
||||
[
|
||||
{
|
||||
id: "image-base",
|
||||
type: "image",
|
||||
data: { url: "https://cdn.example.com/base.png" },
|
||||
},
|
||||
{
|
||||
id: "mixer-1",
|
||||
type: "mixer",
|
||||
data: {},
|
||||
},
|
||||
],
|
||||
[{ source: "image-base", target: "mixer-1", targetHandle: "base" }],
|
||||
);
|
||||
|
||||
expect(resolveMixerPreviewFromGraph({ nodeId: "mixer-1", graph })).toEqual({
|
||||
status: "partial",
|
||||
baseUrl: "https://cdn.example.com/base.png",
|
||||
overlayUrl: undefined,
|
||||
blendMode: "normal",
|
||||
opacity: 100,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes blend mode and clamps numeric values", () => {
|
||||
const graph = buildGraphSnapshot(
|
||||
[
|
||||
{
|
||||
id: "base-ai",
|
||||
type: "ai-image",
|
||||
data: { url: "https://cdn.example.com/base-ai.png" },
|
||||
},
|
||||
{
|
||||
id: "overlay-asset",
|
||||
type: "asset",
|
||||
data: { url: "https://cdn.example.com/overlay-asset.png" },
|
||||
},
|
||||
{
|
||||
id: "mixer-1",
|
||||
type: "mixer",
|
||||
data: {
|
||||
blendMode: "unknown",
|
||||
opacity: 180,
|
||||
offsetX: 9999,
|
||||
offsetY: "-9999",
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{ source: "base-ai", target: "mixer-1", targetHandle: "base" },
|
||||
{ source: "overlay-asset", target: "mixer-1", targetHandle: "overlay" },
|
||||
],
|
||||
);
|
||||
|
||||
expect(resolveMixerPreviewFromGraph({ nodeId: "mixer-1", graph })).toEqual({
|
||||
status: "ready",
|
||||
baseUrl: "https://cdn.example.com/base-ai.png",
|
||||
overlayUrl: "https://cdn.example.com/overlay-asset.png",
|
||||
blendMode: "normal",
|
||||
opacity: 100,
|
||||
offsetX: 2048,
|
||||
offsetY: -2048,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns error when multiple edges target the same mixer handle", () => {
|
||||
const graph = buildGraphSnapshot(
|
||||
[
|
||||
{
|
||||
id: "image-a",
|
||||
type: "image",
|
||||
data: { url: "https://cdn.example.com/a.png" },
|
||||
},
|
||||
{
|
||||
id: "image-b",
|
||||
type: "image",
|
||||
data: { url: "https://cdn.example.com/b.png" },
|
||||
},
|
||||
{
|
||||
id: "mixer-1",
|
||||
type: "mixer",
|
||||
data: {},
|
||||
},
|
||||
],
|
||||
[
|
||||
{ source: "image-a", target: "mixer-1", targetHandle: "base" },
|
||||
{ source: "image-b", target: "mixer-1", targetHandle: "base" },
|
||||
],
|
||||
);
|
||||
|
||||
expect(resolveMixerPreviewFromGraph({ nodeId: "mixer-1", graph })).toEqual({
|
||||
status: "error",
|
||||
baseUrl: undefined,
|
||||
overlayUrl: undefined,
|
||||
blendMode: "normal",
|
||||
opacity: 100,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
error: "duplicate-handle-edge",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user