v0.2.0 759cc92

fuzzy_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close