1package parser
2
3import (
4 "database/sql"
5 "testing"
6 "time"
7
8 "github.com/bnema/dumber/internal/db"
9)
10
11func TestFuzzyMatcher_JaroWinklerSimilarity(t *testing.T) {
12 fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig())
13
14 tests := []struct {
15 s1 string
16 s2 string
17 expected float64
18 delta float64 // Acceptable difference for float comparison
19 }{
20 {"", "", 1.0, 0.0},
21 {"", "test", 0.0, 0.0},
22 {"test", "", 0.0, 0.0},
23 {"test", "test", 1.0, 0.0},
24 {"github", "github", 1.0, 0.0},
25 {"github", "gihub", 0.95, 0.05}, // High similarity
26 {"github", "bitbucket", 0.62, 0.15}, // Lower similarity
27 {"stackoverflow", "stack", 0.87, 0.05}, // Partial match
28 {"google", "goggle", 0.9, 0.1}, // Single char difference
29 {"programming", "programing", 0.95, 0.05}, // Missing char
30 }
31
32 for _, tt := range tests {
33 t.Run(tt.s1+"_vs_"+tt.s2, func(t *testing.T) {
34 result := fuzzyMatcher.jaroWinklerSimilarity(tt.s1, tt.s2)
35
36 if result < 0.0 || result > 1.0 {
37 t.Errorf("jaroWinklerSimilarity() = %v, should be between 0.0 and 1.0", result)
38 }
39
40 diff := result - tt.expected
41 if diff < 0 {
42 diff = -diff
43 }
44 if diff > tt.delta {
45 t.Errorf("jaroWinklerSimilarity(%q, %q) = %v, want %v ±%v", tt.s1, tt.s2, result, tt.expected, tt.delta)
46 }
47 })
48 }
49}
50
51func TestFuzzyMatcher_LevenshteinDistance(t *testing.T) {
52 fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig())
53
54 tests := []struct {
55 s1 string
56 s2 string
57 expected int
58 }{
59 {"", "", 0},
60 {"", "test", 4},
61 {"test", "", 4},
62 {"test", "test", 0},
63 {"github", "github", 0},
64 {"github", "gihub", 1}, // 1 deletion
65 {"cat", "bat", 1}, // 1 substitution
66 {"kitten", "sitting", 3}, // Classic example
67 {"programming", "programing", 1}, // 1 deletion
68 }
69
70 for _, tt := range tests {
71 t.Run(tt.s1+"_vs_"+tt.s2, func(t *testing.T) {
72 result := fuzzyMatcher.LevenshteinDistance(tt.s1, tt.s2)
73 if result != tt.expected {
74 t.Errorf("LevenshteinDistance(%q, %q) = %v, want %v", tt.s1, tt.s2, result, tt.expected)
75 }
76 })
77 }
78}
79
80func TestFuzzyMatcher_LevenshteinSimilarity(t *testing.T) {
81 fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig())
82
83 tests := []struct {
84 s1 string
85 s2 string
86 expected float64
87 delta float64
88 }{
89 {"test", "test", 1.0, 0.0},
90 {"", "", 1.0, 0.0},
91 {"github", "gihub", 0.83, 0.05}, // 1 error out of 6 chars
92 {"cat", "bat", 0.67, 0.05}, // 1 error out of 3 chars
93 }
94
95 for _, tt := range tests {
96 t.Run(tt.s1+"_vs_"+tt.s2, func(t *testing.T) {
97 result := fuzzyMatcher.LevenshteinSimilarity(tt.s1, tt.s2)
98
99 if result < 0.0 || result > 1.0 {
100 t.Errorf("LevenshteinSimilarity() = %v, should be between 0.0 and 1.0", result)
101 }
102
103 diff := result - tt.expected
104 if diff < 0 {
105 diff = -diff
106 }
107 if diff > tt.delta {
108 t.Errorf("LevenshteinSimilarity(%q, %q) = %v, want %v ±%v", tt.s1, tt.s2, result, tt.expected, tt.delta)
109 }
110 })
111 }
112}
113
114func TestFuzzyMatcher_SubstringMatch(t *testing.T) {
115 fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig())
116
117 tests := []struct {
118 query string
119 text string
120 expected float64
121 delta float64
122 }{
123 {"", "test", 0.0, 0.0},
124 {"test", "", 0.0, 0.0},
125 {"github", "github.com", 0.9, 0.1}, // Full match at start gets boost
126 {"hub", "github.com", 0.3, 0.1}, // Partial match
127 {"git", "github.com", 0.45, 0.1}, // Match at beginning gets boost
128 {"com", "github.com", 0.3, 0.1}, // Match at end
129 {"xyz", "github.com", 0.0, 0.0}, // No match
130 }
131
132 for _, tt := range tests {
133 t.Run(tt.query+"_in_"+tt.text, func(t *testing.T) {
134 result := fuzzyMatcher.substringMatch(tt.query, tt.text)
135
136 if result < 0.0 || result > 1.0 {
137 t.Errorf("substringMatch() = %v, should be between 0.0 and 1.0", result)
138 }
139
140 diff := result - tt.expected
141 if diff < 0 {
142 diff = -diff
143 }
144 if diff > tt.delta {
145 t.Errorf("substringMatch(%q, %q) = %v, want %v ±%v", tt.query, tt.text, result, tt.expected, tt.delta)
146 }
147 })
148 }
149}
150
151func TestFuzzyMatcher_TokenizedMatch(t *testing.T) {
152 fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig())
153
154 tests := []struct {
155 query string
156 text string
157 expected float64
158 delta float64
159 }{
160 {"go programming", "The Go Programming Language", 0.8, 0.2},
161 {"stack overflow", "Stack Overflow - Where Developers Learn", 0.8, 0.2},
162 {"github repo", "GitHub Repository", 0.94, 0.1},
163 {"missing words", "Something completely different", 0.34, 0.1},
164 }
165
166 for _, tt := range tests {
167 t.Run(tt.query+"_vs_"+tt.text, func(t *testing.T) {
168 result := fuzzyMatcher.TokenizedMatch(tt.query, tt.text)
169
170 if result < 0.0 || result > 1.0 {
171 t.Errorf("TokenizedMatch() = %v, should be between 0.0 and 1.0", result)
172 }
173
174 diff := result - tt.expected
175 if diff < 0 {
176 diff = -diff
177 }
178 if diff > tt.delta {
179 t.Errorf("TokenizedMatch(%q, %q) = %v, want %v ±%v", tt.query, tt.text, result, tt.expected, tt.delta)
180 }
181 })
182 }
183}
184
185func TestFuzzyMatcher_SearchHistory(t *testing.T) {
186 config := DefaultFuzzyConfig()
187 config.MinSimilarityThreshold = 0.3
188 config.MaxResults = 10
189 fuzzyMatcher := NewFuzzyMatcher(config)
190
191 now := time.Now()
192 history := []*db.History{
193 {
194 ID: 1,
195 Url: "https://github.com",
196 Title: sql.NullString{String: "GitHub", Valid: true},
197 VisitCount: sql.NullInt64{Int64: 25, Valid: true},
198 LastVisited: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true},
199 },
200 {
201 ID: 2,
202 Url: "https://stackoverflow.com",
203 Title: sql.NullString{String: "Stack Overflow", Valid: true},
204 VisitCount: sql.NullInt64{Int64: 15, Valid: true},
205 LastVisited: sql.NullTime{Time: now.Add(-2 * time.Hour), Valid: true},
206 },
207 {
208 ID: 3,
209 Url: "https://golang.org",
210 Title: sql.NullString{String: "The Go Programming Language", Valid: true},
211 VisitCount: sql.NullInt64{Int64: 8, Valid: true},
212 LastVisited: sql.NullTime{Time: now.Add(-5 * time.Hour), Valid: true},
213 },
214 }
215
216 tests := []struct {
217 name string
218 query string
219 expectMatches int
220 expectFirst string // Expected first match URL
221 }{
222 {
223 name: "Exact domain match",
224 query: "github",
225 expectMatches: 1,
226 expectFirst: "https://github.com",
227 },
228 {
229 name: "Partial title match",
230 query: "programming",
231 expectMatches: 1,
232 expectFirst: "https://golang.org",
233 },
234 {
235 name: "Multiple matches",
236 query: "o", // Should match github.com, stackoverflow.com, golang.org
237 expectMatches: 3,
238 },
239 {
240 name: "No matches",
241 query: "nonexistent",
242 expectMatches: 0,
243 },
244 }
245
246 for _, tt := range tests {
247 t.Run(tt.name, func(t *testing.T) {
248 matches := fuzzyMatcher.SearchHistory(tt.query, history)
249
250 if len(matches) < tt.expectMatches {
251 t.Errorf("SearchHistory() returned %d matches, want >= %d", len(matches), tt.expectMatches)
252 }
253
254 if len(matches) > 0 && tt.expectFirst != "" {
255 if matches[0].HistoryEntry.Url != tt.expectFirst {
256 t.Errorf("SearchHistory() first match = %q, want %q", matches[0].HistoryEntry.Url, tt.expectFirst)
257 }
258 }
259
260 // Verify scores are in descending order
261 for i := 1; i < len(matches); i++ {
262 if matches[i].Score > matches[i-1].Score {
263 t.Errorf("SearchHistory() results not sorted by score: %f > %f", matches[i].Score, matches[i-1].Score)
264 }
265 }
266
267 // Verify all scores meet threshold
268 for i, match := range matches {
269 if match.Score < config.MinSimilarityThreshold {
270 t.Errorf("SearchHistory() match %d score %f below threshold %f", i, match.Score, config.MinSimilarityThreshold)
271 }
272 }
273 })
274 }
275}
276
277func TestFuzzyMatcher_CalculateRecencyScore(t *testing.T) {
278 fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig())
279 now := time.Now()
280
281 tests := []struct {
282 name string
283 lastVisit sql.NullTime
284 expected float64
285 delta float64
286 }{
287 {
288 name: "Invalid time",
289 lastVisit: sql.NullTime{Valid: false},
290 expected: 0.0,
291 delta: 0.0,
292 },
293 {
294 name: "Recent visit (1 hour ago)",
295 lastVisit: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true},
296 expected: 0.95,
297 delta: 0.1,
298 },
299 {
300 name: "Old visit (30 days ago)",
301 lastVisit: sql.NullTime{Time: now.Add(-30 * 24 * time.Hour), Valid: true},
302 expected: 0.37,
303 delta: 0.1,
304 },
305 {
306 name: "Very old visit (90 days ago)",
307 lastVisit: sql.NullTime{Time: now.Add(-90 * 24 * time.Hour), Valid: true},
308 expected: 0.05,
309 delta: 0.05,
310 },
311 }
312
313 for _, tt := range tests {
314 t.Run(tt.name, func(t *testing.T) {
315 result := fuzzyMatcher.calculateRecencyScore(tt.lastVisit)
316
317 if result < 0.0 || result > 1.0 {
318 t.Errorf("calculateRecencyScore() = %v, should be between 0.0 and 1.0", result)
319 }
320
321 diff := result - tt.expected
322 if diff < 0 {
323 diff = -diff
324 }
325 if diff > tt.delta {
326 t.Errorf("calculateRecencyScore() = %v, want %v ±%v", result, tt.expected, tt.delta)
327 }
328 })
329 }
330}
331
332func TestFuzzyMatcher_CalculateVisitScore(t *testing.T) {
333 fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig())
334
335 tests := []struct {
336 name string
337 visitCount sql.NullInt64
338 expected float64
339 delta float64
340 }{
341 {
342 name: "Invalid count",
343 visitCount: sql.NullInt64{Valid: false},
344 expected: 0.0,
345 delta: 0.0,
346 },
347 {
348 name: "Zero visits",
349 visitCount: sql.NullInt64{Int64: 0, Valid: true},
350 expected: 0.0,
351 delta: 0.0,
352 },
353 {
354 name: "Single visit",
355 visitCount: sql.NullInt64{Int64: 1, Valid: true},
356 expected: 0.1,
357 delta: 0.05,
358 },
359 {
360 name: "Many visits",
361 visitCount: sql.NullInt64{Int64: 100, Valid: true},
362 expected: 0.67,
363 delta: 0.1,
364 },
365 {
366 name: "Very many visits",
367 visitCount: sql.NullInt64{Int64: 1000, Valid: true},
368 expected: 1.0,
369 delta: 0.01,
370 },
371 }
372
373 for _, tt := range tests {
374 t.Run(tt.name, func(t *testing.T) {
375 result := fuzzyMatcher.calculateVisitScore(tt.visitCount)
376
377 if result < 0.0 || result > 1.0 {
378 t.Errorf("calculateVisitScore() = %v, should be between 0.0 and 1.0", result)
379 }
380
381 diff := result - tt.expected
382 if diff < 0 {
383 diff = -diff
384 }
385 if diff > tt.delta {
386 t.Errorf("calculateVisitScore() = %v, want %v ±%v", result, tt.expected, tt.delta)
387 }
388 })
389 }
390}
391
392func TestFuzzyMatcher_IsDomainMatch(t *testing.T) {
393 fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig())
394
395 tests := []struct {
396 query string
397 url string
398 expected bool
399 }{
400 {"github", "https://github.com", true},
401 {"github", "https://www.github.com", true},
402 {"github", "https://api.github.com", true},
403 {"github", "https://stackoverflow.com", false},
404 {"stackoverflow", "https://stackoverflow.com/questions", true},
405 {"google", "https://www.google.com/search", true},
406 {"localhost", "http://localhost:8080", true},
407 {"example", "https://example.org", true}, // Matches domain part
408 {"sub.example", "https://sub.example.com", true},
409 }
410
411 for _, tt := range tests {
412 t.Run(tt.query+"_vs_"+tt.url, func(t *testing.T) {
413 result := fuzzyMatcher.isDomainMatch(tt.query, tt.url)
414 if result != tt.expected {
415 t.Errorf("isDomainMatch(%q, %q) = %v, want %v", tt.query, tt.url, result, tt.expected)
416 }
417 })
418 }
419}
420
421func TestFuzzyMatcher_RankMatches(t *testing.T) {
422 config := DefaultFuzzyConfig()
423 fuzzyMatcher := NewFuzzyMatcher(config)
424
425 now := time.Now()
426 matches := []FuzzyMatch{
427 {
428 HistoryEntry: &db.History{
429 Url: "https://example.com/very/long/path/that/makes/url/longer",
430 VisitCount: sql.NullInt64{Int64: 5, Valid: true},
431 LastVisited: sql.NullTime{Time: now.Add(-10 * time.Hour), Valid: true},
432 },
433 Score: 0.5,
434 },
435 {
436 HistoryEntry: &db.History{
437 Url: "https://github.com",
438 VisitCount: sql.NullInt64{Int64: 25, Valid: true},
439 LastVisited: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true},
440 },
441 Score: 0.7,
442 },
443 {
444 HistoryEntry: &db.History{
445 Url: "https://test.com",
446 VisitCount: sql.NullInt64{Int64: 1, Valid: true},
447 LastVisited: sql.NullTime{Time: now.Add(-24 * time.Hour), Valid: true},
448 },
449 Score: 0.6,
450 },
451 }
452
453 rankedMatches := fuzzyMatcher.RankMatches(matches, "github")
454
455 // Verify the matches are still sorted by score (descending)
456 for i := 1; i < len(rankedMatches); i++ {
457 if rankedMatches[i].Score > rankedMatches[i-1].Score {
458 t.Errorf("RankMatches() results not sorted: %f > %f", rankedMatches[i].Score, rankedMatches[i-1].Score)
459 }
460 }
461
462 // The github.com match should get a boost and likely be first
463 githubFound := false
464 for i, match := range rankedMatches {
465 if match.HistoryEntry.Url == "https://github.com" {
466 githubFound = true
467 // Should be boosted due to domain match
468 if i > 0 && rankedMatches[0].HistoryEntry.Url != "https://github.com" {
469 // It's possible another match scored higher even after boost
470 t.Logf("GitHub match at position %d with score %f", i, match.Score)
471 }
472 break
473 }
474 }
475
476 if !githubFound {
477 t.Errorf("RankMatches() didn't preserve GitHub match")
478 }
479}
480
481func BenchmarkFuzzyMatcher_JaroWinklerSimilarity(b *testing.B) {
482 fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig())
483
484 b.ResetTimer()
485 for i := 0; i < b.N; i++ {
486 fuzzyMatcher.jaroWinklerSimilarity("github", "gihub")
487 }
488}
489
490func BenchmarkFuzzyMatcher_LevenshteinDistance(b *testing.B) {
491 fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig())
492
493 b.ResetTimer()
494 for i := 0; i < b.N; i++ {
495 fuzzyMatcher.LevenshteinDistance("programming", "programing")
496 }
497}
498
499func BenchmarkFuzzyMatcher_SearchHistory(b *testing.B) {
500 config := DefaultFuzzyConfig()
501 fuzzyMatcher := NewFuzzyMatcher(config)
502
503 now := time.Now()
504 // Create larger history set for more realistic benchmark
505 history := make([]*db.History, 100)
506 for i := 0; i < 100; i++ {
507 history[i] = &db.History{
508 ID: int64(i + 1),
509 Url: "https://example" + string(rune(i%26+'a')) + ".com",
510 Title: sql.NullString{String: "Test Site " + string(rune(i%26+'A')), Valid: true},
511 VisitCount: sql.NullInt64{Int64: int64(i % 50), Valid: true},
512 LastVisited: sql.NullTime{Time: now.Add(-time.Duration(i) * time.Hour), Valid: true},
513 }
514 }
515
516 b.ResetTimer()
517 for i := 0; i < b.N; i++ {
518 fuzzyMatcher.SearchHistory("example", history)
519 }
520}