v0.6.0 5c50dd3

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
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}

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