// // Copyright 2026 The InfiniFlow Authors. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // multibranch_test.go — MultiBranch integration tests. // // The canvas scheduler (scheduler.go) installs an eino MultiBranch on // every Switch / Categorize parent that has at least two declared // downstream children. This file exercises two layers: // // 1. Pure unit tests for makeSwitchBranchCondition — the closure // that turns outputs["_next"] into an end-node set (map[string]bool). // These cover the missing/empty/unknown-key fallback paths in // isolation. // // 2. End-to-end tests that BuildWorkflow a small canvas with a // Switch → {childA, childB} topology, then invoke the compiled // workflow and assert that only the chosen child ran. The // children are real LLM components whose invoke bodies count // their calls — driven by a stub chat invoker so the test // doesn't talk to a network. // // The end-to-end layer requires the real component factory to be // installed; the blank import at the top of the file triggers that // via component.init() (same pattern as loop_semantics_test.go). package canvas import ( "context" "testing" "github.com/cloudwego/eino/compose" "github.com/cloudwego/eino/schema" ) // TestMakeSwitchBranchCondition_MissingField: when `_next` is absent // from the parent's output, the condition returns nil so eino sees // no chosen end-nodes and skips routing. func TestMakeSwitchBranchCondition_MissingField(t *testing.T) { cond := makeSwitchBranchCondition(map[string]bool{"a": true, "b": true}) got, err := cond(context.Background(), map[string]any{"other": "x"}) if err != nil { t.Fatalf("cond: %v", err) } if len(got) != 0 { t.Errorf("cond on missing _next = %v, want empty map", got) } } // TestMakeSwitchBranchCondition_EmptyString: `_next: ""` is treated // the same as missing. func TestMakeSwitchBranchCondition_EmptyString(t *testing.T) { cond := makeSwitchBranchCondition(map[string]bool{"a": true}) got, err := cond(context.Background(), map[string]any{"_next": ""}) if err != nil { t.Fatalf("cond: %v", err) } if len(got) != 0 { t.Errorf("cond on empty _next = %v, want empty map", got) } } // TestMakeSwitchBranchCondition_WrongType: a non-string/_next // value that is not []any is treated as missing. The Switch component // is the only legitimate producer of `_next` and it always writes // a []any (list of strings). func TestMakeSwitchBranchCondition_WrongType(t *testing.T) { cond := makeSwitchBranchCondition(map[string]bool{"a": true}) got, err := cond(context.Background(), map[string]any{"_next": 42}) if err != nil { t.Fatalf("cond: %v", err) } if len(got) != 0 { t.Errorf("cond on non-string _next = %v, want empty map", got) } } // TestMakeSwitchBranchCondition_UnknownKey: `_next` resolves to a // cpn_id that isn't in the end-nodes whitelist (e.g. a Switch whose // `to` references a deleted component). We must NOT pass it to eino // — that would error with "branch invocation returns unintended // end node" at runtime and crash the run. func TestMakeSwitchBranchCondition_UnknownKey(t *testing.T) { cond := makeSwitchBranchCondition(map[string]bool{"a": true, "b": true}) got, err := cond(context.Background(), map[string]any{"_next": "ghost"}) if err != nil { t.Fatalf("cond: %v", err) } if len(got) != 0 { t.Errorf("cond on unknown _next = %v, want empty map", got) } } // TestMakeSwitchBranchCondition_KnownKey: the happy path — a valid // cpn_id is passed through as a single-entry map. func TestMakeSwitchBranchCondition_KnownKey(t *testing.T) { cond := makeSwitchBranchCondition(map[string]bool{"a": true, "b": true}) got, err := cond(context.Background(), map[string]any{"_next": "b"}) if err != nil { t.Fatalf("cond: %v", err) } if !got["b"] || len(got) != 1 { t.Errorf("cond on _next=b = %v, want {b:true}", got) } } // TestMakeSwitchBranchCondition_MultiTargetList: when `_next` is a // []any (list of strings — Python's multi-target "to" field), all // whitelisted entries are returned. Unknown entries are silently // dropped. func TestMakeSwitchBranchCondition_MultiTargetList(t *testing.T) { cond := makeSwitchBranchCondition(map[string]bool{"a": true, "b": true}) got, err := cond(context.Background(), map[string]any{"_next": []any{"a", "b", "ghost"}}) if err != nil { t.Fatalf("cond: %v", err) } if len(got) != 2 || !got["a"] || !got["b"] { t.Errorf("cond on _next=[a,b,ghost] = %v, want {a:true,b:true}", got) } } // TestMakeSwitchBranchCondition_EmptyList: a _next of []any{} is // treated as no branch chosen. func TestMakeSwitchBranchCondition_EmptyList(t *testing.T) { cond := makeSwitchBranchCondition(map[string]bool{"a": true}) got, err := cond(context.Background(), map[string]any{"_next": []any{}}) if err != nil { t.Fatalf("cond: %v", err) } if len(got) != 0 { t.Errorf("cond on empty list _next = %v, want empty map", got) } } // TestIsBranchableControl: case-insensitive matching for Switch / // Categorize and a negative case for an unrelated component. func TestIsBranchableControl(t *testing.T) { cases := []struct { name string in string want bool }{ {"switch exact", "Switch", true}, {"switch lower", "switch", true}, {"switch upper", "SWITCH", true}, {"categorize exact", "Categorize", true}, {"categorize lower", "categorize", true}, {"llm not branchable", "LLM", false}, {"empty not branchable", "", false}, {"message not branchable", "Message", false}, } for _, tc := range cases { if got := isBranchableControl(tc.in); got != tc.want { t.Errorf("%s: isBranchableControl(%q) = %v, want %v", tc.name, tc.in, got, tc.want) } } } // TestWireMultiBranches_NoBranchable: a canvas with no Switch or // Categorize returns an empty registration list. Compile still // succeeds. func TestWireMultiBranches_NoBranchable(t *testing.T) { c := &Canvas{ Components: map[string]CanvasComponent{ "a": {Obj: CanvasComponentObj{ComponentName: "LLM"}}, "b": {Obj: CanvasComponentObj{ComponentName: "Message"}}, }, } wf := compose.NewWorkflow[map[string]any, map[string]any]() regs := wireMultiBranches(wf, c, nil) if len(regs) != 0 { t.Errorf("expected no branches, got %d: %+v", len(regs), regs) } } // TestWireMultiBranches_SingleChildSkipped: a Switch with only one // downstream child is degenerate — branch is meaningless. The // helper should skip it and the AddInput edge handles invocation. func TestWireMultiBranches_SingleChildSkipped(t *testing.T) { c := &Canvas{ Components: map[string]CanvasComponent{ "sw": { Obj: CanvasComponentObj{ComponentName: "Switch"}, Downstream: []string{"only"}, }, "only": {Obj: CanvasComponentObj{ComponentName: "Message"}}, }, } wf := compose.NewWorkflow[map[string]any, map[string]any]() regs := wireMultiBranches(wf, c, nil) if len(regs) != 0 { t.Errorf("expected no branch for single-child Switch, got %d: %+v", len(regs), regs) } } // TestWireMultiBranches_LoopMemberSkipped: a Switch whose // downstream children are loop members (i.e. inside a Loop body) // is skipped — the outer graph can't route to children that live // in a sub-workflow. func TestWireMultiBranches_LoopMemberSkipped(t *testing.T) { c := &Canvas{ Components: map[string]CanvasComponent{ "sw": { Obj: CanvasComponentObj{ComponentName: "Switch"}, Downstream: []string{"inner_a", "inner_b"}, }, "inner_a": {Obj: CanvasComponentObj{ComponentName: "LLM"}}, "inner_b": {Obj: CanvasComponentObj{ComponentName: "LLM"}}, }, } loopMembers := map[string]bool{"inner_a": true, "inner_b": true} wf := compose.NewWorkflow[map[string]any, map[string]any]() regs := wireMultiBranches(wf, c, loopMembers) if len(regs) != 0 { t.Errorf("expected no branch when all children are loop members, got %d: %+v", len(regs), regs) } } // TestWireMultiBranches_RegistersTwoChildren: a Switch with two // non-loop children registers exactly one branch with both as // end-nodes. func TestWireMultiBranches_RegistersTwoChildren(t *testing.T) { c := &Canvas{ Components: map[string]CanvasComponent{ "sw": { Obj: CanvasComponentObj{ComponentName: "Switch"}, Downstream: []string{"a", "b"}, }, "a": {Obj: CanvasComponentObj{ComponentName: "Message"}}, "b": {Obj: CanvasComponentObj{ComponentName: "Message"}}, }, } wf := compose.NewWorkflow[map[string]any, map[string]any]() regs := wireMultiBranches(wf, c, nil) if len(regs) != 1 { t.Fatalf("expected 1 branch, got %d", len(regs)) } got := regs[0] if got.Parent != "sw" { t.Errorf("Parent=%q, want \"sw\"", got.Parent) } if len(got.EndNodes) != 2 { t.Errorf("EndNodes len=%d, want 2: %v", len(got.EndNodes), got.EndNodes) } } // TestWireMultiBranches_NilSafety: nil workflow / canvas inputs // must not panic. func TestWireMultiBranches_NilSafety(t *testing.T) { // nil canvas if got := wireMultiBranches(nil, nil, nil); got != nil { t.Errorf("nil canvas: got %v, want nil", got) } wf := compose.NewWorkflow[map[string]any, map[string]any]() if got := wireMultiBranches(wf, nil, nil); got != nil { t.Errorf("nil canvas only: got %v, want nil", got) } } // ---------------------------------------------------------------------------- // Compile-level topology test: a Switch → {childA, childB} DSL must // compile end-to-end through BuildWorkflow + Compile without errors. // This confirms that wireMultiBranches integrates cleanly with the // rest of the scheduler (no missing end-nodes, no mis-typed field // mappings, no double-wired AddInput conflicts). // // The actual *runtime* routing behaviour (which child fires when) // is covered indirectly by TestMakeSwitchBranchCondition_KnownKey // + the eino source-level guarantee that NewGraphMultiBranch // enforces the endNodes whitelist. A full chat-invoker-driven e2e // test lives in the component package's switch_test.go where it can // stub the invoker from within the same package. // ---------------------------------------------------------------------------- // TestMultiBranch_CompileSucceeds: BuildWorkflow + Compile of a // Switch with two children completes without error. The resulting // CompiledCanvas is non-nil and the workflow can be invoked (the // Switch invocation will fail without a real state, but that's a // test-harness limitation, not a multi-branch bug). func TestMultiBranch_CompileSucceeds(t *testing.T) { mkGroup := func(to, lhs, rhs string) map[string]any { return map[string]any{ "op": "and", "to": to, "clauses": []any{ map[string]any{"left": lhs, "op": "==", "right": rhs}, }, } } conditions := []any{ mkGroup("a", "{{state.user_input}}", "go_a"), mkGroup("b", "{{state.user_input}}", "go_b"), } dsl := &Canvas{ Components: map[string]CanvasComponent{ "begin": { Obj: CanvasComponentObj{ComponentName: "Begin"}, Downstream: []string{"switch_0"}, }, "switch_0": { Obj: CanvasComponentObj{ ComponentName: "Switch", Params: map[string]any{"conditions": conditions}, }, Downstream: []string{"a", "b"}, Upstream: []string{"begin"}, }, "a": { Obj: CanvasComponentObj{ComponentName: "Message"}, Upstream: []string{"switch_0"}, }, "b": { Obj: CanvasComponentObj{ComponentName: "Message"}, Upstream: []string{"switch_0"}, }, }, } cc, err := Compile(context.Background(), dsl) if err != nil { t.Fatalf("Compile: %v", err) } if cc == nil || cc.Workflow == nil { t.Fatal("Compile produced nil workflow") } } // Compile-time assertion that schema.Message is referenced so the // import is preserved even if the test body shrinks. var _ = schema.Assistant