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 sent
44// before the spawn can reach the launcher. In a synctest bubble the worker
45// is waited on until it blocks sending the flag on the unread command
46// channel: the spawn channel must still be empty then.
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 done := make(chan error, 1)
56 go func() { done <- c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"firefox"}}, false) }()
57 synctest.Wait()
58 if len(spawn) != 0 {
59 t.Fatal("spawned before the pending flag was delivered")
60 }
61 select {
62 case err := <-done:
63 t.Fatalf("spawnStash returned before the flag was read: %v", err)
64 default:
65 }
66 if p, ok := (<-commands).(ports.SlotsPending); !ok || !p.Pending {
67 t.Fatal("first command is not SlotsPending{true}")
68 }
69 synctest.Wait()
70 if err := <-done; err != nil {
71 t.Fatal(err)
72 }
73 if len(spawn) != 1 {
74 t.Fatal("no spawn after the flag")
75 }
76 })
77}
78
79// A send the launcher queue suppresses leaves no claim behind, and the
80// pending flag falls again at the next publish.
81func TestSpawnStashSuppressedLeavesNoClaim(t *testing.T) {
82 now := claimBase
83 commands := make(chan ports.ClientCommand, 8)
84 c := stashSpawnCore(t, commands, nil, fixedClock(t, &now)) // no launcher queue: every send is suppressed
85 if err := c.spawnStash(context.Background(), c.cur().mon.Current(), [][]string{{"a"}, {"b"}}, false); err != nil {
86 t.Fatal(err)
87 }
88 if c.placement.anyPending() {
89 t.Fatal("suppressed claims stayed pending")
90 }
91 if v := (<-commands).(ports.SlotsPending); !v.Pending {
92 t.Fatal("pending not announced")
93 }
94 if err := c.publishPending(context.Background()); err != nil {
95 t.Fatal(err)
96 }
97 if v := (<-commands).(ports.SlotsPending); v.Pending {
98 t.Fatal("stale pending flag not republished")
99 }
100}
101
102// From the first command that is not issued, the rest are not sent and their
103// claims are cancelled; the issued ones keep theirs.
104func TestSpawnStashCancelsClaimsFromFirstUnissued(t *testing.T) {
105 for _, tc := range []struct {
106 name string
107 queue int
108 cancelled bool
109 issued int
110 wantErr error
111 }{
112 {"queue of one", 1, false, 1, nil},
113 {"publication fails", 4, true, 0, context.Canceled},
114 } {
115 t.Run(tc.name, func(t *testing.T) {
116 now := claimBase
117 commands := make(chan ports.ClientCommand) // unread: a send blocks until the context ends
118 spawn := make(chan ports.SpawnRequest, tc.queue)
119 c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now))
120 ctx, cancel := context.WithCancel(context.Background())
121 t.Cleanup(cancel)
122 if tc.cancelled {
123 cancel()
124 } else {
125 // An unread unbuffered channel would block the flag: read it.
126 go func() {
127 select {
128 case <-commands:
129 case <-ctx.Done():
130 }
131 }()
132 }
133 err := c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"a"}, {"b"}}, false)
134 if !errors.Is(err, tc.wantErr) {
135 t.Fatalf("error %v, want %v", err, tc.wantErr)
136 }
137 if len(spawn) != tc.issued {
138 t.Fatalf("%d spawns, want %d", len(spawn), tc.issued)
139 }
140 if len(c.placement.pending) != tc.issued {
141 t.Fatalf("%d claims kept, want %d", len(c.placement.pending), tc.issued)
142 }
143 if tc.issued == 1 {
144 if _, ok := c.placement.pending[claimToken(<-spawn)]; !ok {
145 t.Fatal("the issued command lost its claim")
146 }
147 }
148 })
149 }
150}
151
152// A claim is placed up to termRetry after it was made, and not a moment
153// later, even if no publish ran between its age and the window mapping.
154func TestStashClaimExpiry(t *testing.T) {
155 for _, tc := range []struct {
156 name string
157 age time.Duration
158 placed bool
159 }{
160 {"fresh", 0, true},
161 {"at the limit", termRetry, true},
162 {"just after", termRetry + time.Nanosecond, false},
163 } {
164 t.Run(tc.name, func(t *testing.T) {
165 now := claimBase
166 c := stashSpawnCore(t, nil, nil, fixedClock(t, &now))
167 m := c.cur().mon
168 m.AddWindow(1)
169 target := m.Current()
170 m.show(m.Workspaces[1])
171 req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: target, at: now})
172 now = claimBase.Add(tc.age)
173 c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)})
174 _, w := c.screenOf(5)
175 if placed := w == target && len(target.Stash) == 1; placed != tc.placed {
176 t.Fatalf("placed %t, want %t", placed, tc.placed)
177 }
178 })
179 }
180}
181
182// A workspace that left every screen no longer takes the window.
183func TestStashClaimWithoutWorkspaceMapsNormally(t *testing.T) {
184 now := claimBase
185 c := stashSpawnCore(t, nil, nil, fixedClock(t, &now))
186 req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: &Workspace{}, at: now})
187 c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)})
188 if _, w := c.screenOf(5); w != c.cur().mon.Current() {
189 t.Fatal("window did not map onto the current workspace")
190 }
191 if c.placement.anyPending() {
192 t.Fatal("claim kept")
193 }
194}
195
196// A window opened by a stash spawn is shown, selected and focused in the
197// stash whatever fullscreen state the workspace and the stash are in.
198func TestStashSpawnedAlwaysShown(t *testing.T) {
199 // base has tile 1 and stash [2 3], 3 selected, the stash shown.
200 base := func() *Workspace {
201 w := workspace()
202 w.AddWindow(1)
203 w.AddWindow(2)
204 w.AddWindow(3)
205 w.FocusID(2)
206 w.ToggleWindowStash()
207 w.FocusID(3)
208 w.ToggleWindowStash()
209 return w
210 }
211 shown := func(t *testing.T, w *Workspace) {
212 t.Helper()
213 if id, _ := w.Focused(); id != 5 {
214 t.Fatalf("focused %d, want 5", id)
215 }
216 if p := previewOf(t, w.Layout(), 5); p.Hidden || !p.Focused {
217 t.Fatalf("new window %+v", p)
218 }
219 if w.Stash[w.stashAt].ID != 5 {
220 t.Fatalf("selected %d", w.Stash[w.stashAt].ID)
221 }
222 }
223
224 t.Run("covering tile outside the stash", func(t *testing.T) {
225 w := base()
226 w.FocusID(1)
227 w.ToggleFullscreen()
228 w.stashSpawned(5)
229 shown(t, w)
230 if w.cover() != 1 || !w.stashOverCover() {
231 t.Fatalf("cover %d: the tile must stay fullscreen under the stash", w.cover())
232 }
233 if p := previewOf(t, w.Layout(), 1); !p.Fullscreen || p.Hidden {
234 t.Fatalf("tile %+v", p)
235 }
236 w.ToggleStashVisible()
237 if id, _ := w.Focused(); id != 1 || w.cover() != 1 {
238 t.Fatalf("hidden stash: focused %d cover %d, want the tile", id, w.cover())
239 }
240 })
241
242 t.Run("fullscreen stash member", func(t *testing.T) {
243 w := base()
244 w.ToggleFullscreen() // 3
245 if w.cover() != 3 {
246 t.Fatalf("setup: cover %d", w.cover())
247 }
248 w.stashSpawned(5)
249 shown(t, w)
250 if w.cover() != 0 {
251 t.Fatalf("cover %d: the stash member still covers", w.cover())
252 }
253 if p := previewOf(t, w.Layout(), 3); p.Fullscreen {
254 t.Fatalf("old member %+v", p)
255 }
256 })
257
258 t.Run("fullscreen waiting for the stash", func(t *testing.T) {
259 w := base()
260 w.ToggleStashVisible() // hidden
261 w.SetFullscreen(3, true)
262 if w.hiddenFullscreen != 3 {
263 t.Fatalf("setup: deferred %d", w.hiddenFullscreen)
264 }
265 w.stashSpawned(5)
266 shown(t, w)
267 if w.cover() != 0 || w.hiddenFullscreen != 0 {
268 t.Fatalf("cover %d, deferred %d", w.cover(), w.hiddenFullscreen)
269 }
270 w.ToggleStashVisible()
271 w.ToggleStashVisible()
272 shown(t, w)
273 })
274}