Browse Source
The dimming algorithm processed potentially-dimmed nodes in arbitrary order, causing nodes downstream of a rescued convergence point to stay dimmed. Fix by topologically sorting the candidate set (upstream-first) and checking finalDimmed instead of potentiallyDimmed, so rescued nodes properly propagate their active status downstream. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>handoff-20260429-1057
2 changed files with 310 additions and 3 deletions
@ -0,0 +1,249 @@ |
|||||
|
import { describe, it, expect } from "vitest"; |
||||
|
import { computeDimmedNodes } from "../dimmingUtils"; |
||||
|
import type { WorkflowNode, WorkflowEdge } from "@/types"; |
||||
|
|
||||
|
function makeNode(id: string, type: string, data: Record<string, unknown> = {}): WorkflowNode { |
||||
|
return { id, type, position: { x: 0, y: 0 }, data } as WorkflowNode; |
||||
|
} |
||||
|
|
||||
|
function makeEdge( |
||||
|
source: string, |
||||
|
target: string, |
||||
|
opts: { sourceHandle?: string; targetHandle?: string } = {} |
||||
|
): WorkflowEdge { |
||||
|
return { |
||||
|
id: `${source}-${target}-${opts.sourceHandle ?? "out"}-${opts.targetHandle ?? "in"}`, |
||||
|
source, |
||||
|
target, |
||||
|
sourceHandle: opts.sourceHandle ?? "image", |
||||
|
targetHandle: opts.targetHandle ?? "image", |
||||
|
} as WorkflowEdge; |
||||
|
} |
||||
|
|
||||
|
function makeSwitchNode( |
||||
|
id: string, |
||||
|
switches: Array<{ id: string; enabled: boolean }> |
||||
|
): WorkflowNode { |
||||
|
return makeNode(id, "switch", { |
||||
|
switches: switches.map(s => ({ ...s, label: s.id })), |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function makeConditionalSwitchNode( |
||||
|
id: string, |
||||
|
rules: Array<{ id: string; isMatched: boolean }>, |
||||
|
opts: { evaluationPaused?: boolean } = {} |
||||
|
): WorkflowNode { |
||||
|
return makeNode(id, "conditionalSwitch", { |
||||
|
rules: rules.map(r => ({ ...r, label: r.id })), |
||||
|
evaluationPaused: opts.evaluationPaused ?? false, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
describe("computeDimmedNodes", () => { |
||||
|
it("returns empty set when there are no switch nodes", () => { |
||||
|
const nodes = [makeNode("a", "prompt"), makeNode("b", "nanoBanana")]; |
||||
|
const edges = [makeEdge("a", "b", { sourceHandle: "text", targetHandle: "text" })]; |
||||
|
const dimmed = computeDimmedNodes(nodes, edges); |
||||
|
expect(dimmed.size).toBe(0); |
||||
|
}); |
||||
|
|
||||
|
it("dims nodes downstream of a disabled switch output", () => { |
||||
|
// switch --[disabled]--> gen --> output
|
||||
|
const nodes = [ |
||||
|
makeSwitchNode("sw", [{ id: "out1", enabled: false }]), |
||||
|
makeNode("gen", "nanoBanana"), |
||||
|
makeNode("out", "output"), |
||||
|
]; |
||||
|
const edges = [ |
||||
|
makeEdge("sw", "gen", { sourceHandle: "out1" }), |
||||
|
makeEdge("gen", "out"), |
||||
|
]; |
||||
|
const dimmed = computeDimmedNodes(nodes, edges); |
||||
|
expect(dimmed.has("gen")).toBe(true); |
||||
|
expect(dimmed.has("out")).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("does not dim nodes downstream of an enabled switch output", () => { |
||||
|
const nodes = [ |
||||
|
makeSwitchNode("sw", [{ id: "out1", enabled: true }]), |
||||
|
makeNode("gen", "nanoBanana"), |
||||
|
]; |
||||
|
const edges = [makeEdge("sw", "gen", { sourceHandle: "out1" })]; |
||||
|
const dimmed = computeDimmedNodes(nodes, edges); |
||||
|
expect(dimmed.size).toBe(0); |
||||
|
}); |
||||
|
|
||||
|
it("rescues a node that has an active input replacing the blocked type (convergence)", () => { |
||||
|
// Two paths from a switch converge on "merge":
|
||||
|
// switch --[out1:disabled]--> dimmedPath --> merge
|
||||
|
// switch --[out2:enabled]---> activePath --> merge
|
||||
|
const nodes = [ |
||||
|
makeSwitchNode("sw", [ |
||||
|
{ id: "out1", enabled: false }, |
||||
|
{ id: "out2", enabled: true }, |
||||
|
]), |
||||
|
makeNode("dimmedPath", "nanoBanana"), |
||||
|
makeNode("activePath", "nanoBanana"), |
||||
|
makeNode("merge", "nanoBanana"), |
||||
|
]; |
||||
|
const edges = [ |
||||
|
makeEdge("sw", "dimmedPath", { sourceHandle: "out1" }), |
||||
|
makeEdge("sw", "activePath", { sourceHandle: "out2" }), |
||||
|
makeEdge("dimmedPath", "merge"), |
||||
|
makeEdge("activePath", "merge"), |
||||
|
]; |
||||
|
const dimmed = computeDimmedNodes(nodes, edges); |
||||
|
expect(dimmed.has("dimmedPath")).toBe(true); |
||||
|
expect(dimmed.has("activePath")).toBe(false); |
||||
|
expect(dimmed.has("merge")).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it("keeps nodes downstream of a rescued convergence node active (the main bug)", () => { |
||||
|
// switch --[out1:disabled]--> A --> converge --> downstream
|
||||
|
// switch --[out2:enabled]---> B --> converge --> downstream
|
||||
|
//
|
||||
|
// "converge" is rescued because it has active input from B.
|
||||
|
// "downstream" must also stay active — it was dimmed before this fix.
|
||||
|
const nodes = [ |
||||
|
makeSwitchNode("sw", [ |
||||
|
{ id: "out1", enabled: false }, |
||||
|
{ id: "out2", enabled: true }, |
||||
|
]), |
||||
|
makeNode("A", "nanoBanana"), |
||||
|
makeNode("B", "nanoBanana"), |
||||
|
makeNode("converge", "nanoBanana"), |
||||
|
makeNode("downstream", "output"), |
||||
|
]; |
||||
|
const edges = [ |
||||
|
makeEdge("sw", "A", { sourceHandle: "out1" }), |
||||
|
makeEdge("sw", "B", { sourceHandle: "out2" }), |
||||
|
makeEdge("A", "converge"), |
||||
|
makeEdge("B", "converge"), |
||||
|
makeEdge("converge", "downstream"), |
||||
|
]; |
||||
|
const dimmed = computeDimmedNodes(nodes, edges); |
||||
|
expect(dimmed.has("A")).toBe(true); |
||||
|
expect(dimmed.has("B")).toBe(false); |
||||
|
expect(dimmed.has("converge")).toBe(false); |
||||
|
expect(dimmed.has("downstream")).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it("keeps a chain of nodes downstream of rescued convergence active", () => { |
||||
|
// switch --[disabled]--> A --> converge --> D1 --> D2 --> D3
|
||||
|
// switch --[enabled]---> B --> converge
|
||||
|
const nodes = [ |
||||
|
makeSwitchNode("sw", [ |
||||
|
{ id: "out1", enabled: false }, |
||||
|
{ id: "out2", enabled: true }, |
||||
|
]), |
||||
|
makeNode("A", "nanoBanana"), |
||||
|
makeNode("B", "nanoBanana"), |
||||
|
makeNode("converge", "nanoBanana"), |
||||
|
makeNode("D1", "nanoBanana"), |
||||
|
makeNode("D2", "nanoBanana"), |
||||
|
makeNode("D3", "output"), |
||||
|
]; |
||||
|
const edges = [ |
||||
|
makeEdge("sw", "A", { sourceHandle: "out1" }), |
||||
|
makeEdge("sw", "B", { sourceHandle: "out2" }), |
||||
|
makeEdge("A", "converge"), |
||||
|
makeEdge("B", "converge"), |
||||
|
makeEdge("converge", "D1"), |
||||
|
makeEdge("D1", "D2"), |
||||
|
makeEdge("D2", "D3"), |
||||
|
]; |
||||
|
const dimmed = computeDimmedNodes(nodes, edges); |
||||
|
expect(dimmed.has("A")).toBe(true); |
||||
|
expect(dimmed.has("B")).toBe(false); |
||||
|
expect(dimmed.has("converge")).toBe(false); |
||||
|
expect(dimmed.has("D1")).toBe(false); |
||||
|
expect(dimmed.has("D2")).toBe(false); |
||||
|
expect(dimmed.has("D3")).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
describe("ConditionalSwitch", () => { |
||||
|
it("dims downstream of non-matching rule outputs", () => { |
||||
|
const nodes = [ |
||||
|
makeConditionalSwitchNode("cs", [ |
||||
|
{ id: "rule1", isMatched: true }, |
||||
|
{ id: "rule2", isMatched: false }, |
||||
|
]), |
||||
|
makeNode("active", "nanoBanana"), |
||||
|
makeNode("dimmed", "nanoBanana"), |
||||
|
]; |
||||
|
const edges = [ |
||||
|
makeEdge("cs", "active", { sourceHandle: "rule1" }), |
||||
|
makeEdge("cs", "dimmed", { sourceHandle: "rule2" }), |
||||
|
]; |
||||
|
const dimmed = computeDimmedNodes(nodes, edges); |
||||
|
expect(dimmed.has("active")).toBe(false); |
||||
|
expect(dimmed.has("dimmed")).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("dims default output when a rule matches", () => { |
||||
|
const nodes = [ |
||||
|
makeConditionalSwitchNode("cs", [{ id: "rule1", isMatched: true }]), |
||||
|
makeNode("defaultTarget", "nanoBanana"), |
||||
|
]; |
||||
|
const edges = [ |
||||
|
makeEdge("cs", "defaultTarget", { sourceHandle: "default" }), |
||||
|
]; |
||||
|
const dimmed = computeDimmedNodes(nodes, edges); |
||||
|
expect(dimmed.has("defaultTarget")).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("keeps downstream of rescued convergence active (conditionalSwitch variant)", () => { |
||||
|
// condSwitch --[rule1:matched]--> B --> converge --> downstream
|
||||
|
// condSwitch --[rule2:unmatched]--> A --> converge --> downstream
|
||||
|
const nodes = [ |
||||
|
makeConditionalSwitchNode("cs", [ |
||||
|
{ id: "rule1", isMatched: true }, |
||||
|
{ id: "rule2", isMatched: false }, |
||||
|
]), |
||||
|
makeNode("A", "nanoBanana"), |
||||
|
makeNode("B", "nanoBanana"), |
||||
|
makeNode("converge", "nanoBanana"), |
||||
|
makeNode("downstream", "output"), |
||||
|
]; |
||||
|
const edges = [ |
||||
|
makeEdge("cs", "A", { sourceHandle: "rule2" }), |
||||
|
makeEdge("cs", "B", { sourceHandle: "rule1" }), |
||||
|
makeEdge("A", "converge"), |
||||
|
makeEdge("B", "converge"), |
||||
|
makeEdge("converge", "downstream"), |
||||
|
]; |
||||
|
const dimmed = computeDimmedNodes(nodes, edges); |
||||
|
expect(dimmed.has("A")).toBe(true); |
||||
|
expect(dimmed.has("B")).toBe(false); |
||||
|
expect(dimmed.has("converge")).toBe(false); |
||||
|
expect(dimmed.has("downstream")).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it("skips dimming entirely when evaluationPaused is true", () => { |
||||
|
const nodes = [ |
||||
|
makeConditionalSwitchNode( |
||||
|
"cs", |
||||
|
[{ id: "rule1", isMatched: false }], |
||||
|
{ evaluationPaused: true } |
||||
|
), |
||||
|
makeNode("target", "nanoBanana"), |
||||
|
]; |
||||
|
const edges = [ |
||||
|
makeEdge("cs", "target", { sourceHandle: "rule1" }), |
||||
|
]; |
||||
|
const dimmed = computeDimmedNodes(nodes, edges); |
||||
|
expect(dimmed.size).toBe(0); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it("does not dim the switch node itself", () => { |
||||
|
const nodes = [ |
||||
|
makeSwitchNode("sw", [{ id: "out1", enabled: false }]), |
||||
|
makeNode("target", "nanoBanana"), |
||||
|
]; |
||||
|
const edges = [makeEdge("sw", "target", { sourceHandle: "out1" })]; |
||||
|
const dimmed = computeDimmedNodes(nodes, edges); |
||||
|
expect(dimmed.has("sw")).toBe(false); |
||||
|
}); |
||||
|
}); |
||||
Loading…
Reference in new issue