Files
Blizzard 4bec95fde1 fix(dispatcher): Branch else 兜底 + 选路留痕(T4.E)
- branchNode 识别 default/else 边:主选择(true/false 或边序)未命中任何下游时,
  走显式 default 边而非悄悄落 END
- trace 明确区分「走 default / 未匹配收口结束(END) / 正常选路」,
  杜绝静默 fall-through 被误当 bug
- compose 层原有 len(chosen)==0 → END 收口保留(无 default 时的安全兜底)
- 3 断言单测:default 命中 / 条件真走 true / 无 default 未命中仍返回空

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:08:05 +08:00

164 lines
5.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package eino
import (
"testing"
"github.com/sundynix/sundynix-dispatcher/internal/dsl"
)
func TestEvalCondition(t *testing.T) {
b := &board{refs: []string{"a", "b"}, toolOut: []string{"x"}, answer: "12345", profile: "p"}
cases := []struct {
cond string
want bool
}{
{"", true}, // 空 → 真
{"2>1", true}, // 字面量比较
{"1>2", false}, //
{"refs > 0", true}, // refs=2
{"refs == 2", true}, //
{"refs < 1", false}, //
{"tools >= 1", true}, // tools=1
{"tools != 0", true}, //
{"answer <= 5", true}, // answer 长度 5
{"answer > 5", false}, //
{"profile == 1", true},
{"乱写的条件", true}, // 非空但无法解析 → 默认真
}
for _, c := range cases {
if got := evalCondition(c.cond, b); got != c.want {
t.Errorf("evalCondition(%q) = %v, want %v", c.cond, got, c.want)
}
}
}
func TestResolveOperand(t *testing.T) {
b := &board{refs: []string{"a"}, toolOut: []string{"x", "y"}, answer: "汉字三", profile: ""}
cases := []struct {
s string
want float64
}{
{"refs", 1},
{"tools", 2},
{"answer", 3}, // 3 个 rune
{"profile", 0},
{"42", 42},
{"unknown", 0}, // 非关键字非数字 → 0
}
for _, c := range cases {
if got := resolveOperand(c.s, b); got != c.want {
t.Errorf("resolveOperand(%q) = %v, want %v", c.s, got, c.want)
}
}
}
func TestAggregate(t *testing.T) {
parts := []string{"甲", "乙", "甲", " ", ""}
if got := aggregate("拼接", parts); len(got) != 1 || got[0] != "甲\n---\n乙\n---\n甲" {
t.Errorf("拼接 = %v", got)
}
if got := aggregate("去重合并", parts); len(got) != 1 || got[0] != "甲\n---\n乙" {
t.Errorf("去重合并 = %v", got)
}
if got := aggregate("未知策略", []string{"a", "b"}); len(got) != 1 || got[0] != "a\n---\nb" {
t.Errorf("默认应为拼接, got %v", got)
}
if got := aggregate("拼接", []string{"", " "}); got != nil {
t.Errorf("全空应返回 nil, got %v", got)
}
}
func TestBranchNode_Labeled(t *testing.T) {
o := &Orchestrator{}
tr := &execTracer{} // sink 为 nil → 所有发射均空操作
byID := map[string]dsl.Node{"A": {ID: "A"}, "B": {ID: "B"}}
// true 边故意列在第二位,验证按标签而非边序选路。
outs := []dsl.Edge{
{Source: "br", Target: "B", SourceHandle: "false"},
{Source: "br", Target: "A", SourceHandle: "true"},
}
n := dsl.Node{ID: "br", Kind: "branch", Config: map[string]any{"condition": "2>1"}}
if got := o.branchNode(n, &board{}, outs, byID, tr); len(got) != 1 || got[0] != "A" {
t.Errorf("条件真应走 true 标签(A), got %v", got)
}
n.Config["condition"] = "1>2"
if got := o.branchNode(n, &board{}, outs, byID, tr); len(got) != 1 || got[0] != "B" {
t.Errorf("条件假应走 false 标签(B), got %v", got)
}
}
func TestBranchNode_OrderFallback(t *testing.T) {
o := &Orchestrator{}
tr := &execTracer{}
byID := map[string]dsl.Node{"A": {ID: "A"}, "B": {ID: "B"}}
outs := []dsl.Edge{{Source: "br", Target: "A"}, {Source: "br", Target: "B"}} // 无标签
n := dsl.Node{ID: "br", Kind: "branch", Config: map[string]any{"condition": "2>1"}}
if got := o.branchNode(n, &board{}, outs, byID, tr); len(got) != 1 || got[0] != "A" {
t.Errorf("无标签条件真应走第一条边(A), got %v", got)
}
n.Config["condition"] = "1>2"
if got := o.branchNode(n, &board{}, outs, byID, tr); len(got) != 1 || got[0] != "B" {
t.Errorf("无标签条件假应走第二条边(B), got %v", got)
}
// 单出边 + 条件假 → 不继续。
single := []dsl.Edge{{Source: "br", Target: "A"}}
n.Config["condition"] = "1>2"
if got := o.branchNode(n, &board{}, single, byID, tr); len(got) != 0 {
t.Errorf("单边条件假应剪掉, got %v", got)
}
}
func TestBranchNode_DefaultFallback(t *testing.T) {
o := &Orchestrator{}
tr := &execTracer{}
byID := map[string]dsl.Node{"A": {ID: "A"}, "D": {ID: "D"}}
// 只有 true 边 + default 边,无 false 边。
outs := []dsl.Edge{
{Source: "br", Target: "A", SourceHandle: "true"},
{Source: "br", Target: "D", SourceHandle: "default"},
}
n := dsl.Node{ID: "br", Kind: "branch", Config: map[string]any{"condition": "1>2"}}
// 条件假 → true 未命中 → 走 default(D),而非悄悄落 END。
if got := o.branchNode(n, &board{}, outs, byID, tr); len(got) != 1 || got[0] != "D" {
t.Errorf("条件假且无 false 边应走 default(D), got %v", got)
}
// 条件真 → 正常走 true(A)default 不介入。
n.Config["condition"] = "2>1"
if got := o.branchNode(n, &board{}, outs, byID, tr); len(got) != 1 || got[0] != "A" {
t.Errorf("条件真应走 true(A), got %v", got)
}
// 无 default 边时,未命中仍返回空(兜底不凭空造边,交上层收口 END)。
noDef := []dsl.Edge{{Source: "br", Target: "A", SourceHandle: "true"}}
n.Config["condition"] = "1>2"
if got := o.branchNode(n, &board{}, noDef, byID, tr); len(got) != 0 {
t.Errorf("无 default 且未命中应为空, got %v", got)
}
}
func TestSmallHelpers(t *testing.T) {
cfg := map[string]any{"s": " hi ", "n": 7, "b": true, "nil": nil}
if cstr(cfg, "s") != "hi" {
t.Error("cstr 应去空白")
}
if cstr(cfg, "n") != "7" {
t.Error("cstr 非字符串应 fmt 转换")
}
if cstr(cfg, "missing") != "" || cstr(cfg, "nil") != "" || cstr(nil, "x") != "" {
t.Error("cstr 缺失/nil 应空串")
}
if !cbool(cfg, "b") || cbool(cfg, "s") || cbool(nil, "b") {
t.Error("cbool 行为不符")
}
if countLines("") != 0 || countLines("a") != 1 || countLines("a\nb\nc") != 3 {
t.Error("countLines 行为不符")
}
if labelOf(dsl.Node{Label: "L"}, "d") != "L" || labelOf(dsl.Node{}, "d") != "d" {
t.Error("labelOf 行为不符")
}
es := []dsl.Edge{{Target: "x"}, {Target: "y"}}
got := targetsOf(es)
if len(got) != 2 || got[0] != "x" || got[1] != "y" {
t.Errorf("targetsOf = %v", got)
}
}