diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-06 17:32:24 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-06 17:32:24 +0200 |
| commit | 1561987330cb898f5ff64383a9c78e7e6559f118 (patch) | |
| tree | 69a823e8f98dce572566c97e6879c11c9d591bda /internal/tui/flamegraph/model_test.go | |
| parent | 96225fb6159212a8851043a08d781aba721b4e78 (diff) | |
| parent | 110a193e04b81abb8d8e159abd73f9f6ed1acd7e (diff) | |
Merge branch 'feat/bubbletea-v2-migration'
Diffstat (limited to 'internal/tui/flamegraph/model_test.go')
| -rw-r--r-- | internal/tui/flamegraph/model_test.go | 987 |
1 files changed, 987 insertions, 0 deletions
diff --git a/internal/tui/flamegraph/model_test.go b/internal/tui/flamegraph/model_test.go new file mode 100644 index 0000000..74ce8d9 --- /dev/null +++ b/internal/tui/flamegraph/model_test.go @@ -0,0 +1,987 @@ +package flamegraph + +import ( + "reflect" + "strings" + "testing" + + coreflamegraph "ior/internal/flamegraph" + + tea "charm.land/bubbletea/v2" +) + +func TestNewModelDefaults(t *testing.T) { + m := NewModel(nil) + if m.liveTrie != nil { + t.Fatalf("expected nil liveTrie when constructor input is nil") + } + if m.matchIndices == nil { + t.Fatalf("expected matchIndices map to be initialized") + } + if len(m.fieldPresets) == 0 { + t.Fatalf("expected default field presets to be initialized") + } + if got, want := m.fieldPresets[0], []string{"comm", "tracepoint", "path"}; !reflect.DeepEqual(got, want) { + t.Fatalf("default field preset[0] = %v, want %v", got, want) + } + if !m.isDark { + t.Fatalf("expected dark mode enabled by default") + } +} + +func TestSetViewportAndDarkMode(t *testing.T) { + m := NewModel(nil) + m.SetViewport(120, 40) + m.SetDarkMode(false) + if m.width != 120 || m.height != 40 { + t.Fatalf("expected viewport 120x40, got %dx%d", m.width, m.height) + } + if m.isDark { + t.Fatalf("expected dark mode to be disabled") + } +} + +func TestRefreshFromLiveTrieTracksVersionAndSnapshot(t *testing.T) { + trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count") + m := NewModel(trie) + + if changed := m.RefreshFromLiveTrie(); !changed { + t.Fatalf("expected first refresh to load baseline snapshot") + } + if m.snapshot == nil { + t.Fatalf("expected snapshot to be populated after refresh") + } + + if changed := m.RefreshFromLiveTrie(); changed { + t.Fatalf("expected no refresh when version is unchanged") + } +} + +func TestRefreshFromLiveTrieAllowsInitialLoadWhilePaused(t *testing.T) { + trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count") + m := NewModel(trie) + m.paused = true + + if changed := m.RefreshFromLiveTrie(); !changed { + t.Fatalf("expected initial paused refresh to load first snapshot") + } + if m.snapshot == nil { + t.Fatalf("expected snapshot to be available after initial paused refresh") + } + if changed := m.RefreshFromLiveTrie(); changed { + t.Fatalf("expected subsequent paused refresh to be skipped once snapshot exists") + } +} + +func TestRefreshFromLiveTriePausedBlocksAfterNavigableSnapshot(t *testing.T) { + trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count") + m := NewModel(trie) + m.paused = true + m.snapshot = &snapshotNode{Name: "root", Total: 1} + m.frames = []tuiFrame{ + {Name: "root", Path: "root"}, + {Name: "child", Path: "root" + pathSeparator + "child"}, + } + m.hasNavigableSnapshot = true + m.lastVersion = 1 + + if changed := m.RefreshFromLiveTrie(); changed { + t.Fatalf("expected paused refresh to remain frozen once navigable snapshot exists") + } + if got, want := m.lastVersion, uint64(1); got != want { + t.Fatalf("expected version to remain unchanged while paused, got %d want %d", got, want) + } +} + +func TestRefreshFromLiveTriePausedBlocksAfterAnySnapshot(t *testing.T) { + trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count") + m := NewModel(trie) + m.paused = true + m.snapshot = &snapshotNode{Name: "root", Total: 1} + m.frames = []tuiFrame{{Name: "root", Path: "root"}} + m.hasNavigableSnapshot = false + m.lastVersion = 1 + + if changed := m.RefreshFromLiveTrie(); changed { + t.Fatalf("expected paused refresh to freeze after first snapshot even when non-navigable") + } + if got, want := m.lastVersion, uint64(1); got != want { + t.Fatalf("expected paused refresh to keep existing snapshot version, got %d want %d", got, want) + } +} + +func TestKeyboardNavigationDeepNarrowTree(t *testing.T) { + m := NewModel(nil) + m.frames = []tuiFrame{ + {Name: "root", Depth: 0, Col: 0, Path: "root"}, + {Name: "child", Depth: 1, Col: 0, Path: "root" + pathSeparator + "child"}, + {Name: "leaf", Depth: 2, Col: 0, Path: "root" + pathSeparator + "child" + pathSeparator + "leaf"}, + } + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'k'}[0], Text: "k"}) + if m.selectedIdx != 1 { + t.Fatalf("expected selection to move deeper to idx 1, got %d", m.selectedIdx) + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'k'}[0], Text: "k"}) + if m.selectedIdx != 2 { + t.Fatalf("expected selection to move deeper to idx 2, got %d", m.selectedIdx) + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'j'}[0], Text: "j"}) + if m.selectedIdx != 1 { + t.Fatalf("expected selection to move shallower to idx 1, got %d", m.selectedIdx) + } +} + +func TestKeyboardNavigationShallowWideSiblings(t *testing.T) { + m := NewModel(nil) + m.frames = []tuiFrame{ + {Name: "root", Depth: 0, Col: 0, Path: "root"}, + {Name: "A", Depth: 1, Col: 0, Path: "root" + pathSeparator + "A"}, + {Name: "B", Depth: 1, Col: 30, Path: "root" + pathSeparator + "B"}, + {Name: "C", Depth: 1, Col: 60, Path: "root" + pathSeparator + "C"}, + } + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'k'}[0], Text: "k"}) + if m.selectedIdx != 1 { + t.Fatalf("expected first deeper frame to be A, got idx %d", m.selectedIdx) + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'l'}[0], Text: "l"}) + if m.selectedIdx != 2 { + t.Fatalf("expected next sibling B, got idx %d", m.selectedIdx) + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'l'}[0], Text: "l"}) + if m.selectedIdx != 3 { + t.Fatalf("expected next sibling C, got idx %d", m.selectedIdx) + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'l'}[0], Text: "l"}) + if m.selectedIdx != 3 { + t.Fatalf("expected selection to clamp at last sibling, got idx %d", m.selectedIdx) + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'h'}[0], Text: "h"}) + if m.selectedIdx != 2 { + t.Fatalf("expected previous sibling B, got idx %d", m.selectedIdx) + } +} + +func TestHorizontalTraversalFallbackFromRoot(t *testing.T) { + m := NewModel(nil) + m.frames = []tuiFrame{ + {Name: "root", Depth: 0, Col: 0, Path: "root"}, + {Name: "A", Depth: 1, Col: 0, Path: "root" + pathSeparator + "A"}, + {Name: "B", Depth: 1, Col: 30, Path: "root" + pathSeparator + "B"}, + } + m.selectedIdx = 0 + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyRight}) + if m.selectedIdx != 1 { + t.Fatalf("expected right arrow from root to move to first traversable frame, got idx %d", m.selectedIdx) + } + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'l'}[0], Text: "l"}) + if m.selectedIdx != 2 { + t.Fatalf("expected vi right key to move to next frame, got idx %d", m.selectedIdx) + } + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyLeft}) + if m.selectedIdx != 1 { + t.Fatalf("expected left arrow to move back to previous frame, got idx %d", m.selectedIdx) + } + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'h'}[0], Text: "h"}) + if m.selectedIdx != 0 { + t.Fatalf("expected vi left key to move back to root, got idx %d", m.selectedIdx) + } +} + +func TestPageUpJumpsSelectionToTopMostDepth(t *testing.T) { + m := NewModel(nil) + m.frames = []tuiFrame{ + {Name: "root", Depth: 0, Col: 0, Path: "root"}, + {Name: "A", Depth: 1, Col: 0, Path: "root" + pathSeparator + "A"}, + {Name: "B", Depth: 1, Col: 40, Path: "root" + pathSeparator + "B"}, + {Name: "A1", Depth: 2, Col: 0, Path: "root" + pathSeparator + "A" + pathSeparator + "A1"}, + {Name: "B1", Depth: 2, Col: 40, Path: "root" + pathSeparator + "B" + pathSeparator + "B1"}, + {Name: "A2", Depth: 3, Col: 0, Path: "root" + pathSeparator + "A" + pathSeparator + "A1" + pathSeparator + "A2"}, + {Name: "B2", Depth: 3, Col: 40, Path: "root" + pathSeparator + "B" + pathSeparator + "B1" + pathSeparator + "B2"}, + } + m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"B"+pathSeparator+"B1") + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyPgUp}) + if got, want := m.frames[m.selectedIdx].Path, "root"+pathSeparator+"B"+pathSeparator+"B1"+pathSeparator+"B2"; got != want { + t.Fatalf("expected pgup to jump to deepest top frame %q, got %q", want, got) + } +} + +func TestPageDownJumpsSelectionToCurrentViewRoot(t *testing.T) { + m := newZoomModel() + m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A") + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A"+pathSeparator+"A1") + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyPgDown}) + if got, want := m.frames[m.selectedIdx].Path, "root"+pathSeparator+"A"; got != want { + t.Fatalf("expected pgdn to jump to current zoom root %q, got %q", want, got) + } +} + +func TestPausedStateStillAllowsNavigation(t *testing.T) { + m := NewModel(nil) + m.frames = []tuiFrame{ + {Name: "root", Depth: 0, Col: 0, Path: "root"}, + {Name: "A", Depth: 1, Col: 0, Path: "root" + pathSeparator + "A"}, + } + m.paused = true + m.selectedIdx = 0 + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyRight}) + if m.selectedIdx != 1 { + t.Fatalf("expected navigation to work while paused, got idx %d", m.selectedIdx) + } +} + +func TestStaticFixtureArrowTraversalVisitsAllFrames(t *testing.T) { + trie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") + coreflamegraph.SeedTestFlameData(trie) + + m := NewModel(trie) + m.SetViewport(180, 40) + if changed := m.RefreshFromLiveTrie(); !changed { + t.Fatalf("expected seeded fixture refresh to load frames") + } + if len(m.frames) < 2 { + t.Fatalf("expected seeded fixture to contain navigable frames, got %d", len(m.frames)) + } + + visited := map[int]bool{m.selectedIdx: true} + for i := 0; i < len(m.frames)*4; i++ { + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyRight}) + visited[m.selectedIdx] = true + } + for i := 0; i < len(m.frames)*4; i++ { + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyLeft}) + visited[m.selectedIdx] = true + } + + if got, want := len(visited), len(m.frames); got != want { + t.Fatalf("expected arrow traversal to visit all frames: visited=%d frames=%d", got, want) + } + if !strings.Contains(m.View().Content, "sel:") { + t.Fatalf("expected view to expose selected-frame status line") + } +} + +func TestLiveFixtureArrowTraversalWhileStreamingVisitsAllFrames(t *testing.T) { + trie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") + coreflamegraph.SeedTestLiveFlameData(trie, 0) + + m := NewModel(trie) + m.SetViewport(180, 40) + if changed := m.RefreshFromLiveTrie(); !changed { + t.Fatalf("expected initial refresh to load frames") + } + if len(m.frames) < 2 { + t.Fatalf("expected seeded fixture to contain navigable frames, got %d", len(m.frames)) + } + + selectedPath := func(model Model) string { + if len(model.frames) == 0 || model.selectedIdx < 0 || model.selectedIdx >= len(model.frames) { + return "" + } + return model.frames[model.selectedIdx].Path + } + + visitedPaths := map[string]bool{selectedPath(m): true} + moves := 0 + for i := 0; i < len(m.frames)*4; i++ { + trie.Reset() + coreflamegraph.SeedTestLiveFlameData(trie, uint64(i+1)) + if changed := m.RefreshFromLiveTrie(); !changed { + t.Fatalf("expected refresh after synthetic live ingest at step %d", i) + } + before := selectedPath(m) + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyRight}) + after := selectedPath(m) + if after != before { + moves++ + } + visitedPaths[after] = true + } + for i := 0; i < len(m.frames)*4; i++ { + trie.Reset() + coreflamegraph.SeedTestLiveFlameData(trie, uint64(i+1+len(m.frames)*4)) + if changed := m.RefreshFromLiveTrie(); !changed { + t.Fatalf("expected refresh after synthetic live ingest (reverse) at step %d", i) + } + before := selectedPath(m) + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyLeft}) + after := selectedPath(m) + if after != before { + moves++ + } + visitedPaths[after] = true + } + + if moves == 0 { + t.Fatalf("expected live-stream navigation to change selection at least once") + } + if len(visitedPaths) < 8 { + t.Fatalf("expected traversal across live updates to reach multiple frame paths, got %d", len(visitedPaths)) + } +} + +func TestSelectionRestoresByPathAcrossLiveRefresh(t *testing.T) { + trie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") + coreflamegraph.SeedTestLiveFlameData(trie, 0) + + m := NewModel(trie) + m.SetViewport(180, 40) + if changed := m.RefreshFromLiveTrie(); !changed { + t.Fatalf("expected initial refresh") + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyRight}) + selected := m.frames[m.selectedIdx].Path + if selected == "" || selected == "root" { + t.Fatalf("expected selection to move off root, got %q", selected) + } + + trie.Reset() + coreflamegraph.SeedTestLiveFlameData(trie, 2) + if changed := m.RefreshFromLiveTrie(); !changed { + t.Fatalf("expected refresh after live update") + } + if got := m.frames[m.selectedIdx].Path; got != selected { + t.Fatalf("expected selection path to persist across refresh, got %q want %q", got, selected) + } +} + +func TestKeyboardNavigationSingleNodeClamped(t *testing.T) { + m := NewModel(nil) + m.frames = []tuiFrame{{Name: "root", Depth: 0, Col: 0, Path: "root"}} + + keys := []tea.KeyPressMsg{ + {Code: []rune{'j'}[0], Text: "j"}, + {Code: []rune{'k'}[0], Text: "k"}, + {Code: []rune{'h'}[0], Text: "h"}, + {Code: []rune{'l'}[0], Text: "l"}, + {Code: tea.KeyDown}, + {Code: tea.KeyUp}, + {Code: tea.KeyLeft}, + {Code: tea.KeyRight}, + } + for _, keyMsg := range keys { + m = pressFlameKey(t, m, keyMsg) + if m.selectedIdx != 0 { + t.Fatalf("expected single-node selection to stay at idx 0, got %d", m.selectedIdx) + } + } +} + +func TestArrowDownFallsBackToVisibleDepthFromRoot(t *testing.T) { + m := NewModel(nil) + m.frames = []tuiFrame{ + {Name: "root", Depth: 0, Col: 0, Path: "root"}, + {Name: "child", Depth: 1, Col: 0, Path: "root" + pathSeparator + "child"}, + } + m.selectedIdx = 0 + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyDown}) + if m.selectedIdx != 1 { + t.Fatalf("expected down arrow to move selection to child when root has no shallower row, got %d", m.selectedIdx) + } +} + +func TestArrowEscapeSequencesAreRecognized(t *testing.T) { + tests := []struct { + key string + dir string + ansiCode byte + }{ + {key: "\x1b[A", dir: "up", ansiCode: 'A'}, + {key: "\x1b[B", dir: "down", ansiCode: 'B'}, + {key: "\x1b[C", dir: "right", ansiCode: 'C'}, + {key: "\x1b[D", dir: "left", ansiCode: 'D'}, + {key: "\x1bOA", dir: "up", ansiCode: 'A'}, // application mode + {key: "\x1bOB", dir: "down", ansiCode: 'B'}, // application mode + {key: "\x1b[1;2A", dir: "up", ansiCode: 'A'}, + } + for _, tc := range tests { + if !keyMatchesDirection(tc.key, tc.dir, tc.ansiCode) { + t.Fatalf("expected key %q to match %s", tc.key, tc.dir) + } + } +} + +func TestFilteredNavigationSkipsHiddenBranches(t *testing.T) { + m := NewModel(nil) + m.frames = []tuiFrame{ + {Name: "root", Depth: 0, Col: 0, Row: 0, Path: "root"}, + {Name: "keep", Depth: 1, Col: 0, Row: 1, Path: "root" + pathSeparator + "keep"}, + {Name: "drop", Depth: 1, Col: 40, Row: 1, Path: "root" + pathSeparator + "drop"}, + } + m.searchQuery = "keep" + m.recomputeFilterState() + m.selectedIdx = 1 + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyRight}) + if m.selectedIdx != 1 { + t.Fatalf("expected sibling navigation to stay on visible filtered branch, got idx %d", m.selectedIdx) + } + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyDown}) + if m.selectedIdx != 0 { + t.Fatalf("expected down key to move to visible root ancestor, got idx %d", m.selectedIdx) + } +} + +func TestZoomInUndoSingleLevelAndNestedEsc(t *testing.T) { + m := newZoomModel() + + m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A") + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if got, want := m.zoomPath, "root"+pathSeparator+"A"; got != want { + t.Fatalf("expected zoomPath %q, got %q", want, got) + } + if len(m.zoomStack) != 1 || m.zoomStack[0].path != "" { + t.Fatalf("expected one zoom stack entry from root, got %#v", m.zoomStack) + } + if m.zoomRoot == nil || m.zoomRoot.Name != "A" { + t.Fatalf("expected zoomRoot A, got %+v", m.zoomRoot) + } + + m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A"+pathSeparator+"A1") + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if got, want := m.zoomPath, "root"+pathSeparator+"A"+pathSeparator+"A1"; got != want { + t.Fatalf("expected nested zoomPath %q, got %q", want, got) + } + if len(m.zoomStack) != 2 || m.zoomStack[1].path != "root"+pathSeparator+"A" { + t.Fatalf("expected nested zoom stack to preserve parent path, got %#v", m.zoomStack) + } + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEsc}) + if got, want := m.zoomPath, "root"+pathSeparator+"A"; got != want { + t.Fatalf("expected zoomPath after esc undo %q, got %q", want, got) + } + if len(m.zoomStack) != 1 { + t.Fatalf("expected one stack entry after esc undo, got %d", len(m.zoomStack)) + } + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEsc}) + if m.zoomPath != "" || m.zoomRoot != nil || len(m.zoomStack) != 0 { + t.Fatalf("expected second esc undo to return to root state, got path=%q root=%+v stack=%d", m.zoomPath, m.zoomRoot, len(m.zoomStack)) + } +} + +func TestZoomResetToRoot(t *testing.T) { + m := newZoomModel() + m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A") + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A"+pathSeparator+"A1") + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if m.zoomPath == "" || len(m.zoomStack) == 0 { + t.Fatalf("expected nested zoom before reset") + } + + m.zoomReset() + if m.zoomPath != "" || m.zoomRoot != nil || len(m.zoomStack) != 0 { + t.Fatalf("expected explicit zoom reset to clear zoom stack, got path=%q root=%+v stack=%d", m.zoomPath, m.zoomRoot, len(m.zoomStack)) + } +} + +func TestZoomInOnCurrentRootSetsStatusMessage(t *testing.T) { + m := newZoomModel() + m.selectedIdx = mustFrameIndex(t, m.frames, "root") + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if m.zoomPath != "" { + t.Fatalf("expected zoom path to remain root, got %q", m.zoomPath) + } + if m.statusMessage != "Zoom unchanged: selected frame is current view root" { + t.Fatalf("unexpected status message: %q", m.statusMessage) + } +} + +func TestZoomTransitionAnimatesToNewLayout(t *testing.T) { + m := newZoomModel() + pathA := "root" + pathSeparator + "A" + preWidth := m.frames[mustFrameIndex(t, m.frames, pathA)].Width + + m.selectedIdx = mustFrameIndex(t, m.frames, pathA) + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if !m.animating { + t.Fatalf("expected zoom-in to start animation") + } + currentWidth := m.frames[mustFrameIndex(t, m.frames, pathA)].Width + targetWidth := m.targetFrames[mustFrameIndex(t, m.targetFrames, pathA)].Width + if currentWidth == targetWidth { + t.Fatalf("expected intermediate zoom frame width to differ from target (current=%d target=%d, pre=%d)", currentWidth, targetWidth, preWidth) + } + + for i := 0; i < 180 && m.animating; i++ { + next, _ := m.Update(animTickMsg{}) + m = next.(Model) + } + if m.animating { + t.Fatalf("expected zoom animation to settle within 180 ticks") + } + finalWidth := m.frames[mustFrameIndex(t, m.frames, pathA)].Width + if finalWidth != targetWidth { + t.Fatalf("expected final zoom width %d, got %d", targetWidth, finalWidth) + } +} + +func TestSearchLifecycleAndMatchNavigation(t *testing.T) { + m := NewModel(nil) + m.frames = []tuiFrame{ + {Name: "alpha", Path: "root" + pathSeparator + "alpha"}, + {Name: "beta", Path: "root" + pathSeparator + "beta"}, + {Name: "alphabet", Path: "root" + pathSeparator + "alphabet"}, + } + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'/'}[0], Text: "/"}) + if !m.searchActive { + t.Fatalf("expected search mode to activate on '/'") + } + for _, r := range []rune{'a', 'l', 'p'} { + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: r, Text: string(r)}) + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + + if m.searchActive { + t.Fatalf("expected search mode to close on enter") + } + if got := len(m.matchIndices); got != 2 { + t.Fatalf("expected 2 matches for 'alp', got %d", got) + } + first := m.selectedIdx + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'n'}[0], Text: "n"}) + if m.selectedIdx == first { + t.Fatalf("expected 'n' to jump to next match") + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'N'}[0], Text: "N"}) + if m.selectedIdx != first { + t.Fatalf("expected 'N' to jump back to previous match") + } +} + +func TestSearchEscapeClearsState(t *testing.T) { + m := NewModel(nil) + m.frames = []tuiFrame{{Name: "alpha", Path: "root" + pathSeparator + "alpha"}} + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'/'}[0], Text: "/"}) + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'a'}[0], Text: "a"}) + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEsc}) + + if m.searchActive { + t.Fatalf("expected search mode to close on escape") + } + if m.searchQuery != "" || len(m.matchIndices) != 0 { + t.Fatalf("expected search state to reset on escape, got query=%q matches=%d", m.searchQuery, len(m.matchIndices)) + } + if m.statusMessage != "Filter cleared" { + t.Fatalf("expected filter cleared status message, got %q", m.statusMessage) + } +} + +func TestSearchSubmitSetsFilterStatusMessage(t *testing.T) { + m := NewModel(nil) + m.frames = []tuiFrame{ + {Name: "alpha", Path: "root" + pathSeparator + "alpha"}, + {Name: "beta", Path: "root" + pathSeparator + "beta"}, + } + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'/'}[0], Text: "/"}) + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'a'}[0], Text: "a"}) + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if m.statusMessage != `Filter "a": 2 matches` { + t.Fatalf("unexpected status after applying filter: %q", m.statusMessage) + } + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'/'}[0], Text: "/"}) + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEsc}) + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'/'}[0], Text: "/"}) + for _, r := range []rune{'z', 'z'} { + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: r, Text: string(r)}) + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if m.statusMessage != `Filter "zz": no matches` { + t.Fatalf("unexpected status for unmatched filter: %q", m.statusMessage) + } +} + +func TestControlPauseToggle(t *testing.T) { + m := NewModel(nil) + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'p'}[0], Text: "p"}) + if !m.paused { + t.Fatalf("expected pause to toggle on") + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}) + if m.paused { + t.Fatalf("expected space key to toggle pause off") + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}) + if !m.paused { + t.Fatalf("expected space key to toggle pause on") + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'p'}[0], Text: "p"}) + if m.paused { + t.Fatalf("expected p key to toggle pause off") + } +} + +func TestControlResetBaseline(t *testing.T) { + liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") + m := NewModel(liveTrie) + m.snapshot = &snapshotNode{Name: "root", Total: 10} + m.frames = []tuiFrame{{Name: "root", Path: "root"}} + m.zoomPath = "root" + m.zoomStack = []zoomState{{path: "", previousSelectedIdx: 0}} + m.selectedIdx = 3 + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'r'}[0], Text: "r"}) + if m.snapshot != nil || len(m.frames) != 0 || len(m.zoomStack) != 0 || m.zoomPath != "" { + t.Fatalf("expected baseline reset to clear snapshot/layout/zoom state") + } + if m.statusMessage != "Baseline reset" { + t.Fatalf("expected reset status message, got %q", m.statusMessage) + } +} + +func TestViewIncludesSelectionStatusBar(t *testing.T) { + m := NewModel(nil) + m.width = 120 + m.height = 20 + m.frames = []tuiFrame{ + {Name: "root", Depth: 0, Col: 0, Row: 0, Width: 120, Total: 100, Percent: 100, Path: "root"}, + {Name: "child", Depth: 1, Col: 0, Row: 1, Width: 60, Total: 40, Percent: 40, Path: "root" + pathSeparator + "child"}, + } + m.selectedIdx = 1 + m.globalTotal = 100 + + view := m.View().Content + if !strings.Contains(view, "[LIVE] sel:2/2 child") { + t.Fatalf("expected selection status bar to include selected frame info, got %q", view) + } + if !strings.Contains(view, "40.00% of total events") { + t.Fatalf("expected selection status bar to include selected share, got %q", view) + } +} + +func TestViewSelectionStatusUsesBytesLabelInBytesMode(t *testing.T) { + m := NewModel(nil) + m.width = 120 + m.height = 20 + m.countField = "bytes" + m.frames = []tuiFrame{ + {Name: "root", Depth: 0, Col: 0, Row: 0, Width: 120, Total: 200, Percent: 100, Path: "root"}, + {Name: "child", Depth: 1, Col: 0, Row: 1, Width: 60, Total: 80, Percent: 40, Path: "root" + pathSeparator + "child"}, + } + m.selectedIdx = 1 + m.globalTotal = 200 + + view := m.View().Content + if !strings.Contains(view, "40.00% of total bytes") { + t.Fatalf("expected bytes-based selection share label, got %q", view) + } +} + +func TestViewFitsViewportHeightAndKeepsSearchFooterVisible(t *testing.T) { + m := NewModel(nil) + m.width = 100 + m.height = 12 + m.frames = []tuiFrame{ + {Name: "root", Depth: 0, Col: 0, Row: 0, Width: 100, Total: 100, Percent: 100, Path: "root"}, + {Name: "child", Depth: 1, Col: 0, Row: 1, Width: 80, Total: 80, Percent: 80, Path: "root" + pathSeparator + "child"}, + } + m.selectedIdx = 1 + m.globalTotal = 100 + m.searchActive = true + m.searchInput.SetValue("child") + + view := m.View().Content + lines := strings.Split(view, "\n") + if got, max := len(lines), m.height; got > max { + t.Fatalf("expected flame view to fit viewport height <=%d, got %d lines", max, got) + } + if !strings.Contains(view, "matches") { + t.Fatalf("expected search footer to remain visible in viewport, got %q", view) + } + if !strings.Contains(view, "[LIVE] sel:2/2 child") { + t.Fatalf("expected selection status line to remain visible, got %q", view) + } +} + +func TestViewFilterSelectionStatusUsesFilteredTotalAndKeepsContextVisible(t *testing.T) { + snapshot := &snapshotNode{ + Name: "root", + Total: 100, + Children: []*snapshotNode{ + { + Name: "keep", + Total: 60, + Children: []*snapshotNode{ + {Name: "needle", Total: 60}, + }, + }, + { + Name: "drop", + Total: 40, + Children: []*snapshotNode{ + {Name: "noise", Total: 40}, + }, + }, + }, + } + m := NewModel(nil) + m.width = 220 + m.height = 12 + m.frames = BuildTerminalLayout(snapshot, m.width, m.height) + m.globalTotal = 100 + m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"keep"+pathSeparator+"needle") + m.searchQuery = "needle" + m.recomputeFilterState() + + view := m.View().Content + if !strings.Contains(view, "100.00% of filtered events") { + t.Fatalf("expected filtered selection share in status line, got %q", view) + } + if !strings.Contains(view, "drop") || !strings.Contains(view, "noise") { + t.Fatalf("expected non-matching branches to remain visible while filtering, got %q", view) + } +} + +func TestControlCycleFieldOrderReconfiguresLiveTrie(t *testing.T) { + liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") + m := NewModel(liveTrie) + initial := append([]string(nil), m.fieldPresets[m.fieldIndex]...) + expectedNextIdx := (m.fieldIndex + 1) % len(m.fieldPresets) + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'o'}[0], Text: "o"}) + if m.fieldIndex != expectedNextIdx { + t.Fatalf("expected field index to advance to %d, got %d", expectedNextIdx, m.fieldIndex) + } + next := m.fieldPresets[m.fieldIndex] + if reflect.DeepEqual(initial, next) { + t.Fatalf("expected next field preset to differ from initial") + } + if got := liveTrie.Fields(); !reflect.DeepEqual(got, next) { + t.Fatalf("expected live trie fields %v, got %v", next, got) + } +} + +func TestControlMetricToggleReconfiguresLiveTrieCountField(t *testing.T) { + liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") + m := NewModel(liveTrie) + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'b'}[0], Text: "b"}) + if got, want := m.countField, "bytes"; got != want { + t.Fatalf("expected model count field %q, got %q", want, got) + } + if got, want := liveTrie.CountField(), "bytes"; got != want { + t.Fatalf("expected live trie count field %q, got %q", want, got) + } + if got, want := m.statusMessage, "Metric: bytes (new baseline)"; got != want { + t.Fatalf("expected metric toggle status %q, got %q", want, got) + } + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'b'}[0], Text: "b"}) + if got, want := m.countField, "count"; got != want { + t.Fatalf("expected model count field %q after second toggle, got %q", want, got) + } + if got, want := liveTrie.CountField(), "count"; got != want { + t.Fatalf("expected live trie count field %q after second toggle, got %q", want, got) + } +} + +func TestNewModelAlignsPresetIndexToLiveTrieFields(t *testing.T) { + liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") + m := NewModel(liveTrie) + if got, want := m.fieldPresets[m.fieldIndex], []string{"comm", "path", "tracepoint"}; !reflect.DeepEqual(got, want) { + t.Fatalf("expected model field preset to align with trie fields, got %v want %v", got, want) + } +} + +func TestNewModelAlignsCountFieldToLiveTrie(t *testing.T) { + liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "bytes") + m := NewModel(liveTrie) + if got, want := m.countField, "bytes"; got != want { + t.Fatalf("expected model count field to align with trie field, got %q want %q", got, want) + } +} + +func TestControlHelpToggle(t *testing.T) { + m := NewModel(nil) + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'?'}[0], Text: "?"}) + if !m.showHelp { + t.Fatalf("expected help overlay to toggle on") + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'?'}[0], Text: "?"}) + if m.showHelp { + t.Fatalf("expected help overlay to toggle off") + } +} + +func TestDataRefreshAnimationConvergesOverTicks(t *testing.T) { + m := NewModel(nil) + m.width = 120 + m.height = 20 + m.snapshot = &snapshotNode{ + Name: "root", + Total: 100, + Children: []*snapshotNode{ + {Name: "A", Total: 60}, + {Name: "B", Total: 40}, + }, + } + m.rebuildFrames(false) + initial := append([]tuiFrame(nil), m.frames...) + + m.snapshot = &snapshotNode{ + Name: "root", + Total: 100, + Children: []*snapshotNode{ + {Name: "A", Total: 20}, + {Name: "B", Total: 80}, + }, + } + m.rebuildFrames(true) + if !m.animating { + t.Fatalf("expected animation to start after animated rebuild") + } + + next, _ := m.Update(animTickMsg{}) + m = next.(Model) + if len(m.frames) != len(initial) { + t.Fatalf("expected frame count to remain stable during animation") + } + + for i := 0; i < 180 && m.animating; i++ { + next, _ = m.Update(animTickMsg{}) + m = next.(Model) + } + if m.animating { + t.Fatalf("expected animation to settle within 180 ticks") + } + if len(m.frames) != len(m.targetFrames) { + t.Fatalf("expected settled frame count to match targets") + } + for i := range m.frames { + if m.frames[i].Width != m.targetFrames[i].Width || m.frames[i].Col != m.targetFrames[i].Col { + t.Fatalf("frame %d did not converge to target: got col=%d width=%d want col=%d width=%d", + i, m.frames[i].Col, m.frames[i].Width, m.targetFrames[i].Col, m.targetFrames[i].Width) + } + } +} + +func TestRebuildKeepsSelectionOnVisibleRowsWhenTruncated(t *testing.T) { + m := NewModel(nil) + m.width = 80 + m.height = 4 // only 2 render rows remain after toolbar+status + m.snapshot = &snapshotNode{ + Name: "root", + Children: []*snapshotNode{ + { + Name: "a", + Children: []*snapshotNode{ + { + Name: "b", + Children: []*snapshotNode{ + {Name: "c", Total: 5}, + }, + }, + }, + }, + }, + } + + m.rebuildFrames(false) + if len(m.frames) == 0 { + t.Fatalf("expected rebuilt frames") + } + rowOffset := m.visibleRowOffset() + if m.frames[m.selectedIdx].Row < rowOffset { + t.Fatalf("expected selected frame row %d to be visible (offset=%d)", m.frames[m.selectedIdx].Row, rowOffset) + } +} + +func TestResizeRecalculatesLayoutAndCullsNarrowFrames(t *testing.T) { + m := NewModel(nil) + m.width = 120 + m.height = 40 + m.snapshot = &snapshotNode{ + Name: "root", + Total: 100, + Children: []*snapshotNode{ + { + Name: "big", + Total: 99, + Children: []*snapshotNode{ + {Name: "deep", Total: 99}, + }, + }, + {Name: "tiny", Total: 1}, + }, + } + m.rebuildFrames(false) + _ = mustFrameIndex(t, m.frames, "root"+pathSeparator+"tiny") + + next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + m = next.(Model) + for i := 0; i < 180 && m.animating; i++ { + next, _ = m.Update(animTickMsg{}) + m = next.(Model) + } + + for _, frame := range m.frames { + if frame.Col+frame.Width > 80 { + t.Fatalf("frame exceeds resized width: %+v", frame) + } + if frame.Row >= 24 { + t.Fatalf("frame row exceeds resized height: %+v", frame) + } + } + for _, frame := range m.frames { + if frame.Path == "root"+pathSeparator+"tiny" { + t.Fatalf("expected tiny frame to be culled at width 80") + } + } +} + +func newZoomModel() Model { + m := NewModel(nil) + m.width = 120 + m.height = 30 + m.snapshot = &snapshotNode{ + Name: "root", + Total: 100, + Children: []*snapshotNode{ + { + Name: "A", + Total: 60, + Children: []*snapshotNode{ + {Name: "A1", Total: 30}, + {Name: "A2", Total: 30}, + }, + }, + {Name: "B", Total: 40}, + }, + } + m.rebuildFrames(false) + return m +} + +func mustFrameIndex(t *testing.T, frames []tuiFrame, path string) int { + t.Helper() + for idx, frame := range frames { + if frame.Path == path { + return idx + } + } + t.Fatalf("frame path %q not found", path) + return -1 +} + +func pressFlameKey(t *testing.T, m Model, keyMsg tea.KeyPressMsg) Model { + t.Helper() + next, _ := m.Update(keyMsg) + return next.(Model) +} |
