test(core): cover stash spawn ordering, suppression, expiry and fullscreen
The publication-order test waits on channels instead of a timing window, claim tests use a fixed mock clock and check the termRetry boundary, and new tests cover a full spawn queue, a failed publication and a window spawned over a fullscreen tile or stash member.
- commit
- ac0ff6806a79771c7773116f1109d8e617db71d6
- author
- brice · · 2026-10-14 20:36 UTC
- parent
- baa0832
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
1 file · +201 -34
M internal/core/stash_spawn_internal_test.go +201 -34
| @@ -2,19 +2,32 @@ package core | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "testing" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| 10 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 9 | 11 | "github.com/bnema/neferwl/internal/ports" | |
| 10 | 12 | ) | |
| 11 | 13 | ||
| 12 | - | func stashSpawnCore(t *testing.T, commands chan ports.ClientCommand, spawn chan ports.SpawnRequest) *Core { | |
| 14 | + | // claimBase is the instant of the fixed test clock. | |
| 15 | + | var claimBase = time.Unix(1000, 0) | |
| 16 | + | ||
| 17 | + | // fixedClock is a mock clock at *now, which a test moves by assigning. | |
| 18 | + | func 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 | + | ||
| 25 | + | func stashSpawnCore(t *testing.T, commands chan ports.ClientCommand, spawn chan ports.SpawnRequest, clock ports.Clock) *Core { | |
| 13 | 26 | t.Helper() | |
| 14 | 27 | var cfg ports.Config | |
| 15 | 28 | cfg.Keyboard.CmdKey = "alt" | |
| 16 | 29 | cfg.Layout.MaxColumns = 2 | |
| 17 | - | c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: commands, Spawn: spawn}, Options{}) | |
| 30 | + | c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: commands, Spawn: spawn}, Options{Clock: clock}) | |
| 18 | 31 | if err != nil { | |
| 19 | 32 | t.Fatal(err) | |
| 20 | 33 | } | |
| @@ -26,34 +39,41 @@ func claimToken(req ports.SpawnRequest) string { | |||
| 26 | 39 | return strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=") | |
| 27 | 40 | } | |
| 28 | 41 | ||
| 29 | - | // Wayland reads tokens only while slots are pending: the flag is sent before | |
| 30 | - | // the spawn can reach the launcher. | |
| 42 | + | // Wayland reads tokens only while slots are pending, so the flag is sent | |
| 43 | + | // before the spawn can reach the launcher. The command channel is unbuffered | |
| 44 | + | // and read last: the spawn can only follow the flag, never come first. | |
| 31 | 45 | func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) { | |
| 32 | - | commands := make(chan ports.ClientCommand) // unbuffered: sends are observable | |
| 46 | + | now := claimBase | |
| 47 | + | commands := make(chan ports.ClientCommand) | |
| 33 | 48 | spawn := make(chan ports.SpawnRequest, 1) | |
| 34 | - | c := stashSpawnCore(t, commands, spawn) | |
| 49 | + | c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now)) | |
| 50 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 51 | + | t.Cleanup(cancel) | |
| 35 | 52 | done := make(chan error, 1) | |
| 36 | - | go func() { | |
| 37 | - | done <- c.spawnStash(context.Background(), c.cur().mon.Current(), [][]string{{"firefox"}}, false) | |
| 38 | - | }() | |
| 39 | - | // The send blocks on the unread command: nothing may be spawned yet. | |
| 40 | - | select { | |
| 41 | - | case req := <-spawn: | |
| 42 | - | t.Fatalf("spawned before the pending flag was delivered: %v", req) | |
| 43 | - | case <-time.After(50 * time.Millisecond): | |
| 44 | - | } | |
| 53 | + | go func() { done <- c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"firefox"}}, false) }() | |
| 54 | + | // Both channels are awaited at once: the first to communicate wins. | |
| 55 | + | timeout := time.After(5 * time.Second) | |
| 45 | 56 | select { | |
| 46 | 57 | case v := <-commands: | |
| 47 | 58 | if p, ok := v.(ports.SlotsPending); !ok || !p.Pending { | |
| 48 | 59 | t.Fatalf("command %v", v) | |
| 49 | 60 | } | |
| 50 | - | case <-time.After(time.Second): | |
| 61 | + | case req := <-spawn: | |
| 62 | + | t.Fatalf("spawned before the pending flag was delivered: %v", req) | |
| 63 | + | case <-timeout: | |
| 51 | 64 | t.Fatal("no SlotsPending") | |
| 52 | 65 | } | |
| 53 | - | if err := <-done; err != nil { | |
| 54 | - | t.Fatal(err) | |
| 66 | + | select { | |
| 67 | + | case err := <-done: | |
| 68 | + | if err != nil { | |
| 69 | + | t.Fatal(err) | |
| 70 | + | } | |
| 71 | + | case <-timeout: | |
| 72 | + | t.Fatal("spawnStash did not return") | |
| 55 | 73 | } | |
| 56 | - | if len(spawn) != 1 { | |
| 74 | + | select { | |
| 75 | + | case <-spawn: | |
| 76 | + | default: | |
| 57 | 77 | t.Fatal("no spawn") | |
| 58 | 78 | } | |
| 59 | 79 | } | |
| @@ -61,8 +81,9 @@ func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) { | |||
| 61 | 81 | // A send the launcher queue suppresses leaves no claim behind, and the | |
| 62 | 82 | // pending flag falls again at the next publish. | |
| 63 | 83 | func TestSpawnStashSuppressedLeavesNoClaim(t *testing.T) { | |
| 84 | + | now := claimBase | |
| 64 | 85 | commands := make(chan ports.ClientCommand, 8) | |
| 65 | - | c := stashSpawnCore(t, commands, nil) // no launcher queue: every send is suppressed | |
| 86 | + | c := stashSpawnCore(t, commands, nil, fixedClock(t, &now)) // no launcher queue: every send is suppressed | |
| 66 | 87 | if err := c.spawnStash(context.Background(), c.cur().mon.Current(), [][]string{{"a"}, {"b"}}, false); err != nil { | |
| 67 | 88 | t.Fatal(err) | |
| 68 | 89 | } | |
| @@ -80,25 +101,91 @@ func TestSpawnStashSuppressedLeavesNoClaim(t *testing.T) { | |||
| 80 | 101 | } | |
| 81 | 102 | } | |
| 82 | 103 | ||
| 83 | - | // An unanswered claim expires like a terminal's, even if no publish ran | |
| 84 | - | // between its age and the window mapping. | |
| 85 | - | func TestStashClaimExpires(t *testing.T) { | |
| 86 | - | c := stashSpawnCore(t, nil, nil) | |
| 87 | - | m := c.cur().mon | |
| 88 | - | m.AddWindow(1) | |
| 89 | - | target := m.Current() | |
| 90 | - | m.show(m.Workspaces[1]) | |
| 91 | - | req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: target, at: time.Now().Add(-2 * termRetry)}) | |
| 92 | - | c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)}) | |
| 93 | - | if _, w := c.screenOf(5); w != m.Current() || len(target.Stash) != 0 { | |
| 94 | - | t.Fatal("expired claim still placed the window in the stash") | |
| 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. | |
| 106 | + | func 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. | |
| 156 | + | func 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 | + | }) | |
| 95 | 181 | } | |
| 96 | 182 | } | |
| 97 | 183 | ||
| 98 | 184 | // A workspace that left every screen no longer takes the window. | |
| 99 | 185 | func TestStashClaimWithoutWorkspaceMapsNormally(t *testing.T) { | |
| 100 | - | c := stashSpawnCore(t, nil, nil) | |
| 101 | - | req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: &Workspace{}, at: time.Now()}) | |
| 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}) | |
| 102 | 189 | c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)}) | |
| 103 | 190 | if _, w := c.screenOf(5); w != c.cur().mon.Current() { | |
| 104 | 191 | t.Fatal("window did not map onto the current workspace") | |
| @@ -107,3 +194,83 @@ func TestStashClaimWithoutWorkspaceMapsNormally(t *testing.T) { | |||
| 107 | 194 | t.Fatal("claim kept") | |
| 108 | 195 | } | |
| 109 | 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. | |
| 200 | + | func 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 | + | } | |
| @@ -2,19 +2,32 @@ package core | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 5 | "strings" | 6 | "strings" | ||
| 6 | "testing" | 7 | "testing" | ||
| 7 | "time" | 8 | "time" | ||
| 8 | 9 | ||||
| 10 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 9 | "github.com/bnema/neferwl/internal/ports" | 11 | "github.com/bnema/neferwl/internal/ports" | ||
| 10 | ) | 12 | ) | ||
| 11 | 13 | ||||
| 12 | - | func stashSpawnCore(t *testing.T, commands chan ports.ClientCommand, spawn chan ports.SpawnRequest) *Core { | 14 | + | // claimBase is the instant of the fixed test clock. |
| 15 | + | var claimBase = time.Unix(1000, 0) | |||
| 16 | + | ||||
| 17 | + | // fixedClock is a mock clock at *now, which a test moves by assigning. | |||
| 18 | + | func 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 | + | ||||
| 25 | + | func stashSpawnCore(t *testing.T, commands chan ports.ClientCommand, spawn chan ports.SpawnRequest, clock ports.Clock) *Core { | |||
| 13 | t.Helper() | 26 | t.Helper() | ||
| 14 | var cfg ports.Config | 27 | var cfg ports.Config | ||
| 15 | cfg.Keyboard.CmdKey = "alt" | 28 | cfg.Keyboard.CmdKey = "alt" | ||
| 16 | cfg.Layout.MaxColumns = 2 | 29 | cfg.Layout.MaxColumns = 2 | ||
| 17 | - | c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: commands, Spawn: spawn}, Options{}) | 30 | + | c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: commands, Spawn: spawn}, Options{Clock: clock}) |
| 18 | if err != nil { | 31 | if err != nil { | ||
| 19 | t.Fatal(err) | 32 | t.Fatal(err) | ||
| 20 | } | 33 | } | ||
| @@ -26,34 +39,41 @@ func claimToken(req ports.SpawnRequest) string { | |||||
| 26 | return strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=") | 39 | return strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=") | ||
| 27 | } | 40 | } | ||
| 28 | 41 | ||||
| 29 | - | // Wayland reads tokens only while slots are pending: the flag is sent before | 42 | + | // Wayland reads tokens only while slots are pending, so the flag is sent |
| 30 | - | // the spawn can reach the launcher. | 43 | + | // before the spawn can reach the launcher. The command channel is unbuffered |
| 44 | + | // and read last: the spawn can only follow the flag, never come first. | |||
| 31 | func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) { | 45 | func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) { | ||
| 32 | - | commands := make(chan ports.ClientCommand) // unbuffered: sends are observable | 46 | + | now := claimBase |
| 47 | + | commands := make(chan ports.ClientCommand) | |||
| 33 | spawn := make(chan ports.SpawnRequest, 1) | 48 | spawn := make(chan ports.SpawnRequest, 1) | ||
| 34 | - | c := stashSpawnCore(t, commands, spawn) | 49 | + | c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now)) |
| 50 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 51 | + | t.Cleanup(cancel) | |||
| 35 | done := make(chan error, 1) | 52 | done := make(chan error, 1) | ||
| 36 | - | go func() { | 53 | + | go func() { done <- c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"firefox"}}, false) }() |
| 37 | - | done <- c.spawnStash(context.Background(), c.cur().mon.Current(), [][]string{{"firefox"}}, false) | 54 | + | // Both channels are awaited at once: the first to communicate wins. |
| 38 | - | }() | 55 | + | timeout := time.After(5 * time.Second) |
| 39 | - | // The send blocks on the unread command: nothing may be spawned yet. | |||
| 40 | - | select { | |||
| 41 | - | case req := <-spawn: | |||
| 42 | - | t.Fatalf("spawned before the pending flag was delivered: %v", req) | |||
| 43 | - | case <-time.After(50 * time.Millisecond): | |||
| 44 | - | } | |||
| 45 | select { | 56 | select { | ||
| 46 | case v := <-commands: | 57 | case v := <-commands: | ||
| 47 | if p, ok := v.(ports.SlotsPending); !ok || !p.Pending { | 58 | if p, ok := v.(ports.SlotsPending); !ok || !p.Pending { | ||
| 48 | t.Fatalf("command %v", v) | 59 | t.Fatalf("command %v", v) | ||
| 49 | } | 60 | } | ||
| 50 | - | case <-time.After(time.Second): | 61 | + | case req := <-spawn: |
| 62 | + | t.Fatalf("spawned before the pending flag was delivered: %v", req) | |||
| 63 | + | case <-timeout: | |||
| 51 | t.Fatal("no SlotsPending") | 64 | t.Fatal("no SlotsPending") | ||
| 52 | } | 65 | } | ||
| 53 | - | if err := <-done; err != nil { | 66 | + | select { |
| 54 | - | t.Fatal(err) | 67 | + | case err := <-done: |
| 68 | + | if err != nil { | |||
| 69 | + | t.Fatal(err) | |||
| 70 | + | } | |||
| 71 | + | case <-timeout: | |||
| 72 | + | t.Fatal("spawnStash did not return") | |||
| 55 | } | 73 | } | ||
| 56 | - | if len(spawn) != 1 { | 74 | + | select { |
| 75 | + | case <-spawn: | |||
| 76 | + | default: | |||
| 57 | t.Fatal("no spawn") | 77 | t.Fatal("no spawn") | ||
| 58 | } | 78 | } | ||
| 59 | } | 79 | } | ||
| @@ -61,8 +81,9 @@ func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) { | |||||
| 61 | // A send the launcher queue suppresses leaves no claim behind, and the | 81 | // A send the launcher queue suppresses leaves no claim behind, and the | ||
| 62 | // pending flag falls again at the next publish. | 82 | // pending flag falls again at the next publish. | ||
| 63 | func TestSpawnStashSuppressedLeavesNoClaim(t *testing.T) { | 83 | func TestSpawnStashSuppressedLeavesNoClaim(t *testing.T) { | ||
| 84 | + | now := claimBase | |||
| 64 | commands := make(chan ports.ClientCommand, 8) | 85 | commands := make(chan ports.ClientCommand, 8) | ||
| 65 | - | c := stashSpawnCore(t, commands, nil) // no launcher queue: every send is suppressed | 86 | + | c := stashSpawnCore(t, commands, nil, fixedClock(t, &now)) // no launcher queue: every send is suppressed |
| 66 | if err := c.spawnStash(context.Background(), c.cur().mon.Current(), [][]string{{"a"}, {"b"}}, false); err != nil { | 87 | if err := c.spawnStash(context.Background(), c.cur().mon.Current(), [][]string{{"a"}, {"b"}}, false); err != nil { | ||
| 67 | t.Fatal(err) | 88 | t.Fatal(err) | ||
| 68 | } | 89 | } | ||
| @@ -80,25 +101,91 @@ func TestSpawnStashSuppressedLeavesNoClaim(t *testing.T) { | |||||
| 80 | } | 101 | } | ||
| 81 | } | 102 | } | ||
| 82 | 103 | ||||
| 83 | - | // An unanswered claim expires like a terminal's, even if no publish ran | 104 | + | // From the first command that is not issued, the rest are not sent and their |
| 84 | - | // between its age and the window mapping. | 105 | + | // claims are cancelled; the issued ones keep theirs. |
| 85 | - | func TestStashClaimExpires(t *testing.T) { | 106 | + | func TestSpawnStashCancelsClaimsFromFirstUnissued(t *testing.T) { |
| 86 | - | c := stashSpawnCore(t, nil, nil) | 107 | + | for _, tc := range []struct { |
| 87 | - | m := c.cur().mon | 108 | + | name string |
| 88 | - | m.AddWindow(1) | 109 | + | queue int |
| 89 | - | target := m.Current() | 110 | + | cancelled bool |
| 90 | - | m.show(m.Workspaces[1]) | 111 | + | issued int |
| 91 | - | req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: target, at: time.Now().Add(-2 * termRetry)}) | 112 | + | wantErr error |
| 92 | - | c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)}) | 113 | + | }{ |
| 93 | - | if _, w := c.screenOf(5); w != m.Current() || len(target.Stash) != 0 { | 114 | + | {"queue of one", 1, false, 1, nil}, |
| 94 | - | t.Fatal("expired claim still placed the window in the stash") | 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. | |||
| 156 | + | func 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 | + | }) | |||
| 95 | } | 181 | } | ||
| 96 | } | 182 | } | ||
| 97 | 183 | ||||
| 98 | // A workspace that left every screen no longer takes the window. | 184 | // A workspace that left every screen no longer takes the window. | ||
| 99 | func TestStashClaimWithoutWorkspaceMapsNormally(t *testing.T) { | 185 | func TestStashClaimWithoutWorkspaceMapsNormally(t *testing.T) { | ||
| 100 | - | c := stashSpawnCore(t, nil, nil) | 186 | + | now := claimBase |
| 101 | - | req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: &Workspace{}, at: time.Now()}) | 187 | + | c := stashSpawnCore(t, nil, nil, fixedClock(t, &now)) |
| 188 | + | req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: &Workspace{}, at: now}) | |||
| 102 | c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)}) | 189 | c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)}) | ||
| 103 | if _, w := c.screenOf(5); w != c.cur().mon.Current() { | 190 | if _, w := c.screenOf(5); w != c.cur().mon.Current() { | ||
| 104 | t.Fatal("window did not map onto the current workspace") | 191 | t.Fatal("window did not map onto the current workspace") | ||
| @@ -107,3 +194,83 @@ func TestStashClaimWithoutWorkspaceMapsNormally(t *testing.T) { | |||||
| 107 | t.Fatal("claim kept") | 194 | t.Fatal("claim kept") | ||
| 108 | } | 195 | } | ||
| 109 | } | 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. | |||
| 200 | + | func 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 | + | } | |||