v0.1 bf7c821

parser_test.go RawHistoryFolder

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

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