Files
lda-wf/web/apps/console/src/workspace/authoring/selected-step-dataflow.test.ts
T

432 lines
16 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { DraftDiagnostic, DraftWorkspace } from "../domain/draft-workspace-models.js";
import {
bindingDiagnosticsForStep,
capabilityLocalPathSuggestions,
inferredStateSchemaPreview,
isJsonValue,
inputBindingRows,
outputBindingRows,
projectSelectedStepDataflow,
serializeInputBindingRow,
serializeInputBindingRows,
serializeOutputBindingRow,
serializeOutputBindingRows,
stateTargetSuggestions,
workflowSourceSuggestions,
} from "./selected-step-dataflow.js";
const summary = {
name: "report",
start: "render",
stepCount: 1,
routeCount: 0,
steps: ["render"],
};
const bindings = {
input: [
{ path: "input.items", target: "items" },
{ target: "separator", value: null },
{ path: "state.fallback", target: "fallback" },
],
output: [
{ source: "text", target: "state.report" },
{ source: "text", target: "state.audit.latest" },
],
};
const keyedDraft: DraftWorkspace = {
workspaceId: "draft-report",
revision: 3,
title: "Report",
status: "valid",
diagnostics: [],
summary,
draft: {
steps: {
render: {
use: "wf.std.concat",
...bindings,
retry: 0,
},
},
},
};
const compiledDraft: DraftWorkspace = {
...keyedDraft,
draft: { nodes: [{ id: "render", type: "node", node: "wf.std.concat", ...bindings, retry: 0 }] },
};
const schema = {
type: "object",
properties: {
items: { type: "array", items: { type: "string" } },
account: {
type: "object",
properties: { name: { type: "string" } },
},
},
};
const diagnostic = (
path: string,
stepId: string | null = null,
): DraftDiagnostic => ({
code: `diagnostic-${path}`,
path,
message: `Issue at ${path}`,
stepId,
repairHint: null,
details: {},
});
describe("selected-step dataflow projection", () => {
it("projects keyed and compiled drafts to the same ordered canonical dataflow", () => {
const keyed = projectSelectedStepDataflow(keyedDraft, "render");
const compiled = projectSelectedStepDataflow(compiledDraft, "render");
expect(keyed).toEqual({
stepId: "render",
compiledNodeIndex: null,
capabilityName: "wf.std.concat",
description: undefined,
retry: 0,
timeoutSeconds: undefined,
inputs: bindings.input,
outputs: bindings.output,
unsupported: [],
});
expect(compiled).toEqual({
...keyed,
compiledNodeIndex: 0,
});
});
it("keeps one composite expression as one input binding and preserves its legacy row index", () => {
const composite = {
target: "items",
expression: {
kind: "array",
items: [
{ kind: "path", path: "state.foo" },
{ kind: "literal", value: "wowcool" },
],
},
};
const projected = projectSelectedStepDataflow({
...keyedDraft,
draft: {
steps: {
render: { use: "wf.std.concat", input: [bindings.input[0], composite, bindings.input[1]] },
},
},
}, "render");
expect(projected?.inputs).toHaveLength(3);
expect(projected?.inputs[1]).toEqual(composite);
expect(inputBindingRows([bindings.input[0], composite, bindings.input[1]])).toEqual([
expect.objectContaining({ kind: "canonical", index: 0 }),
expect.objectContaining({ kind: "unsupported", index: 1, raw: composite }),
expect.objectContaining({ kind: "canonical", index: 2 }),
]);
});
it("accepts structural paths, whole-payload paths, empty lists, and reports malformed rows", () => {
const draft: DraftWorkspace = {
...keyedDraft,
draft: {
steps: {
render: {
use: "wf.std.concat",
input: [
{
path: { root: "input", parts: ["items"] },
target: { root: "local", parts: [] },
},
{ target: "broken", path: { root: "input", parts: [""] } },
{ target: "literal", value: null },
null,
],
output: [
{ source: { root: "local", parts: [] }, target: { root: "state", parts: ["report"] } },
{ source: "text", target: "state" },
{ source: "text", target: "state." },
{ source: "text", target: "state.audit..latest" },
],
},
},
},
};
const projected = projectSelectedStepDataflow(draft, "render");
expect(projected?.inputs).toEqual([
{ path: { root: "input", parts: ["items"] }, target: { root: "local", parts: [] } },
{ target: "literal", value: null },
]);
expect(projected?.outputs).toEqual([
{ source: { root: "local", parts: [] }, target: { root: "state", parts: ["report"] } },
]);
expect(projected?.unsupported).toEqual([
expect.objectContaining({ field: "input", index: 1, raw: draft.draft && expect.anything() }),
expect.objectContaining({ field: "input", index: 3 }),
expect.objectContaining({ field: "output", index: 1 }),
expect.objectContaining({ field: "output", index: 2 }),
expect.objectContaining({ field: "output", index: 3 }),
]);
expect(projected?.unsupported.every((row) => row.reason.length > 0)).toBe(true);
expect(projectSelectedStepDataflow({ ...keyedDraft, draft: { steps: { render: { use: "x", input: [], output: [] } } } }, "render")).toEqual({
stepId: "render",
compiledNodeIndex: null,
capabilityName: "x",
description: undefined,
retry: undefined,
timeoutSeconds: undefined,
inputs: [],
outputs: [],
unsupported: [],
});
});
it("preserves malformed binding containers and extra row keys for keyed and compiled steps", () => {
const extraInput = { path: "input.items", target: "items", extra: true };
const extraOutput = { source: "text", target: "state.report", extra: true };
expect(inputBindingRows(undefined)).toEqual([]);
expect(inputBindingRows(null)).toEqual([
expect.objectContaining({ kind: "unsupported", index: 0, raw: null }),
]);
expect(inputBindingRows({ path: "input.items", target: "items" })).toEqual([
expect.objectContaining({ kind: "unsupported", index: 0 }),
]);
expect(inputBindingRows([extraInput])).toEqual([
expect.objectContaining({ kind: "unsupported", index: 0, raw: extraInput }),
]);
expect(outputBindingRows(null)).toEqual([
expect.objectContaining({ kind: "unsupported", index: 0, raw: null }),
]);
expect(outputBindingRows({ source: "text", target: "state.report" })).toEqual([
expect.objectContaining({ kind: "unsupported", index: 0 }),
]);
expect(outputBindingRows([extraOutput])).toEqual([
expect.objectContaining({ kind: "unsupported", index: 0, raw: extraOutput }),
]);
const keyed = projectSelectedStepDataflow({
...keyedDraft,
draft: { steps: { render: { use: "wf.std.concat", input: null, output: extraOutput } } },
}, "render");
const compiled = projectSelectedStepDataflow({
...compiledDraft,
draft: { nodes: [{ id: "render", node: "wf.std.concat", input: extraInput, output: null }] },
}, "render");
expect(keyed?.inputs).toEqual([]);
expect(keyed?.outputs).toEqual([]);
expect(keyed?.unsupported.map(({ field, index, raw }) => ({ field, index, raw }))).toEqual([
{ field: "input", index: 0, raw: null },
{ field: "output", index: 0, raw: extraOutput },
]);
expect(compiled?.inputs).toEqual([]);
expect(compiled?.outputs).toEqual([]);
expect(compiled?.unsupported.map(({ field, index, raw }) => ({ field, index, raw }))).toEqual([
{ field: "input", index: 0, raw: extraInput },
{ field: "output", index: 0, raw: null },
]);
});
it("returns null for a missing step", () => {
expect(projectSelectedStepDataflow(keyedDraft, "missing")).toBeNull();
});
it("keeps unsupported rows at their original positions for explicit repair", () => {
const rawInput = [
{ path: "input.first", target: "first" },
{ target: "broken", value: undefined },
{ path: "input.third", target: "third" },
];
const rawOutput = [
{ source: "first", target: "state.first" },
{ source: "broken", target: "state" },
{ source: "third", target: "state.third" },
];
expect(inputBindingRows(rawInput)).toEqual([
{ kind: "canonical", index: 0, value: rawInput[0] },
expect.objectContaining({ kind: "unsupported", index: 1, raw: rawInput[1] }),
{ kind: "canonical", index: 2, value: rawInput[2] },
]);
expect(outputBindingRows(rawOutput)).toEqual([
{ kind: "canonical", index: 0, value: rawOutput[0] },
expect.objectContaining({ kind: "unsupported", index: 1, raw: rawOutput[1] }),
{ kind: "canonical", index: 2, value: rawOutput[2] },
]);
const inputRows = inputBindingRows(rawInput);
const outputRows = outputBindingRows(rawOutput);
expect(serializeInputBindingRows(inputRows)).toBeNull();
expect(serializeOutputBindingRows(outputRows)).toBeNull();
expect(serializeInputBindingRows(inputRows.filter((row) => row.kind === "canonical"))).toEqual([
rawInput[0],
rawInput[2],
]);
expect(serializeOutputBindingRows(outputRows.filter((row) => row.kind === "canonical"))).toEqual([
rawOutput[0],
rawOutput[2],
]);
});
it("serializes canonical strings and rejects invalid output state targets", () => {
expect(serializeInputBindingRow({
path: { root: "input", parts: ["items"] },
target: { root: "local", parts: [] },
})).toEqual({ path: "input.items", target: "." });
expect(serializeInputBindingRow({ target: "separator", value: null })).toEqual({
target: "separator",
value: null,
});
expect(serializeOutputBindingRow({
source: { root: "local", parts: [] },
target: { root: "state", parts: ["audit", "latest"] },
})).toEqual({ source: ".", target: "state.audit.latest" });
for (const target of ["state", "state.", "state.audit..latest", { root: "state", parts: [] }]) {
expect(serializeOutputBindingRow({ source: "text", target })).toBeNull();
}
});
it("accepts finite recursive JSON literals and rejects non-JSON values", () => {
const valid = {
target: "payload",
value: { count: 0, enabled: false, nested: [null, "ok", { ratio: -2.5 }] },
};
expect(isJsonValue(valid.value)).toBe(true);
expect(serializeInputBindingRow(valid)).toEqual(valid);
const invalidValues: unknown[] = [
undefined,
Number.NaN,
Number.POSITIVE_INFINITY,
Number.NEGATIVE_INFINITY,
() => "not JSON",
Symbol("not JSON"),
["ok", undefined],
{ nested: Number.NaN },
new Date("2026-08-09T00:00:00.000Z"),
];
for (const value of invalidValues) {
expect(isJsonValue(value)).toBe(false);
expect(serializeInputBindingRow({ target: "payload", value })).toBeNull();
}
});
});
describe("selected-step schema helpers", () => {
it("returns canonical schema path suggestions", () => {
expect(workflowSourceSuggestions(schema, {
type: "object",
properties: { fallback: { type: "string" } },
})).toEqual([
"input.items",
"input.items.0",
"input.account",
"input.account.name",
"state.fallback",
]);
expect(capabilityLocalPathSuggestions(schema)).toEqual([
".",
"items",
"items.0",
"account",
"account.name",
]);
expect(stateTargetSuggestions(schema)).toEqual(["state.items", "state.items.0", "state.account", "state.account.name"]);
});
it("combines workflow input and state source suggestions with nested local targets", () => {
expect(workflowSourceSuggestions(
{ type: "object", properties: { request: { type: "object", properties: { id: { type: "string" } } } } },
{ type: "object", properties: { session: { type: "object", properties: { token: { type: "string" } } } } },
)).toEqual(["input.request", "input.request.id", "state.session", "state.session.token"]);
expect(capabilityLocalPathSuggestions({
type: "object",
properties: { profile: { type: "object", properties: { name: { type: "string" } } } },
})).toEqual([".", "profile", "profile.name"]);
});
it("previews only the selected output source schema", () => {
const outputSchema = {
type: "object",
properties: {
text: { type: "string" },
items: { type: "array", items: { type: "string" } },
audit: { type: "object", properties: { latest: { type: "integer" } } },
},
};
expect(inferredStateSchemaPreview(outputSchema, "text", "state.report")).toEqual({ type: "string" });
expect(inferredStateSchemaPreview(outputSchema, "audit.latest", "state.audit.latest")).toEqual({ type: "integer" });
expect(inferredStateSchemaPreview(outputSchema, "items.1", "state.item")).toEqual({ type: "string" });
expect(inferredStateSchemaPreview(outputSchema, "items.not-an-index", "state.item")).toBeNull();
expect(inferredStateSchemaPreview(outputSchema, ".", "state.report")).toEqual(outputSchema);
expect(inferredStateSchemaPreview(outputSchema, "missing", "state.report")).toBeNull();
expect(inferredStateSchemaPreview(outputSchema, "text", "state")).toBeNull();
});
});
describe("selected-step binding diagnostics", () => {
it("maps only structural row paths and leaves unmatched diagnostics shared", () => {
const stepId = "render/one~x";
const diagnostics = [
diagnostic("nodes[2].input[1].target", stepId),
diagnostic("nodes[2].output[3].target", stepId),
diagnostic("/steps/render~1one~0x/input/4/path", null),
diagnostic("/steps/render~1one~0x/output/5/target", stepId),
diagnostic("bindings[6].target", stepId),
diagnostic("/steps/render~1one~0x/input", stepId),
diagnostic("nodes[4].input[7].target", stepId),
diagnostic("bindings[8].target", null),
diagnostic("not a row location", stepId),
diagnostic("/steps/other/input/9/path", stepId),
diagnostic("/steps/render~1one~0x/input/10/~2path", null),
];
const inputs = bindingDiagnosticsForStep(diagnostics, stepId, "input", 2);
expect(inputs.rowIssues[1]?.[0]?.path).toBe("nodes[2].input[1].target");
expect(inputs.rowIssues[4]?.[0]?.path).toBe("/steps/render~1one~0x/input/4/path");
expect(inputs.rowIssues[7]).toBeUndefined();
expect(inputs.unmatchedIssues.map((item) => item.path)).toEqual([
"nodes[2].output[3].target",
"/steps/render~1one~0x/output/5/target",
"/steps/render~1one~0x/input",
"nodes[4].input[7].target",
"bindings[8].target",
"not a row location",
"/steps/other/input/9/path",
"/steps/render~1one~0x/input/10/~2path",
]);
const outputs = bindingDiagnosticsForStep(diagnostics, stepId, "output", 2);
expect(outputs.rowIssues[3]?.[0]?.path).toBe("nodes[2].output[3].target");
expect(outputs.rowIssues[5]?.[0]?.path).toBe("/steps/render~1one~0x/output/5/target");
expect(outputs.rowIssues[6]?.[0]?.path).toBe("bindings[6].target");
});
it("maps a compiled row diagnostic using only the explicit node index", () => {
const stepId = "render";
const inputOnly = [diagnostic("nodes[3].input[4].target", stepId)];
const outputOnly = [diagnostic("nodes[3].output[2].target", stepId)];
expect(bindingDiagnosticsForStep(inputOnly, stepId, "input", 3).rowIssues[4]?.[0]?.path).toBe(
"nodes[3].input[4].target",
);
expect(bindingDiagnosticsForStep(outputOnly, stepId, "output", 3).rowIssues[2]?.[0]?.path).toBe(
"nodes[3].output[2].target",
);
expect(bindingDiagnosticsForStep(inputOnly, stepId, "input", 2).unmatchedIssues).toEqual(inputOnly);
expect(bindingDiagnosticsForStep(outputOnly, stepId, "output", 2).unmatchedIssues).toEqual(outputOnly);
expect(bindingDiagnosticsForStep(inputOnly, stepId, "input", null).unmatchedIssues).toEqual(inputOnly);
});
});