test(backend): 编排引擎/DSL/docx/报告导出 首批 Go 单测(19 用例,纯逻辑无依赖)
补齐核心后端逻辑的自动化测试,全部纯函数级、不依赖 docker/NATS/LLM,毫秒级跑完, 把"手动起全栈 curl 验证"变成 `go test`: - dispatcher/internal/eino (graph_test.go) evalCondition(各运算符+refs/tools/answer/profile 关键字+兜底)、resolveOperand、 aggregate(拼接/去重合并/默认/全空)、branchNode(真假边标签精确选路 + 无标签退回边序 + 单边剪枝)、cstr/cbool/countLines/labelOf/targetsOf。 - dispatcher/internal/dsl (compile_test.go) Topo(线性序 + 有环不丢节点)、Compile(system/query/tools 抽取 + 无输入兜底 + 空图用原文)、 ToolBinding(tool/retriever/非工具)、Parse(合法/非法)。 - mcp-go/internal/office (unioffice_test.go) RenderReport 产物为合法 docx(zip 三部件 + 标题/章节文本 + XML 转义)、escapeXML。 - mcp-go/internal/mcp (report_test.go) reportMarkdown(标题+多章 / 无标题)。 - gateway/internal/dsl (parser_test.go) ParseAndAssemble(task_ 前缀 + Graph 透传 + Meta 初始化 + 空/非法报错)。 `go test ./...` 各模块全绿。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 应报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 应报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<c>"d'`)
|
||||||
|
want := "a&b<c>"d'"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("escapeXML = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user