diff options
Diffstat (limited to 'internal/tui/dashboard')
| -rw-r--r-- | internal/tui/dashboard/model.go | 37 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 137 |
2 files changed, 168 insertions, 6 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) + } +} |
