fix: close selected-step dataflow review gaps

This commit is contained in:
lda
2026-08-10 01:20:29 +07:00 Verified
parent 7453d6b21b
commit 14892e89c3
6 changed files with 172 additions and 16 deletions
@@ -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) {