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 | |
| 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>
| -rw-r--r-- | internal/export/snapshot_csv_test.go | 141 | ||||
| -rw-r--r-- | internal/streamrow/ringbuffer_test.go | 104 | ||||
| -rw-r--r-- | internal/streamrow/row_test.go | 61 | ||||
| -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 |
7 files changed, 1257 insertions, 0 deletions
diff --git a/internal/export/snapshot_csv_test.go b/internal/export/snapshot_csv_test.go new file mode 100644 index 0000000..77a0c3a --- /dev/null +++ b/internal/export/snapshot_csv_test.go @@ -0,0 +1,141 @@ +package export + +import ( + "encoding/csv" + "os" + "path/filepath" + "strings" + "testing" + + "ior/internal/statsengine" +) + +// TestSnapshotCSVNilSnapshot verifies that SnapshotCSV writes a valid CSV file +// with only the header and summary rows when the snapshot is nil. +func TestSnapshotCSVNilSnapshot(t *testing.T) { + // Run inside a temp dir so the timestamped CSV file is automatically + // cleaned up after the test. + dir := t.TempDir() + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + name, err := SnapshotCSV(nil) + if err != nil { + t.Fatalf("SnapshotCSV(nil) returned error: %v", err) + } + if name == "" { + t.Fatal("SnapshotCSV returned empty filename") + } + if !strings.HasPrefix(filepath.Base(name), "ior-snapshot-") { + t.Fatalf("unexpected filename prefix: %q", name) + } + + data, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + t.Fatalf("read csv file: %v", err) + } + + r := csv.NewReader(strings.NewReader(string(data))) + records, err := r.ReadAll() + if err != nil { + t.Fatalf("parse csv: %v", err) + } + + // Nil snapshot: expect header + 4 summary rows. + if len(records) < 5 { + t.Fatalf("expected at least 5 CSV rows, got %d", len(records)) + } + if records[0][0] != "section" { + t.Fatalf("expected header row first cell 'section', got %q", records[0][0]) + } + for _, row := range records[1:5] { + if row[0] != "summary" { + t.Fatalf("expected summary row, got section=%q", row[0]) + } + } +} + +// TestSnapshotCSVWithData verifies that SnapshotCSV emits syscall, file, and +// process rows when the snapshot contains non-empty data. +func TestSnapshotCSVWithData(t *testing.T) { + dir := t.TempDir() + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + snap := statsengine.NewSnapshot( + nil, nil, nil, + []statsengine.SyscallSnapshot{ + {Name: "read", Count: 10, RatePerSec: 1.5, Bytes: 4096}, + }, + []statsengine.FileSnapshot{ + {Path: "/tmp/x", Accesses: 3, BytesRead: 128, BytesWritten: 64, AvgLatencyNs: 100}, + }, + []statsengine.ProcessSnapshot{ + {PID: 42, Comm: "cat", Syscalls: 5, RatePerSec: 0.5, AvgLatencyNs: 200}, + }, + statsengine.NewHistogramSnapshot(1, []statsengine.HistogramBucketSnapshot{ + {Label: "0-1µs", LowerNs: 0, UpperNs: 1000, Count: 1}, + }), + statsengine.NewHistogramSnapshot(0, nil), + ) + + name, err := SnapshotCSV(&snap) + if err != nil { + t.Fatalf("SnapshotCSV returned error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + t.Fatalf("read csv file: %v", err) + } + + content := string(data) + for _, want := range []string{"syscall", "read", "file", "/tmp/x", "process", "42", "latency_hist", "0-1µs"} { + if !strings.Contains(content, want) { + t.Fatalf("expected %q in CSV output, got:\n%s", want, content) + } + } +} + +// TestSnapValueHelpers verifies the nil-safe helper functions directly. +func TestSnapValueHelpers(t *testing.T) { + if got := snapValue(nil, func(s *statsengine.Snapshot) uint64 { return s.TotalSyscalls }); got != 0 { + t.Fatalf("snapValue(nil) = %d, want 0", got) + } + if got := snapValueF(nil, func(s *statsengine.Snapshot) float64 { return s.SyscallRatePerSec }); got != 0 { + t.Fatalf("snapValueF(nil) = %f, want 0", got) + } + if got := trendSummary(nil, func(s *statsengine.Snapshot) statsengine.Trend { return s.LatencyTrend }); got != "stable:0.00" { + t.Fatalf("trendSummary(nil) = %q, want stable:0.00", got) + } + + snap := statsengine.NewSnapshot(nil, nil, nil, nil, nil, nil, + statsengine.NewHistogramSnapshot(0, nil), + statsengine.NewHistogramSnapshot(0, nil), + ) + snap.TotalSyscalls = 99 + snap.SyscallRatePerSec = 3.14 + snap.LatencyTrend = statsengine.Trend{Direction: statsengine.TrendRising, DeltaPercent: 12.5} + + if got := snapValue(&snap, func(s *statsengine.Snapshot) uint64 { return s.TotalSyscalls }); got != 99 { + t.Fatalf("snapValue = %d, want 99", got) + } + if got := snapValueF(&snap, func(s *statsengine.Snapshot) float64 { return s.SyscallRatePerSec }); got != 3.14 { + t.Fatalf("snapValueF = %f, want 3.14", got) + } + if got := trendSummary(&snap, func(s *statsengine.Snapshot) statsengine.Trend { return s.LatencyTrend }); got != "rising:12.50" { + t.Fatalf("trendSummary = %q, want rising:12.50", got) + } +} diff --git a/internal/streamrow/ringbuffer_test.go b/internal/streamrow/ringbuffer_test.go new file mode 100644 index 0000000..91f3f7d --- /dev/null +++ b/internal/streamrow/ringbuffer_test.go @@ -0,0 +1,104 @@ +package streamrow + +import ( + "testing" +) + +// TestRingBufferPushAndSnapshot verifies that pushed rows are retrievable in +// insertion order. +func TestRingBufferPushAndSnapshot(t *testing.T) { + rb := NewRingBuffer() + + if got := rb.Len(); got != 0 { + t.Fatalf("expected empty buffer, got len=%d", got) + } + + rows := []Row{ + {Seq: 1, Syscall: "read"}, + {Seq: 2, Syscall: "write"}, + {Seq: 3, Syscall: "openat"}, + } + for _, r := range rows { + rb.Push(r) + } + + if got := rb.Len(); got != 3 { + t.Fatalf("expected len=3, got %d", got) + } + if got := rb.TotalPushed(); got != 3 { + t.Fatalf("expected totalPushed=3, got %d", got) + } + + snap := rb.Snapshot() + if len(snap) != 3 { + t.Fatalf("expected snapshot len 3, got %d", len(snap)) + } + for i, want := range rows { + if snap[i].Seq != want.Seq || snap[i].Syscall != want.Syscall { + t.Fatalf("row[%d] = %+v, want %+v", i, snap[i], want) + } + } +} + +// TestRingBufferWrapsAroundCapacity verifies that the ring buffer overwrites the +// oldest entry when full and preserves insertion order in the snapshot. +func TestRingBufferWrapsAroundCapacity(t *testing.T) { + rb := NewRingBuffer() + + // Fill beyond capacity to force wrap-around. + const extra = 5 + for i := range RingBufferCapacity + extra { + rb.Push(Row{Seq: uint64(i + 1)}) + } + + if got := rb.Len(); got != RingBufferCapacity { + t.Fatalf("expected len=%d after overflow, got %d", RingBufferCapacity, got) + } + if got := rb.TotalPushed(); got != uint64(RingBufferCapacity+extra) { + t.Fatalf("expected totalPushed=%d, got %d", RingBufferCapacity+extra, got) + } + + snap := rb.Snapshot() + if len(snap) != RingBufferCapacity { + t.Fatalf("snapshot len = %d, want %d", len(snap), RingBufferCapacity) + } + // After filling cap+extra rows the oldest surviving seq should be extra+1. + wantFirstSeq := uint64(extra + 1) + if snap[0].Seq != wantFirstSeq { + t.Fatalf("oldest surviving seq = %d, want %d", snap[0].Seq, wantFirstSeq) + } +} + +// TestRingBufferSnapshotOnEmpty verifies that Snapshot on an empty buffer +// returns an empty (non-nil) slice. +func TestRingBufferSnapshotOnEmpty(t *testing.T) { + rb := NewRingBuffer() + snap := rb.Snapshot() + if snap == nil { + t.Fatal("expected non-nil snapshot on empty buffer") + } + if len(snap) != 0 { + t.Fatalf("expected empty snapshot, got len=%d", len(snap)) + } +} + +// TestRingBufferReset verifies that Reset clears all rows and counters. +func TestRingBufferReset(t *testing.T) { + rb := NewRingBuffer() + for i := range 10 { + rb.Push(Row{Seq: uint64(i + 1)}) + } + + rb.Reset() + + if got := rb.Len(); got != 0 { + t.Fatalf("expected len=0 after reset, got %d", got) + } + if got := rb.TotalPushed(); got != 0 { + t.Fatalf("expected totalPushed=0 after reset, got %d", got) + } + snap := rb.Snapshot() + if len(snap) != 0 { + t.Fatalf("expected empty snapshot after reset, got len=%d", len(snap)) + } +} diff --git a/internal/streamrow/row_test.go b/internal/streamrow/row_test.go index 17d6c40..ea63bcc 100644 --- a/internal/streamrow/row_test.go +++ b/internal/streamrow/row_test.go @@ -98,3 +98,64 @@ func TestNewWarningPopulatesSyntheticWarningFields(t *testing.T) { t.Fatalf("RetVal/IsError = %d/%v, want -1/true", got.RetVal, got.IsError) } } + +// TestRowValueAccessors verifies that all typed accessor methods return the +// underlying field values set on a Row. +func TestRowValueAccessors(t *testing.T) { + r := Row{ + Syscall: "read", + Comm: "cat", + FileName: "/etc/hosts", + PID: 10, + TID: 11, + FD: 3, + DurationNs: 500, + GapNs: 200, + Bytes: 1024, + RetVal: -1, + IsError: true, + } + + if r.SyscallValue() != "read" { + t.Fatalf("SyscallValue = %q, want read", r.SyscallValue()) + } + if r.CommValue() != "cat" { + t.Fatalf("CommValue = %q, want cat", r.CommValue()) + } + if r.FileValue() != "/etc/hosts" { + t.Fatalf("FileValue = %q, want /etc/hosts", r.FileValue()) + } + if r.PIDValue() != 10 { + t.Fatalf("PIDValue = %d, want 10", r.PIDValue()) + } + if r.TIDValue() != 11 { + t.Fatalf("TIDValue = %d, want 11", r.TIDValue()) + } + if r.FDValue() != 3 { + t.Fatalf("FDValue = %d, want 3", r.FDValue()) + } + if r.LatencyValue() != 500 { + t.Fatalf("LatencyValue = %d, want 500", r.LatencyValue()) + } + if r.GapValue() != 200 { + t.Fatalf("GapValue = %d, want 200", r.GapValue()) + } + if r.BytesValue() != 1024 { + t.Fatalf("BytesValue = %d, want 1024", r.BytesValue()) + } + if r.ReturnValue() != -1 { + t.Fatalf("ReturnValue = %d, want -1", r.ReturnValue()) + } + if !r.ErrorValue() { + t.Fatal("ErrorValue = false, want true") + } +} + +// TestSequencerNilSafeNext verifies that calling Next on a nil Sequencer returns +// 0 without panicking. +func TestSequencerNilSafeNext(t *testing.T) { + var s *Sequencer + if got := s.Next(); got != 0 { + t.Fatalf("nil Sequencer.Next() = %d, want 0", got) + } +} 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) + } +} |
