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 DefaultSearchEngine: "https://www.google.com/search?q=%s",
98 SearchShortcuts: map[string]config.SearchShortcut{
99 "g": {
100 URL: "https://www.google.com/search?q={query}",
101 Description: "Google Search",
102 },
103 "gh": {
104 URL: "https://github.com/search?q={query}",
105 Description: "GitHub Search",
106 },
107 "so": {
108 URL: "https://stackoverflow.com/search?q={query}",
109 Description: "Stack Overflow Search",
110 },
111 },
112 }
113}
114
115func TestParser_ParseInput_DirectURL(t *testing.T) {
116 mockHistory := createMockHistory()
117 provider := &MockHistoryProvider{history: mockHistory}
118 config := createTestConfig()
119 parser := NewParser(config, provider)
120
121 tests := []struct {
122 name string
123 input string
124 expectType InputType
125 expectURL string
126 expectConf float64
127 }{
128 {
129 name: "HTTPS URL",
130 input: "https://example.com",
131 expectType: InputTypeDirectURL,
132 expectURL: "https://example.com",
133 expectConf: 1.0,
134 },
135 {
136 name: "HTTP URL",
137 input: "http://example.com",
138 expectType: InputTypeDirectURL,
139 expectURL: "http://example.com",
140 expectConf: 1.0,
141 },
142 {
143 name: "Domain only",
144 input: "example.com",
145 expectType: InputTypeDirectURL,
146 expectURL: "https://example.com",
147 expectConf: 1.0,
148 },
149 {
150 name: "Subdomain",
151 input: "www.example.com",
152 expectType: InputTypeDirectURL,
153 expectURL: "https://www.example.com",
154 expectConf: 1.0,
155 },
156 {
157 name: "Domain with path",
158 input: "example.com/path",
159 expectType: InputTypeDirectURL,
160 expectURL: "https://example.com/path",
161 expectConf: 1.0,
162 },
163 }
164
165 for _, tt := range tests {
166 t.Run(tt.name, func(t *testing.T) {
167 result, err := parser.ParseInput(tt.input)
168 if err != nil {
169 t.Fatalf("ParseInput() error = %v", err)
170 }
171
172 if result.Type != tt.expectType {
173 t.Errorf("ParseInput() type = %v, want %v", result.Type, tt.expectType)
174 }
175
176 if result.URL != tt.expectURL {
177 t.Errorf("ParseInput() URL = %v, want %v", result.URL, tt.expectURL)
178 }
179
180 if result.Confidence != tt.expectConf {
181 t.Errorf("ParseInput() confidence = %v, want %v", result.Confidence, tt.expectConf)
182 }
183 })
184 }
185}
186
187func TestParser_ParseInput_SearchShortcut(t *testing.T) {
188 mockHistory := createMockHistory()
189 provider := &MockHistoryProvider{history: mockHistory}
190 config := createTestConfig()
191 parser := NewParser(config, provider)
192
193 tests := []struct {
194 name string
195 input string
196 expectType InputType
197 expectURL string
198 expectKey string
199 expectQuery string
200 }{
201 {
202 name: "Google search",
203 input: "g: golang tutorial",
204 expectType: InputTypeSearchShortcut,
205 expectURL: "https://www.google.com/search?q=golang+tutorial",
206 expectKey: "g",
207 expectQuery: "golang tutorial",
208 },
209 {
210 name: "GitHub search",
211 input: "gh: cobra cli",
212 expectType: InputTypeSearchShortcut,
213 expectURL: "https://github.com/search?q=cobra+cli",
214 expectKey: "gh",
215 expectQuery: "cobra cli",
216 },
217 {
218 name: "Stack Overflow search",
219 input: "so: how to fuzzy search",
220 expectType: InputTypeSearchShortcut,
221 expectURL: "https://stackoverflow.com/search?q=how+to+fuzzy+search",
222 expectKey: "so",
223 expectQuery: "how to fuzzy search",
224 },
225 }
226
227 for _, tt := range tests {
228 t.Run(tt.name, func(t *testing.T) {
229 result, err := parser.ParseInput(tt.input)
230 if err != nil {
231 t.Fatalf("ParseInput() error = %v", err)
232 }
233
234 if result.Type != tt.expectType {
235 t.Errorf("ParseInput() type = %v, want %v", result.Type, tt.expectType)
236 }
237
238 if result.URL != tt.expectURL {
239 t.Errorf("ParseInput() URL = %v, want %v", result.URL, tt.expectURL)
240 }
241
242 if result.Shortcut == nil {
243 t.Fatalf("ParseInput() shortcut is nil")
244 }
245
246 if result.Shortcut.Key != tt.expectKey {
247 t.Errorf("ParseInput() shortcut key = %v, want %v", result.Shortcut.Key, tt.expectKey)
248 }
249
250 if result.Shortcut.Query != tt.expectQuery {
251 t.Errorf("ParseInput() shortcut query = %v, want %v", result.Shortcut.Query, tt.expectQuery)
252 }
253 })
254 }
255}
256
257func TestParser_ParseInput_SearchTerms(t *testing.T) {
258 // Note: ParseInput now uses CLI-style parsing which converts single words
259 // (without TLDs) into search URLs. The result type is DirectURL because
260 // the generated URL has a scheme. The key behavior is that single words
261 // become search engine queries.
262 mockHistory := createMockHistory()
263 provider := &MockHistoryProvider{history: mockHistory}
264 config := createTestConfig()
265 parser := NewParser(config, provider)
266
267 tests := []struct {
268 name string
269 input string
270 expectURL string // Should contain this in the URL
271 }{
272 {
273 name: "Single word becomes search",
274 input: "github",
275 expectURL: "google.com/search?q=github",
276 },
277 {
278 name: "Another single word becomes search",
279 input: "stack",
280 expectURL: "google.com/search?q=stack",
281 },
282 {
283 name: "Search term becomes search",
284 input: "golang",
285 expectURL: "google.com/search?q=golang",
286 },
287 }
288
289 for _, tt := range tests {
290 t.Run(tt.name, func(t *testing.T) {
291 result, err := parser.ParseInput(tt.input)
292 if err != nil {
293 t.Fatalf("ParseInput() error = %v", err)
294 }
295
296 // Key behavior: single words without TLDs become search URLs
297 if !strings.Contains(result.URL, tt.expectURL) {
298 t.Errorf("ParseInput() URL = %v, expected to contain %v", result.URL, tt.expectURL)
299 }
300 })
301 }
302}
303
304func TestParser_FuzzySearchHistory(t *testing.T) {
305 mockHistory := createMockHistory()
306 provider := &MockHistoryProvider{history: mockHistory}
307 config := createTestConfig()
308 parser := NewParser(config, provider)
309
310 tests := []struct {
311 name string
312 query string
313 threshold float64
314 expectMatches int
315 expectFirst string // Expected first match URL
316 }{
317 {
318 name: "GitHub search",
319 query: "github",
320 threshold: 0.3,
321 expectMatches: 1,
322 expectFirst: "https://github.com",
323 },
324 {
325 name: "Programming search",
326 query: "programming",
327 threshold: 0.2,
328 expectMatches: 1,
329 expectFirst: "https://golang.org",
330 },
331 {
332 name: "Stack search",
333 query: "stack",
334 threshold: 0.3,
335 expectMatches: 1,
336 expectFirst: "https://stackoverflow.com",
337 },
338 }
339
340 for _, tt := range tests {
341 t.Run(tt.name, func(t *testing.T) {
342 matches, err := parser.FuzzySearchHistory(tt.query, tt.threshold)
343 if err != nil {
344 t.Fatalf("FuzzySearchHistory() error = %v", err)
345 }
346
347 if len(matches) < tt.expectMatches {
348 t.Errorf("FuzzySearchHistory() matches = %d, want >= %d", len(matches), tt.expectMatches)
349 }
350
351 if len(matches) > 0 && tt.expectFirst != "" {
352 if matches[0].HistoryEntry.Url != tt.expectFirst {
353 t.Errorf("FuzzySearchHistory() first match = %v, want %v", matches[0].HistoryEntry.Url, tt.expectFirst)
354 }
355 }
356 })
357 }
358}
359
360func TestParser_SuggestCompletions(t *testing.T) {
361 mockHistory := createMockHistory()
362 provider := &MockHistoryProvider{history: mockHistory}
363 config := createTestConfig()
364 parser := NewParser(config, provider)
365
366 tests := []struct {
367 name string
368 input string
369 limit int
370 expectMin int // Minimum expected completions
371 expectMax int // Maximum expected completions
372 expectInList string
373 }{
374 {
375 name: "GitHub completion",
376 input: "gith",
377 limit: 5,
378 expectMin: 1,
379 expectMax: 5,
380 expectInList: "https://github.com", // Should include GitHub URL
381 },
382 {
383 name: "Shortcut completion",
384 input: "g",
385 limit: 5,
386 expectMin: 1, // At least shortcuts should match
387 expectMax: 5,
388 },
389 {
390 name: "Empty input",
391 input: "",
392 limit: 5,
393 expectMin: 0,
394 expectMax: 0,
395 },
396 }
397
398 for _, tt := range tests {
399 t.Run(tt.name, func(t *testing.T) {
400 completions, err := parser.SuggestCompletions(tt.input, tt.limit)
401 if err != nil {
402 t.Fatalf("SuggestCompletions() error = %v", err)
403 }
404
405 if len(completions) < tt.expectMin || len(completions) > tt.expectMax {
406 t.Errorf("SuggestCompletions() count = %d, want between %d and %d", len(completions), tt.expectMin, tt.expectMax)
407 }
408
409 // Check if expected item is in the list
410 if tt.expectInList != "" {
411 found := false
412 for _, c := range completions {
413 if strings.Contains(c, tt.expectInList) {
414 found = true
415 break
416 }
417 }
418 if !found {
419 t.Errorf("SuggestCompletions() expected %q in completions, got %v", tt.expectInList, completions)
420 }
421 }
422 })
423 }
424}
425
426func TestParser_ProcessShortcut(t *testing.T) {
427 config := createTestConfig()
428 mockHistory := createMockHistory()
429 provider := &MockHistoryProvider{history: mockHistory}
430 parser := NewParser(config, provider)
431
432 tests := []struct {
433 name string
434 shortcut string
435 query string
436 expectURL string
437 expectErr bool
438 }{
439 {
440 name: "Google shortcut",
441 shortcut: "g",
442 query: "test query",
443 expectURL: "https://www.google.com/search?q=test+query",
444 expectErr: false,
445 },
446 {
447 name: "GitHub shortcut",
448 shortcut: "gh",
449 query: "golang",
450 expectURL: "https://github.com/search?q=golang",
451 expectErr: false,
452 },
453 {
454 name: "Unknown shortcut",
455 shortcut: "unknown",
456 query: "test",
457 expectURL: "",
458 expectErr: true,
459 },
460 }
461
462 for _, tt := range tests {
463 t.Run(tt.name, func(t *testing.T) {
464 url, err := parser.ProcessShortcut(tt.shortcut, tt.query, config.SearchShortcuts)
465
466 if tt.expectErr {
467 if err == nil {
468 t.Errorf("ProcessShortcut() expected error but got none")
469 }
470 return
471 }
472
473 if err != nil {
474 t.Fatalf("ProcessShortcut() error = %v", err)
475 }
476
477 if url != tt.expectURL {
478 t.Errorf("ProcessShortcut() URL = %v, want %v", url, tt.expectURL)
479 }
480 })
481 }
482}
483
484func TestParser_ValidateConfig(t *testing.T) {
485 mockHistory := createMockHistory()
486 provider := &MockHistoryProvider{history: mockHistory}
487
488 tests := []struct {
489 name string
490 config *config.Config
491 fuzzy *FuzzyConfig
492 provider HistoryProvider
493 expectErr bool
494 }{
495 {
496 name: "Valid configuration",
497 config: createTestConfig(),
498 fuzzy: DefaultFuzzyConfig(),
499 provider: provider,
500 expectErr: false,
501 },
502 {
503 name: "Nil config",
504 config: nil,
505 fuzzy: DefaultFuzzyConfig(),
506 provider: provider,
507 expectErr: true,
508 },
509 {
510 name: "Nil fuzzy config",
511 config: createTestConfig(),
512 fuzzy: nil,
513 provider: provider,
514 expectErr: true,
515 },
516 {
517 name: "Nil history provider",
518 config: createTestConfig(),
519 fuzzy: DefaultFuzzyConfig(),
520 provider: nil,
521 expectErr: true,
522 },
523 }
524
525 for _, tt := range tests {
526 t.Run(tt.name, func(t *testing.T) {
527 parser := NewParser(tt.config, tt.provider, WithFuzzyConfig(tt.fuzzy))
528
529 err := parser.ValidateConfig()
530
531 if tt.expectErr {
532 if err == nil {
533 t.Errorf("ValidateConfig() expected error but got none")
534 }
535 } else {
536 if err != nil {
537 t.Errorf("ValidateConfig() unexpected error = %v", err)
538 }
539 }
540 })
541 }
542}
543
544func TestFuzzyConfig_IsValid(t *testing.T) {
545 tests := []struct {
546 name string
547 config *FuzzyConfig
548 expect bool
549 }{
550 {
551 name: "Default config",
552 config: DefaultFuzzyConfig(),
553 expect: true,
554 },
555 {
556 name: "Invalid threshold (negative)",
557 config: &FuzzyConfig{
558 MinSimilarityThreshold: -0.1,
559 MaxResults: 10,
560 URLWeight: 0.4,
561 TitleWeight: 0.3,
562 RecencyWeight: 0.2,
563 VisitWeight: 0.1,
564 RecencyDecayDays: 30,
565 },
566 expect: false,
567 },
568 {
569 name: "Invalid threshold (>1.0)",
570 config: &FuzzyConfig{
571 MinSimilarityThreshold: 1.1,
572 MaxResults: 10,
573 URLWeight: 0.4,
574 TitleWeight: 0.3,
575 RecencyWeight: 0.2,
576 VisitWeight: 0.1,
577 RecencyDecayDays: 30,
578 },
579 expect: false,
580 },
581 {
582 name: "Invalid MaxResults",
583 config: &FuzzyConfig{
584 MinSimilarityThreshold: 0.3,
585 MaxResults: 0,
586 URLWeight: 0.4,
587 TitleWeight: 0.3,
588 RecencyWeight: 0.2,
589 VisitWeight: 0.1,
590 RecencyDecayDays: 30,
591 },
592 expect: false,
593 },
594 }
595
596 for _, tt := range tests {
597 t.Run(tt.name, func(t *testing.T) {
598 result := tt.config.IsValid()
599 if result != tt.expect {
600 t.Errorf("FuzzyConfig.IsValid() = %v, want %v", result, tt.expect)
601 }
602 })
603 }
604}
605
606func TestInputType_String(t *testing.T) {
607 tests := []struct {
608 inputType InputType
609 expected string
610 }{
611 {InputTypeDirectURL, "direct_url"},
612 {InputTypeSearchShortcut, "search_shortcut"},
613 {InputTypeHistorySearch, "history_search"},
614 {InputTypeFallbackSearch, "fallback_search"},
615 {InputType(99), "unknown"}, // Unknown type
616 }
617
618 for _, tt := range tests {
619 t.Run(tt.expected, func(t *testing.T) {
620 result := tt.inputType.String()
621 if result != tt.expected {
622 t.Errorf("InputType.String() = %v, want %v", result, tt.expected)
623 }
624 })
625 }
626}