package memory import ( "context" "errors" "sync" "testing" "time" ) func TestNewStore_NilDB_异常(t *testing.T) { t.Parallel() ctx := context.Background() if _, err := NewStore(ctx, nil, testLogger()); !errors.Is(err, ErrNilDB) { t.Fatalf("NewStore(nil db) err = %v, want ErrNilDB", err) } } func TestStore_AddGet_正常(t *testing.T) { t.Parallel() ctx := context.Background() s := newTestStore(t) item := baseItem() // Add returns the stored item with id and timestamps filled in. stored, err := s.Add(ctx, item) if err != nil { t.Fatalf("Add: %v", err) } if stored.ID == "" { t.Fatal("Add returned empty id") } if stored.CreatedAt.IsZero() || stored.UpdatedAt.IsZero() { t.Fatal("Add did not assign timestamps") } got, err := s.Get(ctx, stored.ID) if err != nil { t.Fatalf("Get: %v", err) } if got.ID != stored.ID { t.Errorf("ID = %q, want %q", got.ID, stored.ID) } if got.PersonaID != item.PersonaID { t.Errorf("PersonaID = %q, want %q", got.PersonaID, item.PersonaID) } if got.RoleID != item.RoleID { t.Errorf("RoleID = %q, want %q", got.RoleID, item.RoleID) } if got.Scope != item.Scope { t.Errorf("Scope = %q, want %q", got.Scope, item.Scope) } if got.Kind != item.Kind { t.Errorf("Kind = %q, want %q", got.Kind, item.Kind) } if got.Content != item.Content { t.Errorf("Content = %q, want %q", got.Content, item.Content) } if got.Confidence != item.Confidence { t.Errorf("Confidence = %v, want %v", got.Confidence, item.Confidence) } if len(got.Tags) != 2 || got.Tags[0] != "habit" || got.Tags[1] != "coffee" { t.Errorf("Tags = %v, want [habit coffee]", got.Tags) } } func TestStore_Add_Defaults_正常(t *testing.T) { t.Parallel() ctx := context.Background() s := newTestStore(t) // Empty Scope/Kind/Confidence/DecayWeight normalize to their defaults. stored, err := s.Add(ctx, MemoryItem{ PersonaID: "persona.a", Content: "我们约定每周日晚上看电影", }) if err != nil { t.Fatalf("Add: %v", err) } if stored.Scope != ScopePersona { t.Errorf("Scope = %q, want persona default", stored.Scope) } if stored.Kind != KindRelationship { t.Errorf("Kind = %q, want relationship default", stored.Kind) } if stored.Confidence != 1.0 { t.Errorf("Confidence = %v, want 1.0 default", stored.Confidence) } if stored.DecayWeight != 1.0 { t.Errorf("DecayWeight = %v, want 1.0 default", stored.DecayWeight) } } func TestStore_Add_Invalid_异常(t *testing.T) { t.Parallel() ctx := context.Background() s := newTestStore(t) tests := []struct { name string item MemoryItem wantErr error }{ {name: "missing persona", item: MemoryItem{Content: "x"}, wantErr: ErrMissingPersona}, {name: "missing content", item: MemoryItem{PersonaID: "p"}, wantErr: ErrMissingContent}, {name: "invalid scope", item: MemoryItem{PersonaID: "p", Content: "x", Scope: "bogus"}, wantErr: ErrInvalidScope}, {name: "invalid kind", item: MemoryItem{PersonaID: "p", Content: "x", Kind: "bogus"}, wantErr: ErrInvalidKind}, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() if _, err := s.Add(ctx, tt.item); !errors.Is(err, tt.wantErr) { t.Fatalf("Add err = %v, want wrap of %v", err, tt.wantErr) } }) } } func TestStore_Get_NotFound_异常(t *testing.T) { t.Parallel() ctx := context.Background() s := newTestStore(t) for _, id := range []string{"", "does-not-exist"} { if _, err := s.Get(ctx, id); !errors.Is(err, ErrNotFound) { t.Fatalf("Get(%q) err = %v, want ErrNotFound", id, err) } } } func TestStore_ListByPersona_Isolation(t *testing.T) { t.Parallel() ctx := context.Background() s := newTestStore(t) for _, p := range []string{"persona.a", "persona.b"} { stored, err := s.Add(ctx, MemoryItem{ PersonaID: p, Content: "内容 " + p, Scope: ScopePersona, Kind: KindWorldEvent, }) if err != nil { t.Fatalf("Add for %s: %v", p, err) } if stored.PersonaID != p { t.Fatalf("stored persona = %q, want %q", stored.PersonaID, p) } } items, err := s.ListByPersona(ctx, "persona.a") if err != nil { t.Fatalf("ListByPersona: %v", err) } if len(items) != 1 { t.Fatalf("ListByPersona(a) = %d items, want 1", len(items)) } if items[0].PersonaID != "persona.a" { t.Errorf("item persona = %q, want persona.a", items[0].PersonaID) } } func TestStore_ListByPersona_MissingPersona_异常(t *testing.T) { t.Parallel() ctx := context.Background() s := newTestStore(t) if _, err := s.ListByPersona(ctx, ""); !errors.Is(err, ErrMissingPersona) { t.Fatalf("ListByPersona(\"\") err = %v, want ErrMissingPersona", err) } } func TestStore_List_正常(t *testing.T) { t.Parallel() ctx := context.Background() s := newTestStore(t) // An empty store lists nothing but stays non-nil. if got, err := s.List(ctx); err != nil || got == nil || len(got) != 0 { t.Fatalf("List() on empty store = %#v, err=%v; want empty non-nil", got, err) } for _, item := range []MemoryItem{ {PersonaID: "persona.a", Content: "A 的记忆", Scope: ScopePersona, Kind: KindRelationship}, {PersonaID: "persona.b", Content: "B 的记忆", Scope: ScopePersona, Kind: KindWorldEvent}, {PersonaID: "persona.a", Content: "项目记忆", Scope: ScopeProject, Kind: KindWorldEvent, ProjectID: "proj.1"}, } { if _, err := s.Add(ctx, item); err != nil { t.Fatalf("Add(%s): %v", item.Content, err) } } got, err := s.List(ctx) if err != nil { t.Fatalf("List: %v", err) } if len(got) != 3 { t.Fatalf("List = %d items, want 3", len(got)) } // Newest first (created_at DESC). if got[0].Content != "项目记忆" { t.Errorf("got[0].Content = %q, want the latest added (项目记忆)", got[0].Content) } if got[2].Content != "A 的记忆" { t.Errorf("got[2].Content = %q, want the oldest (A 的记忆)", got[2].Content) } if got[0].Scope != ScopeProject || got[0].PersonaID != "persona.a" { t.Errorf("project item fields = %+v", got[0]) } } // TestStore_List_变宽时间戳_排序 is the regression for the variable-width // timestamp bug: time.RFC3339Nano drops trailing fractional zeros, so the older // item was once stored as ":05.83" and the newer as ":05.8309789", and the text // comparison SQLite applies to these columns ranked the older one as newer. func TestStore_List_变宽时间戳_排序(t *testing.T) { t.Parallel() ctx := context.Background() s := newTestStore(t) sec := time.Date(2026, 9, 7, 3, 20, 5, 0, time.UTC) older := sec.Add(830 * time.Millisecond) // ":05.83" under RFC3339Nano newer := older.Add(978900 * time.Nanosecond) // ":05.8309789" under RFC3339Nano // The newer item is inserted first, so a rowid-only tiebreak would surface // the older one: only correctly ordered created_at text yields 较晚 first. for _, in := range []struct { content string at time.Time }{{"较晚", newer}, {"较早", older}} { item := MemoryItem{PersonaID: "persona.a", Content: in.content, CreatedAt: in.at, UpdatedAt: in.at} if _, err := s.Add(ctx, item); err != nil { t.Fatalf("Add(%s): %v", in.content, err) } } var raw string if err := s.db.QueryRowContext(ctx, `SELECT created_at FROM memory_items WHERE content = ?`, "较早").Scan(&raw); err != nil { t.Fatalf("read stored created_at: %v", err) } if len(raw) != storedTimeLen { t.Errorf("stored created_at = %q (%d bytes), want fixed width %d", raw, len(raw), storedTimeLen) } got, err := s.List(ctx) if err != nil { t.Fatalf("List: %v", err) } if len(got) != 2 { t.Fatalf("List = %d items, want 2", len(got)) } if got[0].Content != "较晚" { t.Errorf("got[0].Content = %q, want the later timestamp (较晚)", got[0].Content) } if got[1].Content != "较早" { t.Errorf("got[1].Content = %q, want the earlier timestamp (较早)", got[1].Content) } } // TestStore_List_同一时间戳_插入序 covers the tie case: the host clock is coarse // enough that three consecutive Adds can share one timestamp, so ordering must // fall back to rowid rather than an arbitrary row order. func TestStore_List_同一时间戳_插入序(t *testing.T) { t.Parallel() ctx := context.Background() s := newTestStore(t) at := time.Date(2026, 9, 7, 3, 20, 5, 830978900, time.UTC) for _, content := range []string{"第一条", "第二条", "第三条"} { item := MemoryItem{PersonaID: "persona.a", Content: content, CreatedAt: at, UpdatedAt: at} if _, err := s.Add(ctx, item); err != nil { t.Fatalf("Add(%s): %v", content, err) } } want := []string{"第三条", "第二条", "第一条"} for _, tc := range []struct { name string list func(context.Context) ([]MemoryItem, error) }{ {"List", s.List}, {"ListByPersona", func(ctx context.Context) ([]MemoryItem, error) { return s.ListByPersona(ctx, "persona.a") }}, } { got, err := tc.list(ctx) if err != nil { t.Fatalf("%s: %v", tc.name, err) } if len(got) != len(want) { t.Fatalf("%s = %d items, want %d", tc.name, len(got), len(want)) } for i, w := range want { if got[i].Content != w { t.Errorf("%s got[%d].Content = %q, want %q (rowid DESC)", tc.name, i, got[i].Content, w) } } } } func TestStore_Delete_正常(t *testing.T) { t.Parallel() ctx := context.Background() s := newTestStore(t) stored, err := s.Add(ctx, baseItem()) if err != nil { t.Fatalf("Add: %v", err) } if err := s.Delete(ctx, stored.ID); err != nil { t.Fatalf("Delete: %v", err) } if _, err := s.Get(ctx, stored.ID); !errors.Is(err, ErrNotFound) { t.Fatalf("Get after delete err = %v, want ErrNotFound", err) } } func TestStore_Delete_NotFound_异常(t *testing.T) { t.Parallel() ctx := context.Background() s := newTestStore(t) for _, id := range []string{"", "missing"} { if err := s.Delete(ctx, id); !errors.Is(err, ErrNotFound) { t.Fatalf("Delete(%q) err = %v, want ErrNotFound", id, err) } } } func TestStore_Concurrent_并发(t *testing.T) { t.Parallel() ctx := context.Background() s := newTestStore(t) const n = 40 var wg sync.WaitGroup errs := make(chan error, n) for i := 0; i < n; i++ { wg.Add(1) go func(i int) { defer wg.Done() _, err := s.Add(ctx, MemoryItem{ PersonaID: "persona.a", Content: "并发内容", Scope: ScopePersona, Kind: KindRelationship, }) errs <- err }(i) } wg.Wait() close(errs) for err := range errs { if err != nil { t.Fatalf("concurrent Add error: %v", err) } } items, err := s.ListByPersona(ctx, "persona.a") if err != nil { t.Fatalf("ListByPersona: %v", err) } if len(items) != n { t.Fatalf("ListByPersona(a) = %d items, want %d", len(items), n) } }