diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go new file mode 100644 index 0000000..897fd25 --- /dev/null +++ b/internal/ui/table_test.go @@ -0,0 +1,172 @@ +package ui + +import ( + "testing" + + "github.com/lukaszraczylo/kportal/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestNewTableUI tests the constructor. +func TestNewTableUI(t *testing.T) { + tui := NewTableUI(false) + require.NotNil(t, tui) + assert.NotNil(t, tui.forwards) + assert.False(t, tui.verbose) + + tuiVerbose := NewTableUI(true) + assert.True(t, tuiVerbose.verbose) +} + +// TestTableUI_AddForward covers the happy path and resource-parsing branches. +func TestTableUI_AddForward(t *testing.T) { + tests := []struct { + name string + resource string + alias string + expectedType string + expectedName string + expectedAlias string + }{ + { + name: "pod with prefix", + resource: "pod/my-app", + alias: "alias", + expectedType: "pod", + expectedName: "my-app", + expectedAlias: "alias", + }, + { + name: "service resource", + resource: "service/postgres", + alias: "", + expectedType: "service", + expectedName: "postgres", + expectedAlias: "postgres", // Falls back to resource name + }, + { + name: "no type prefix defaults to pod", + resource: "my-pod", + alias: "", + expectedType: "pod", + expectedName: "my-pod", + expectedAlias: "my-pod", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tui := NewTableUI(false) + fwd := &config.Forward{ + Resource: tt.resource, + Port: 8080, + LocalPort: 8080, + Alias: tt.alias, + } + tui.AddForward("id-1", fwd) + + tui.mu.RLock() + defer tui.mu.RUnlock() + + require.Len(t, tui.forwards, 1) + status := tui.forwards["id-1"] + assert.Equal(t, tt.expectedType, status.Type) + assert.Equal(t, tt.expectedName, status.Resource) + assert.Equal(t, tt.expectedAlias, status.Alias) + assert.Equal(t, "Starting", status.Status) + assert.Equal(t, 8080, status.RemotePort) + assert.Equal(t, 8080, status.LocalPort) + }) + } +} + +// TestTableUI_UpdateStatus verifies status mutation. +func TestTableUI_UpdateStatus(t *testing.T) { + tui := NewTableUI(false) + fwd := &config.Forward{Resource: "pod/app", Port: 80, LocalPort: 8080} + tui.AddForward("id-1", fwd) + + tui.UpdateStatus("id-1", "Active") + + tui.mu.RLock() + assert.Equal(t, "Active", tui.forwards["id-1"].Status) + tui.mu.RUnlock() + + // Updating non-existent ID must not panic. + tui.UpdateStatus("nonexistent", "Active") +} + +// TestTableUI_GetForward covers the lookup path. +func TestTableUI_GetForward(t *testing.T) { + tui := NewTableUI(false) + fwd := &config.Forward{Resource: "pod/app", Port: 80, LocalPort: 8080} + tui.AddForward("id-1", fwd) + + got := tui.GetForward("id-1") + require.NotNil(t, got) + assert.Equal(t, "app", got.Resource) + + missing := tui.GetForward("nonexistent") + assert.Nil(t, missing) +} + +// TestTableUI_Remove tests deletion. +func TestTableUI_Remove(t *testing.T) { + tui := NewTableUI(false) + fwd := &config.Forward{Resource: "pod/app", Port: 80, LocalPort: 8080} + tui.AddForward("id-1", fwd) + tui.AddForward("id-2", fwd) + + tui.Remove("id-1") + + tui.mu.RLock() + defer tui.mu.RUnlock() + assert.Len(t, tui.forwards, 1) + assert.Nil(t, tui.forwards["id-1"]) + assert.NotNil(t, tui.forwards["id-2"]) +} + +// TestTruncate covers the truncation helper. +func TestTruncate(t *testing.T) { + tests := []struct { + input string + expected string + maxLen int + }{ + {"hello", "hello", 10}, + {"hello world", "hello...", 8}, + {"hi", "hi", 2}, + {"hi!", "hi", 2}, // maxLen <= 3 branch: no ellipsis + {"abcd", "abc", 3}, // maxLen <= 3 branch + {"", "", 5}, + } + + for _, tt := range tests { + t.Run(tt.input+"_"+string(rune('0'+tt.maxLen)), func(t *testing.T) { + assert.Equal(t, tt.expected, truncate(tt.input, tt.maxLen)) + }) + } +} + +// TestHyperlink verifies the OSC-8 escape sequence is produced. +func TestHyperlink(t *testing.T) { + result := hyperlink("http://localhost:8080", "8080→") + assert.Contains(t, result, "http://localhost:8080") + assert.Contains(t, result, "8080→") + // Must contain OSC-8 opener and closer + assert.Contains(t, result, "\x1b]8;;") + assert.Contains(t, result, "\x1b\\") +} + +// TestFormatStatusWithIndicator covers all status branches. +func TestFormatStatusWithIndicator(t *testing.T) { + statuses := []string{"Active", "Starting", "Reconnecting", "Error", "Failed", "Unknown"} + for _, s := range statuses { + t.Run(s, func(t *testing.T) { + result := formatStatusWithIndicator(s) + // Must contain the original status string. + assert.Contains(t, result, s) + }) + } +} diff --git a/internal/ui/wizard_handlers_extended_test.go b/internal/ui/wizard_handlers_extended_test.go new file mode 100644 index 0000000..bedb25d --- /dev/null +++ b/internal/ui/wizard_handlers_extended_test.go @@ -0,0 +1,1693 @@ +package ui + +import ( + "os" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/lukaszraczylo/kportal/internal/config" + "github.com/lukaszraczylo/kportal/internal/k8s" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ---- helpers ------------------------------------------------------------- + +func newModelWithBenchmark() model { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeBenchmark + ui.benchmarkState = newBenchmarkState("fwd-id", "alias", 8080) + ui.mu.Unlock() + return model{ui: ui, termWidth: 120, termHeight: 40} +} + +func newModelWithHTTPLog() model { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeHTTPLog + ui.httpLogState = newHTTPLogState("fwd-id", "alias") + ui.mu.Unlock() + return model{ui: ui, termWidth: 120, termHeight: 40} +} + +// ---- handleMainViewKeys: pgup / pgdown ----------------------------------- + +func TestHandleMainViewKeys_PageUpDown(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + for i := 0; i < 15; i++ { + fwd := &config.Forward{Resource: "pod/app", Port: int(8080 + i), LocalPort: int(8080 + i)} + ui.AddForward(string(rune('a'+i)), fwd) + } + ui.mu.Lock() + ui.selectedIndex = 10 + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + keyMsg := tea.KeyMsg{Type: tea.KeyPgUp} + m.handleMainViewKeys(keyMsg) + ui.mu.RLock() + assert.Equal(t, 0, ui.selectedIndex) + ui.mu.RUnlock() + + // Reset and test pgdown. + ui.mu.Lock() + ui.selectedIndex = 0 + ui.mu.Unlock() + keyMsg = tea.KeyMsg{Type: tea.KeyPgDown} + m.handleMainViewKeys(keyMsg) + ui.mu.RLock() + assert.Equal(t, 10, ui.selectedIndex) + ui.mu.RUnlock() +} + +// ---- handleMainViewKeys: 'n' with real discovery (kubeconfig optional) -- + +// TestHandleMainViewKeys_NewWizard_WithDiscovery checks that the 'n' key activates +// the add wizard when a discovery + mutator are set. The actual loadContextsCmd +// is NOT invoked (we only verify the wizard is opened and a cmd is returned). +func TestHandleMainViewKeys_NewWizard_WithDiscovery(t *testing.T) { + realDiscovery := &k8s.Discovery{} + realMutator := &config.Mutator{} + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.discovery = realDiscovery + ui.mutator = realMutator + ui.configPath = "/tmp/test.yaml" + + m := model{ui: ui, termWidth: 120, termHeight: 40} + + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")} + _, cmd := m.handleMainViewKeys(keyMsg) + + ui.mu.RLock() + assert.Equal(t, ViewModeAddWizard, ui.viewMode) + require.NotNil(t, ui.addWizard) + ui.mu.RUnlock() + + // The wizard was opened and a cmd was returned (loadContextsCmd). + assert.NotNil(t, cmd) +} + +// ---- handleMainViewKeys: 'n' blocks when wizard already active ---------- + +func TestHandleMainViewKeys_NewWizard_AlreadyActive(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.discovery = &k8s.Discovery{} + ui.mutator = &config.Mutator{} + ui.mu.Lock() + ui.addWizard = newAddWizardState() // wizard already active + ui.mu.Unlock() + + m := model{ui: ui, termWidth: 120, termHeight: 40} + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")} + m.handleMainViewKeys(keyMsg) + + // Should still be the same wizard (not replaced). + ui.mu.RLock() + assert.Equal(t, StepSelectContext, ui.addWizard.step) + ui.mu.RUnlock() +} + +// ---- handleMainViewKeys: 'l' HTTP log subscription ---------------------- + +func TestHandleMainViewKeys_HttpLog_NoSubscriber(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + fwd := &config.Forward{Resource: "pod/app", Port: 8080, LocalPort: 8080, Alias: "app"} + ui.AddForward("id-1", fwd) + m := model{ui: ui, termWidth: 120, termHeight: 40} + + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")} + m.handleMainViewKeys(keyMsg) + + ui.mu.RLock() + assert.Equal(t, ViewModeHTTPLog, ui.viewMode) + require.NotNil(t, ui.httpLogState) + ui.mu.RUnlock() +} + +func TestHandleMainViewKeys_HttpLog_WithSubscriber(t *testing.T) { + mockSub := NewMockHTTPLogSubscriber() + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.SetHTTPLogSubscriber(mockSub.GetSubscriberFunc()) + fwd := &config.Forward{Resource: "pod/app", Port: 8080, LocalPort: 8080, Alias: "app"} + ui.AddForward("id-1", fwd) + m := model{ui: ui, termWidth: 120, termHeight: 40} + + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")} + m.handleMainViewKeys(keyMsg) + + // Subscription should be established. + mockSub.mu.Lock() + _, subscribed := mockSub.Subscriptions["id-1"] + mockSub.mu.Unlock() + assert.True(t, subscribed) +} + +// ---- handleMainViewKeys: 'b' benchmark ---------------------------------- + +func TestHandleMainViewKeys_Benchmark(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + fwd := &config.Forward{Resource: "pod/app", Port: 8080, LocalPort: 8080, Alias: "app"} + ui.AddForward("id-1", fwd) + m := model{ui: ui, termWidth: 120, termHeight: 40} + + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("b")} + m.handleMainViewKeys(keyMsg) + + ui.mu.RLock() + assert.Equal(t, ViewModeBenchmark, ui.viewMode) + require.NotNil(t, ui.benchmarkState) + ui.mu.RUnlock() +} + +func TestHandleMainViewKeys_Benchmark_BlocksWhenActive(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + fwd := &config.Forward{Resource: "pod/app", Port: 8080, LocalPort: 8080, Alias: "app"} + ui.AddForward("id-1", fwd) + ui.mu.Lock() + ui.benchmarkState = newBenchmarkState("id-1", "app", 8080) + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + prevState := ui.benchmarkState + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("b")} + m.handleMainViewKeys(keyMsg) + + // Should not have replaced the benchmark state. + ui.mu.RLock() + assert.Equal(t, prevState, ui.benchmarkState) + ui.mu.RUnlock() +} + +// ---- handleAddWizardKeys: ctrl+c / esc ---------------------------------- + +func TestHandleAddWizardKeys_CtrlC(t *testing.T) { + m := newModelWithWizard(StepSelectContext) + + keyMsg := tea.KeyMsg{Type: tea.KeyCtrlC} + _, cmd := m.handleAddWizardKeys(keyMsg) + + assert.NotNil(t, cmd) // tea.ClearScreen + m.ui.mu.RLock() + assert.Nil(t, m.ui.addWizard) + assert.Equal(t, ViewModeMain, m.ui.viewMode) + m.ui.mu.RUnlock() +} + +func TestHandleAddWizardKeys_Esc_FirstStep_Cancels(t *testing.T) { + m := newModelWithWizard(StepSelectContext) + + keyMsg := tea.KeyMsg{Type: tea.KeyEsc} + m.handleAddWizardKeys(keyMsg) + + m.ui.mu.RLock() + assert.Nil(t, m.ui.addWizard) + assert.Equal(t, ViewModeMain, m.ui.viewMode) + m.ui.mu.RUnlock() +} + +func TestHandleAddWizardKeys_Esc_MiddleStep_GoesBack(t *testing.T) { + m := newModelWithWizard(StepSelectNamespace) + + keyMsg := tea.KeyMsg{Type: tea.KeyEsc} + m.handleAddWizardKeys(keyMsg) + + m.ui.mu.RLock() + assert.Equal(t, StepSelectContext, m.ui.addWizard.step) + m.ui.mu.RUnlock() +} + +func TestHandleAddWizardKeys_Esc_ClearsSearchFilter_InsteadOfBack(t *testing.T) { + m := newModelWithWizard(StepSelectContext) + m.ui.addWizard.searchFilter = "my" + + keyMsg := tea.KeyMsg{Type: tea.KeyEsc} + m.handleAddWizardKeys(keyMsg) + + m.ui.mu.RLock() + // Should not go back, should just clear filter. + assert.Equal(t, StepSelectContext, m.ui.addWizard.step) + assert.Empty(t, m.ui.addWizard.searchFilter) + m.ui.mu.RUnlock() +} + +func TestHandleAddWizardKeys_Esc_EditMode_AlwaysCancels(t *testing.T) { + m := newModelWithWizard(StepEnterRemotePort) + m.ui.addWizard.isEditing = true + + keyMsg := tea.KeyMsg{Type: tea.KeyEsc} + m.handleAddWizardKeys(keyMsg) + + m.ui.mu.RLock() + assert.Nil(t, m.ui.addWizard) + assert.Equal(t, ViewModeMain, m.ui.viewMode) + m.ui.mu.RUnlock() +} + +// ---- handleAddWizardKeys: navigation ------------------------------------ + +func TestHandleAddWizardKeys_Navigation(t *testing.T) { + m := newModelWithWizard(StepSelectContext) + m.ui.addWizard.contexts = []string{"ctx-a", "ctx-b", "ctx-c"} + + keyMsg := tea.KeyMsg{Type: tea.KeyDown} + m.handleAddWizardKeys(keyMsg) + assert.Equal(t, 1, m.ui.addWizard.cursor) + + keyMsg = tea.KeyMsg{Type: tea.KeyUp} + m.handleAddWizardKeys(keyMsg) + assert.Equal(t, 0, m.ui.addWizard.cursor) +} + +func TestHandleAddWizardKeys_PageNavigation(t *testing.T) { + m := newModelWithWizard(StepSelectContext) + contexts := make([]string, 30) + for i := range contexts { + contexts[i] = "ctx" + } + m.ui.addWizard.contexts = contexts + m.ui.addWizard.cursor = 15 + + keyMsg := tea.KeyMsg{Type: tea.KeyPgUp} + m.handleAddWizardKeys(keyMsg) + assert.Equal(t, 5, m.ui.addWizard.cursor) + + keyMsg = tea.KeyMsg{Type: tea.KeyPgDown} + m.handleAddWizardKeys(keyMsg) + assert.Equal(t, 15, m.ui.addWizard.cursor) +} + +// ---- handleAddWizardKeys: confirmation step navigation ------------------ + +func TestHandleAddWizardKeys_ConfirmationStep_UpFocusAlias(t *testing.T) { + m := newModelWithWizard(StepConfirmation) + m.ui.addWizard.confirmationFocus = FocusButtons + + keyMsg := tea.KeyMsg{Type: tea.KeyUp} + m.handleAddWizardKeys(keyMsg) + assert.Equal(t, FocusAlias, m.ui.addWizard.confirmationFocus) +} + +func TestHandleAddWizardKeys_ConfirmationStep_DownFocusButtons(t *testing.T) { + m := newModelWithWizard(StepConfirmation) + m.ui.addWizard.confirmationFocus = FocusAlias + + keyMsg := tea.KeyMsg{Type: tea.KeyDown} + m.handleAddWizardKeys(keyMsg) + assert.Equal(t, FocusButtons, m.ui.addWizard.confirmationFocus) +} + +func TestHandleAddWizardKeys_Tab_TogglesFocus(t *testing.T) { + m := newModelWithWizard(StepConfirmation) + m.ui.addWizard.confirmationFocus = FocusAlias + + keyMsg := tea.KeyMsg{Type: tea.KeyTab} + m.handleAddWizardKeys(keyMsg) + assert.Equal(t, FocusButtons, m.ui.addWizard.confirmationFocus) + + m.handleAddWizardKeys(keyMsg) + assert.Equal(t, FocusAlias, m.ui.addWizard.confirmationFocus) +} + +// ---- handleAddWizardKeys: backspace ------------------------------------ + +func TestHandleAddWizardKeys_Backspace_TextMode(t *testing.T) { + m := newModelWithWizard(StepEnterRemotePort) + m.ui.addWizard.inputMode = InputModeText + m.ui.addWizard.textInput = "808" + + keyMsg := tea.KeyMsg{Type: tea.KeyBackspace} + m.handleAddWizardKeys(keyMsg) + assert.Equal(t, "80", m.ui.addWizard.textInput) +} + +func TestHandleAddWizardKeys_Backspace_SearchFilter(t *testing.T) { + m := newModelWithWizard(StepSelectContext) + m.ui.addWizard.searchFilter = "my" + + keyMsg := tea.KeyMsg{Type: tea.KeyBackspace} + m.handleAddWizardKeys(keyMsg) + assert.Equal(t, "m", m.ui.addWizard.searchFilter) +} + +func TestHandleAddWizardKeys_Backspace_EmptyFilter_NoOp(t *testing.T) { + m := newModelWithWizard(StepSelectContext) + m.ui.addWizard.searchFilter = "" + + keyMsg := tea.KeyMsg{Type: tea.KeyBackspace} + // Must not panic. + m.handleAddWizardKeys(keyMsg) +} + +// ---- handleAddWizardKeys: text input in filterable list mode ------------ + +func TestHandleAddWizardKeys_TypeCharAddsToSearchFilter(t *testing.T) { + m := newModelWithWizard(StepSelectContext) + // Context step is filterable in list mode. + + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("p")} + m.handleAddWizardKeys(keyMsg) + assert.Equal(t, "p", m.ui.addWizard.searchFilter) +} + +// ---- handleAddWizardKeys: text input in text mode ---------------------- + +func TestHandleAddWizardKeys_TypeCharInTextMode(t *testing.T) { + m := newModelWithWizard(StepEnterRemotePort) + m.ui.addWizard.inputMode = InputModeText + + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("8")} + m.handleAddWizardKeys(keyMsg) + assert.Equal(t, "8", m.ui.addWizard.textInput) +} + +// ---- handleAddWizardEnter: StepSelectContext ---------------------------- + +func TestHandleAddWizardEnter_SelectContext(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + // Use a real discovery (may be nil cluster, but we only need the cmd to be returned). + ui.discovery = &k8s.Discovery{} + ui.mu.Lock() + ui.viewMode = ViewModeAddWizard + w := newAddWizardState() + w.step = StepSelectContext + w.contexts = []string{"prod", "staging"} + w.cursor = 0 + ui.addWizard = w + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + _, cmd := m.handleAddWizardKeys(keyMsg) + + ui.mu.RLock() + assert.Equal(t, StepSelectNamespace, ui.addWizard.step) + assert.Equal(t, "prod", ui.addWizard.selectedContext) + ui.mu.RUnlock() + + // A cmd is returned (loadNamespacesCmd) — we just verify it's not nil. + assert.NotNil(t, cmd) +} + +func TestHandleAddWizardEnter_SelectContext_Loading_NoOp(t *testing.T) { + m := newModelWithWizard(StepSelectContext) + m.ui.addWizard.loading = true + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + _, cmd := m.handleAddWizardKeys(keyMsg) + assert.Nil(t, cmd) +} + +// ---- handleAddWizardEnter: StepSelectNamespace -------------------------- + +func TestHandleAddWizardEnter_SelectNamespace(t *testing.T) { + m := newModelWithWizard(StepSelectNamespace) + m.ui.addWizard.namespaces = []string{"default", "kube-system"} + m.ui.addWizard.cursor = 1 // kube-system + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + m.handleAddWizardKeys(keyMsg) + + assert.Equal(t, StepSelectResourceType, m.ui.addWizard.step) + assert.Equal(t, "kube-system", m.ui.addWizard.selectedNamespace) +} + +// ---- handleAddWizardEnter: StepSelectResourceType ---------------------- + +func TestHandleAddWizardEnter_SelectResourceType_Service(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.discovery = &k8s.Discovery{} + ui.mu.Lock() + ui.viewMode = ViewModeAddWizard + w := newAddWizardState() + w.step = StepSelectResourceType + w.selectedContext = "ctx" + w.selectedNamespace = "ns" + w.cursor = 2 // ResourceTypeService + ui.addWizard = w + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + _, cmd := m.handleAddWizardKeys(keyMsg) + + ui.mu.RLock() + assert.Equal(t, StepEnterResource, ui.addWizard.step) + assert.Equal(t, ResourceTypeService, ui.addWizard.selectedResourceType) + ui.mu.RUnlock() + + // A cmd (loadServicesCmd) is returned. + assert.NotNil(t, cmd) +} + +func TestHandleAddWizardEnter_SelectResourceType_PodPrefix(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.discovery = &k8s.Discovery{} + ui.mu.Lock() + ui.viewMode = ViewModeAddWizard + w := newAddWizardState() + w.step = StepSelectResourceType + w.selectedContext = "ctx" + w.selectedNamespace = "ns" + w.cursor = 0 // ResourceTypePodPrefix + ui.addWizard = w + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + _, cmd := m.handleAddWizardKeys(keyMsg) + + ui.mu.RLock() + assert.Equal(t, StepEnterResource, ui.addWizard.step) + ui.mu.RUnlock() + + // A cmd (loadPodsCmd) is returned. + assert.NotNil(t, cmd) +} + +// ---- handleAddWizardEnter: StepEnterResource PodPrefix ----------------- + +func TestHandleAddWizardEnter_EnterResource_PodPrefix(t *testing.T) { + m := newModelWithWizard(StepEnterResource) + m.ui.addWizard.selectedResourceType = ResourceTypePodPrefix + m.ui.addWizard.inputMode = InputModeText + m.ui.addWizard.textInput = "my-app" + m.ui.addWizard.pods = []k8s.PodInfo{{ + Name: "my-app-abc", + Containers: []k8s.ContainerInfo{{Name: "main", Ports: []k8s.PortInfo{{Port: 8080}}}}, + }} + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + m.handleAddWizardKeys(keyMsg) + + assert.Equal(t, StepEnterRemotePort, m.ui.addWizard.step) + assert.Equal(t, "my-app", m.ui.addWizard.resourceValue) +} + +func TestHandleAddWizardEnter_EnterResource_PodPrefix_EmptyInput_NoOp(t *testing.T) { + m := newModelWithWizard(StepEnterResource) + m.ui.addWizard.selectedResourceType = ResourceTypePodPrefix + m.ui.addWizard.inputMode = InputModeText + m.ui.addWizard.textInput = "" + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + m.handleAddWizardKeys(keyMsg) + + assert.Equal(t, StepEnterResource, m.ui.addWizard.step) // should not advance +} + +// ---- handleAddWizardEnter: StepEnterResource PodSelector --------------- + +func TestHandleAddWizardEnter_EnterResource_PodSelector(t *testing.T) { + m := newModelWithWizard(StepEnterResource) + m.ui.addWizard.selectedResourceType = ResourceTypePodSelector + m.ui.addWizard.inputMode = InputModeText + m.ui.addWizard.textInput = "app=my" + m.ui.addWizard.matchingPods = []k8s.PodInfo{{Name: "my-pod"}} + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + m.handleAddWizardKeys(keyMsg) + + assert.Equal(t, StepEnterRemotePort, m.ui.addWizard.step) + assert.Equal(t, "app=my", m.ui.addWizard.selector) +} + +func TestHandleAddWizardEnter_EnterResource_PodSelector_NoMatchingPods_NoOp(t *testing.T) { + m := newModelWithWizard(StepEnterResource) + m.ui.addWizard.selectedResourceType = ResourceTypePodSelector + m.ui.addWizard.textInput = "app=my" + m.ui.addWizard.matchingPods = nil + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + m.handleAddWizardKeys(keyMsg) + + assert.Equal(t, StepEnterResource, m.ui.addWizard.step) +} + +// ---- handleAddWizardEnter: StepEnterResource Service ------------------- + +func TestHandleAddWizardEnter_EnterResource_Service(t *testing.T) { + m := newModelWithWizard(StepEnterResource) + m.ui.addWizard.selectedResourceType = ResourceTypeService + m.ui.addWizard.inputMode = InputModeList + m.ui.addWizard.services = []k8s.ServiceInfo{ + {Name: "api-svc", Ports: []k8s.PortInfo{{Port: 80}}}, + } + m.ui.addWizard.cursor = 0 + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + m.handleAddWizardKeys(keyMsg) + + assert.Equal(t, StepEnterRemotePort, m.ui.addWizard.step) + assert.Equal(t, "api-svc", m.ui.addWizard.resourceValue) +} + +// ---- handleAddWizardEnter: StepEnterRemotePort ------------------------- + +func TestHandleAddWizardEnter_RemotePort_TextMode_ValidPort(t *testing.T) { + m := newModelWithWizard(StepEnterRemotePort) + m.ui.addWizard.inputMode = InputModeText + m.ui.addWizard.textInput = "8080" + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + m.handleAddWizardKeys(keyMsg) + + assert.Equal(t, StepEnterLocalPort, m.ui.addWizard.step) + assert.Equal(t, 8080, m.ui.addWizard.remotePort) +} + +func TestHandleAddWizardEnter_RemotePort_TextMode_InvalidPort(t *testing.T) { + m := newModelWithWizard(StepEnterRemotePort) + m.ui.addWizard.inputMode = InputModeText + m.ui.addWizard.textInput = "invalid" + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + m.handleAddWizardKeys(keyMsg) + + assert.Equal(t, StepEnterRemotePort, m.ui.addWizard.step) + assert.NotNil(t, m.ui.addWizard.error) +} + +func TestHandleAddWizardEnter_RemotePort_ListMode_SelectPort(t *testing.T) { + m := newModelWithWizard(StepEnterRemotePort) + m.ui.addWizard.inputMode = InputModeList + m.ui.addWizard.detectedPorts = []k8s.PortInfo{ + {Port: 80}, + {Port: 8080, TargetPort: 9090}, + } + m.ui.addWizard.cursor = 1 // 8080 → TargetPort 9090 + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + m.handleAddWizardKeys(keyMsg) + + assert.Equal(t, StepEnterLocalPort, m.ui.addWizard.step) + assert.Equal(t, 9090, m.ui.addWizard.remotePort) // TargetPort used +} + +func TestHandleAddWizardEnter_RemotePort_ListMode_ManualEntry(t *testing.T) { + m := newModelWithWizard(StepEnterRemotePort) + m.ui.addWizard.inputMode = InputModeList + m.ui.addWizard.detectedPorts = []k8s.PortInfo{{Port: 80}} + m.ui.addWizard.cursor = 1 // "Manual entry" option + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + m.handleAddWizardKeys(keyMsg) + + assert.Equal(t, InputModeText, m.ui.addWizard.inputMode) + assert.Equal(t, StepEnterRemotePort, m.ui.addWizard.step) +} + +// ---- handleAddWizardEnter: StepEnterLocalPort -------------------------- + +func TestHandleAddWizardEnter_LocalPort_ValidPort(t *testing.T) { + tmpDir := t.TempDir() + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeAddWizard + ui.configPath = tmpDir + "/nonexistent.yaml" + w := newAddWizardState() + w.step = StepEnterLocalPort + w.textInput = "19876" + ui.addWizard = w + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + _, cmd := m.handleAddWizardKeys(keyMsg) + + // loading set, cmd generated. + require.NotNil(t, cmd) + ui.mu.RLock() + assert.True(t, ui.addWizard.loading) + assert.Equal(t, 19876, ui.addWizard.localPort) + ui.mu.RUnlock() +} + +func TestHandleAddWizardEnter_LocalPort_InvalidPort(t *testing.T) { + m := newModelWithWizard(StepEnterLocalPort) + m.ui.addWizard.textInput = "0" // invalid (must be > 0) + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + m.handleAddWizardKeys(keyMsg) + + assert.NotNil(t, m.ui.addWizard.error) +} + +// ---- handleAddWizardEnter: StepConfirmation ---------------------------- + +func TestHandleAddWizardEnter_Confirmation_FocusAlias_MoveToButtons(t *testing.T) { + m := newModelWithWizard(StepConfirmation) + m.ui.addWizard.confirmationFocus = FocusAlias + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + m.handleAddWizardKeys(keyMsg) + + assert.Equal(t, FocusButtons, m.ui.addWizard.confirmationFocus) +} + +func TestHandleAddWizardEnter_Confirmation_CancelButton(t *testing.T) { + m := newModelWithWizard(StepConfirmation) + m.ui.addWizard.confirmationFocus = FocusButtons + m.ui.addWizard.cursor = 1 // Cancel + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + _, cmd := m.handleAddWizardKeys(keyMsg) + + assert.NotNil(t, cmd) // tea.ClearScreen + m.ui.mu.RLock() + assert.Nil(t, m.ui.addWizard) + assert.Equal(t, ViewModeMain, m.ui.viewMode) + m.ui.mu.RUnlock() +} + +func TestHandleAddWizardEnter_Confirmation_PortNotAvailable_Error(t *testing.T) { + m := newModelWithWizard(StepConfirmation) + m.ui.addWizard.confirmationFocus = FocusButtons + m.ui.addWizard.cursor = 0 // Yes / Add + m.ui.addWizard.portAvailable = false + m.ui.addWizard.localPort = 8080 + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + m.handleAddWizardKeys(keyMsg) + + assert.NotNil(t, m.ui.addWizard.error) + assert.Equal(t, StepConfirmation, m.ui.addWizard.step) // should stay +} + +func TestHandleAddWizardEnter_Confirmation_Save_NewForward(t *testing.T) { + mutator := newTempMutator(t) + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mutator = mutator + ui.mu.Lock() + ui.viewMode = ViewModeAddWizard + w := newAddWizardState() + w.step = StepConfirmation + w.confirmationFocus = FocusButtons + w.cursor = 0 // Yes/Add + w.portAvailable = true + w.selectedContext = "ctx" + w.selectedNamespace = "ns" + w.resourceValue = "my-app" + w.selectedResourceType = ResourceTypePodPrefix + w.remotePort = 80 + w.localPort = 18090 + w.textInput = "my-alias" + w.httpLog = false + ui.addWizard = w + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + _, cmd := m.handleAddWizardKeys(keyMsg) + + require.NotNil(t, cmd) + msg := cmd() + savedMsg, ok := msg.(ForwardSavedMsg) + require.True(t, ok) + assert.True(t, savedMsg.success) +} + +func TestHandleAddWizardEnter_Confirmation_Save_NewForward_WithHTTPLog(t *testing.T) { + mutator := newTempMutator(t) + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mutator = mutator + ui.mu.Lock() + ui.viewMode = ViewModeAddWizard + w := newAddWizardState() + w.step = StepConfirmation + w.confirmationFocus = FocusButtons + w.cursor = 0 + w.portAvailable = true + w.selectedContext = "ctx" + w.selectedNamespace = "ns" + w.resourceValue = "my-app" + w.selectedResourceType = ResourceTypePodPrefix + w.remotePort = 80 + w.localPort = 18091 + w.httpLog = true + // Simulate having an existing HTTPLog config with advanced fields. + w.httpLogOriginal = &config.HTTPLogSpec{Enabled: false, IncludeHeaders: true, MaxBodySize: 4096} + ui.addWizard = w + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + _, cmd := m.handleAddWizardKeys(keyMsg) + require.NotNil(t, cmd) + msg := cmd() + savedMsg, ok := msg.(ForwardSavedMsg) + require.True(t, ok) + assert.True(t, savedMsg.success) +} + +func TestHandleAddWizardEnter_Confirmation_Update_EditMode(t *testing.T) { + mutator := newTempMutator(t) + // Add the original forward first so the update has something to update. + origFwd := config.Forward{Resource: "pod/my-app", Port: 80, LocalPort: 18092} + require.NoError(t, mutator.AddForward("ctx", "ns", origFwd)) + + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mutator = mutator + ui.mu.Lock() + ui.viewMode = ViewModeAddWizard + w := newAddWizardState() + w.step = StepConfirmation + w.confirmationFocus = FocusButtons + w.cursor = 0 + w.portAvailable = true + w.isEditing = true + w.originalID = "ctx/ns/pod/my-app:18092" + w.selectedContext = "ctx" + w.selectedNamespace = "ns" + w.resourceValue = "my-app" + w.selectedResourceType = ResourceTypePodPrefix + w.remotePort = 80 + w.localPort = 18092 + ui.addWizard = w + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + _, cmd := m.handleAddWizardKeys(keyMsg) + require.NotNil(t, cmd) + msg := cmd() + savedMsg, ok := msg.(ForwardSavedMsg) + require.True(t, ok) + assert.True(t, savedMsg.success) +} + +// ---- handleAddWizardEnter: StepSuccess --------------------------------- + +func TestHandleAddWizardEnter_Success_AddAnother(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.discovery = &k8s.Discovery{} + ui.mu.Lock() + ui.viewMode = ViewModeAddWizard + w := newAddWizardState() + w.step = StepSuccess + w.cursor = 0 // "Add another" + ui.addWizard = w + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + _, cmd := m.handleAddWizardKeys(keyMsg) + + // A cmd (loadContextsCmd) must be returned. + assert.NotNil(t, cmd) +} + +func TestHandleAddWizardEnter_Success_ReturnToMain(t *testing.T) { + m := newModelWithWizard(StepSuccess) + m.ui.addWizard.cursor = 1 // "Return to main" + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + _, cmd := m.handleAddWizardKeys(keyMsg) + + assert.NotNil(t, cmd) + m.ui.mu.RLock() + assert.Nil(t, m.ui.addWizard) + assert.Equal(t, ViewModeMain, m.ui.viewMode) + m.ui.mu.RUnlock() +} + +// ---- handleAddWizardKeys: selector validation on type ----------------- + +func TestHandleAddWizardKeys_SelectorValidation_OnChar(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.discovery = &k8s.Discovery{} + ui.mu.Lock() + ui.viewMode = ViewModeAddWizard + w := newAddWizardState() + w.step = StepEnterResource + w.selectedResourceType = ResourceTypePodSelector + w.inputMode = InputModeText + w.textInput = "app=my" + ui.addWizard = w + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + // Typing a char in PodSelector step should return a validation cmd. + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("-")} + _, cmd := m.handleAddWizardKeys(keyMsg) + + // A cmd is returned (validateSelectorCmd); we don't invoke it as it needs a real cluster. + assert.NotNil(t, cmd) +} + +// ---- handleRemoveWizardKeys: Space, a, n keys -------------------------- + +func TestHandleRemoveWizardKeys_SpaceToggle(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeRemoveWizard + ui.removeWizard = &RemoveWizardState{ + forwards: []RemovableForward{{ID: "f1", Alias: "alpha"}, {ID: "f2", Alias: "beta"}}, + selected: map[int]bool{}, + cursor: 0, + } + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + keyMsg := tea.KeyMsg{Type: tea.KeySpace} + m.handleRemoveWizardKeys(keyMsg) + + ui.mu.RLock() + assert.True(t, ui.removeWizard.selected[0]) + ui.mu.RUnlock() + + // Toggle again to deselect. + m.handleRemoveWizardKeys(keyMsg) + ui.mu.RLock() + assert.False(t, ui.removeWizard.selected[0]) + ui.mu.RUnlock() +} + +func TestHandleRemoveWizardKeys_SelectAll(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeRemoveWizard + ui.removeWizard = &RemoveWizardState{ + forwards: []RemovableForward{{ID: "f1"}, {ID: "f2"}, {ID: "f3"}}, + selected: map[int]bool{}, + } + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")} + m.handleRemoveWizardKeys(keyMsg) + + ui.mu.RLock() + assert.True(t, ui.removeWizard.selected[0]) + assert.True(t, ui.removeWizard.selected[1]) + assert.True(t, ui.removeWizard.selected[2]) + ui.mu.RUnlock() +} + +func TestHandleRemoveWizardKeys_SelectNone(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeRemoveWizard + ui.removeWizard = &RemoveWizardState{ + forwards: []RemovableForward{{ID: "f1"}, {ID: "f2"}}, + selected: map[int]bool{0: true, 1: true}, + } + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")} + m.handleRemoveWizardKeys(keyMsg) + + ui.mu.RLock() + assert.Equal(t, 0, len(ui.removeWizard.selected)) + ui.mu.RUnlock() +} + +func TestHandleRemoveWizardKeys_CtrlC_ExitsAlways(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeRemoveWizard + ui.removeWizard = &RemoveWizardState{ + forwards: []RemovableForward{{ID: "f1"}}, + selected: map[int]bool{0: true}, + confirming: true, + } + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + keyMsg := tea.KeyMsg{Type: tea.KeyCtrlC} + m.handleRemoveWizardKeys(keyMsg) + + ui.mu.RLock() + assert.Nil(t, ui.removeWizard) + assert.Equal(t, ViewModeMain, ui.viewMode) + ui.mu.RUnlock() +} + +func TestHandleRemoveWizardKeys_Enter_NothingSelected_NoOp(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeRemoveWizard + ui.removeWizard = &RemoveWizardState{ + forwards: []RemovableForward{{ID: "f1"}}, + selected: map[int]bool{}, + } + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + _, cmd := m.handleRemoveWizardKeys(keyMsg) + + assert.Nil(t, cmd) + ui.mu.RLock() + assert.False(t, ui.removeWizard.confirming) + ui.mu.RUnlock() +} + +func TestHandleRemoveWizardKeys_Enter_ShowConfirmation(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeRemoveWizard + ui.removeWizard = &RemoveWizardState{ + forwards: []RemovableForward{{ID: "f1"}}, + selected: map[int]bool{0: true}, + } + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + m.handleRemoveWizardKeys(keyMsg) + + ui.mu.RLock() + assert.True(t, ui.removeWizard.confirming) + ui.mu.RUnlock() +} + +func TestHandleRemoveWizardKeys_Enter_ConfirmNo(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeRemoveWizard + ui.removeWizard = &RemoveWizardState{ + forwards: []RemovableForward{{ID: "f1"}}, + selected: map[int]bool{0: true}, + confirming: true, + confirmCursor: 1, // "No" + } + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + _, cmd := m.handleRemoveWizardKeys(keyMsg) + + assert.Nil(t, cmd) + ui.mu.RLock() + assert.False(t, ui.removeWizard.confirming) // returned to selection + ui.mu.RUnlock() +} + +func TestHandleRemoveWizardKeys_Navigation_InSelection(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeRemoveWizard + ui.removeWizard = &RemoveWizardState{ + forwards: []RemovableForward{{ID: "f1"}, {ID: "f2"}, {ID: "f3"}}, + selected: map[int]bool{}, + cursor: 0, + } + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + keyMsg := tea.KeyMsg{Type: tea.KeyDown} + m.handleRemoveWizardKeys(keyMsg) + ui.mu.RLock() + assert.Equal(t, 1, ui.removeWizard.cursor) + ui.mu.RUnlock() + + keyMsg = tea.KeyMsg{Type: tea.KeyUp} + m.handleRemoveWizardKeys(keyMsg) + ui.mu.RLock() + assert.Equal(t, 0, ui.removeWizard.cursor) + ui.mu.RUnlock() +} + +// ---- handleBenchmarkKeys: tab / text input / backspace ----------------- + +func TestHandleBenchmarkKeys_Tab(t *testing.T) { + m := newModelWithBenchmark() + m.ui.benchmarkState.step = BenchmarkStepConfig + m.ui.benchmarkState.cursor = 0 + + keyMsg := tea.KeyMsg{Type: tea.KeyTab} + m.handleBenchmarkKeys(keyMsg) + + assert.Equal(t, 1, m.ui.benchmarkState.cursor) +} + +func TestHandleBenchmarkKeys_Tab_Wraps(t *testing.T) { + m := newModelWithBenchmark() + m.ui.benchmarkState.step = BenchmarkStepConfig + m.ui.benchmarkState.cursor = 3 + + keyMsg := tea.KeyMsg{Type: tea.KeyTab} + m.handleBenchmarkKeys(keyMsg) + + assert.Equal(t, 0, m.ui.benchmarkState.cursor) +} + +func TestHandleBenchmarkKeys_Backspace(t *testing.T) { + m := newModelWithBenchmark() + m.ui.benchmarkState.step = BenchmarkStepConfig + m.ui.benchmarkState.cursor = 0 + m.ui.benchmarkState.textInput = "/api" + + keyMsg := tea.KeyMsg{Type: tea.KeyBackspace} + m.handleBenchmarkKeys(keyMsg) + + assert.Equal(t, "/ap", m.ui.benchmarkState.textInput) + assert.Equal(t, "/ap", m.ui.benchmarkState.urlPath) +} + +func TestHandleBenchmarkKeys_TypeChar_UpdatesField(t *testing.T) { + m := newModelWithBenchmark() + m.ui.benchmarkState.step = BenchmarkStepConfig + m.ui.benchmarkState.cursor = 1 // Method field + m.ui.benchmarkState.textInput = "GE" + + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("T")} + m.handleBenchmarkKeys(keyMsg) + + assert.Equal(t, "GET", m.ui.benchmarkState.method) +} + +func TestHandleBenchmarkKeys_Enter_ResultsStep_ReturnsToMain(t *testing.T) { + m := newModelWithBenchmark() + m.ui.benchmarkState.step = BenchmarkStepResults + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + _, cmd := m.handleBenchmarkKeys(keyMsg) + + assert.NotNil(t, cmd) // tea.ClearScreen + m.ui.mu.RLock() + assert.Nil(t, m.ui.benchmarkState) + assert.Equal(t, ViewModeMain, m.ui.viewMode) + m.ui.mu.RUnlock() +} + +func TestHandleBenchmarkKeys_Enter_ConfigStep_StartsRunning(t *testing.T) { + m := newModelWithBenchmark() + m.ui.benchmarkState.step = BenchmarkStepConfig + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + _, cmd := m.handleBenchmarkKeys(keyMsg) + + require.NotNil(t, cmd) + m.ui.mu.RLock() + assert.Equal(t, BenchmarkStepRunning, m.ui.benchmarkState.step) + assert.True(t, m.ui.benchmarkState.running) + m.ui.mu.RUnlock() +} + +// ---- applyBenchmarkTextInput / getBenchmarkFieldValue ------------------ + +func TestApplyBenchmarkTextInput_AllFields(t *testing.T) { + m := newModelWithBenchmark() + state := m.ui.benchmarkState + state.step = BenchmarkStepConfig + + // URL path (cursor 0) + state.cursor = 0 + state.textInput = "/new" + m.applyBenchmarkTextInput() + assert.Equal(t, "/new", state.urlPath) + + // Method (cursor 1) + state.cursor = 1 + state.textInput = "post" + m.applyBenchmarkTextInput() + assert.Equal(t, "POST", state.method) + + // Concurrency (cursor 2) + state.cursor = 2 + state.textInput = "5" + m.applyBenchmarkTextInput() + assert.Equal(t, 5, state.concurrency) + + // Requests (cursor 3) + state.cursor = 3 + state.textInput = "50" + m.applyBenchmarkTextInput() + assert.Equal(t, 50, state.requests) +} + +func TestApplyBenchmarkTextInput_ConcurrencyCappedAtRequests(t *testing.T) { + m := newModelWithBenchmark() + state := m.ui.benchmarkState + state.requests = 10 + state.cursor = 2 + state.textInput = "999" + m.applyBenchmarkTextInput() + assert.Equal(t, 10, state.concurrency) +} + +func TestGetBenchmarkFieldValue_AllFields(t *testing.T) { + m := newModelWithBenchmark() + state := m.ui.benchmarkState + state.urlPath = "/test" + state.method = "DELETE" + state.concurrency = 7 + state.requests = 77 + + assert.Equal(t, "/test", m.getBenchmarkFieldValue(0)) + assert.Equal(t, "DELETE", m.getBenchmarkFieldValue(1)) + assert.Equal(t, "7", m.getBenchmarkFieldValue(2)) + assert.Equal(t, "77", m.getBenchmarkFieldValue(3)) + assert.Equal(t, "", m.getBenchmarkFieldValue(99)) +} + +func TestGetBenchmarkFieldValue_NilState(t *testing.T) { + m := newTestModel() + result := m.getBenchmarkFieldValue(0) + assert.Equal(t, "", result) +} + +// ---- handleHTTPLogKeys: detail view keys -------------------------------- + +func TestHandleHTTPLogKeys_Detail_Esc(t *testing.T) { + m := newModelWithHTTPLog() + m.ui.httpLogState.showingDetail = true + m.ui.httpLogState.entries = []HTTPLogEntry{{Method: "GET", Path: "/test"}} + + keyMsg := tea.KeyMsg{Type: tea.KeyEsc} + m.handleHTTPLogKeys(keyMsg) + + assert.False(t, m.ui.httpLogState.showingDetail) +} + +func TestHandleHTTPLogKeys_Detail_UpDown(t *testing.T) { + m := newModelWithHTTPLog() + m.ui.httpLogState.showingDetail = true + m.ui.httpLogState.detailScroll = 5 + m.ui.httpLogState.entries = []HTTPLogEntry{{Method: "GET"}} + + keyMsg := tea.KeyMsg{Type: tea.KeyUp} + m.handleHTTPLogKeys(keyMsg) + assert.Equal(t, 4, m.ui.httpLogState.detailScroll) + + keyMsg = tea.KeyMsg{Type: tea.KeyDown} + m.handleHTTPLogKeys(keyMsg) + assert.Equal(t, 5, m.ui.httpLogState.detailScroll) +} + +func TestHandleHTTPLogKeys_Detail_PgUpDown(t *testing.T) { + m := newModelWithHTTPLog() + m.ui.httpLogState.showingDetail = true + m.ui.httpLogState.detailScroll = 25 + m.ui.httpLogState.entries = []HTTPLogEntry{{Method: "GET"}} + + keyMsg := tea.KeyMsg{Type: tea.KeyPgUp} + m.handleHTTPLogKeys(keyMsg) + assert.Equal(t, 5, m.ui.httpLogState.detailScroll) + + keyMsg = tea.KeyMsg{Type: tea.KeyPgDown} + m.handleHTTPLogKeys(keyMsg) + assert.Equal(t, 25, m.ui.httpLogState.detailScroll) +} + +func TestHandleHTTPLogKeys_Detail_GoToTop(t *testing.T) { + m := newModelWithHTTPLog() + m.ui.httpLogState.showingDetail = true + m.ui.httpLogState.detailScroll = 99 + m.ui.httpLogState.entries = []HTTPLogEntry{{Method: "GET"}} + + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("g")} + m.handleHTTPLogKeys(keyMsg) + + assert.Equal(t, 0, m.ui.httpLogState.detailScroll) +} + +// ---- handleHTTPLogKeys: list view keys ---------------------------------- + +func TestHandleHTTPLogKeys_Enter_ShowDetail(t *testing.T) { + m := newModelWithHTTPLog() + // StatusCode must be non-zero or getFilteredEntries will skip the entry. + m.ui.httpLogState.entries = []HTTPLogEntry{{Method: "GET", Path: "/test", StatusCode: 200}} + m.ui.httpLogState.cursor = 0 + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + m.handleHTTPLogKeys(keyMsg) + + assert.True(t, m.ui.httpLogState.showingDetail) +} + +func TestHandleHTTPLogKeys_Navigate_UpDown(t *testing.T) { + m := newModelWithHTTPLog() + // StatusCode must be non-zero or getFilteredEntries will skip entries. + m.ui.httpLogState.entries = []HTTPLogEntry{ + {Method: "GET", StatusCode: 200}, {Method: "POST", StatusCode: 200}, {Method: "PUT", StatusCode: 200}, + } + m.ui.httpLogState.cursor = 1 + + keyMsg := tea.KeyMsg{Type: tea.KeyUp} + m.handleHTTPLogKeys(keyMsg) + assert.Equal(t, 0, m.ui.httpLogState.cursor) + + keyMsg = tea.KeyMsg{Type: tea.KeyDown} + m.handleHTTPLogKeys(keyMsg) + assert.Equal(t, 1, m.ui.httpLogState.cursor) +} + +func TestHandleHTTPLogKeys_Down_AtBottom_EnablesAutoScroll(t *testing.T) { + m := newModelWithHTTPLog() + // StatusCode must be non-zero or getFilteredEntries will skip entries. + m.ui.httpLogState.entries = []HTTPLogEntry{{Method: "GET", StatusCode: 200}, {Method: "POST", StatusCode: 200}} + m.ui.httpLogState.cursor = 0 + m.ui.httpLogState.autoScroll = false + + keyMsg := tea.KeyMsg{Type: tea.KeyDown} + m.handleHTTPLogKeys(keyMsg) + + assert.True(t, m.ui.httpLogState.autoScroll) +} + +func TestHandleHTTPLogKeys_GoToTop_G(t *testing.T) { + m := newModelWithHTTPLog() + // StatusCode must be non-zero or getFilteredEntries will skip entries. + m.ui.httpLogState.entries = []HTTPLogEntry{ + {Method: "GET", StatusCode: 200}, {Method: "POST", StatusCode: 200}, {Method: "PUT", StatusCode: 200}, + } + m.ui.httpLogState.cursor = 2 + + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("g")} + m.handleHTTPLogKeys(keyMsg) + + assert.Equal(t, 0, m.ui.httpLogState.cursor) + assert.False(t, m.ui.httpLogState.autoScroll) +} + +func TestHandleHTTPLogKeys_GoToBottom_CapitalG(t *testing.T) { + m := newModelWithHTTPLog() + // StatusCode must be non-zero or getFilteredEntries will skip entries. + m.ui.httpLogState.entries = []HTTPLogEntry{ + {Method: "GET", StatusCode: 200}, {Method: "POST", StatusCode: 200}, {Method: "PUT", StatusCode: 200}, + } + m.ui.httpLogState.cursor = 0 + + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("G")} + m.handleHTTPLogKeys(keyMsg) + + assert.Equal(t, 2, m.ui.httpLogState.cursor) + assert.True(t, m.ui.httpLogState.autoScroll) +} + +func TestHandleHTTPLogKeys_ToggleAutoScroll(t *testing.T) { + m := newModelWithHTTPLog() + m.ui.httpLogState.autoScroll = false + + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")} + m.handleHTTPLogKeys(keyMsg) + assert.True(t, m.ui.httpLogState.autoScroll) + + m.handleHTTPLogKeys(keyMsg) + assert.False(t, m.ui.httpLogState.autoScroll) +} + +func TestHandleHTTPLogKeys_PgUpDown_List(t *testing.T) { + m := newModelWithHTTPLog() + entries := make([]HTTPLogEntry, 30) + for i := range entries { + // StatusCode must be non-zero or getFilteredEntries will skip entries. + entries[i] = HTTPLogEntry{Method: "GET", StatusCode: 200} + } + m.ui.httpLogState.entries = entries + m.ui.httpLogState.cursor = 25 + + keyMsg := tea.KeyMsg{Type: tea.KeyPgUp} + m.handleHTTPLogKeys(keyMsg) + assert.Equal(t, 5, m.ui.httpLogState.cursor) + assert.False(t, m.ui.httpLogState.autoScroll) + + keyMsg = tea.KeyMsg{Type: tea.KeyPgDown} + m.handleHTTPLogKeys(keyMsg) + // PgDown +20 from 5 = 25; index 25 is in range (30 items, indices 0-29). + assert.Equal(t, 25, m.ui.httpLogState.cursor) +} + +// ---- handleHTTPLogKeys: filter active (text input mode) ---------------- + +func TestHandleHTTPLogKeys_FilterActive_Typing(t *testing.T) { + m := newModelWithHTTPLog() + m.ui.httpLogState.filterActive = true + m.ui.httpLogState.filterText = "te" + + keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("s")} + m.handleHTTPLogKeys(keyMsg) + + assert.Equal(t, "tes", m.ui.httpLogState.filterText) +} + +func TestHandleHTTPLogKeys_FilterActive_Backspace(t *testing.T) { + m := newModelWithHTTPLog() + m.ui.httpLogState.filterActive = true + m.ui.httpLogState.filterText = "tes" + + keyMsg := tea.KeyMsg{Type: tea.KeyBackspace} + m.handleHTTPLogKeys(keyMsg) + + assert.Equal(t, "te", m.ui.httpLogState.filterText) +} + +func TestHandleHTTPLogKeys_FilterActive_EscClearsFilter(t *testing.T) { + m := newModelWithHTTPLog() + m.ui.httpLogState.filterActive = true + m.ui.httpLogState.filterText = "test" + + keyMsg := tea.KeyMsg{Type: tea.KeyEsc} + m.handleHTTPLogKeys(keyMsg) + + assert.False(t, m.ui.httpLogState.filterActive) + assert.Empty(t, m.ui.httpLogState.filterText) +} + +func TestHandleHTTPLogKeys_FilterActive_EnterConfirms(t *testing.T) { + m := newModelWithHTTPLog() + m.ui.httpLogState.filterActive = true + m.ui.httpLogState.filterText = "api" + + keyMsg := tea.KeyMsg{Type: tea.KeyEnter} + m.handleHTTPLogKeys(keyMsg) + + assert.False(t, m.ui.httpLogState.filterActive) + assert.Equal(t, "api", m.ui.httpLogState.filterText) +} + +// ---- handleHTTPLogEntry: response merging, capping --------------------- + +func TestHandleHTTPLogEntry_ResponseMerging(t *testing.T) { + m := newModelWithHTTPLog() + reqID := "req-123" + + // Add a request entry first. + requestEntry := HTTPLogEntry{ + Direction: "request", + Method: "GET", + Path: "/api/test", + RequestID: reqID, + } + m.ui.httpLogState.entries = []HTTPLogEntry{requestEntry} + + // Now handle the response. + responseEntry := HTTPLogEntry{ + Direction: "response", + RequestID: reqID, + StatusCode: 200, + LatencyMs: 50, + BodySize: 100, + ResponseBody: "ok", + } + m.handleHTTPLogEntry(HTTPLogEntryMsg{Entry: responseEntry}) + + // Should be merged into the single request entry. + assert.Len(t, m.ui.httpLogState.entries, 1) + assert.Equal(t, 200, m.ui.httpLogState.entries[0].StatusCode) + assert.Equal(t, "response", m.ui.httpLogState.entries[0].Direction) +} + +func TestHandleHTTPLogEntry_UnmatchedResponse_Appended(t *testing.T) { + m := newModelWithHTTPLog() + + responseEntry := HTTPLogEntry{ + Direction: "response", + RequestID: "no-match-id", + StatusCode: 404, + } + m.handleHTTPLogEntry(HTTPLogEntryMsg{Entry: responseEntry}) + + assert.Len(t, m.ui.httpLogState.entries, 1) +} + +func TestHandleHTTPLogEntry_CapsAt10000(t *testing.T) { + m := newModelWithHTTPLog() + + // Fill to 10000. + entries := make([]HTTPLogEntry, 10000) + for i := range entries { + entries[i] = HTTPLogEntry{Method: "GET", Path: "/old"} + } + m.ui.httpLogState.entries = entries + + // Add one more. + m.handleHTTPLogEntry(HTTPLogEntryMsg{Entry: HTTPLogEntry{Method: "POST", Path: "/new"}}) + + assert.Len(t, m.ui.httpLogState.entries, 10000) + // The newest entry should be at the end. + last := m.ui.httpLogState.entries[len(m.ui.httpLogState.entries)-1] + assert.Equal(t, "/new", last.Path) +} + +// ---- handleContextsLoaded: without discovery (nil) --------------------- + +// TestHandleContextsLoaded_NilDiscovery_UsesMessagesDirectly verifies that +// when discovery is nil (no kubeconfig), the contexts from the message are +// used as-is without reordering. +func TestHandleContextsLoaded_NilDiscovery_UsesMessagesDirectly(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + // No discovery set — contexts must still be loaded from the message. + ui.mu.Lock() + ui.viewMode = ViewModeAddWizard + ui.addWizard = newAddWizardState() + ui.addWizard.loading = true + ui.mu.Unlock() + + m := model{ui: ui, termWidth: 120, termHeight: 40} + + msg := ContextsLoadedMsg{ + contexts: []string{"default", "production", "staging"}, + } + m.handleContextsLoaded(msg) + + ui.mu.RLock() + defer ui.mu.RUnlock() + assert.Contains(t, ui.addWizard.contexts, "staging") + assert.Contains(t, ui.addWizard.contexts, "default") +} + +// ---- handlePodsLoaded: edit mode port detection ------------------------ + +func TestHandlePodsLoaded_EditMode_DetectsPorts(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeAddWizard + w := newAddWizardState() + w.step = StepEnterRemotePort + w.isEditing = true + w.remotePort = 8080 + ui.addWizard = w + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + pods := []k8s.PodInfo{ + { + Name: "my-pod", + Containers: []k8s.ContainerInfo{ + {Name: "main", Ports: []k8s.PortInfo{{Port: 8080}}}, + }, + }, + } + m.handlePodsLoaded(PodsLoadedMsg{pods: pods}) + + ui.mu.RLock() + assert.NotEmpty(t, ui.addWizard.detectedPorts) + ui.mu.RUnlock() +} + +// ---- handleServicesLoaded: edit mode port detection -------------------- + +func TestHandleServicesLoaded_EditMode_DetectsPorts(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeAddWizard + w := newAddWizardState() + w.step = StepEnterRemotePort + w.isEditing = true + w.resourceValue = "api-svc" + w.remotePort = 80 + ui.addWizard = w + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + services := []k8s.ServiceInfo{ + {Name: "api-svc", Ports: []k8s.PortInfo{{Port: 80}}}, + } + m.handleServicesLoaded(ServicesLoadedMsg{services: services}) + + ui.mu.RLock() + assert.NotEmpty(t, ui.addWizard.detectedPorts) + ui.mu.RUnlock() +} + +// ---- handleForwardSaved: error path ------------------------------------ + +func TestHandleForwardSaved_Error(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeAddWizard + ui.addWizard = newAddWizardState() + ui.addWizard.step = StepConfirmation + ui.addWizard.loading = true + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + msg := ForwardSavedMsg{success: false, err: assert.AnError} + m.handleForwardSaved(msg) + + // On failure, step stays at StepConfirmation (not moved to StepSuccess). + // loading is cleared and error is populated. + ui.mu.RLock() + assert.Equal(t, StepConfirmation, ui.addWizard.step) + assert.NotNil(t, ui.addWizard.error) + assert.False(t, ui.addWizard.loading) + ui.mu.RUnlock() +} + +// ---- handleSelectorValidated: invalid path ---------------------------- + +func TestHandleSelectorValidated_Invalid(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeAddWizard + ui.addWizard = newAddWizardState() + ui.addWizard.loading = true + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + msg := SelectorValidatedMsg{valid: false, err: assert.AnError} + m.handleSelectorValidated(msg) + + ui.mu.RLock() + assert.Nil(t, ui.addWizard.matchingPods) + assert.NotNil(t, ui.addWizard.error) + ui.mu.RUnlock() +} + +// ---- isFilterableStep -------------------------------------------------- + +func TestIsFilterableStep(t *testing.T) { + assert.True(t, isFilterableStep(StepSelectContext)) + assert.True(t, isFilterableStep(StepSelectNamespace)) + assert.True(t, isFilterableStep(StepEnterResource)) + assert.False(t, isFilterableStep(StepEnterRemotePort)) + assert.False(t, isFilterableStep(StepEnterLocalPort)) + assert.False(t, isFilterableStep(StepConfirmation)) + assert.False(t, isFilterableStep(StepSuccess)) +} + +// ---- clearCopyMessageMsg handling -------------------------------------- + +func TestModel_Update_ClearCopyMessage(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeHTTPLog + ui.httpLogState = newHTTPLogState("fwd-id", "alias") + ui.httpLogState.copyMessage = "Copied!" + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + newM, _ := m.Update(clearCopyMessageMsg{}) + updatedUI := newM.(model).ui + updatedUI.mu.RLock() + assert.Empty(t, updatedUI.httpLogState.copyMessage) + updatedUI.mu.RUnlock() +} + +// ---- ForwardAddMsg / ForwardUpdateMsg / ForwardRemoveMsg routing -------- + +func TestModel_Update_ForwardMessages_NoPanic(t *testing.T) { + m := newTestModelWithForward() + + msgs := []tea.Msg{ + ForwardAddMsg{ID: "x", Forward: &ForwardStatus{}}, + ForwardUpdateMsg{ID: "test-id", Status: "Active"}, + ForwardErrorMsg{ID: "test-id", Error: "boom"}, + ForwardRemoveMsg{ID: "test-id"}, + } + + for _, msg := range msgs { + assert.NotPanics(t, func() { + _, _ = m.Update(msg) + }) + } +} + +// ---- SetHTTPLogSubscriber ----------------------------------------------- + +func TestBubbleTeaUI_SetHTTPLogSubscriber(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + mock := NewMockHTTPLogSubscriber() + ui.SetHTTPLogSubscriber(mock.GetSubscriberFunc()) + + ui.mu.RLock() + assert.NotNil(t, ui.httpLogSubscriber) + ui.mu.RUnlock() +} + +// ---- ResourceType.String / Description --------------------------------- + +func TestResourceType_StringAndDescription(t *testing.T) { + tests := []struct { + expStr string + expDescContains string + rt ResourceType + }{ + {"Pod (by name prefix)", "specific", ResourceTypePodPrefix}, + {"Pod (by label selector)", "survives", ResourceTypePodSelector}, + {"Service", "stable", ResourceTypeService}, + {"Unknown", "", ResourceType(99)}, + } + + for _, tt := range tests { + t.Run(tt.expStr, func(t *testing.T) { + assert.Equal(t, tt.expStr, tt.rt.String()) + desc := tt.rt.Description() + if tt.expDescContains != "" { + assert.Contains(t, desc, tt.expDescContains) + } + }) + } +} + +// ---- wizard_commands: saveForwardCmd / removeForwardsCmd / removeForwardByIDCmd +// These use *config.Mutator (concrete type), so we create a real mutator +// backed by a temp config file. + +func newTempMutator(t *testing.T) *config.Mutator { + t.Helper() + tmpDir := t.TempDir() + cfgPath := tmpDir + "/.kportal.yaml" + if err := os.WriteFile(cfgPath, []byte("contexts: []\n"), 0o600); err != nil { + t.Fatalf("create temp config: %v", err) + } + return config.NewMutator(cfgPath) +} + +func TestSaveForwardCmd_Success(t *testing.T) { + mutator := newTempMutator(t) + fwd := config.Forward{Resource: "pod/app", Port: 80, LocalPort: 18081} + cmd := saveForwardCmd(mutator, "ctx", "ns", fwd) + msg := cmd() + savedMsg, ok := msg.(ForwardSavedMsg) + require.True(t, ok) + assert.True(t, savedMsg.success) +} + +func TestRemoveForwardsCmd_Success(t *testing.T) { + mutator := newTempMutator(t) + // Add two forwards: removing one must leave the context non-empty to pass validation. + fwd := config.Forward{Resource: "pod/app", Port: 80, LocalPort: 18082} + fwdKeep := config.Forward{Resource: "pod/app2", Port: 80, LocalPort: 18090} + require.NoError(t, mutator.AddForward("ctx", "ns", fwd)) + require.NoError(t, mutator.AddForward("ctx", "ns", fwdKeep)) + + forwards := []RemovableForward{{ID: "ctx/ns/pod/app:18082", Alias: "app"}} + cmd := removeForwardsCmd(mutator, forwards) + msg := cmd() + removedMsg, ok := msg.(ForwardsRemovedMsg) + require.True(t, ok) + assert.True(t, removedMsg.success) + assert.Equal(t, 1, removedMsg.count) +} + +func TestRemoveForwardByIDCmd_Success(t *testing.T) { + mutator := newTempMutator(t) + // Add two forwards: removing one must leave the context non-empty to pass validation. + fwd := config.Forward{Resource: "pod/app", Port: 80, LocalPort: 18083} + fwdKeep := config.Forward{Resource: "pod/app2", Port: 80, LocalPort: 18091} + require.NoError(t, mutator.AddForward("ctx", "ns", fwd)) + require.NoError(t, mutator.AddForward("ctx", "ns", fwdKeep)) + + id := "ctx/ns/pod/app:18083" + cmd := removeForwardByIDCmd(mutator, id) + msg := cmd() + removedMsg, ok := msg.(ForwardsRemovedMsg) + require.True(t, ok) + assert.True(t, removedMsg.success) +} diff --git a/internal/ui/wizard_views_test.go b/internal/ui/wizard_views_test.go new file mode 100644 index 0000000..92f1b7a --- /dev/null +++ b/internal/ui/wizard_views_test.go @@ -0,0 +1,1467 @@ +package ui + +import ( + "strings" + "testing" + + "github.com/lukaszraczylo/kportal/internal/benchmark" + "github.com/lukaszraczylo/kportal/internal/config" + "github.com/lukaszraczylo/kportal/internal/k8s" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newModelWithWizard returns a model with an initialised AddWizardState at the given step. +func newModelWithWizard(step AddWizardStep) model { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeAddWizard + w := newAddWizardState() + w.step = step + w.selectedContext = "my-ctx" + w.selectedNamespace = "default" + w.resourceValue = "my-app" + w.remotePort = 8080 + w.localPort = 8081 + w.contexts = []string{"my-ctx", "other-ctx"} + w.namespaces = []string{"default", "kube-system"} + ui.addWizard = w + ui.mu.Unlock() + return model{ui: ui, termWidth: 120, termHeight: 40} +} + +// ----- renderAddWizard (dispatch) ----------------------------------------- + +func TestRenderAddWizard_NilWizard(t *testing.T) { + m := newTestModel() + result := m.renderAddWizard() + assert.Empty(t, result) +} + +// ----- renderSelectContext ------------------------------------------------ + +func TestRenderSelectContext_Loading(t *testing.T) { + m := newModelWithWizard(StepSelectContext) + m.ui.addWizard.loading = true + result := m.renderSelectContext() + assert.Contains(t, result, "Loading") +} + +func TestRenderSelectContext_Error(t *testing.T) { + m := newModelWithWizard(StepSelectContext) + m.ui.addWizard.error = assert.AnError + result := m.renderSelectContext() + assert.Contains(t, result, "Error") +} + +func TestRenderSelectContext_NoContexts(t *testing.T) { + m := newModelWithWizard(StepSelectContext) + m.ui.addWizard.contexts = []string{} + result := m.renderSelectContext() + assert.Contains(t, result, "No contexts") +} + +func TestRenderSelectContext_WithContexts(t *testing.T) { + m := newModelWithWizard(StepSelectContext) + result := m.renderSelectContext() + assert.Contains(t, result, "my-ctx") + assert.Contains(t, result, "(current)") +} + +func TestRenderSelectContext_WithSearchFilter(t *testing.T) { + m := newModelWithWizard(StepSelectContext) + m.ui.addWizard.searchFilter = "my" + result := m.renderSelectContext() + assert.Contains(t, result, "Filter: ") +} + +func TestRenderSelectContext_FilterNoMatch(t *testing.T) { + m := newModelWithWizard(StepSelectContext) + m.ui.addWizard.searchFilter = "zzz" + result := m.renderSelectContext() + assert.Contains(t, result, "No matching") +} + +func TestRenderSelectContext_ScrollIndicators(t *testing.T) { + m := newModelWithWizard(StepSelectContext) + // Create many contexts to trigger scroll. + many := make([]string, 30) + for i := range many { + many[i] = "ctx" + } + m.ui.addWizard.contexts = many + m.ui.addWizard.scrollOffset = 5 + result := m.renderSelectContext() + assert.Contains(t, result, "↑ More above ↑") + assert.Contains(t, result, "↓ More below ↓") +} + +// ----- renderSelectNamespace --------------------------------------------- + +func TestRenderSelectNamespace_Loading(t *testing.T) { + m := newModelWithWizard(StepSelectNamespace) + m.ui.addWizard.loading = true + result := m.renderSelectNamespace() + assert.Contains(t, result, "Loading namespaces") +} + +func TestRenderSelectNamespace_Error(t *testing.T) { + m := newModelWithWizard(StepSelectNamespace) + m.ui.addWizard.error = assert.AnError + result := m.renderSelectNamespace() + assert.Contains(t, result, "Error") + assert.Contains(t, result, "unreachable") +} + +func TestRenderSelectNamespace_NoNamespaces(t *testing.T) { + m := newModelWithWizard(StepSelectNamespace) + m.ui.addWizard.namespaces = []string{} + result := m.renderSelectNamespace() + assert.Contains(t, result, "No namespaces") +} + +func TestRenderSelectNamespace_WithNamespaces(t *testing.T) { + m := newModelWithWizard(StepSelectNamespace) + result := m.renderSelectNamespace() + assert.Contains(t, result, "default") +} + +func TestRenderSelectNamespace_FilterNoMatch(t *testing.T) { + m := newModelWithWizard(StepSelectNamespace) + m.ui.addWizard.searchFilter = "zzz" + result := m.renderSelectNamespace() + assert.Contains(t, result, "No matching") +} + +func TestRenderSelectNamespace_WithSearchFilter(t *testing.T) { + m := newModelWithWizard(StepSelectNamespace) + m.ui.addWizard.searchFilter = "def" + result := m.renderSelectNamespace() + assert.Contains(t, result, "Filter: ") +} + +// ----- renderSelectResourceType ------------------------------------------ + +func TestRenderSelectResourceType_AllTypes(t *testing.T) { + m := newModelWithWizard(StepSelectResourceType) + result := m.renderSelectResourceType() + assert.Contains(t, result, "Pod (by name prefix)") + assert.Contains(t, result, "Pod (by label selector)") + assert.Contains(t, result, "Service") +} + +func TestRenderSelectResourceType_CursorHighlight(t *testing.T) { + m := newModelWithWizard(StepSelectResourceType) + m.ui.addWizard.cursor = 1 + result := m.renderSelectResourceType() + // Description of second type should be shown. + assert.Contains(t, result, "Flexible") +} + +// ----- renderEnterResource ----------------------------------------------- + +func TestRenderEnterResource_PodPrefix_Loading(t *testing.T) { + m := newModelWithWizard(StepEnterResource) + m.ui.addWizard.selectedResourceType = ResourceTypePodPrefix + m.ui.addWizard.loading = true + result := m.renderEnterResource() + assert.Contains(t, result, "Loading pods") +} + +func TestRenderEnterResource_PodPrefix_WithPods(t *testing.T) { + m := newModelWithWizard(StepEnterResource) + m.ui.addWizard.selectedResourceType = ResourceTypePodPrefix + m.ui.addWizard.pods = []k8s.PodInfo{{Name: "my-app-abc"}, {Name: "my-app-def"}} + m.ui.addWizard.textInput = "my-app" + result := m.renderEnterResource() + assert.Contains(t, result, "my-app-abc") + assert.Contains(t, result, "Matches") +} + +func TestRenderEnterResource_PodPrefix_NoMatchingPods(t *testing.T) { + m := newModelWithWizard(StepEnterResource) + m.ui.addWizard.selectedResourceType = ResourceTypePodPrefix + m.ui.addWizard.pods = []k8s.PodInfo{{Name: "other-pod"}} + m.ui.addWizard.textInput = "nomatch" + result := m.renderEnterResource() + assert.Contains(t, result, "No matching pods") +} + +func TestRenderEnterResource_PodPrefix_MorePodsIndicator(t *testing.T) { + m := newModelWithWizard(StepEnterResource) + m.ui.addWizard.selectedResourceType = ResourceTypePodPrefix + pods := make([]k8s.PodInfo, 10) + for i := range pods { + pods[i] = k8s.PodInfo{Name: "my-pod"} + } + m.ui.addWizard.pods = pods + result := m.renderEnterResource() + assert.Contains(t, result, "more") +} + +func TestRenderEnterResource_PodSelector_WithMatchingPods(t *testing.T) { + m := newModelWithWizard(StepEnterResource) + m.ui.addWizard.selectedResourceType = ResourceTypePodSelector + m.ui.addWizard.matchingPods = []k8s.PodInfo{{Name: "pod-1"}, {Name: "pod-2"}, {Name: "pod-3"}, {Name: "pod-4"}} + result := m.renderEnterResource() + assert.Contains(t, result, "Found 4 matching") +} + +func TestRenderEnterResource_PodSelector_Error(t *testing.T) { + m := newModelWithWizard(StepEnterResource) + m.ui.addWizard.selectedResourceType = ResourceTypePodSelector + m.ui.addWizard.error = assert.AnError + result := m.renderEnterResource() + assert.Contains(t, result, "Invalid selector") +} + +func TestRenderEnterResource_PodSelector_Loading(t *testing.T) { + m := newModelWithWizard(StepEnterResource) + m.ui.addWizard.selectedResourceType = ResourceTypePodSelector + m.ui.addWizard.loading = true + result := m.renderEnterResource() + assert.Contains(t, result, "Validating") +} + +func TestRenderEnterResource_Service_Loading(t *testing.T) { + m := newModelWithWizard(StepEnterResource) + m.ui.addWizard.selectedResourceType = ResourceTypeService + m.ui.addWizard.loading = true + result := m.renderEnterResource() + assert.Contains(t, result, "Loading services") +} + +func TestRenderEnterResource_Service_Empty(t *testing.T) { + m := newModelWithWizard(StepEnterResource) + m.ui.addWizard.selectedResourceType = ResourceTypeService + result := m.renderEnterResource() + assert.Contains(t, result, "No services") +} + +func TestRenderEnterResource_Service_WithServices(t *testing.T) { + m := newModelWithWizard(StepEnterResource) + m.ui.addWizard.selectedResourceType = ResourceTypeService + m.ui.addWizard.services = []k8s.ServiceInfo{{Name: "api-svc"}, {Name: "db-svc"}} + result := m.renderEnterResource() + assert.Contains(t, result, "api-svc") +} + +func TestRenderEnterResource_Service_FilterNoMatch(t *testing.T) { + m := newModelWithWizard(StepEnterResource) + m.ui.addWizard.selectedResourceType = ResourceTypeService + m.ui.addWizard.services = []k8s.ServiceInfo{{Name: "api-svc"}} + m.ui.addWizard.searchFilter = "zzz" + result := m.renderEnterResource() + assert.Contains(t, result, "No matching services") +} + +func TestRenderEnterResource_Service_WithSearchFilter(t *testing.T) { + m := newModelWithWizard(StepEnterResource) + m.ui.addWizard.selectedResourceType = ResourceTypeService + m.ui.addWizard.services = []k8s.ServiceInfo{{Name: "api-svc"}} + m.ui.addWizard.searchFilter = "api" + result := m.renderEnterResource() + assert.Contains(t, result, "Filter: ") +} + +// ----- renderEnterRemotePort --------------------------------------------- + +func TestRenderEnterRemotePort_TextMode(t *testing.T) { + m := newModelWithWizard(StepEnterRemotePort) + m.ui.addWizard.inputMode = InputModeText + result := m.renderEnterRemotePort() + assert.Contains(t, result, "Remote port:") +} + +func TestRenderEnterRemotePort_TextMode_Error(t *testing.T) { + m := newModelWithWizard(StepEnterRemotePort) + m.ui.addWizard.inputMode = InputModeText + m.ui.addWizard.error = assert.AnError + result := m.renderEnterRemotePort() + assert.Contains(t, result, "✗") +} + +func TestRenderEnterRemotePort_TextMode_WithInput(t *testing.T) { + m := newModelWithWizard(StepEnterRemotePort) + m.ui.addWizard.inputMode = InputModeText + m.ui.addWizard.textInput = "8080" + result := m.renderEnterRemotePort() + assert.Contains(t, result, "Press Enter") +} + +func TestRenderEnterRemotePort_ListMode_WithPorts(t *testing.T) { + m := newModelWithWizard(StepEnterRemotePort) + m.ui.addWizard.inputMode = InputModeList + m.ui.addWizard.detectedPorts = []k8s.PortInfo{ + {Port: 80, Name: "http"}, + {Port: 8080, TargetPort: 9090, Name: "grpc"}, + } + result := m.renderEnterRemotePort() + assert.Contains(t, result, "Manual entry") + assert.Contains(t, result, "80") +} + +func TestRenderEnterRemotePort_ListMode_ScrollIndicators(t *testing.T) { + m := newModelWithWizard(StepEnterRemotePort) + m.ui.addWizard.inputMode = InputModeList + ports := make([]k8s.PortInfo, 25) + for i := range ports { + ports[i] = k8s.PortInfo{Port: int32(8000 + i)} //nolint:gosec // safe: i ≤ 24, no overflow + } + m.ui.addWizard.detectedPorts = ports + m.ui.addWizard.scrollOffset = 3 + result := m.renderEnterRemotePort() + assert.Contains(t, result, "↑ More above ↑") +} + +func TestRenderEnterRemotePort_TextMode_DetectedPortsShown(t *testing.T) { + // Text mode but with detected ports — shows them as reference. + m := newModelWithWizard(StepEnterRemotePort) + m.ui.addWizard.inputMode = InputModeText + m.ui.addWizard.detectedPorts = []k8s.PortInfo{{Port: 80}} + result := m.renderEnterRemotePort() + assert.Contains(t, result, "Detected ports") +} + +func TestRenderEnterRemotePort_Selector(t *testing.T) { + m := newModelWithWizard(StepEnterRemotePort) + m.ui.addWizard.inputMode = InputModeText + m.ui.addWizard.selector = "app=my-app" + result := m.renderEnterRemotePort() + assert.Contains(t, result, "app=my-app") +} + +// ----- renderEnterLocalPort ---------------------------------------------- + +func TestRenderEnterLocalPort_Loading(t *testing.T) { + m := newModelWithWizard(StepEnterLocalPort) + m.ui.addWizard.loading = true + result := m.renderEnterLocalPort() + assert.Contains(t, result, "Checking availability") +} + +func TestRenderEnterLocalPort_Error(t *testing.T) { + m := newModelWithWizard(StepEnterLocalPort) + m.ui.addWizard.error = assert.AnError + result := m.renderEnterLocalPort() + assert.Contains(t, result, "✗") +} + +func TestRenderEnterLocalPort_PortAvailable(t *testing.T) { + m := newModelWithWizard(StepEnterLocalPort) + m.ui.addWizard.portAvailable = true + m.ui.addWizard.portCheckMsg = "✓ Port 8081 available" + result := m.renderEnterLocalPort() + assert.Contains(t, result, "✓") +} + +func TestRenderEnterLocalPort_PortUnavailable(t *testing.T) { + m := newModelWithWizard(StepEnterLocalPort) + m.ui.addWizard.portAvailable = false + m.ui.addWizard.portCheckMsg = "✗ Port 8081 in use" + result := m.renderEnterLocalPort() + assert.Contains(t, result, "in use") +} + +func TestRenderEnterLocalPort_WithInput(t *testing.T) { + m := newModelWithWizard(StepEnterLocalPort) + m.ui.addWizard.textInput = "8081" + result := m.renderEnterLocalPort() + assert.Contains(t, result, "Press Enter") +} + +func TestRenderEnterLocalPort_WithSelector(t *testing.T) { + m := newModelWithWizard(StepEnterLocalPort) + m.ui.addWizard.selector = "app=db" + result := m.renderEnterLocalPort() + assert.Contains(t, result, "app=db") +} + +// ----- renderConfirmation ------------------------------------------------ + +func TestRenderConfirmation_DefaultView(t *testing.T) { + m := newModelWithWizard(StepConfirmation) + m.ui.addWizard.confirmationFocus = FocusButtons + m.ui.addWizard.cursor = 0 + result := m.renderConfirmation() + assert.Contains(t, result, "Add to .kportal.yaml") + assert.Contains(t, result, "my-ctx") + assert.Contains(t, result, "default") +} + +func TestRenderConfirmation_AliasFocus(t *testing.T) { + m := newModelWithWizard(StepConfirmation) + m.ui.addWizard.confirmationFocus = FocusAlias + result := m.renderConfirmation() + assert.Contains(t, result, "█") // cursor in alias field +} + +func TestRenderConfirmation_ButtonsCancelSelected(t *testing.T) { + m := newModelWithWizard(StepConfirmation) + m.ui.addWizard.confirmationFocus = FocusButtons + m.ui.addWizard.cursor = 1 // Cancel + result := m.renderConfirmation() + assert.Contains(t, result, "Cancel") +} + +func TestRenderConfirmation_HTTPLogEnabled(t *testing.T) { + m := newModelWithWizard(StepConfirmation) + m.ui.addWizard.httpLog = true + result := m.renderConfirmation() + assert.Contains(t, result, "[x] enabled") +} + +func TestRenderConfirmation_HTTPLogDisabled(t *testing.T) { + m := newModelWithWizard(StepConfirmation) + m.ui.addWizard.httpLog = false + result := m.renderConfirmation() + assert.Contains(t, result, "[ ] disabled") +} + +func TestRenderConfirmation_SelectorResource(t *testing.T) { + m := newModelWithWizard(StepConfirmation) + m.ui.addWizard.selector = "app=myapp" + result := m.renderConfirmation() + assert.Contains(t, result, "selector") +} + +func TestRenderConfirmation_ServiceResource(t *testing.T) { + m := newModelWithWizard(StepConfirmation) + m.ui.addWizard.selectedResourceType = ResourceTypeService + result := m.renderConfirmation() + assert.Contains(t, result, "service/") +} + +func TestRenderConfirmation_PodPrefixResource(t *testing.T) { + m := newModelWithWizard(StepConfirmation) + m.ui.addWizard.selectedResourceType = ResourceTypePodPrefix + result := m.renderConfirmation() + assert.Contains(t, result, "pod/") +} + +// ----- renderSuccess ----------------------------------------------------- + +func TestRenderSuccess_Success(t *testing.T) { + m := newModelWithWizard(StepSuccess) + m.ui.addWizard.alias = "my-alias" + result := m.renderSuccess() + assert.Contains(t, result, "Success") + assert.Contains(t, result, "Add another") + assert.Contains(t, result, "Return to main view") +} + +func TestRenderSuccess_Error(t *testing.T) { + m := newModelWithWizard(StepSuccess) + m.ui.addWizard.error = assert.AnError + result := m.renderSuccess() + assert.Contains(t, result, "Error") +} + +func TestRenderSuccess_CursorOnReturn(t *testing.T) { + m := newModelWithWizard(StepSuccess) + m.ui.addWizard.cursor = 1 + result := m.renderSuccess() + assert.Contains(t, result, "▸ Return to main view") +} + +func TestRenderSuccess_WithAlias(t *testing.T) { + m := newModelWithWizard(StepSuccess) + m.ui.addWizard.alias = "my-svc" + result := m.renderSuccess() + assert.Contains(t, result, "my-svc") +} + +// ----- renderAddWizard dispatch ------------------------------------------ + +func TestRenderAddWizard_UnknownStep(t *testing.T) { + m := newModelWithWizard(AddWizardStep(99)) + result := m.renderAddWizard() + assert.Contains(t, result, "Unknown step") +} + +// ----- renderRemoveWizard ------------------------------------------------ + +func TestRenderRemoveWizard_NilWizard(t *testing.T) { + m := newTestModel() + result := m.renderRemoveWizard() + assert.Empty(t, result) +} + +func TestRenderRemoveSelection_ShowsForwards(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeRemoveWizard + ui.removeWizard = &RemoveWizardState{ + forwards: []RemovableForward{ + {ID: "f1", Alias: "alpha", Port: 80, LocalPort: 8080, Context: "ctx", Namespace: "ns", Resource: "pod/alpha"}, + {ID: "f2", Alias: "beta", Port: 81, LocalPort: 8081, Context: "ctx", Namespace: "ns", Resource: "pod/beta"}, + }, + selected: map[int]bool{0: true}, + cursor: 0, + } + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + result := m.renderRemoveSelection() + assert.Contains(t, result, "alpha") + assert.Contains(t, result, "beta") + assert.Contains(t, result, "[✓]") + assert.Contains(t, result, "1 of 2 selected") +} + +func TestRenderRemoveConfirmation_Shows(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeRemoveWizard + ui.removeWizard = &RemoveWizardState{ + forwards: []RemovableForward{ + {ID: "f1", Alias: "alpha", Port: 80, LocalPort: 8080, Context: "ctx", Namespace: "ns", Resource: "pod/alpha"}, + }, + selected: map[int]bool{0: true}, + confirming: true, + } + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + result := m.renderRemoveConfirmation() + assert.Contains(t, result, "alpha") + assert.Contains(t, result, "Yes, remove") + assert.Contains(t, result, "cannot be undone") +} + +func TestRenderRemoveConfirmation_CancelSelected(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeRemoveWizard + ui.removeWizard = &RemoveWizardState{ + forwards: []RemovableForward{{ID: "f1", Alias: "alpha"}}, + selected: map[int]bool{0: true}, + confirming: true, + confirmCursor: 1, + } + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + result := m.renderRemoveConfirmation() + assert.Contains(t, result, "▸ Cancel") +} + +// ----- renderBenchmark --------------------------------------------------- + +func TestRenderBenchmark_NilState(t *testing.T) { + m := newTestModel() + result := m.renderBenchmark() + assert.Empty(t, result) +} + +func TestRenderBenchmarkConfig(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeBenchmark + ui.benchmarkState = newBenchmarkState("fwd-id", "my-svc", 8080) + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + result := m.renderBenchmarkConfig() + assert.Contains(t, result, "URL Path") + assert.Contains(t, result, "Method") + assert.Contains(t, result, "Concurrency") + assert.Contains(t, result, "Requests") + assert.Contains(t, result, "my-svc") +} + +func TestRenderBenchmarkRunning(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeBenchmark + state := newBenchmarkState("fwd-id", "my-svc", 8080) + state.step = BenchmarkStepRunning + state.running = true + state.progress = 30 + state.total = 100 + ui.benchmarkState = state + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + result := m.renderBenchmarkRunning() + assert.Contains(t, result, "Running") + assert.Contains(t, result, "30") + assert.Contains(t, result, "100") +} + +func TestRenderBenchmarkResults_WithError(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeBenchmark + state := newBenchmarkState("fwd-id", "my-svc", 8080) + state.step = BenchmarkStepResults + state.error = assert.AnError + ui.benchmarkState = state + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + result := m.renderBenchmarkResults() + assert.Contains(t, result, "Error") +} + +func TestRenderBenchmarkResults_NilResults(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeBenchmark + state := newBenchmarkState("fwd-id", "my-svc", 8080) + state.step = BenchmarkStepResults + ui.benchmarkState = state + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + result := m.renderBenchmarkResults() + assert.Contains(t, result, "No results") +} + +func TestRenderBenchmarkResults_WithResults(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeBenchmark + state := newBenchmarkState("fwd-id", "my-svc", 8080) + state.step = BenchmarkStepResults + state.results = &BenchmarkResults{ + TotalRequests: 100, + Successful: 95, + Failed: 5, + MinLatency: 1.0, + MaxLatency: 50.0, + AvgLatency: 10.0, + P50Latency: 8.0, + P95Latency: 40.0, + P99Latency: 48.0, + Throughput: 50.0, + BytesRead: 10240, + StatusCodes: map[int]int{200: 95, 500: 5}, + } + ui.benchmarkState = state + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + result := m.renderBenchmarkResults() + assert.Contains(t, result, "Total Requests: 100") + assert.Contains(t, result, "Successful:") + assert.Contains(t, result, "Failed:") + assert.Contains(t, result, "P95") + assert.Contains(t, result, "Status Codes") +} + +func TestRenderBenchmark_Dispatch(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeBenchmark + state := newBenchmarkState("fwd-id", "my-svc", 8080) + state.step = BenchmarkStepConfig + ui.benchmarkState = state + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + result := m.renderBenchmark() + require.NotEmpty(t, result) + assert.Contains(t, result, "URL Path") +} + +// ----- renderHTTPLog ----------------------------------------------------- + +func TestRenderHTTPLog_NilState(t *testing.T) { + m := newTestModel() + result := m.renderHTTPLog() + assert.Empty(t, result) +} + +func TestRenderHTTPLog_NoEntries(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeHTTPLog + ui.httpLogState = newHTTPLogState("fwd-id", "my-svc") + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + result := m.renderHTTPLog() + assert.Contains(t, result, "No HTTP traffic") +} + +func TestRenderHTTPLog_WithEntries(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeHTTPLog + state := newHTTPLogState("fwd-id", "my-svc") + state.entries = []HTTPLogEntry{ + {Method: "GET", Path: "/api/test", StatusCode: 200, Timestamp: "12:00:00"}, + {Method: "POST", Path: "/api/create", StatusCode: 500, Timestamp: "12:00:01"}, + {Method: "PUT", Path: "/api/update", StatusCode: 404, Timestamp: "12:00:02"}, + } + ui.httpLogState = state + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + result := m.renderHTTPLog() + assert.Contains(t, result, "GET") + assert.Contains(t, result, "/api/test") +} + +func TestRenderHTTPLog_FilterActive(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeHTTPLog + state := newHTTPLogState("fwd-id", "my-svc") + state.filterActive = true + state.filterText = "test" + ui.httpLogState = state + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + result := m.renderHTTPLog() + assert.Contains(t, result, "Search: ") +} + +func TestRenderHTTPLog_FilteredEntries(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeHTTPLog + state := newHTTPLogState("fwd-id", "my-svc") + state.entries = []HTTPLogEntry{ + {Method: "GET", Path: "/api/test", StatusCode: 200}, + } + state.filterMode = HTTPLogFilterNon200 + ui.httpLogState = state + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + result := m.renderHTTPLog() + // With Non200 filter active and only 200s, no entries match. + assert.Contains(t, result, "No entries match filter") +} + +func TestRenderHTTPLog_AutoScrollIndicator(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeHTTPLog + state := newHTTPLogState("fwd-id", "my-svc") + state.autoScroll = true + ui.httpLogState = state + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + result := m.renderHTTPLog() + assert.Contains(t, result, "Auto-scroll") +} + +func TestRenderHTTPLog_FilterIndicator(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeHTTPLog + state := newHTTPLogState("fwd-id", "my-svc") + state.filterMode = HTTPLogFilterErrors + ui.httpLogState = state + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + result := m.renderHTTPLog() + assert.Contains(t, result, "Filter:") +} + +func TestRenderHTTPLog_DetailView(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeHTTPLog + state := newHTTPLogState("fwd-id", "my-svc") + state.entries = []HTTPLogEntry{ + { + Method: "GET", + Path: "/detail", + StatusCode: 200, + LatencyMs: 100, + RequestHeaders: map[string]string{ + "Content-Type": "application/json", + }, + ResponseHeaders: map[string]string{ + "X-Request-ID": "abc123", + }, + RequestBody: `{"key": "value"}`, + ResponseBody: `{"result": "ok"}`, + }, + } + state.cursor = 0 + state.showingDetail = true + ui.httpLogState = state + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + result := m.renderHTTPLog() + assert.Contains(t, result, "Request Detail") +} + +// ----- renderHTTPLogDetail ----------------------------------------------- + +func TestRenderHTTPLogDetail_HighLatency(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeHTTPLog + state := newHTTPLogState("fwd-id", "my-svc") + ui.httpLogState = state + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + entry := HTTPLogEntry{ + Method: "GET", + Path: "/slow", + LatencyMs: 2500, // >1000ms → should display as seconds + } + result := m.renderHTTPLogDetail(entry, 120, 40) + assert.Contains(t, result, "2.50s") +} + +func TestRenderHTTPLogDetail_Status500(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + state := newHTTPLogState("fwd-id", "my-svc") + ui.httpLogState = state + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + entry := HTTPLogEntry{Method: "POST", Path: "/fail", StatusCode: 500} + result := m.renderHTTPLogDetail(entry, 120, 40) + assert.Contains(t, result, "500") +} + +func TestRenderHTTPLogDetail_Status400(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + state := newHTTPLogState("fwd-id", "my-svc") + ui.httpLogState = state + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + entry := HTTPLogEntry{Method: "GET", Path: "/notfound", StatusCode: 404} + result := m.renderHTTPLogDetail(entry, 120, 40) + assert.Contains(t, result, "404") +} + +func TestRenderHTTPLogDetail_WithError(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + state := newHTTPLogState("fwd-id", "my-svc") + ui.httpLogState = state + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + entry := HTTPLogEntry{Method: "GET", Path: "/err", Error: "connection reset"} + result := m.renderHTTPLogDetail(entry, 120, 40) + assert.Contains(t, result, "connection reset") +} + +func TestRenderHTTPLogDetail_BinaryRequestBody(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + state := newHTTPLogState("fwd-id", "my-svc") + ui.httpLogState = state + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + entry := HTTPLogEntry{ + Method: "POST", + Path: "/upload", + RequestBody: "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a", + RequestHeaders: map[string]string{ + "Content-Type": "application/octet-stream", + }, + } + result := m.renderHTTPLogDetail(entry, 120, 40) + assert.Contains(t, result, "Binary data") +} + +func TestRenderHTTPLogDetail_CopyMessage(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + state := newHTTPLogState("fwd-id", "my-svc") + state.copyMessage = "Copied!" + ui.httpLogState = state + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + entry := HTTPLogEntry{Method: "GET", Path: "/x"} + result := m.renderHTTPLogDetail(entry, 120, 40) + assert.Contains(t, result, "Copied!") +} + +func TestRenderHTTPLogDetail_ScrollIndicator(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + state := newHTTPLogState("fwd-id", "my-svc") + // Force a long response body to make content exceed viewport. + bodyLines := strings.Repeat("a line of body content\n", 100) + state.entries = []HTTPLogEntry{{ + Method: "GET", + Path: "/long", + StatusCode: 200, + ResponseBody: bodyLines, + ResponseHeaders: map[string]string{ + "Content-Type": "text/plain", + }, + }} + state.detailScroll = 5 + ui.httpLogState = state + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 10} + + entry := state.entries[0] + result := m.renderHTTPLogDetail(entry, 120, 10) + // Should contain a scroll percentage indicator. + assert.Contains(t, result, "%") +} + +// ----- renderMainView helpers ------------------------------------------- + +func TestRenderMainView_EmptyForwards(t *testing.T) { + m := newTestModel() + result := m.renderMainView() + assert.Contains(t, result, "No forwards configured") +} + +func TestRenderMainView_WithForwards(t *testing.T) { + m := newTestModelWithForward() + result := m.renderMainView() + assert.Contains(t, result, "my-app") + assert.Contains(t, result, "CONTEXT") + assert.Contains(t, result, "STATUS") +} + +func TestRenderMainView_WithErrors(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + fwd := &config.Forward{Resource: "pod/my-app", Port: 8080, LocalPort: 8080, Alias: "my-app"} + ui.AddForward("id-1", fwd) + ui.SetError("id-1", "connection refused") + m := model{ui: ui, termWidth: 120, termHeight: 40} + + result := m.renderMainView() + assert.Contains(t, result, "Errors:") + assert.Contains(t, result, "connection refused") +} + +func TestRenderMainView_UpdateAvailable(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.SetUpdateAvailable("2.0.0", "http://example.com") + m := model{ui: ui, termWidth: 120, termHeight: 40} + + result := m.renderMainView() + assert.Contains(t, result, "Update available") + assert.Contains(t, result, "2.0.0") +} + +func TestRenderDeleteConfirmation_YesSelected(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.deleteConfirming = true + ui.deleteConfirmID = "id-1" + ui.deleteConfirmAlias = "my-service" + ui.deleteConfirmCursor = 0 // Yes + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + result := m.renderDeleteConfirmation() + assert.Contains(t, result, "my-service") + assert.Contains(t, result, "Yes") + assert.Contains(t, result, "No") +} + +func TestRenderDeleteConfirmation_NoSelected(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.deleteConfirming = true + ui.deleteConfirmAlias = "my-service" + ui.deleteConfirmCursor = 1 // No + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + result := m.renderDeleteConfirmation() + assert.Contains(t, result, "Yes") + assert.Contains(t, result, "No") +} + +// ----- View() dispatch -------------------------------------------------- + +func TestModelView_MainViewDefault(t *testing.T) { + m := newTestModel() + result := m.View() + assert.NotEmpty(t, result) +} + +func TestModelView_DeleteConfirmationOverlay(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + fwd := &config.Forward{Resource: "pod/app", Port: 8080, LocalPort: 8080, Alias: "app"} + ui.AddForward("id-1", fwd) + ui.mu.Lock() + ui.deleteConfirming = true + ui.deleteConfirmAlias = "app" + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + result := m.View() + assert.Contains(t, result, "Delete Port Forward") +} + +func TestModelView_AddWizardOverlay(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeAddWizard + ui.addWizard = newAddWizardState() + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + result := m.View() + assert.NotEmpty(t, result) +} + +func TestModelView_RemoveWizardOverlay(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeRemoveWizard + ui.removeWizard = &RemoveWizardState{ + forwards: []RemovableForward{{ID: "f1", Alias: "alpha"}}, + selected: map[int]bool{}, + } + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + result := m.View() + assert.Contains(t, result, "alpha") +} + +func TestModelView_BenchmarkOverlay(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeBenchmark + ui.benchmarkState = newBenchmarkState("fwd-id", "my-svc", 8080) + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + result := m.View() + assert.NotEmpty(t, result) +} + +func TestModelView_HTTPLogFullScreen(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeHTTPLog + ui.httpLogState = newHTTPLogState("fwd-id", "my-svc") + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + result := m.View() + assert.Contains(t, result, "HTTP Traffic Log") +} + +func TestModelView_ZeroTermSize(t *testing.T) { + m := newTestModel() + m.termWidth = 0 + m.termHeight = 0 + // Must not panic even with zero term dims. + result := m.View() + assert.NotEmpty(t, result) +} + +// ----- decompressContent ------------------------------------------------- + +func TestDecompressContent_NoEncoding(t *testing.T) { + content := "hello world" + result := decompressContent(content, map[string]string{}) + assert.Equal(t, content, result) +} + +func TestDecompressContent_UnknownEncoding(t *testing.T) { + content := "hello world" + result := decompressContent(content, map[string]string{"Content-Encoding": "br"}) + assert.Equal(t, content, result) +} + +func TestDecompressContent_InvalidGzip(t *testing.T) { + content := "not-gzip-data" + result := decompressContent(content, map[string]string{"Content-Encoding": "gzip"}) + assert.Equal(t, content, result) +} + +func TestDecompressContent_InvalidDeflate(t *testing.T) { + content := "not-deflate-data" + result := decompressContent(content, map[string]string{"Content-Encoding": "deflate"}) + // Should return original since invalid deflate data. + assert.NotEmpty(t, result) +} + +// ----- isBinaryContent --------------------------------------------------- + +func TestIsBinaryContent_ImageContentType(t *testing.T) { + result := isBinaryContent("data", map[string]string{"Content-Type": "image/png"}) + assert.True(t, result) +} + +func TestIsBinaryContent_AudioContentType(t *testing.T) { + result := isBinaryContent("data", map[string]string{"Content-Type": "audio/mp3"}) + assert.True(t, result) +} + +func TestIsBinaryContent_OctetStream(t *testing.T) { + result := isBinaryContent("data", map[string]string{"Content-Type": "application/octet-stream"}) + assert.True(t, result) +} + +func TestIsBinaryContent_TextContent(t *testing.T) { + result := isBinaryContent("hello world", map[string]string{"Content-Type": "text/plain"}) + assert.False(t, result) +} + +func TestIsBinaryContent_BinaryBytes(t *testing.T) { + // Many non-printable bytes should be detected as binary. + binary := strings.Repeat("\x00\x01\x02", 50) + result := isBinaryContent(binary, map[string]string{}) + assert.True(t, result) +} + +func TestIsBinaryContent_EmptyContent(t *testing.T) { + result := isBinaryContent("", map[string]string{}) + assert.False(t, result) +} + +// ----- formatJSONContent ------------------------------------------------ + +func TestFormatJSONContent_ValidJSON(t *testing.T) { + json := `{"key":"value","num":42}` + result := formatJSONContent(json, map[string]string{"Content-Type": "application/json"}) + assert.Contains(t, result, "key") + assert.Contains(t, result, "value") +} + +func TestFormatJSONContent_NotJSON(t *testing.T) { + text := "plain text content" + result := formatJSONContent(text, map[string]string{"Content-Type": "text/plain"}) + assert.Equal(t, text, result) +} + +func TestFormatJSONContent_InvalidJSON(t *testing.T) { + bad := `{"key": invalid}` + result := formatJSONContent(bad, map[string]string{"Content-Type": "application/json"}) + assert.Equal(t, bad, result) +} + +func TestFormatJSONContent_AutoDetect(t *testing.T) { + json := `{"auto": true}` + result := formatJSONContent(json, map[string]string{}) + assert.Contains(t, result, "auto") +} + +func TestFormatJSONContent_ArrayJSON(t *testing.T) { + json := `[1, 2, 3]` + result := formatJSONContent(json, map[string]string{}) + assert.Contains(t, result, "1") +} + +func TestFormatJSONContent_JSONPlusType(t *testing.T) { + json := `{"ok":true}` + result := formatJSONContent(json, map[string]string{"Content-Type": "application/vnd.api+json"}) + assert.Contains(t, result, "ok") +} + +// ----- colorizeJSON / colorizeLine / colorizeValue / isJSONNumber -------- + +func TestColorizeJSON_RoundTrip(t *testing.T) { + json := `{ + "key": "value", + "num": 42, + "flag": true, + "nothing": null, + "arr": [] +}` + result := colorizeJSON(json) + assert.Contains(t, result, "key") + assert.Contains(t, result, "value") + assert.Contains(t, result, "42") +} + +func TestColorizeValue_Null(t *testing.T) { + result := colorizeValue("null") + assert.Contains(t, result, "null") +} + +func TestColorizeValue_Bool(t *testing.T) { + assert.Contains(t, colorizeValue("true"), "true") + assert.Contains(t, colorizeValue("false"), "false") +} + +func TestColorizeValue_String(t *testing.T) { + result := colorizeValue(`"hello"`) + assert.Contains(t, result, "hello") +} + +func TestColorizeValue_Number(t *testing.T) { + result := colorizeValue("42") + assert.Contains(t, result, "42") +} + +func TestColorizeValue_Structural(t *testing.T) { + for _, v := range []string{"{", "}", "[", "]", "{}", "[]"} { + assert.Equal(t, v, colorizeValue(v)) + } +} + +func TestColorizeValue_Unknown(t *testing.T) { + result := colorizeValue("weird_value") + assert.Equal(t, "weird_value", result) +} + +func TestColorizeValue_Empty(t *testing.T) { + result := colorizeValue("") + assert.Equal(t, "", result) +} + +func TestIsJSONNumber(t *testing.T) { + tests := []struct { + input string + expected bool + }{ + {"42", true}, + {"3.14", true}, + {"-5", true}, + {"1e10", true}, + {"1.5E-3", true}, + {"", false}, + {"-", false}, + {"abc", false}, + {"1.2.3", false}, + {"1e", false}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + assert.Equal(t, tt.expected, isJSONNumber(tt.input)) + }) + } +} + +// ----- renderStyles helpers ---------------------------------------------- + +func TestRenderProgress(t *testing.T) { + result := renderProgress(3, 7) + assert.Contains(t, result, "3") + assert.Contains(t, result, "7") +} + +func TestRenderHeader(t *testing.T) { + result := renderHeader("My Title", "Step 1/5") + assert.Contains(t, result, "My Title") + assert.Contains(t, result, "Step 1/5") +} + +func TestRenderHeader_NoProgress(t *testing.T) { + result := renderHeader("Title Only", "") + assert.Contains(t, result, "Title Only") +} + +func TestRenderBreadcrumb(t *testing.T) { + result := renderBreadcrumb("ctx", "ns") + assert.Contains(t, result, "ctx") + assert.Contains(t, result, "ns") +} + +func TestRenderList(t *testing.T) { + items := []string{"alpha", "beta", "gamma"} + result := renderList(items, 1, " ", 0) + assert.Contains(t, result, "alpha") + assert.Contains(t, result, "beta") + assert.Contains(t, result, "gamma") + assert.Contains(t, result, "▸") // cursor on item 1 +} + +func TestRenderList_ScrollIndicators(t *testing.T) { + // Need offset > 0 for the "above" indicator and offset+ViewportHeight < totalItems for the "below" indicator. + // ViewportHeight = 20, so with offset=5 we need totalItems > 25. + items := make([]string, 27) + for i := range items { + items[i] = "item" + } + // cursor=22 is within the visible window [5,25), scrollOffset=5. + // end = 5+20 = 25 < 27, so "↓ More below ↓" is rendered. + result := renderList(items, 22, " ", 5) + assert.Contains(t, result, "↑ More above ↑") + assert.Contains(t, result, "↓ More below ↓") +} + +func TestRenderTextInput_Valid(t *testing.T) { + result := renderTextInput("Label: ", "hello", true) + assert.Contains(t, result, "Label: ") + assert.Contains(t, result, "hello") + assert.Contains(t, result, "█") +} + +func TestRenderTextInput_Invalid(t *testing.T) { + result := renderTextInput("Port: ", "abc", false) + assert.Contains(t, result, "abc") +} + +func TestWizardHelpWidth_Zero(t *testing.T) { + w := wizardHelpWidth(0) + assert.Greater(t, w, 0) +} + +func TestWizardHelpWidth_Narrow(t *testing.T) { + w := wizardHelpWidth(30) + assert.LessOrEqual(t, w, 30) +} + +func TestWizardHelpWidth_Normal(t *testing.T) { + w := wizardHelpWidth(120) + assert.Equal(t, 70, w) // capped at 70 +} + +// ----- overlayContent --------------------------------------------------- + +func TestOverlayContent_BasicPlacement(t *testing.T) { + bg := strings.Repeat("background\n", 20) + overlay := "MODAL" + result := overlayContent(bg, overlay, 80, 20) + assert.Contains(t, result, "MODAL") +} + +// ----- buildFooterLines ------------------------------------------------- + +func TestBuildFooterLines_NormalWidth(t *testing.T) { + m := newTestModel() + lines := m.buildFooterLines(120) + require.NotEmpty(t, lines) + // Last line should contain the total count. + lastLine := lines[len(lines)-1] + assert.Contains(t, lastLine, "Total:") +} + +func TestBuildFooterLines_NarrowTerminal(t *testing.T) { + m := newTestModel() + // Narrow terminal forces wrapping. + lines := m.buildFooterLines(40) + require.NotEmpty(t, lines) +} + +// ----- getTermDimensions ------------------------------------------------ + +func TestGetTermDimensions_Defaults(t *testing.T) { + m := model{ui: NewBubbleTeaUI(nil, "1.0.0"), termWidth: 0, termHeight: 0} + w, h := m.getTermDimensions() + assert.Equal(t, DefaultTermWidth, w) + assert.Equal(t, DefaultTermHeight, h) +} + +func TestGetTermDimensions_Set(t *testing.T) { + m := model{ui: NewBubbleTeaUI(nil, "1.0.0"), termWidth: 200, termHeight: 50} + w, h := m.getTermDimensions() + assert.Equal(t, 200, w) + assert.Equal(t, 50, h) +} + +// ----- getStatusIconAndText --------------------------------------------- + +func TestGetStatusIconAndText(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + fwd := &config.Forward{Resource: "pod/app", Port: 80, LocalPort: 8080} + ui.AddForward("id-1", fwd) + m := model{ui: ui, termWidth: 120, termHeight: 40} + + tests := []struct { + status string + expectIcon string + expectText string + disabled bool + }{ + {"Active", "●", "Active", false}, + {"Starting", "○", "Starting", false}, + {"Reconnecting", "◐", "Reconnecting", false}, + {"Error", "✗", "Error", false}, + {"Active", "○", "Disabled", true}, + } + + for _, tt := range tests { + t.Run(tt.status+"_disabled="+func() string { + if tt.disabled { + return "true" + } + return "false" + }(), func(t *testing.T) { + ui.mu.Lock() + ui.forwards["id-1"].Status = tt.status + ui.disabledMap["id-1"] = tt.disabled + ui.mu.Unlock() + + ui.mu.RLock() + icon, text := m.getStatusIconAndText("id-1", ui.forwards["id-1"]) + ui.mu.RUnlock() + + assert.Equal(t, tt.expectIcon, icon) + assert.Equal(t, tt.expectText, text) + }) + } +} + +// ----- defaultMainViewColors / mainViewKeyBindings ---------------------- + +func TestDefaultMainViewColors(t *testing.T) { + colors := defaultMainViewColors() + assert.NotEmpty(t, string(colors.header)) + assert.NotEmpty(t, string(colors.active)) +} + +func TestMainViewKeyBindings(t *testing.T) { + bindings := mainViewKeyBindings() + require.NotEmpty(t, bindings) + // Spot-check a few expected bindings. + var keys []string + for _, b := range bindings { + keys = append(keys, b.key) + } + assert.Contains(t, keys, "n") + assert.Contains(t, keys, "d") +} + +// ----- safeRecover ------------------------------------------------------- + +func TestSafeRecover_DoesNotPanicOnRecover(t *testing.T) { + // Must not propagate panic. + assert.NotPanics(t, func() { + func() { + defer safeRecover("test context") + panic("test panic") + }() + }) +} + +// ----- BenchmarkResults (populate via handleBenchmarkComplete) ----------- + +func TestBenchmarkResults_FromCompleteMsg(t *testing.T) { + ui := NewBubbleTeaUI(nil, "1.0.0") + ui.mu.Lock() + ui.viewMode = ViewModeBenchmark + state := newBenchmarkState("fwd-id", "alias", 8080) + state.running = true + ui.benchmarkState = state + ui.mu.Unlock() + m := model{ui: ui, termWidth: 120, termHeight: 40} + + results := &benchmark.Results{ + TotalRequests: 100, + Successful: 100, + Failed: 0, + } + // CalculateStats operates on the Latencies slice; with no durations, stats are zero. + _ = results.CalculateStats() + + msg := BenchmarkCompleteMsg{ + ForwardID: "fwd-id", + Results: results, + } + m.handleBenchmarkComplete(msg) + + ui.mu.RLock() + defer ui.mu.RUnlock() + require.NotNil(t, ui.benchmarkState.results) + assert.Equal(t, 100, ui.benchmarkState.results.TotalRequests) +}