fix: snapshot selected-step mutation payloads

This commit is contained in:
lda
2026-08-09 23:27:03 +07:00 Verified
parent cfd810389e
commit fd79c1d279
2 changed files with 205 additions and 17 deletions
@@ -681,4 +681,126 @@ describe("useDraftAuthoring", () => {
});
expect(result.current.draft).toBe(canonical);
});
it("reapplies an immutable input snapshot after caller-owned values mutate", async () => {
const initial = workspace({ revision: 7 });
const conflict = workspace({ revision: 7, status: "conflict" });
const reloaded = workspace({ revision: 8, status: "invalid" });
const canonical = workspace({ revision: 9 });
const pathBinding = {
path: { root: "state" as const, parts: ["fallback", "value"] },
target: { root: "local" as const, parts: ["fallback"] },
} satisfies InputBinding;
const literalBinding = {
target: { root: "local" as const, parts: ["options"] },
value: { nested: { items: [{ enabled: true }] } },
} satisfies InputBinding;
const bindings: InputBinding[] = [pathBinding, literalBinding];
const expectedBindings: ReadonlyArray<InputBinding> = [
{
path: { root: "state", parts: ["fallback", "value"] },
target: { root: "local", parts: ["fallback"] },
},
{
target: { root: "local", parts: ["options"] },
value: { nested: { items: [{ enabled: true }] } },
},
];
setStepInputBindings.mockResolvedValueOnce(conflict).mockResolvedValueOnce(canonical);
load.mockResolvedValue(reloaded);
const { result } = renderHook(() => useDraftAuthoring({
draft: initial,
initialSelection: { kind: "node", nodeId: "render" },
}));
await act(async () => result.current.setStepInputs(bindings));
pathBinding.path.parts[0] = "context";
pathBinding.target.parts[0] = "mutated-target";
Object.assign(literalBinding, { target: "mutated-target" });
const firstItem = literalBinding.value.nested.items[0];
if (firstItem !== undefined) firstItem.enabled = false;
bindings.push({ target: "added", value: true });
act(() => result.current.select({ kind: "node", nodeId: "publish" }));
await act(async () => result.current.reload());
await act(async () => result.current.reapply());
expect(setStepInputBindings).toHaveBeenLastCalledWith({
workspaceId: "draft-report",
revision: 8,
stepId: "render",
bindings: expectedBindings,
});
});
it("reapplies an immutable output snapshot after caller-owned values mutate", async () => {
const initial = workspace({ revision: 7 });
const conflict = workspace({ revision: 7, status: "conflict" });
const reloaded = workspace({ revision: 8, status: "invalid" });
const canonical = workspace({ revision: 9 });
const firstBinding = {
source: { root: "local" as const, parts: ["text"] },
target: { root: "state" as const, parts: ["report"] },
} satisfies OutputBinding;
const secondBinding = {
source: { root: "local" as const, parts: ["text"] },
target: "state.audit.latest",
} satisfies OutputBinding;
const bindings: OutputBinding[] = [firstBinding, secondBinding];
const expectedBindings: ReadonlyArray<OutputBinding> = [
{
source: { root: "local", parts: ["text"] },
target: { root: "state", parts: ["report"] },
},
{ source: { root: "local", parts: ["text"] }, target: "state.audit.latest" },
];
setStepOutputBindings.mockResolvedValueOnce(conflict).mockResolvedValueOnce(canonical);
load.mockResolvedValue(reloaded);
const { result } = renderHook(() => useDraftAuthoring({
draft: initial,
initialSelection: { kind: "node", nodeId: "render" },
}));
await act(async () => result.current.setStepOutputs(bindings));
firstBinding.source.parts[0] = "mutated-source";
firstBinding.target.parts[0] = "mutated-target";
Object.assign(secondBinding, { source: "mutated-source", target: "mutated-target" });
bindings.push({ source: "added", target: "state.added" });
act(() => result.current.select({ kind: "node", nodeId: "publish" }));
await act(async () => result.current.reload());
await act(async () => result.current.reapply());
expect(setStepOutputBindings).toHaveBeenLastCalledWith({
workspaceId: "draft-report",
revision: 8,
stepId: "render",
bindings: expectedBindings,
});
});
it("reapplies an immutable setup patch after the caller mutates it", async () => {
const initial = workspace({ revision: 7 });
const conflict = workspace({ revision: 7, status: "conflict" });
const reloaded = workspace({ revision: 8, status: "invalid" });
const canonical = workspace({ revision: 9 });
const patch = { description: "Original", retry: 0, timeoutSeconds: null } satisfies CapabilitySetupPatch;
updateCapabilityStep.mockResolvedValueOnce(conflict).mockResolvedValueOnce(canonical);
load.mockResolvedValue(reloaded);
const { result } = renderHook(() => useDraftAuthoring({
draft: initial,
initialSelection: { kind: "node", nodeId: "render" },
}));
await act(async () => result.current.updateSetup(patch));
Object.assign(patch, { description: "Mutated", retry: 4, timeoutSeconds: 30 });
act(() => result.current.select({ kind: "node", nodeId: "publish" }));
await act(async () => result.current.reload());
await act(async () => result.current.reapply());
expect(updateCapabilityStep).toHaveBeenLastCalledWith({
workspaceId: "draft-report",
revision: 8,
stepId: "render",
update: { description: "Original", retry: 0, timeoutSeconds: null },
});
});
});
@@ -156,6 +156,63 @@ const mutationKey = (kind: string, input: unknown, revision: number): string =>
return `${kind}:${revision}:${encoded ?? "undefined"}`;
};
const copyJsonValue = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(copyJsonValue);
if (value !== null && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, nestedValue]) => [key, copyJsonValue(nestedValue)]),
);
}
return value;
};
const copyInputBinding = (binding: InputBinding): InputBinding => {
if ("path" in binding) {
return {
path:
typeof binding.path === "string"
? binding.path
: { root: binding.path.root, parts: [...binding.path.parts] },
target:
typeof binding.target === "string"
? binding.target
: { root: binding.target.root, parts: [...binding.target.parts] },
};
}
return {
target:
typeof binding.target === "string"
? binding.target
: { root: binding.target.root, parts: [...binding.target.parts] },
value: copyJsonValue(binding.value),
};
};
const copyOutputBinding = (binding: OutputBinding): OutputBinding => ({
source:
typeof binding.source === "string"
? binding.source
: { root: binding.source.root, parts: [...binding.source.parts] },
target:
typeof binding.target === "string"
? binding.target
: { root: binding.target.root, parts: [...binding.target.parts] },
});
const copyInputBindings = (
bindings: ReadonlyArray<InputBinding>,
): ReadonlyArray<InputBinding> => bindings.map(copyInputBinding);
const copyOutputBindings = (
bindings: ReadonlyArray<OutputBinding>,
): ReadonlyArray<OutputBinding> => bindings.map(copyOutputBinding);
const copySetupPatch = (patch: CapabilitySetupPatch): CapabilitySetupPatch => ({
...(patch.description !== undefined ? { description: patch.description } : {}),
...(patch.retry !== undefined ? { retry: patch.retry } : {}),
...(patch.timeoutSeconds !== undefined ? { timeoutSeconds: patch.timeoutSeconds } : {}),
});
export const useDraftAuthoring = ({
draft: initialDraft,
initialSelection = canvasSelection,
@@ -442,14 +499,19 @@ export const useDraftAuthoring = ({
patch: CapabilitySetupPatch,
allowTargetSelectionChange = false,
): Promise<void> => {
const submittedPatch = copySetupPatch(patch);
const update = {
...(patch.description !== undefined ? { description: patch.description } : {}),
...(patch.retry !== undefined ? { retry: patch.retry } : {}),
...(patch.timeoutSeconds !== undefined ? { timeoutSeconds: patch.timeoutSeconds } : {}),
...(submittedPatch.description !== undefined
? { description: submittedPatch.description }
: {}),
...(submittedPatch.retry !== undefined ? { retry: submittedPatch.retry } : {}),
...(submittedPatch.timeoutSeconds !== undefined
? { timeoutSeconds: submittedPatch.timeoutSeconds }
: {}),
};
return runMutation(
"setup",
{ targetStepId, patch },
{ targetStepId, patch: submittedPatch },
(client, requestDraft) =>
client.updateCapabilityStep({
workspaceId: requestDraft.workspaceId,
@@ -460,7 +522,7 @@ export const useDraftAuthoring = ({
{
targetStepId,
allowTargetSelectionChange,
submission: { kind: "setup", targetStepId, patch },
submission: { kind: "setup", targetStepId, patch: submittedPatch },
},
);
},
@@ -472,23 +534,25 @@ export const useDraftAuthoring = ({
targetStepId: string,
bindings: ReadonlyArray<InputBinding>,
allowTargetSelectionChange = false,
): Promise<void> =>
runMutation(
): Promise<void> => {
const submittedBindings = copyInputBindings(bindings);
return runMutation(
"inputs",
{ targetStepId, bindings },
{ targetStepId, bindings: submittedBindings },
(client, requestDraft) =>
client.setStepInputBindings({
workspaceId: requestDraft.workspaceId,
revision: requestDraft.revision,
stepId: targetStepId,
bindings,
bindings: submittedBindings,
}),
{
targetStepId,
allowTargetSelectionChange,
submission: { kind: "inputs", targetStepId, bindings },
submission: { kind: "inputs", targetStepId, bindings: submittedBindings },
},
),
);
},
[runMutation],
);
@@ -497,23 +561,25 @@ export const useDraftAuthoring = ({
targetStepId: string,
bindings: ReadonlyArray<OutputBinding>,
allowTargetSelectionChange = false,
): Promise<void> =>
runMutation(
): Promise<void> => {
const submittedBindings = copyOutputBindings(bindings);
return runMutation(
"outputs",
{ targetStepId, bindings },
{ targetStepId, bindings: submittedBindings },
(client, requestDraft) =>
client.setStepOutputBindings({
workspaceId: requestDraft.workspaceId,
revision: requestDraft.revision,
stepId: targetStepId,
bindings,
bindings: submittedBindings,
}),
{
targetStepId,
allowTargetSelectionChange,
submission: { kind: "outputs", targetStepId, bindings },
submission: { kind: "outputs", targetStepId, bindings: submittedBindings },
},
),
);
},
[runMutation],
);