fix: complete task 5 rereview repairs
This commit is contained in:
@@ -60,6 +60,71 @@ describe("CapabilityNodeForm", () => {
|
||||
).toEqual([expressionBinding]);
|
||||
});
|
||||
|
||||
it("preserves expression and path interleaving on a no-op save", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submissions: CapabilityNodeFormValue[] = [];
|
||||
const expressionBinding = {
|
||||
target: "request",
|
||||
expression: { kind: "literal", value: "wowcool" },
|
||||
} as const;
|
||||
const pathBinding = { target: "title", path: "state.title" } as const;
|
||||
|
||||
render(
|
||||
<CapabilityNodeForm
|
||||
capabilityName="wf.std.concat"
|
||||
initialValue={{ stepId: "concat", inputBindings: [expressionBinding, pathBinding] }}
|
||||
initialInputSources={{ title: { mode: "bind", sourcePath: "state.title" } }}
|
||||
inputSchema={{ type: "object", properties: { title: { type: "string" } } }}
|
||||
onSubmit={(value) => { submissions.push(value); }}
|
||||
submitLabel="Save node"
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Save node" }));
|
||||
|
||||
expect(submissions[0]?.inputBindings).toEqual([expressionBinding, pathBinding]);
|
||||
});
|
||||
|
||||
it("preserves mixed path, expression, and literal interleaving on a no-op save", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submissions: CapabilityNodeFormValue[] = [];
|
||||
const pathBinding = { target: "title", path: "state.title" } as const;
|
||||
const expressionBinding = {
|
||||
target: "request",
|
||||
expression: { kind: "literal", value: "wowcool" },
|
||||
} as const;
|
||||
const valueBinding = { target: "label", value: "Quarterly report" } as const;
|
||||
|
||||
render(
|
||||
<CapabilityNodeForm
|
||||
capabilityName="wf.std.concat"
|
||||
initialValue={{
|
||||
stepId: "concat",
|
||||
inputBindings: [pathBinding, expressionBinding, valueBinding],
|
||||
}}
|
||||
initialInputValue={{ label: "Quarterly report" }}
|
||||
initialInputSources={{ title: { mode: "bind", sourcePath: "state.title" } }}
|
||||
inputSchema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string" },
|
||||
label: { type: "string" },
|
||||
},
|
||||
}}
|
||||
onSubmit={(value) => { submissions.push(value); }}
|
||||
submitLabel="Save node"
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Save node" }));
|
||||
|
||||
expect(submissions[0]?.inputBindings).toEqual([
|
||||
pathBinding,
|
||||
expressionBinding,
|
||||
valueBinding,
|
||||
]);
|
||||
});
|
||||
|
||||
it("submits explicit node metadata and serialized schema bindings", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submissions: unknown[] = [];
|
||||
|
||||
@@ -38,6 +38,62 @@ export type CapabilityNodeFormProps = {
|
||||
readonly hidden?: boolean;
|
||||
};
|
||||
|
||||
const bindingTargetKey = (binding: StepInputBinding): string => {
|
||||
const target = binding.target;
|
||||
return typeof target === "string"
|
||||
? target
|
||||
: `${target.root}:${target.parts.join(".")}`;
|
||||
};
|
||||
|
||||
const bindingKind = (binding: StepInputBinding): "path" | "value" | "expression" => {
|
||||
if ("expression" in binding) return "expression";
|
||||
return "path" in binding ? "path" : "value";
|
||||
};
|
||||
|
||||
/**
|
||||
* Reconcile the legacy serializer with canonical binding slots.
|
||||
*
|
||||
* Expression rows are read-only in this form. Existing simple rows claim
|
||||
* their original kind/target slot first; unmatched serializer rows are new
|
||||
* rows and are appended rather than silently moving existing rows.
|
||||
*/
|
||||
const mergeInputBindings = (
|
||||
original: ReadonlyArray<StepInputBinding>,
|
||||
serialized: ReadonlyArray<StepInputBinding>,
|
||||
): ReadonlyArray<StepInputBinding> => {
|
||||
const used = new Set<number>();
|
||||
const merged: StepInputBinding[] = [];
|
||||
|
||||
for (const originalBinding of original) {
|
||||
if (bindingKind(originalBinding) === "expression") {
|
||||
merged.push(originalBinding);
|
||||
continue;
|
||||
}
|
||||
const kind = bindingKind(originalBinding);
|
||||
const target = bindingTargetKey(originalBinding);
|
||||
const index = serialized.findIndex(
|
||||
(candidate, candidateIndex) =>
|
||||
!used.has(candidateIndex) &&
|
||||
bindingKind(candidate) === kind &&
|
||||
bindingTargetKey(candidate) === target,
|
||||
);
|
||||
const fallbackIndex = serialized.findIndex(
|
||||
(candidate, candidateIndex) =>
|
||||
!used.has(candidateIndex) && bindingKind(candidate) === kind,
|
||||
);
|
||||
const selectedIndex = index === -1 ? fallbackIndex : index;
|
||||
if (selectedIndex !== -1) {
|
||||
used.add(selectedIndex);
|
||||
merged.push(serialized[selectedIndex]!);
|
||||
}
|
||||
}
|
||||
|
||||
serialized.forEach((binding, index) => {
|
||||
if (!used.has(index)) merged.push(binding);
|
||||
});
|
||||
return merged;
|
||||
};
|
||||
|
||||
const optionalNumber = (
|
||||
ref: RefObject<HTMLInputElement | null>,
|
||||
initial: number | null | undefined,
|
||||
@@ -105,14 +161,10 @@ export const CapabilityNodeForm = ({
|
||||
: {}),
|
||||
...(retry === undefined ? {} : { retry }),
|
||||
...(timeoutSeconds === undefined ? {} : { timeoutSeconds }),
|
||||
// The legacy schema editor cannot deserialize expression rows. Keep the
|
||||
// rehydrated rows alongside its fresh simple bindings until Task 6 owns
|
||||
// expression editing, rather than silently deleting them on save.
|
||||
inputBindings: [
|
||||
inputBindings: mergeInputBindings(initialValue?.inputBindings ?? [], [
|
||||
...result.bindings,
|
||||
...result.literalBindings,
|
||||
...(initialValue?.inputBindings?.filter((binding) => "expression" in binding) ?? []),
|
||||
],
|
||||
]),
|
||||
...(routeOutcomes.length > 0
|
||||
? {
|
||||
routes: Object.fromEntries(
|
||||
|
||||
Reference in New Issue
Block a user