1package cache
2
3import (
4 "context"
5 "database/sql"
6 "os"
7 "path/filepath"
8 "testing"
9 "time"
10
11 mock_cache "github.com/bnema/dumber/internal/cache/mocks"
12 "github.com/bnema/dumber/internal/db"
13 "github.com/golang/mock/gomock"
14)
15
16func createTestHistory() []db.History {
17 now := time.Now()
18 return []db.History{
19 {
20 ID: 1,
21 Url: "https://github.com",
22 Title: sql.NullString{String: "GitHub", Valid: true},
23 VisitCount: sql.NullInt64{Int64: 25, Valid: true},
24 LastVisited: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true},
25 },
26 {
27 ID: 2,
28 Url: "https://stackoverflow.com",
29 Title: sql.NullString{String: "Stack Overflow", Valid: true},
30 VisitCount: sql.NullInt64{Int64: 15, Valid: true},
31 LastVisited: sql.NullTime{Time: now.Add(-2 * time.Hour), Valid: true},
32 },
33 {
34 ID: 3,
35 Url: "https://golang.org",
36 Title: sql.NullString{String: "The Go Programming Language", Valid: true},
37 VisitCount: sql.NullInt64{Int64: 8, Valid: true},
38 LastVisited: sql.NullTime{Time: now.Add(-5 * time.Hour), Valid: true},
39 },
40 {
41 ID: 4,
42 Url: "https://news.ycombinator.com",
43 Title: sql.NullString{String: "Hacker News", Valid: true},
44 VisitCount: sql.NullInt64{Int64: 30, Valid: true},
45 LastVisited: sql.NullTime{Time: now.Add(-30 * time.Minute), Valid: true},
46 },
47 {
48 ID: 5,
49 Url: "https://youtube.com",
50 Title: sql.NullString{String: "YouTube", Valid: true},
51 VisitCount: sql.NullInt64{Int64: 50, Valid: true},
52 LastVisited: sql.NullTime{Time: now.Add(-10 * time.Minute), Valid: true},
53 },
54 }
55}
56
57func TestCacheManager_BuildCache(t *testing.T) {
58 ctrl := gomock.NewController(t)
59 defer ctrl.Finish()
60
61 mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
62 history := createTestHistory()
63
64 // Mock both calls - one for building cache, one for calculating hash
65 mockQuerier.EXPECT().GetHistory(gomock.Any(), int64(10000)).Return(history, nil)
66 mockQuerier.EXPECT().GetHistory(gomock.Any(), int64(20)).Return(history, nil)
67
68 tempDir, err := os.MkdirTemp("", "dumber_cache_test")
69 if err != nil {
70 t.Fatalf("Failed to create temp dir: %v", err)
71 }
72 defer os.RemoveAll(tempDir)
73
74 config := DefaultCacheConfig()
75 config.CacheFile = filepath.Join(tempDir, "test_cache.bin")
76 config.MaxResults = 10
77
78 manager := NewCacheManager(mockQuerier, config)
79 ctx := context.Background()
80
81 // Test cache building
82 cache, err := manager.GetCache(ctx)
83 if err != nil {
84 t.Fatalf("Failed to get cache: %v", err)
85 }
86
87 if cache.entryCount != 5 {
88 t.Errorf("Expected 5 entries, got %d", cache.entryCount)
89 }
90
91 // Test that indices are built
92 if len(cache.trigramIndex) == 0 {
93 t.Error("Trigram index should not be empty")
94 }
95
96 if cache.prefixTrie == nil || cache.prefixTrie.Root == nil {
97 t.Error("Prefix trie should be initialized")
98 }
99}
100
101func TestFuzzySearcher_JaroWinklerSimilarity(t *testing.T) {
102 config := DefaultCacheConfig()
103 searcher := NewFuzzySearcher(config)
104
105 tests := []struct {
106 name string
107 s1 string
108 s2 string
109 expected float64
110 delta float64
111 }{
112 {"identical strings", "test", "test", 1.0, 0.0},
113 {"empty strings", "", "", 1.0, 0.0},
114 {"one empty", "", "test", 0.0, 0.0},
115 {"similar strings", "github", "gihub", 0.88, 0.1},
116 {"different strings", "github", "stackoverflow", 0.41, 0.05},
117 {"prefix match", "programming", "program", 0.9, 0.1},
118 }
119
120 for _, tt := range tests {
121 t.Run(tt.name, func(t *testing.T) {
122 result := searcher.jaroWinklerSimilarity(tt.s1, tt.s2)
123
124 if result < 0.0 || result > 1.0 {
125 t.Errorf("Score %f should be between 0.0 and 1.0", result)
126 }
127
128 diff := result - tt.expected
129 if diff < 0 {
130 diff = -diff
131 }
132 if diff > tt.delta {
133 t.Errorf("Expected %f ±%f, got %f", tt.expected, tt.delta, result)
134 }
135 })
136 }
137}
138
139func TestFuzzySearcher_SubstringSimilarity(t *testing.T) {
140 config := DefaultCacheConfig()
141 searcher := NewFuzzySearcher(config)
142
143 tests := []struct {
144 name string
145 query string
146 text string
147 expected float64
148 delta float64
149 }{
150 {"exact match", "github", "github", 1.0, 0.0},
151 {"substring at start", "git", "github.com", 0.45, 0.2},
152 {"substring at end", "com", "github.com", 0.3, 0.1},
153 {"no match", "xyz", "github.com", 0.0, 0.0},
154 {"empty query", "", "github.com", 0.0, 0.0},
155 {"empty text", "git", "", 0.0, 0.0},
156 }
157
158 for _, tt := range tests {
159 t.Run(tt.name, func(t *testing.T) {
160 result := searcher.substringSimilarity(tt.query, tt.text)
161
162 if result < 0.0 || result > 1.0 {
163 t.Errorf("Score %f should be between 0.0 and 1.0", result)
164 }
165
166 diff := result - tt.expected
167 if diff < 0 {
168 diff = -diff
169 }
170 if diff > tt.delta {
171 t.Errorf("Expected %f ±%f, got %f", tt.expected, tt.delta, result)
172 }
173 })
174 }
175}
176
177func TestFuzzySearcher_TokenizedSimilarity(t *testing.T) {
178 config := DefaultCacheConfig()
179 searcher := NewFuzzySearcher(config)
180
181 tests := []struct {
182 name string
183 query string
184 text string
185 expected float64
186 delta float64
187 }{
188 {"exact token match", "go programming", "The Go Programming Language", 0.8, 0.2},
189 {"partial token match", "stack overflow", "Stack Overflow Questions", 0.8, 0.2},
190 {"no token match", "python django", "JavaScript React Framework", 0.0, 0.1},
191 {"single token", "github", "GitHub Repository", 0.78, 0.05},
192 }
193
194 for _, tt := range tests {
195 t.Run(tt.name, func(t *testing.T) {
196 result := searcher.tokenizedSimilarity(tt.query, tt.text)
197
198 if result < 0.0 || result > 1.0 {
199 t.Errorf("Score %f should be between 0.0 and 1.0", result)
200 }
201
202 diff := result - tt.expected
203 if diff < 0 {
204 diff = -diff
205 }
206 if diff > tt.delta {
207 t.Errorf("Expected %f ±%f, got %f", tt.expected, tt.delta, result)
208 }
209 })
210 }
211}
212
213func TestFuzzySearcher_RecencyScore(t *testing.T) {
214 config := DefaultCacheConfig()
215 searcher := NewFuzzySearcher(config)
216
217 now := time.Now()
218 tests := []struct {
219 name string
220 lastVisitDays uint32
221 expected float64
222 delta float64
223 }{
224 {"recent visit", DaysFromTime(now.Add(-1 * time.Hour)), 0.95, 0.1},
225 {"one day ago", DaysFromTime(now.Add(-24 * time.Hour)), 0.88, 0.1},
226 {"one week ago", DaysFromTime(now.Add(-7 * 24 * time.Hour)), 0.7, 0.1},
227 {"one month ago", DaysFromTime(now.Add(-30 * 24 * time.Hour)), 0.37, 0.1},
228 {"very old", DaysFromTime(now.Add(-365 * 24 * time.Hour)), 0.01, 0.01},
229 }
230
231 for _, tt := range tests {
232 t.Run(tt.name, func(t *testing.T) {
233 result := searcher.calculateRecencyScore(tt.lastVisitDays)
234
235 if result < 0.0 || result > 1.0 {
236 t.Errorf("Score %f should be between 0.0 and 1.0", result)
237 }
238
239 diff := result - tt.expected
240 if diff < 0 {
241 diff = -diff
242 }
243 if diff > tt.delta {
244 t.Errorf("Expected %f ±%f, got %f", tt.expected, tt.delta, result)
245 }
246 })
247 }
248}
249
250func TestFuzzySearcher_VisitScore(t *testing.T) {
251 config := DefaultCacheConfig()
252 searcher := NewFuzzySearcher(config)
253
254 tests := []struct {
255 name string
256 visitCount uint16
257 expected float64
258 delta float64
259 }{
260 {"zero visits", 0, 0.0, 0.0},
261 {"single visit", 1, 0.1, 0.05},
262 {"moderate visits", 10, 0.35, 0.1},
263 {"many visits", 100, 0.67, 0.1},
264 {"max visits", 1000, 1.0, 0.01},
265 }
266
267 for _, tt := range tests {
268 t.Run(tt.name, func(t *testing.T) {
269 result := searcher.calculateVisitScore(tt.visitCount)
270
271 if result < 0.0 || result > 1.0 {
272 t.Errorf("Score %f should be between 0.0 and 1.0", result)
273 }
274
275 diff := result - tt.expected
276 if diff < 0 {
277 diff = -diff
278 }
279 if diff > tt.delta {
280 t.Errorf("Expected %f ±%f, got %f", tt.expected, tt.delta, result)
281 }
282 })
283 }
284}
285
286func TestTextNormalization(t *testing.T) {
287 tests := []struct {
288 name string
289 input string
290 expected string
291 }{
292 {"basic url", "https://github.com", "github com"},
293 {"with www", "https://www.google.com", "google com"},
294 {"with path", "https://stackoverflow.com/questions/123", "stackoverflow com questions 123"},
295 {"mixed case", "GitHub.COM/User", "github com user"},
296 {"with special chars", "test-site_name.com", "test site name com"},
297 {"unicode chars", "café-münü.com", "café münü com"},
298 }
299
300 for _, tt := range tests {
301 t.Run(tt.name, func(t *testing.T) {
302 result := normalizeText(tt.input)
303 if result != tt.expected {
304 t.Errorf("Expected %q, got %q", tt.expected, result)
305 }
306 })
307 }
308}
309
310func TestTrigramExtraction(t *testing.T) {
311 tests := []struct {
312 name string
313 input string
314 expected []string
315 }{
316 {"short string", "ab", nil},
317 {"exact length", "abc", []string{"abc"}},
318 {"longer string", "github", []string{"git", "ith", "thu", "hub"}},
319 {"with spaces", "go lang", []string{"go ", "o l", " la", "lan", "ang"}},
320 }
321
322 for _, tt := range tests {
323 t.Run(tt.name, func(t *testing.T) {
324 result := extractTrigrams(tt.input)
325 if len(result) != len(tt.expected) {
326 t.Errorf("Expected %d trigrams, got %d", len(tt.expected), len(result))
327 return
328 }
329 for i, expected := range tt.expected {
330 if result[i] != expected {
331 t.Errorf("Expected trigram %q at index %d, got %q", expected, i, result[i])
332 }
333 }
334 })
335 }
336}
337
338func TestCompactEntry_BaseScore(t *testing.T) {
339 now := time.Now()
340
341 tests := []struct {
342 name string
343 visitCount uint16
344 lastVisit time.Time
345 expectRange [2]uint16 // min, max expected score
346 }{
347 {"high visits, recent", 100, now.Add(-1 * time.Hour), [2]uint16{44000, 65535}},
348 {"low visits, recent", 5, now.Add(-1 * time.Hour), [2]uint16{35000, 50000}},
349 {"high visits, old", 100, now.Add(-90 * 24 * time.Hour), [2]uint16{5000, 25000}},
350 {"low visits, old", 5, now.Add(-90 * 24 * time.Hour), [2]uint16{1000, 15000}},
351 }
352
353 for _, tt := range tests {
354 t.Run(tt.name, func(t *testing.T) {
355 entry := CompactEntry{
356 VisitCount: tt.visitCount,
357 LastVisit: DaysFromTime(tt.lastVisit),
358 }
359
360 score := entry.calculateBaseScore()
361
362 if score < tt.expectRange[0] || score > tt.expectRange[1] {
363 t.Errorf("Expected score between %d and %d, got %d",
364 tt.expectRange[0], tt.expectRange[1], score)
365 }
366 })
367 }
368}
369
370func TestFuzzySearch_Integration(t *testing.T) {
371 ctrl := gomock.NewController(t)
372 defer ctrl.Finish()
373
374 mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
375 history := createTestHistory()
376
377 mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(history, nil).AnyTimes()
378
379 tempDir, err := os.MkdirTemp("", "dumber_search_test")
380 if err != nil {
381 t.Fatalf("Failed to create temp dir: %v", err)
382 }
383 defer os.RemoveAll(tempDir)
384
385 config := DefaultCacheConfig()
386 config.CacheFile = filepath.Join(tempDir, "search_cache.bin")
387 config.ScoreThreshold = 0.1
388
389 manager := NewCacheManager(mockQuerier, config)
390 ctx := context.Background()
391
392 // Critical fuzzy search tests
393 searchTests := []struct {
394 name string
395 query string
396 expectCount int
397 expectFirst string
398 expectMatchType MatchType
399 }{
400 {
401 name: "exact domain match",
402 query: "github",
403 expectCount: 1,
404 expectFirst: "https://github.com",
405 expectMatchType: MatchTypeExact,
406 },
407 {
408 name: "partial title match",
409 query: "programming",
410 expectCount: 1,
411 expectFirst: "https://golang.org",
412 expectMatchType: MatchTypeExact,
413 },
414 {
415 name: "fuzzy typo match",
416 query: "githb", // typo in github
417 expectCount: 1,
418 expectFirst: "https://github.com",
419 expectMatchType: MatchTypeFuzzy,
420 },
421 {
422 name: "prefix match",
423 query: "you",
424 expectCount: 1,
425 expectFirst: "https://youtube.com",
426 expectMatchType: MatchTypePrefix,
427 },
428 {
429 name: "no matches",
430 query: "nonexistentsite12345",
431 expectCount: 0,
432 },
433 }
434
435 for _, tt := range searchTests {
436 t.Run(tt.name, func(t *testing.T) {
437 result, err := manager.Search(ctx, tt.query)
438 if err != nil {
439 t.Fatalf("Search failed: %v", err)
440 }
441
442 if len(result.Matches) < tt.expectCount {
443 t.Errorf("Expected at least %d matches, got %d", tt.expectCount, len(result.Matches))
444 return
445 }
446
447 if tt.expectCount == 0 {
448 return // No further checks needed
449 }
450
451 // Check first result
452 if result.Matches[0].Entry.URL != tt.expectFirst {
453 t.Errorf("Expected first result %q, got %q",
454 tt.expectFirst, result.Matches[0].Entry.URL)
455 }
456
457 // Verify results are sorted by score
458 for i := 1; i < len(result.Matches); i++ {
459 if result.Matches[i].Score > result.Matches[i-1].Score {
460 t.Errorf("Results not sorted by score: %f > %f at position %d",
461 result.Matches[i].Score, result.Matches[i-1].Score, i)
462 }
463 }
464
465 // Verify all scores meet threshold
466 for i, match := range result.Matches {
467 if match.Score < config.ScoreThreshold {
468 t.Errorf("Match %d score %f below threshold %f",
469 i, match.Score, config.ScoreThreshold)
470 }
471 }
472
473 // Verify query time is recorded
474 if result.QueryTime <= 0 {
475 t.Error("Query time should be positive")
476 }
477 })
478 }
479}
480
481func TestCacheInvalidation(t *testing.T) {
482 ctrl := gomock.NewController(t)
483 defer ctrl.Finish()
484
485 mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
486
487 // First call returns initial history
488 firstHistory := createTestHistory()[:3] // Only 3 entries
489 secondHistory := createTestHistory() // All 5 entries
490
491 // Set up multiple expectations for the different calls
492 gomock.InOrder(
493 mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(firstHistory, nil).Times(2), // Build + hash
494 mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(secondHistory, nil).AnyTimes(), // Subsequent calls
495 )
496
497 tempDir, err := os.MkdirTemp("", "dumber_invalidation_test")
498 if err != nil {
499 t.Fatalf("Failed to create temp dir: %v", err)
500 }
501 defer os.RemoveAll(tempDir)
502
503 config := DefaultCacheConfig()
504 config.CacheFile = filepath.Join(tempDir, "invalidation_cache.bin")
505
506 manager := NewCacheManager(mockQuerier, config)
507 ctx := context.Background()
508
509 // Initial cache build
510 cache1, err := manager.GetCache(ctx)
511 if err != nil {
512 t.Fatalf("Failed to get initial cache: %v", err)
513 }
514
515 if cache1.entryCount != 3 {
516 t.Errorf("Expected 3 entries in initial cache, got %d", cache1.entryCount)
517 }
518
519 // Invalidate and refresh
520 manager.InvalidateAndRefresh(ctx)
521
522 // Wait for background refresh
523 time.Sleep(100 * time.Millisecond)
524
525 // Get updated cache
526 cache2, err := manager.GetCache(ctx)
527 if err != nil {
528 t.Fatalf("Failed to get updated cache: %v", err)
529 }
530
531 if cache2.entryCount != 5 {
532 t.Errorf("Expected 5 entries in updated cache, got %d", cache2.entryCount)
533 }
534}
535
536// create40TestEntries creates a realistic dataset with 40 entries for benchmarking
537func create40TestEntries() []db.History {
538 now := time.Now()
539 entries := make([]db.History, 40)
540
541 urls := []string{
542 "https://github.com", "https://stackoverflow.com", "https://golang.org", "https://news.ycombinator.com",
543 "https://youtube.com", "https://google.com", "https://reddit.com", "https://twitter.com",
544 "https://linkedin.com", "https://medium.com", "https://dev.to", "https://docs.python.org",
545 "https://reactjs.org", "https://nodejs.org", "https://kubernetes.io", "https://docker.com",
546 "https://aws.amazon.com", "https://cloud.google.com", "https://azure.microsoft.com", "https://heroku.com",
547 "https://netlify.com", "https://vercel.com", "https://stripe.com", "https://shopify.com",
548 "https://gitlab.com", "https://bitbucket.org", "https://atlassian.com", "https://slack.com",
549 "https://discord.com", "https://telegram.org", "https://whatsapp.com", "https://facebook.com",
550 "https://instagram.com", "https://tiktok.com", "https://netflix.com", "https://spotify.com",
551 "https://apple.com", "https://microsoft.com", "https://amazon.com", "https://wikipedia.org",
552 }
553
554 titles := []string{
555 "GitHub", "Stack Overflow", "Go Programming Language", "Hacker News",
556 "YouTube", "Google", "Reddit", "Twitter",
557 "LinkedIn", "Medium", "Dev.to", "Python Documentation",
558 "React", "Node.js", "Kubernetes", "Docker",
559 "AWS", "Google Cloud", "Microsoft Azure", "Heroku",
560 "Netlify", "Vercel", "Stripe", "Shopify",
561 "GitLab", "Bitbucket", "Atlassian", "Slack",
562 "Discord", "Telegram", "WhatsApp", "Facebook",
563 "Instagram", "TikTok", "Netflix", "Spotify",
564 "Apple", "Microsoft", "Amazon", "Wikipedia",
565 }
566
567 for i := 0; i < 40; i++ {
568 entries[i] = db.History{
569 ID: int64(i + 1),
570 Url: urls[i],
571 Title: sql.NullString{String: titles[i], Valid: true},
572 VisitCount: sql.NullInt64{Int64: int64(40 - i), Valid: true}, // Decreasing visit counts
573 LastVisited: sql.NullTime{Time: now.Add(-time.Duration(i) * time.Hour), Valid: true},
574 }
575 }
576
577 return entries
578}
579
580// BenchmarkGetTopEntries benchmarks the critical path for dmenu performance
581func BenchmarkGetTopEntries(b *testing.B) {
582 ctrl := gomock.NewController(b)
583 defer ctrl.Finish()
584
585 mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
586
587 // Setup 40 test entries
588 testHistory := create40TestEntries()
589 mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(testHistory, nil).AnyTimes()
590
591 // Create temporary directory for cache file
592 tempDir, err := os.MkdirTemp("", "cache_benchmark")
593 if err != nil {
594 b.Fatalf("Failed to create temp dir: %v", err)
595 }
596 defer os.RemoveAll(tempDir)
597
598 config := DefaultCacheConfig()
599 config.CacheFile = filepath.Join(tempDir, "benchmark_cache.bin")
600 config.MaxResults = 50
601
602 manager := NewCacheManager(mockQuerier, config)
603 ctx := context.Background()
604
605 // Pre-build cache once (this isn't part of the benchmark)
606 _, err = manager.GetCache(ctx)
607 if err != nil {
608 b.Fatalf("Failed to pre-build cache: %v", err)
609 }
610
611 b.ResetTimer() // Start timing from here
612 b.ReportAllocs()
613
614 // This is the critical path that needs to be under 2ms
615 for i := 0; i < b.N; i++ {
616 result, err := manager.GetTopEntries(ctx)
617 if err != nil {
618 b.Fatalf("GetTopEntries failed: %v", err)
619 }
620 if len(result.Matches) == 0 {
621 b.Fatalf("Expected matches, got none")
622 }
623 }
624}
625
626// BenchmarkGetTopEntriesParallel benchmarks concurrent access performance
627func BenchmarkGetTopEntriesParallel(b *testing.B) {
628 ctrl := gomock.NewController(b)
629 defer ctrl.Finish()
630
631 mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
632
633 // Setup 40 test entries
634 testHistory := create40TestEntries()
635 mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(testHistory, nil).AnyTimes()
636
637 // Create temporary directory for cache file
638 tempDir, err := os.MkdirTemp("", "cache_benchmark_parallel")
639 if err != nil {
640 b.Fatalf("Failed to create temp dir: %v", err)
641 }
642 defer os.RemoveAll(tempDir)
643
644 config := DefaultCacheConfig()
645 config.CacheFile = filepath.Join(tempDir, "benchmark_parallel_cache.bin")
646 config.MaxResults = 50
647
648 manager := NewCacheManager(mockQuerier, config)
649 ctx := context.Background()
650
651 // Pre-build cache once (this isn't part of the benchmark)
652 _, err = manager.GetCache(ctx)
653 if err != nil {
654 b.Fatalf("Failed to pre-build cache: %v", err)
655 }
656
657 b.ResetTimer()
658 b.ReportAllocs()
659
660 b.RunParallel(func(pb *testing.PB) {
661 for pb.Next() {
662 result, err := manager.GetTopEntries(ctx)
663 if err != nil {
664 b.Fatalf("GetTopEntries failed: %v", err)
665 }
666 if len(result.Matches) == 0 {
667 b.Fatalf("Expected matches, got none")
668 }
669 }
670 })
671}