v0.6.0 5c50dd3

parser_test.go RawHistoryFolder

1package parser
2
3import (
4	"database/sql"
5	"strings"
6	"testing"
7	"time"
8
9	"github.com/bnema/dumber/internal/config"
10	"github.com/bnema/dumber/internal/db"
11)
12
13// MockHistoryProvider implements HistoryProvider for testing.
14type MockHistoryProvider struct {
15	history []*db.History
16}
17
18func (m *MockHistoryProvider) GetRecentHistory(limit int) ([]*db.History, error) {
19	if limit >= len(m.history) {
20		return m.history, nil
21	}
22	return m.history[:limit], nil
23}
24
25func (m *MockHistoryProvider) GetAllHistory() ([]*db.History, error) {
26	return m.history, nil
27}
28
29func (m *MockHistoryProvider) SearchHistory(query string, limit int) ([]*db.History, error) {
30	// Simple mock search - return entries that contain the query in URL or title
31	result := make([]*db.History, 0)
32	queryLower := strings.ToLower(query)
33	for _, entry := range m.history {
34		if len(result) >= limit {
35			break
36		}
37		// Check if query matches URL or title
38		if strings.Contains(strings.ToLower(entry.Url), queryLower) ||
39			(entry.Title.Valid && strings.Contains(strings.ToLower(entry.Title.String), queryLower)) {
40			result = append(result, entry)
41		}
42	}
43	return result, nil
44}
45
46func (m *MockHistoryProvider) GetHistoryByURL(url string) (*db.History, error) {
47	for _, entry := range m.history {
48		if entry.Url == url {
49			return entry, nil
50		}
51	}
52	return nil, sql.ErrNoRows
53}
54
55// createMockHistory creates mock history data for testing.
56func createMockHistory() []*db.History {
57	now := time.Now()
58	return []*db.History{
59		{
60			ID:          1,
61			Url:         "https://github.com",
62			Title:       sql.NullString{String: "GitHub", Valid: true},
63			VisitCount:  sql.NullInt64{Int64: 25, Valid: true},
64			LastVisited: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true},
65			CreatedAt:   sql.NullTime{Time: now.Add(-30 * 24 * time.Hour), Valid: true},
66		},
67		{
68			ID:          2,
69			Url:         "https://stackoverflow.com",
70			Title:       sql.NullString{String: "Stack Overflow", Valid: true},
71			VisitCount:  sql.NullInt64{Int64: 15, Valid: true},
72			LastVisited: sql.NullTime{Time: now.Add(-2 * time.Hour), Valid: true},
73			CreatedAt:   sql.NullTime{Time: now.Add(-20 * 24 * time.Hour), Valid: true},
74		},
75		{
76			ID:          3,
77			Url:         "https://reddit.com",
78			Title:       sql.NullString{String: "Reddit", Valid: true},
79			VisitCount:  sql.NullInt64{Int64: 50, Valid: true},
80			LastVisited: sql.NullTime{Time: now.Add(-3 * time.Hour), Valid: true},
81			CreatedAt:   sql.NullTime{Time: now.Add(-10 * 24 * time.Hour), Valid: true},
82		},
83		{
84			ID:          4,
85			Url:         "https://golang.org",
86			Title:       sql.NullString{String: "The Go Programming Language", Valid: true},
87			VisitCount:  sql.NullInt64{Int64: 8, Valid: true},
88			LastVisited: sql.NullTime{Time: now.Add(-5 * time.Hour), Valid: true},
89			CreatedAt:   sql.NullTime{Time: now.Add(-5 * 24 * time.Hour), Valid: true},
90		},
91	}
92}
93
94// createTestConfig creates a test configuration.
95func createTestConfig() *config.Config {
96	return &config.Config{
97		SearchShortcuts: map[string]config.SearchShortcut{
98			"g": {
99				URL:         "https://www.google.com/search?q={query}",
100				Description: "Google Search",
101			},
102			"gh": {
103				URL:         "https://github.com/search?q={query}",
104				Description: "GitHub Search",
105			},
106			"so": {
107				URL:         "https://stackoverflow.com/search?q={query}",
108				Description: "Stack Overflow Search",
109			},
110		},
111	}
112}
113
114func TestParser_ParseInput_DirectURL(t *testing.T) {
115	mockHistory := createMockHistory()
116	provider := &MockHistoryProvider{history: mockHistory}
117	config := createTestConfig()
118	parser := NewParser(config, provider)
119
120	tests := []struct {
121		name       string
122		input      string
123		expectType InputType
124		expectURL  string
125		expectConf float64
126	}{
127		{
128			name:       "HTTPS URL",
129			input:      "https://example.com",
130			expectType: InputTypeDirectURL,
131			expectURL:  "https://example.com",
132			expectConf: 1.0,
133		},
134		{
135			name:       "HTTP URL",
136			input:      "http://example.com",
137			expectType: InputTypeDirectURL,
138			expectURL:  "http://example.com",
139			expectConf: 1.0,
140		},
141		{
142			name:       "Domain only",
143			input:      "example.com",
144			expectType: InputTypeDirectURL,
145			expectURL:  "https://example.com",
146			expectConf: 1.0,
147		},
148		{
149			name:       "Subdomain",
150			input:      "www.example.com",
151			expectType: InputTypeDirectURL,
152			expectURL:  "https://www.example.com",
153			expectConf: 1.0,
154		},
155		{
156			name:       "Domain with path",
157			input:      "example.com/path",
158			expectType: InputTypeDirectURL,
159			expectURL:  "https://example.com/path",
160			expectConf: 1.0,
161		},
162	}
163
164	for _, tt := range tests {
165		t.Run(tt.name, func(t *testing.T) {
166			result, err := parser.ParseInput(tt.input)
167			if err != nil {
168				t.Fatalf("ParseInput() error = %v", err)
169			}
170
171			if result.Type != tt.expectType {
172				t.Errorf("ParseInput() type = %v, want %v", result.Type, tt.expectType)
173			}
174
175			if result.URL != tt.expectURL {
176				t.Errorf("ParseInput() URL = %v, want %v", result.URL, tt.expectURL)
177			}
178
179			if result.Confidence != tt.expectConf {
180				t.Errorf("ParseInput() confidence = %v, want %v", result.Confidence, tt.expectConf)
181			}
182		})
183	}
184}
185
186func TestParser_ParseInput_SearchShortcut(t *testing.T) {
187	mockHistory := createMockHistory()
188	provider := &MockHistoryProvider{history: mockHistory}
189	config := createTestConfig()
190	parser := NewParser(config, provider)
191
192	tests := []struct {
193		name        string
194		input       string
195		expectType  InputType
196		expectURL   string
197		expectKey   string
198		expectQuery string
199	}{
200		{
201			name:        "Google search",
202			input:       "g: golang tutorial",
203			expectType:  InputTypeSearchShortcut,
204			expectURL:   "https://www.google.com/search?q=golang+tutorial",
205			expectKey:   "g",
206			expectQuery: "golang tutorial",
207		},
208		{
209			name:        "GitHub search",
210			input:       "gh: cobra cli",
211			expectType:  InputTypeSearchShortcut,
212			expectURL:   "https://github.com/search?q=cobra+cli",
213			expectKey:   "gh",
214			expectQuery: "cobra cli",
215		},
216		{
217			name:        "Stack Overflow search",
218			input:       "so: how to fuzzy search",
219			expectType:  InputTypeSearchShortcut,
220			expectURL:   "https://stackoverflow.com/search?q=how+to+fuzzy+search",
221			expectKey:   "so",
222			expectQuery: "how to fuzzy search",
223		},
224	}
225
226	for _, tt := range tests {
227		t.Run(tt.name, func(t *testing.T) {
228			result, err := parser.ParseInput(tt.input)
229			if err != nil {
230				t.Fatalf("ParseInput() error = %v", err)
231			}
232
233			if result.Type != tt.expectType {
234				t.Errorf("ParseInput() type = %v, want %v", result.Type, tt.expectType)
235			}
236
237			if result.URL != tt.expectURL {
238				t.Errorf("ParseInput() URL = %v, want %v", result.URL, tt.expectURL)
239			}
240
241			if result.Shortcut == nil {
242				t.Fatalf("ParseInput() shortcut is nil")
243			}
244
245			if result.Shortcut.Key != tt.expectKey {
246				t.Errorf("ParseInput() shortcut key = %v, want %v", result.Shortcut.Key, tt.expectKey)
247			}
248
249			if result.Shortcut.Query != tt.expectQuery {
250				t.Errorf("ParseInput() shortcut query = %v, want %v", result.Shortcut.Query, tt.expectQuery)
251			}
252		})
253	}
254}
255
256func TestParser_ParseInput_HistorySearch(t *testing.T) {
257	mockHistory := createMockHistory()
258	provider := &MockHistoryProvider{history: mockHistory}
259	config := createTestConfig()
260	parser := NewParser(config, provider)
261
262	tests := []struct {
263		name          string
264		input         string
265		expectType    InputType
266		expectMatches bool
267		minConfidence float64
268	}{
269		{
270			name:          "Exact domain match",
271			input:         "github",
272			expectType:    InputTypeHistorySearch,
273			expectMatches: true,
274			minConfidence: 0.5,
275		},
276		{
277			name:          "Partial title match",
278			input:         "stack",
279			expectType:    InputTypeHistorySearch,
280			expectMatches: true,
281			minConfidence: 0.3,
282		},
283		{
284			name:          "Programming language search",
285			input:         "golang",
286			expectType:    InputTypeHistorySearch,
287			expectMatches: true,
288			minConfidence: 0.3,
289		},
290	}
291
292	for _, tt := range tests {
293		t.Run(tt.name, func(t *testing.T) {
294			result, err := parser.ParseInput(tt.input)
295			if err != nil {
296				t.Fatalf("ParseInput() error = %v", err)
297			}
298
299			if result.Type != tt.expectType {
300				t.Errorf("ParseInput() type = %v, want %v", result.Type, tt.expectType)
301			}
302
303			if tt.expectMatches && len(result.FuzzyMatches) == 0 {
304				t.Errorf("ParseInput() expected fuzzy matches but got none")
305			}
306
307			if result.Confidence < tt.minConfidence {
308				t.Errorf("ParseInput() confidence = %v, want >= %v", result.Confidence, tt.minConfidence)
309			}
310		})
311	}
312}
313
314func TestParser_FuzzySearchHistory(t *testing.T) {
315	mockHistory := createMockHistory()
316	provider := &MockHistoryProvider{history: mockHistory}
317	config := createTestConfig()
318	parser := NewParser(config, provider)
319
320	tests := []struct {
321		name          string
322		query         string
323		threshold     float64
324		expectMatches int
325		expectFirst   string // Expected first match URL
326	}{
327		{
328			name:          "GitHub search",
329			query:         "github",
330			threshold:     0.3,
331			expectMatches: 1,
332			expectFirst:   "https://github.com",
333		},
334		{
335			name:          "Programming search",
336			query:         "programming",
337			threshold:     0.2,
338			expectMatches: 1,
339			expectFirst:   "https://golang.org",
340		},
341		{
342			name:          "Stack search",
343			query:         "stack",
344			threshold:     0.3,
345			expectMatches: 1,
346			expectFirst:   "https://stackoverflow.com",
347		},
348	}
349
350	for _, tt := range tests {
351		t.Run(tt.name, func(t *testing.T) {
352			matches, err := parser.FuzzySearchHistory(tt.query, tt.threshold)
353			if err != nil {
354				t.Fatalf("FuzzySearchHistory() error = %v", err)
355			}
356
357			if len(matches) < tt.expectMatches {
358				t.Errorf("FuzzySearchHistory() matches = %d, want >= %d", len(matches), tt.expectMatches)
359			}
360
361			if len(matches) > 0 && tt.expectFirst != "" {
362				if matches[0].HistoryEntry.Url != tt.expectFirst {
363					t.Errorf("FuzzySearchHistory() first match = %v, want %v", matches[0].HistoryEntry.Url, tt.expectFirst)
364				}
365			}
366		})
367	}
368}
369
370func TestParser_SuggestCompletions(t *testing.T) {
371	mockHistory := createMockHistory()
372	provider := &MockHistoryProvider{history: mockHistory}
373	config := createTestConfig()
374	parser := NewParser(config, provider)
375
376	tests := []struct {
377		name        string
378		input       string
379		limit       int
380		expectCount int
381	}{
382		{
383			name:        "GitHub completion",
384			input:       "gith",
385			limit:       5,
386			expectCount: 1,
387		},
388		{
389			name:        "Shortcut completion",
390			input:       "g",
391			limit:       5,
392			expectCount: 2, // "g" and "gh" shortcuts
393		},
394		{
395			name:        "Empty input",
396			input:       "",
397			limit:       5,
398			expectCount: 0,
399		},
400	}
401
402	for _, tt := range tests {
403		t.Run(tt.name, func(t *testing.T) {
404			completions, err := parser.SuggestCompletions(tt.input, tt.limit)
405			if err != nil {
406				t.Fatalf("SuggestCompletions() error = %v", err)
407			}
408
409			if len(completions) != tt.expectCount {
410				t.Errorf("SuggestCompletions() count = %d, want %d", len(completions), tt.expectCount)
411			}
412		})
413	}
414}
415
416func TestParser_ProcessShortcut(t *testing.T) {
417	config := createTestConfig()
418	mockHistory := createMockHistory()
419	provider := &MockHistoryProvider{history: mockHistory}
420	parser := NewParser(config, provider)
421
422	tests := []struct {
423		name      string
424		shortcut  string
425		query     string
426		expectURL string
427		expectErr bool
428	}{
429		{
430			name:      "Google shortcut",
431			shortcut:  "g",
432			query:     "test query",
433			expectURL: "https://www.google.com/search?q=test+query",
434			expectErr: false,
435		},
436		{
437			name:      "GitHub shortcut",
438			shortcut:  "gh",
439			query:     "golang",
440			expectURL: "https://github.com/search?q=golang",
441			expectErr: false,
442		},
443		{
444			name:      "Unknown shortcut",
445			shortcut:  "unknown",
446			query:     "test",
447			expectURL: "",
448			expectErr: true,
449		},
450	}
451
452	for _, tt := range tests {
453		t.Run(tt.name, func(t *testing.T) {
454			url, err := parser.ProcessShortcut(tt.shortcut, tt.query, config.SearchShortcuts)
455
456			if tt.expectErr {
457				if err == nil {
458					t.Errorf("ProcessShortcut() expected error but got none")
459				}
460				return
461			}
462
463			if err != nil {
464				t.Fatalf("ProcessShortcut() error = %v", err)
465			}
466
467			if url != tt.expectURL {
468				t.Errorf("ProcessShortcut() URL = %v, want %v", url, tt.expectURL)
469			}
470		})
471	}
472}
473
474func TestParser_ValidateConfig(t *testing.T) {
475	mockHistory := createMockHistory()
476	provider := &MockHistoryProvider{history: mockHistory}
477
478	tests := []struct {
479		name      string
480		config    *config.Config
481		fuzzy     *FuzzyConfig
482		provider  HistoryProvider
483		expectErr bool
484	}{
485		{
486			name:      "Valid configuration",
487			config:    createTestConfig(),
488			fuzzy:     DefaultFuzzyConfig(),
489			provider:  provider,
490			expectErr: false,
491		},
492		{
493			name:      "Nil config",
494			config:    nil,
495			fuzzy:     DefaultFuzzyConfig(),
496			provider:  provider,
497			expectErr: true,
498		},
499		{
500			name:      "Nil fuzzy config",
501			config:    createTestConfig(),
502			fuzzy:     nil,
503			provider:  provider,
504			expectErr: true,
505		},
506		{
507			name:      "Nil history provider",
508			config:    createTestConfig(),
509			fuzzy:     DefaultFuzzyConfig(),
510			provider:  nil,
511			expectErr: true,
512		},
513	}
514
515	for _, tt := range tests {
516		t.Run(tt.name, func(t *testing.T) {
517			parser := NewParser(tt.config, tt.provider, WithFuzzyConfig(tt.fuzzy))
518
519			err := parser.ValidateConfig()
520
521			if tt.expectErr {
522				if err == nil {
523					t.Errorf("ValidateConfig() expected error but got none")
524				}
525			} else {
526				if err != nil {
527					t.Errorf("ValidateConfig() unexpected error = %v", err)
528				}
529			}
530		})
531	}
532}
533
534func TestFuzzyConfig_IsValid(t *testing.T) {
535	tests := []struct {
536		name   string
537		config *FuzzyConfig
538		expect bool
539	}{
540		{
541			name:   "Default config",
542			config: DefaultFuzzyConfig(),
543			expect: true,
544		},
545		{
546			name: "Invalid threshold (negative)",
547			config: &FuzzyConfig{
548				MinSimilarityThreshold: -0.1,
549				MaxResults:             10,
550				URLWeight:              0.4,
551				TitleWeight:            0.3,
552				RecencyWeight:          0.2,
553				VisitWeight:            0.1,
554				RecencyDecayDays:       30,
555			},
556			expect: false,
557		},
558		{
559			name: "Invalid threshold (>1.0)",
560			config: &FuzzyConfig{
561				MinSimilarityThreshold: 1.1,
562				MaxResults:             10,
563				URLWeight:              0.4,
564				TitleWeight:            0.3,
565				RecencyWeight:          0.2,
566				VisitWeight:            0.1,
567				RecencyDecayDays:       30,
568			},
569			expect: false,
570		},
571		{
572			name: "Invalid MaxResults",
573			config: &FuzzyConfig{
574				MinSimilarityThreshold: 0.3,
575				MaxResults:             0,
576				URLWeight:              0.4,
577				TitleWeight:            0.3,
578				RecencyWeight:          0.2,
579				VisitWeight:            0.1,
580				RecencyDecayDays:       30,
581			},
582			expect: false,
583		},
584	}
585
586	for _, tt := range tests {
587		t.Run(tt.name, func(t *testing.T) {
588			result := tt.config.IsValid()
589			if result != tt.expect {
590				t.Errorf("FuzzyConfig.IsValid() = %v, want %v", result, tt.expect)
591			}
592		})
593	}
594}
595
596func TestInputType_String(t *testing.T) {
597	tests := []struct {
598		inputType InputType
599		expected  string
600	}{
601		{InputTypeDirectURL, "direct_url"},
602		{InputTypeSearchShortcut, "search_shortcut"},
603		{InputTypeHistorySearch, "history_search"},
604		{InputTypeFallbackSearch, "fallback_search"},
605		{InputType(99), "unknown"}, // Unknown type
606	}
607
608	for _, tt := range tests {
609		t.Run(tt.expected, func(t *testing.T) {
610			result := tt.inputType.String()
611			if result != tt.expected {
612				t.Errorf("InputType.String() = %v, want %v", result, tt.expected)
613			}
614		})
615	}
616}

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