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