oficina / memoria Public

Clone
git clone https://amadan.net/oficina/memoria

Plain git — no account needed to clone.

Download

Download this file

1package index
2
3import (
4 "strings"
5 "testing"
6)
7
8func build(t *testing.T, entries ...Entry) *Index {
9 t.Helper()
10 ix, err := Open()
11 if err != nil {
12 t.Fatal(err)
13 }
14 t.Cleanup(func() { ix.Close() })
15 if err := ix.Add(entries...); err != nil {
16 t.Fatal(err)
17 }
18 return ix
19}
20
21func TestSearchFindsAPassage(t *testing.T) {
22 ix := build(t,
23 Entry{ChunkID: 1, DocumentID: 10, Heading: "FY2024 › Revenue", Text: "June revenue was 1,350,000 across all regions."},
24 Entry{ChunkID: 2, DocumentID: 10, Heading: "FY2024 › Costs", Text: "Hosting costs fell after the migration."},
25 )
26 hits, err := ix.Search("june revenue", 10)
27 if err != nil {
28 t.Fatal(err)
29 }
30 if len(hits) == 0 {
31 t.Fatal("no hits for a query that plainly matches")
32 }
33 if hits[0].ChunkID != 1 {
34 t.Errorf("best hit is chunk %d, want 1", hits[0].ChunkID)
35 }
36 if !strings.Contains(hits[0].Snippet, "mark>") {
37 t.Errorf("snippet carries no highlight: %q", hits[0].Snippet)
38 }
39}
40
41// The heading is weighted above the body. A passage filed under Revenue
42// should beat one that merely says the word.
43func TestHeadingOutweighsBody(t *testing.T) {
44 ix := build(t,
45 Entry{ChunkID: 1, DocumentID: 1, Heading: "Revenue", Text: "Figures for the period."},
46 Entry{ChunkID: 2, DocumentID: 2, Heading: "Minutes", Text: "We briefly mentioned revenue and moved on."},
47 )
48 hits, err := ix.Search("revenue", 10)
49 if err != nil {
50 t.Fatal(err)
51 }
52 if len(hits) != 2 {
53 t.Fatalf("want 2 hits, got %d", len(hits))
54 }
55 if hits[0].ChunkID != 1 {
56 t.Errorf("heading weighting not applied: best hit is chunk %d", hits[0].ChunkID)
57 }
58}
59
60// Ordinary English contains apostrophes and punctuation. Fed raw to
61// MATCH these are syntax errors, and a syntax error surfaces as an empty
62// page that reads exactly like "nothing matched" — the worst available
63// failure shape.
64func TestPunctuationDoesNotBreakSearch(t *testing.T) {
65 ix := build(t, Entry{ChunkID: 1, DocumentID: 1, Heading: "Notes", Text: "June revenue was strong."})
66 for _, q := range []string{
67 "what's june revenue?",
68 "revenue: june",
69 "NEAR revenue",
70 "OR",
71 `revenue "june`,
72 "(june)",
73 "revenue AND",
74 "-june",
75 "revenue*",
76 } {
77 hits, err := ix.Search(q, 10)
78 if err != nil {
79 t.Errorf("Search(%q) errored: %v", q, err)
80 continue
81 }
82 if strings.Contains(strings.ToLower(q), "revenue") && len(hits) == 0 {
83 t.Errorf("Search(%q) found nothing but mentions revenue", q)
84 }
85 }
86}
87
88func TestEmptyQueryReturnsNothingWithoutError(t *testing.T) {
89 ix := build(t, Entry{ChunkID: 1, DocumentID: 1, Heading: "H", Text: "body"})
90 for _, q := range []string{"", " ", "!!!", "???"} {
91 hits, err := ix.Search(q, 10)
92 if err != nil {
93 t.Errorf("Search(%q) errored: %v", q, err)
94 }
95 if len(hits) != 0 {
96 t.Errorf("Search(%q) returned %d hits", q, len(hits))
97 }
98 }
99}
100
101func TestRemoveDocumentDropsItsChunks(t *testing.T) {
102 ix := build(t,
103 Entry{ChunkID: 1, DocumentID: 10, Heading: "A", Text: "keepme text"},
104 Entry{ChunkID: 2, DocumentID: 20, Heading: "B", Text: "keepme text"},
105 )
106 if err := ix.RemoveDocument(10); err != nil {
107 t.Fatal(err)
108 }
109 hits, err := ix.Search("keepme", 10)
110 if err != nil {
111 t.Fatal(err)
112 }
113 if len(hits) != 1 || hits[0].DocumentID != 20 {
114 t.Fatalf("after removing doc 10, got %d hits %+v", len(hits), hits)
115 }
116 n, err := ix.Count()
117 if err != nil {
118 t.Fatal(err)
119 }
120 if n != 1 {
121 t.Errorf("Count = %d, want 1", n)
122 }
123}
124
125func TestMatchQueryQuotesAndOrs(t *testing.T) {
126 // The trailing operand requires the query's words to appear
127 // together. It is a RANKING signal: FTS5 sums per-phrase
128 // contributions, so a passage carrying the words together scores
129 // above one carrying them scattered, while both are still returned.
130 // See MatchQuery.
131 //
132 // NEAR and not a phrase, because a phrase is ordered and "revenue
133 // june" then ranked differently from "june revenue" — see
134 // TestWordOrderDoesNotChangeTheRanking.
135 //
136 // "what's up" is the interesting row. The apostrophe splits the query
137 // into three terms and they are required together as three, which
138 // still matches a document's "what's up": the tokenizer is
139 // `porter unicode61`, and unicode61 drops the apostrophe on both
140 // sides while porter stems what is left. The phrase logic depends on
141 // both halves of that tokenizer, so swapping either changes this.
142 for in, want := range map[string]string{
143 "june revenue": `"june" OR "revenue" OR NEAR("june" "revenue", 2)`,
144 "what's up": `"what" OR "s" OR "up" OR NEAR("what" "s" "up", 2)`,
145 " spaced out ": `"spaced" OR "out" OR NEAR("spaced" "out", 2)`,
146 "!!!": ``,
147 "café": `"café"`,
148 } {
149 if got := MatchQuery(in); got != want {
150 t.Errorf("MatchQuery(%q) = %q, want %q", in, got, want)
151 }
152 }
153}
154
155// OR keeps recall, so the discriminating has to be done by ranking. A
156// passage matching both real terms must beat one matching only a common
157// word, or the OR decision has traded a false empty for a false answer.
158func TestOrRecallStillRanksBestMatchFirst(t *testing.T) {
159 ix := build(t,
160 Entry{ChunkID: 1, DocumentID: 1, Heading: "Notes", Text: "What we discussed today."},
161 Entry{ChunkID: 2, DocumentID: 2, Heading: "Revenue", Text: "June revenue was strong."},
162 )
163 hits, err := ix.Search("what is june revenue", 10)
164 if err != nil {
165 t.Fatal(err)
166 }
167 if len(hits) == 0 {
168 t.Fatal("no hits; OR recall is not working")
169 }
170 if hits[0].ChunkID != 2 {
171 t.Errorf("best hit is chunk %d, want 2 — ranking is not compensating for OR", hits[0].ChunkID)
172 }
173}
174
175// Two indexes in one process must be independent. They were not: the
176// shared-cache DSN used a fixed name, so every Index was the same
177// in-memory database. The visible symptom was "table chunks already
178// exists"; the invisible one, had the schema matched, would have been two
179// teams' passages merged into one searchable corpus.
180func TestTwoIndexesAreIndependent(t *testing.T) {
181 a, err := Open()
182 if err != nil {
183 t.Fatal(err)
184 }
185 defer a.Close()
186 b, err := Open()
187 if err != nil {
188 t.Fatalf("opening a second index failed: %v", err)
189 }
190 defer b.Close()
191
192 if err := a.Add(Entry{ChunkID: 1, DocumentID: 1, Heading: "A", Text: "alpha secret"}); err != nil {
193 t.Fatal(err)
194 }
195 hits, err := b.Search("alpha", 10)
196 if err != nil {
197 t.Fatal(err)
198 }
199 if len(hits) != 0 {
200 t.Fatalf("index B can see index A's passages: %+v — indexes are sharing state", hits)
201 }
202 na, err := a.Count()
203 if err != nil {
204 t.Fatal(err)
205 }
206 nb, err := b.Count()
207 if err != nil {
208 t.Fatal(err)
209 }
210 if na != 1 || nb != 0 {
211 t.Errorf("counts are A=%d B=%d, want 1 and 0", na, nb)
212 }
213}
214
215// §3.1 rules that FTS5 postings are content, so the index must never be
216// backed by a file. Nothing tested that: a review pointed the DSN at a
217// path on disk and the entire suite stayed green with plaintext postings
218// in a file, because the only plaintext gate scanned the app database's
219// directory and nothing looked at the index at all.
220//
221// MUTATION-TESTED 2026-09-04: changing Open's DSN to
222// "file:"+os.TempDir()+"/x.db" makes this fail naming the path. Reverted.
223func TestIndexIsBackedByNoFile(t *testing.T) {
224 ix, err := Open()
225 if err != nil {
226 t.Fatal(err)
227 }
228 defer ix.Close()
229 if err := ix.Add(Entry{ChunkID: 1, DocumentID: 1, Heading: "H", Text: "a passage of text"}); err != nil {
230 t.Fatal(err)
231 }
232 file, err := ix.File()
233 if err != nil {
234 t.Fatal(err)
235 }
236 if file != "" {
237 t.Fatalf("the index is backed by %q; FTS5 postings are plaintext and §3.1 forbids them reaching a file", file)
238 }
239}
240