test: 按模块导出接口重写黑盒判据(批次 14:mcp / proc / cfgmgr)

三个模块新增 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
This commit is contained in:
JianFeeeee
2026-11-09 21:37:53 +08:00
parent e74d66492d
commit 3d05dcac71
3 changed files with 276 additions and 0 deletions

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)
}
}