1package core_test
2
3import (
4 "slices"
5 "testing"
6 "time"
7
8 "github.com/bnema/neferwl/internal/ports"
9)
10
11// stashSpawnRig has a stash bind on Alt+f and, as in the defaults, foot as
12// the terminal. Tiles map without any automatic terminal.
13func stashSpawnRig(t *testing.T, edit func(*ports.Config)) *multiRig {
14 t.Helper()
15 return startMulti(t, func(c *ports.Config) {
16 c.Binds["Alt+Ctrl+Shift+f"] = "stash firefox --new-window"
17 c.Stash.Capture = false
18 if edit != nil {
19 edit(c)
20 }
21 }, left)
22}
23
24func slotToken(t *testing.T, req ports.SpawnRequest) string {
25 t.Helper()
26 if len(req.Env) != 1 || len(req.Env[0]) <= len(ports.SlotEnv)+1 || req.Env[0][:len(ports.SlotEnv)+1] != ports.SlotEnv+"=" {
27 t.Fatalf("no slot token: %v", req.Env)
28 }
29 return req.Env[0][len(ports.SlotEnv)+1:]
30}
31
32// pendingSent drains the command queue and reports the last SlotsPending.
33func pendingSent(r *multiRig) (pending, sent bool) {
34 for len(r.commands) > 0 {
35 if v, ok := (<-r.commands).(ports.SlotsPending); ok {
36 pending, sent = v.Pending, true
37 }
38 }
39 return pending, sent
40}
41
42func sceneWindow(set []ports.Scene, id ports.WindowID) (ports.SceneWindow, bool) {
43 for _, s := range set {
44 for _, w := range s.Windows {
45 if w.ID == id {
46 return w, true
47 }
48 }
49 }
50 return ports.SceneWindow{}, false
51}
52
53// The window of a stash bind opens in the stash of the workspace the key
54// was pressed on, shown and focused, even when the user moved on before it
55// mapped; toggle-window-stash then tiles it as a new column.
56func TestStashBindOpensWindowInStash(t *testing.T) {
57 r := stashSpawnRig(t, nil)
58 r.mapWindow(t, 1)
59 r.key(t, "f", ports.ModAlt|ports.ModCtrl|ports.ModShift)
60 req := receive(t, r.spawn)
61 if !slices.Equal(req.Argv, []string{"firefox", "--new-window"}) {
62 t.Fatalf("argv %v", req.Argv)
63 }
64 token := slotToken(t, req)
65 if pending, sent := pendingSent(r); !sent || !pending {
66 t.Fatal("SlotsPending{true} was not sent")
67 }
68 r.key(t, "Next", ports.ModAlt) // focus moves to workspace 2
69 r.client <- ports.WindowMapped{ID: 2, Slot: token}
70 set := receive(t, r.scenes)
71 if w, ok := sceneWindow(set, 2); ok && !w.Hidden {
72 t.Fatalf("window shown on the workspace the user moved to: %+v", w)
73 }
74 set = r.key(t, "Prior", ports.ModAlt)
75 w, ok := sceneWindow(set, 2)
76 if !ok || w.Hidden || !w.Floating || !w.Focused {
77 t.Fatalf("stashed window %+v", w)
78 }
79 if w1, _ := sceneWindow(set, 1); w1.Focused {
80 t.Fatal("tile kept the focus")
81 }
82 if pending, sent := pendingSent(r); !sent || pending {
83 t.Fatalf("pending flag not cleared: %v %v", pending, sent)
84 }
85 // Unstashing tiles it as a column of its own.
86 set = r.key(t, "s", ports.ModAlt|ports.ModShift)
87 if w, ok := sceneWindow(set, 2); !ok || w.Floating || w.Hidden || !w.Focused {
88 t.Fatalf("unstashed window %+v", w)
89 }
90 if len(r.spawn) != 0 {
91 t.Fatal("unexpected spawn")
92 }
93}
94
95func TestStashBindRunsEveryCommand(t *testing.T) {
96 r := stashSpawnRig(t, func(c *ports.Config) { c.Binds["Alt+Ctrl+Shift+f"] = "stash one; two" })
97 r.key(t, "f", ports.ModAlt|ports.ModCtrl|ports.ModShift)
98 a, b := receive(t, r.spawn), receive(t, r.spawn)
99 if a.Argv[0] != "one" || b.Argv[0] != "two" || slotToken(t, a) == slotToken(t, b) {
100 t.Fatal(a, b)
101 }
102}
103
104// With an empty stash, toggle-stash-visible opens the terminal in it, once
105// while it is awaited.
106func TestToggleEmptyStashOpensTerminal(t *testing.T) {
107 r := stashSpawnRig(t, nil)
108 r.mapWindow(t, 1)
109 r.key(t, "s", ports.ModAlt)
110 req := receive(t, r.spawn)
111 if len(req.Argv) != 1 || req.Argv[0] != "foot" {
112 t.Fatalf("argv %v", req.Argv)
113 }
114 token := slotToken(t, req)
115 if pending, sent := pendingSent(r); !sent || !pending {
116 t.Fatal("SlotsPending{true} was not sent")
117 }
118 r.key(t, "s", ports.ModAlt)
119 if len(r.spawn) != 0 {
120 t.Fatal("second terminal while the first is awaited")
121 }
122 r.client <- ports.WindowMapped{ID: 2, Slot: token}
123 set := receive(t, r.scenes)
124 if w, ok := sceneWindow(set, 2); !ok || w.Hidden || !w.Floating || !w.Focused {
125 t.Fatalf("terminal %+v", w)
126 }
127 // The stash has a window now: the toggle hides it, no spawn.
128 set = r.key(t, "s", ports.ModAlt)
129 if w, _ := sceneWindow(set, 2); !w.Hidden {
130 t.Fatalf("stash not hidden: %+v", w)
131 }
132 if len(r.spawn) != 0 {
133 t.Fatal("spawned for a stash holding a window")
134 }
135}
136
137func TestToggleNonEmptyStashDoesNotSpawn(t *testing.T) {
138 r := stashSpawnRig(t, nil)
139 r.mapWindow(t, 1)
140 r.mapWindow(t, 2)
141 r.key(t, "s", ports.ModAlt|ports.ModShift) // 2 stashed
142 r.key(t, "s", ports.ModAlt)
143 r.key(t, "s", ports.ModAlt)
144 if len(r.spawn) != 0 {
145 t.Fatal("spawned for a non-empty stash")
146 }
147}
148
149func TestToggleEmptyStashRespectsOption(t *testing.T) {
150 r := stashSpawnRig(t, func(c *ports.Config) { c.Stash.SpawnTerminal = false })
151 r.mapWindow(t, 1)
152 r.key(t, "s", ports.ModAlt)
153 if len(r.spawn) != 0 {
154 t.Fatal("spawned with stash.spawn-terminal off")
155 }
156 r = stashSpawnRig(t, func(c *ports.Config) { c.Terminal.Command = nil })
157 r.mapWindow(t, 1)
158 r.key(t, "s", ports.ModAlt)
159 if len(r.spawn) != 0 {
160 t.Fatal("spawned without a terminal command")
161 }
162}
163
164func TestToggleEmptyStashInOverviewDoesNotSpawn(t *testing.T) {
165 r := stashSpawnRig(t, nil)
166 r.mapWindow(t, 1)
167 r.key(t, "o", ports.ModAlt)
168 r.key(t, "s", ports.ModAlt)
169 if len(r.spawn) != 0 {
170 t.Fatal("spawned from the overview")
171 }
172}
173
174// A four-finger swipe down on an empty stash is toggle-stash-visible: with
175// stash.spawn-terminal it opens the terminal in the stash, once while that
176// one is awaited, and the window that maps lands in the stash.
177func TestFourFingerSwipeEmptyStashOpensTerminal(t *testing.T) {
178 r := startSwipe(t, func(c *ports.Config) { c.Stash.SpawnTerminal = true })
179 r.four(t, 0, 40, 10)
180 req := receive(t, r.spawn)
181 if len(req.Argv) != 1 || req.Argv[0] != "foot" {
182 t.Fatalf("argv %v", req.Argv)
183 }
184 token := slotToken(t, req)
185 if pending, sent := pendingSent(r.multiRig); !sent || !pending {
186 t.Fatal("SlotsPending{true} was not sent")
187 }
188 r.four(t, 0, 40, 10)
189 if len(r.spawn) != 0 {
190 t.Fatal("second terminal while the first is awaited")
191 }
192 r.client <- ports.WindowMapped{ID: 2, Slot: token}
193 if w, ok := sceneWindow(receive(t, r.scenes), 2); !ok || w.Hidden || !w.Floating || !w.Focused {
194 t.Fatalf("terminal %+v", w)
195 }
196}
197
198func TestFourFingerSwipeEmptyStashRespectsOption(t *testing.T) {
199 r := startSwipe(t, func(c *ports.Config) { c.Stash.SpawnTerminal = false })
200 r.four(t, 0, 40, 10)
201 if len(r.spawn) != 0 {
202 t.Fatal("spawned with stash.spawn-terminal off")
203 }
204}
205
206// The terminal of a four-finger swipe shows no workspace: the empty slots of
207// the workspace on screen are not refilled, as with the bind.
208func TestFourFingerSwipeEmptyStashDoesNotRefillSlots(t *testing.T) {
209 r := startSlots(t)
210 r.fill(t)
211 r.press(t, "d") // dev on screen, its slots filled
212 r.client <- ports.WindowUnmapped{ID: 2}
213 sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
214 noSpawn(t, r.spawn) // nothing relaunches on its own
215 var at time.Duration
216 r.input <- ports.SwipeBegin{Fingers: 4, Time: at}
217 for range 10 {
218 at += 8 * time.Millisecond
219 r.input <- ports.SwipeUpdate{DY: 40, Time: at}
220 }
221 r.input <- ports.SwipeEnd{Time: at}
222 req := receive(t, r.spawn)
223 if len(req.Argv) != 1 || req.Argv[0] != "foot" {
224 t.Fatalf("argv %v, want the stash terminal", req.Argv)
225 }
226 r.sync(t)
227 noSpawn(t, r.spawn) // the empty slot of dev stays empty
228}