@cachly-dev/mcp-server 0.2.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Tests for the BM25+ search engine components.
3
+ *
4
+ * Run: npx vitest run src/search.test.ts
5
+ */
6
+ import { describe, it, expect } from 'vitest';
7
+ import { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp, STOPWORDS } from './index.js';
8
+
9
+ // ── Tokenizer ─────────────────────────────────────────────────────────────────
10
+
11
+ describe('tokenize', () => {
12
+ it('lowercases and splits on whitespace', () => {
13
+ expect(tokenize('Deploy API')).toEqual(['deploy', 'api']);
14
+ });
15
+
16
+ it('filters English stopwords', () => {
17
+ const result = tokenize('the quick brown fox is a dog');
18
+ expect(result).not.toContain('the');
19
+ expect(result).not.toContain('is');
20
+ expect(result).not.toContain('a');
21
+ expect(result).toContain('quick');
22
+ expect(result).toContain('brown');
23
+ expect(result).toContain('fox');
24
+ expect(result).toContain('dog');
25
+ });
26
+
27
+ it('filters German stopwords', () => {
28
+ const result = tokenize('der schnelle braune Fuchs ist ein Hund');
29
+ expect(result).not.toContain('der');
30
+ expect(result).not.toContain('ist');
31
+ expect(result).not.toContain('ein');
32
+ expect(result).toContain('schnelle');
33
+ expect(result).toContain('fuchs');
34
+ expect(result).toContain('hund');
35
+ });
36
+
37
+ it('filters French stopwords', () => {
38
+ const result = tokenize('le chat est sur la table');
39
+ expect(result).not.toContain('le');
40
+ expect(result).not.toContain('est');
41
+ expect(result).not.toContain('sur');
42
+ expect(result).not.toContain('la');
43
+ expect(result).toContain('chat');
44
+ expect(result).toContain('table');
45
+ });
46
+
47
+ it('filters Spanish stopwords', () => {
48
+ const result = tokenize('el gato está en la mesa');
49
+ expect(result).not.toContain('el');
50
+ expect(result).not.toContain('en');
51
+ expect(result).not.toContain('la');
52
+ expect(result).toContain('gato');
53
+ expect(result).toContain('mesa');
54
+ });
55
+
56
+ it('filters Italian stopwords', () => {
57
+ const result = tokenize('il gatto è sul tavolo');
58
+ expect(result).not.toContain('il');
59
+ expect(result).not.toContain('sul');
60
+ expect(result).toContain('gatto');
61
+ expect(result).toContain('tavolo');
62
+ });
63
+
64
+ it('filters Portuguese stopwords', () => {
65
+ const result = tokenize('o gato está na mesa');
66
+ expect(result).not.toContain('na');
67
+ expect(result).toContain('gato');
68
+ expect(result).toContain('mesa');
69
+ });
70
+
71
+ it('removes single-char tokens', () => {
72
+ expect(tokenize('a b c deploy')).toEqual(['deploy']);
73
+ });
74
+
75
+ it('preserves colons and dots in tokens (topic slugs)', () => {
76
+ const result = tokenize('deploy:api fix:routing-bug v1.2.3');
77
+ expect(result).toContain('deploy:api');
78
+ expect(result).toContain('fix:routing-bug');
79
+ expect(result).toContain('v1.2.3');
80
+ });
81
+
82
+ it('handles accented characters', () => {
83
+ const result = tokenize('résumé café naïve über straße');
84
+ expect(result).toContain('résumé');
85
+ expect(result).toContain('café');
86
+ expect(result).toContain('naïve');
87
+ expect(result).toContain('straße');
88
+ });
89
+
90
+ it('returns empty array for all-stopwords input', () => {
91
+ expect(tokenize('the is a an')).toEqual([]);
92
+ expect(tokenize('der die das ist ein')).toEqual([]);
93
+ });
94
+ });
95
+
96
+ // ── Stopwords completeness ────────────────────────────────────────────────────
97
+
98
+ describe('STOPWORDS', () => {
99
+ it('has English equivalents of German stopwords', () => {
100
+ // These are conceptual equivalents that should both be present
101
+ const pairs: [string, string][] = [
102
+ ['the', 'der'], ['the', 'die'], ['the', 'das'],
103
+ ['and', 'und'], ['or', 'oder'], ['but', 'aber'],
104
+ ['is', 'ist'], ['are', 'sind'], ['was', 'war'],
105
+ ['have', 'haben'], ['has', 'hat'], ['will', 'wird'],
106
+ ['can', 'kann'], ['with', 'mit'], ['for', 'für'],
107
+ ['on', 'auf'], ['from', 'von'], ['at', 'bei'],
108
+ ['after', 'nach'], ['not', 'nicht'], ['also', 'auch'],
109
+ ['how', 'wie'], ['what', 'was'], ['who', 'wer'],
110
+ ['where', 'wo'], ['when', 'wann'], ['why', 'warum'],
111
+ ['my', 'mein'], ['your', 'dein'], ['his', 'sein'],
112
+ ];
113
+ for (const [en, de] of pairs) {
114
+ expect(STOPWORDS.has(en), `Missing EN: "${en}"`).toBe(true);
115
+ expect(STOPWORDS.has(de), `Missing DE: "${de}"`).toBe(true);
116
+ }
117
+ });
118
+
119
+ it('contains at least 50 words per major language', () => {
120
+ // Sample words unique to each language
121
+ const enSample = ['the', 'would', 'could', 'should', 'through', 'between'];
122
+ const deSample = ['der', 'werden', 'können', 'zwischen', 'während', 'wegen'];
123
+ const frSample = ['le', 'avec', 'dans', 'pour', 'être', 'avoir'];
124
+ const esSample = ['el', 'para', 'como', 'pero', 'cuando', 'porque'];
125
+ const itSample = ['che', 'sono', 'questo', 'perché', 'ancora', 'sempre'];
126
+ const ptSample = ['são', 'fazer', 'estar', 'você', 'também', 'ainda'];
127
+
128
+ for (const w of [...enSample, ...deSample, ...frSample, ...esSample, ...itSample, ...ptSample]) {
129
+ expect(STOPWORDS.has(w), `Missing: "${w}"`).toBe(true);
130
+ }
131
+ });
132
+ });
133
+
134
+ // ── Multi-Query Splitting ─────────────────────────────────────────────────────
135
+
136
+ describe('splitMultiQuery', () => {
137
+ it('splits numbered lists', () => {
138
+ const result = splitMultiQuery('1. deploy API 2. fix routing 3. check auth');
139
+ expect(result).toHaveLength(3);
140
+ expect(result[0]).toBe('deploy API');
141
+ expect(result[1]).toBe('fix routing');
142
+ expect(result[2]).toBe('check auth');
143
+ });
144
+
145
+ it('splits semicolons', () => {
146
+ const result = splitMultiQuery('deploy API; fix routing; check auth');
147
+ expect(result).toHaveLength(3);
148
+ expect(result[0]).toBe('deploy API');
149
+ });
150
+
151
+ it('splits "and also"', () => {
152
+ const result = splitMultiQuery('deploy API and also fix routing');
153
+ expect(result).toHaveLength(2);
154
+ });
155
+
156
+ it('splits "außerdem"', () => {
157
+ const result = splitMultiQuery('deploy API außerdem routing fixen');
158
+ expect(result).toHaveLength(2);
159
+ });
160
+
161
+ it('splits "de plus" (French)', () => {
162
+ const result = splitMultiQuery('déployer API de plus corriger routing');
163
+ expect(result).toHaveLength(2);
164
+ });
165
+
166
+ it('splits "además" (Spanish)', () => {
167
+ const result = splitMultiQuery('desplegar API además corregir routing');
168
+ expect(result).toHaveLength(2);
169
+ });
170
+
171
+ it('splits comma-separated with 3+ items', () => {
172
+ const result = splitMultiQuery('deploy, routing, auth, redis');
173
+ expect(result).toHaveLength(4);
174
+ });
175
+
176
+ it('keeps single query intact', () => {
177
+ expect(splitMultiQuery('deploy API')).toEqual(['deploy API']);
178
+ });
179
+
180
+ it('handles 12 numbered items', () => {
181
+ const q = Array.from({ length: 12 }, (_, i) => `${i + 1}. task number ${i + 1}`).join(' ');
182
+ const result = splitMultiQuery(q);
183
+ expect(result.length).toBe(12);
184
+ });
185
+
186
+ it('filters out too-short fragments from numbered lists', () => {
187
+ // With short items filtered (≤2 chars), may not split at all
188
+ const result = splitMultiQuery('1. deploy API 2. fix routing 3. ab');
189
+ // "ab" is too short, but "deploy API" and "fix routing" should remain
190
+ expect(result.length).toBeGreaterThanOrEqual(2);
191
+ expect(result[0]).toBe('deploy API');
192
+ expect(result[1]).toBe('fix routing');
193
+ });
194
+ });
195
+
196
+ // ── Levenshtein Distance ──────────────────────────────────────────────────────
197
+
198
+ describe('levenshtein', () => {
199
+ it('returns 0 for identical strings', () => {
200
+ expect(levenshtein('deploy', 'deploy')).toBe(0);
201
+ });
202
+
203
+ it('returns correct distance for single edit', () => {
204
+ expect(levenshtein('deploy', 'deploi')).toBe(1); // substitution
205
+ expect(levenshtein('deploy', 'deplo')).toBe(1); // deletion
206
+ expect(levenshtein('deploy', 'deployy')).toBe(1); // insertion
207
+ });
208
+
209
+ it('returns 2 for two edits', () => {
210
+ expect(levenshtein('deploy', 'dploi')).toBe(2);
211
+ });
212
+
213
+ it('returns 3+ for distant strings (early exit)', () => {
214
+ expect(levenshtein('deploy', 'abc')).toBeGreaterThanOrEqual(3);
215
+ expect(levenshtein('a', 'abcde')).toBeGreaterThanOrEqual(3);
216
+ });
217
+
218
+ it('handles empty strings', () => {
219
+ expect(levenshtein('', '')).toBe(0);
220
+ expect(levenshtein('abc', '')).toBe(3);
221
+ expect(levenshtein('', 'abc')).toBe(3);
222
+ });
223
+ });
224
+
225
+ // ── Recency Boost ─────────────────────────────────────────────────────────────
226
+
227
+ describe('recencyBoost', () => {
228
+ it('returns 1.0 for undefined timestamp', () => {
229
+ expect(recencyBoost(undefined)).toBe(1.0);
230
+ });
231
+
232
+ it('returns max boost for very recent entries', () => {
233
+ const boost = recencyBoost(Date.now());
234
+ expect(boost).toBeGreaterThan(1.4);
235
+ expect(boost).toBeLessThanOrEqual(1.5);
236
+ });
237
+
238
+ it('decays over time', () => {
239
+ const now = Date.now();
240
+ const oneDay = recencyBoost(now - 1 * 24 * 60 * 60 * 1000);
241
+ const sevenDays = recencyBoost(now - 7 * 24 * 60 * 60 * 1000);
242
+ const thirtyDays = recencyBoost(now - 30 * 24 * 60 * 60 * 1000);
243
+
244
+ expect(oneDay).toBeGreaterThan(sevenDays);
245
+ expect(sevenDays).toBeGreaterThan(thirtyDays);
246
+ });
247
+
248
+ it('never goes below 0.5', () => {
249
+ const ancient = recencyBoost(Date.now() - 365 * 24 * 60 * 60 * 1000);
250
+ expect(ancient).toBeGreaterThanOrEqual(0.5);
251
+ });
252
+
253
+ it('at half-life (7 days), boost is approximately 1.0', () => {
254
+ const atHalfLife = recencyBoost(Date.now() - 7 * 24 * 60 * 60 * 1000);
255
+ // 0.5^1 + 0.5 = 1.0
256
+ expect(atHalfLife).toBeCloseTo(1.0, 1);
257
+ });
258
+ });
259
+
260
+ // ── Timestamp Extraction ──────────────────────────────────────────────────────
261
+
262
+ describe('extractTimestamp', () => {
263
+ it('extracts "ts" field from JSON', () => {
264
+ const content = '{"topic":"deploy:api","ts":"2026-04-17T12:00:00Z","what_worked":"rsync"}';
265
+ const ts = extractTimestamp(content);
266
+ expect(ts).toBeDefined();
267
+ expect(new Date(ts!).getFullYear()).toBe(2026);
268
+ });
269
+
270
+ it('extracts "created" field', () => {
271
+ const content = '{"key":"test","created":"2026-01-01T00:00:00Z"}';
272
+ expect(extractTimestamp(content)).toBeDefined();
273
+ });
274
+
275
+ it('returns undefined for non-JSON content', () => {
276
+ expect(extractTimestamp('just plain text about deploy')).toBeUndefined();
277
+ });
278
+
279
+ it('returns undefined for invalid dates', () => {
280
+ expect(extractTimestamp('{"ts":"not-a-date"}')).toBeUndefined();
281
+ });
282
+ });