fix: close selected-step dataflow review gaps
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { act, cleanup, render, screen, within } from "@testing-library/react";
|
||||
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CapabilityDetail } from "../domain/capability-models.js";
|
||||
@@ -258,6 +258,86 @@ describe("DraftWorkbench", () => {
|
||||
).toHaveValue("title");
|
||||
});
|
||||
|
||||
it("rehydrates all selected-step tabs through the graph selection boundary", async () => {
|
||||
setViewport(1024);
|
||||
mockedUseAuthoringCapabilityDetail.mockReturnValue({
|
||||
phase: "ready",
|
||||
detail: dataflowCapabilityDetail,
|
||||
message: null,
|
||||
});
|
||||
const twoNodeWorkspace: DraftWorkspace = {
|
||||
...workspace,
|
||||
summary: { ...workspace.summary, start: "first", steps: ["first", "second"] },
|
||||
draft: {
|
||||
name: "review-workflow",
|
||||
start: "first",
|
||||
steps: {
|
||||
first: {
|
||||
use: "demo.collect",
|
||||
desc: "First setup",
|
||||
retry: 1,
|
||||
timeout_seconds: 11,
|
||||
input: [{ target: "title", value: "first input" }],
|
||||
output: [{ source: "text", target: "state.first" }],
|
||||
},
|
||||
second: {
|
||||
use: "demo.collect",
|
||||
desc: "Second setup",
|
||||
retry: 2,
|
||||
timeout_seconds: 22,
|
||||
input: [{ target: "title", value: "second input" }],
|
||||
output: [{ source: "text", target: "state.second" }],
|
||||
},
|
||||
},
|
||||
routes: { first: { ok: "second" } },
|
||||
},
|
||||
};
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(
|
||||
<DraftWorkbench
|
||||
draft={twoNodeWorkspace}
|
||||
initialSelection={{ kind: "node", nodeId: "first" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const inspector = screen.getByRole("region", { name: "Context inspector" });
|
||||
expect(within(inspector).getByRole("heading", { name: "first" })).toBeInTheDocument();
|
||||
expect(within(inspector).getByRole("tab", { name: "Setup" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
expect(within(inspector).getByRole("textbox", { name: "Description" })).toHaveValue(
|
||||
"First setup",
|
||||
);
|
||||
await user.click(within(inspector).getByRole("tab", { name: "Inputs" }));
|
||||
expect(within(inspector).getByRole("textbox", { name: "Title" })).toHaveValue(
|
||||
"first input",
|
||||
);
|
||||
await user.click(within(inspector).getByRole("tab", { name: "Outputs" }));
|
||||
const firstOutputPanel = within(inspector).getByRole("tabpanel", { name: "Outputs" });
|
||||
expect(within(firstOutputPanel).getByDisplayValue("state.first")).toBeInTheDocument();
|
||||
|
||||
const secondNode = container.querySelector('[data-node-id="second"]');
|
||||
expect(secondNode).not.toBeNull();
|
||||
fireEvent.click(secondNode as HTMLElement);
|
||||
|
||||
expect(within(inspector).getByRole("heading", { name: "second" })).toBeInTheDocument();
|
||||
expect(within(inspector).getByRole("tab", { name: "Setup" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
expect(within(inspector).getByRole("textbox", { name: "Description" })).toHaveValue(
|
||||
"Second setup",
|
||||
);
|
||||
await user.click(within(inspector).getByRole("tab", { name: "Inputs" }));
|
||||
expect(within(inspector).getByRole("textbox", { name: "Title" })).toHaveValue(
|
||||
"second input",
|
||||
);
|
||||
await user.click(within(inspector).getByRole("tab", { name: "Outputs" }));
|
||||
const secondOutputPanel = within(inspector).getByRole("tabpanel", { name: "Outputs" });
|
||||
expect(within(secondOutputPanel).getByDisplayValue("state.second")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reopens an open desktop sheet as a modal after resizing to mobile", async () => {
|
||||
setViewport(1024);
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -77,7 +77,10 @@ describe("SelectedCapabilityInspector", () => {
|
||||
{ target: "title", value: "Existing title" },
|
||||
{ target: "broken", value: () => "not JSON" },
|
||||
],
|
||||
[{ source: "text", target: "state.existing" }],
|
||||
[
|
||||
{ source: "text", target: "state.existing" },
|
||||
{ source: "text", target: "not-state" },
|
||||
],
|
||||
);
|
||||
const controller = controllerFor(workspace);
|
||||
|
||||
@@ -111,6 +114,17 @@ describe("SelectedCapabilityInspector", () => {
|
||||
expect(controller.setStepInputs).toHaveBeenCalledWith([{ target: "title", value: "Existing title" }]);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Outputs" }));
|
||||
expect(screen.getByRole("region", { name: "Raw unsupported output row 2" })).toHaveTextContent(
|
||||
"not-state",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Save outputs" }));
|
||||
expect(controller.setStepOutputs).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "Clear outputs" }));
|
||||
expect(controller.setStepOutputs).not.toHaveBeenCalled();
|
||||
expect(screen.getAllByRole("alert").some((alert) =>
|
||||
alert.textContent?.includes("Remove or repair this unsupported output row before clearing outputs.") ?? false,
|
||||
)).toBe(true);
|
||||
await user.click(screen.getByRole("button", { name: "Remove unsupported output row 2" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save outputs" }));
|
||||
expect(controller.setStepOutputs).toHaveBeenCalledWith([
|
||||
{ source: "text", target: "state.existing" },
|
||||
|
||||
@@ -92,6 +92,28 @@ describe("projectAuthoringGraph", () => {
|
||||
const empty = projectAuthoringGraph({ ...draft, steps: { collect: { use: "demo.collect" } } });
|
||||
expect(empty.nodes.find((node) => node.id === "collect")?.data.summary).toBeUndefined();
|
||||
});
|
||||
|
||||
it("summarizes canonical bindings for compiled array-shaped nodes", () => {
|
||||
const model = projectAuthoringGraph({
|
||||
nodes: [
|
||||
{
|
||||
id: "collect",
|
||||
type: "node",
|
||||
node: "demo.collect",
|
||||
input: [
|
||||
{ target: "title", value: "Report" },
|
||||
{ target: "count", path: "input.count" },
|
||||
],
|
||||
output: [{ source: "text", target: "state.report" }],
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
expect(model.nodes.find((node) => node.id === "collect")?.data.summary).toBe(
|
||||
"2 inputs · 1 state write",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkbenchSelection", () => {
|
||||
|
||||
@@ -53,6 +53,19 @@ const stepKind = (step: JsonRecord): string => {
|
||||
return "unsupported";
|
||||
};
|
||||
|
||||
const bindingSummary = (input: unknown, output: unknown): Readonly<Record<string, string>> => {
|
||||
const inputCount = inputBindingRows(input).filter((row) => row.kind === "canonical").length;
|
||||
const outputCount = outputBindingRows(output).filter((row) => row.kind === "canonical").length;
|
||||
if (inputCount === 0 && outputCount === 0) return {};
|
||||
const inputLabel = `${inputCount} input${inputCount === 1 ? "" : "s"}`;
|
||||
const outputLabel = `${outputCount} state write${outputCount === 1 ? "" : "s"}`;
|
||||
return {
|
||||
summary: [inputCount > 0 ? inputLabel : null, outputCount > 0 ? outputLabel : null]
|
||||
.filter((value): value is string => value !== null)
|
||||
.join(" · "),
|
||||
};
|
||||
};
|
||||
|
||||
const nodeForStep = (id: string, step: JsonRecord): JsonRecord => {
|
||||
const kind = stepKind(step);
|
||||
const payload = recordValue(step[kind]);
|
||||
@@ -68,17 +81,7 @@ const nodeForStep = (id: string, step: JsonRecord): JsonRecord => {
|
||||
detail: stringValue(step.desc),
|
||||
};
|
||||
|
||||
if (kind === "use") {
|
||||
const inputCount = inputBindingRows(step.input).filter((row) => row.kind === "canonical").length;
|
||||
const outputCount = outputBindingRows(step.output).filter((row) => row.kind === "canonical").length;
|
||||
if (inputCount > 0 || outputCount > 0) {
|
||||
const inputLabel = `${inputCount} input${inputCount === 1 ? "" : "s"}`;
|
||||
const outputLabel = `${outputCount} state write${outputCount === 1 ? "" : "s"}`;
|
||||
node.summary = [inputCount > 0 ? inputLabel : null, outputCount > 0 ? outputLabel : null]
|
||||
.filter((value): value is string => value !== null)
|
||||
.join(" · ");
|
||||
}
|
||||
}
|
||||
if (kind === "use") Object.assign(node, bindingSummary(step.input, step.output));
|
||||
|
||||
if (kind === "use") node.node = stringValue(step.use) ?? id;
|
||||
if (kind === "interrupt") {
|
||||
@@ -154,7 +157,10 @@ const compiledPlan = (draft: JsonRecord): {
|
||||
const rawEdges = Array.isArray(draft.edges)
|
||||
? copiedRecordArray(draft.edges)
|
||||
: routesForSteps(recordValue(draft.routes));
|
||||
const nodes = rawNodes;
|
||||
const nodes = rawNodes.map((node) => ({
|
||||
...node,
|
||||
...bindingSummary(node.input, node.output),
|
||||
}));
|
||||
const nodeIds = nodeIdsFor(nodes);
|
||||
|
||||
for (const edge of rawEdges) {
|
||||
|
||||
Reference in New Issue
Block a user