From 3d05dcac715fba6b21cc9110b9cc64398f634e3b Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 9 Nov 2026 21:37:53 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E6=8C=89=E6=A8=A1=E5=9D=97=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E6=8E=A5=E5=8F=A3=E9=87=8D=E5=86=99=E9=BB=91=E7=9B=92?= =?UTF-8?q?=E5=88=A4=E6=8D=AE=EF=BC=88=E6=89=B9=E6=AC=A1=2014=EF=BC=9Amcp?= =?UTF-8?q?=20/=20proc=20/=20cfgmgr=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三个模块新增 test/ 黑盒契约,测试仅通过导出 API 驱动。 ## internal/plugins/mcp TestListToolsThroughHTTP TestCallToolSuccessAndEmptyContentHandling TestCallToolSurfacesIsErrorAsFailure TestCallToolSurfacesRPCError TestCallToolSurfacesHTTPError TestListToolsParseSSEStreamFrame 使用 httptest + NewSSETransport + NewServer,覆盖 JSON-RPC 工具列表、 成功调用、空内容失败、MCP isError、RPC 错误、HTTP 错误和 SSE data 帧。 ## internal/plugin/proc TestMajorVersionParsing TestKnownCapabilitiesExcludesCoreAndIsStable TestSharedRefSliceBoundary TestPublicCapabilityConstantsStable 覆盖版本主段解析、能力清单排序/去重/core 排除、共享引用切片边界和能力常量。 ## internal/plugins/cfgmgr TestStartRegistersConfigToolsWithConcurrencyMarkers TestStopIsSafe 通过公开 Start 注入 SDK 工具注册回调,观察七个配置工具及其 Serial/ParallelSafe 并发契约;未访问私有 handler。 ## 变异验证 mcp 忽略 MCP isError → isError 未被判失败 proc MajorVersion 不截断 → 多组版本断言红 cfgmgr 写工具取消 Serial 标记 → 写工具未声明 Serial ## 验证 go build ./... PASS go vet ./... PASS gofmt -l 干净 python3 .github/scripts/check-ci-gates.py PASS go test ./... -count=1 51 个测试包 / 100 包无测试 / 0 FAIL 3 个新包 -race 全绿 源码目录 _test.go 0 --- .../plugin/proc/test/public_contract_test.go | 87 +++++++++++++ .../test/start_registers_contract_test.go | 69 ++++++++++ .../plugins/mcp/test/server_contract_test.go | 120 ++++++++++++++++++ 3 files changed, 276 insertions(+) create mode 100644 internal/plugin/proc/test/public_contract_test.go create mode 100644 internal/plugins/cfgmgr/test/start_registers_contract_test.go create mode 100644 internal/plugins/mcp/test/server_contract_test.go diff --git a/internal/plugin/proc/test/public_contract_test.go b/internal/plugin/proc/test/public_contract_test.go new file mode 100644 index 00000000..e3a89cf1 --- /dev/null +++ b/internal/plugin/proc/test/public_contract_test.go @@ -0,0 +1,87 @@ +package test + +// internal/plugin/proc 的黑盒判据:公开纯函数与值对象语义。 + +import ( + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/plugin/proc" +) + +func TestMajorVersionParsing(t *testing.T) { + cases := []struct{ in, want string }{ + {"1.4.0", "1"}, + {"v2.10.3", "2"}, + {"0.9", "0"}, + {"17", "17"}, + {"", ""}, + {" v3.1 ", "3"}, + } + for _, c := range cases { + if got := proc.MajorVersion(c.in); got != c.want { + t.Errorf("MajorVersion(%q)=%q,期望 %q", c.in, got, c.want) + } + } +} + +func TestKnownCapabilitiesExcludesCoreAndIsStable(t *testing.T) { + first := proc.KnownCapabilities() + second := proc.KnownCapabilities() + if len(first) == 0 { + t.Fatal("KnownCapabilities 为空") + } + for i := range first { + if first[i] == "core" { + t.Fatal("core 能力出现在声明清单里(无需声明)") + } + if i > 0 && first[i-1] >= first[i] { + t.Fatalf("能力清单未按升序:%v", first) + } + } + for i := range first { + if first[i] != second[i] { + t.Fatal("两次调用结果不一致(无确定性)") + } + } +} + +func TestSharedRefSliceBoundary(t *testing.T) { + data := []byte("0123456789") + ok := proc.SharedRef{Offset: 2, Length: 4} + if got := ok.Slice(data); string(got) != "2345" { + t.Fatalf("Slice=%q", got) + } + if ok.IsZero() { + t.Fatal("非零 ref 被判为零值") + } + if !(proc.SharedRef{}).IsZero() { + t.Fatal("零值 ref 未判零") + } + // 越界: offset+length 超出 data 时返回 nil + oob := proc.SharedRef{Offset: 8, Length: 8} + if oob.Slice(data) != nil { + t.Fatalf("越界 Slice 未返回 nil:%q", oob.Slice(data)) + } + // 零值: 长度 0 + if zero := (proc.SharedRef{}).Slice(data); zero != nil { + t.Fatalf("零值 ref Slice 应 nil:%q", zero) + } +} + +func TestPublicCapabilityConstantsStable(t *testing.T) { + want := map[string]proc.Capability{ + "core": proc.CapCore, "io": proc.CapIO, "memory": proc.CapMemory, + "doc_memory": proc.CapDocMemory, "knowledge": proc.CapKnowledge, + "text_memory": proc.CapTextMemory, "llm": proc.CapLLM, + "social": proc.CapSocial, "events": proc.CapEvents, + "plugin_mgr": proc.CapPluginMgr, "kernel_control": proc.CapKernelControl, + "settings_cross": proc.CapCrossPluginSettings, + } + for name, cap := range want { + if string(cap) != name { + t.Errorf("常量 %q 值与名不符:%q", name, cap) + } + } + _ = proc.MethodHandshake + _ = proc.DefaultHandshakeTimeout +} diff --git a/internal/plugins/cfgmgr/test/start_registers_contract_test.go b/internal/plugins/cfgmgr/test/start_registers_contract_test.go new file mode 100644 index 00000000..c8fc31c0 --- /dev/null +++ b/internal/plugins/cfgmgr/test/start_registers_contract_test.go @@ -0,0 +1,69 @@ +package test + +// cfgmgr 黑盒判据:公开 Start 注入工具注册回调,观察注册的工具契约。 + +import ( + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/plugins/cfgmgr" + sdkpkg "github.com/JianFeeeee/HologramAgent/internal/sdk" +) + +func TestStartRegistersConfigToolsWithConcurrencyMarkers(t *testing.T) { + var names []string + serial := map[string]bool{} + parallel := map[string]bool{} + reg := func(name string, def sdkpkg.ToolDef, handler sdkpkg.ToolHandler) error { + names = append(names, name) + if def.Serial { + serial[name] = true + } + if def.ParallelSafe { + parallel[name] = true + } + return nil + } + p := cfgmgr.New("contract-cfgmgr") + sdk := sdkpkg.New("contract-cfgmgr", sdkpkg.SDKConfig{RegTool: reg}) + if err := p.Start(sdk); err != nil { + t.Fatalf("Start: %v", err) + } + if got := p.Name(); got != "contract-cfgmgr" { + t.Fatalf("Name=%q", got) + } + + for _, want := range []string{ + "config_list_plugins", "config_get", "config_set", + "config_list_keys", "config_get_defs", "config_dump", "config_batch_set", + } { + found := false + for _, n := range names { + if n == want { + found = true + } + } + if !found { + t.Errorf("缺少工具 %s;实际 %v", want, names) + } + } + // 写工具必须串行;读工具必须可并发。 + if !serial["config_set"] || !serial["config_batch_set"] { + t.Error("写工具未声明 Serial") + } + for _, ro := range []string{"config_list_plugins", "config_get", "config_list_keys", "config_get_defs", "config_dump"} { + if !parallel[ro] { + t.Errorf("只读工具 %s 未声明 ParallelSafe", ro) + } + } +} + +func TestStopIsSafe(t *testing.T) { + p := cfgmgr.New("x") + sdk := sdkpkg.New("x", sdkpkg.SDKConfig{RegTool: func(string, sdkpkg.ToolDef, sdkpkg.ToolHandler) error { return nil }}) + if err := p.Start(sdk); err != nil { + t.Fatal(err) + } + if err := p.Stop(); err != nil { + t.Fatalf("Stop: %v", err) + } +} diff --git a/internal/plugins/mcp/test/server_contract_test.go b/internal/plugins/mcp/test/server_contract_test.go new file mode 100644 index 00000000..5ee31ccf --- /dev/null +++ b/internal/plugins/mcp/test/server_contract_test.go @@ -0,0 +1,120 @@ +package test + +// mcp 的黑盒判据:httptest 提供真实 HTTP JSON-RPC 输入, +// 通过公开 NewSSETransport/NewServer/ListTools/CallTool 观察输出。 + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/plugins/mcp" +) + +func rpcServer(t *testing.T, handler func(method string, body []byte) (status int, ct string, resp string)) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var req struct{ Method string } + _ = json.Unmarshal(body, &req) + st, ct, resp := handler(req.Method, body) + w.Header().Set("Content-Type", ct) + w.WriteHeader(st) + _, _ = w.Write([]byte(resp)) + })) + t.Cleanup(srv.Close) + return srv +} + +func newClient(t *testing.T, url string) *mcp.Server { + t.Helper() + s := mcp.NewServer("contract-mcp", mcp.NewSSETransport(url)) + t.Cleanup(func() { _ = s.Close() }) + return s +} + +func TestListToolsThroughHTTP(t *testing.T) { + srv := rpcServer(t, func(method string, body []byte) (int, string, string) { + if method != "tools/list" { + t.Errorf("method = %q,期望 tools/list", method) + } + return 200, "application/json", `{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"read_file","description":"读取文件"},{"name":"write_file","description":"写文件"}]}}` + }) + tools, err := newClient(t, srv.URL).ListTools() + if err != nil { + t.Fatalf("ListTools: %v", err) + } + if len(tools) != 2 || tools[0].Name != "read_file" || tools[1].Name != "write_file" { + t.Fatalf("tools = %+v", tools) + } +} + +func TestCallToolSuccessAndEmptyContentHandling(t *testing.T) { + srv := rpcServer(t, func(method string, body []byte) (int, string, string) { + if method != "tools/call" { + t.Errorf("method = %q,期望 tools/call", method) + } + return 200, "application/json", `{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"world"}],"isError":false}}` + }) + out, err := newClient(t, srv.URL).CallTool("greet", map[string]interface{}{"x": 1}) + if err != nil { + t.Fatalf("CallTool: %v", err) + } + if out != "world" { + t.Fatalf("out = %q", out) + } + + emptySrv := rpcServer(t, func(method string, body []byte) (int, string, string) { + return 200, "application/json", `{"jsonrpc":"2.0","id":1,"result":{"content":[]}}` + }) + if _, err := newClient(t, emptySrv.URL).CallTool("x", nil); err == nil || !strings.Contains(err.Error(), "未返回任何文本") { + t.Fatalf("空内容未被判失败:%v", err) + } +} + +func TestCallToolSurfacesIsErrorAsFailure(t *testing.T) { + srv := rpcServer(t, func(method string, body []byte) (int, string, string) { + return 200, "application/json", `{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"id is required"}],"isError":true}}` + }) + _, err := newClient(t, srv.URL).CallTool("x", nil) + if err == nil || !strings.Contains(err.Error(), "id is required") { + t.Fatalf("isError 未被判失败:%v", err) + } +} + +func TestCallToolSurfacesRPCError(t *testing.T) { + srv := rpcServer(t, func(method string, body []byte) (int, string, string) { + return 200, "application/json", `{"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"bad params"}}` + }) + _, err := newClient(t, srv.URL).CallTool("x", nil) + if err == nil || !strings.Contains(err.Error(), "bad params") { + t.Fatalf("RPC error 未被透出:%v", err) + } +} + +func TestCallToolSurfacesHTTPError(t *testing.T) { + srv := rpcServer(t, func(method string, body []byte) (int, string, string) { + return 500, "text/plain", "boom" + }) + _, err := newClient(t, srv.URL).CallTool("x", nil) + if err == nil || !strings.Contains(err.Error(), "http status 500") { + t.Fatalf("HTTP 错误未被透出:%v", err) + } +} + +func TestListToolsParseSSEStreamFrame(t *testing.T) { + srv := rpcServer(t, func(method string, body []byte) (int, string, string) { + frame := `{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"sse_tool","description":"d"}]}}` + return 200, "text/event-stream", "event: message\ndata: " + frame + "\n\n" + }) + tools, err := newClient(t, srv.URL).ListTools() + if err != nil { + t.Fatalf("SSE LL: %v", err) + } + if len(tools) != 1 || tools[0].Name != "sse_tool" { + t.Fatalf("tools=%+v", tools) + } +}