diff --git a/internal/agent/test/personality_contract_test.go b/internal/agent/test/personality_contract_test.go new file mode 100644 index 00000000..a871f5ef --- /dev/null +++ b/internal/agent/test/personality_contract_test.go @@ -0,0 +1,85 @@ +package test + +// internal/agent 的黑盒判据:人格文件往返 + InjectPrompt 输出。 +// 不碰内部正则表、不读源码。 + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/agent" +) + +// TestSaveThenLoadPersonality:Save → Load 拿回原文与路径。 +func TestSaveThenLoadPersonality(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "personal.md") + const content = "你是小宅,说话自然一点。" + if err := agent.SavePersonality(path, content); err != nil { + t.Fatalf("SavePersonality: %v", err) + } + p, err := agent.LoadPersonality(path) + if err != nil { + t.Fatalf("LoadPersonality: %v", err) + } + if p.Content != content { + t.Fatalf("★ Content = %q,期望原文", p.Content) + } + if p.Path != path { + t.Fatalf("★ Path = %q,期望 %q", p.Path, path) + } +} + +// TestLoadMissingPersonalityGivesEmpty:文件不存在返回空人格而非报错。 +func TestLoadMissingPersonalityGivesEmpty(t *testing.T) { + p, err := agent.LoadPersonality(filepath.Join(t.TempDir(), "nope.md")) + if err != nil { + t.Fatalf("★ 文件不存在却报错:%v", err) + } + if p == nil || p.Content != "" { + t.Fatalf("★ 期望空人格,实际 %+v", p) + } +} + +// TestInjectPromptContainsContentAndRules:非空人格注入后含原文与说话规则。 +func TestInjectPromptContainsContentAndRules(t *testing.T) { + p := &agent.Personality{Content: "你是小宅"} + out := p.InjectPrompt() + if !strings.Contains(out, "你是小宅") || !strings.Contains(out, "[Persona]") { + t.Fatalf("★ InjectPrompt 输出不符:%q", out) + } + if len(out) <= len(p.Content) { + t.Fatal("★ 内核没追加说话规则") + } +} + +// TestInjectPromptEmptyPersonalityIsEmpty:空人格不注入任何内容。 +func TestInjectPromptEmptyPersonalityIsEmpty(t *testing.T) { + if got := (&agent.Personality{}).InjectPrompt(); got != "" { + t.Fatalf("★ 空人格却注入了内容:%q", got) + } +} + +// TestPersonaStaleHints:含版本号字面量/陈旧措辞时给出提示;干净文本不提示。 +func TestPersonaStaleHints(t *testing.T) { + dirty := "你是 HΔ-Kernel v1.0.3 型号的家政型 AI 管家助手。" + if got := agent.PersonaStaleHints(dirty); len(got) == 0 { + t.Fatal("★ 陈旧人格卡没给出提示") + } + clean := "你是小宅,说话自然,别用客服腔。" + if got := agent.PersonaStaleHints(clean); len(got) != 0 { + t.Fatalf("★ 干净人格卡被误报:%v", got) + } +} + +// TestSavePersonalityCreatesParentDirs:父目录不存在时自动创建。 +func TestSavePersonalityCreatesParentDirs(t *testing.T) { + path := filepath.Join(t.TempDir(), "a", "b", "personal.md") + if err := agent.SavePersonality(path, "x"); err != nil { + t.Fatalf("SavePersonality: %v", err) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("★ 文件没落盘:%v", err) + } +} diff --git a/internal/log/test/manager_contract_test.go b/internal/log/test/manager_contract_test.go new file mode 100644 index 00000000..fe8ac397 --- /dev/null +++ b/internal/log/test/manager_contract_test.go @@ -0,0 +1,54 @@ +package test + +// internal/log 的黑盒判据:NewManager → Start/Stop 不 panic; +// RetentionPolicy 常量是对外契约。不读日志文件内容、不碰 m.done。 + +import ( + "context" + "testing" + "time" + + "github.com/JianFeeeee/HologramAgent/internal/log" +) + +type stubCfg struct{} + +func (c *stubCfg) GetString(key, def string) string { return def } +func (c *stubCfg) GetInt(key string, def int) int { return def } + +// TestManagerStartStopIsSafe:构造 + 启动 + 停止不得 panic/挂住。 +func TestManagerStartStopIsSafe(t *testing.T) { + m := log.NewManager(t.TempDir(), &stubCfg{}) + ctx, cancel := context.WithCancel(context.Background()) + m.Start(ctx) + cancel() + done := make(chan struct{}) + go func() { m.Stop(); close(done) }() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("★ Stop 挂住") + } +} + +// TestManagerStopIsIdempotent:连停两次不得 panic(close 纪律)。 +func TestManagerStopIsIdempotent(t *testing.T) { + m := log.NewManager(t.TempDir(), &stubCfg{}) + m.Stop() + m.Stop() +} + +// TestRetentionPoliciesAreDistinct:四个策略常量互不相同且非空。 +func TestRetentionPoliciesAreDistinct(t *testing.T) { + all := []log.RetentionPolicy{ + log.RetentionThisWeek, log.RetentionThisMonth, + log.RetentionNMonths, log.RetentionForever, + } + seen := map[log.RetentionPolicy]bool{} + for _, p := range all { + if p == "" || seen[p] { + t.Fatalf("★ 策略常量为空或重复:%q", p) + } + seen[p] = true + } +} diff --git a/internal/lua/test/vm_contract_test.go b/internal/lua/test/vm_contract_test.go new file mode 100644 index 00000000..777d50d6 --- /dev/null +++ b/internal/lua/test/vm_contract_test.go @@ -0,0 +1,77 @@ +package test + +// internal/lua 的黑盒判据:NewVM → LoadAdapterSource → 可观察输出。 +// 不读适配器源码、不碰 v.pools。 + +import ( + "strings" + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/lua" +) + +func newVM(t *testing.T) *lua.VM { + t.Helper() + return lua.NewVM(t.TempDir()) +} + +// TestNewVMExposesAdapterDir:构造参数可从 AdapterDir 读回。 +func TestNewVMExposesAdapterDir(t *testing.T) { + dir := t.TempDir() + v := lua.NewVM(dir) + if got := v.AdapterDir(); got != dir { + t.Fatalf("★ AdapterDir = %q,期望 %q", got, dir) + } +} + +// TestListAdaptersEmptyThenLoaded:加载适配器后 ListAdapters 反映它。 +func TestListAdaptersEmptyThenLoaded(t *testing.T) { + v := newVM(t) + if got := v.ListAdapters(); len(got) != 0 { + t.Fatalf("★ 新 VM 的 ListAdapters = %v,期望空", got) + } + // 最小可用适配器脚本(静态检查要求声明 name/version) + code := `-- name: contract-adapter +-- version: 1.0 +return { + transform_request = function(raw) return raw end, + transform_response = function(raw) return raw end, + transform_stream_chunk = function(raw) return raw end, + endpoint = function() return "http://127.0.0.1/v1" end, + headers = function() return {} end, +}` + if err := v.LoadAdapterSource("contract-adapter", code); err != nil { + t.Fatalf("LoadAdapterSource: %v", err) + } + got := v.ListAdapters() + if len(got) != 1 { + t.Fatalf("★ ListAdapters = %v,期望 1 个", got) + } + if got[0].Name != "contract-adapter" { + t.Fatalf("★ Name = %q", got[0].Name) + } +} + +// TestLoadAdapterSourceRejectsBadScript:明显不合法的脚本必须报错。 +func TestLoadAdapterSourceRejectsBadScript(t *testing.T) { + v := newVM(t) + if err := v.LoadAdapterSource("bad", "this is not lua at all $$$"); err == nil { + t.Fatal("★ 非法脚本被接受") + } +} + +// TestCallTransformRequestUnknownAdapter:未加载的适配器调用必须报错。 +func TestCallTransformRequestUnknownAdapter(t *testing.T) { + v := newVM(t) + _, err := v.CallTransformRequest("ghost", `{}`) + if err == nil || !strings.Contains(err.Error(), "not loaded") { + t.Fatalf("★ 未加载适配器的报错不可诊断:%v", err) + } +} + +// TestGetAdapterEndpointUnknownIsEmpty:未知适配器端点为空串、不 panic。 +func TestGetAdapterEndpointUnknownIsEmpty(t *testing.T) { + if got := newVM(t).GetAdapterEndpoint("ghost"); got != "" { + t.Fatalf("★ 未知适配器端点 = %q,期望空", got) + } +} diff --git a/providers/ollama/test/generate_contract_test.go b/providers/ollama/test/generate_contract_test.go new file mode 100644 index 00000000..9691a381 --- /dev/null +++ b/providers/ollama/test/generate_contract_test.go @@ -0,0 +1,99 @@ +package test + +// providers/ollama 的黑盒判据:New(cfg) → Generate → Response 输出。 +// 用 httptest 提供真实 HTTP 输入,不 mock client、不碰 p.endpoint。 + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/JianFeeeee/HologramAgent/pkg/generation" + ollama "github.com/JianFeeeee/HologramAgent/providers/ollama" +) + +func newProvider(t *testing.T, endpoint string) *ollama.Provider { + t.Helper() + p, err := ollama.New(generation.Config{Options: map[string]string{ + "endpoint": endpoint, "model": "contract-model", + }}) + if err != nil { + t.Fatalf("New: %v", err) + } + t.Cleanup(p.Close) + return p +} + +// TestGenerateReturnsText:200 + {"response":...} → Response.Text。 +func TestGenerateReturnsText(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"response":"生成结果","done_reason":"stop"}`)) + })) + defer srv.Close() + + out, err := newProvider(t, srv.URL).Generate(context.Background(), generation.Request{Prompt: "你好"}) + if err != nil { + t.Fatalf("Generate: %v", err) + } + if out.Text != "生成结果" { + t.Fatalf("★ Text = %q,期望 生成结果", out.Text) + } + if out.Truncated { + t.Fatal("★ done_reason=stop 却报告 Truncated") + } +} + +// TestGenerateMarksTruncated:done_reason=length → Truncated=true。 +func TestGenerateMarksTruncated(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"response":"半截","done_reason":"length"}`)) + })) + defer srv.Close() + + out, err := newProvider(t, srv.URL).Generate(context.Background(), generation.Request{}) + if err != nil { + t.Fatal(err) + } + if !out.Truncated { + t.Fatal("★ done_reason=length 未标记 Truncated") + } +} + +// TestGenerateHTTPError:非 200 → 报错。 +func TestGenerateHTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("boom")) + })) + defer srv.Close() + + if _, err := newProvider(t, srv.URL).Generate(context.Background(), generation.Request{}); err == nil { + t.Fatal("★ 5xx 却没报错") + } +} + +// TestGenerateRejectsInvalidJSONSchema:非 JSON 的 schema 必须报错。 +func TestGenerateRejectsInvalidJSONSchema(t *testing.T) { + p, err := ollama.New(generation.Config{Options: map[string]string{"model": "m"}}) + if err != nil { + t.Fatal(err) + } + defer p.Close() + if _, err := p.Generate(context.Background(), generation.Request{JSONSchema: "not-json"}); err == nil { + t.Fatal("★ 非法 JSONSchema 被接受") + } +} + +// TestNewFallsBackToDefaultModel:空 model 走默认值,构造仍可用。 +func TestNewFallsBackToDefaultModel(t *testing.T) { + p, err := ollama.New(generation.Config{Options: map[string]string{"model": ""}}) + if err != nil { + t.Fatalf("★ 空 model 应回退默认值,实际报错:%v", err) + } + defer p.Close() + if got := p.Info().Model; got == "" { + t.Fatal("★ 默认模型名为空") + } +}