1package core
2
3import (
4 "context"
5 "testing"
6 "time"
7
8 "github.com/bnema/neferwl/internal/ports"
9)
10
11// hiddenCapture is a 300x200 output whose first workspace (windows 1 and 2,
12// a 100x100 viewport) is off screen, captured.
13func hiddenCapture(t *testing.T) (*Core, *Workspace) {
14 t.Helper()
15 c, ws, _ := hiddenCaptureCommands(t)
16 return c, ws
17}
18
19// hiddenCaptureCommands is hiddenCapture with the channel core sends its commands on.
20func hiddenCaptureCommands(t *testing.T) (*Core, *Workspace, chan ports.ClientCommand) {
21 t.Helper()
22 commands := make(chan ports.ClientCommand, 64)
23 var cfg ports.Config
24 cfg.Keyboard.CmdKey = "super"
25 cfg.Layout.MaxColumns = 2
26 cfg.Border.Width = 2
27 cfg.Background.Color = "#102030"
28 c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: commands}, Options{})
29 if err != nil {
30 t.Fatal(err)
31 }
32 c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200})
33 m := c.screens[0].mon
34 ws := m.Current()
35 ws.SetSize(100, 100)
36 m.AddWindow(1)
37 m.AddWindow(2)
38 m.Focus(1)
39 if m.Current() == ws {
40 t.Fatal("workspace still on screen")
41 }
42 c.configures.cw.load(c.screens[0], ws)
43 return c, ws, commands
44}
45
46func TestCaptureConfigureSizesHiddenWindowWithoutShowingIt(t *testing.T) {
47 c, _ := hiddenCapture(t)
48 sc := c.screens[0]
49 phys := Placement{ID: 1, Hidden: true}
50 t0 := configureTarget{output: "A", view: sc.mon.viewport()}
51 cp, tt := c.captureConfigure(sc, phys, t0)
52 if cp == nil || cp.Hidden {
53 t.Fatalf("no real placement: %+v", cp)
54 }
55 v, send := c.configures.nextWithCapture(phys, tt, cp)
56 want := c.clientRect(*cp)
57 if !send || v.Width != want.W || v.Height != want.H || want.W == 0 {
58 t.Fatalf("configure %+v send %v want size %+v", v, send, want)
59 }
60 if v.Visible || v.Activated || !v.Captured || v.Output != "A" {
61 t.Fatalf("captured hidden window must be sized, unfocused, physically invisible: %+v", v)
62 }
63 c.configures.mark(v)
64 if _, send := c.configures.nextWithCapture(phys, tt, cp); send {
65 t.Fatal("unchanged captured configure sent again")
66 }
67 // The session stops: the window is only hidden again, keeps its size and drops Captured.
68 c.configures.cw.reset()
69 cp, tt = c.captureConfigure(sc, phys, t0)
70 if cp != nil {
71 t.Fatal("placement kept after the session")
72 }
73 v2, send := c.configures.nextWithCapture(phys, tt, cp)
74 if !send || v2.Captured || v2.Visible || v2.Width != v.Width || v2.Height != v.Height {
75 t.Fatalf("released window %+v send %v after %+v", v2, send, v)
76 }
77}
78
79func TestCaptureConfigureLeavesOtherWindowsAlone(t *testing.T) {
80 c, _ := hiddenCapture(t)
81 sc := c.screens[0]
82 phys := Placement{ID: 99, Hidden: true}
83 t0 := configureTarget{output: "A", view: sc.mon.viewport()}
84 if cp, tt := c.captureConfigure(sc, phys, t0); cp != nil || tt != t0 {
85 t.Fatalf("window of another workspace captured: %+v", cp)
86 }
87 v, _ := c.configures.nextWithCapture(phys, t0, nil)
88 if v.Captured || v.Visible || v.Width != 0 {
89 t.Fatalf("plain hidden configure %+v", v)
90 }
91}
92
93func TestCaptureSceneRebasesOnViewport(t *testing.T) {
94 c, ws := hiddenCapture(t)
95 f := ws.Output
96 if f != (Rect{X: 100, Y: 50, W: 100, H: 100}) {
97 t.Fatalf("viewport %+v", f)
98 }
99 c.popups[10] = &popupState{id: 10, parent: 1, rect: Rect{X: 3, Y: 4, W: 10, H: 8}, mapped: true}
100 c.popupOrder = append(c.popupOrder, 10)
101 s := c.captureScene(7)
102 if s == nil || s.OutputWidth != 100 || s.OutputHeight != 100 || s.WorkspaceClip != (Rect{W: 100, H: 100}) || s.Seq != 7 || s.Output != "A" {
103 t.Fatalf("scene %+v", s)
104 }
105 if s.Capture != nil || s.CaptureScene != nil || len(s.Layers) != 0 || s.Background != "#102030" || s.Border.Width != 2 {
106 t.Fatalf("capture scene must be flat, layerless and use the normal config: %+v", s)
107 }
108 byID := map[WindowID]ports.SceneWindow{}
109 for _, w := range s.Windows {
110 byID[w.ID] = w
111 }
112 for _, p := range ws.Layout() {
113 w := byID[p.ID]
114 if want := (Rect{X: p.Rect.X - f.X, Y: p.Rect.Y - f.Y, W: p.Rect.W, H: p.Rect.H}); w.Rect != want || w.Focused {
115 t.Fatalf("window %d rect %+v want %+v focused %v", p.ID, w.Rect, want, w.Focused)
116 }
117 if w.Rect.X < 0 || w.Rect.Y < 0 || w.Rect.X+w.Rect.W > 100 || w.Rect.Y+w.Rect.H > 100 {
118 t.Fatalf("window %d outside its viewport: %+v", p.ID, w.Rect)
119 }
120 }
121 client := c.clientRect(ws.Layout()[0])
122 pop := byID[10]
123 if !pop.Popup || pop.Rect != (Rect{X: client.X - f.X + 3, Y: client.Y - f.Y + 4, W: 10, H: 8}) {
124 t.Fatalf("popup %+v client %+v", pop, client)
125 }
126 for _, sep := range s.Separators {
127 if sep.Rect.X < 0 || sep.Rect.Y < 0 || sep.Rect.X+sep.Rect.W > 100 || sep.Rect.Y+sep.Rect.H > 100 {
128 t.Fatalf("separator outside the viewport: %+v", sep)
129 }
130 }
131 c.configures.cw.reset()
132 if c.captureScene(8) != nil {
133 t.Fatal("scene without a captured workspace")
134 }
135}
136
137func TestCapturedPopupsSurviveButHiddenWindowsTakeNoInput(t *testing.T) {
138 c, _ := hiddenCapture(t)
139 c.popups[10] = &popupState{id: 10, parent: 1, rect: Rect{X: 3, Y: 4, W: 10, H: 8}, mapped: true}
140 c.popupOrder = append(c.popupOrder, 10)
141 if _, _, ok := c.windowRect(10); ok {
142 t.Fatal("captured popup is physically on screen")
143 }
144 if _, _, ok := c.windowRect(1); ok || c.visible(1) {
145 t.Fatal("captured window is physically on screen")
146 }
147 if id, _, _ := c.hit(150, 100); id != 0 {
148 t.Fatalf("pointer hit hidden window %d", id)
149 }
150 if sc := c.screens[0]; len(c.scenePopups(sc)) != 0 {
151 t.Fatal("captured popup drawn on the physical scene")
152 }
153 if err := c.closeHiddenPopups(context.Background()); err != nil {
154 t.Fatal(err)
155 }
156 if c.popups[10] == nil {
157 t.Fatal("popup of a captured window dismissed")
158 }
159 c.configures.cw.reset()
160 if err := c.closeHiddenPopups(context.Background()); err != nil {
161 t.Fatal(err)
162 }
163 if c.popups[10] != nil {
164 t.Fatal("popup of a hidden window kept after the session")
165 }
166}
167
168func TestCaptureWorkspaceReusesStorage(t *testing.T) {
169 c, ws := hiddenCapture(t)
170 sc := c.screens[0]
171 cw := &c.configures.cw
172 layoutAllocs := testing.AllocsPerRun(50, func() { _ = ws.Layout() })
173 loadAllocs := testing.AllocsPerRun(50, func() { cw.load(sc, ws) })
174 if loadAllocs > layoutAllocs {
175 t.Fatalf("load allocates %v, more than the layout it wraps (%v)", loadAllocs, layoutAllocs)
176 }
177 if got := testing.AllocsPerRun(50, func() {
178 cw.reset()
179 cw.load(sc, ws)
180 }); got > layoutAllocs {
181 t.Fatalf("reset+load allocates %v, layout %v", got, layoutAllocs)
182 }
183}
184
185// While the captured workspace itself slides, the live (Active) session keeps
186// tracking it in its own viewport: its windows stay captured and its popups
187// stay, and both are forgotten when the session ends.
188func TestCapturedWorkspaceStaysCapturedWhileSliding(t *testing.T) {
189 c, ws, _ := hiddenCaptureCommands(t)
190 c.configures.cw.reset()
191 c.capt.sessions = []*capSession{{open: ports.CaptureSessionOpen{ID: 1, Workspace: ws.ID}}}
192 c.popups[10] = &popupState{id: 10, parent: 1, rect: Rect{X: 3, Y: 4, W: 10, H: 8}, mapped: true}
193 c.popupOrder = append(c.popupOrder, 10)
194 ws.view.motion = newMotion(viewSpring(10, 0), time.Time{}, 1)
195 ctx := context.Background()
196 if err := c.publish(ctx); err != nil {
197 t.Fatal(err)
198 }
199 if v := c.capt.sessions[0].last; !v.Hidden || !v.Active || v.Reason.Terminal() {
200 t.Fatalf("session state %+v", v)
201 }
202 if !c.configures.cw.active() || c.popups[10] == nil {
203 t.Fatal("sliding session lost its captured workspace or popup")
204 }
205 if v, ok := c.configures.sent[1]; !ok || !v.Captured || v.Visible || v.Activated || v.Width <= 0 {
206 t.Fatalf("window during the slide %+v (sent %v)", v, ok)
207 }
208 ws.view.motion = motion{}
209 if err := c.publish(ctx); err != nil {
210 t.Fatal(err)
211 }
212 if !c.configures.cw.active() {
213 t.Fatalf("captured workspace lost after the slide: %+v", c.capt.sessions[0].last)
214 }
215 // The session ends: the captured workspace is forgotten and its popup goes.
216 c.captureClose(1)
217 if err := c.publish(ctx); err != nil {
218 t.Fatal(err)
219 }
220 if c.configures.cw.active() || c.popups[10] != nil {
221 t.Fatal("captured workspace or popup kept after the session")
222 }
223 if v, ok := c.configures.sent[1]; !ok || v.Captured || v.Visible || v.Width <= 0 {
224 t.Fatalf("window after the session %+v (sent %v)", v, ok)
225 }
226}
227
228// In the overview the captured workspace's windows keep their real size and
229// their physical state; nothing is sized as a thumbnail.
230func TestCapturedWorkspaceInOverviewKeepsRealSize(t *testing.T) {
231 c, ws, _ := hiddenCaptureCommands(t)
232 c.configures.cw.reset()
233 m := c.screens[0].mon
234 m.Focus(0)
235 if m.Current() != ws {
236 t.Fatal("workspace not current")
237 }
238 ctx := context.Background()
239 if err := c.publish(ctx); err != nil {
240 t.Fatal(err)
241 }
242 before := map[WindowID]ports.ConfigureWindow{}
243 for _, p := range ws.Layout() {
244 v, ok := c.configures.sent[p.ID]
245 if !ok || v.Width <= 0 || !v.Visible || v.Captured {
246 t.Fatalf("window %d before the overview %+v (sent %v)", p.ID, v, ok)
247 }
248 before[p.ID] = v
249 }
250 m.ToggleOverview()
251 c.capt.sessions = []*capSession{{open: ports.CaptureSessionOpen{ID: 1, Workspace: ws.ID}}}
252 if err := c.publish(ctx); err != nil {
253 t.Fatal(err)
254 }
255 if !c.configures.cw.active() {
256 t.Fatal("overview did not track the captured workspace")
257 }
258 for _, p := range ws.Layout() {
259 v, ok := c.configures.sent[p.ID]
260 if !ok {
261 t.Fatalf("window %d has no configure in the overview", p.ID)
262 }
263 // A preview keeps the client's real size and its physical state; it
264 // is on screen, so it is not Captured.
265 if v.Width != before[p.ID].Width || v.Height != before[p.ID].Height || v.Width != c.clientRect(p).W || v.Height != c.clientRect(p).H {
266 t.Fatalf("window %d configured %dx%d in the overview, real %dx%d", p.ID, v.Width, v.Height, c.clientRect(p).W, c.clientRect(p).H)
267 }
268 if !v.Visible || v.Captured || v.Activated != before[p.ID].Activated {
269 t.Fatalf("window %d shown as a preview but %+v, was %+v", p.ID, v, before[p.ID])
270 }
271 }
272}
273
274// The workspace capture scene carries the corner radius like the live one:
275// a tile with gaps and a float are rounded.
276func TestCaptureSceneRadius(t *testing.T) {
277 c, ws := hiddenCapture(t)
278 c.cfg.Border.Radius = 9
279 ws.Gaps = 4
280 ws.AddFloating(3, 20, 20)
281 c.configures.cw.load(c.screens[0], ws)
282 s := c.captureScene(1)
283 if s == nil || s.Border.Radius != 9 {
284 t.Fatalf("scene %+v", s)
285 }
286 var tiles, floats int
287 for _, w := range s.Windows {
288 if w.Popup {
289 continue
290 }
291 if w.Radius != 9 {
292 t.Fatalf("window %d (floating %v) radius %d, want 9", w.ID, w.Floating, w.Radius)
293 }
294 if w.Floating {
295 floats++
296 } else {
297 tiles++
298 }
299 }
300 if tiles == 0 || floats == 0 {
301 t.Fatalf("tiles %d floats %d", tiles, floats)
302 }
303}