package eventstream import ( "encoding/csv" "os" "strings" "testing" ) func pushEvents(rb *RingBuffer, count int) { for i := 0; i < count; i++ { rb.Push(StreamEvent{ Seq: uint64(i), Syscall: map[bool]string{true: "read", false: "write"}[i%2 == 0], Comm: "proc", PID: 100, TID: uint32(100 + i), DurationNs: uint64(1000 + i), GapNs: uint64(10 + i), Bytes: uint64(64 + i), FileName: "/tmp/file", RetVal: int64(i), IsError: i%3 == 0, FD: UnknownFD, }) } } func TestModelPauseFreezesDisplay(t *testing.T) { rb := NewRingBuffer() m := NewModel(rb) m.height = 20 pushEvents(rb, 3) m.Refresh() if len(m.filtered) != 3 { t.Fatalf("filtered=%d, want 3", len(m.filtered)) } if !m.HandleKey("space") { t.Fatalf("space should be handled") } pushEvents(rb, 2) m.Refresh() if len(m.filtered) != 3 { t.Fatalf("paused refresh should not change filtered len, got %d", len(m.filtered)) } } func TestModelScrollClamp(t *testing.T) { rb := NewRingBuffer() m := NewModel(rb) m.height = 10 pushEvents(rb, 30) m.Refresh() for i := 0; i < 100; i++ { m.HandleKey("j") } if m.scrollOffset > m.maxScrollOffset() { t.Fatalf("scrollOffset=%d exceeds max=%d", m.scrollOffset, m.maxScrollOffset()) } for i := 0; i < 100; i++ { m.HandleKey("k") } if m.scrollOffset != 0 { t.Fatalf("scrollOffset=%d, want 0", m.scrollOffset) } } func TestModelPageScrollWithPgUpPgDown(t *testing.T) { rb := NewRingBuffer() m := NewModel(rb) m.height = 12 // visibleRows=4, pageStep=3 pushEvents(rb, 30) m.Refresh() m.HandleKey("g") if !m.HandleKey("pgdown") { t.Fatalf("pgdown should be handled") } if m.scrollOffset != 3 { t.Fatalf("expected page down to move by 3, got %d", m.scrollOffset) } if !m.HandleKey("pagedown") { t.Fatalf("pagedown should be handled") } if m.scrollOffset != 6 { t.Fatalf("expected pagedown alias to move by 3, got %d", m.scrollOffset) } if !m.HandleKey("pgup") { t.Fatalf("pgup should be handled") } if m.scrollOffset != 3 { t.Fatalf("expected page up to move up by 3, got %d", m.scrollOffset) } if !m.HandleKey("pageup") { t.Fatalf("pageup should be handled") } if m.scrollOffset != 0 { t.Fatalf("expected pageup alias to return to top, got %d", m.scrollOffset) } } func TestModelArrowAndJKScroll(t *testing.T) { rb := NewRingBuffer() m := NewModel(rb) m.height = 12 pushEvents(rb, 30) m.Refresh() m.HandleKey("g") if !m.HandleKey("down") { t.Fatalf("down should be handled") } if m.scrollOffset != 1 { t.Fatalf("expected down to increment offset, got %d", m.scrollOffset) } if !m.HandleKey("j") { t.Fatalf("j should be handled") } if m.scrollOffset != 2 { t.Fatalf("expected j to increment offset, got %d", m.scrollOffset) } if !m.HandleKey("up") { t.Fatalf("up should be handled") } if m.scrollOffset != 1 { t.Fatalf("expected up to decrement offset, got %d", m.scrollOffset) } if !m.HandleKey("k") { t.Fatalf("k should be handled") } if m.scrollOffset != 0 { t.Fatalf("expected k to decrement offset, got %d", m.scrollOffset) } } func TestModelFilterReducesVisibleRows(t *testing.T) { rb := NewRingBuffer() m := NewModel(rb) m.height = 20 pushEvents(rb, 10) m.Refresh() m.setFilterForTest(Filter{Syscall: &StringFilter{Pattern: "read"}}) m.applyFilter() if len(m.filtered) >= len(m.allEvents) { t.Fatalf("expected filtered rows to be less than all rows: filtered=%d all=%d", len(m.filtered), len(m.allEvents)) } } func TestModelAutoScrollBehavior(t *testing.T) { rb := NewRingBuffer() m := NewModel(rb) m.height = 10 pushEvents(rb, 12) m.Refresh() if m.scrollOffset != m.maxScrollOffset() { t.Fatalf("expected auto-scroll at bottom, got offset=%d max=%d", m.scrollOffset, m.maxScrollOffset()) } m.HandleKey("k") prev := m.scrollOffset pushEvents(rb, 3) m.Refresh() if m.scrollOffset != prev { t.Fatalf("when autoScroll=false, offset should stay %d, got %d", prev, m.scrollOffset) } m.HandleKey("G") if m.scrollOffset != m.maxScrollOffset() { t.Fatalf("G should jump to tail") } } func TestModelHandleKeyRouting(t *testing.T) { rb := NewRingBuffer() m := NewModel(rb) if m.HandleKey("x") { t.Fatalf("unknown key should not be handled") } if !m.HandleKey("f") { t.Fatalf("f should be handled") } if !m.filterModal.Visible() { t.Fatalf("modal should be visible after f") } if !m.HandleKey("esc") { t.Fatalf("esc should route to modal") } if m.filterModal.Visible() { t.Fatalf("modal should close on esc") } } func TestFilterModalTemporarilyPausesAndRestoresState(t *testing.T) { rb := NewRingBuffer() m := NewModel(rb) m.height = 20 pushEvents(rb, 4) m.Refresh() if m.paused { t.Fatalf("expected model to start unpaused") } if !m.HandleKey("f") { t.Fatalf("f should be handled") } if !m.paused { t.Fatalf("expected model paused while filter modal is open") } if !m.filterModal.Visible() { t.Fatalf("expected filter modal visible after f") } if !m.HandleKey("esc") { t.Fatalf("esc should be routed to filter modal") } if m.filterModal.Visible() { t.Fatalf("expected filter modal closed after esc") } if m.paused { t.Fatalf("expected pause state restored to unpaused after modal close") } // If the user was already paused before opening the filter modal, // that pause state should remain after closing. if !m.HandleKey("space") { t.Fatalf("space should toggle pause") } if !m.paused { t.Fatalf("expected paused=true after space") } if !m.HandleKey("f") { t.Fatalf("f should be handled while paused") } if !m.HandleKey("esc") { t.Fatalf("esc should close modal") } if !m.paused { t.Fatalf("expected paused state preserved after modal close") } } func TestUnpauseRestoresLiveTailAndRefresh(t *testing.T) { rb := NewRingBuffer() m := NewModel(rb) m.height = 10 pushEvents(rb, 20) m.Refresh() // Move off tail, then pause. m.HandleKey("g") if m.autoScroll { t.Fatalf("expected autoScroll disabled at top") } m.HandleKey("space") if !m.paused { t.Fatalf("expected paused") } // New events arrive while paused. pushEvents(rb, 5) m.Refresh() // Resume: should auto-tail and refresh immediately. m.HandleKey("space") if m.paused { t.Fatalf("expected unpaused") } if !m.autoScroll { t.Fatalf("expected autoScroll restored on resume") } if m.scrollOffset != m.maxScrollOffset() { t.Fatalf("expected tail offset after resume, got offset=%d max=%d", m.scrollOffset, m.maxScrollOffset()) } } func TestPausedScrollWithJKAndPageKeys(t *testing.T) { rb := NewRingBuffer() m := NewModel(rb) m.height = 20 pushEvents(rb, 100) m.Refresh() if !m.HandleKey("space") { t.Fatalf("space should toggle pause") } before := rowNumber(m.scrollOffset, len(m.filtered)) if !m.HandleKey("k") { t.Fatalf("k should be handled while paused") } afterK := rowNumber(m.scrollOffset, len(m.filtered)) if afterK >= before { t.Fatalf("expected k to scroll up while paused: before=%d after=%d", before, afterK) } if !m.HandleKey("pgup") { t.Fatalf("pgup should be handled while paused") } afterPgUp := rowNumber(m.scrollOffset, len(m.filtered)) if afterPgUp >= afterK { t.Fatalf("expected pgup to scroll up while paused: afterK=%d afterPgUp=%d", afterK, afterPgUp) } if !m.HandleKey("pgdown") { t.Fatalf("pgdown should be handled while paused") } afterPgDown := rowNumber(m.scrollOffset, len(m.filtered)) if afterPgDown <= afterPgUp { t.Fatalf("expected pgdown to scroll down while paused: afterPgUp=%d afterPgDown=%d", afterPgUp, afterPgDown) } } func TestPausedSelectionInitializesNearMiddleAndCenters(t *testing.T) { rb := NewRingBuffer() m := NewModel(rb) m.height = 20 // visibleRows = 12 pushEvents(rb, 100) m.Refresh() if !m.HandleKey("space") { t.Fatalf("space should toggle pause") } if !m.paused { t.Fatalf("expected paused state") } if m.selectedIdx < 0 { t.Fatalf("expected selected index while paused") } mid := m.visibleRows() / 2 wantOffset := clamp(m.selectedIdx-mid, 0, m.maxScrollOffset()) if m.scrollOffset != wantOffset { t.Fatalf("expected centered offset %d, got %d", wantOffset, m.scrollOffset) } } func TestPausedSelectionMovesAndRecentersWithJKAndArrows(t *testing.T) { rb := NewRingBuffer() m := NewModel(rb) m.height = 20 // visibleRows = 12 pushEvents(rb, 100) m.Refresh() if !m.HandleKey("g") { t.Fatalf("g should be handled") } if !m.HandleKey("space") { t.Fatalf("space should toggle pause") } startSel := m.selectedIdx if !m.HandleKey("j") { t.Fatalf("j should be handled while paused") } if m.selectedIdx != startSel+1 { t.Fatalf("expected selected index +1 after j, got %d->%d", startSel, m.selectedIdx) } mid := m.visibleRows() / 2 if m.scrollOffset != clamp(m.selectedIdx-mid, 0, m.maxScrollOffset()) { t.Fatalf("expected centered viewport after j") } if !m.HandleKey("up") { t.Fatalf("up should be handled while paused") } if m.selectedIdx != startSel { t.Fatalf("expected selected index back to start after up, got %d", m.selectedIdx) } if m.scrollOffset != clamp(m.selectedIdx-mid, 0, m.maxScrollOffset()) { t.Fatalf("expected centered viewport after up") } } func TestPausedSelectionMovesAcrossColumnsWithLeftRightAndHL(t *testing.T) { rb := NewRingBuffer() m := NewModel(rb) m.height = 20 pushEvents(rb, 100) m.Refresh() if !m.HandleKey("space") { t.Fatalf("space should toggle pause") } startCol := m.selectedCol startRow := m.selectedIdx if !m.HandleKey("right") { t.Fatalf("right should be handled while paused") } if m.selectedCol != startCol+1 { t.Fatalf("expected selected col +1 after right, got %d->%d", startCol, m.selectedCol) } if m.selectedIdx != startRow { t.Fatalf("expected selected row unchanged after right, got %d->%d", startRow, m.selectedIdx) } if !m.HandleKey("l") { t.Fatalf("l should be handled while paused") } if m.selectedCol != startCol+2 { t.Fatalf("expected selected col +2 after l, got %d", m.selectedCol) } if !m.HandleKey("left") { t.Fatalf("left should be handled while paused") } if !m.HandleKey("h") { t.Fatalf("h should be handled while paused") } if m.selectedCol != startCol { t.Fatalf("expected selected col back to start, got %d", m.selectedCol) } } func TestPausedEnterAddsStackableFiltersAndEscUndoes(t *testing.T) { rb := NewRingBuffer() rb.Push(StreamEvent{Seq: 1, PID: 10, TID: 101, Comm: "firefox", Syscall: "read", FD: 5, RetVal: 10, Bytes: 10, GapNs: 50, DurationNs: 100, FileName: "/a"}) rb.Push(StreamEvent{Seq: 2, PID: 10, TID: 102, Comm: "firefox", Syscall: "write", FD: 6, RetVal: 11, Bytes: 11, GapNs: 51, DurationNs: 101, FileName: "/b"}) rb.Push(StreamEvent{Seq: 3, PID: 11, TID: 201, Comm: "bash", Syscall: "read", FD: 7, RetVal: 12, Bytes: 12, GapNs: 52, DurationNs: 102, FileName: "/c"}) m := NewModel(rb) m.height = 20 m.Refresh() if !m.HandleKey("space") { t.Fatalf("space should toggle pause") } m.selectedIdx = 0 m.selectedCol = streamColComm if !m.HandleKey("enter") { t.Fatalf("enter should add filter from selected comm cell") } if m.filter.Comm == nil || m.filter.Comm.Pattern != "firefox" { t.Fatalf("expected comm filter firefox, got %+v", m.filter.Comm) } if len(m.filtered) != 2 { t.Fatalf("expected 2 rows after comm filter, got %d", len(m.filtered)) } if len(m.filterStack) != 1 { t.Fatalf("expected filter stack len 1, got %d", len(m.filterStack)) } if got := m.View(120, 24); !strings.Contains(got, "Stack: comm~firefox") { t.Fatalf("expected stack status line with comm filter, got:\n%s", got) } m.selectedIdx = 1 m.selectedCol = streamColTID if !m.HandleKey("enter") { t.Fatalf("enter should add filter from selected tid cell") } if m.filter.TID == nil || m.filter.TID.Value != 102 { t.Fatalf("expected tid filter 102, got %+v", m.filter.TID) } if m.filter.Comm == nil || m.filter.Comm.Pattern != "firefox" { t.Fatalf("expected comm filter preserved, got %+v", m.filter.Comm) } if len(m.filtered) != 1 { t.Fatalf("expected 1 row after comm+tid filters, got %d", len(m.filtered)) } if !m.HandleKey("esc") { t.Fatalf("esc should undo last filter") } if m.filter.TID != nil { t.Fatalf("expected tid filter cleared after undo") } if m.filter.Comm == nil || m.filter.Comm.Pattern != "firefox" { t.Fatalf("expected comm filter kept after first undo") } if len(m.filtered) != 2 { t.Fatalf("expected 2 rows after undo tid filter, got %d", len(m.filtered)) } if !m.HandleKey("esc") { t.Fatalf("esc should undo previous filter") } if m.filter.IsActive() { t.Fatalf("expected no active filters after second undo, got summary=%q", m.filter.Summary()) } if len(m.filtered) != 3 { t.Fatalf("expected all rows after second undo, got %d", len(m.filtered)) } if got := m.View(120, 24); strings.Contains(got, "Stack:") { t.Fatalf("did not expect stack status line after all undos, got:\n%s", got) } } func TestPausedEnterCanFilterLatencyAndGapColumns(t *testing.T) { rb := NewRingBuffer() rb.Push(StreamEvent{Seq: 1, PID: 1, TID: 1, Comm: "a", DurationNs: 100, GapNs: 5}) rb.Push(StreamEvent{Seq: 2, PID: 1, TID: 2, Comm: "b", DurationNs: 200, GapNs: 6}) m := NewModel(rb) m.height = 20 m.Refresh() _ = m.HandleKey("space") m.selectedIdx = 0 m.selectedCol = streamColLatency if !m.HandleKey("enter") { t.Fatalf("expected enter to filter by latency") } if m.filter.LatencyNs == nil || m.filter.LatencyNs.Op != OpGte || m.filter.LatencyNs.Value != 100 { t.Fatalf("expected latency filter >=100ns, got %+v", m.filter.LatencyNs) } if len(m.filtered) != 2 { t.Fatalf("expected both rows after latency >=100 filter, got %d", len(m.filtered)) } m.selectedCol = streamColGap if !m.HandleKey("enter") { t.Fatalf("expected enter to filter by gap") } if m.filter.GapNs == nil || m.filter.GapNs.Op != OpGte || m.filter.GapNs.Value != 5 { t.Fatalf("expected gap filter >=5ns, got %+v", m.filter.GapNs) } if len(m.filtered) != 2 { t.Fatalf("expected both rows after gap >=5 filter, got %d", len(m.filtered)) } } func TestPausedEnterKeepsSelectedRowCenteredAfterFilter(t *testing.T) { rb := NewRingBuffer() for i := 0; i < 300; i++ { comm := "other" if i >= 90 && i <= 210 { comm = "match" } rb.Push(StreamEvent{ Seq: uint64(i + 1), PID: 1000, TID: uint32(2000 + i), Comm: comm, Syscall: "read", FileName: "/tmp/f", }) } m := NewModel(rb) m.height = 20 m.Refresh() _ = m.HandleKey("space") m.moveSelectionTo(150) before := m.selectedIdx - m.scrollOffset if before < 4 || before > 8 { t.Fatalf("expected initial selected row near middle, got relative idx %d", before) } m.selectedCol = streamColComm if !m.HandleKey("enter") { t.Fatalf("expected enter to apply filter") } after := m.selectedIdx - m.scrollOffset if after < 4 || after > 8 { t.Fatalf("expected selected row to stay near middle after filter, got relative idx %d", after) } } func TestPausedEnterCanFilterByFDColumn(t *testing.T) { rb := NewRingBuffer() rb.Push(StreamEvent{Seq: 1, PID: 10, TID: 101, FD: 3, Syscall: "read", FileName: "/a"}) rb.Push(StreamEvent{Seq: 2, PID: 10, TID: 102, FD: 3, Syscall: "write", FileName: "/a"}) rb.Push(StreamEvent{Seq: 3, PID: 10, TID: 103, FD: 4, Syscall: "read", FileName: "/b"}) rb.Push(StreamEvent{Seq: 4, PID: 11, TID: 104, FD: 3, Syscall: "read", FileName: "/c"}) m := NewModel(rb) m.height = 20 m.Refresh() if !m.HandleKey("space") { t.Fatalf("space should pause") } m.selectedIdx = 0 m.selectedCol = streamColFD if !m.HandleKey("enter") { t.Fatalf("enter should add fd filter") } if m.filter.FD == nil || m.filter.FD.Value != 3 { t.Fatalf("expected fd filter 3, got %+v", m.filter.FD) } if len(m.filtered) != 3 { t.Fatalf("expected 3 rows with fd=3, got %d", len(m.filtered)) } } func TestPausedQuickExportWritesFilteredRows(t *testing.T) { rb := NewRingBuffer() rb.Push(StreamEvent{Seq: 1, Comm: "firefox", PID: 10, TID: 100, Syscall: "read", FileName: "/a"}) rb.Push(StreamEvent{Seq: 2, Comm: "bash", PID: 11, TID: 200, Syscall: "write", FileName: "/b"}) rb.Push(StreamEvent{Seq: 3, Comm: "firefox", PID: 12, TID: 300, Syscall: "open", FileName: "/c"}) m := NewModel(rb) m.height = 20 m.setExportDirForTest(t.TempDir()) m.Refresh() if !m.HandleKey("space") { t.Fatalf("space should pause") } m.selectedIdx = 0 m.selectedCol = streamColComm if !m.HandleKey("enter") { t.Fatalf("enter should apply comm filter") } if len(m.filtered) != 2 { t.Fatalf("expected 2 filtered rows before export, got %d", len(m.filtered)) } if !m.HandleKey("x") { t.Fatalf("x should quick-export while paused") } if m.lastExportPath == "" { t.Fatalf("expected last export path to be set") } records := readCSVRecords(t, m.lastExportPath) if len(records) != 3 { t.Fatalf("expected header + 2 rows in export, got %d records", len(records)) } if records[1][4] != "firefox" || records[2][4] != "firefox" { t.Fatalf("expected only firefox rows exported, got %q and %q", records[1][4], records[2][4]) } } func TestPausedExportAsModalSavesWithProvidedFilename(t *testing.T) { rb := NewRingBuffer() rb.Push(StreamEvent{Seq: 1, Comm: "proc", PID: 1, TID: 1, Syscall: "read"}) m := NewModel(rb) m.height = 20 m.setExportDirForTest(t.TempDir()) m.Refresh() _ = m.HandleKey("space") if !m.HandleKey("X") { t.Fatalf("X should open export modal while paused") } if !m.exportModal.Visible() { t.Fatalf("expected export modal visible") } // Replace default value fully and submit. m.exportModal = m.exportModal.Open("custom-name") if !m.HandleKey("enter") { t.Fatalf("enter should submit export modal") } if m.exportModal.Visible() { t.Fatalf("expected export modal closed after submit") } if !strings.HasSuffix(m.lastExportPath, "custom-name.csv") { t.Fatalf("expected custom-name.csv export path, got %q", m.lastExportPath) } if _, err := os.Stat(m.lastExportPath); err != nil { t.Fatalf("expected exported file to exist: %v", err) } } func TestPausedOpenLastExportQueuesRequest(t *testing.T) { rb := NewRingBuffer() rb.Push(StreamEvent{Seq: 1, Comm: "proc", PID: 1, TID: 1, Syscall: "read"}) m := NewModel(rb) m.height = 20 m.setExportDirForTest(t.TempDir()) m.Refresh() _ = m.HandleKey("space") _ = m.HandleKey("x") if !m.HandleKey("E") { t.Fatalf("E should queue opening last export while paused") } path, ok := m.ConsumeOpenEditorRequest() if !ok { t.Fatalf("expected queued open-editor request") } if path != m.lastExportPath { t.Fatalf("expected opened path %q, got %q", m.lastExportPath, path) } if _, ok := m.ConsumeOpenEditorRequest(); ok { t.Fatalf("expected request to be consumed once") } } func TestRegexSearchForwardBackwardAndRepeat(t *testing.T) { rb := NewRingBuffer() rb.Push(StreamEvent{Seq: 1, Comm: "alpha", PID: 10, TID: 100, Syscall: "read", FileName: "/tmp/a"}) rb.Push(StreamEvent{Seq: 2, Comm: "beta", PID: 11, TID: 110, Syscall: "write", FileName: "/tmp/b"}) rb.Push(StreamEvent{Seq: 3, Comm: "gamma", PID: 12, TID: 120, Syscall: "open", FileName: "/tmp/c"}) rb.Push(StreamEvent{Seq: 4, Comm: "beta", PID: 13, TID: 130, Syscall: "close", FileName: "/tmp/d"}) m := NewModel(rb) m.height = 20 m.Refresh() _ = m.HandleKey("space") m.moveSelectionTo(0) if !m.HandleKey("/") { t.Fatalf("/ should open search modal") } if !m.searchModal.Visible() { t.Fatalf("expected search modal visible") } if !m.HandleKey("b") || !m.HandleKey("e") || !m.HandleKey("t") || !m.HandleKey("a") { t.Fatalf("expected term typing keys handled") } if !m.HandleKey("enter") { t.Fatalf("enter should submit search") } if m.selectedIdx != 1 { t.Fatalf("expected first forward beta hit at idx 1, got %d", m.selectedIdx) } if m.searchDirection != SearchForward { t.Fatalf("expected search direction forward") } if !m.HandleKey("n") { t.Fatalf("n should jump to next hit") } if m.selectedIdx != 3 { t.Fatalf("expected next forward beta hit at idx 3, got %d", m.selectedIdx) } if !m.HandleKey("N") { t.Fatalf("N should jump opposite direction") } if m.selectedIdx != 1 { t.Fatalf("expected opposite-direction beta hit at idx 1, got %d", m.selectedIdx) } if !m.HandleKey("?") { t.Fatalf("? should open backward search modal") } if !m.HandleKey("enter") { t.Fatalf("enter should submit backward search") } if m.selectedIdx != 3 { t.Fatalf("expected backward beta hit at idx 3, got %d", m.selectedIdx) } if m.searchDirection != SearchBackward { t.Fatalf("expected search direction backward") } } func readCSVRecords(t *testing.T, path string) [][]string { t.Helper() f, err := os.Open(path) if err != nil { t.Fatalf("open csv: %v", err) } defer f.Close() r := csv.NewReader(f) records, err := r.ReadAll() if err != nil { t.Fatalf("read csv: %v", err) } return records }