| 1 | package memory |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "testing" |
| 6 | "time" |
| 7 | ) |
| 8 | |
| 9 | var freshnessNow = time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC) |
| 10 | |
| 11 | func agedMemory(age time.Duration) Memory { |
| 12 | return Memory{Type: TypeProject, UpdatedAt: freshnessNow.Add(-age)} |
| 13 | } |
| 14 | |
| 15 | func TestVolatilityOverridesTypeWindows(t *testing.T) { |
| 16 | old := agedMemory(60 * 24 * time.Hour) // past project fresh (30d), inside current (180d) |
| 17 | if got := FreshnessFor(old, freshnessNow); got != FreshnessCurrent { |
| 18 | t.Fatalf("type default: 60d project fact = %q, want current", got) |
| 19 | } |
| 20 | |
| 21 | volatile := old |
| 22 | volatile.Volatility = VolatilityVolatile |
| 23 | if got := FreshnessFor(volatile, freshnessNow); got != FreshnessStale { |
| 24 | t.Fatalf("volatile 60d fact = %q, want stale (7/30d windows)", got) |
| 25 | } |
| 26 | |
| 27 | evergreen := agedMemory(3 * 365 * 24 * time.Hour) |
| 28 | evergreen.Volatility = VolatilityEvergreen |
| 29 | if got := FreshnessFor(evergreen, freshnessNow); got != FreshnessFresh { |
| 30 | t.Fatalf("evergreen 3y fact = %q, want fresh forever", got) |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | func TestExpiryIsAHardBoundaryExcludedFromRecall(t *testing.T) { |
| 35 | store := recallTestStore(t) |
| 36 | expired := Memory{ |
| 37 | ID: "mem-branch", Name: "release-branch", Title: "Current release branch", |
| 38 | Description: "release/1.21 is the current release branch", Type: TypeProject, |
| 39 | Scope: FactScopeProject, Body: "Target release/1.21 for hotfixes.", |
| 40 | } |
| 41 | expired.UpdatedAt = freshnessNow.Add(-24 * time.Hour) |
| 42 | expired.ExpiresAt = freshnessNow.Add(-time.Hour) |
| 43 | recallTestWrite(t, store.Dir, expired) |
| 44 | |
| 45 | if got := FreshnessFor(expired, freshnessNow); got != FreshnessExpired { |
| 46 | t.Fatalf("past expires_at = %q, want expired", got) |
| 47 | } |
| 48 | result := AutoRecall(store, "hotfix 应该打到 release branch release/1.21 吗", RecallOptions{Now: freshnessNow}) |
| 49 | if len(result.Hits) != 0 { |
| 50 | t.Fatalf("expired facts must never be auto-recalled: %+v", result.Hits) |
| 51 | } |
| 52 | // Explicit search still finds it — expiry hides nothing from management. |
| 53 | hits, err := searchMemories(t.Context(), store, "release branch", "", "", 8) |
| 54 | if err != nil || len(hits) != 1 { |
| 55 | t.Fatalf("explicit search should still see expired facts, got %v %v", hits, err) |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | func TestVerificationRenewsTheFreshnessClock(t *testing.T) { |
| 60 | m := agedMemory(200 * 24 * time.Hour) // past project current window: stale |
| 61 | if got := FreshnessFor(m, freshnessNow); got != FreshnessStale { |
| 62 | t.Fatalf("200d project fact = %q, want stale", got) |
| 63 | } |
| 64 | m.LastVerifiedAt = freshnessNow.Add(-24 * time.Hour) |
| 65 | if got := FreshnessFor(m, freshnessNow); got != FreshnessFresh { |
| 66 | t.Fatalf("verified yesterday = %q, want fresh", got) |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | func TestSaveRoundTripsVolatilityExpiryAndClearExpiry(t *testing.T) { |
| 71 | store := recallTestStore(t) |
| 72 | saved, err := store.SaveWithOptions(Memory{ |
| 73 | Name: "windows-port", Description: "current weekly focus", |
| 74 | Volatility: VolatilityVolatile, ExpiresAt: freshnessNow.Add(30 * 24 * time.Hour), |
| 75 | Body: "User is doing the Windows port this week.", |
| 76 | }, SaveOptions{}) |
| 77 | if err != nil { |
| 78 | t.Fatal(err) |
| 79 | } |
| 80 | loaded, _ := store.Read(saved.Memory.ID) |
| 81 | if NormalizeVolatility(string(loaded.Volatility)) != VolatilityVolatile || loaded.ExpiresAt.IsZero() { |
| 82 | t.Fatalf("volatility/expiry lost in round trip: %+v", loaded) |
| 83 | } |
| 84 | |
| 85 | // An update omitting both preserves them. |
| 86 | if _, err := store.SaveWithOptions(Memory{ID: saved.Memory.ID, Description: "current weekly focus v2", Body: "Still on the port."}, SaveOptions{}); err != nil { |
| 87 | t.Fatal(err) |
| 88 | } |
| 89 | kept, _ := store.Read(saved.Memory.ID) |
| 90 | if NormalizeVolatility(string(kept.Volatility)) != VolatilityVolatile || kept.ExpiresAt.IsZero() { |
| 91 | t.Fatalf("update must inherit volatility/expiry: %+v", kept) |
| 92 | } |
| 93 | |
| 94 | // ClearExpiry drops the boundary without touching volatility. |
| 95 | if _, err := store.SaveWithOptions(Memory{ID: saved.Memory.ID, Description: "current weekly focus v3", Body: "Port done."}, SaveOptions{ClearExpiry: true}); err != nil { |
| 96 | t.Fatal(err) |
| 97 | } |
| 98 | cleared, _ := store.Read(saved.Memory.ID) |
| 99 | if !cleared.ExpiresAt.IsZero() || NormalizeVolatility(string(cleared.Volatility)) != VolatilityVolatile { |
| 100 | t.Fatalf("ClearExpiry must drop only the expiry: %+v", cleared) |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | func TestParseExpiryFormats(t *testing.T) { |
| 105 | if _, clear, err := parseExpiry("never"); err != nil || !clear { |
| 106 | t.Fatalf("never should clear, got clear=%v err=%v", clear, err) |
| 107 | } |
| 108 | when, _, err := parseExpiry("2026-09-01") |
| 109 | if err != nil || when.IsZero() { |
| 110 | t.Fatalf("bare date rejected: %v", err) |
| 111 | } |
| 112 | if _, _, err := parseExpiry("下个月"); err == nil || !strings.Contains(err.Error(), "RFC3339") { |
| 113 | t.Fatalf("invalid expiry must error with format hint, got %v", err) |
| 114 | } |
| 115 | } |
| 116 |