diff options
Diffstat (limited to 'internal/tui')
| -rw-r--r-- | internal/tui/dashboard/model.go | 37 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 137 | ||||
| -rw-r--r-- | internal/tui/tui.go | 19 | ||||
| -rw-r--r-- | internal/tui/tui_test.go | 52 |
4 files changed, 235 insertions, 10 deletions
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index 0437f05..478e735 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -916,9 +916,11 @@ func (m *Model) resetBaselineCmd() tea.Cmd { // autoResetTickCmd returns a command that fires an autoResetTickMsg after // the current auto-reset interval. Returns nil when the timer is disabled -// (interval <= 0), so callers can compose it without extra branching. +// (interval <= 0) or while the dashboard is blurred, so callers can +// compose it without extra branching. SetFocused re-arms the tick when +// focus returns. func (m Model) autoResetTickCmd() tea.Cmd { - if m.autoResetEvery <= 0 { + if m.autoResetEvery <= 0 || !m.focused { return nil } gen := m.autoResetGen @@ -930,9 +932,11 @@ func (m Model) autoResetTickCmd() tea.Cmd { // handleAutoResetTick fires the same reset path as the `r` key (live trie // + stats engine) and re-arms the timer for the next tick. Stale ticks // from a previous cadence are dropped via the generation counter so that -// changing the interval does not double-fire. +// changing the interval does not double-fire. While the dashboard is +// blurred the tick is also dropped without re-arming; SetFocused will +// arm a fresh tick on focus regain. func (m Model) handleAutoResetTick(msg autoResetTickMsg) (tea.Model, tea.Cmd) { - if msg.generation != m.autoResetGen || m.autoResetEvery <= 0 { + if msg.generation != m.autoResetGen || m.autoResetEvery <= 0 || !m.focused { return m, nil } resetCmd := m.resetBaselineCmd() @@ -1048,9 +1052,24 @@ func (m *Model) SetDarkMode(isDark bool) { m.processesChart.SetDarkMode(isDark) } -// SetFocused controls whether periodic refresh ticks are processed. -func (m *Model) SetFocused(focused bool) { +// SetFocused controls whether periodic refresh ticks are processed and +// returns a tea.Cmd that arms a fresh auto-reset tick when focus returns +// (or nil otherwise). The auto-reset generation counter is bumped on +// every focus change so any in-flight tick scheduled before a blur is +// dropped when it eventually arrives — the tick payload's generation +// will no longer match. Without bumping, a tick that was already in +// flight when blur occurred could fire moments after the user re-focuses +// and surprise them with a reset. +func (m *Model) SetFocused(focused bool) tea.Cmd { + if m.focused == focused { + return nil + } m.focused = focused + m.autoResetGen++ + if !focused { + return nil + } + return m.autoResetTickCmd() } // SnapshotCmd returns a command that fetches and emits a fresh dashboard snapshot. @@ -1106,10 +1125,16 @@ func (m Model) filterSummary() string { // autoResetStatus is the human-readable label for the current // auto-reset cadence shown in the dashboard chrome. "off" when the // timer is disabled, otherwise the configured interval (e.g. "30s"). +// When the timer is enabled but the TUI has lost focus, a "(paused)" +// suffix is appended so the user knows the timer will not fire until +// focus returns. Disabled timers stay "off" regardless of focus. func (m Model) autoResetStatus() string { if m.autoResetEvery <= 0 { return "auto-reset: off" } + if !m.focused { + return "auto-reset: " + m.autoResetEvery.String() + " (paused)" + } return "auto-reset: " + m.autoResetEvery.String() } diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go index 8c73230..2fb59f5 100644 --- a/internal/tui/dashboard/model_test.go +++ b/internal/tui/dashboard/model_test.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" "testing" + "time" coreflamegraph "ior/internal/flamegraph" "ior/internal/statsengine" @@ -1587,3 +1588,139 @@ func TestTranslateFlamegraphMsgLeavesNonMouseUnchanged(t *testing.T) { t.Fatalf("expected non-mouse message to remain unchanged, got %T", translated) } } + +// TestAutoResetTickIgnoredWhileBlurred drives the same tick the +// tea.Tick scheduler would deliver after the cadence elapses, but +// from a blurred state. The expected behavior is: no reset fires +// (no Reset() call on the engine), and no new tick is re-armed — +// SetFocused will arm a fresh one when focus returns. +func TestAutoResetTickIgnoredWhileBlurred(t *testing.T) { + engine := &fakeResettableSnapshotSource{} + m := NewModelWithConfig(engine, nil, 250, common.DefaultKeyMap()) + if cmd := m.SetAutoResetInterval(50 * time.Millisecond); cmd == nil { + t.Fatalf("SetAutoResetInterval should return a tick command for a positive interval") + } + gen := m.autoResetGen + + // Simulate blur. The returned cmd must be nil (no rearm). + if cmd := m.SetFocused(false); cmd != nil { + t.Fatalf("SetFocused(false) should not return a tick command, got %v", cmd) + } + if m.autoResetGen == gen { + t.Fatalf("SetFocused(false) should bump autoResetGen so in-flight ticks are dropped") + } + + // Deliver the in-flight tick that was scheduled before the blur. It + // carries the pre-blur generation, so it must be silently dropped. + staleTick := autoResetTickMsg{generation: gen} + next, cmd := m.Update(staleTick) + m = next.(Model) + if cmd != nil { + t.Fatalf("blurred dashboard should not re-arm the timer on a stale tick, got %v", cmd) + } + if engine.resetCount != 0 { + t.Fatalf("blurred dashboard should not reset the engine, got resetCount=%d", engine.resetCount) + } + + // Even a tick crafted with the current generation must not fire + // while blurred — handleAutoResetTick gates on m.focused. + currentTick := autoResetTickMsg{generation: m.autoResetGen} + next, cmd = m.Update(currentTick) + m = next.(Model) + if cmd != nil { + t.Fatalf("blurred dashboard must not re-arm even on a current-gen tick, got %v", cmd) + } + if engine.resetCount != 0 { + t.Fatalf("blurred dashboard must not reset on a current-gen tick, got resetCount=%d", engine.resetCount) + } +} + +// TestAutoResetTickResumesOnFocusRegain checks that focus regain arms +// a fresh tick at the configured cadence, and that the next tick fires +// the reset path. We deliver the tick by direct injection (the same +// payload tea.Tick would deliver) rather than waiting on real time. +func TestAutoResetTickResumesOnFocusRegain(t *testing.T) { + engine := &fakeResettableSnapshotSource{} + m := NewModelWithConfig(engine, nil, 250, common.DefaultKeyMap()) + m.SetAutoResetInterval(50 * time.Millisecond) + m.SetFocused(false) + + // Focus regain must return a non-nil tick cmd because the timer is + // still configured, and bump the generation again. + preGen := m.autoResetGen + cmd := m.SetFocused(true) + if cmd == nil { + t.Fatalf("SetFocused(true) should return a fresh tick cmd when timer is enabled") + } + if m.autoResetGen == preGen { + t.Fatalf("SetFocused(true) should bump autoResetGen to invalidate any leftover ticks") + } + + // Deliver a tick at the post-regain generation: the reset must fire + // and a fresh tick must be re-armed for the next interval. + tick := autoResetTickMsg{generation: m.autoResetGen} + next, cmd := m.Update(tick) + m = next.(Model) + if cmd == nil { + t.Fatalf("focused dashboard should re-arm timer and emit reset cmd, got nil") + } + if engine.resetCount != 1 { + t.Fatalf("focused tick should reset engine exactly once, got resetCount=%d", engine.resetCount) + } +} + +// TestSetFocusedNoOpWhenStateUnchanged guards against accidental +// generation churn when focus messages arrive without an actual state +// change (e.g. a duplicate FocusMsg). Bumping the generation in that +// case would silently invalidate a healthy in-flight tick. +func TestSetFocusedNoOpWhenStateUnchanged(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.SetAutoResetInterval(50 * time.Millisecond) + gen := m.autoResetGen + + if cmd := m.SetFocused(true); cmd != nil { + t.Fatalf("SetFocused(true) on already-focused model should be a no-op, got %v", cmd) + } + if m.autoResetGen != gen { + t.Fatalf("autoResetGen should not change on no-op focus call, was %d now %d", gen, m.autoResetGen) + } +} + +// TestSetFocusedReturnsNilWhenTimerDisabled is the corner case where +// focus returns but the user has the auto-reset timer turned off. No +// tick should be armed (it would never fire anyway). +func TestSetFocusedReturnsNilWhenTimerDisabled(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + // Timer disabled by default. + m.SetFocused(false) + if cmd := m.SetFocused(true); cmd != nil { + t.Fatalf("SetFocused(true) with disabled timer should return nil, got %v", cmd) + } +} + +// TestAutoResetStatusAddsPausedSuffixWhenBlurred locks in the chrome +// label contract: enabled+focused -> "auto-reset: 30s", +// enabled+blurred -> "auto-reset: 30s (paused)", disabled stays "off" +// regardless of focus. +func TestAutoResetStatusAddsPausedSuffixWhenBlurred(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.SetAutoResetInterval(30 * time.Second) + if got, want := m.autoResetStatus(), "auto-reset: 30s"; got != want { + t.Fatalf("focused enabled status = %q, want %q", got, want) + } + + m.SetFocused(false) + if got, want := m.autoResetStatus(), "auto-reset: 30s (paused)"; got != want { + t.Fatalf("blurred enabled status = %q, want %q", got, want) + } + + m.SetAutoResetInterval(0) + if got, want := m.autoResetStatus(), "auto-reset: off"; got != want { + t.Fatalf("blurred disabled status = %q, want %q", got, want) + } + + m.SetFocused(true) + if got, want := m.autoResetStatus(), "auto-reset: off"; got != want { + t.Fatalf("focused disabled status = %q, want %q", got, want) + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index e1d5581..63321ac 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -481,13 +481,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.FocusMsg: m.focused = true - m.dashboard.SetFocused(true) + // SetFocused returns a tea.Cmd that arms a fresh auto-reset tick + // when focus returns (or nil if the timer is disabled). It also + // bumps the dashboard's autoResetGen so any tick that was scheduled + // before the blur and is still in flight is dropped on arrival. + focusCmd := m.dashboard.SetFocused(true) if m.screen == ScreenDashboard && !m.attaching { + // Init() arms its own auto-reset tick at the post-bump + // generation, so discard focusCmd here to avoid two + // concurrently-live ticks racing the cadence. return m, tea.Batch(m.dashboard.Init(), m.dashboard.SnapshotCmd()) } - return m, nil + return m, focusCmd case tea.BlurMsg: m.focused = false + // SetFocused returns nil on blur but still bumps autoResetGen so + // that any in-flight tick scheduled before the blur is ignored. m.dashboard.SetFocused(false) return m, nil case tea.KeyPressMsg: @@ -666,12 +675,14 @@ func (m Model) handleGlobalKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bo // autoResetCycle is the ordered set of cadences exposed via the `I` // hotkey. The first entry (0) disables the timer; the rest are // progressively longer to give users a quick way to slow auto-resets -// down on long traces or turn them off entirely. +// down on long traces or turn them off entirely. The cycle wraps so +// pressing `I` past the last preset returns to off. var autoResetCycle = []time.Duration{ 0, - 15 * time.Second, + 10 * time.Second, 30 * time.Second, 60 * time.Second, + 2 * time.Minute, 5 * time.Minute, } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 27bf041..b8d6108 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -2130,3 +2130,55 @@ func TestGlobalHelpOverlayFitsStandardTerminal(t *testing.T) { t.Fatalf("expected overlay to include bubble dashboard hotkeys") } } + +// TestNextAutoResetIntervalCyclesThroughPresets walks the full preset +// sequence (off -> 10s -> 30s -> 60s -> 2m -> 5m -> off) to lock in the +// user-facing behavior of the `I` hotkey. The cycle wraps so the user +// can press `I` repeatedly without overshooting into surprise states. +func TestNextAutoResetIntervalCyclesThroughPresets(t *testing.T) { + t.Parallel() + cases := []struct { + name string + in time.Duration + want time.Duration + }{ + {"off->10s", 0, 10 * time.Second}, + {"10s->30s", 10 * time.Second, 30 * time.Second}, + {"30s->60s", 30 * time.Second, 60 * time.Second}, + {"60s->2m", 60 * time.Second, 2 * time.Minute}, + {"2m->5m", 2 * time.Minute, 5 * time.Minute}, + {"5m->off", 5 * time.Minute, 0}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := nextAutoResetInterval(tc.in) + if got != tc.want { + t.Fatalf("nextAutoResetInterval(%s) = %s, want %s", tc.in, got, tc.want) + } + }) + } +} + +// TestNextAutoResetIntervalAdvancesCustomValueToNextPreset covers the +// custom-value passthrough: when the user passes a -resetTimer value +// that is not in the preset cycle (e.g. 47s), pressing `I` should jump +// to the smallest preset strictly greater than the current cadence +// rather than restarting from the top of the cycle. +func TestNextAutoResetIntervalAdvancesCustomValueToNextPreset(t *testing.T) { + t.Parallel() + if got := nextAutoResetInterval(47 * time.Second); got != 60*time.Second { + t.Fatalf("nextAutoResetInterval(47s) = %s, want 60s", got) + } + if got := nextAutoResetInterval(90 * time.Second); got != 2*time.Minute { + t.Fatalf("nextAutoResetInterval(90s) = %s, want 2m", got) + } + if got := nextAutoResetInterval(3 * time.Minute); got != 5*time.Minute { + t.Fatalf("nextAutoResetInterval(3m) = %s, want 5m", got) + } + // A custom value larger than every preset wraps to off. + if got := nextAutoResetInterval(10 * time.Minute); got != 0 { + t.Fatalf("nextAutoResetInterval(10m) = %s, want 0 (off)", got) + } +} |
