1package core
2
3import (
4 "context"
5 "slices"
6 "testing"
7 "time"
8
9 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
10 "github.com/bnema/neferwl/internal/ports"
11)
12
13type pointerRig struct {
14 *Core
15 commands chan ports.ClientCommand
16}
17
18func borderCore(t *testing.T, row bool) (*pointerRig, *Workspace) {
19 t.Helper()
20 commands := make(chan ports.ClientCommand, 1024)
21 c := &pointerRig{Core: dragCore(t), commands: commands}
22 c.ch.Commands = commands
23 w := c.cur().mon.Current()
24 w.Overflow = OverflowFixed
25 w.SetMaxColumns(2)
26 w.SetGaps(10)
27 w.AddWindow(1)
28 w.AddWindow(2)
29 if row {
30 w.Columns = []Column{{Windows: []WindowID{1, 2}}}
31 w.Focus = 0
32 }
33 rs := w.columnRects()
34 c.cursorX, c.cursorY = float64(rs[0].X+rs[0].W+w.gap()/2), 100
35 if row {
36 rows := rowRects(rs[0], w.Columns[0], w.gap())
37 c.cursorX, c.cursorY = 100, float64(rows[0].Y+rows[0].H+w.gap()/2)
38 }
39 c.mods = ports.ModSuper
40 return c, w
41}
42
43func grabButton(t *testing.T, c *pointerRig, b uint32, down bool, at time.Duration) {
44 t.Helper()
45 if err := c.pointerButton(context.Background(), ports.PointerButton{Button: b, Pressed: down, Time: at}); err != nil {
46 t.Fatal(err)
47 }
48}
49
50func grabMove(t *testing.T, c *pointerRig, x, y float64) {
51 t.Helper()
52 if err := c.pointerMotion(context.Background(), ports.PointerMotion{X: x, Y: y, Time: time.Second}); err != nil {
53 t.Fatal(err)
54 }
55}
56
57func grabCommands(c *pointerRig) []ports.ClientCommand {
58 var out []ports.ClientCommand
59 for len(c.commands) > 0 {
60 out = append(out, <-c.commands)
61 }
62 return out
63}
64
65func requireOverride(t *testing.T, cmds []ports.ClientCommand, shape string) {
66 t.Helper()
67 if !slices.ContainsFunc(cmds, func(cmd ports.ClientCommand) bool { v, ok := cmd.(ports.CursorOverride); return ok && v.Shape == shape }) {
68 t.Fatalf("missing cursor %q in %+v", shape, cmds)
69 }
70}
71
72func TestBorderPointerGrab(t *testing.T) {
73 for _, row := range []bool{false, true} {
74 t.Run(map[bool]string{false: "column", true: "row"}[row], func(t *testing.T) {
75 c, w := borderCore(t, row)
76 before := w.Layout()
77 grabButton(t, c, btnRight, true, time.Second)
78 if c.pgrab == nil || c.pointer != 0 || c.drag != nil {
79 t.Fatal("no exclusive border grab")
80 }
81 shape := "ew-resize"
82 if row {
83 shape = "ns-resize"
84 }
85 requireOverride(t, grabCommands(c), shape)
86 x, y := c.cursorX+150, c.cursorY
87 if row {
88 x, y = c.cursorX, c.cursorY+100
89 }
90 grabMove(t, c, x, y)
91 after := w.Layout()
92 if before[0].Rect == after[0].Rect || before[1].Rect == after[1].Rect {
93 t.Fatal("tiles did not resize", before, after)
94 }
95 cmds := grabCommands(c)
96 for _, id := range []WindowID{1, 2} {
97 if !slices.ContainsFunc(cmds, func(cmd ports.ClientCommand) bool {
98 v, ok := cmd.(ports.ConfigureWindow)
99 r := after[int(id)-1].Rect
100 return ok && v.ID == id && v.Width == r.W-2*w.border && v.Height == r.H-2*w.border
101 }) {
102 t.Fatalf("no live configure for %d: %+v", id, cmds)
103 }
104 }
105 grabButton(t, c, btnRight, false, 2*time.Second)
106 if c.pgrab != nil || !slices.Equal(w.Layout(), after) {
107 t.Fatal("release changed sizes")
108 }
109 requireOverride(t, grabCommands(c), "")
110 })
111 }
112}
113
114func TestBorderGrabEscape(t *testing.T) {
115 for _, row := range []bool{false, true} {
116 c, w := borderCore(t, row)
117 before := w.Layout()
118 grabButton(t, c, btnRight, true, time.Second)
119 grabMove(t, c, c.cursorX+100, c.cursorY+100)
120 grabCommands(c)
121 if err := c.keyEvent(context.Background(), ports.KeyEvent{Keysym: "Escape", Pressed: true}); err != nil {
122 t.Fatal(err)
123 }
124 if c.pgrab != nil || !slices.Equal(w.Layout(), before) {
125 t.Fatal("cancel did not restore", w.Layout(), before)
126 }
127 requireOverride(t, grabCommands(c), "")
128 if !c.swallow[btnRight] {
129 t.Fatal("release not swallowed")
130 }
131 grabButton(t, c, btnRight, false, 2*time.Second)
132 if c.buttons[btnRight] || c.swallow[btnRight] {
133 t.Fatal("release still held")
134 }
135 }
136}
137
138func TestBorderGrabOtherButtons(t *testing.T) {
139 c, _ := borderCore(t, false)
140 grabButton(t, c, btnRight, true, time.Second)
141 grabCommands(c)
142 grabButton(t, c, btnLeft, true, time.Second)
143 grabButton(t, c, btnLeft, false, time.Second)
144 if c.pgrab == nil || len(grabCommands(c)) != 0 || c.buttons[btnLeft] {
145 t.Fatal("other button escaped grab")
146 }
147}
148
149func TestBorderGrabPreconditions(t *testing.T) {
150 for _, kind := range []string{"no-cmd", "interior", "overview", "preview", "synthetic", "held", "inhibited"} {
151 t.Run(kind, func(t *testing.T) {
152 c, _ := borderCore(t, false)
153 switch kind {
154 case "no-cmd":
155 c.mods = 0
156 case "interior":
157 c.cursorX = 100
158 case "overview":
159 c.cur().mon.ov.open = true
160 case "preview":
161 c.cur().mon.sw = switcherState{ws: c.cur().mon.Current(), shown: true}
162 case "synthetic":
163 c.synthetic = true
164 case "held":
165 c.buttons[btnLeft] = true
166 case "inhibited":
167 c.keyboard.inhibiting = 1
168 c.cur().mon.Current().FocusID(1)
169 }
170 started, err := c.startPointerGrab(context.Background(), ports.PointerButton{Button: btnRight, Pressed: true})
171 if err != nil || started || c.pgrab != nil {
172 t.Fatal(started, err)
173 }
174 })
175 }
176}
177
178func TestBorderGrabFloatFallback(t *testing.T) {
179 c, w := borderCore(t, false)
180 w.AddFloating(3, 200, 200)
181 r := w.floatRect(w.Floats[0])
182 c.pointer = 3
183 c.cursorX, c.cursorY = float64(r.X+r.W/2), float64(r.Y+r.H/2)
184 grabButton(t, c, btnRight, true, time.Second)
185 if c.pgrab != nil || c.drag == nil || !c.drag.resize || !c.drag.float {
186 t.Fatal("float resize lost")
187 }
188}
189
190func TestBorderDoubleClick(t *testing.T) {
191 for _, row := range []bool{false, true} {
192 c, w := borderCore(t, row)
193 grabButton(t, c, btnRight, true, time.Second)
194 grabMove(t, c, c.cursorX+100, c.cursorY+100)
195 grabButton(t, c, btnRight, false, 1100*time.Millisecond)
196 grabButton(t, c, btnRight, true, 1200*time.Millisecond)
197 if c.pgrab != nil || !c.swallow[btnRight] || c.lastBorderClick.ws != nil {
198 t.Fatal("not reset")
199 }
200 for _, col := range w.Columns {
201 if col.Span != 0 || col.Shares != nil {
202 t.Fatal("manual size remains", col)
203 }
204 }
205 grabButton(t, c, btnRight, false, 1300*time.Millisecond)
206 }
207}
208
209func TestBorderGrabInvalidation(t *testing.T) {
210 for _, change := range []string{"window", "workspace", "output"} {
211 t.Run(change, func(t *testing.T) {
212 c, w := borderCore(t, false)
213 grabButton(t, c, btnRight, true, time.Second)
214 grabCommands(c)
215 switch change {
216 case "window":
217 if err := c.unmapClient(context.Background(), ports.WindowUnmapped{ID: 2}); err != nil {
218 t.Fatal(err)
219 }
220 case "workspace":
221 c.cur().mon.Workspaces = append(c.cur().mon.Workspaces, workspace())
222 c.cur().mon.Active = len(c.cur().mon.Workspaces) - 1
223 case "output":
224 c.screens = []*screen{dragCore(t).cur()}
225 }
226 if c.pgrab != nil {
227 if err := c.pgrab.motion(context.Background(), time.Second); err != nil {
228 t.Fatal(err)
229 }
230 }
231 if c.pgrab != nil || !c.swallow[btnRight] {
232 t.Fatal("stale grab survives", w.Columns)
233 }
234 requireOverride(t, grabCommands(c), "")
235 })
236 }
237}
238
239func TestBorderGrabSecurityReset(t *testing.T) {
240 c, _ := borderCore(t, false)
241 grabButton(t, c, btnRight, true, time.Second)
242 grabCommands(c)
243 gate := portsmocks.NewMockSessionSecurity(t)
244 gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true})
245 c.opts.Security = gate
246 if !c.syncSecurity() || c.pgrab != nil {
247 t.Fatal("grab survived protection")
248 }
249 if len(grabCommands(c)) != 0 {
250 t.Fatal("security reset sent commands")
251 }
252}
253
254func TestPointerGrabValidityContract(t *testing.T) {
255 c, _ := borderCore(t, false)
256 grabButton(t, c, btnRight, true, time.Second)
257 if !c.pgrab.valid() {
258 t.Fatal("new grab is invalid")
259 }
260 c.screens = nil
261 if c.pgrab.valid() {
262 t.Fatal("grab survived output removal")
263 }
264}
265
266func TestBorderGrabFullscreenPublish(t *testing.T) {
267 c, w := borderCore(t, false)
268 w.Overflow = OverflowScroll
269 grabButton(t, c, btnRight, true, time.Second)
270 if c.pgrab == nil {
271 t.Fatal("grab not started")
272 }
273 grabCommands(c)
274 w.fullscreen = 1
275 if err := c.publish(context.Background()); err != nil {
276 t.Fatal(err)
277 }
278 if c.pgrab != nil || !c.swallow[btnRight] {
279 t.Fatal("fullscreen did not abort grab and swallow release")
280 }
281 requireOverride(t, grabCommands(c), "")
282 grabButton(t, c, btnRight, false, 2*time.Second)
283 for _, cmd := range grabCommands(c) {
284 if _, ok := cmd.(ports.PointerButtonTo); ok {
285 t.Fatal("release forwarded")
286 }
287 }
288 if c.buttons[btnRight] || c.swallow[btnRight] {
289 t.Fatal("release still held")
290 }
291}