@@ -0,0 +1,137 @@
7+ tea "charm.land/bubbletea/v2"
8+ "github.com/charmbracelet/crush/internal/ui/common"
9+ "github.com/stretchr/testify/require"
12+func newTestFilter(t *testing.T) (*Filter, *time.Time) {
15+ f := &Filter{now: func() time.Time { return now }}
19+func wheelUp() tea.MouseWheelMsg {
20+ return tea.MouseWheelMsg(tea.Mouse{Button: tea.MouseWheelUp})
23+func wheelDown() tea.MouseWheelMsg {
24+ return tea.MouseWheelMsg(tea.Mouse{Button: tea.MouseWheelDown})
27+func TestFilter_SingleEventPassthrough(t *testing.T) {
29+ f, _ := newTestFilter(t)
31+ result := f.Filter(nil, wheelDown())
32+ msg, ok := result.(common.CoalescedWheelMsg)
33+ require.True(t, ok, "expected CoalescedWheelMsg")
34+ require.Equal(t, float64(0), msg.DeltaX)
35+ require.Equal(t, float64(1), msg.DeltaY)
38+func TestFilter_CoalescesSameDirection(t *testing.T) {
40+ f, now := newTestFilter(t)
42+ // First event passes through.
43+ result := f.Filter(nil, wheelDown())
44+ require.IsType(t, common.CoalescedWheelMsg{}, result)
46+ // Second event within window gets dropped.
47+ *now = now.Add(5 * time.Millisecond)
48+ result = f.Filter(nil, wheelDown())
49+ require.Nil(t, result)
51+ // Third event within window gets dropped.
52+ *now = now.Add(5 * time.Millisecond)
53+ result = f.Filter(nil, wheelDown())
54+ require.Nil(t, result)
56+ // After window expires, accumulated delta is emitted.
57+ *now = now.Add(20 * time.Millisecond)
58+ result = f.Filter(nil, wheelDown())
59+ msg, ok := result.(common.CoalescedWheelMsg)
60+ require.True(t, ok, "expected CoalescedWheelMsg after window")
61+ require.Equal(t, float64(3), msg.DeltaY, "should accumulate 3 down events")
64+func TestFilter_DirectionChangeResetsAccumulator(t *testing.T) {
66+ f, now := newTestFilter(t)
68+ // Scroll down (passes through immediately).
69+ f.Filter(nil, wheelDown())
71+ // Immediately scroll up (within window). Accumulates but suppressed.
72+ *now = now.Add(5 * time.Millisecond)
73+ result := f.Filter(nil, wheelUp())
74+ require.Nil(t, result, "within time window, should be suppressed")
76+ // After window, the up delta includes the suppressed event plus this one.
77+ *now = now.Add(20 * time.Millisecond)
78+ result = f.Filter(nil, wheelUp())
79+ msg, ok := result.(common.CoalescedWheelMsg)
81+ require.Equal(t, float64(-2), msg.DeltaY, "should have 2 accumulated up events")
84+func TestFilter_WindowExpiryEmitsAccumulated(t *testing.T) {
86+ f, now := newTestFilter(t)
88+ // First event passes through immediately.
89+ result := f.Filter(nil, wheelUp())
90+ require.IsType(t, common.CoalescedWheelMsg{}, result)
92+ // Two more events within window get accumulated.
93+ *now = now.Add(5 * time.Millisecond)
94+ f.Filter(nil, wheelUp())
95+ *now = now.Add(5 * time.Millisecond)
96+ f.Filter(nil, wheelUp())
98+ // Wait for window to expire.
99+ *now = now.Add(20 * time.Millisecond)
101+ // Next event triggers emission of accumulated delta (2 suppressed + 1 new).
102+ result = f.Filter(nil, wheelUp())
103+ msg, ok := result.(common.CoalescedWheelMsg)
105+ require.Equal(t, float64(-3), msg.DeltaY, "should include accumulated events plus triggering event")
108+func TestFilter_MotionThrottledIndependently(t *testing.T) {
110+ f, now := newTestFilter(t)
112+ motion := tea.MouseMotionMsg(tea.Mouse{X: 1, Y: 1})
114+ // First motion passes.
115+ result := f.Filter(nil, motion)
116+ require.NotNil(t, result)
118+ // Motion within window is dropped.
119+ *now = now.Add(5 * time.Millisecond)
120+ result = f.Filter(nil, motion)
121+ require.Nil(t, result)
123+ // Wheel event is not affected by motion throttle.
124+ *now = now.Add(5 * time.Millisecond)
125+ result = f.Filter(nil, wheelDown())
126+ require.NotNil(t, result, "wheel should not be throttled by motion")
129+func TestFilter_NonMouseMessagesPassThrough(t *testing.T) {
131+ f, _ := newTestFilter(t)
133+ type customMsg struct{}
135+ result := f.Filter(nil, msg)
136+ require.Equal(t, msg, result, "non-mouse messages should pass through unchanged")