package memory import ( "context" "os" "strings" "testing" "time" ) // ── FreshnessConfig ────────────────────────────────────────────────────────── func TestDefaultFreshnessConfig(t *testing.T) { cfg := DefaultFreshnessConfig() if cfg.GlobalThreshold != 24*time.Hour { t.Errorf("GlobalThreshold = %v, want 24h", cfg.GlobalThreshold) } if cfg.TypeOverrides != nil { t.Errorf("TypeOverrides should be nil by default") } } func TestFreshnessConfig_ThresholdFor_Global(t *testing.T) { cfg := FreshnessConfig{GlobalThreshold: 12 * time.Hour} got := cfg.ThresholdFor(TypeUser) if got != 12*time.Hour { t.Errorf("ThresholdFor(user) = %v, want 12h", got) } } func TestFreshnessConfig_ThresholdFor_Override(t *testing.T) { cfg := FreshnessConfig{ GlobalThreshold: 24 * time.Hour, TypeOverrides: map[string]time.Duration{ "project": 2 * time.Hour, }, } if got := cfg.ThresholdFor(TypeProject); got != 2*time.Hour { t.Errorf("ThresholdFor(project) = %v, want 2h", got) } // 非覆盖类型回落到 GlobalThreshold if got := cfg.ThresholdFor(TypeUser); got != 24*time.Hour { t.Errorf("ThresholdFor(user) = %v, want 24h", got) } } // ── ShouldWarn ─────────────────────────────────────────────────────────────── func TestShouldWarn_ZeroModTime(t *testing.T) { // 零值 modTime = 未落盘,不警告 if ShouldWarn(time.Time{}, 24*time.Hour) { t.Error("ShouldWarn with zero modTime should return false") } } func TestShouldWarn_BelowThreshold(t *testing.T) { recent := time.Now().Add(-1 * time.Hour) if ShouldWarn(recent, 24*time.Hour) { t.Error("1 hour old memory with 24h threshold should not warn") } } func TestShouldWarn_AboveThreshold(t *testing.T) { old := time.Now().Add(-25 * time.Hour) if !ShouldWarn(old, 24*time.Hour) { t.Error("25 hour old memory with 24h threshold should warn") } } func TestShouldWarn_ZeroThreshold_AlwaysWarn(t *testing.T) { // threshold == 0 = 总是警告(医疗/金融场景) recent := time.Now().Add(-1 * time.Second) if !ShouldWarn(recent, 0) { t.Error("zero threshold should always warn for any non-zero age") } } // ── AgeString ──────────────────────────────────────────────────────────────── func TestAgeString_Minutes(t *testing.T) { cases := []struct { d time.Duration want string }{ {30 * time.Second, "1 minute"}, // < 90s → 1 minute {45 * time.Minute, "45 minutes"}, {90 * time.Minute, "90 minutes"}, // 1.5h < 2h → still minutes } for _, tc := range cases { got := AgeString(tc.d) if got != tc.want { t.Errorf("AgeString(%v) = %q, want %q", tc.d, got, tc.want) } } } func TestAgeString_Hours(t *testing.T) { cases := []struct { d time.Duration want string }{ {2 * time.Hour, "2 hours"}, {6 * time.Hour, "6 hours"}, {47 * time.Hour, "47 hours"}, // < 48h → hours } for _, tc := range cases { got := AgeString(tc.d) if got != tc.want { t.Errorf("AgeString(%v) = %q, want %q", tc.d, got, tc.want) } } } func TestAgeString_Days(t *testing.T) { cases := []struct { d time.Duration want string }{ {48 * time.Hour, "2 days"}, {72 * time.Hour, "3 days"}, {24 * time.Hour * 30, "30 days"}, } for _, tc := range cases { got := AgeString(tc.d) if got != tc.want { t.Errorf("AgeString(%v) = %q, want %q", tc.d, got, tc.want) } } } func TestAgeString_Singular(t *testing.T) { // 边界规则:< 2h → 分钟显示;< 48h → 小时显示;>= 48h → 天显示 // 因此 1 分钟 → "1 minute",1 小时 → "60 minutes"(< 2h),48h → "2 days" if got := AgeString(1 * time.Minute); got != "1 minute" { t.Errorf("AgeString(1min) = %q, want \"1 minute\"", got) } // 1h < 2h threshold → minutes bucket if got := AgeString(1 * time.Hour); got != "60 minutes" { t.Errorf("AgeString(1h) = %q, want \"60 minutes\" (< 2h threshold)", got) } // 48h >= 48h threshold → days bucket,48h = 2 days if got := AgeString(48 * time.Hour); got != "2 days" { t.Errorf("AgeString(48h) = %q, want \"2 days\"", got) } // 24h < 48h threshold → hours bucket if got := AgeString(24 * time.Hour); got != "24 hours" { t.Errorf("AgeString(24h) = %q, want \"24 hours\" (< 48h threshold)", got) } } func TestAgeString_Negative(t *testing.T) { // 负值(时钟偏差)不 panic,返回 "1 minute" got := AgeString(-1 * time.Hour) if got == "" { t.Error("AgeString with negative duration should not return empty") } } // ── FreshnessText ───────────────────────────────────────────────────────────── func TestFreshnessText_ContainsAge(t *testing.T) { modTime := time.Now().Add(-3 * 24 * time.Hour) text := FreshnessText(modTime, 24*time.Hour) if !strings.Contains(text, "3 days") { t.Errorf("FreshnessText should mention age, got: %s", text) } if !strings.Contains(text, "verify") { t.Errorf("FreshnessText should contain action advice, got: %s", text) } } // ── FreshnessNote ───────────────────────────────────────────────────────────── func TestFreshnessNote_EmptyWhenFresh(t *testing.T) { recent := time.Now().Add(-1 * time.Hour) note := FreshnessNote(recent, 24*time.Hour) if note != "" { t.Errorf("FreshnessNote should be empty for fresh memory, got: %s", note) } } func TestFreshnessNote_SystemReminderTags(t *testing.T) { old := time.Now().Add(-48 * time.Hour) note := FreshnessNote(old, 24*time.Hour) if !strings.HasPrefix(note, "") { t.Errorf("FreshnessNote should start with , got: %s", note) } if !strings.HasSuffix(note, "") { t.Errorf("FreshnessNote should end with , got: %s", note) } } // ── IndexAnnotation ─────────────────────────────────────────────────────────── func TestIndexAnnotation_EmptyWhenFresh(t *testing.T) { recent := time.Now().Add(-1 * time.Hour) ann := IndexAnnotation(recent, 24*time.Hour) if ann != "" { t.Errorf("IndexAnnotation should be empty for fresh memory, got: %s", ann) } } func TestIndexAnnotation_Format(t *testing.T) { old := time.Now().Add(-3 * 24 * time.Hour) ann := IndexAnnotation(old, 24*time.Hour) if !strings.HasPrefix(ann, " _(last updated") { t.Errorf("IndexAnnotation format wrong, got: %s", ann) } if !strings.Contains(ann, "3 days") { t.Errorf("IndexAnnotation should mention age, got: %s", ann) } if !strings.HasSuffix(ann, ")_") { t.Errorf("IndexAnnotation should end with )_, got: %s", ann) } } // ── TruncateIndex ───────────────────────────────────────────────────────────── func TestTruncateIndex_Empty(t *testing.T) { if got := TruncateIndex(""); got != "" { t.Errorf("TruncateIndex(\"\") = %q, want empty", got) } } func TestTruncateIndex_NoTruncation(t *testing.T) { content := "line1\nline2\nline3\n" got := TruncateIndex(content) if got != content { t.Errorf("TruncateIndex should not modify short content") } } func TestTruncateIndex_LineLimit(t *testing.T) { // 生成 250 行内容 var sb strings.Builder for i := 0; i < 250; i++ { sb.WriteString("line content here\n") } content := sb.String() got := TruncateIndex(content) lines := strings.Split(got, "\n") // 截断后行数 <= 200 行正文 + WARNING 段落(约 3 行) if len(lines) > 210 { t.Errorf("TruncateIndex should limit to ~200 lines, got %d lines", len(lines)) } if !strings.Contains(got, "WARNING") { t.Error("TruncateIndex should append WARNING when truncating") } } func TestTruncateIndex_ByteLimit(t *testing.T) { // 生成超过 25KB 的内容(每行 200 字节,130 行 = 26KB) longLine := strings.Repeat("x", 199) + "\n" // 200 bytes var sb strings.Builder for i := 0; i < 130; i++ { sb.WriteString(longLine) } content := sb.String() got := TruncateIndex(content) if len(got) > 26*1024 { // 允许 WARNING 附加文本 t.Errorf("TruncateIndex output too large: %d bytes", len(got)) } if !strings.Contains(got, "WARNING") { t.Error("TruncateIndex should append WARNING when byte limit exceeded") } } // ── UpdateIndex 集成:WithFreshness 改变注记阈值 ──────────────────────────── func TestUpdateIndex_WithFreshnessConfig(t *testing.T) { dir := t.TempDir() // 写一条 2 小时前的 project 记忆 entry := &Entry{ Name: "old_project", Description: "stale project context", Type: TypeProject, Content: "some content", } ctx := context.Background() store := NewFileStoreWithBaseDir(dir) if err := store.Save(ctx, entry); err != nil { t.Fatalf("Save: %v", err) } // 简单验证:threshold=1h,刚写的记忆 < 1s,不应有注记 store2 := &fileStore{ baseDir: dir, freshnessConfig: &FreshnessConfig{ GlobalThreshold: 1 * time.Hour, }, } if err := store2.UpdateIndex(ctx); err != nil { t.Fatalf("UpdateIndex: %v", err) } idxBytes, err := os.ReadFile(dir + "/MEMORY.md") if err != nil { t.Fatalf("ReadFile: %v", err) } idxContent := string(idxBytes) if strings.Contains(idxContent, "_(last updated") { t.Errorf("Fresh memory should not have age annotation, MEMORY.md:\n%s", idxContent) } } func TestUpdateIndex_FallbackThirtyDays(t *testing.T) { // nil freshnessConfig → 回落 30 天阈值,刚写的记忆不注记 dir := t.TempDir() entry := &Entry{ Name: "recent", Type: TypeUser, Content: "user data", } ctx := context.Background() store := NewFileStoreWithBaseDir(dir) if err := store.Save(ctx, entry); err != nil { t.Fatalf("Save: %v", err) } if err := store.UpdateIndex(ctx); err != nil { t.Fatalf("UpdateIndex: %v", err) } idxBytes, err := os.ReadFile(dir + "/MEMORY.md") if err != nil { t.Fatalf("ReadFile: %v", err) } if strings.Contains(string(idxBytes), "_(last updated") { t.Errorf("Recent memory with 30-day fallback should not have annotation, MEMORY.md:\n%s", idxBytes) } }