106 lines
4.3 KiB
Go
106 lines
4.3 KiB
Go
package dream
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/smallnest/pigo/internal/memory"
|
|
)
|
|
|
|
// searchHits reopens the memory store that Runner.Run built at
|
|
// <root>/index.db and runs a full-text query, returning the set of hit paths.
|
|
// The store is reconciled-on-open so the query reflects the exact on-disk state
|
|
// left behind by the dream writeback (US-009 / FR-15). The score floor is
|
|
// disabled so recall — not ranking — is what the assertion measures.
|
|
func searchHits(t *testing.T, root, query string) map[string]bool {
|
|
t.Helper()
|
|
st, err := memory.Open(filepath.Join(root, "index.db"), root, "")
|
|
if err != nil {
|
|
t.Fatalf("reopen store: %v", err)
|
|
}
|
|
defer st.Close()
|
|
res, err := st.Search(query, memory.SearchOptions{ScoreFloor: -1, Limit: 50, ReconcileFirst: true})
|
|
if err != nil {
|
|
t.Fatalf("Search(%q): %v", query, err)
|
|
}
|
|
hits := make(map[string]bool, len(res))
|
|
for _, r := range res {
|
|
hits[filepath.Clean(r.Path)] = true
|
|
}
|
|
return hits
|
|
}
|
|
|
|
// TestReconcileConvergesAfterConsolidation is the US-009 / FR-15 acceptance
|
|
// test: after a dream writeback that merges some entries and prunes others,
|
|
// memory.Reconcile must rebuild the FTS index and memory_search must converge on
|
|
// the compacted current state — never returning the merged-away or pruned
|
|
// fragments, always returning the compacted entry.
|
|
//
|
|
// The scenario uses unique, made-up tokens per fragment so BM25 recall is
|
|
// unambiguous: each token exists in exactly one fragment before the run, and the
|
|
// assertions check that stale tokens vanish from the index while the compacted
|
|
// token appears.
|
|
func TestReconcileConvergesAfterConsolidation(t *testing.T) {
|
|
root := t.TempDir()
|
|
|
|
// Seed distinct (non-duplicate, path-ref-free) fragments so the deterministic
|
|
// dedupe/path-clean passes are no-ops and the Consolidator drives the change.
|
|
fragA := writeMemFile(t, root, "global/project/frag_a.md", "zorptholine legacy architecture fragment")
|
|
fragB := writeMemFile(t, root, "global/project/frag_b.md", "wibblequux duplicate architecture note")
|
|
fragC := writeMemFile(t, root, "global/project/frag_c.md", "frobnitz stale prunable outdated entry")
|
|
keep := writeMemFile(t, root, "global/user/keep.md", "unrelated grocery shopping list")
|
|
|
|
// Stub Consolidator: rewrite frag_a into the compacted current state, merge
|
|
// frag_b away into it (deletion), and prune the stale frag_c (deletion).
|
|
stub := &stubConsolidator{result: ConsolidateResult{
|
|
MergedBodies: map[string]string{
|
|
fragA: "quombalter consolidated current architecture state",
|
|
},
|
|
Deletions: []string{fragB, fragC},
|
|
Merged: 1,
|
|
Pruned: 1,
|
|
}}
|
|
|
|
r := &Runner{MemoryRoot: root, Consolidator: stub}
|
|
rep, err := r.Run(context.Background(), RunOptions{})
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if !stub.called {
|
|
t.Fatal("Consolidator was not called")
|
|
}
|
|
|
|
// (1) memory.Reconcile ran during writeback and indexed the surviving files.
|
|
// The store's index.db is created fresh inside Run, so every kept file is a
|
|
// new index row: Indexed must cover the compacted frag_a and the untouched
|
|
// keep.md (>=2), proving the FTS index was rebuilt.
|
|
if rep.Reconciled.Indexed < 2 {
|
|
t.Fatalf("Reconciled.Indexed = %d, want >= 2 (rebuilt index over surviving files)", rep.Reconciled.Indexed)
|
|
}
|
|
if rep.Merged != 1 || rep.Pruned != 1 {
|
|
t.Fatalf("counters not surfaced: Merged=%d Pruned=%d, want 1/1", rep.Merged, rep.Pruned)
|
|
}
|
|
|
|
// (2) Stale fragments must be gone from the index: neither the merged-away
|
|
// fragment (frag_b), the pruned fragment (frag_c), nor the overwritten body of
|
|
// frag_a ("zorptholine") may still be searchable.
|
|
for _, token := range []string{"zorptholine", "wibblequux", "frobnitz"} {
|
|
if hits := searchHits(t, root, token); len(hits) != 0 {
|
|
t.Fatalf("stale fragment token %q still searchable after consolidation: %v", token, hits)
|
|
}
|
|
}
|
|
|
|
// (3) The compacted current state IS searchable and resolves to the surviving
|
|
// consolidated file (frag_a rewritten in place).
|
|
hits := searchHits(t, root, "quombalter")
|
|
if !hits[fragA] {
|
|
t.Fatalf("compacted entry %q not returned by memory_search for its token, got %v", fragA, hits)
|
|
}
|
|
|
|
// The unrelated memory must be untouched and still indexed.
|
|
if hits := searchHits(t, root, "grocery"); !hits[keep] {
|
|
t.Fatalf("untouched memory %q missing from index after consolidation, got %v", keep, hits)
|
|
}
|
|
}
|