1package webterm
2
3import (
4 "context"
5 "io"
6 "strings"
7 "testing"
8 "time"
9
10 "github.com/bnema/vev-vt/html/browser"
11 "github.com/bnema/vev/internal/domain"
12 "github.com/stretchr/testify/require"
13)
14
15func TestWheelNotchesConvertModes(t *testing.T) {
16 tests := []struct {
17 name string
18 deltaY float64
19 deltaMode int
20 want float64
21 }{
22 {"pixel notch", 100, 0, 1},
23 {"pixel fraction", 25, 0, 0.25},
24 {"pixel negative", -100, 0, -1},
25 {"line notch", 3, 1, 1},
26 {"line fraction", 1, 1, 1.0 / 3.0},
27 {"page notch", 1, 2, 10},
28 {"page negative", -1, 2, -10},
29 {"unknown mode", 100, 7, 0},
30 }
31 for _, test := range tests {
32 t.Run(test.name, func(t *testing.T) {
33 if got := wheelNotches(test.deltaY, test.deltaMode); got != test.want {
34 t.Fatalf("wheelNotches(%v, %d) = %v, want %v", test.deltaY, test.deltaMode, got, test.want)
35 }
36 })
37 }
38}
39
40func TestConsumeWheelQuantizesNotches(t *testing.T) {
41 tests := []struct {
42 name string
43 deltas []float64
44 mode int
45 shift bool
46 wantReports []int
47 wantUp []bool
48 wantRemainder float64
49 }{
50 {
51 name: "one pixel notch down",
52 deltas: []float64{100},
53 wantReports: []int{1},
54 wantUp: []bool{false},
55 wantRemainder: 0,
56 },
57 {
58 name: "one pixel notch up",
59 deltas: []float64{-100},
60 wantReports: []int{1},
61 wantUp: []bool{true},
62 wantRemainder: 0,
63 },
64 {
65 name: "micro burst forms one notch",
66 deltas: []float64{5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5},
67 wantReports: []int{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1},
68 wantUp: make([]bool, 20),
69 wantRemainder: 0,
70 },
71 {
72 name: "same distance at once or fragmented",
73 deltas: []float64{200},
74 wantReports: []int{2},
75 wantUp: []bool{false},
76 wantRemainder: 0,
77 },
78 {
79 name: "line mode notch",
80 deltas: []float64{3},
81 mode: 1,
82 wantReports: []int{1},
83 wantUp: []bool{false},
84 wantRemainder: 0,
85 },
86 {
87 name: "remainder carries over",
88 deltas: []float64{150, 150},
89 wantReports: []int{1, 2},
90 wantUp: []bool{false, false},
91 wantRemainder: 0,
92 },
93 {
94 name: "shift multiplies notches",
95 deltas: []float64{100},
96 shift: true,
97 wantReports: []int{10},
98 wantUp: []bool{false},
99 wantRemainder: 0,
100 },
101 {
102 name: "emission is capped",
103 deltas: []float64{5000},
104 wantReports: []int{10},
105 wantUp: []bool{false},
106 wantRemainder: 0,
107 },
108 {
109 name: "accumulator is capped",
110 deltas: []float64{50000, 0},
111 wantReports: []int{10, 0},
112 wantUp: []bool{false, false},
113 wantRemainder: 0,
114 },
115 }
116 for _, test := range tests {
117 t.Run(test.name, func(t *testing.T) {
118 terminal := &Terminal{}
119 if len(test.wantReports) != len(test.deltas) {
120 t.Fatalf("test declares %d deltas but %d expectations", len(test.deltas), len(test.wantReports))
121 }
122 for i, delta := range test.deltas {
123 reports, button := terminal.consumeWheel(delta, test.mode, test.shift)
124 if reports != test.wantReports[i] {
125 t.Fatalf("delta %d: reports = %d, want %d", i, reports, test.wantReports[i])
126 }
127 if reports == 0 {
128 continue
129 }
130 wantButton := 65
131 if test.wantUp[i] {
132 wantButton = 64
133 }
134 if button != wantButton {
135 t.Fatalf("delta %d: button = %d, want %d", i, button, wantButton)
136 }
137 }
138 if terminal.wheelAcc != test.wantRemainder {
139 t.Fatalf("remainder = %v, want %v", terminal.wheelAcc, test.wantRemainder)
140 }
141 })
142 }
143}
144
145func TestConsumeWheelKeepsDirectionAcrossFractions(t *testing.T) {
146 terminal := &Terminal{}
147 if reports, _ := terminal.consumeWheel(-90, 0, false); reports != 0 {
148 t.Fatalf("reports = %d, want 0 (banked)", reports)
149 }
150 reports, button := terminal.consumeWheel(-30, 0, false)
151 if reports != 1 || button != 64 {
152 t.Fatalf("reports = %d button = %d, want 1/64", reports, button)
153 }
154}
155
156func TestWheelHorizontalOnlyPreservesRemainder(t *testing.T) {
157 ctx, cancel := context.WithTimeout(t.Context(), time.Second)
158 defer cancel()
159 terminal, err := New(ctx, domain.Geometry{Size: domain.Size{Cols: 10, Rows: 4}})
160 require.NoError(t, err)
161 defer terminal.Close()
162 _, err = terminal.Write([]byte("\x1b[?1000h\x1b[?1006h"))
163 require.NoError(t, err)
164 require.NoError(t, terminal.Flush())
165 up := browser.Event{Kind: browser.EventWheel, Wheel: &browser.WheelEvent{DeltaY: 90, DeltaMode: 0, Row: 0, Column: 0}}
166 require.NoError(t, terminal.Handle(ctx, up))
167 if terminal.wheelAcc != 0.9 {
168 t.Fatalf("remainder = %v, want 0.9", terminal.wheelAcc)
169 }
170 // A pure horizontal swipe is ignored without wiping banked vertical.
171 horizontal := browser.Event{Kind: browser.EventWheel, Wheel: &browser.WheelEvent{DeltaX: 50, DeltaY: 0, DeltaMode: 0, Row: 0, Column: 0}}
172 require.NoError(t, terminal.Handle(ctx, horizontal))
173 if terminal.wheelAcc != 0.9 {
174 t.Fatalf("remainder = %v, want 0.9 (horizontal must not reset)", terminal.wheelAcc)
175 }
176}
177
178func TestWheelIgnoredInputResetsRemainder(t *testing.T) {
179 terminal := &Terminal{}
180 ctx := context.Background()
181 down := browser.Event{
182 Kind: browser.EventWheel,
183 Wheel: &browser.WheelEvent{
184 DeltaY: 90, DeltaMode: 0, Row: 9999, Column: 9999,
185 },
186 }
187 // Out-of-bounds cells are ignored and must drop the banked fraction.
188 if err := terminal.Handle(ctx, down); err != nil {
189 t.Fatal(err)
190 }
191 if terminal.wheelAcc != 0 {
192 t.Fatalf("remainder = %v, want 0", terminal.wheelAcc)
193 }
194}
195
196func TestHandleWheelShiftOmitsShiftModifier(t *testing.T) {
197 ctx, cancel := context.WithTimeout(t.Context(), time.Second)
198 defer cancel()
199 terminal, err := New(ctx, domain.Geometry{Size: domain.Size{Cols: 10, Rows: 4}})
200 require.NoError(t, err)
201 defer terminal.Close()
202 _, err = terminal.Write([]byte("\x1b[?1000h\x1b[?1006h"))
203 require.NoError(t, err)
204 require.NoError(t, terminal.Flush())
205
206 // Shift is consumed as the x10 multiplier: reports stay plain wheel
207 // buttons (65) instead of Shift-flagged buttons (69).
208 event := browser.Event{Kind: browser.EventWheel, Wheel: &browser.WheelEvent{
209 DeltaY: 100, DeltaMode: 0, Row: 0, Column: 0, Modifiers: browser.Modifiers{Shift: true},
210 }}
211 require.NoError(t, terminal.Handle(ctx, event))
212 want := strings.Repeat("\x1b[<65;1;1M", 10)
213 data := make([]byte, len(want))
214 _, err = io.ReadFull(terminal.In(), data)
215 require.NoError(t, err)
216 require.Equal(t, want, string(data))
217}
218
219func TestHandleWheelEmitsOneReportPerNotch(t *testing.T) {
220 ctx, cancel := context.WithTimeout(t.Context(), time.Second)
221 defer cancel()
222 terminal, err := New(ctx, domain.Geometry{Size: domain.Size{Cols: 10, Rows: 4}})
223 require.NoError(t, err)
224 defer terminal.Close()
225 _, err = terminal.Write([]byte("\x1b[?1000h\x1b[?1006h"))
226 require.NoError(t, err)
227 require.NoError(t, terminal.Flush())
228
229 wheel := func(deltaY float64) browser.Event {
230 return browser.Event{Kind: browser.EventWheel, Wheel: &browser.WheelEvent{
231 DeltaY: deltaY, DeltaMode: 0, Row: 0, Column: 0,
232 }}
233 }
234 // A 5px micro-event is banked: nothing is sent yet.
235 require.NoError(t, terminal.Handle(ctx, wheel(5)))
236 // Nineteen more micro-events complete the notch: exactly one report.
237 for i := 0; i < 19; i++ {
238 require.NoError(t, terminal.Handle(ctx, wheel(5)))
239 }
240 want := "\x1b[<65;1;1M"
241 data := make([]byte, len(want))
242 _, err = io.ReadFull(terminal.In(), data)
243 require.NoError(t, err)
244 require.Equal(t, want, string(data))
245}