mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-15 08:51:49 +08:00
Compare commits
2 Commits
cd4290807c
...
3d05dcac71
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d05dcac71 | |||
| e74d66492d |
87
internal/plugin/proc/test/public_contract_test.go
Normal file
87
internal/plugin/proc/test/public_contract_test.go
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
120
internal/plugins/mcp/test/server_contract_test.go
Normal file
120
internal/plugins/mcp/test/server_contract_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
60
internal/plugins/remotedevice/test/registry_contract_test.go
Normal file
60
internal/plugins/remotedevice/test/registry_contract_test.go
Normal file
@ -0,0 +1,60 @@
|
||||
package test
|
||||
|
||||
// remotedevice Registry 黑盒判据:通过公开方法观察结果保存、能力查询与媒体落盘。
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/plugins/remotedevice"
|
||||
)
|
||||
|
||||
func TestSaveResultThenGetResult(t *testing.T) {
|
||||
r := remotedevice.NewRegistry()
|
||||
want := map[string]interface{}{"status": "ok", "output": "done"}
|
||||
r.SaveResult("req-123", want)
|
||||
got, ok := r.GetResult("req-123")
|
||||
if !ok || got["status"] != "ok" || got["output"] != "done" {
|
||||
t.Fatalf("GetResult=%v,%v", got, ok)
|
||||
}
|
||||
if _, ok := r.GetResult("missing"); ok {
|
||||
t.Fatal("unknown request unexpectedly found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportsToolForUnknownAndUnknownCapability(t *testing.T) {
|
||||
r := remotedevice.NewRegistry()
|
||||
if r.SupportsTool("missing-device", "screensee") {
|
||||
t.Fatal("unknown device supports tool")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveInlineMediaRequiresDirectoryAndPersistsBytes(t *testing.T) {
|
||||
r := remotedevice.NewRegistry()
|
||||
payload := base64.StdEncoding.EncodeToString([]byte("media bytes"))
|
||||
if _, err := r.SaveInlineMedia("capture", payload, "application/octet-stream"); err == nil {
|
||||
t.Fatal("media saved without configured directory")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
r.SetMediaDir(dir)
|
||||
meta, err := r.SaveInlineMedia("capture", payload, "application/octet-stream")
|
||||
if err != nil {
|
||||
t.Fatalf("SaveInlineMedia: %v", err)
|
||||
}
|
||||
path, ok := meta["file"].(string)
|
||||
if !ok || filepath.Dir(path) != dir {
|
||||
t.Fatalf("file metadata=%v", meta)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("media file missing: %v", err)
|
||||
}
|
||||
if string(got) != "media bytes" || meta["size"] != len(got) || meta["mime"] != "application/octet-stream" {
|
||||
t.Fatalf("saved payload/metadata mismatch: %q / %v", got, meta)
|
||||
}
|
||||
if _, ok := meta["sha256"].(string); !ok {
|
||||
t.Fatalf("missing digest: %v", meta)
|
||||
}
|
||||
}
|
||||
54
internal/plugins/skillmgr/test/descriptions_contract_test.go
Normal file
54
internal/plugins/skillmgr/test/descriptions_contract_test.go
Normal file
@ -0,0 +1,54 @@
|
||||
package test
|
||||
|
||||
// skillmgr 黑盒判据:技能目录输入 → SkillDescriptions 可观察文本。
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/plugins/skillmgr"
|
||||
sdkpkg "github.com/JianFeeeee/HologramAgent/internal/sdk"
|
||||
)
|
||||
|
||||
func TestSkillDescriptionsListsScannedSkillsInStableOrder(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for name, desc := range map[string]string{"zeta": "处理 zeta 的工作流。", "alpha": "处理 alpha 的工作流。"} {
|
||||
p := filepath.Join(dir, name)
|
||||
if err := os.MkdirAll(p, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := "# " + name + "\n\n" + desc + "\n"
|
||||
if err := os.WriteFile(filepath.Join(p, "SKILL.md"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
p := skillmgr.New("contract-skillmgr", dir)
|
||||
// Start performs the public directory scan; provide only the two public
|
||||
// registrar callbacks required by the plugin SDK and no hidden test hooks.
|
||||
sdk := sdkpkg.New("contract-skillmgr", sdkpkg.SDKConfig{
|
||||
RegTool: func(string, sdkpkg.ToolDef, sdkpkg.ToolHandler) error { return nil },
|
||||
})
|
||||
if err := p.Start(sdk); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = p.Stop() })
|
||||
got := p.SkillDescriptions()
|
||||
if !strings.Contains(got, "alpha: 处理 alpha 的工作流。") || !strings.Contains(got, "zeta: 处理 zeta 的工作流。") {
|
||||
t.Fatalf("scanned descriptions missing: %q", got)
|
||||
}
|
||||
if strings.Index(got, "alpha:") > strings.Index(got, "zeta:") {
|
||||
t.Fatalf("descriptions not in stable name order: %q", got)
|
||||
}
|
||||
if p.Name() != "contract-skillmgr" {
|
||||
t.Fatalf("Name=%q", p.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillDescriptionsEmptyBeforeStart(t *testing.T) {
|
||||
p := skillmgr.New("empty", t.TempDir())
|
||||
if got := p.SkillDescriptions(); got != "" {
|
||||
t.Fatalf("unexpected descriptions: %q", got)
|
||||
}
|
||||
}
|
||||
68
internal/sdk/test/public_contract_test.go
Normal file
68
internal/sdk/test/public_contract_test.go
Normal file
@ -0,0 +1,68 @@
|
||||
package test
|
||||
|
||||
// internal/sdk 的模块级黑盒判据:公开 SDK 包装 API 的输入→输出。
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/sdk"
|
||||
"github.com/JianFeeeee/HologramAgent/pkg/types"
|
||||
)
|
||||
|
||||
func TestEffectiveProxyAuthNormalizesLegacyAndEmpty(t *testing.T) {
|
||||
for _, in := range []string{"", sdk.ProxyAuthAgentLegacy} {
|
||||
if got := sdk.EffectiveProxyAuth(in); got != sdk.ProxyAuthAgent {
|
||||
t.Errorf("EffectiveProxyAuth(%q)=%q", in, got)
|
||||
}
|
||||
}
|
||||
if got := sdk.EffectiveProxyAuth(sdk.ProxyAuthNone); got != sdk.ProxyAuthNone {
|
||||
t.Fatalf("explicit no-auth changed: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyHostNormalizationAndValidation(t *testing.T) {
|
||||
label := sdk.NormalizeProxyHost("My Plugin")
|
||||
if label == "" || !sdk.ValidProxyHostLabel(label) {
|
||||
t.Fatalf("normalized host %q is not a valid DNS label", label)
|
||||
}
|
||||
if sdk.ValidProxyHostLabel("bad..label") || sdk.ValidProxyHostLabel("-leading") {
|
||||
t.Fatal("invalid DNS host label accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewConfigGetPutRoundTrip(t *testing.T) {
|
||||
first := &types.Config{LLM: types.LLMConfig{Provider: "first"}}
|
||||
api := sdk.NewConfig(first)
|
||||
if got := api.Get(); got != first {
|
||||
t.Fatal("Get did not return configured value")
|
||||
}
|
||||
second := &types.Config{LLM: types.LLMConfig{Provider: "second"}}
|
||||
api.Put(second)
|
||||
if got := api.Get(); got != second {
|
||||
t.Fatal("Put value not observable via Get")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinProxyRegistrySnapshotsAndClear(t *testing.T) {
|
||||
const name = "contract-sdk-proxy"
|
||||
sdk.ClearBuiltinProxyDefs(name)
|
||||
t.Cleanup(func() { sdk.ClearBuiltinProxyDefs(name) })
|
||||
before := sdk.BuiltinProxyVersion()
|
||||
sdk.RegisterBuiltinProxy(name, "web", sdk.ProxyDef{Target: "http://127.0.0.1:1234"})
|
||||
defs := sdk.BuiltinProxyDefs()
|
||||
if len(defs[name]) != 1 || defs[name][0].Name != "web" {
|
||||
t.Fatalf("registered proxy not observable: %+v", defs[name])
|
||||
}
|
||||
if sdk.BuiltinProxyVersion() <= before {
|
||||
t.Fatal("registration did not advance public version")
|
||||
}
|
||||
// Returned slice is a snapshot; mutating it must not mutate the registry.
|
||||
defs[name][0].Target = "mutated"
|
||||
if got := sdk.BuiltinProxyDefs()[name][0].Target; got != "http://127.0.0.1:1234" {
|
||||
t.Fatalf("snapshot mutation leaked into registry: %q", got)
|
||||
}
|
||||
sdk.ClearBuiltinProxyDefs(name)
|
||||
if _, ok := sdk.BuiltinProxyDefs()[name]; ok {
|
||||
t.Fatal("ClearBuiltinProxyDefs did not remove entry")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user