package tui import ( "strings" "testing" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" "github.com/google/uuid" "termtap.dev/internal/model" ) func TestParseStatusToken(t *testing.T) { t.Parallel() tests := []struct { name string in string want int ok bool }{ {name: "exact code", in: "404", want: 404, ok: true}, {name: "class code", in: "5xx", want: 5000, ok: true}, {name: "invalid class", in: "9xx", want: 0, ok: false}, {name: "non-number", in: "abc", want: 0, ok: false}, {name: "too low", in: "99", want: 0, ok: false}, {name: "too high", in: "600", want: 0, ok: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got, ok := parseStatusToken(tt.in) if ok != tt.ok { t.Fatalf("parseStatusToken(%q) ok = %v, want %v", tt.in, ok, tt.ok) } if got != tt.want { t.Fatalf("parseStatusToken(%q) = %d, want %d", tt.in, got, tt.want) } }) } } func TestParseRequestSearchQuery(t *testing.T) { t.Parallel() q := parseRequestSearchQuery("api method:post status:5xx status:404 foo:bar") if len(q.methods) != 1 || q.methods[0] != "post" { t.Fatalf("methods = %#v, want [post]", q.methods) } if _, ok := q.statusHuns[5]; !ok { t.Fatal("expected status class 5xx to be parsed") } if _, ok := q.statuses[404]; !ok { t.Fatal("expected status 404 to be parsed") } if len(q.terms) != 2 || q.terms[0] != "api" || q.terms[1] != "foo:bar" { t.Fatalf("terms = %#v, want [api foo:bar]", q.terms) } } func TestParseRequestSearchQuery_Table(t *testing.T) { t.Parallel() tests := []struct { name string input string wantTerms []string wantMethods []string wantStatuses []int wantClasses []int }{ { name: "normalizes case and spaces", input: " API.Example.Com METHOD:GET STATUS:2xx ", wantTerms: []string{"api.example.com"}, wantMethods: []string{"get"}, wantStatuses: nil, wantClasses: []int{2}, }, { name: "invalid status token falls back to free text", input: "status:wat service", wantTerms: []string{"status:wat", "service"}, wantMethods: nil, wantStatuses: nil, wantClasses: nil, }, { name: "supports spaced method and status filters", input: "method: get status: 5xx", wantTerms: nil, wantMethods: []string{"get"}, wantStatuses: nil, wantClasses: []int{5}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() q := parseRequestSearchQuery(tt.input) if len(q.terms) != len(tt.wantTerms) { t.Fatalf("terms len = %d, want %d", len(q.terms), len(tt.wantTerms)) } for i := range tt.wantTerms { if q.terms[i] != tt.wantTerms[i] { t.Fatalf("terms[%d] = %q, want %q", i, q.terms[i], tt.wantTerms[i]) } } if len(q.methods) != len(tt.wantMethods) { t.Fatalf("methods len = %d, want %d", len(q.methods), len(tt.wantMethods)) } for i := range tt.wantMethods { if q.methods[i] != tt.wantMethods[i] { t.Fatalf("methods[%d] = %q, want %q", i, q.methods[i], tt.wantMethods[i]) } } for _, status := range tt.wantStatuses { if _, ok := q.statuses[status]; !ok { t.Fatalf("expected status %d to exist", status) } } for _, class := range tt.wantClasses { if _, ok := q.statusHuns[class]; !ok { t.Fatalf("expected status class %dxx to exist", class) } } }) } } func TestRequestMatchesQuery(t *testing.T) { t.Parallel() req := model.Request{ Method: "POST", Host: "api.example.com", URL: "/v1/login", RawURL: "https://api.example.com/v1/login", Status: 502, } tests := []struct { name string query string want bool }{ {name: "free text host", query: "api.example.com", want: true}, {name: "free text path", query: "/v1/login", want: true}, {name: "method match", query: "method:post", want: true}, {name: "method mismatch", query: "method:get", want: false}, {name: "status class match", query: "status:5xx", want: true}, {name: "status exact mismatch", query: "status:200", want: false}, {name: "combined and match", query: "api method:post status:5xx", want: true}, {name: "combined and mismatch", query: "api method:post status:2xx", want: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() q := parseRequestSearchQuery(tt.query) got := requestMatchesQuery(req, q) if got != tt.want { t.Fatalf("requestMatchesQuery(%q) = %v, want %v", tt.query, got, tt.want) } }) } } func TestSelectedRequestUsesFilteredResults(t *testing.T) { t.Parallel() m := NewModel(make(chan model.Event), Controls{}) m.requests = []model.Request{ {ID: uuid.New(), Method: "GET", Host: "a.test", URL: "/a", Status: 200}, {ID: uuid.New(), Method: "POST", Host: "b.test", URL: "/b", Status: 500}, {ID: uuid.New(), Method: "GET", Host: "c.test", URL: "/c", Status: 201}, } m.searchQuery = "status:5xx" req, ok := m.selectedRequest() if !ok { t.Fatal("selectedRequest should succeed with filtered results") } if req.Host != "b.test" { t.Fatalf("selected request host = %q, want %q", req.Host, "b.test") } m.requestCursor = 9 m.clampRequestCursor() if m.requestCursor != 0 { t.Fatalf("requestCursor = %d, want 0 for single filtered row", m.requestCursor) } } func TestFilteredRequestIndices_EmptyQueryReturnsAll(t *testing.T) { t.Parallel() m := NewModel(make(chan model.Event), Controls{}) m.requests = []model.Request{ {ID: uuid.New(), Host: "a", URL: "/a"}, {ID: uuid.New(), Host: "b", URL: "/b"}, } m.searchQuery = "" idx := m.filteredRequestIndices() if len(idx) != 2 { t.Fatalf("filteredRequestIndices len = %d, want 2", len(idx)) } if idx[0] != 0 || idx[1] != 1 { t.Fatalf("filtered indices = %#v, want [0 1]", idx) } } func TestSearchEscClearsAndResetsSelectionState(t *testing.T) { t.Parallel() m := NewModel(make(chan model.Event), Controls{}) m.showSearch = true m.searchQuery = "status:5xx" m.requestCursor = 3 m.requestScroll = 2 m.detailsScroll = 4 next, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) got := next.(Model) if got.showSearch { t.Fatal("esc should close search mode") } if got.searchQuery != "" { t.Fatal("esc should clear search query") } if got.requestCursor != 0 || got.requestScroll != 0 || got.detailsScroll != 0 { t.Fatalf("scroll/cursor not reset: cursor=%d reqScroll=%d detScroll=%d", got.requestCursor, got.requestScroll, got.detailsScroll) } } func TestSearchBackspaceHandlesUnicodeRune(t *testing.T) { t.Parallel() m := NewModel(make(chan model.Event), Controls{}) m.showSearch = true m.searchQuery = "a\u00e9" next, _ := m.Update(tea.KeyMsg{Type: tea.KeyBackspace}) got := next.(Model) if got.searchQuery != "a" { t.Fatalf("searchQuery after unicode backspace = %q, want %q", got.searchQuery, "a") } } func TestCreateRequestKeepsSelectionInFilteredView(t *testing.T) { t.Parallel() selectedID := uuid.New() m := NewModel(make(chan model.Event), Controls{}) m.requests = []model.Request{ {ID: uuid.New(), Method: "GET", Host: "api", URL: "/ok", Status: 200}, {ID: selectedID, Method: "GET", Host: "api", URL: "/err", Status: 500}, } m.searchQuery = "status:5xx" m.requestCursor = 0 before, ok := m.selectedRequest() if !ok || before.ID != selectedID { t.Fatal("expected selected filtered request before createRequest") } m.createRequest(model.Request{ID: uuid.New(), Method: "GET", Host: "api", URL: "/new-ok", Status: 201}) after, ok := m.selectedRequest() if !ok || after.ID != selectedID { t.Fatal("selection should stay on same request when new request does not match filter") } if m.requestCursor != 0 { t.Fatalf("requestCursor = %d, want 0 with single visible row", m.requestCursor) } m.createRequest(model.Request{ID: uuid.New(), Method: "GET", Host: "api", URL: "/new-err", Status: 502}) afterMatch, ok := m.selectedRequest() if !ok || afterMatch.ID != selectedID { t.Fatal("selection should stay on same request when matching request arrives") } if m.requestCursor != 1 { t.Fatalf("requestCursor = %d, want 1 to keep selected row anchored", m.requestCursor) } } func TestCreateRequestWhenSelectedEntryEvictedByTrim(t *testing.T) { t.Parallel() m := NewModel(make(chan model.Event), Controls{}) m.searchQuery = "status:5xx" for i := 0; i < maxRequests; i++ { status := 200 if i%2 == 0 { status = 500 } m.requests = append(m.requests, model.Request{ ID: uuid.New(), Method: "GET", Host: "api", URL: "/r", Status: status, }) } visible := m.filteredRequestIndices() if len(visible) == 0 { t.Fatal("expected non-empty visible filtered set") } // Pick the oldest visible row so it gets evicted first. m.requestCursor = len(visible) - 1 selectedBefore, ok := m.selectedRequest() if !ok { t.Fatal("expected selected request before trim") } for { m.createRequest(model.Request{ID: uuid.New(), Method: "GET", Host: "api", URL: "/new", Status: 201}) selectedAfter, ok := m.selectedRequest() if !ok { t.Fatal("expected selection after trim") } if selectedAfter.ID != selectedBefore.ID { break } } if m.requestCursor < 0 || m.requestCursor >= m.visibleRequestCount() { t.Fatalf("requestCursor out of bounds after eviction: %d of %d", m.requestCursor, m.visibleRequestCount()) } } func TestSearchPaneUsesThemedBackgroundFill(t *testing.T) { t.Parallel() m := NewModel(make(chan model.Event), Controls{}) line := m.renderSearchPane(24, 1)[0] if lipgloss.Width(line) != 24 { t.Fatalf("search line width = %d, want 24", lipgloss.Width(line)) } if line == " " { t.Fatal("search pane should render styled content, not raw spaces") } } func TestSearchPaneShowsQueryText(t *testing.T) { t.Parallel() m := NewModel(make(chan model.Event), Controls{}) m.searchQuery = "method:get status:2xx api" line := m.renderSearchPane(64, 1)[0] plain := ansi.Strip(line) if !strings.Contains(plain, "method:get") || !strings.Contains(plain, "status:2xx") || !strings.Contains(plain, "api") { t.Fatalf("search pane missing query text, got %q", plain) } }