Files
kportal/internal/ui/handlers_test.go
T
lukaszraczylo e50f73ec92 chore: add golangci-lint v2 config and fix linter warnings (#46)
- [x] Add golangci-lint v2 configuration with formatters section
- [x] Reorganize linters-settings under linters section
- [x] Replace if-else chains with switch statements for clarity
- [x] Wrap all ignored error returns with `_ = ` pattern
- [x] Add OSC 8 hyperlink helper function for clickable ports
- [x] Add blank line in table styling function
- [x] Remove unnecessary type assertion in test
2026-02-13 18:46:27 +00:00

904 lines
24 KiB
Go

package ui
import (
"errors"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/nvm/kportal/internal/config"
"github.com/nvm/kportal/internal/k8s"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Helper to create a model for testing
func newTestModel() model {
ui := NewBubbleTeaUI(nil, "1.0.0")
return model{ui: ui, termWidth: 120, termHeight: 40}
}
// Helper to create a model with a forward
func newTestModelWithForward() model {
ui := NewBubbleTeaUI(nil, "1.0.0")
fwd := &config.Forward{
Resource: "pod/my-app",
Port: 8080,
LocalPort: 8080,
Alias: "my-app",
}
ui.AddForward("test-id", fwd)
return model{ui: ui, termWidth: 120, termHeight: 40}
}
// TestHandleMainViewKeys_Quit tests quit key handling
func TestHandleMainViewKeys_Quit(t *testing.T) {
tests := []struct {
key string
expected bool
}{
{"q", true},
{"ctrl+c", true},
}
for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
m := newTestModel()
_, cmd := m.handleMainViewKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tt.key)})
if tt.key == "ctrl+c" {
keyMsg := tea.KeyMsg{Type: tea.KeyCtrlC}
_, cmd = m.handleMainViewKeys(keyMsg)
}
// tea.Quit returns a special command
if tt.expected {
assert.NotNil(t, cmd)
}
})
}
}
// TestHandleMainViewKeys_Navigation tests cursor navigation
func TestHandleMainViewKeys_Navigation(t *testing.T) {
m := newTestModelWithForward()
// Add more forwards for navigation testing
for i := 0; i < 5; i++ {
fwd := &config.Forward{
Resource: "pod/app",
Port: 8080 + i,
LocalPort: 8080 + i,
}
m.ui.AddForward(string(rune('a'+i)), fwd)
}
tests := []struct {
name string
key string
keyType tea.KeyType
initialIndex int
expectedIndex int
}{
{"down arrow", "down", tea.KeyDown, 0, 1},
{"j key", "j", tea.KeyRunes, 0, 1},
{"up arrow", "up", tea.KeyUp, 2, 1},
{"k key", "k", tea.KeyRunes, 2, 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m.ui.mu.Lock()
m.ui.selectedIndex = tt.initialIndex
m.ui.mu.Unlock()
var keyMsg tea.KeyMsg
if tt.keyType == tea.KeyRunes {
keyMsg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tt.key)}
} else {
keyMsg = tea.KeyMsg{Type: tt.keyType}
}
m.handleMainViewKeys(keyMsg)
m.ui.mu.RLock()
assert.Equal(t, tt.expectedIndex, m.ui.selectedIndex)
m.ui.mu.RUnlock()
})
}
}
// TestHandleMainViewKeys_Toggle tests space/enter toggle
func TestHandleMainViewKeys_Toggle(t *testing.T) {
toggleCallback := NewMockToggleCallback()
ui := NewBubbleTeaUI(toggleCallback.GetFunc(), "1.0.0")
fwd := &config.Forward{
Resource: "pod/my-app",
Port: 8080,
LocalPort: 8080,
}
ui.AddForward("test-id", fwd)
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Toggle with space
keyMsg := tea.KeyMsg{Type: tea.KeySpace}
m.handleMainViewKeys(keyMsg)
// Check disabled state changed
m.ui.mu.RLock()
isDisabled := m.ui.disabledMap["test-id"]
m.ui.mu.RUnlock()
assert.True(t, isDisabled)
// Give callback goroutine time to execute
time.Sleep(10 * time.Millisecond)
// Verify callback was called
assert.GreaterOrEqual(t, toggleCallback.CallCount(), 1)
}
// TestHandleMainViewKeys_NewWizard tests 'n' key with dependencies
func TestHandleMainViewKeys_NewWizard(t *testing.T) {
mockDiscovery := NewMockDiscovery()
mockMutator := NewMockMutator()
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.SetWizardDependencies(nil, nil, "/path/to/config") // Real Discovery/Mutator needed
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Without dependencies, 'n' should do nothing
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}
m.handleMainViewKeys(keyMsg)
m.ui.mu.RLock()
assert.Nil(t, m.ui.addWizard, "Wizard should not be created without dependencies")
m.ui.mu.RUnlock()
// With mock (but we can't inject easily due to concrete types)
// This test documents the expected behavior
_ = mockDiscovery
_ = mockMutator
}
// TestHandleMainViewKeys_DeleteConfirmation tests 'd' key
func TestHandleMainViewKeys_DeleteConfirmation(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.SetWizardDependencies(nil, &config.Mutator{}, "/path/to/config")
fwd := &config.Forward{
Resource: "pod/my-app",
Port: 8080,
LocalPort: 8080,
Alias: "my-app",
}
ui.AddForward("test-id", fwd)
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press 'd' to show delete confirmation
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}
m.handleMainViewKeys(keyMsg)
m.ui.mu.RLock()
assert.True(t, m.ui.deleteConfirming)
assert.Equal(t, "test-id", m.ui.deleteConfirmID)
assert.Equal(t, "my-app", m.ui.deleteConfirmAlias)
assert.Equal(t, 1, m.ui.deleteConfirmCursor) // Default to "No"
m.ui.mu.RUnlock()
}
// TestHandleMainViewKeys_DeleteConfirmation_PreventsDuplicate tests that 'd' doesn't overwrite
func TestHandleMainViewKeys_DeleteConfirmation_PreventsDuplicate(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.SetWizardDependencies(nil, &config.Mutator{}, "/path/to/config")
fwd1 := &config.Forward{Resource: "pod/app1", Port: 8080, LocalPort: 8080, Alias: "app1"}
fwd2 := &config.Forward{Resource: "pod/app2", Port: 8081, LocalPort: 8081, Alias: "app2"}
ui.AddForward("id-1", fwd1)
ui.AddForward("id-2", fwd2)
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press 'd' for first forward
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}
m.handleMainViewKeys(keyMsg)
// Change selection
m.ui.mu.Lock()
m.ui.selectedIndex = 1
m.ui.mu.Unlock()
// Press 'd' again - should not change confirmation
m.handleMainViewKeys(keyMsg)
m.ui.mu.RLock()
assert.Equal(t, "id-1", m.ui.deleteConfirmID, "Delete confirmation should not be overwritten")
m.ui.mu.RUnlock()
}
// TestHandleDeleteConfirmation_Cancel tests Esc cancels delete
func TestHandleDeleteConfirmation_Cancel(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
// Set up delete confirmation
ui.mu.Lock()
ui.deleteConfirming = true
ui.deleteConfirmID = "test-id"
ui.deleteConfirmAlias = "test-alias"
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press Esc
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
m.handleDeleteConfirmation(keyMsg)
m.ui.mu.RLock()
assert.False(t, m.ui.deleteConfirming)
m.ui.mu.RUnlock()
}
// TestHandleDeleteConfirmation_NavigateAndConfirm tests cursor navigation in delete dialog
func TestHandleDeleteConfirmation_NavigateAndConfirm(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
// Note: We use SetWizardDependencies with a real (nil) mutator since
// the navigation test doesn't actually call mutator methods
ui.SetWizardDependencies(nil, nil, "/path/to/config")
ui.mu.Lock()
ui.deleteConfirming = true
ui.deleteConfirmID = "test-id"
ui.deleteConfirmCursor = 1 // Start on "No"
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Navigate left to "Yes"
keyMsg := tea.KeyMsg{Type: tea.KeyLeft}
m.handleDeleteConfirmation(keyMsg)
m.ui.mu.RLock()
assert.Equal(t, 0, m.ui.deleteConfirmCursor)
m.ui.mu.RUnlock()
// Navigate right back to "No"
keyMsg = tea.KeyMsg{Type: tea.KeyRight}
m.handleDeleteConfirmation(keyMsg)
m.ui.mu.RLock()
assert.Equal(t, 1, m.ui.deleteConfirmCursor)
m.ui.mu.RUnlock()
}
// TestHandleDeleteConfirmation_ConfirmYes tests confirming deletion
func TestHandleDeleteConfirmation_ConfirmYes(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
// Note: The mutator needs to be set for the command to be generated,
// but we don't call the actual mutator method in this test (just generate the cmd)
ui.SetWizardDependencies(nil, &config.Mutator{}, "/path/to/config")
ui.mu.Lock()
ui.deleteConfirming = true
ui.deleteConfirmID = "test-id"
ui.deleteConfirmCursor = 0 // On "Yes"
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press Enter on "Yes"
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
_, cmd := m.handleDeleteConfirmation(keyMsg)
// Should return a command to remove the forward
assert.NotNil(t, cmd)
// Dialog should be closed
m.ui.mu.RLock()
assert.False(t, m.ui.deleteConfirming)
m.ui.mu.RUnlock()
}
// TestHandleDeleteConfirmation_QuickYKey tests 'y' key for quick confirm
func TestHandleDeleteConfirmation_QuickYKey(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
// Set up with a real mutator (empty but valid) since we're testing command generation
ui.SetWizardDependencies(nil, &config.Mutator{}, "/path/to/config")
ui.mu.Lock()
ui.deleteConfirming = true
ui.deleteConfirmID = "test-id"
ui.deleteConfirmCursor = 1 // On "No"
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press 'y' - should confirm regardless of cursor position
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}
_, cmd := m.handleDeleteConfirmation(keyMsg)
assert.NotNil(t, cmd)
m.ui.mu.RLock()
assert.False(t, m.ui.deleteConfirming)
m.ui.mu.RUnlock()
}
// TestHandleDeleteConfirmation_QuickNKey tests 'n' key for quick cancel
func TestHandleDeleteConfirmation_QuickNKey(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.deleteConfirming = true
ui.deleteConfirmID = "test-id"
ui.deleteConfirmCursor = 0 // On "Yes"
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press 'n' - should cancel regardless of cursor position
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}
m.handleDeleteConfirmation(keyMsg)
m.ui.mu.RLock()
assert.False(t, m.ui.deleteConfirming)
m.ui.mu.RUnlock()
}
// TestHandleBenchmarkKeys_Cancel tests benchmark cancellation
func TestHandleBenchmarkKeys_Cancel(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
cancelled := false
ui.mu.Lock()
ui.viewMode = ViewModeBenchmark
ui.benchmarkState = newBenchmarkState("fwd-id", "alias", 8080)
ui.benchmarkState.cancelFunc = func() { cancelled = true }
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press Esc
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
m.handleBenchmarkKeys(keyMsg)
assert.True(t, cancelled, "Cancel function should be called")
m.ui.mu.RLock()
assert.Nil(t, m.ui.benchmarkState)
assert.Equal(t, ViewModeMain, m.ui.viewMode)
m.ui.mu.RUnlock()
}
// TestHandleBenchmarkKeys_Navigation tests benchmark config navigation
func TestHandleBenchmarkKeys_Navigation(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeBenchmark
ui.benchmarkState = newBenchmarkState("fwd-id", "alias", 8080)
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Initial cursor is 0
m.ui.mu.RLock()
assert.Equal(t, 0, m.ui.benchmarkState.cursor)
m.ui.mu.RUnlock()
// Move down
keyMsg := tea.KeyMsg{Type: tea.KeyDown}
m.handleBenchmarkKeys(keyMsg)
m.ui.mu.RLock()
assert.Equal(t, 1, m.ui.benchmarkState.cursor)
m.ui.mu.RUnlock()
// Move down again
m.handleBenchmarkKeys(keyMsg)
m.ui.mu.RLock()
assert.Equal(t, 2, m.ui.benchmarkState.cursor)
m.ui.mu.RUnlock()
// Move up
keyMsg = tea.KeyMsg{Type: tea.KeyUp}
m.handleBenchmarkKeys(keyMsg)
m.ui.mu.RLock()
assert.Equal(t, 1, m.ui.benchmarkState.cursor)
m.ui.mu.RUnlock()
}
// TestHandleHTTPLogKeys_Close tests HTTP log view closing
func TestHandleHTTPLogKeys_Close(t *testing.T) {
mockSubscriber := NewMockHTTPLogSubscriber()
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeHTTPLog
ui.httpLogState = newHTTPLogState("fwd-id", "alias")
ui.httpLogCleanup = mockSubscriber.Subscribe("fwd-id", func(entry HTTPLogEntry) {})
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press Esc
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
m.handleHTTPLogKeys(keyMsg)
m.ui.mu.RLock()
assert.Nil(t, m.ui.httpLogState)
assert.Nil(t, m.ui.httpLogCleanup)
assert.Equal(t, ViewModeMain, m.ui.viewMode)
m.ui.mu.RUnlock()
// Verify cleanup was called
assert.Equal(t, 1, mockSubscriber.CleanupCalls)
}
// TestHandleHTTPLogKeys_FilterCycle tests filter mode cycling
func TestHandleHTTPLogKeys_FilterCycle(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeHTTPLog
ui.httpLogState = newHTTPLogState("fwd-id", "alias")
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Initial mode is None
m.ui.mu.RLock()
assert.Equal(t, HTTPLogFilterNone, m.ui.httpLogState.filterMode)
m.ui.mu.RUnlock()
// Press 'f' to cycle - should skip Text mode and go to Non200
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("f")}
m.handleHTTPLogKeys(keyMsg)
m.ui.mu.RLock()
assert.Equal(t, HTTPLogFilterNon200, m.ui.httpLogState.filterMode)
m.ui.mu.RUnlock()
// Press 'f' again - should go to Errors
m.handleHTTPLogKeys(keyMsg)
m.ui.mu.RLock()
assert.Equal(t, HTTPLogFilterErrors, m.ui.httpLogState.filterMode)
m.ui.mu.RUnlock()
// Press 'f' again - should go back to None
m.handleHTTPLogKeys(keyMsg)
m.ui.mu.RLock()
assert.Equal(t, HTTPLogFilterNone, m.ui.httpLogState.filterMode)
m.ui.mu.RUnlock()
}
// TestHandleHTTPLogKeys_TextFilter tests '/' for text filter
func TestHandleHTTPLogKeys_TextFilter(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeHTTPLog
ui.httpLogState = newHTTPLogState("fwd-id", "alias")
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press '/'
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
m.handleHTTPLogKeys(keyMsg)
m.ui.mu.RLock()
assert.True(t, m.ui.httpLogState.filterActive)
m.ui.mu.RUnlock()
}
// TestHandleHTTPLogKeys_ClearFilters tests 'c' to clear filters
func TestHandleHTTPLogKeys_ClearFilters(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeHTTPLog
ui.httpLogState = newHTTPLogState("fwd-id", "alias")
ui.httpLogState.filterMode = HTTPLogFilterErrors
ui.httpLogState.filterText = "api"
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press 'c'
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")}
m.handleHTTPLogKeys(keyMsg)
m.ui.mu.RLock()
assert.Equal(t, HTTPLogFilterNone, m.ui.httpLogState.filterMode)
assert.Empty(t, m.ui.httpLogState.filterText)
m.ui.mu.RUnlock()
}
// TestHandleHTTPLogEntry tests HTTP log entry handling
func TestHandleHTTPLogEntry(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeHTTPLog
ui.httpLogState = newHTTPLogState("fwd-id", "alias")
ui.httpLogState.autoScroll = true
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Send an entry
msg := HTTPLogEntryMsg{
Entry: HTTPLogEntry{
Method: "GET",
Path: "/api/test",
StatusCode: 200,
},
}
m.handleHTTPLogEntry(msg)
m.ui.mu.RLock()
assert.Len(t, m.ui.httpLogState.entries, 1)
assert.Equal(t, "/api/test", m.ui.httpLogState.entries[0].Path)
m.ui.mu.RUnlock()
}
// TestHandleContextsLoaded tests context loading handler
func TestHandleContextsLoaded(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeAddWizard
ui.addWizard = newAddWizardState()
ui.addWizard.loading = true
// Note: discovery is nil but the handler doesn't use it directly,
// it uses the message data instead. The current context reordering
// uses GetCurrentContext() which would fail with nil discovery,
// but we test the basic loading behavior here.
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Simulate contexts loaded
msg := ContextsLoadedMsg{
contexts: []string{"default", "production", "staging"},
}
m.handleContextsLoaded(msg)
m.ui.mu.RLock()
assert.False(t, m.ui.addWizard.loading)
// Contexts should be loaded (order depends on GetCurrentContext which may fail with nil discovery)
assert.Contains(t, m.ui.addWizard.contexts, "default")
assert.Contains(t, m.ui.addWizard.contexts, "production")
assert.Contains(t, m.ui.addWizard.contexts, "staging")
m.ui.mu.RUnlock()
}
// TestHandleContextsLoaded_Error tests error handling
func TestHandleContextsLoaded_Error(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}
// Simulate error
expectedErr := errors.New("failed to list contexts")
msg := ContextsLoadedMsg{
err: expectedErr,
}
m.handleContextsLoaded(msg)
m.ui.mu.RLock()
assert.False(t, m.ui.addWizard.loading)
assert.Equal(t, expectedErr, m.ui.addWizard.error)
m.ui.mu.RUnlock()
}
// TestHandleNamespacesLoaded tests namespace loading handler
func TestHandleNamespacesLoaded(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 := NamespacesLoadedMsg{
namespaces: []string{"default", "kube-system", "production"},
}
m.handleNamespacesLoaded(msg)
m.ui.mu.RLock()
assert.False(t, m.ui.addWizard.loading)
assert.Equal(t, []string{"default", "kube-system", "production"}, m.ui.addWizard.namespaces)
m.ui.mu.RUnlock()
}
// TestHandlePodsLoaded tests pod loading handler
func TestHandlePodsLoaded(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}
pods := []k8s.PodInfo{
{Name: "app-1", Namespace: "default"},
{Name: "app-2", Namespace: "default"},
}
msg := PodsLoadedMsg{pods: pods}
m.handlePodsLoaded(msg)
m.ui.mu.RLock()
assert.False(t, m.ui.addWizard.loading)
assert.Len(t, m.ui.addWizard.pods, 2)
m.ui.mu.RUnlock()
}
// TestHandleServicesLoaded tests service loading handler
func TestHandleServicesLoaded(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}
services := []k8s.ServiceInfo{
{Name: "api", Namespace: "default", Ports: []k8s.PortInfo{{Port: 80}}},
{Name: "db", Namespace: "default", Ports: []k8s.PortInfo{{Port: 5432}}},
}
msg := ServicesLoadedMsg{services: services}
m.handleServicesLoaded(msg)
m.ui.mu.RLock()
assert.False(t, m.ui.addWizard.loading)
assert.Len(t, m.ui.addWizard.services, 2)
m.ui.mu.RUnlock()
}
// TestHandleSelectorValidated tests selector validation handler
func TestHandleSelectorValidated(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}
pods := []k8s.PodInfo{
{Name: "app-1", Namespace: "default"},
}
msg := SelectorValidatedMsg{
valid: true,
pods: pods,
}
m.handleSelectorValidated(msg)
m.ui.mu.RLock()
assert.False(t, m.ui.addWizard.loading)
assert.Len(t, m.ui.addWizard.matchingPods, 1)
m.ui.mu.RUnlock()
}
// TestHandlePortChecked tests port availability check handler
func TestHandlePortChecked(t *testing.T) {
tests := []struct {
name string
expectStep AddWizardStep
available bool
expectError bool
}{
{name: "port available", available: true, expectStep: StepConfirmation, expectError: false},
{name: "port in use", available: false, expectStep: StepEnterLocalPort, expectError: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeAddWizard
ui.addWizard = newAddWizardState()
ui.addWizard.step = StepEnterLocalPort
ui.addWizard.loading = true
ui.addWizard.localPort = 8080
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
msg := PortCheckedMsg{
port: 8080,
available: tt.available,
message: "test message",
}
m.handlePortChecked(msg)
m.ui.mu.RLock()
assert.False(t, m.ui.addWizard.loading)
assert.Equal(t, tt.available, m.ui.addWizard.portAvailable)
if tt.expectError {
assert.NotNil(t, m.ui.addWizard.error)
} else {
assert.Equal(t, tt.expectStep, m.ui.addWizard.step)
}
m.ui.mu.RUnlock()
})
}
}
// TestHandleForwardSaved tests forward save handler
func TestHandleForwardSaved(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: true}
m.handleForwardSaved(msg)
m.ui.mu.RLock()
assert.False(t, m.ui.addWizard.loading)
assert.Equal(t, StepSuccess, m.ui.addWizard.step)
m.ui.mu.RUnlock()
}
// TestHandleForwardsRemoved tests forward removal handler
func TestHandleForwardsRemoved(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeRemoveWizard
ui.removeWizard = &RemoveWizardState{}
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
msg := ForwardsRemovedMsg{success: true, count: 2}
m.handleForwardsRemoved(msg)
m.ui.mu.RLock()
assert.Nil(t, m.ui.removeWizard)
assert.Equal(t, ViewModeMain, m.ui.viewMode)
m.ui.mu.RUnlock()
}
// TestHandleBenchmarkProgress tests benchmark progress handler
func TestHandleBenchmarkProgress(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeBenchmark
ui.benchmarkState = newBenchmarkState("fwd-id", "alias", 8080)
ui.benchmarkState.running = true
ui.benchmarkState.progressCh = make(chan BenchmarkProgressMsg, 1)
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
msg := BenchmarkProgressMsg{
ForwardID: "fwd-id",
Completed: 50,
Total: 100,
}
m.handleBenchmarkProgress(msg)
m.ui.mu.RLock()
assert.Equal(t, 50, m.ui.benchmarkState.progress)
assert.Equal(t, 100, m.ui.benchmarkState.total)
m.ui.mu.RUnlock()
}
// TestHandleBenchmarkComplete tests benchmark completion handler
func TestHandleBenchmarkComplete(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = ViewModeBenchmark
ui.benchmarkState = newBenchmarkState("fwd-id", "alias", 8080)
ui.benchmarkState.running = true
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Note: This test documents expected behavior
// The actual BenchmarkCompleteMsg requires benchmark.Results which has CalculateStats
msg := BenchmarkCompleteMsg{
ForwardID: "fwd-id",
Error: errors.New("test error"),
}
m.handleBenchmarkComplete(msg)
m.ui.mu.RLock()
assert.False(t, m.ui.benchmarkState.running)
assert.Equal(t, BenchmarkStepResults, m.ui.benchmarkState.step)
assert.NotNil(t, m.ui.benchmarkState.error)
m.ui.mu.RUnlock()
}
// TestModel_Update_MessageRouting tests message routing in Update
func TestModel_Update_MessageRouting(t *testing.T) {
m := newTestModelWithForward()
// Test window size message
sizeMsg := tea.WindowSizeMsg{Width: 100, Height: 50}
newModel, _ := m.Update(sizeMsg)
updatedModel := newModel.(model)
assert.Equal(t, 100, updatedModel.termWidth)
assert.Equal(t, 50, updatedModel.termHeight)
}
// TestModel_Update_ViewModeRouting tests that key messages are routed based on view mode
func TestModel_Update_ViewModeRouting(t *testing.T) {
tests := []struct {
name string
viewMode ViewMode
}{
{"main view", ViewModeMain},
{"add wizard", ViewModeAddWizard},
{"benchmark", ViewModeBenchmark},
{"http log", ViewModeHTTPLog},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.Lock()
ui.viewMode = tt.viewMode
switch tt.viewMode {
case ViewModeAddWizard:
ui.addWizard = newAddWizardState()
case ViewModeBenchmark:
ui.benchmarkState = newBenchmarkState("id", "alias", 8080)
case ViewModeHTTPLog:
ui.httpLogState = newHTTPLogState("id", "alias")
}
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Send a key message - should not panic
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
_, _ = m.Update(keyMsg)
})
}
}
// TestWizardCompleteMsg tests wizard completion message handling
func TestWizardCompleteMsg(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}
msg := WizardCompleteMsg{}
newModel, _ := m.Update(msg)
updatedModel := newModel.(model)
updatedModel.ui.mu.RLock()
assert.Equal(t, ViewModeMain, updatedModel.ui.viewMode)
assert.Nil(t, updatedModel.ui.addWizard)
updatedModel.ui.mu.RUnlock()
}
// Helper to check that model implements tea.Model
func TestModel_ImplementsTeaModel(t *testing.T) {
m := newTestModel()
var _ tea.Model = m
require.NotNil(t, m)
}