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