1package core
2
3import (
4 "context"
5 "errors"
6 "strings"
7 "testing"
8 "testing/synctest"
9 "time"
10
11 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
12 "github.com/bnema/neferwl/internal/ports"
13)
14
15// claimBase is the instant of the fixed test clock.
16var claimBase = time.Unix(1000, 0)
17
18// fixedClock is a mock clock at *now, which a test moves by assigning.
19func fixedClock(t *testing.T, now *time.Time) ports.Clock {
20 t.Helper()
21 clock := portsmocks.NewMockClock(t)
22 clock.EXPECT().Now().RunAndReturn(func() time.Time { return *now }).Maybe()
23 return clock
24}
25
26func stashSpawnCore(t *testing.T, commands chan ports.ClientCommand, spawn chan ports.SpawnRequest, clock ports.Clock) *Core {
27 t.Helper()
28 var cfg ports.Config
29 cfg.Keyboard.CmdKey = "alt"
30 cfg.Layout.MaxColumns = 2
31 c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: commands, Spawn: spawn}, Options{Clock: clock})
32 if err != nil {
33 t.Fatal(err)
34 }
35 c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200})
36 return c
37}
38
39func claimToken(req ports.SpawnRequest) string {
40 return strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=")
41}
42
43// Wayland reads tokens only while slots are pending, so the flag is in the
44// command channel before the spawn can reach the launcher, also when the
45// display reads nothing: in a synctest bubble the sender is waited on until
46// it blocks on the unread channel, and the spawn channel must still be empty.
47func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) {
48 synctest.Test(t, func(t *testing.T) {
49 now := claimBase
50 commands := make(chan ports.ClientCommand)
51 spawn := make(chan ports.SpawnRequest, 1)
52 c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now))
53 ctx, cancel := context.WithCancel(context.Background())
54 t.Cleanup(cancel)
55 c.out.start()
56 go c.out.run(ctx, commands)
57 done := make(chan error, 1)
58 go func() { done <- c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"firefox"}}, false) }()
59 synctest.Wait()
60 if len(spawn) != 0 {
61 t.Fatal("spawned before the pending flag was delivered")
62 }
63 select {
64 case err := <-done:
65 t.Fatalf("spawnStash returned before the flag was read: %v", err)
66 default:
67 }
68 if p, ok := (<-commands).(ports.SlotsPending); !ok || !p.Pending {
69 t.Fatal("first command is not SlotsPending{true}")
70 }
71 synctest.Wait()
72 if err := <-done; err != nil {
73 t.Fatal(err)
74 }
75 if len(spawn) != 1 {
76 t.Fatal("no spawn after the flag")
77 }
78 })
79}
80
81// A send the launcher queue suppresses leaves no claim behind, and the
82// pending flag falls again at the next publish.
83func TestSpawnStashSuppressedLeavesNoClaim(t *testing.T) {
84 now := claimBase
85 commands := make(chan ports.ClientCommand, 8)
86 c := stashSpawnCore(t, commands, nil, fixedClock(t, &now)) // no launcher queue: every send is suppressed
87 if err := c.spawnStash(context.Background(), c.cur().mon.Current(), [][]string{{"a"}, {"b"}}, false); err != nil {
88 t.Fatal(err)
89 }
90 if c.placement.anyPending() {
91 t.Fatal("suppressed claims stayed pending")
92 }
93 if v := (<-commands).(ports.SlotsPending); !v.Pending {
94 t.Fatal("pending not announced")
95 }
96 if err := c.publishPending(context.Background()); err != nil {
97 t.Fatal(err)
98 }
99 if v := (<-commands).(ports.SlotsPending); v.Pending {
100 t.Fatal("stale pending flag not republished")
101 }
102}
103
104// From the first command that is not issued, the rest are not sent and their
105// claims are cancelled; the issued ones keep theirs.
106func TestSpawnStashCancelsClaimsFromFirstUnissued(t *testing.T) {
107 for _, tc := range []struct {
108 name string
109 queue int
110 cancelled bool
111 issued int
112 wantErr error
113 }{
114 {"queue of one", 1, false, 1, nil},
115 {"publication fails", 4, true, 0, context.Canceled},
116 } {
117 t.Run(tc.name, func(t *testing.T) {
118 now := claimBase
119 commands := make(chan ports.ClientCommand) // unread: a send blocks until the context ends
120 spawn := make(chan ports.SpawnRequest, tc.queue)
121 c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now))
122 ctx, cancel := context.WithCancel(context.Background())
123 t.Cleanup(cancel)
124 if tc.cancelled {
125 cancel()
126 } else {
127 // An unread unbuffered channel would block the flag: read it.
128 go func() {
129 select {
130 case <-commands:
131 case <-ctx.Done():
132 }
133 }()
134 }
135 err := c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"a"}, {"b"}}, false)
136 if !errors.Is(err, tc.wantErr) {
137 t.Fatalf("error %v, want %v", err, tc.wantErr)
138 }
139 if len(spawn) != tc.issued {
140 t.Fatalf("%d spawns, want %d", len(spawn), tc.issued)
141 }
142 if len(c.placement.pending) != tc.issued {
143 t.Fatalf("%d claims kept, want %d", len(c.placement.pending), tc.issued)
144 }
145 if tc.issued == 1 {
146 if _, ok := c.placement.pending[claimToken(<-spawn)]; !ok {
147 t.Fatal("the issued command lost its claim")
148 }
149 }
150 })
151 }
152}
153
154// A claim is placed up to termRetry after it was made, and not a moment
155// later, even if no publish ran between its age and the window mapping.
156func TestStashClaimExpiry(t *testing.T) {
157 for _, tc := range []struct {
158 name string
159 age time.Duration
160 placed bool
161 }{
162 {"fresh", 0, true},
163 {"at the limit", termRetry, true},
164 {"just after", termRetry + time.Nanosecond, false},
165 } {
166 t.Run(tc.name, func(t *testing.T) {
167 now := claimBase
168 c := stashSpawnCore(t, nil, nil, fixedClock(t, &now))
169 m := c.cur().mon
170 m.AddWindow(1)
171 target := m.Current()
172 m.show(m.Workspaces[1])
173 req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: target, at: now})
174 now = claimBase.Add(tc.age)
175 c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)})
176 _, w := c.screenOf(5)
177 if placed := w == target && len(target.Stash) == 1; placed != tc.placed {
178 t.Fatalf("placed %t, want %t", placed, tc.placed)
179 }
180 })
181 }
182}
183
184// A workspace that left every screen no longer takes the window.
185func TestStashClaimWithoutWorkspaceMapsNormally(t *testing.T) {
186 now := claimBase
187 c := stashSpawnCore(t, nil, nil, fixedClock(t, &now))
188 req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: &Workspace{}, at: now})
189 c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)})
190 if _, w := c.screenOf(5); w != c.cur().mon.Current() {
191 t.Fatal("window did not map onto the current workspace")
192 }
193 if c.placement.anyPending() {
194 t.Fatal("claim kept")
195 }
196}
197
198// A window opened by a stash spawn is shown, selected and focused in the
199// stash whatever fullscreen state the workspace and the stash are in.
200func TestStashSpawnedAlwaysShown(t *testing.T) {
201 // base has tile 1 and stash [2 3], 3 selected, the stash shown.
202 base := func() *Workspace {
203 w := workspace()
204 w.AddWindow(1)
205 w.AddWindow(2)
206 w.AddWindow(3)
207 w.FocusID(2)
208 w.ToggleWindowStash()
209 w.FocusID(3)
210 w.ToggleWindowStash()
211 return w
212 }
213 shown := func(t *testing.T, w *Workspace) {
214 t.Helper()
215 if id, _ := w.Focused(); id != 5 {
216 t.Fatalf("focused %d, want 5", id)
217 }
218 if p := previewOf(t, w.Layout(), 5); p.Hidden || !p.Focused {
219 t.Fatalf("new window %+v", p)
220 }
221 if w.Stash[w.stashAt].ID != 5 {
222 t.Fatalf("selected %d", w.Stash[w.stashAt].ID)
223 }
224 }
225
226 t.Run("covering tile outside the stash", func(t *testing.T) {
227 w := base()
228 w.FocusID(1)
229 w.ToggleFullscreen()
230 w.stashSpawned(5)
231 shown(t, w)
232 if w.cover() != 1 || !w.stashOverCover() {
233 t.Fatalf("cover %d: the tile must stay fullscreen under the stash", w.cover())
234 }
235 if p := previewOf(t, w.Layout(), 1); !p.Fullscreen || p.Hidden {
236 t.Fatalf("tile %+v", p)
237 }
238 w.ToggleStashVisible()
239 if id, _ := w.Focused(); id != 1 || w.cover() != 1 {
240 t.Fatalf("hidden stash: focused %d cover %d, want the tile", id, w.cover())
241 }
242 })
243
244 t.Run("fullscreen stash member", func(t *testing.T) {
245 w := base()
246 w.ToggleFullscreen() // 3
247 if w.cover() != 3 {
248 t.Fatalf("setup: cover %d", w.cover())
249 }
250 w.stashSpawned(5)
251 shown(t, w)
252 if w.cover() != 0 {
253 t.Fatalf("cover %d: the stash member still covers", w.cover())
254 }
255 if p := previewOf(t, w.Layout(), 3); p.Fullscreen {
256 t.Fatalf("old member %+v", p)
257 }
258 })
259
260 t.Run("fullscreen waiting for the stash", func(t *testing.T) {
261 w := base()
262 w.ToggleStashVisible() // hidden
263 w.SetFullscreen(3, true)
264 if w.hiddenFullscreen != 3 {
265 t.Fatalf("setup: deferred %d", w.hiddenFullscreen)
266 }
267 w.stashSpawned(5)
268 shown(t, w)
269 if w.cover() != 0 || w.hiddenFullscreen != 0 {
270 t.Fatalf("cover %d, deferred %d", w.cover(), w.hiddenFullscreen)
271 }
272 w.ToggleStashVisible()
273 w.ToggleStashVisible()
274 shown(t, w)
275 })
276}