@@ -0,0 +1,137 @@
1+package model
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ tea "charm.land/bubbletea/v2"
8+ "github.com/charmbracelet/crush/internal/ui/common"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func newTestFilter(t *testing.T) (*Filter, *time.Time) {
13+ t.Helper()
14+ now := time.Now()
15+ f := &Filter{now: func() time.Time { return now }}
16+ return f, &now
17+}
18+
19+func wheelUp() tea.MouseWheelMsg {
20+ return tea.MouseWheelMsg(tea.Mouse{Button: tea.MouseWheelUp})
21+}
22+
23+func wheelDown() tea.MouseWheelMsg {
24+ return tea.MouseWheelMsg(tea.Mouse{Button: tea.MouseWheelDown})
25+}
26+
27+func TestFilter_SingleEventPassthrough(t *testing.T) {
28+ t.Parallel()
29+ f, _ := newTestFilter(t)
30+
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)
36+}
37+
38+func TestFilter_CoalescesSameDirection(t *testing.T) {
39+ t.Parallel()
40+ f, now := newTestFilter(t)
41+
42+ // First event passes through.
43+ result := f.Filter(nil, wheelDown())
44+ require.IsType(t, common.CoalescedWheelMsg{}, result)
45+
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)
50+
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)
55+
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")
62+}
63+
64+func TestFilter_DirectionChangeResetsAccumulator(t *testing.T) {
65+ t.Parallel()
66+ f, now := newTestFilter(t)
67+
68+ // Scroll down (passes through immediately).
69+ f.Filter(nil, wheelDown())
70+
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")
75+
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)
80+ require.True(t, ok)
81+ require.Equal(t, float64(-2), msg.DeltaY, "should have 2 accumulated up events")
82+}
83+
84+func TestFilter_WindowExpiryEmitsAccumulated(t *testing.T) {
85+ t.Parallel()
86+ f, now := newTestFilter(t)
87+
88+ // First event passes through immediately.
89+ result := f.Filter(nil, wheelUp())
90+ require.IsType(t, common.CoalescedWheelMsg{}, result)
91+
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())
97+
98+ // Wait for window to expire.
99+ *now = now.Add(20 * time.Millisecond)
100+
101+ // Next event triggers emission of accumulated delta (2 suppressed + 1 new).
102+ result = f.Filter(nil, wheelUp())
103+ msg, ok := result.(common.CoalescedWheelMsg)
104+ require.True(t, ok)
105+ require.Equal(t, float64(-3), msg.DeltaY, "should include accumulated events plus triggering event")
106+}
107+
108+func TestFilter_MotionThrottledIndependently(t *testing.T) {
109+ t.Parallel()
110+ f, now := newTestFilter(t)
111+
112+ motion := tea.MouseMotionMsg(tea.Mouse{X: 1, Y: 1})
113+
114+ // First motion passes.
115+ result := f.Filter(nil, motion)
116+ require.NotNil(t, result)
117+
118+ // Motion within window is dropped.
119+ *now = now.Add(5 * time.Millisecond)
120+ result = f.Filter(nil, motion)
121+ require.Nil(t, result)
122+
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")
127+}
128+
129+func TestFilter_NonMouseMessagesPassThrough(t *testing.T) {
130+ t.Parallel()
131+ f, _ := newTestFilter(t)
132+
133+ type customMsg struct{}
134+ msg := customMsg{}
135+ result := f.Filter(nil, msg)
136+ require.Equal(t, msg, result, "non-mouse messages should pass through unchanged")
137+}