package vectord import ( "context" "encoding/json" "errors" "fmt" "strings" "sync" "testing" "git.agentview.dev/profit/golangLAKEHOUSE/internal/storeclient" ) // memStore is an in-memory Store fake for unit tests. Returns the // real storeclient.ErrKeyNotFound sentinel so persistor.Load's // errors.Is path is exercised faithfully. type memStore struct { mu sync.Mutex data map[string][]byte } func newMemStore() *memStore { return &memStore{data: map[string][]byte{}} } func (m *memStore) Put(_ context.Context, key string, body []byte) error { m.mu.Lock() defer m.mu.Unlock() cp := make([]byte, len(body)) copy(cp, body) m.data[key] = cp return nil } func (m *memStore) Get(_ context.Context, key string) ([]byte, error) { m.mu.Lock() defer m.mu.Unlock() b, ok := m.data[key] if !ok { return nil, storeclient.ErrKeyNotFound } return b, nil } func (m *memStore) Delete(_ context.Context, key string) error { m.mu.Lock() defer m.mu.Unlock() delete(m.data, key) return nil } func (m *memStore) List(_ context.Context, prefix string) ([]string, error) { m.mu.Lock() defer m.mu.Unlock() out := []string{} for k := range m.data { if strings.HasPrefix(k, prefix) { out = append(out, k) } } return out, nil } func TestPersistor_SaveLoad_RoundTrip(t *testing.T) { const n = 16 store := newMemStore() p := NewPersistor(store) src, _ := NewIndex(IndexParams{Name: "workers", Dimension: n, Distance: DistanceCosine}) for i := 0; i < n; i++ { v := make([]float32, n) v[i%n] = 1.0 v[(i+1)%n] = 0.001 _ = src.Add(fmt.Sprintf("w-%02d", i), v, json.RawMessage(fmt.Sprintf(`{"i":%d}`, i))) } if err := p.Save(context.Background(), src); err != nil { t.Fatal(err) } dst, err := p.Load(context.Background(), "workers") if err != nil { t.Fatalf("Load: %v", err) } if dst.Len() != src.Len() { t.Errorf("Len: src=%d dst=%d", src.Len(), dst.Len()) } for i := 0; i < n; i++ { v := make([]float32, n) v[i%n] = 1.0 v[(i+1)%n] = 0.001 hits, _ := dst.Search(v, 1) want := fmt.Sprintf("w-%02d", i) if len(hits) == 0 || hits[0].ID != want { t.Errorf("recall after Load: i=%d hits=%v", i, hits) } } } func TestPersistor_Load_MissingReturnsErrKeyMissing(t *testing.T) { p := NewPersistor(newMemStore()) _, err := p.Load(context.Background(), "nope") if !errors.Is(err, ErrKeyMissing) { t.Errorf("expected ErrKeyMissing, got %v", err) } } func TestPersistor_Delete_RemovesBothFiles(t *testing.T) { store := newMemStore() p := NewPersistor(store) src, _ := NewIndex(IndexParams{Name: "x", Dimension: 4}) _ = src.Add("a", []float32{1, 0, 0, 0}, nil) _ = p.Save(context.Background(), src) if err := p.Delete(context.Background(), "x"); err != nil { t.Fatal(err) } if _, err := p.Load(context.Background(), "x"); !errors.Is(err, ErrKeyMissing) { t.Errorf("after Delete, Load should ErrKeyMissing, got %v", err) } } func TestPersistor_List_FiltersBySuffix(t *testing.T) { // Single-file format means no orphan-pair concept; we just // filter on the .lhv1 suffix. Garbage files under VectorPrefix // (e.g. operator drops something there) shouldn't show up. store := newMemStore() p := NewPersistor(store) src, _ := NewIndex(IndexParams{Name: "alpha", Dimension: 4}) _ = src.Add("a", []float32{1, 0, 0, 0}, nil) _ = p.Save(context.Background(), src) src2, _ := NewIndex(IndexParams{Name: "beta", Dimension: 4}) _ = src2.Add("b", []float32{0, 1, 0, 0}, nil) _ = p.Save(context.Background(), src2) // A garbage file under the prefix that shouldn't match. _ = store.Put(context.Background(), VectorPrefix+"README.txt", []byte("not an index")) got, err := p.List(context.Background()) if err != nil { t.Fatal(err) } if len(got) != 2 || got[0] != "alpha" || got[1] != "beta" { t.Errorf("List: got %v, want [alpha beta]", got) } } func TestPersistor_Load_BadFormat(t *testing.T) { store := newMemStore() p := NewPersistor(store) // Manually plant a file with bad magic → Load surfaces ErrBadFormat. _ = store.Put(context.Background(), fileKey("corrupt"), []byte("not lhv1 framing")) _, err := p.Load(context.Background(), "corrupt") if !errors.Is(err, ErrBadFormat) { t.Errorf("expected ErrBadFormat, got %v", err) } }