summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/export/snapshot_csv_test.go141
-rw-r--r--internal/streamrow/ringbuffer_test.go104
-rw-r--r--internal/streamrow/row_test.go61
-rw-r--r--internal/tui/common/table_test.go209
-rw-r--r--internal/tui/export/export_extra_test.go195
-rw-r--r--internal/tui/pidpicker/pidpicker_extra_test.go192
-rw-r--r--internal/tui/probes/probes_extra_test.go355
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)
+ }
+}