fix: complete task 5 rereview repairs

This commit is contained in:
lda
2026-08-13 22:37:48 +07:00 Verified
parent cca1be891f
commit 0a7996da9f
6 changed files with 330 additions and 33 deletions
@@ -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(