diff --git a/go/integration_test.go b/go/integration_test.go new file mode 100644 index 0000000..d3061ee --- /dev/null +++ b/go/integration_test.go @@ -0,0 +1,362 @@ +// 织忆 MemoryWeave — 集成测试 + 边界测试 +package memoryweave_test + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "sync" + "testing" + "time" + + "github.com/xiaoxue/memoryweave/internal/api" + "github.com/xiaoxue/memoryweave/internal/governance" + "github.com/xiaoxue/memoryweave/internal/selfoptimize" +) + +var testServer http.Handler + +func TestMain(m *testing.M) { + testServer = api.NewServer() + os.Exit(m.Run()) +} + +// ─── 集成测试:全链路 ───────────────────────────────────── + +func TestIntegration_FullPipeline(t *testing.T) { + t.Skip("requires running LanceDB instance — test in CI with LanceDB available") + h := testServer + body := func(v interface{}) *bytes.Reader { + b, _ := json.Marshal(v) + return bytes.NewReader(b) + } + header := map[string]string{ + "Content-Type": "application/json", + "X-API-Key": "zhiyi-dev-key-2026", + } + + // Step 1: 提交记忆 + commitBody := map[string]interface{}{ + "agent_id": "hermes", + "namespace": "shared", + "content": "牧尘的系统使用 Deepin 25,不是 Arch Linux", + "category": "system_fact", + } + req := httptest.NewRequest("POST", "/api/v1/commit", body(commitBody)) + for k, v := range header { + req.Header.Set(k, v) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != 201 { + t.Fatalf("commit failed: %d %s", w.Code, w.Body.String()) + } + + // Step 2: 提交第二条 + commitBody2 := map[string]interface{}{ + "agent_id": "hermes", + "namespace": "shared", + "content": "牧尘的 GPU 是 RTX 3050 Laptop,4GB 显存", + "category": "system_fact", + } + req2 := httptest.NewRequest("POST", "/api/v1/commit", body(commitBody2)) + for k, v := range header { + req2.Header.Set(k, v) + } + w2 := httptest.NewRecorder() + h.ServeHTTP(w2, req2) + if w2.Code != 201 { + t.Fatalf("commit2 failed: %d", w2.Code) + } + + // Step 3: 召回 + recallBody := map[string]interface{}{ + "query": "牧尘的系统是什么", + "limit": 5, + "namespace": "shared", + } + req3 := httptest.NewRequest("POST", "/api/v1/recall", body(recallBody)) + for k, v := range header { + req3.Header.Set(k, v) + } + w3 := httptest.NewRecorder() + h.ServeHTTP(w3, req3) + if w3.Code != 200 { + t.Errorf("recall failed: %d %s", w3.Code, w3.Body.String()) + } + + // Step 4: 统计 + req4 := httptest.NewRequest("GET", "/api/v1/stats", nil) + for k, v := range header { + req4.Header.Set(k, v) + } + w4 := httptest.NewRecorder() + h.ServeHTTP(w4, req4) + if w4.Code != 200 { + t.Errorf("stats failed: %d", w4.Code) + } + + // Step 5: Bootstrap + req5 := httptest.NewRequest("GET", "/api/v1/bootstrap?agent_id=hermes", nil) + for k, v := range header { + req5.Header.Set(k, v) + } + w5 := httptest.NewRecorder() + h.ServeHTTP(w5, req5) + if w5.Code == 0 { + t.Error("bootstrap got empty response") + } + + // Step 6: 反馈 + feedBody := map[string]string{"memory_id": "test_001"} + req6 := httptest.NewRequest("POST", "/api/v1/feedback/useful", body(feedBody)) + for k, v := range header { + req6.Header.Set(k, v) + } + w6 := httptest.NewRecorder() + h.ServeHTTP(w6, req6) + if w6.Code != 200 { + t.Errorf("feedback failed: %d", w6.Code) + } +} + +func TestIntegration_GraphFlow(t *testing.T) { + h := testServer + header := map[string]string{ + "Content-Type": "application/json", + "X-API-Key": "zhiyi-dev-key-2026", + } + + // Graph stats + req := httptest.NewRequest("GET", "/api/v1/graph/stats", nil) + for k, v := range header { + req.Header.Set(k, v) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != 200 { + t.Errorf("graph stats: %d", w.Code) + } + + // Graph navigate + navBody := map[string]interface{}{"entity": "Docker", "max_hops": 2} + req2 := httptest.NewRequest("POST", "/api/v1/graph/navigate", bytesBody(navBody)) + for k, v := range header { + req2.Header.Set(k, v) + } + w2 := httptest.NewRecorder() + h.ServeHTTP(w2, req2) + if w2.Code != 200 { + t.Errorf("graph navigate: %d %s", w2.Code, w2.Body.String()) + } +} + +func TestIntegration_AgentRegisterFlow(t *testing.T) { + h := testServer + header := map[string]string{ + "Content-Type": "application/json", + "X-API-Key": "zhiyi-dev-key-2026", + } + + // Register + regBody := map[string]string{"agent_id": "integration-test-agent"} + req := httptest.NewRequest("POST", "/api/v1/agents/register", bytesBody(regBody)) + for k, v := range header { + req.Header.Set(k, v) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != 201 { + t.Fatalf("register failed: %d %s", w.Code, w.Body.String()) + } + + var info struct { + APIKey string `json:"api_key"` + } + json.NewDecoder(w.Body).Decode(&info) + if info.APIKey == "" { + t.Error("no API key in response") + } + + // List + req2 := httptest.NewRequest("GET", "/api/v1/agents", nil) + for k, v := range header { + req2.Header.Set(k, v) + } + w2 := httptest.NewRecorder() + h.ServeHTTP(w2, req2) + if w2.Code != 200 { + t.Errorf("agent list: %d", w2.Code) + } +} + +// ─── 边界测试 ───────────────────────────────────────────── + +func TestEdge_EmptyCommit(t *testing.T) { + h := testServer + body := map[string]interface{}{"agent_id": "", "content": ""} + req := httptest.NewRequest("POST", "/api/v1/commit", bytesBody(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-API-Key", "zhiyi-dev-key-2026") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != 400 { + t.Errorf("empty commit should return 400, got %d", w.Code) + } +} + +func TestEdge_NoAuth(t *testing.T) { + h := testServer + req := httptest.NewRequest("GET", "/api/v1/stats", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != 401 { + t.Errorf("no auth should return 401, got %d", w.Code) + } +} + +func TestEdge_HealthNoAuth(t *testing.T) { + h := testServer + req := httptest.NewRequest("GET", "/health", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("/health should return 200 without auth, got %d", w.Code) + } +} + +func TestEdge_LargePayload(t *testing.T) { + // 10KB 内容提交 + largeContent := make([]byte, 10000) + for i := range largeContent { + largeContent[i] = 'x' + } + + h := testServer + body := map[string]interface{}{ + "agent_id": "test", + "content": string(largeContent), + "namespace": "shared", + } + req := httptest.NewRequest("POST", "/api/v1/commit", bytesBody(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-API-Key", "zhiyi-dev-key-2026") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + // Should not crash + if w.Code == 0 { + t.Error("large payload caused empty response") + } +} + +func TestEdge_ConcurrentCommits(t *testing.T) { + t.Skip("requires running LanceDB instance — test in CI with LanceDB available") + h := testServer + var wg sync.WaitGroup + errs := make(chan error, 20) + + for i := 0; i < 20; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + body := map[string]interface{}{ + "agent_id": fmt.Sprintf("agent-%d", idx), + "namespace": "shared", + "content": fmt.Sprintf("concurrent test message %d", idx), + "category": "test", + } + req := httptest.NewRequest("POST", "/api/v1/commit", bytesBody(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-API-Key", "zhiyi-dev-key-2026") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != 201 { + errs <- fmt.Errorf("goroutine %d: expected 201, got %d", idx, w.Code) + } + }(i) + } + + wg.Wait() + close(errs) + + for err := range errs { + t.Error(err) + } +} + +func TestEdge_GapDetectionThreshold(t *testing.T) { + gd := selfoptimize.NewGapDetector() + + // 2 misses — no gap + if gap := gd.RecordMiss("test"); gap != nil { + t.Error("gap should not trigger at 1 miss") + } + if gap := gd.RecordMiss("test"); gap != nil { + t.Error("gap should not trigger at 2 misses") + } + + // 3rd miss — gap + gap := gd.RecordMiss("test") + if gap == nil { + t.Fatal("gap should trigger at 3 misses") + } + if gap.MissCount != 3 { + t.Errorf("expected 3 misses, got %d", gap.MissCount) + } +} + +func TestEdge_GraphEmpty(t *testing.T) { + g := governance.NewInMemoryGraph() + nodes, edges, density := g.Stats() + if nodes != 0 || edges != 0 || density != 0 { + t.Error("empty graph should return all zeros") + } + + paths, err := g.Navigate("nonexistent", 2, "shared") + if err != nil { + t.Errorf("navigate on empty graph should not error: %v", err) + } + if len(paths) != 0 { + t.Errorf("expected 0 paths on empty graph, got %d", len(paths)) + } +} + +func TestEdge_TriggersFired(t *testing.T) { + h := testServer + header := map[string]string{ + "Content-Type": "application/json", + "X-API-Key": "zhiyi-dev-key-2026", + } + + body := map[string]string{"trigger_id": "t1"} + req := httptest.NewRequest("POST", "/api/v1/triggers/fire", bytesBody(body)) + for k, v := range header { + req.Header.Set(k, v) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("trigger fire: expected 200, got %d", w.Code) + } +} + +// ─── 辅助 ───────────────────────────────────────────────── + +func bytesBody(v interface{}) *bytes.Reader { + b, _ := json.Marshal(v) + return bytes.NewReader(b) +} + +// Avoid import cycle — this file is in package zhiyid_test, not routes +func init() { + time.Local = time.UTC +} diff --git a/go/internal/api/routes/routes_test.go b/go/internal/api/routes/routes_test.go new file mode 100644 index 0000000..5f68e41 --- /dev/null +++ b/go/internal/api/routes/routes_test.go @@ -0,0 +1,323 @@ +// 织忆 MemoryWeave — 路由层单元测试 +package routes + +import ( + "bytes" + "encoding/json" + "net/http/httptest" + "testing" + "time" + + "github.com/xiaoxue/memoryweave/internal/governance" + "github.com/xiaoxue/memoryweave/internal/selfoptimize" +) + +// ─── 核心 API(mock LanceDB)─────────────────────────────── + +func TestHealthEndpoint(t *testing.T) { + req := httptest.NewRequest("GET", "/health", nil) + w := httptest.NewRecorder() + HandleHealth(w, req) + + if w.Code != 200 { + t.Errorf("expected 200, got %d", w.Code) + } + var resp map[string]string + json.NewDecoder(w.Body).Decode(&resp) + if resp["status"] != "ok" { + t.Errorf("expected ok status, got %s", resp["status"]) + } +} + +// ─── 知识图谱路由 ──────────────────────────────────────── + +func TestGraphAPI_Stats(t *testing.T) { + g := governance.NewInMemoryGraph() + g.AddNode("n1", "Docker", "tool", "shared") + g.AddNode("n2", "Hermes", "agent", "shared") + g.AddEdge("e1", "n1", "n2", "used", "shared", 1.0) + + api := NewGraphAPI(g) + req := httptest.NewRequest("GET", "/api/v1/graph/stats", nil) + w := httptest.NewRecorder() + api.Stats(w, req) + + if w.Code != 200 { + t.Errorf("expected 200, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["node_count"].(float64) != 2 { + t.Errorf("expected 2 nodes, got %v", resp["node_count"]) + } +} + +func TestGraphAPI_Navigate(t *testing.T) { + g := governance.NewInMemoryGraph() + g.AddEdge("e1", "docker", "hermes", "used", "shared", 1.0) + api := NewGraphAPI(g) + + body, _ := json.Marshal(map[string]interface{}{ + "entity": "docker", "max_hops": 2, + }) + req := httptest.NewRequest("POST", "/api/v1/graph/navigate", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + api.Navigate(w, req) + + if w.Code != 200 { + t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestGraphAPI_Navigate_MissingEntity(t *testing.T) { + g := governance.NewInMemoryGraph() + api := NewGraphAPI(g) + + body, _ := json.Marshal(map[string]interface{}{"max_hops": 2}) + req := httptest.NewRequest("POST", "/api/v1/graph/navigate", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + api.Navigate(w, req) + + if w.Code != 400 { + t.Errorf("expected 400, got %d", w.Code) + } +} + +// ─── 冲突管理路由 ──────────────────────────────────────── + +func TestConflictAPI_List(t *testing.T) { + cd := governance.NewConflictDetector() + // Scan 会产生冲突但不在 active,所以返回空列表 + cd.Scan("A is used by B", []string{"A"}, + []map[string]interface{}{{"content": "A is NOT used by B", "entities": []string{"A"}}}) + + api := NewConflictAPI(cd) + req := httptest.NewRequest("GET", "/api/v1/conflicts", nil) + w := httptest.NewRecorder() + api.List(w, req) + + if w.Code != 200 { + t.Errorf("expected 200, got %d", w.Code) + } + // 空列表也正常 — active 不为空才需要 Scan 内部存储 + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["count"] == nil { + t.Error("response missing count") + } +} + +func TestConflictAPI_Resolve_Validation(t *testing.T) { + cd := governance.NewConflictDetector() + api := NewConflictAPI(cd) + + // 缺少 conflict_id → 400 + body, _ := json.Marshal(map[string]string{"resolution": "keep_left"}) + req := httptest.NewRequest("POST", "/api/v1/conflicts/resolve", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + api.Resolve(w, req) + + if w.Code != 400 { + t.Errorf("expected 400 for missing conflict_id, got %d: %s", w.Code, w.Body.String()) + } +} + +// ─── 知识缺口路由 ──────────────────────────────────────── + +func TestGapAPI_List(t *testing.T) { + gd := selfoptimize.NewGapDetector() + ai := NewGapAPI(gd) + + req := httptest.NewRequest("GET", "/api/v1/gaps", nil) + w := httptest.NewRecorder() + ai.List(w, req) + + if w.Code != 200 { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestGapAPI_Detect(t *testing.T) { + gd := selfoptimize.NewGapDetector() + ai := NewGapAPI(gd) + + body, _ := json.Marshal(map[string]string{"topic": "test_gap"}) + req := httptest.NewRequest("POST", "/api/v1/gaps/detect", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + ai.Detect(w, req) + + // First detection should return "tracking" + if w.Code != 200 { + t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +// ─── L3 世界模型 ────────────────────────────────────────── + +func TestL3WorldModel_Get(t *testing.T) { + req := httptest.NewRequest("GET", "/api/v1/l3/worldmodel", nil) + w := httptest.NewRecorder() + WM.GetHandler(w, req) + + if w.Code != 200 { + t.Errorf("expected 200, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if env, ok := resp["environment"].(map[string]interface{}); !ok || env["os"] != "Linux" { + t.Error("expected Linux OS in world model") + } +} + +func TestL3WorldModel_Update(t *testing.T) { + body, _ := json.Marshal(map[string]interface{}{ + "add_rules": []string{"new_rule"}, + }) + req := httptest.NewRequest("POST", "/api/v1/l3/worldmodel", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + WM.UpdateHandler(w, req) + + if w.Code != 200 { + t.Errorf("expected 200, got %d", w.Code) + } +} + +// ─── 触发器 ─────────────────────────────────────────────── + +func TestTriggers_List(t *testing.T) { + req := httptest.NewRequest("GET", "/api/v1/triggers", nil) + w := httptest.NewRecorder() + Triggers.List(w, req) + + if w.Code != 200 { + t.Errorf("expected 200, got %d", w.Code) + } + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["count"].(float64) != 4 { + t.Errorf("expected 4 triggers, got %v", resp["count"]) + } +} + +// ─── Skills ─────────────────────────────────────────────── + +func TestSkills_List(t *testing.T) { + req := httptest.NewRequest("GET", "/api/v1/skills", nil) + w := httptest.NewRecorder() + Skills.List(w, req) + + if w.Code != 200 { + t.Errorf("expected 200, got %d", w.Code) + } +} + +// ─── 反馈(结构测试)──────────────────────────────────────── + +// ─── Agent 注册 ─────────────────────────────────────────── + +func TestAgentRegistry_Register(t *testing.T) { + ar := NewAgentRegistry(nil) + body, _ := json.Marshal(map[string]string{"agent_id": "test-agent"}) + req := httptest.NewRequest("POST", "/api/v1/agents/register", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + ar.Register(w, req) + + if w.Code != 201 { + t.Errorf("expected 201, got %d: %s", w.Code, w.Body.String()) + } + + var info AgentInfo + json.NewDecoder(w.Body).Decode(&info) + if info.APIKey == "" { + t.Error("expected API key in response") + } + if !t.Run("dup_register", func(t *testing.T) { + w2 := httptest.NewRecorder() + req2 := httptest.NewRequest("POST", "/api/v1/agents/register", bytes.NewReader(body)) + req2.Header.Set("Content-Type", "application/json") + ar.Register(w2, req2) + if w2.Code != 409 { + t.Errorf("expected 409 for duplicate, got %d", w2.Code) + } + }) { + } +} + +func TestAgentRegistry_List(t *testing.T) { + ar := NewAgentRegistry(nil) + body, _ := json.Marshal(map[string]string{"agent_id": "agent1"}) + req := httptest.NewRequest("POST", "/api/v1/agents/register", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + ar.Register(w, req) + + req2 := httptest.NewRequest("GET", "/api/v1/agents", nil) + w2 := httptest.NewRecorder() + ar.List(w2, req2) + + if w2.Code != 200 { + t.Errorf("expected 200, got %d", w2.Code) + } + var resp map[string]interface{} + json.NewDecoder(w2.Body).Decode(&resp) + if resp["count"].(float64) != 1 { + t.Errorf("expected 1 agent, got %v", resp["count"]) + } +} + +// ─── Admin 端点 ─────────────────────────────────────────── + +func TestAuxFunctions(t *testing.T) { + // parseTimeStr + now := time.Now() + if !parseTimeStr(now).Equal(now) { + t.Error("parseTimeStr failed for time.Time") + } + if !parseTimeStr("2026-01-01T00:00:00Z").IsZero() { + // Should parse ok + } + if !parseTimeStr(nil).IsZero() { + t.Error("parseTimeStr(nil) should be zero") + } + + // intVal + if intVal(42) != 42 { + t.Error("intVal(42) failed") + } + if intVal(3.14) != 3 { + t.Error("intVal(3.14) failed") + } + if intVal(nil) != 0 { + t.Error("intVal(nil) should be 0") + } + + // strVal + if strVal("hello") != "hello" { + t.Error("strVal failed") + } + if strVal(nil) != "" { + t.Error("strVal(nil) should be ''") + } +} + +// ─── SSE 管理器 ─────────────────────────────────────────── + +func TestSSEManager_Push(t *testing.T) { + // SSEBus.Push should not panic with no clients + SSEBus.Push(SSEMessage{Type: "test", Payload: "hello"}) +} + +func TestSSEPushHelpers(t *testing.T) { + // All push helpers should not panic + PushMemoryCommitted("agent", "ns", "mem1") + PushConflictDetected("Docker", "facts conflict") + PushGapFound("topic", "A") + PushConsolidationDone("done") + PushConflictResolved("c1", "keep_left") +} diff --git a/go/internal/governance/governance_test.go b/go/internal/governance/governance_test.go new file mode 100644 index 0000000..5b7cbc3 --- /dev/null +++ b/go/internal/governance/governance_test.go @@ -0,0 +1,236 @@ +// 织忆 MemoryWeave — 治理层单元测试 +package governance + +import ( + "testing" + "time" +) + +// ─── 冲突检测 ──────────────────────────────────────────── + +func TestConflictDetector_Contradiction(t *testing.T) { + cd := NewConflictDetector() + + newContent := "Docker is used by Hermes" + newEntities := []string{"Docker"} + existing := []map[string]interface{}{ + {"content": "Docker is NOT used by Hermes", "entities": []string{"Docker"}}, + } + + conflicts := cd.Scan(newContent, newEntities, existing) + if len(conflicts) == 0 { + t.Error("expected at least 1 conflict for contradictory Docker facts") + } + if conflicts[0].Status != "pending" { + t.Errorf("expected status pending, got %s", conflicts[0].Status) + } +} + +func TestConflictDetector_NoContradiction(t *testing.T) { + cd := NewConflictDetector() + conflicts := cd.Scan("nginx config updated", []string{"nginx"}, + []map[string]interface{}{ + {"content": "nginx reverse proxy for hermes", "entities": []string{"nginx"}}, + }) + if len(conflicts) > 0 { + t.Errorf("expected 0 conflicts, got %d", len(conflicts)) + } +} + +func TestConflictDetector_AutoResolve(t *testing.T) { + cd := NewConflictDetector() + c := &Conflict{Strategy: "latest_wins", Status: "pending"} + result := cd.AutoResolve(c) + if result != "latest" { + t.Errorf("expected 'latest', got '%s'", result) + } + + c2 := &Conflict{Strategy: "primary_wins"} + result2 := cd.AutoResolve(c2) + if result2 != "primary" { + t.Errorf("expected 'primary', got '%s'", result2) + } +} + +func TestConflictDetector_ListActive(t *testing.T) { + cd := NewConflictDetector() + cd.active["c1"] = &Conflict{ID: "c1", Status: "pending"} + cd.active["c2"] = &Conflict{ID: "c2", Status: "resolved"} + cd.active["c3"] = &Conflict{ID: "c3", Status: "pending"} + + list := cd.ListActive() + if len(list) != 2 { + t.Errorf("expected 2 active conflicts, got %d", len(list)) + } +} + +func TestConflictDetector_Resolve(t *testing.T) { + cd := NewConflictDetector() + cd.active["c1"] = &Conflict{ID: "c1", Status: "pending"} + + cd.Resolve("c1", "keep_left", "mem_001") + if cd.active["c1"].Status != "resolved" { + t.Error("conflict not resolved") + } +} + +// ─── 遗忘策略 ──────────────────────────────────────────── + +func TestForgetter_CoreMemoryNeverForgotten(t *testing.T) { + f := NewForgetter() + if f.ShouldForget(time.Now().Add(-365*24*time.Hour), 0, "core") { + t.Error("core memory should never be forgotten") + } +} + +func TestForgetter_StaleMemoryForgotten(t *testing.T) { + f := NewForgetter() + if !f.ShouldForget(time.Now().Add(-200*24*time.Hour), 0, "normal") { + t.Error("very old memory should be forgotten") + } +} + +func TestForgetter_RecentMemoryKept(t *testing.T) { + f := NewForgetter() + if f.ShouldForget(time.Now(), 0, "normal") { + t.Error("brand new memory should not be forgotten") + } +} + +func TestForgetter_FrequentlyRecalledSlowsDecay(t *testing.T) { + f := NewForgetter() + scoreNoRecall := f.DecayScore(time.Now().Add(-100*24*time.Hour), 0) + scoreWithRecall := f.DecayScore(time.Now().Add(-100*24*time.Hour), 10) + if scoreNoRecall >= scoreWithRecall { + t.Errorf("memory with recalls should have higher score: no_recall=%.2f, with_recall=%.2f", + scoreNoRecall, scoreWithRecall) + } +} + +// ─── 知识图谱 — 内存实现 ───────────────────────────────── + +func TestInMemoryGraph_AddNodesAndEdges(t *testing.T) { + g := NewInMemoryGraph() + g.AddNode("n1", "Docker", "tool", "shared") + g.AddNode("n2", "Hermes", "agent", "shared") + g.AddEdge("e1", "n1", "n2", "used_by", "shared", 1.0) + + nodes, edges, density := g.Stats() + if nodes != 2 { + t.Errorf("expected 2 nodes, got %d", nodes) + } + if edges != 1 { + t.Errorf("expected 1 edge, got %d", edges) + } + if density <= 0 || density > 1 { + t.Errorf("density out of range: %f", density) + } +} + +func TestInMemoryGraph_Navigate(t *testing.T) { + g := NewInMemoryGraph() + g.AddNode("n1", "Docker", "tool", "shared") + g.AddNode("n2", "Hermes", "agent", "shared") + g.AddNode("n3", "Feishu", "platform", "shared") + g.AddEdge("e1", "n1", "n2", "used_by", "shared", 1.0) + g.AddEdge("e2", "n2", "n3", "connects_to", "shared", 0.8) + + paths, err := g.Navigate("n1", 2, "shared") + if err != nil { + t.Fatalf("navigate failed: %v", err) + } + if len(paths) < 2 { + t.Errorf("expected at least 2 paths, got %d", len(paths)) + } + + // First hop: n1→n2 + if paths[0]["source"] != "n1" || paths[0]["target"] != "n2" { + t.Errorf("first hop wrong: %v → %v", paths[0]["source"], paths[0]["target"]) + } +} + +func TestInMemoryGraph_Prune(t *testing.T) { + g := NewInMemoryGraph() + g.AddNode("n1", "A", "type", "s") + g.AddNode("n2", "B", "type", "s") + g.AddNode("n3", "C", "type", "s") + g.AddEdge("e1", "n1", "n2", "r", "s", 0.9) + g.AddEdge("e2", "n2", "n3", "r", "s", 0.1) // low weight + + g.Prune(0.5) + _, edges, _ := g.Stats() + if edges != 1 { + t.Errorf("expected 1 edge after prune, got %d", edges) + } +} + +func TestInMemoryGraph_NamespaceIsolation(t *testing.T) { + g := NewInMemoryGraph() + g.AddNode("n1", "A", "type", "shared") + g.AddNode("n2", "B", "type", "hermes") + g.AddEdge("e1", "n1", "n2", "r", "hermes", 1.0) + + paths, _ := g.Navigate("n1", 2, "shared") + if len(paths) > 0 { + t.Error("shared namespace should NOT see hermes-only edges") + } + + paths2, _ := g.Navigate("n1", 2, "hermes") + if len(paths2) == 0 { + t.Error("hermes namespace should see its edges") + } +} + +func TestInMemoryGraph_Query(t *testing.T) { + g := NewInMemoryGraph() + g.AddEdge("e1", "docker", "hermes", "used_by", "shared", 1.0) + results := g.Query("docker", "", "shared") + if len(results) != 1 { + t.Errorf("expected 1 result, got %d", len(results)) + } +} + +// ─── 否定词检测 ────────────────────────────────────────── + +func Test_isContradiction(t *testing.T) { + tests := []struct { + a, b string + expect bool + }{ + {"Docker is used by Hermes", "Docker is not used by Hermes", true}, + {"nginx runs on port 80", "nginx runs on port 443", false}, // 无否定词,启发式不检测 + {"system is Linux", "system is Linux", false}, + {"config updated", "config not updated", true}, + } + for _, tc := range tests { + got := isContradiction(tc.a, tc.b) + if got != tc.expect { + t.Errorf("isContradiction(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.expect) + } + } +} + +// ─── CRDT 合并(distributed 包在 routes 中引用,这里测试核心逻辑) ── + +func BenchmarkForgetterDecay(b *testing.B) { + f := NewForgetter() + now := time.Now() + b.ResetTimer() + for i := 0; i < b.N; i++ { + f.DecayScore(now.Add(-time.Duration(i)*24*time.Hour), i%10) + } +} + +func BenchmarkGraphNavigate(b *testing.B) { + g := NewInMemoryGraph() + for i := 0; i < 100; i++ { + g.AddNode("n"+string(rune('a'+i%26)), "node", "t", "s") + } + for i := 0; i < 200; i++ { + g.AddEdge("e"+string(rune('0'+i%10)), "na", "nb", "r", "s", 1.0) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + g.Navigate("na", 2, "s") + } +} diff --git a/go/internal/selfoptimize/selfoptimize_test.go b/go/internal/selfoptimize/selfoptimize_test.go new file mode 100644 index 0000000..2363b1e --- /dev/null +++ b/go/internal/selfoptimize/selfoptimize_test.go @@ -0,0 +1,312 @@ +// 织忆 MemoryWeave — 自优化层单元测试 +package selfoptimize + +import ( + "testing" + "time" +) + +// ─── 仪表盘 ────────────────────────────────────────────── + +func TestDashboard_InitialMetricsAllZero(t *testing.T) { + d := &Dashboard{} + m := d.Metrics() + if m["recall_usefulness_rate"] != 0 { + t.Errorf("expected 0, got %f", m["recall_usefulness_rate"]) + } + if m["recall_hit_rate"] != 0 { + t.Errorf("expected 0, got %f", m["recall_hit_rate"]) + } +} + +func TestDashboard_UsefulnessRate(t *testing.T) { + d := &Dashboard{} + d.RecordFeedback(true) + d.RecordFeedback(true) + d.RecordFeedback(true) + d.RecordFeedback(false) + + m := d.Metrics() + if m["recall_usefulness_rate"] != 0.75 { + t.Errorf("expected 0.75, got %f", m["recall_usefulness_rate"]) + } +} + +func TestDashboard_HitRate(t *testing.T) { + d := &Dashboard{} + d.RecordRecall(true) + d.RecordRecall(true) + d.RecordRecall(false) + + m := d.Metrics() + if m["recall_hit_rate"] < 0.66 || m["recall_hit_rate"] > 0.67 { + t.Errorf("expected ~0.67, got %f", m["recall_hit_rate"]) + } +} + +func TestDashboard_GapClosureRate(t *testing.T) { + d := &Dashboard{} + d.TotalGaps = 10 + d.ClosedGaps = 5 + m := d.Metrics() + if m["gap_closure_rate"] != 0.5 { + t.Errorf("expected 0.5, got %f", m["gap_closure_rate"]) + } +} + +func TestDashboard_RecordDeprecation(t *testing.T) { + d := &Dashboard{} + d.RecordDeprecation() + d.RecordDeprecation() + if d.DeprecatedToday != 2 { + t.Errorf("expected 2, got %d", d.DeprecatedToday) + } +} + +func TestDashboard_RecordCorrection(t *testing.T) { + d := &Dashboard{} + d.RecordCorrection("muchen_correction") + d.RecordCorrection("config_parse") + if d.TotalFixes != 2 { + t.Errorf("expected 2 fixes, got %d", d.TotalFixes) + } + if d.CascadeFixedTotal != 1 { + t.Errorf("expected 1 cascade, got %d", d.CascadeFixedTotal) + } +} + +func TestDashboard_RecordConflictResolved(t *testing.T) { + d := &Dashboard{} + d.RecordConflictResolved(true) + d.RecordConflictResolved(false) + if d.TotalConflicts != 2 { + t.Errorf("expected 2 conflicts, got %d", d.TotalConflicts) + } + if d.AutoResolvedConflicts != 1 { + t.Errorf("expected 1 auto, got %d", d.AutoResolvedConflicts) + } + m := d.Metrics() + if m["auto_resolve_rate"] != 0.5 { + t.Errorf("expected 0.5, got %f", m["auto_resolve_rate"]) + } +} + +// ─── 知识缺口检测 ──────────────────────────────────────── + +func TestGapDetector_NotTriggeredBeforeThreshold(t *testing.T) { + gd := NewGapDetector() + gap := gd.RecordMiss("kubernetes") + if gap != nil { + t.Error("should not trigger gap after 1 miss") + } + gd.RecordMiss("kubernetes") + if gap := gd.RecordMiss("kubernetes"); gap == nil { + t.Error("should trigger gap after 3 misses") + } +} + +func TestGapDetector_DetectsGapAtThreshold(t *testing.T) { + gd := NewGapDetector() + gd.RecordMiss("topicX") + gd.RecordMiss("topicX") + gap := gd.RecordMiss("topicX") + + if gap == nil { + t.Fatal("expected gap after 3 misses") + } + if gap.Topic != "topicX" { + t.Errorf("wrong topic: %s", gap.Topic) + } + if gap.MissCount != 3 { + t.Errorf("expected 3 misses, got %d", gap.MissCount) + } +} + +func TestGapDetector_CloseGap(t *testing.T) { + gd := NewGapDetector() + gd.RecordMiss("topic") + gd.RecordMiss("topic") + gd.RecordMiss("topic") + gd.Close("topic") + + gaps := gd.List() + if len(gaps) == 0 || !gaps[0].Closed { + t.Error("gap should be closed") + } +} + +func TestGapDetector_ClassifySynonym(t *testing.T) { + gd := NewGapDetector() + gd.RecordMiss("API") + gd.RecordMiss("API") + gd.RecordMiss("API") + + gaps := gd.List() + if len(gaps) > 0 && gaps[0].Type != GapSynonym { + t.Errorf("expected synonym gap, got %s", gaps[0].Type) + } +} + +// ─── 因果追踪 ──────────────────────────────────────────── + +func TestCausalTracker_RecordVersions(t *testing.T) { + ct := NewCausalTracker() + ct.RecordVersion("mem1", "Docker is used by Hermes", "muchen_oral", "init") + ct.RecordVersion("mem1", "Docker is used by Hermes AND OpenClaw", "config_parse", "update") + + if !ct.IsVolatile("mem1") { + // 2 versions → not volatile (volatile = 3+) + } + ct.RecordVersion("mem1", "Docker usage updated", "muchen_correction", "correction") + if !ct.IsVolatile("mem1") { + t.Error("mem1 should be volatile after 3 versions") + } +} + +func TestCausalTracker_DependencyChain(t *testing.T) { + ct := NewCausalTracker() + ct.AddDependency("memory_A", "memory_B") + ct.AddDependency("memory_B", "memory_C") + + affected := ct.GetAffected("memory_C", nil) + // memory_B depends on C, memory_A depends on B + foundB := false + foundA := false + for _, id := range affected { + if id == "memory_B" { + foundB = true + } + if id == "memory_A" { + foundA = true + } + } + if !foundB || !foundA { + t.Errorf("expected both A and B affected, got %v", affected) + } +} + +func TestCausalTracker_CircularDependency(t *testing.T) { + ct := NewCausalTracker() + ct.AddDependency("A", "B") + ct.AddDependency("B", "A") + affected := ct.GetAffected("A", nil) + // Should not loop infinitely + if len(affected) >= 10 { + t.Errorf("circular dependency not handled: %d affected", len(affected)) + } +} + +// ─── 来源信任度 ────────────────────────────────────────── + +func TestSourceTrust(t *testing.T) { + tests := []struct { + source string + expect float64 + }{ + {"muchen_oral", 1.0}, + {"muchen_feishu", 0.95}, + {"config_parse", 0.7}, + {"agent_infer", 0.5}, + {"llm_distill", 0.4}, + {"unknown", 0.3}, + } + for _, tc := range tests { + if got := SourceTrust(tc.source); got != tc.expect { + t.Errorf("SourceTrust(%s) = %f, want %f", tc.source, got, tc.expect) + } + } +} + +// ─── 记忆预取 ──────────────────────────────────────────── + +func TestPrefetchGraph_CoOccurrence(t *testing.T) { + pg := NewPrefetchGraph() + pg.RecordCoAccess("docker", "nginx") + pg.RecordCoAccess("docker", "nginx") + pg.RecordCoAccess("docker", "kubernetes") + + prefetch := pg.GetPrefetch("docker") + if len(prefetch) < 1 { + t.Errorf("expected at least 1 prefetch item, got %d", len(prefetch)) + } +} + +func TestPrefetchGraph_NothingToPrefetch(t *testing.T) { + pg := NewPrefetchGraph() + prefetch := pg.GetPrefetch("nonexistent") + if len(prefetch) != 0 { + t.Errorf("expected empty prefetch, got %d", len(prefetch)) + } +} + +// ─── 并发安全 ──────────────────────────────────────────── + +func TestDashboard_Concurrent(t *testing.T) { + d := &Dashboard{} + done := make(chan bool) + for i := 0; i < 50; i++ { + go func(v bool) { + for j := 0; j < 100; j++ { + d.RecordFeedback(v) + d.RecordRecall(v) + } + done <- true + }(i%2 == 0) + } + for i := 0; i < 50; i++ { + <-done + } + m := d.Metrics() + if m["recall_usefulness_rate"] < 0 || m["recall_usefulness_rate"] > 1 { + t.Errorf("usefulness rate out of bounds: %f", m["recall_usefulness_rate"]) + } +} + +// ─── 基准测试 ──────────────────────────────────────────── + +func BenchmarkDashboardMetrics(b *testing.B) { + d := &Dashboard{} + d.UsefulCount = 1000 + d.NotUsefulCount = 200 + d.TotalRecalls = 1200 + d.HitCount = 900 + b.ResetTimer() + for i := 0; i < b.N; i++ { + d.Metrics() + } +} + +func BenchmarkGapDetection(b *testing.B) { + gd := NewGapDetector() + b.ResetTimer() + for i := 0; i < b.N; i++ { + gd.RecordMiss("benchmark_topic") + } +} + +func BenchmarkCausalTracking(b *testing.B) { + ct := NewCausalTracker() + for i := 0; i < 100; i++ { + ct.AddDependency("dep"+string(rune('a'+i%26)), "dep"+string(rune('a'+(i+1)%26))) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + ct.GetAffected("depa", nil) + } +} + +func BenchmarkPrefetch(b *testing.B) { + pg := NewPrefetchGraph() + for i := 0; i < 100; i++ { + pg.RecordCoAccess("main", "item"+string(rune('a'+i%26))) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + pg.GetPrefetch("main") + } +} + +func init() { + // Ensure consistent timestamps for snapshot tests + time.Local = time.UTC +}