diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-13 14:28:37 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-13 14:28:37 +0300 |
| commit | 27b94f917064948fa33141309a3f08deb40ffde2 (patch) | |
| tree | 0f1c63eba01da1cc89fbbedcfe71cdcb55b06cb0 /internal/tui | |
| parent | 140d6c0fe472f112170022b9831dfe700698f382 (diff) | |
improve unit test coverage to >=60% in probes, common, export, streamrow, pidpicker, tui/export
Before: probes=30%, tui/common=41%, export=0%, streamrow=25%, pidpicker=59%, tui/export=45%
After: probes=89%, tui/common=97%, export=77%, streamrow=100%, pidpicker=73%, tui/export=99%
New test files cover RingBuffer push/wrap/reset, Row accessor methods, nil
Sequencer safety, SnapshotCSV nil and data paths, helper functions snapValue /
snapValueF / trendSummary, all table navigation keys, VisibleTableWindow/
ClampTableCol edge cases, RenderTableHeader/Row, PickerShortHelp, probe modal
navigation/search/toggle/view/error paths, truncateText/sanitizeOneLine,
export modal View rendering, key navigation, status messages, scanAllThreadsFrom,
readThreadInfo guards, formatProcess variants, and clamp helper.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/tui')
| -rw-r--r-- | internal/tui/common/table_test.go | 209 | ||||
| -rw-r--r-- | internal/tui/export/export_extra_test.go | 195 | ||||
| -rw-r--r-- | internal/tui/pidpicker/pidpicker_extra_test.go | 192 | ||||
| -rw-r--r-- | internal/tui/probes/probes_extra_test.go | 355 |
4 files changed, 951 insertions, 0 deletions
diff --git a/internal/tui/common/table_test.go b/internal/tui/common/table_test.go new file mode 100644 index 0000000..fa104d1 --- /dev/null +++ b/internal/tui/common/table_test.go @@ -0,0 +1,209 @@ +package common + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" +) + +// TestRenderTableHeaderProducesColumns verifies that RenderTableHeader creates a +// styled row containing each column title padded to its declared width. +func TestRenderTableHeaderProducesColumns(t *testing.T) { + cols := []TableColumn{ + {Title: "PID", Width: 8}, + {Title: "COMM", Width: 12}, + } + out := RenderTableHeader(cols) + if !strings.Contains(out, "PID") { + t.Fatalf("expected header to contain PID, got: %q", out) + } + if !strings.Contains(out, "COMM") { + t.Fatalf("expected header to contain COMM, got: %q", out) + } +} + +// TestRenderTableRowSelectedAndUnselected verifies that selected rows include +// highlighted style and unselected rows use the base style. +func TestRenderTableRowSelectedAndUnselected(t *testing.T) { + cols := []TableColumn{ + {Title: "SYS", Width: 10}, + {Title: "CNT", Width: 6}, + } + cells := []string{"openat", "42"} + base := lipgloss.NewStyle() + + unselected := RenderTableRow(cols, cells, false, 0, base) + if !strings.Contains(unselected, "openat") { + t.Fatalf("unselected row missing cell content, got: %q", unselected) + } + + // Selected row: all cells present, no base style rendering branch. + selected := RenderTableRow(cols, cells, true, 1, base) + if !strings.Contains(selected, "openat") { + t.Fatalf("selected row missing first cell, got: %q", selected) + } + if !strings.Contains(selected, "42") { + t.Fatalf("selected row missing second cell, got: %q", selected) + } +} + +// TestRenderTableRowMissingCells verifies that missing cells default to empty +// strings rather than panicking. +func TestRenderTableRowMissingCells(t *testing.T) { + cols := []TableColumn{ + {Title: "A", Width: 4}, + {Title: "B", Width: 4}, + } + // Provide only one cell for two columns. + out := RenderTableRow(cols, []string{"x"}, false, 0, lipgloss.NewStyle()) + if !strings.Contains(out, "x") { + t.Fatalf("expected cell content x in output, got: %q", out) + } +} + +// TestHandleTableNavigationKeyMovement verifies all supported key strings +// produce the expected row/col changes. +func TestHandleTableNavigationKeyMovement(t *testing.T) { + cases := []struct { + key string + initRow int + initCol int + wantRow int + wantCol int + rowCount int + colCount int + pageStep int + wantResult bool + }{ + {"down", 0, 0, 1, 0, 5, 3, 1, true}, + {"j", 2, 0, 3, 0, 5, 3, 1, true}, + {"up", 2, 0, 1, 0, 5, 3, 1, true}, + {"k", 1, 0, 0, 0, 5, 3, 1, true}, + {"right", 0, 0, 0, 1, 5, 3, 1, true}, + {"l", 0, 1, 0, 2, 5, 3, 1, true}, + {"left", 0, 2, 0, 1, 5, 3, 1, true}, + {"h", 0, 1, 0, 0, 5, 3, 1, true}, + {"g", 3, 1, 0, 1, 5, 3, 1, true}, + {"G", 0, 1, 4, 1, 5, 3, 1, true}, + {"pgup", 4, 0, 2, 0, 5, 3, 2, true}, + {"pageup", 4, 0, 2, 0, 5, 3, 2, true}, + {"pgdown", 0, 0, 2, 0, 5, 3, 2, true}, + {"pgdn", 0, 0, 2, 0, 5, 3, 2, true}, + {"pagedown", 0, 0, 2, 0, 5, 3, 2, true}, + {"x", 0, 0, 0, 0, 5, 3, 1, false}, + } + + for _, tc := range cases { + row, col := tc.initRow, tc.initCol + got := HandleTableNavigationKey(tc.key, &row, &col, tc.rowCount, tc.colCount, tc.pageStep) + if got != tc.wantResult { + t.Errorf("key=%q: handled=%v, want %v", tc.key, got, tc.wantResult) + } + if tc.wantResult && (row != tc.wantRow || col != tc.wantCol) { + t.Errorf("key=%q: row=%d col=%d, want row=%d col=%d", + tc.key, row, col, tc.wantRow, tc.wantCol) + } + } +} + +// TestHandleTableNavigationKeyZeroPageStep verifies that a zero page step is +// treated as 1 to avoid staying in place. +func TestHandleTableNavigationKeyZeroPageStep(t *testing.T) { + row, col := 3, 0 + HandleTableNavigationKey("pgup", &row, &col, 5, 3, 0) + if row != 2 { + t.Fatalf("expected row=2 after pgup with pageStep=0 (clamped to 1), got %d", row) + } +} + +// TestVisibleTableWindowEdgeCases verifies boundary conditions for the visible +// window calculation. +func TestVisibleTableWindowEdgeCases(t *testing.T) { + // Empty row set: returns (0,0). + s, e := VisibleTableWindow(0, 0, 10) + if s != 0 || e != 0 { + t.Fatalf("empty rows: want (0,0), got (%d,%d)", s, e) + } + + // All rows fit in the visible window. + s, e = VisibleTableWindow(2, 5, 10) + if s != 0 || e != 5 { + t.Fatalf("all fit: want (0,5), got (%d,%d)", s, e) + } + + // Selection near start. + s, e = VisibleTableWindow(0, 10, 4) + if s != 0 || e != 4 { + t.Fatalf("start: want (0,4), got (%d,%d)", s, e) + } + + // Selection near end. + s, e = VisibleTableWindow(9, 10, 4) + if s != 6 || e != 10 { + t.Fatalf("end: want (6,10), got (%d,%d)", s, e) + } + + // Middle selection. + s, e = VisibleTableWindow(5, 10, 4) + if s != 3 || e != 7 { + t.Fatalf("middle: want (3,7), got (%d,%d)", s, e) + } +} + +// TestClampTableCol verifies ClampTableCol constrains within [0, colCount-1]. +func TestClampTableCol(t *testing.T) { + if got := ClampTableCol(-1, 5); got != 0 { + t.Fatalf("ClampTableCol(-1,5) = %d, want 0", got) + } + if got := ClampTableCol(10, 5); got != 4 { + t.Fatalf("ClampTableCol(10,5) = %d, want 4", got) + } + if got := ClampTableCol(2, 5); got != 2 { + t.Fatalf("ClampTableCol(2,5) = %d, want 2", got) + } + if got := ClampTableCol(0, 0); got != 0 { + t.Fatalf("ClampTableCol(0,0) = %d, want 0", got) + } +} + +// TestRenderTableCellTruncation verifies that cells wider than their column are +// truncated with an ellipsis. +func TestRenderTableCellTruncation(t *testing.T) { + // renderTableCell is unexported; exercise it via RenderTableHeader. + cols := []TableColumn{{Title: "ABCDEFGHIJ", Width: 6}} + out := RenderTableHeader(cols) + // The title should be truncated to fit within width=6: "ABC..." + if !strings.Contains(out, "ABC...") { + t.Fatalf("expected truncated header ABC..., got: %q", out) + } +} + +// TestRenderTableCellZeroWidth verifies that a zero-width column renders an +// empty string without panicking. +func TestRenderTableCellZeroWidth(t *testing.T) { + cols := []TableColumn{{Title: "X", Width: 0}} + out := RenderTableHeader(cols) + // Expect nothing meaningful — just no panic and no content. + _ = out +} + +// TestSanitizeAndTruncateCellEmbeddedNewlines verifies that embedded newlines +// and tabs in cell values are replaced by spaces. +func TestSanitizeAndTruncateCellEmbeddedNewlines(t *testing.T) { + cols := []TableColumn{{Title: "A\nB\tC", Width: 10}} + out := RenderTableHeader(cols) + if strings.Contains(out, "\n") || strings.Contains(out, "\t") { + t.Fatalf("expected newlines and tabs stripped from cell, got: %q", out) + } +} + +// TestPickerShortHelpReturnsThreeBindings verifies that PickerShortHelp from +// the KeyMap returns three entries (Enter, Refresh, Esc). +func TestPickerShortHelpReturnsThreeBindings(t *testing.T) { + keys := DefaultKeyMap() + bindings := keys.PickerShortHelp() + if len(bindings) != 3 { + t.Fatalf("PickerShortHelp len = %d, want 3", len(bindings)) + } +} diff --git a/internal/tui/export/export_extra_test.go b/internal/tui/export/export_extra_test.go new file mode 100644 index 0000000..edc0632 --- /dev/null +++ b/internal/tui/export/export_extra_test.go @@ -0,0 +1,195 @@ +package export + +import ( + "errors" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" +) + +// TestViewInvisibleReturnsEmpty verifies that View returns an empty string when +// the modal is not visible. +func TestViewInvisibleReturnsEmpty(t *testing.T) { + m := NewModel() + if out := m.View(80, 24); out != "" { + t.Fatalf("expected empty view when invisible, got %q", out) + } +} + +// TestViewVisibleContainsOptionLabels verifies that View renders option labels +// while the modal is open. +func TestViewVisibleContainsOptionLabels(t *testing.T) { + m := NewModel().Open() + out := m.View(80, 24) + for _, label := range optionLabels { + if !strings.Contains(out, label) { + t.Fatalf("expected option label %q in view, got:\n%s", label, out) + } + } + if !strings.Contains(out, "Enter confirm") { + t.Fatalf("expected help text in view, got:\n%s", out) + } +} + +// TestViewZeroDimensionsUsesDefaults verifies that zero width/height fall back +// to defaults without panicking. +func TestViewZeroDimensionsUsesDefaults(t *testing.T) { + m := NewModel().Open() + out := m.View(0, 0) + if out == "" { + t.Fatal("expected non-empty view with zero dimensions") + } +} + +// TestViewNarrowWidthClamped verifies that very narrow widths are clamped to a +// minimum modal width. +func TestViewNarrowWidthClamped(t *testing.T) { + m := NewModel().Open() + // Should not panic on very narrow terminals. + out := m.View(10, 24) + if out == "" { + t.Fatal("expected non-empty view on narrow terminal") + } +} + +// TestViewExportingHidesHelp verifies that the help text "Enter confirm" is +// hidden while an export is in progress. +func TestViewExportingHidesHelp(t *testing.T) { + m := NewModel().Open() + m.exporting = true + out := m.View(80, 24) + if strings.Contains(out, "Enter confirm") { + t.Fatalf("expected help hidden while exporting, got:\n%s", out) + } +} + +// TestViewShowsStatus verifies that status messages appear in the view. +func TestViewShowsStatus(t *testing.T) { + m := NewModel().Open() + m.status = "Export done: out.csv" + out := m.View(80, 24) + if !strings.Contains(out, "Export done: out.csv") { + t.Fatalf("expected status in view, got:\n%s", out) + } +} + +// TestUpDownKeysChangeSelection verifies that up/down and j/k move the +// selected option index. +func TestUpDownKeysChangeSelection(t *testing.T) { + m := NewModel().Open() + + // Move down to last option. + m, _ = m.Update(tea.KeyPressMsg{Code: []rune{'j'}[0], Text: "j"}) + if m.selected != 1 { + t.Fatalf("expected selected=1 after j, got %d", m.selected) + } + + // Move up back to first. + m, _ = m.Update(tea.KeyPressMsg{Code: []rune{'k'}[0], Text: "k"}) + if m.selected != 0 { + t.Fatalf("expected selected=0 after k, got %d", m.selected) + } + + // down key alias. + m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + if m.selected != 1 { + t.Fatalf("expected selected=1 after down, got %d", m.selected) + } + + // up key alias. + m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyUp}) + if m.selected != 0 { + t.Fatalf("expected selected=0 after up, got %d", m.selected) + } +} + +// TestKeyIgnoredWhenNotVisible verifies that key events are no-ops when the +// modal is closed. +func TestKeyIgnoredWhenNotVisible(t *testing.T) { + m := NewModel() // not opened + next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd != nil { + t.Fatal("expected no command when modal is invisible") + } + if next.selected != 0 { + t.Fatalf("expected no selection change when invisible, got %d", next.selected) + } +} + +// TestEscWhileExportingCloses verifies that esc can close the modal even while +// an export is in progress. +func TestEscWhileExportingCloses(t *testing.T) { + m := NewModel().Open() + m.exporting = true + m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if m.Visible() { + t.Fatal("expected modal closed by esc while exporting") + } +} + +// TestCompletedMsgWithEmptyPathUsesDone verifies that an empty path in +// CompletedMsg defaults to "done". +func TestCompletedMsgWithEmptyPathUsesDone(t *testing.T) { + m := NewModel().Open() + m.exporting = true + m, _ = m.Update(CompletedMsg{Path: ""}) + if !strings.Contains(m.status, "done") { + t.Fatalf("expected 'done' in status for empty path, got %q", m.status) + } +} + +// TestFailedMsgWithNilErrUsesDefault verifies that a nil error in FailedMsg +// produces a non-empty error message. +func TestFailedMsgWithNilErrUsesDefault(t *testing.T) { + m := NewModel().Open() + m.exporting = true + m, _ = m.Update(FailedMsg{Err: nil}) + if m.status == "" { + t.Fatal("expected non-empty status after FailedMsg with nil error") + } +} + +// TestUnknownMsgIsNoOp verifies that unsupported message types leave the model +// unchanged. +func TestUnknownMsgIsNoOp(t *testing.T) { + m := NewModel().Open() + next, cmd := m.Update("unknown message type") + if cmd != nil { + t.Fatal("expected no command for unknown message") + } + if next.selected != m.selected { + t.Fatalf("unexpected state change on unknown msg") + } +} + +// TestKeyIgnoredWhenExportingAndNotEsc verifies that non-esc keys are ignored +// during an in-progress export. +func TestKeyIgnoredWhenExportingAndNotEsc(t *testing.T) { + m := NewModel().Open() + m.exporting = true + origSelected := m.selected + next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'j'}[0], Text: "j"}) + if cmd != nil { + t.Fatal("expected no command for j during export") + } + if next.selected != origSelected { + t.Fatalf("expected selection unchanged during export, got %d", next.selected) + } + if !next.Visible() { + t.Fatal("expected modal still visible during export") + } +} + +// TestUpdateWithErrors verifies the error recovery path for unknown errors. +func TestUpdateWithErrors(t *testing.T) { + m := NewModel().Open() + m.exporting = true + m, _ = m.Update(FailedMsg{Err: errors.New("disk full")}) + if !strings.Contains(m.status, "disk full") { + t.Fatalf("expected disk full in status, got %q", m.status) + } + if m.exporting { + t.Fatal("expected exporting=false after failure") + } +} diff --git a/internal/tui/pidpicker/pidpicker_extra_test.go b/internal/tui/pidpicker/pidpicker_extra_test.go new file mode 100644 index 0000000..c452ab6 --- /dev/null +++ b/internal/tui/pidpicker/pidpicker_extra_test.go @@ -0,0 +1,192 @@ +package pidpicker + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestFormatProcessWithParent verifies that formatProcess includes parent PID +// when present and different from the process PID. +func TestFormatProcessWithParent(t *testing.T) { + p := ProcessInfo{Pid: 200, ParentPID: 100, Comm: "worker", Cmdline: "worker --run"} + out := formatProcess(p) + if !strings.Contains(out, "200") || !strings.Contains(out, "100") { + t.Fatalf("expected pid and parent in output, got: %q", out) + } + if !strings.Contains(out, "worker") { + t.Fatalf("expected comm in output, got: %q", out) + } + if !strings.Contains(out, "worker --run") { + t.Fatalf("expected cmdline in output, got: %q", out) + } +} + +// TestFormatProcessWithParentNoCmdline verifies that formatProcess omits the +// cmdline section when Cmdline is empty and a parent PID is present. +func TestFormatProcessWithParentNoCmdline(t *testing.T) { + p := ProcessInfo{Pid: 300, ParentPID: 100, Comm: "kworker", Cmdline: ""} + out := formatProcess(p) + if !strings.Contains(out, "300") || !strings.Contains(out, "kworker") { + t.Fatalf("expected pid and comm in output, got: %q", out) + } +} + +// TestFormatProcessNoParentWithCmdline verifies formatProcess for a top-level +// process that has a cmdline. +func TestFormatProcessNoParentWithCmdline(t *testing.T) { + p := ProcessInfo{Pid: 1, ParentPID: 1, Comm: "init", Cmdline: "/sbin/init"} + out := formatProcess(p) + if !strings.Contains(out, "1") || !strings.Contains(out, "init") || !strings.Contains(out, "/sbin/init") { + t.Fatalf("expected pid, comm, cmdline in output, got: %q", out) + } +} + +// TestFormatProcessNoParentNoCmdline verifies formatProcess for a kernel thread +// with no cmdline. +func TestFormatProcessNoParentNoCmdline(t *testing.T) { + p := ProcessInfo{Pid: 5, ParentPID: 5, Comm: "kthread", Cmdline: ""} + out := formatProcess(p) + if !strings.Contains(out, "5") || !strings.Contains(out, "kthread") { + t.Fatalf("expected pid and comm in output, got: %q", out) + } +} + +// TestClampHelperBoundaries verifies that the unexported clamp function handles +// below-min, above-max, and in-range values. +func TestClampHelperBoundaries(t *testing.T) { + if got := clamp(-5, 0, 10); got != 0 { + t.Fatalf("clamp below min = %d, want 0", got) + } + if got := clamp(15, 0, 10); got != 10 { + t.Fatalf("clamp above max = %d, want 10", got) + } + if got := clamp(7, 0, 10); got != 7 { + t.Fatalf("clamp in range = %d, want 7", got) + } +} + +// TestSetDarkModeDoesNotPanic verifies that SetDarkMode can be called for both +// dark and light themes without panicking. +func TestSetDarkModeDoesNotPanic(t *testing.T) { + m := NewWithKeys(DefaultKeyMap()) + m = m.SetDarkMode(false) + m = m.SetDarkMode(true) + _ = m +} + +// TestScanAllThreadsFromIntegration exercises scanAllThreadsFrom against a +// temporary /proc-like directory tree with two processes and their task threads. +func TestScanAllThreadsFromIntegration(t *testing.T) { + root := t.TempDir() + + // Process 10 with one thread. + proc10 := filepath.Join(root, "10") + if err := os.MkdirAll(filepath.Join(proc10, "task", "10"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + writeFile(t, filepath.Join(proc10, "stat"), "10 (proc10) S 1 1 1 0") + writeFile(t, filepath.Join(proc10, "cmdline"), "proc10\x00") + writeFile(t, filepath.Join(proc10, "task", "10", "comm"), "proc10-main\n") + + // Process 20 with two threads. + proc20 := filepath.Join(root, "20") + if err := os.MkdirAll(filepath.Join(proc20, "task", "20"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.MkdirAll(filepath.Join(proc20, "task", "21"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + writeFile(t, filepath.Join(proc20, "stat"), "20 (proc20) S 1 1 1 0") + writeFile(t, filepath.Join(proc20, "cmdline"), "proc20\x00") + writeFile(t, filepath.Join(proc20, "task", "20", "comm"), "proc20-main\n") + writeFile(t, filepath.Join(proc20, "task", "21", "comm"), "proc20-worker\n") + + threads, err := scanAllThreadsFrom(root) + if err != nil { + t.Fatalf("scanAllThreadsFrom: %v", err) + } + if len(threads) != 3 { + t.Fatalf("expected 3 threads, got %d", len(threads)) + } + + // Results are sorted by TID. + tids := make([]int, len(threads)) + for i, th := range threads { + tids[i] = th.Pid + } + for i := 1; i < len(tids); i++ { + if tids[i] < tids[i-1] { + t.Fatalf("threads not sorted: %v", tids) + } + } +} + +// TestReadThreadInfoSkipsNonDirEntry verifies that readThreadInfo returns false +// for non-directory entries. +func TestReadThreadInfoSkipsNonDirEntry(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "not-a-dir"), "content") + + entries, err := os.ReadDir(root) + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + + _, ok := readThreadInfo(root, entries[0], "cmdline text") + if ok { + t.Fatal("expected readThreadInfo to return false for a file entry") + } +} + +// TestReadThreadInfoSkipsNonNumericDirs verifies that readThreadInfo returns +// false for directory entries whose names are not numeric TIDs. +func TestReadThreadInfoSkipsNonNumericDirs(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "notanumber"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + entries, err := os.ReadDir(root) + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + _, ok := readThreadInfo(root, entries[0], "") + if ok { + t.Fatal("expected readThreadInfo to skip non-numeric dir") + } +} + +// TestExtractPIDFromPath verifies the PID extraction logic for task root paths. +func TestExtractPIDFromPath(t *testing.T) { + pid := extractPIDFromPath("/proc/1234/task") + if pid != 1234 { + t.Fatalf("extractPIDFromPath = %d, want 1234", pid) + } + + if got := extractPIDFromPath("short"); got != -1 { + t.Fatalf("extractPIDFromPath(short) = %d, want -1", got) + } +} + +// TestPickerShortHelpReturnsBindings verifies that KeyMap.PickerShortHelp +// returns exactly three bindings. +func TestPickerShortHelpReturnsBindings(t *testing.T) { + keys := DefaultKeyMap() + bindings := keys.PickerShortHelp() + if len(bindings) != 3 { + t.Fatalf("PickerShortHelp len = %d, want 3", len(bindings)) + } +} + +// writeFile is a test helper that writes content to a file, failing the test on +// any error. +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("writeFile %s: %v", path, err) + } +} diff --git a/internal/tui/probes/probes_extra_test.go b/internal/tui/probes/probes_extra_test.go new file mode 100644 index 0000000..c7a7067 --- /dev/null +++ b/internal/tui/probes/probes_extra_test.go @@ -0,0 +1,355 @@ +package probes + +import ( + "strings" + "testing" + + "ior/internal/probemanager" + + tea "charm.land/bubbletea/v2" +) + +// TestVisibleReturnsFalseBeforeOpen verifies that a freshly constructed model +// is not visible. +func TestVisibleReturnsFalseBeforeOpen(t *testing.T) { + m := NewModel(nil) + if m.Visible() { + t.Fatal("expected model to be invisible after NewModel") + } +} + +// TestSetDarkModeDoesNotPanic verifies that SetDarkMode can be called without +// panicking regardless of mode. +func TestSetDarkModeDoesNotPanic(t *testing.T) { + m := NewModel(nil) + m = m.SetDarkMode(false) + m = m.SetDarkMode(true) + _ = m +} + +// TestNavigationKeysMoveCursor verifies that j/k and down/up keys move the +// cursor within the filtered list. +func TestNavigationKeysMoveCursor(t *testing.T) { + fm := &fakeManager{ + states: []probemanager.ProbeState{ + {Syscall: "read", Active: true}, + {Syscall: "write", Active: true}, + {Syscall: "openat", Active: true}, + }, + } + m := NewModel(fm).Open() + + // Move down twice. + m, _ = m.Update(tea.KeyPressMsg{Code: []rune{'j'}[0], Text: "j"}) + m, _ = m.Update(tea.KeyPressMsg{Code: []rune{'j'}[0], Text: "j"}) + if m.cursor != 2 { + t.Fatalf("expected cursor=2 after two j presses, got %d", m.cursor) + } + + // Move up once. + m, _ = m.Update(tea.KeyPressMsg{Code: []rune{'k'}[0], Text: "k"}) + if m.cursor != 1 { + t.Fatalf("expected cursor=1 after k press, got %d", m.cursor) + } + + // down/up aliases. + m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + if m.cursor != 2 { + t.Fatalf("expected cursor=2 after down press, got %d", m.cursor) + } + m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyUp}) + if m.cursor != 1 { + t.Fatalf("expected cursor=1 after up press, got %d", m.cursor) + } +} + +// TestEscClosesModal verifies that pressing esc returns an invisible model. +func TestEscClosesModal(t *testing.T) { + fm := &fakeManager{ + states: []probemanager.ProbeState{{Syscall: "read", Active: true}}, + } + m := NewModel(fm).Open() + m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if m.Visible() { + t.Fatal("expected modal closed after esc") + } +} + +// TestUpdateIgnoredWhenNotVisible verifies that key events are no-ops when the +// model is closed. +func TestUpdateIgnoredWhenNotVisible(t *testing.T) { + fm := &fakeManager{ + states: []probemanager.ProbeState{{Syscall: "read", Active: true}}, + } + m := NewModel(fm) // not opened + next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'j'}[0], Text: "j"}) + if cmd != nil { + t.Fatal("expected no command when model is closed") + } + if next.cursor != 0 { + t.Fatalf("cursor moved while closed, got %d", next.cursor) + } +} + +// TestSearchFilterNarrowsResults verifies that typing '/' followed by a search +// term filters the visible probe list. +func TestSearchFilterNarrowsResults(t *testing.T) { + fm := &fakeManager{ + states: []probemanager.ProbeState{ + {Syscall: "read", Active: true}, + {Syscall: "write", Active: true}, + {Syscall: "readlink", Active: false}, + }, + } + m := NewModel(fm).Open() + + // Enter search mode. + m, _ = m.Update(tea.KeyPressMsg{Code: []rune{'/'}[0], Text: "/"}) + if !m.searching { + t.Fatal("expected searching=true after /") + } + + // Type "read" via individual rune key presses so the textinput accumulates + // the characters, then confirm with enter. + for _, ch := range "read" { + m, _ = m.Update(tea.KeyPressMsg{Code: ch, Text: string(ch)}) + } + m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if m.searching { + t.Fatal("expected searching=false after enter") + } + if m.search != "read" { + t.Fatalf("expected search=read, got %q", m.search) + } + + filtered := m.filtered() + for _, p := range filtered { + if !strings.Contains(p.Syscall, "read") { + t.Fatalf("unexpected probe %q in filtered list", p.Syscall) + } + } +} + +// TestSearchEscCancelsSearch verifies that pressing esc during search exits +// search mode without applying the filter. +func TestSearchEscCancelsSearch(t *testing.T) { + fm := &fakeManager{ + states: []probemanager.ProbeState{ + {Syscall: "read", Active: true}, + {Syscall: "write", Active: true}, + }, + } + m := NewModel(fm).Open() + + m, _ = m.Update(tea.KeyPressMsg{Code: []rune{'/'}[0], Text: "/"}) + m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if m.searching { + t.Fatal("expected searching=false after esc") + } + // Filter should not be active. + if len(m.filtered()) != 2 { + t.Fatalf("expected 2 unfiltered probes after esc, got %d", len(m.filtered())) + } +} + +// TestProbeToggledMsgUpdatesState verifies that ProbeToggledMsg triggers a +// reload and clears any previous error. +func TestProbeToggledMsgUpdatesState(t *testing.T) { + fm := &fakeManager{ + states: []probemanager.ProbeState{{Syscall: "read", Active: true}}, + } + m := NewModel(fm).Open() + m.lastErr = "previous error" + + // Add a new probe to the manager so reload picks it up. + fm.states = append(fm.states, probemanager.ProbeState{Syscall: "write", Active: false}) + + m, _ = m.Update(ProbeToggledMsg{Syscall: "read"}) + if m.lastErr != "" { + t.Fatalf("expected lastErr cleared after successful toggle, got %q", m.lastErr) + } + if len(m.probes) != 2 { + t.Fatalf("expected probes refreshed to 2 entries, got %d", len(m.probes)) + } +} + +// TestProbeToggledMsgSetsError verifies that a ProbeToggledMsg carrying an +// error populates lastErr. +func TestProbeToggledMsgSetsError(t *testing.T) { + fm := &fakeManager{ + states: []probemanager.ProbeState{{Syscall: "read", Active: true}}, + } + m := NewModel(fm).Open() + m, _ = m.Update(ProbeToggledMsg{Syscall: "read", Err: &testErr{"boom"}}) + if !strings.Contains(m.lastErr, "boom") { + t.Fatalf("expected lastErr to contain boom, got %q", m.lastErr) + } +} + +// testErr is a minimal error implementation for testing error message capture. +type testErr struct{ msg string } + +func (e *testErr) Error() string { return e.msg } + +// TestViewRendersEmptyProbeList verifies that View returns a non-empty string +// even when the probe list is empty. +func TestViewRendersEmptyProbeList(t *testing.T) { + fm := &fakeManager{states: nil} + m := NewModel(fm).Open() + out := m.View(80, 24) + if out == "" { + t.Fatal("expected non-empty View output") + } + if !strings.Contains(out, "no probes") { + t.Fatalf("expected 'no probes' in view output, got:\n%s", out) + } +} + +// TestViewInvisibleReturnsEmpty verifies that View returns an empty string when +// the model is not visible. +func TestViewInvisibleReturnsEmpty(t *testing.T) { + m := NewModel(nil) // not opened + if out := m.View(80, 24); out != "" { + t.Fatalf("expected empty View when not visible, got %q", out) + } +} + +// TestVisibleRowsDefault verifies the fallback when height is zero. +func TestVisibleRowsDefault(t *testing.T) { + m := NewModel(nil) + m.height = 0 + if got := m.visibleRows(); got != 10 { + t.Fatalf("visibleRows with height=0 = %d, want 10", got) + } +} + +// TestVisibleRowsMinimum verifies that visibleRows never returns less than 3. +func TestVisibleRowsMinimum(t *testing.T) { + m := NewModel(nil) + m.height = 5 + if got := m.visibleRows(); got < 3 { + t.Fatalf("visibleRows = %d, want >= 3", got) + } +} + +// TestSanitizeOneLine verifies embedded control characters are replaced with +// spaces. +func TestSanitizeOneLine(t *testing.T) { + out := sanitizeOneLine("a\nb\rc\td") + if strings.ContainsAny(out, "\n\r\t") { + t.Fatalf("sanitizeOneLine left control chars: %q", out) + } + if out != "a b c d" { + t.Fatalf("sanitizeOneLine = %q, want 'a b c d'", out) + } +} + +// TestTruncateText verifies ellipsis truncation and edge cases. +func TestTruncateText(t *testing.T) { + if got := truncateText("hello", 10); got != "hello" { + t.Fatalf("truncateText no-op = %q, want hello", got) + } + if got := truncateText("hello world", 8); got != "hello..." { + t.Fatalf("truncateText 8 = %q, want hello...", got) + } + if got := truncateText("ab", 2); got != "ab" { + t.Fatalf("truncateText exact = %q, want ab", got) + } + if got := truncateText("abc", 3); got != "abc" { + t.Fatalf("truncateText exact 3 = %q, want abc", got) + } + if got := truncateText("abcde", 3); got != "abc" { + t.Fatalf("truncateText short limit = %q, want abc", got) + } + if got := truncateText("abcde", 0); got != "" { + t.Fatalf("truncateText 0 = %q, want empty", got) + } +} + +// TestToggleCmdNilManager verifies that toggleCmd with a nil manager returns a +// ProbeToggledMsg carrying an error. +func TestToggleCmdNilManager(t *testing.T) { + cmd := toggleCmd(nil, "read") + msg := cmd() + toggled, ok := msg.(ProbeToggledMsg) + if !ok { + t.Fatalf("expected ProbeToggledMsg, got %T", msg) + } + if toggled.Err == nil { + t.Fatal("expected error from nil manager toggle") + } +} + +// TestBulkToggleCmdNilManager verifies that bulkToggleCmd with a nil manager +// returns a ProbeToggledMsg carrying an error. +func TestBulkToggleCmdNilManager(t *testing.T) { + cmd := bulkToggleCmd(nil, nil, false) + msg := cmd() + toggled, ok := msg.(ProbeToggledMsg) + if !ok { + t.Fatalf("expected ProbeToggledMsg, got %T", msg) + } + if toggled.Err == nil { + t.Fatal("expected error from nil manager bulk toggle") + } +} + +// TestEnterKeyTogglesSelectedProbe verifies that pressing enter emits a toggle +// command equivalent to space. +func TestEnterKeyTogglesSelectedProbe(t *testing.T) { + fm := &fakeManager{ + states: []probemanager.ProbeState{{Syscall: "write", Active: false}}, + } + m := NewModel(fm).Open() + _, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatal("expected toggle command on enter") + } + msg := cmd() + toggled, ok := msg.(ProbeToggledMsg) + if !ok { + t.Fatalf("expected ProbeToggledMsg, got %T", msg) + } + if toggled.Err != nil { + t.Fatalf("unexpected toggle error: %v", toggled.Err) + } +} + +// TestFKeySetsSearchMode verifies that pressing 'f' also enters search mode. +func TestFKeySetsSearchMode(t *testing.T) { + fm := &fakeManager{ + states: []probemanager.ProbeState{{Syscall: "read", Active: true}}, + } + m := NewModel(fm).Open() + m, _ = m.Update(tea.KeyPressMsg{Code: []rune{'f'}[0], Text: "f"}) + if !m.searching { + t.Fatal("expected searching=true after 'f'") + } +} + +// TestViewWithProbeErrors verifies that View shows probe error annotations. +func TestViewWithProbeErrors(t *testing.T) { + fm := &fakeManager{ + states: []probemanager.ProbeState{ + {Syscall: "read", Active: true, Error: "attach failed"}, + }, + } + m := NewModel(fm).Open() + out := m.View(80, 24) + if !strings.Contains(out, "attach failed") { + t.Fatalf("expected probe error in view, got:\n%s", out) + } +} + +// TestViewRendersLastError verifies that lastErr is shown in the view. +func TestViewRendersLastError(t *testing.T) { + fm := &fakeManager{ + states: []probemanager.ProbeState{{Syscall: "read", Active: true}}, + } + m := NewModel(fm).Open() + m.lastErr = "something went wrong" + out := m.View(80, 24) + if !strings.Contains(out, "something went wrong") { + t.Fatalf("expected lastErr in view, got:\n%s", out) + } +} |
