summaryrefslogtreecommitdiff
path: root/internal/tui/flamegraph/async_refresh_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tui/flamegraph/async_refresh_test.go')
-rw-r--r--internal/tui/flamegraph/async_refresh_test.go204
1 files changed, 204 insertions, 0 deletions
diff --git a/internal/tui/flamegraph/async_refresh_test.go b/internal/tui/flamegraph/async_refresh_test.go
new file mode 100644
index 0000000..d6027de
--- /dev/null
+++ b/internal/tui/flamegraph/async_refresh_test.go
@@ -0,0 +1,204 @@
+package flamegraph
+
+import (
+ "testing"
+ "time"
+
+ coreflamegraph "ior/internal/flamegraph"
+ "ior/internal/types"
+
+ tea "charm.land/bubbletea/v2"
+)
+
+func ingestTwoEventsForAsync(t *testing.T, trie *coreflamegraph.LiveTrie) {
+ t.Helper()
+ for i := 0; i < 2; i++ {
+ traceID := types.SYS_ENTER_READ
+ if i%2 == 0 {
+ traceID = types.SYS_ENTER_WRITE
+ }
+ pair := newBenchmarkPair("worker", traceID, uint32(1000+i), uint32(200000+i), "/srv/app")
+ trie.Ingest(pair)
+ pair.Recycle()
+ }
+}
+
+func TestRefreshFromLiveTrieCmdNilWhenNoTrie(t *testing.T) {
+ m := NewModel(nil)
+ if cmd := m.RefreshFromLiveTrieCmd(); cmd != nil {
+ t.Fatalf("expected nil command when liveTrie is nil")
+ }
+}
+
+func TestRefreshFromLiveTrieCmdProducesSnapshotReady(t *testing.T) {
+ trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count")
+ ingestTwoEventsForAsync(t, trie)
+ m := NewModel(trie)
+ m.width = 120
+ m.height = 30
+
+ cmd := m.RefreshFromLiveTrieCmd()
+ if cmd == nil {
+ t.Fatalf("expected a refresh command when trie has new events")
+ }
+ if !m.refreshInFlight {
+ t.Fatalf("expected refreshInFlight=true after dispatch")
+ }
+
+ msg := cmd()
+ ready, ok := msg.(flameSnapshotReadyMsg)
+ if !ok {
+ t.Fatalf("expected flameSnapshotReadyMsg, got %T", msg)
+ }
+ if ready.snapshot == nil {
+ t.Fatalf("expected snapshot in ready message")
+ }
+ if ready.layoutWidth != 120 || ready.layoutHeight != 30 {
+ t.Fatalf("ready msg layout = %dx%d, want 120x30", ready.layoutWidth, ready.layoutHeight)
+ }
+ if ready.version == 0 {
+ t.Fatalf("expected non-zero version after ingestion")
+ }
+ if len(ready.ancestry.parent) != len(ready.targetFrames) {
+ t.Fatalf("ancestry length %d != frames length %d", len(ready.ancestry.parent), len(ready.targetFrames))
+ }
+}
+
+func TestRefreshFromLiveTrieCmdCoalescesInFlight(t *testing.T) {
+ trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count")
+ ingestTwoEventsForAsync(t, trie)
+ m := NewModel(trie)
+ m.width = 80
+ m.height = 24
+
+ if first := m.RefreshFromLiveTrieCmd(); first == nil {
+ t.Fatalf("expected first cmd to dispatch")
+ }
+ if second := m.RefreshFromLiveTrieCmd(); second != nil {
+ t.Fatalf("expected second cmd to coalesce (return nil) while a refresh is in flight")
+ }
+}
+
+func TestRefreshFromLiveTrieCmdSkippedWhileUserDrives(t *testing.T) {
+ trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count")
+ ingestTwoEventsForAsync(t, trie)
+ m := NewModel(trie)
+ m.width = 80
+ m.height = 24
+
+ // Load an initial snapshot so the drive gate (which requires an existing
+ // snapshot to skip) takes effect.
+ if !m.RefreshFromLiveTrie() {
+ t.Fatalf("expected initial sync refresh to populate snapshot")
+ }
+ ingestTwoEventsForAsync(t, trie)
+
+ m.lastKeyAt = time.Now()
+ if cmd := m.RefreshFromLiveTrieCmd(); cmd != nil {
+ t.Fatalf("expected refresh to be skipped while user is actively pressing keys")
+ }
+
+ // Move the timestamp outside the drive window — should dispatch.
+ m.lastKeyAt = time.Now().Add(-2 * driveWindow)
+ if cmd := m.RefreshFromLiveTrieCmd(); cmd == nil {
+ t.Fatalf("expected refresh to dispatch once drive window expires")
+ }
+}
+
+func TestSnapshotReadyHandlerSnapsToTargetWhileDriving(t *testing.T) {
+ trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count")
+ ingestTwoEventsForAsync(t, trie)
+ m := NewModel(trie)
+ m.width = 120
+ m.height = 30
+ m.refreshInFlight = true
+
+ cmd := func() tea.Cmd {
+ liveTrie := trie
+ return func() tea.Msg {
+ tree, ver := liveTrie.SnapshotTree()
+ targetFrames := buildTerminalLayoutWithPath(tree, m.width, m.height, "")
+ ancestry := buildFrameAncestry(targetFrames)
+ return flameSnapshotReadyMsg{
+ version: ver,
+ layoutWidth: m.width,
+ layoutHeight: m.height,
+ snapshot: tree,
+ targetFrames: targetFrames,
+ ancestry: ancestry,
+ globalTotal: snapshotTotal(tree),
+ }
+ }
+ }()
+ msg := cmd().(flameSnapshotReadyMsg)
+
+ m.lastKeyAt = time.Now()
+ next, _ := m.handleSnapshotReady(msg)
+ post := next.(Model)
+ if post.animating {
+ t.Fatalf("expected snapshot ready to skip animation while user is driving")
+ }
+ if len(post.frames) != len(msg.targetFrames) {
+ t.Fatalf("expected frames to snap directly to target (len %d != %d)", len(post.frames), len(msg.targetFrames))
+ }
+}
+
+func TestViewCacheReusesContentWhenStateUnchanged(t *testing.T) {
+ trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count")
+ ingestTwoEventsForAsync(t, trie)
+ m := NewModel(trie)
+ m.width = 120
+ m.height = 30
+ if !m.RefreshFromLiveTrie() {
+ t.Fatalf("expected initial refresh to populate snapshot")
+ }
+
+ // Drain any pending animation so the cache path is exercised.
+ for m.animating {
+ nextModel, _ := m.Update(animTickMsg{})
+ m = nextModel.(Model)
+ }
+
+ first := m.View().Content
+ if !m.viewCache.valid {
+ t.Fatalf("expected viewCache to be marked valid after first View() call")
+ }
+ cachedAddr := &m.viewCache.content
+
+ second := m.View().Content
+ if first != second {
+ t.Fatalf("expected identical content from two consecutive View() calls when state unchanged")
+ }
+ if cachedAddr != &m.viewCache.content {
+ t.Fatalf("expected cache content pointer to remain stable on a hit")
+ }
+}
+
+func BenchmarkRecomputeFilterState(b *testing.B) {
+ // Performance target: 5000-frame filter recompute should remain below 200us/op.
+ cases := []struct {
+ label string
+ frameCount int
+ }{
+ {label: "1000frames", frameCount: 1000},
+ {label: "5000frames", frameCount: 5000},
+ }
+ queries := []string{"sys_", "read", "/srv"}
+
+ for _, tc := range cases {
+ frames := benchmarkFramesForCount(tc.frameCount)
+ decorateFramesForSearch(frames)
+ b.Run(tc.label, func(b *testing.B) {
+ model := NewModel(nil)
+ model.frames = frames
+ model.ancestry = buildFrameAncestry(frames)
+ model.selectedIdx = midDepthFrameIndex(frames)
+
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ model.applySearchQuery(queries[i%len(queries)])
+ benchIntSink = len(model.matchIndices)
+ }
+ })
+ }
+}