diff --git a/sundynix-dispatcher/internal/dsl/compile_test.go b/sundynix-dispatcher/internal/dsl/compile_test.go new file mode 100644 index 0000000..e7c0e84 --- /dev/null +++ b/sundynix-dispatcher/internal/dsl/compile_test.go @@ -0,0 +1,102 @@ +package dsl + +import ( + "encoding/json" + "testing" +) + +func graphJSON(t *testing.T, f Flow) json.RawMessage { + t.Helper() + b, err := json.Marshal(f) + if err != nil { + t.Fatal(err) + } + return b +} + +func TestTopo_Linear(t *testing.T) { + f := Flow{ + Nodes: []Node{{ID: "i"}, {ID: "a"}, {ID: "o"}}, + Edges: []Edge{{Source: "i", Target: "a"}, {Source: "a", Target: "o"}}, + } + order := f.Topo() + ids := []string{order[0].ID, order[1].ID, order[2].ID} + want := []string{"i", "a", "o"} + for k := range want { + if ids[k] != want[k] { + t.Fatalf("Topo 顺序 = %v, want %v", ids, want) + } + } +} + +func TestTopo_CycleFallback(t *testing.T) { + // 有环:Kahn 排不出,退化为声明序补齐,但不能丢节点。 + f := Flow{ + Nodes: []Node{{ID: "a"}, {ID: "b"}}, + Edges: []Edge{{Source: "a", Target: "b"}, {Source: "b", Target: "a"}}, + } + if got := f.Topo(); len(got) != 2 { + t.Fatalf("有环也应返回全部节点, got %d", len(got)) + } +} + +func TestCompile(t *testing.T) { + raw := graphJSON(t, Flow{Nodes: []Node{ + {ID: "i", Kind: "input", Config: map[string]any{"text": "你好"}}, + {ID: "a", Kind: "agent", Config: map[string]any{"system": "你是助手", "prompt": "再见"}}, + {ID: "t", Kind: "tool", Config: map[string]any{"tool": "wiki_search"}}, + }}) + p := Compile(raw) + if p.System != "你是助手" { + t.Errorf("System = %q", p.System) + } + if p.Query != "你好\n再见" { + t.Errorf("Query = %q", p.Query) + } + if len(p.Tools) != 1 || p.Tools[0] != "wiki_search" { + t.Errorf("Tools = %v", p.Tools) + } +} + +func TestCompile_NoInputFallback(t *testing.T) { + // 有节点但无 input/agent 输入 → System 兜底默认,Query 兜底 "你好"。 + raw := graphJSON(t, Flow{Nodes: []Node{{ID: "t", Kind: "tool", Config: map[string]any{"tool": "wiki_search"}}}}) + p := Compile(raw) + if p.System != defaultSystem { + t.Errorf("System 应兜底, got %q", p.System) + } + if p.Query != "你好" { + t.Errorf("无输入 Query 应兜底为 你好, got %q", p.Query) + } +} + +func TestCompile_EmptyGraphUsesRaw(t *testing.T) { + // 无节点(无法解析为结构化图)→ 兼容旧行为:原文当输入。 + p := Compile(json.RawMessage(`帮我写个东西`)) + if p.Query != "帮我写个东西" { + t.Errorf("空图应把原文当 Query, got %q", p.Query) + } +} + +func TestToolBinding(t *testing.T) { + tool, args := ToolBinding(Node{Kind: "tool", Config: map[string]any{"tool": "x", "args": `{"q":"v"}`}}) + if tool != "x" || args["q"] != "v" { + t.Errorf("tool 节点 = %q %v", tool, args) + } + rtool, rargs := ToolBinding(Node{Kind: "retriever", Config: map[string]any{"kb": "docs"}}) + if rtool != "wiki_search" || rargs["kb"] != "docs" { + t.Errorf("retriever 节点 = %q %v", rtool, rargs) + } + if ntool, _ := ToolBinding(Node{Kind: "agent"}); ntool != "" { + t.Errorf("非工具节点应返回空工具, got %q", ntool) + } +} + +func TestParse(t *testing.T) { + if _, err := Parse(json.RawMessage(`{"nodes":[{"id":"a"}],"edges":[]}`)); err != nil { + t.Errorf("合法 DSL 不应报错: %v", err) + } + if _, err := Parse(json.RawMessage(`{bad json`)); err == nil { + t.Error("非法 JSON 应报错") + } +} diff --git a/sundynix-dispatcher/internal/eino/graph_test.go b/sundynix-dispatcher/internal/eino/graph_test.go new file mode 100644 index 0000000..b6ec042 --- /dev/null +++ b/sundynix-dispatcher/internal/eino/graph_test.go @@ -0,0 +1,136 @@ +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 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) + } +} diff --git a/sundynix-gateway/internal/dsl/parser_test.go b/sundynix-gateway/internal/dsl/parser_test.go new file mode 100644 index 0000000..442ba7e --- /dev/null +++ b/sundynix-gateway/internal/dsl/parser_test.go @@ -0,0 +1,33 @@ +package dsl + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestParseAndAssemble_OK(t *testing.T) { + raw := json.RawMessage(`{"version":"1","nodes":[{"id":"a","kind":"input"}],"edges":[]}`) + task, err := ParseAndAssemble(raw) + if err != nil { + t.Fatalf("合法 DSL 不应报错: %v", err) + } + if !strings.HasPrefix(task.ID, "task_") { + t.Errorf("任务 ID 应以 task_ 前缀, got %q", task.ID) + } + if string(task.Graph) != string(raw) { + t.Error("Graph 应原样透传 DSL") + } + if task.Meta == nil { + t.Error("Meta 应已初始化(供网关注入身份)") + } +} + +func TestParseAndAssemble_Errors(t *testing.T) { + if _, err := ParseAndAssemble(json.RawMessage(``)); err == nil { + t.Error("空 DSL 应报错") + } + if _, err := ParseAndAssemble(json.RawMessage(`{bad`)); err == nil { + t.Error("非法 JSON 应报错") + } +} diff --git a/sundynix-mcp-go/internal/mcp/report_test.go b/sundynix-mcp-go/internal/mcp/report_test.go new file mode 100644 index 0000000..e353303 --- /dev/null +++ b/sundynix-mcp-go/internal/mcp/report_test.go @@ -0,0 +1,30 @@ +package mcp + +import ( + "testing" + + "github.com/sundynix/sundynix-mcp-go/internal/office" +) + +func TestReportMarkdown(t *testing.T) { + src := reportSource{ + Title: "咖啡", + Sections: []office.Section{ + {Heading: "提神", Body: " 含咖啡因 "}, + {Heading: "风险", Body: "过量失眠"}, + }, + } + got := reportMarkdown(src) + want := "# 咖啡\n\n## 提神\n\n含咖啡因\n\n## 风险\n\n过量失眠\n\n" + if got != want { + t.Errorf("reportMarkdown =\n%q\nwant\n%q", got, want) + } +} + +func TestReportMarkdown_NoTitle(t *testing.T) { + got := reportMarkdown(reportSource{Sections: []office.Section{{Heading: "H", Body: "B"}}}) + want := "## H\n\nB\n\n" + if got != want { + t.Errorf("无标题 reportMarkdown = %q, want %q", got, want) + } +} diff --git a/sundynix-mcp-go/internal/office/unioffice_test.go b/sundynix-mcp-go/internal/office/unioffice_test.go new file mode 100644 index 0000000..b7f63a6 --- /dev/null +++ b/sundynix-mcp-go/internal/office/unioffice_test.go @@ -0,0 +1,56 @@ +package office + +import ( + "archive/zip" + "bytes" + "context" + "io" + "strings" + "testing" +) + +func TestRenderReport_ValidDocx(t *testing.T) { + secs := []Section{ + {Heading: "第一章", Body: "正文内容 A&B"}, + {Heading: "第二章", Body: "正文 <内容> B"}, + } + data, err := NewRenderer().RenderReport(context.Background(), "测试报告", secs) + if err != nil { + t.Fatalf("RenderReport err: %v", err) + } + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + t.Fatalf("产物不是合法 zip/docx: %v", err) + } + parts := map[string]string{} + for _, f := range zr.File { + rc, _ := f.Open() + b, _ := io.ReadAll(rc) + rc.Close() + parts[f.Name] = string(b) + } + for _, need := range []string{"[Content_Types].xml", "_rels/.rels", "word/document.xml"} { + if _, ok := parts[need]; !ok { + t.Errorf("docx 缺少部件 %s", need) + } + } + doc := parts["word/document.xml"] + if !strings.Contains(doc, "测试报告") || !strings.Contains(doc, "第一章") || !strings.Contains(doc, "第二章") { + t.Error("document.xml 应含标题与各章标题") + } + // XML 特殊字符必须转义,避免破坏文档。 + if strings.Contains(doc, "A&B") || !strings.Contains(doc, "A&B") { + t.Error("正文 & 应被转义为 &") + } + if strings.Contains(doc, "<内容>") || !strings.Contains(doc, "<内容>") { + t.Error("正文尖括号应被转义") + } +} + +func TestEscapeXML(t *testing.T) { + got := escapeXML(`a&b"d'`) + want := "a&b<c>"d'" + if got != want { + t.Errorf("escapeXML = %q, want %q", got, want) + } +}