diff --git a/cmd/homed/main.go b/cmd/homed/main.go index 8049cb1..11e9b41 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -3,6 +3,8 @@ package main import ( "context" "flag" + "fmt" + "io" "log" "os" "os/signal" @@ -45,6 +47,22 @@ func main() { } log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) + + // 文件日志:同时输出到控制台和 data/log/ 目录 + logDir := filepath.Join(*dataDir, "log") + if err := os.MkdirAll(logDir, 0755); err != nil { + log.Printf("[homed] warning: cannot create log dir: %v", err) + } else { + logPath := filepath.Join(logDir, fmt.Sprintf("homed_%s.log", time.Now().Format("2006-01-02_15-04-05"))) + logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + log.Printf("[homed] warning: cannot open log file: %v", err) + } else { + log.SetOutput(io.MultiWriter(os.Stderr, logFile)) + log.Printf("[homed] logging to %s", logPath) + } + } + log.Printf("[homed] starting HomeAgent v0.1.0 (pure kernel)") agentWorkDir := filepath.Join(*dataDir, "agentfs") diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index 0b1f04c..d1a992d 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -1651,6 +1651,65 @@ func (a *Agent) reorgGraph() { } else { log.Printf("[agent] graph reorg: no similar entities found") } + + // 5. 图连接质量评估:由 LLM 判断低质量关系并丢弃 + a.evaluateGraphQuality() +} + +func (a *Agent) evaluateGraphQuality() { + if a.memory == nil { + return + } + + // 召回近期低 confidence 关系(使用默认 recall 获取最新实体和关系) + result, err := a.memory.Recall(nil, nil, 1, "") + if err != nil || result == nil || len(result.Relations) == 0 { + return + } + + // 选出低质量候选:generic 关系(如 distiller 自动生成的泛化关系) + var lowQuality []string + for _, r := range result.Relations { + // 自动蒸馏生成的 (用户, 提及, ...) 和 (AI, 回应, ...) 通常是噪音 + if (r.SourceName == "用户" || r.SourceName == "AI") && + (r.RelationType == "提及" || r.RelationType == "回应") { + lowQuality = append(lowQuality, fmt.Sprintf("「%s」-「%s」→「%s」", r.SourceName, r.RelationType, r.TargetName)) + continue + } + // 极低 mention 的实体+generic 关系 + if r.Confidence < 0.3 && r.RelationType != "" { + lowQuality = append(lowQuality, fmt.Sprintf("「%s」-「%s」→「%s」(confidence=%.1f)", r.SourceName, r.RelationType, r.TargetName, r.Confidence)) + } + } + + if len(lowQuality) == 0 { + return + } + + // 分批发送给 LLM 决策,每批最多 10 条 + batchSize := 10 + for i := 0; i < len(lowQuality); i += batchSize { + end := i + batchSize + if end > len(lowQuality) { + end = len(lowQuality) + } + batch := lowQuality[i:end] + + a.enqueueConsolidationTask(ConsolidationTask{ + Type: "graph_quality", + Reason: fmt.Sprintf( + "图数据库中发现 %d 条低质量关系,请逐条判断是否应该删除(保留 = keep,删除 = discard):\n%s", + len(batch), + strings.Join(batch, "\n"), + ), + Data: map[string]interface{}{ + "candidates": batch, + "action": "evaluate_quality", + }, + }) + } + + log.Printf("[agent] graph quality: %d low-quality connection batches sent for LLM evaluation", (len(lowQuality)+batchSize-1)/batchSize) } // entitySimilarity 计算两个实体名的相似度(字符 bigram Jaccard) diff --git a/internal/memory/indexer_test.go b/internal/memory/indexer_test.go new file mode 100644 index 0000000..3b6d845 --- /dev/null +++ b/internal/memory/indexer_test.go @@ -0,0 +1,246 @@ +package memory + +import ( + "path/filepath" + "testing" +) + +func TestNewIndexer(t *testing.T) { + idx := NewIndexer(nil) + if idx == nil { + t.Fatal("expected non-nil indexer") + } +} + +func TestIndexerSyncWithNilDB(t *testing.T) { + idx := NewIndexer(nil) + if err := idx.Sync(); err != nil { + t.Errorf("expected no error with nil db, got %v", err) + } +} + +func TestIndexerSync(t *testing.T) { + db, err := NewGraphDB(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + // 写入一些实体 + ec, _, err := db.Commit([]Triple{ + {Subject: "张三", Relation: "喜欢", Object: "篮球"}, + {Subject: "李四", Relation: "喜欢", Object: "足球"}, + }, "test", 0) + if err != nil { + t.Fatal(err) + } + + idx := NewIndexer(db) + if err := idx.Sync(); err != nil { + t.Fatal(err) + } + + if !idx.trained { + t.Error("expected indexer to be trained after sync") + } + if idx.vec.Size() != ec { + t.Errorf("expected %d vectors, got %d", ec, idx.vec.Size()) + } +} + +func TestIndexerBuildContext(t *testing.T) { + db, err := NewGraphDB(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + db.Commit([]Triple{ + {Subject: "张三", Relation: "喜欢", Object: "篮球"}, + {Subject: "张三", Relation: "职业", Object: "程序员"}, + }, "test", 0) + + idx := NewIndexer(db) + idx.Sync() + + ctx := idx.BuildContext("张三") + if ctx == nil { + t.Fatal("expected non-nil context") + } + if len(ctx.Entities) == 0 { + t.Error("expected at least one entity in context") + } +} + +func TestIndexerBuildContextEmpty(t *testing.T) { + idx := NewIndexer(nil) + ctx := idx.BuildContext("anything") + if ctx == nil { + t.Fatal("expected non-nil context even with nil db") + } + if ctx.Summary != "" { + t.Errorf("expected empty summary with nil db, got %q", ctx.Summary) + } +} + +func TestIndexerMarkRecalled(t *testing.T) { + idx := NewIndexer(nil) + idx.MarkRecalled("张三", "李四") + + idx.mu.RLock() + _, ok1 := idx.recalled["张三"] + _, ok2 := idx.recalled["李四"] + _, ok3 := idx.recalled["王五"] + idx.mu.RUnlock() + + if !ok1 { + t.Error("expected '张三' to be marked recalled") + } + if !ok2 { + t.Error("expected '李四' to be marked recalled") + } + if ok3 { + t.Error("expected '王五' NOT to be marked recalled") + } +} + +func TestIndexerFilterRecalled(t *testing.T) { + db, err := NewGraphDB(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + db.Commit([]Triple{ + {Subject: "张三", Relation: "喜欢", Object: "篮球"}, + {Subject: "李四", Relation: "喜欢", Object: "足球"}, + }, "test", 0) + + idx := NewIndexer(db) + idx.Sync() + idx.MarkRecalled("张三") + + ctx := idx.BuildContext("张三") + if ctx == nil { + t.Fatal("expected non-nil context") + } + for _, e := range ctx.Entities { + if e.Name == "张三" { + t.Error("expected '张三' to be filtered out (marked recalled)") + } + } +} + +func TestFormatContext(t *testing.T) { + idx := NewIndexer(nil) + + result := idx.FormatContext(nil) + if result != "" { + t.Errorf("expected empty string for nil context, got %q", result) + } + + ctx := &InjectedContext{ + Entities: []Entity{ + {ID: 1, Name: "张三", Type: "Person"}, + {ID: 2, Name: "篮球", Type: "Concept"}, + }, + Summary: "关联 2 个记忆实体,高频:张三、篮球", + } + result = idx.FormatContext(ctx) + if result == "" { + t.Error("expected non-empty formatted context") + } + if !contains(result, "张三") || !contains(result, "篮球") { + t.Errorf("expected context to contain entity names, got %q", result) + } +} + +func TestBuildIndexSummary(t *testing.T) { + entities := []Entity{ + {Name: "张三", Type: "Person", MentionCount: 10}, + {Name: "李四", Type: "Person", MentionCount: 5}, + {Name: "篮球", Type: "Concept", MentionCount: 3}, + {Name: "北京", Type: "Location", MentionCount: 2}, + } + s := buildIndexSummary(entities) + if !contains(s, "张三") || !contains(s, "李四") { + t.Errorf("expected summary to contain top entities, got %q", s) + } +} + +func TestBuildIndexSummaryEmpty(t *testing.T) { + s := buildIndexSummary(nil) + if s != "" { + t.Errorf("expected empty summary for nil, got %q", s) + } +} + +func TestExtractKeywords(t *testing.T) { + tests := []struct { + input string + min int + }{ + {"今天天气怎么样", 1}, + {"张三的朋友李四", 1}, + {"hello world", 1}, + {"的了的", 0}, // all stop words + } + for _, tt := range tests { + kw := extractKeywords(tt.input) + if len(kw) < tt.min { + t.Errorf("extractKeywords(%q) = %v, want at least %d keywords", tt.input, kw, tt.min) + } + } +} + +func TestGetToolDefinitions(t *testing.T) { + idx := NewIndexer(nil) + defs := idx.GetToolDefinitions() + if len(defs) < 3 { + t.Errorf("expected at least 3 tool defs, got %d", len(defs)) + } +} + +func TestBuildToolPrompt(t *testing.T) { + idx := NewIndexer(nil) + prompt := idx.BuildToolPrompt() + if !contains(prompt, "memory_recall") { + t.Errorf("expected prompt to mention memory_recall, got %q", prompt) + } +} + +func TestVectorSearchEntities(t *testing.T) { + db, err := NewGraphDB(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + db.Commit([]Triple{ + {Subject: "张三", Relation: "喜欢", Object: "篮球"}, + }, "test", 0) + + idx := NewIndexer(db) + idx.Sync() + + // After sync, we have entities. Vector search should find relevant ones. + entities := idx.vectorSearchEntities("张三") + if len(entities) == 0 { + // This might be empty due to TF-IDF matching — vector search on single + // entity names is approximate. Just check it doesn't crash. + t.Log("vector search returned 0 results (acceptable for short queries)") + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsStr(s, substr)) +} + +func containsStr(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/memory/pipeline/pipeline_test.go b/internal/memory/pipeline/pipeline_test.go new file mode 100644 index 0000000..6a0a48c --- /dev/null +++ b/internal/memory/pipeline/pipeline_test.go @@ -0,0 +1,290 @@ +package pipeline + +import ( + "os" + "path/filepath" + "testing" + "time" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" +) + +func TestNewDistiller(t *testing.T) { + d := NewDistiller(nil, t.TempDir(), DistillerConfig{ + Interval: 10 * time.Minute, + RetentionDays: 7, + BatchSize: 50, + }) + if d == nil { + t.Fatal("expected non-nil distiller") + } + if d.cfg.Interval != 10*time.Minute { + t.Errorf("expected interval 10m, got %v", d.cfg.Interval) + } +} + +func TestAppendAndFlush(t *testing.T) { + dir := t.TempDir() + d := NewDistiller(nil, dir, DistillerConfig{ + Interval: 10 * time.Minute, + RetentionDays: 7, + BatchSize: 50, + }) + // flush 需要 rawPath 目录已存在(Start() 负责创建) + os.MkdirAll(d.rawPath, 0755) + + d.Append("sess1", "user", "今天天气怎么样?") + d.Append("sess1", "assistant", "今天天气很好,适合出行。") + + if len(d.records) != 2 { + t.Fatalf("expected 2 records, got %d", len(d.records)) + } + + d.flush() + + // 验证 JSONL 已写入 + entries, err := os.ReadDir(d.rawPath) + if err != nil { + t.Fatal(err) + } + if len(entries) == 0 { + t.Fatal("expected flushed files") + } +} + +func TestLoadExisting(t *testing.T) { + dir := t.TempDir() + + // 先写一个文件再创建 Distiller 验证 load + d1 := NewDistiller(nil, dir, DistillerConfig{ + Interval: 10 * time.Minute, + RetentionDays: 7, + BatchSize: 50, + }) + os.MkdirAll(d1.rawPath, 0755) + d1.Append("sess1", "user", "test content") + d1.flush() + + d2 := NewDistiller(nil, dir, DistillerConfig{ + Interval: 10 * time.Minute, + RetentionDays: 7, + BatchSize: 50, + }) + d2.loadExisting() + + if len(d2.records) != 1 { + t.Fatalf("expected 1 record after load, got %d", len(d2.records)) + } +} + +func TestDistillOnce(t *testing.T) { + db, err := memory.NewGraphDB(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + dir := t.TempDir() + d := NewDistiller(db, dir, DistillerConfig{ + Interval: 10 * time.Minute, + RetentionDays: 7, + BatchSize: 50, + }) + // 写入一条旧记录(超过保留期) + past := time.Now().Add(-8 * 24 * time.Hour) + d.records = append(d.records, RawRecord{ + ID: 1, SessionID: "sess1", Role: "user", + Content: "我的名字是张三", CreatedAt: past, Distilled: false, + }) + d.records = append(d.records, RawRecord{ + ID: 2, SessionID: "sess1", Role: "assistant", + Content: "你好张三!", CreatedAt: past, Distilled: false, + }) + + d.distillOnce() + + // 验证蒸馏后已有记录被标记(distillOnce 从 records 中移除已蒸馏记录,检查剩余数量) + if len(d.records) != 0 { + t.Logf("records after distill: %d (all should have been removed)", len(d.records)) + } +} + +func TestExtractKeyTriples(t *testing.T) { + tests := []struct { + user string + assistant string + want int // expected number of triples + check func([]memory.Triple) bool + }{ + { + user: "我叫张三", + want: 1, + check: func(triples []memory.Triple) bool { + for _, tr := range triples { + if tr.Subject == "用户" && tr.Relation == "姓名" && tr.Object == "张三" { + return true + } + } + return false + }, + }, + { + user: "我住在北京", + want: 1, + check: func(triples []memory.Triple) bool { + for _, tr := range triples { + if tr.Subject == "用户" && tr.Relation == "居住地" && tr.Object == "北京" { + return true + } + } + return false + }, + }, + { + user: "我喜欢打篮球", + want: 1, + check: func(triples []memory.Triple) bool { + for _, tr := range triples { + if tr.Subject == "用户" && tr.Relation == "喜好" && tr.Object == "打篮球" { + return true + } + } + return false + }, + }, + { + user: "我28岁", + want: 1, + check: func(triples []memory.Triple) bool { + for _, tr := range triples { + if tr.Subject == "用户" && tr.Relation == "年龄" && tr.Object == "28" { + return true + } + } + return false + }, + }, + { + user: "我的工作是程序员", + want: 1, + check: func(triples []memory.Triple) bool { + for _, tr := range triples { + if tr.Subject == "用户" && tr.Relation == "职业" && tr.Object == "程序员" { + return true + } + } + return false + }, + }, + { + user: "今天天气真好", + want: 0, // 没有匹配任何规则 + check: func(triples []memory.Triple) bool { + return true // any result is fine + }, + }, + } + + for _, tt := range tests { + triples := extractKeyTriples(tt.user, tt.assistant) + if len(triples) != tt.want { + t.Errorf("extractKeyTriples(%q) = %d triples, want %d", tt.user, len(triples), tt.want) + } + if tt.check != nil && !tt.check(triples) { + t.Errorf("extractKeyTriples(%q) = %v, check failed", tt.user, triples) + } + } +} + +func TestExtractName(t *testing.T) { + tests := []struct{ input, want string }{ + {"我叫张三", "张三"}, + {"我的名字是李四", "李四"}, + {"今天天气好", ""}, + } + for _, tt := range tests { + got := extractName(tt.input) + if got != tt.want { + t.Errorf("extractName(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestExtractLocation(t *testing.T) { + tests := []struct{ input, want string }{ + {"我住在北京", "北京"}, + {"我家在上海", "上海"}, + {"hello", ""}, + } + for _, tt := range tests { + got := extractLocation(tt.input) + if got != tt.want { + t.Errorf("extractLocation(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestExtractLike(t *testing.T) { + tests := []struct{ input, want string }{ + {"我喜欢打篮球", "打篮球"}, + {"我最喜欢跑步", "跑步"}, + {"nothing", ""}, + } + for _, tt := range tests { + got := extractLike(tt.input) + if got != tt.want { + t.Errorf("extractLike(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestExtractAge(t *testing.T) { + tests := []struct{ input, want string }{ + {"我28岁", "28"}, + {"我的年龄是30", "30"}, + {"hello", ""}, + } + for _, tt := range tests { + got := extractAge(tt.input) + if got != tt.want { + t.Errorf("extractAge(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestDistillerGetRecentRecords(t *testing.T) { + d := NewDistiller(nil, t.TempDir(), DistillerConfig{}) + d.Append("s1", "user", "a") + d.Append("s1", "user", "b") + d.Append("s1", "user", "c") + + recent := d.GetRecentRecords(2) + if len(recent) != 2 { + t.Fatalf("expected 2 recent, got %d", len(recent)) + } + if recent[0].Content != "b" || recent[1].Content != "c" { + t.Errorf("expected [b c], got %v", recent) + } +} + +func TestDistillerStats(t *testing.T) { + d := NewDistiller(nil, t.TempDir(), DistillerConfig{ + Interval: 5 * time.Minute, + RetentionDays: 3, + BatchSize: 10, + }) + d.Append("s1", "user", "hello") + stats := d.Stats() + if stats["raw_records"] != 1 { + t.Errorf("expected 1 raw record, got %v", stats["raw_records"]) + } +} + +func TestTruncate(t *testing.T) { + if truncate("hello world", 5) != "hello..." { + t.Errorf("expected 'hello...', got %q", truncate("hello world", 5)) + } + if truncate("hi", 10) != "hi" { + t.Errorf("expected 'hi', got %q", truncate("hi", 10)) + } +} diff --git a/internal/memory/social/social_test.go b/internal/memory/social/social_test.go new file mode 100644 index 0000000..9b0d4bd --- /dev/null +++ b/internal/memory/social/social_test.go @@ -0,0 +1,195 @@ +package social + +import ( + "path/filepath" + "testing" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" +) + +func setupTestDB(t *testing.T) (*memory.GraphDB, *SocialStore) { + t.Helper() + db, err := memory.NewGraphDB(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + s := New(db) + return db, s +} + +func TestNew(t *testing.T) { + s := New(nil) + if s == nil { + t.Fatal("expected non-nil social store") + } +} + +func TestGetPersonNotFound(t *testing.T) { + _, s := setupTestDB(t) + defer s.db.Close() + + _, err := s.GetPerson("不存在的人") + if err == nil { + t.Fatal("expected error for non-existent person") + } +} + +func TestSetAndGetTrait(t *testing.T) { + _, s := setupTestDB(t) + defer s.db.Close() + + err := s.SetTrait("张三", "性格", "开朗") + if err != nil { + t.Fatal(err) + } + + val, found := s.GetTrait("张三", "性格") + if !found { + t.Fatal("trait not found") + } + if val != "开朗" { + t.Errorf("expected '开朗', got %q", val) + } +} + +func TestUpdateTrait(t *testing.T) { + _, s := setupTestDB(t) + defer s.db.Close() + + s.SetTrait("张三", "性格", "开朗") + s.SetTrait("张三", "性格", "内向") + + val, found := s.GetTrait("张三", "性格") + if !found { + t.Fatal("trait not found after update") + } + if val != "内向" { + t.Errorf("expected '内向', got %q", val) + } +} + +func TestAddAndGetRelation(t *testing.T) { + _, s := setupTestDB(t) + defer s.db.Close() + + err := s.AddRelation("张三", "朋友", "李四") + if err != nil { + t.Fatal(err) + } + + rels, err := s.GetRelations("张三") + if err != nil { + t.Fatal(err) + } + if len(rels) != 1 { + t.Fatalf("expected 1 relation, got %d", len(rels)) + } + if rels[0].Person != "李四" || rels[0].Relation != "朋友" { + t.Errorf("unexpected relation: %+v", rels[0]) + } +} + +func TestGetNetwork(t *testing.T) { + _, s := setupTestDB(t) + defer s.db.Close() + + s.AddRelation("张三", "朋友", "李四") + s.AddRelation("李四", "同事", "王五") + + network, err := s.GetNetwork("张三", 2) + if err != nil { + t.Fatal(err) + } + if len(network) < 2 { + t.Errorf("expected at least 2 persons in network, got %d", len(network)) + } +} + +func TestListPersons(t *testing.T) { + _, s := setupTestDB(t) + defer s.db.Close() + + s.SetTrait("张三", "性格", "开朗") + s.AddRelation("张三", "朋友", "李四") + + persons, err := s.ListPersons() + if err != nil { + t.Fatal(err) + } + if len(persons) == 0 { + t.Fatal("expected at least one person") + } +} + +func TestRemoveRelation(t *testing.T) { + _, s := setupTestDB(t) + defer s.db.Close() + + s.AddRelation("张三", "朋友", "李四") + err := s.RemoveRelation("张三", "朋友", "李四") + if err != nil { + t.Fatal(err) + } + + rels, _ := s.GetRelations("张三") + if len(rels) != 0 { + t.Errorf("expected 0 relations after remove, got %d", len(rels)) + } +} + +func TestNilDBSafety(t *testing.T) { + s := New(nil) + + _, err := s.GetPerson("test") + if err == nil { + t.Error("expected error with nil db") + } + + err = s.SetTrait("test", "t", "v") + if err == nil { + t.Error("expected error with nil db") + } + + _, found := s.GetTrait("test", "t") + if found { + t.Error("expected not found with nil db") + } + + _, err = s.ListPersons() + if err == nil { + t.Error("expected error with nil db") + } +} + +func TestGetPersonProfile(t *testing.T) { + _, s := setupTestDB(t) + defer s.db.Close() + + s.SetTrait("张三", "性格", "开朗") + s.SetTrait("张三", "职业", "程序员") + s.AddRelation("张三", "朋友", "李四") + + profile, err := s.GetPerson("张三") + if err != nil { + t.Fatal(err) + } + if profile.Name != "张三" { + t.Errorf("expected name '张三', got %q", profile.Name) + } + if profile.Traits["性格"] != "开朗" { + t.Errorf("expected trait '开朗', got %q", profile.Traits["性格"]) + } + if profile.Traits["职业"] != "程序员" { + t.Errorf("expected trait '程序员', got %q", profile.Traits["职业"]) + } + if len(profile.Relations) != 1 || profile.Relations[0].Person != "李四" { + // BFS 可能返回重复关系,用 set 去重验证 + seen := make(map[string]bool) + for _, r := range profile.Relations { + seen[r.Person+"/"+r.Relation] = true + } + if !seen["李四/朋友"] { + t.Errorf("expected relation 李四/朋友, got %+v", profile.Relations) + } + } +} diff --git a/internal/memory/text/memory_test.go b/internal/memory/text/memory_test.go new file mode 100644 index 0000000..29fa7fc --- /dev/null +++ b/internal/memory/text/memory_test.go @@ -0,0 +1,332 @@ +package text + +import ( + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +func TestNew(t *testing.T) { + m := New(t.TempDir()) + if m == nil { + t.Fatal("expected non-nil memory") + } +} + +func TestAppendAndReplay(t *testing.T) { + dir := t.TempDir() + m := New(dir) + if err := m.Start(); err != nil { + t.Fatal(err) + } + defer m.Stop() + + evt := Event{ + Timestamp: time.Now().Unix(), + Source: "test", + Input: "hello", + Response: "world", + } + if err := m.Append(evt); err != nil { + t.Fatal(err) + } + + var count int + err := m.Replay(func(e Event) error { + count++ + if e.Input != "hello" || e.Response != "world" { + t.Errorf("unexpected event: %+v", e) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if count != 1 { + t.Errorf("expected 1 event, got %d", count) + } +} + +func TestAppendMultiple(t *testing.T) { + dir := t.TempDir() + m := New(dir) + if err := m.Start(); err != nil { + t.Fatal(err) + } + defer m.Stop() + + for i := 0; i < 10; i++ { + m.Append(Event{ + Timestamp: time.Now().Unix(), + Source: "test", + Input: "msg", + }) + } + + var count int + m.Replay(func(e Event) error { count++; return nil }) + if count != 10 { + t.Errorf("expected 10 events, got %d", count) + } +} + +func TestRecentEvents(t *testing.T) { + dir := t.TempDir() + m := New(dir) + if err := m.Start(); err != nil { + t.Fatal(err) + } + defer m.Stop() + + for i := 0; i < 10; i++ { + m.Append(Event{ + Timestamp: time.Now().Unix(), + Source: "test", + Input: "msg", + }) + } + + recent, err := m.RecentEvents(3) + if err != nil { + t.Fatal(err) + } + if len(recent) != 3 { + t.Errorf("expected 3 recent events, got %d", len(recent)) + } +} + +func TestFileCount(t *testing.T) { + dir := t.TempDir() + m := New(dir) + if err := m.Start(); err != nil { + t.Fatal(err) + } + defer m.Stop() + + if m.FileCount() != 1 { + t.Errorf("expected 1 file, got %d", m.FileCount()) + } +} + +func TestConcurrentAppend(t *testing.T) { + dir := t.TempDir() + m := New(dir) + if err := m.Start(); err != nil { + t.Fatal(err) + } + defer m.Stop() + + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + m.Append(Event{ + Timestamp: time.Now().Unix(), + Source: "test", + Input: "concurrent", + }) + }() + } + wg.Wait() + + var count int + m.Replay(func(e Event) error { count++; return nil }) + if count != 20 { + t.Errorf("expected 20 events, got %d", count) + } +} + +func TestPurgeByFilter(t *testing.T) { + dir := t.TempDir() + m := New(dir) + if err := m.Start(); err != nil { + t.Fatal(err) + } + defer m.Stop() + + m.Append(Event{Timestamp: 1, Source: "keep", Input: "a"}) + m.Append(Event{Timestamp: 2, Source: "delete", Input: "b"}) + m.Append(Event{Timestamp: 3, Source: "keep", Input: "c"}) + + removed, err := m.PurgeByFilter(func(e Event) bool { + return e.Source == "delete" + }) + if err != nil { + t.Fatal(err) + } + if removed != 1 { + t.Errorf("expected 1 removed, got %d", removed) + } + + var count int + m.Replay(func(e Event) error { count++; return nil }) + if count != 2 { + t.Errorf("expected 2 events after purge, got %d", count) + } +} + +func TestReplaceByFilter(t *testing.T) { + dir := t.TempDir() + m := New(dir) + if err := m.Start(); err != nil { + t.Fatal(err) + } + defer m.Stop() + + m.Append(Event{Timestamp: 1, Source: "old", Input: "a"}) + + replaced, err := m.ReplaceByFilter( + func(e Event) bool { return e.Source == "old" }, + func(e Event) Event { e.Source = "new"; return e }, + ) + if err != nil { + t.Fatal(err) + } + if replaced != 1 { + t.Errorf("expected 1 replaced, got %d", replaced) + } + + var evt Event + m.Replay(func(e Event) error { evt = e; return nil }) + if evt.Source != "new" { + t.Errorf("expected source 'new', got %q", evt.Source) + } +} + +func TestRotation(t *testing.T) { + dir := t.TempDir() + m := New(dir, WithMaxSizeBytes(100)) // small max size to trigger rotation + if err := m.Start(); err != nil { + t.Fatal(err) + } + defer m.Stop() + + // Write enough data to trigger rotation + for i := 0; i < 50; i++ { + m.Append(Event{ + Timestamp: time.Now().Unix(), + Source: "test", + Input: strings.Repeat("x", 50), + }) + } + + if m.FileCount() > 1 { + t.Logf("rotation triggered: %d files", m.FileCount()) + } +} + +func TestStats(t *testing.T) { + dir := t.TempDir() + m := New(dir, WithMaxSizeBytes(1000), WithRotationInterval(30*time.Minute)) + if err := m.Start(); err != nil { + t.Fatal(err) + } + defer m.Stop() + + m.Append(Event{Timestamp: time.Now().Unix(), Source: "test", Input: "hello"}) + + stats := m.Stats() + if stats["file_count"] != 1 { + t.Errorf("expected 1 file, got %v", stats["file_count"]) + } + if stats["rotation_bytes"].(int64) != 1000 { + t.Errorf("expected 1000 bytes, got %v", stats["rotation_bytes"]) + } +} + +func TestStopWithoutStart(t *testing.T) { + m := New(t.TempDir()) + // Should not panic + m.Stop() +} + +func TestEmptyDir(t *testing.T) { + dir := t.TempDir() + m := New(dir) + if err := m.Start(); err != nil { + t.Fatal(err) + } + defer m.Stop() + + var count int + m.Replay(func(e Event) error { count++; return nil }) + if count != 0 { + t.Errorf("expected 0 events in empty dir, got %d", count) + } +} + +func TestFilePersistence(t *testing.T) { + dir := t.TempDir() + + // Write events + m1 := New(dir) + m1.Start() + m1.Append(Event{Timestamp: 100, Source: "test", Input: "persist"}) + m1.Stop() + + // Read back with new instance + m2 := New(dir) + m2.Start() + defer m2.Stop() + + var count int + m2.Replay(func(e Event) error { + count++ + return nil + }) + if count != 1 { + t.Errorf("expected 1 persisted event, got %d", count) + } +} + +func TestListFiles(t *testing.T) { + dir := t.TempDir() + // Create a non-text file that should be ignored + os.WriteFile(filepath.Join(dir, "other.txt"), []byte("ignored"), 0644) + + m := New(dir) + if err := m.Start(); err != nil { + t.Fatal(err) + } + defer m.Stop() + + m.Append(Event{Timestamp: 1, Source: "test", Input: "a"}) + + files, err := m.listFiles() + if err != nil { + t.Fatal(err) + } + if len(files) != 1 { + t.Errorf("expected 1 file, got %d", len(files)) + } +} + +func TestPurgeAll(t *testing.T) { + dir := t.TempDir() + m := New(dir) + if err := m.Start(); err != nil { + t.Fatal(err) + } + defer m.Stop() + + m.Append(Event{Timestamp: 1, Source: "test", Input: "a"}) + m.Append(Event{Timestamp: 2, Source: "test", Input: "b"}) + + removed, err := m.PurgeByFilter(func(e Event) bool { return true }) + if err != nil { + t.Fatal(err) + } + if removed != 2 { + t.Errorf("expected 2 removed, got %d", removed) + } + + var count int + m.Replay(func(e Event) error { count++; return nil }) + if count != 0 { + t.Errorf("expected 0 events after full purge, got %d", count) + } +}