1package config
2
3import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "reflect"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/bnema/neferwl/internal/ports"
13)
14
15func parseString(t *testing.T, s string) (ports.Config, []Warning) {
16 t.Helper()
17 c, w, err := Parse(strings.NewReader(s))
18 if err != nil {
19 t.Fatal(err)
20 }
21 return c, w
22}
23
24func TestDefaultsAndLoad(t *testing.T) {
25 t.Setenv("TERMINAL", "")
26 d := Defaults()
27 if d.Keyboard.RepeatRate != 25 || d.Keyboard.CmdKey != "super" || !d.Render.DirectScanout || len(d.Binds) != 77 || d.Layout.MaxColumns != 2 || d.Floating.Dim != 0.3 {
28 t.Fatalf("defaults: %+v", d)
29 }
30 if d.Binds["Cmd+s"] != "toggle-stash-visible" || d.Binds["Cmd+Shift+s"] != "toggle-window-stash" || d.Binds["Cmd+o"] != "toggle-overview" || d.Binds["Cmd+f"] != "maximize-column" || d.Binds["Cmd+Shift+f"] != "toggle-fullscreen" || d.Binds["Cmd+Shift+h"] != "move-column-left" || d.Binds["Cmd+j"] != "focus-window-down" || d.Binds["Cmd+Shift+code:2"] != "move-column-to-workspace 1" || d.Focus.FollowMove {
31 t.Fatal(d.Binds)
32 }
33 if d.Binds["Cmd+Shift+Up"] != "move-window-up" || d.Binds["Cmd+Shift+j"] != "move-window-down" || d.Binds["Alt+Cmd+Left"] != "set-column-width -10%" || d.Binds["Alt+Cmd+l"] != "set-column-width +10%" || d.Binds["Alt+Cmd+k"] != "set-window-height -10%" || d.Binds["Cmd+Ctrl+Shift+Down"] != "move-workspace-down" || d.Binds["Cmd+Ctrl+Shift+k"] != "move-workspace-up" || d.Binds["Cmd+Shift+space"] != "toggle-floating" {
34 t.Fatal(d.Binds)
35 }
36 if d.Binds["Cmd+Ctrl+space"] != "spawn fuzzel" || d.Binds["Alt+Ctrl+BackSpace"] != "quit" {
37 t.Fatal(d.Binds)
38 }
39 if _, _, err := Load(filepath.Join(t.TempDir(), "missing")); !os.IsNotExist(err) {
40 t.Fatalf("explicit missing: %v", err)
41 }
42 t.Setenv("XDG_CONFIG_HOME", t.TempDir())
43 if DefaultPath() != filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "neferwl", "config") {
44 t.Fatal(DefaultPath())
45 }
46 got, w, err := LoadDefault()
47 if err != nil || len(w) != 0 || !reflect.DeepEqual(got, d) {
48 t.Fatalf("missing: %v %v %+v", err, w, got)
49 }
50 example, w, err := Load("../../../examples/config")
51 if err != nil || len(w) != 0 || !reflect.DeepEqual(example, d) {
52 t.Fatalf("example: %v %v\n%+v\n%+v", err, w, example, d)
53 }
54}
55
56func TestRealtime(t *testing.T) {
57 if !Defaults().Performance.Realtime {
58 t.Fatal("realtime default disabled")
59 }
60 c, w := parseString(t, "performance.realtime = off\n")
61 if c.Performance.Realtime || len(w) != 0 {
62 t.Fatal(c.Performance, w)
63 }
64 c, w = parseString(t, "performance.realtime = invalid\n")
65 if !c.Performance.Realtime || len(w) != 1 {
66 t.Fatal(c.Performance, w)
67 }
68}
69
70func TestFollowMove(t *testing.T) {
71 c, w := parseString(t, "focus.follow-move = on\n")
72 if !c.Focus.FollowMove || len(w) != 0 {
73 t.Fatal(c.Focus, w)
74 }
75 c, w = parseString(t, "focus.follow-move = yes\n")
76 if c.Focus.FollowMove || len(w) != 1 {
77 t.Fatal(c.Focus, w)
78 }
79}
80
81func TestFocusIndicator(t *testing.T) {
82 d := Defaults().Focus
83 if d.Animation != ports.FocusAnimationPulse || d.Effect != ports.FocusEffectScreen || d.Strength != 0.04 {
84 t.Fatal("focus indicator defaults", d)
85 }
86 c, w := parseString(t, "focus.animation = off\nfocus.effect = screen\nfocus.strength = 0.08\n")
87 if c.Focus.Animation != ports.FocusAnimationOff || c.Focus.Effect != ports.FocusEffectScreen || c.Focus.Strength != 0.08 || len(w) != 0 {
88 t.Fatal(c.Focus, w)
89 }
90 // Each rejected value warns and keeps its default.
91 for _, line := range []string{"focus.animation = blink", "focus.effect = glow", "focus.strength = 0", "focus.strength = 0.5", "focus.strength = x"} {
92 c, w = parseString(t, line+"\n")
93 if c.Focus != d || len(w) != 1 {
94 t.Fatal(line, c.Focus, w)
95 }
96 }
97}
98
99func TestTouchpadTap(t *testing.T) {
100 if !Defaults().Touchpad.Tap {
101 t.Fatal("tap to click off by default")
102 }
103 c, w := parseString(t, "touchpad.tap = off\n")
104 if c.Touchpad.Tap || len(w) != 0 {
105 t.Fatal(c.Touchpad, w)
106 }
107 c, w = parseString(t, "touchpad.tap = sometimes\n")
108 if !c.Touchpad.Tap || len(w) != 1 {
109 t.Fatal(c.Touchpad, w)
110 }
111}
112
113func TestTouchpadSpeed(t *testing.T) {
114 d := Defaults().Touchpad
115 if d.AccelSpeed != 0 || d.AccelProfile != ports.AccelAdaptive || d.ScrollFactor != 1 {
116 t.Fatal(d)
117 }
118 c, w := parseString(t, "touchpad.scroll-factor = 0.5\n")
119 if c.Touchpad.ScrollFactor != 0.5 || len(w) != 0 {
120 t.Fatal(c.Touchpad, w)
121 }
122 for _, bad := range []string{
123 "touchpad.scroll-factor = 0",
124 "touchpad.scroll-factor = 11",
125 } {
126 c, w := parseString(t, bad+"\n")
127 if c.Touchpad != d || len(w) != 1 {
128 t.Errorf("%s: %+v %v", bad, c.Touchpad, w)
129 }
130 }
131}
132
133func TestPointerKeys(t *testing.T) {
134 d := Defaults()
135 if d.Mouse != (ports.PointerConfig{AccelProfile: ports.AccelAdaptive}) {
136 t.Fatal(d.Mouse)
137 }
138 if d.Touchpad.LeftHanded || d.Touchpad.NaturalScroll {
139 t.Fatal(d.Touchpad)
140 }
141 touchpad := func(c ports.Config) ports.PointerConfig { return c.Touchpad.PointerConfig }
142 mouse := func(c ports.Config) ports.PointerConfig { return c.Mouse }
143 for _, tc := range []struct {
144 prefix string
145 pointer, other func(ports.Config) ports.PointerConfig
146 }{
147 {"touchpad", touchpad, mouse},
148 {"mouse", mouse, touchpad},
149 } {
150 t.Run(tc.prefix, func(t *testing.T) {
151 prefix := tc.prefix
152 c, w := parseString(t, prefix+".natural-scroll = on\n"+prefix+".left-handed = on\n"+prefix+".accel-speed = -0.5\n"+prefix+".accel-profile = flat\n")
153 want := ports.PointerConfig{NaturalScroll: true, LeftHanded: true, AccelSpeed: -0.5, AccelProfile: ports.AccelFlat}
154 if tc.pointer(c) != want || len(w) != 0 {
155 t.Errorf("%+v %v", tc.pointer(c), w)
156 }
157 // The other device keeps its defaults.
158 if other := tc.other(c); other != (ports.PointerConfig{AccelProfile: ports.AccelAdaptive}) {
159 t.Errorf("leaked into the other device: %+v", other)
160 }
161 for _, bad := range []string{
162 ".natural-scroll = maybe",
163 ".left-handed = 1",
164 ".accel-speed = 2",
165 ".accel-speed = -1.5",
166 ".accel-speed = NaN",
167 ".accel-speed = fast",
168 ".accel-profile = fast",
169 ".accel-profile = ",
170 ".unknown = on",
171 } {
172 c, w := parseString(t, prefix+bad+"\n")
173 if c.Mouse != d.Mouse || c.Touchpad != d.Touchpad || len(w) != 1 {
174 t.Errorf("%s%s: %+v %v", prefix, bad, tc.pointer(c), w)
175 }
176 }
177 })
178 }
179 // Touchpad-only keys do not exist for mice.
180 for _, bad := range []string{"mouse.tap = on", "mouse.scroll-factor = 2"} {
181 if c, w := parseString(t, bad+"\n"); c.Mouse != d.Mouse || c.Touchpad != d.Touchpad || len(w) != 1 {
182 t.Errorf("%s: %+v %v", bad, c.Mouse, w)
183 }
184 }
185}
186
187func TestParse(t *testing.T) {
188 c, w := parseString(t, `
189# comment
190keyboard.layout = fr
191keyboard.repeat-rate = 40 # inline comment
192terminal = foot --server
193background = #000000
194border.inactive =
195layout.presets = 1/3, 1/2 ,1
196log.debug = core, input
197output.DP-2.scale = 4/3
198output.DP-2 = 5120x2160@165.058
199output.HDMI-A-1 = off
200output.DP-1 = preferred
201output.DP-1.scale = 1.5
202output.DP-1.primary = on
203bind.cmd+code:30 = quit
204render.direct-scanout = off
205render.tearing = off
206render.vrr = off
207startup = wl-paste --watch cliphist store
208startup = wl-paste --primary --watch cliphist store
209`)
210 if len(w) != 0 {
211 t.Fatal(w)
212 }
213 if !reflect.DeepEqual(c.Startup, [][]string{{"wl-paste", "--watch", "cliphist", "store"}, {"wl-paste", "--primary", "--watch", "cliphist", "store"}}) {
214 t.Fatalf("startup: %q", c.Startup)
215 }
216 if c.Keyboard.Layout != "fr" || c.Keyboard.RepeatRate != 40 || !reflect.DeepEqual(c.Terminal.Command, []string{"foot", "--server"}) || c.Background.Color != "#000000" || c.Render.DirectScanout || c.Render.Tearing || c.Render.VRR {
217 t.Fatalf("%+v", c)
218 }
219 if !reflect.DeepEqual(c.Layout.Presets, []string{"1/3", "1/2", "1"}) || !reflect.DeepEqual(c.Log.Debug, []string{"core", "input"}) {
220 t.Fatalf("%+v", c)
221 }
222 // Scale and mode lines combine in either order.
223 if len(c.Outputs) != 3 || c.Outputs[0].Mode != "5120x2160@165.058" || c.Outputs[0].Scale != 4.0/3 || !c.Outputs[1].Off || c.Outputs[2].Mode != "" || c.Outputs[2].Off || c.Outputs[2].Scale != 1.5 || !c.Outputs[2].Primary || c.Outputs[0].Primary {
224 t.Fatalf("%+v", c.Outputs)
225 }
226 if c.Binds["Cmd+code:30"] != "quit" {
227 t.Fatal(c.Binds)
228 }
229}
230
231func TestWarningsKeepDefaults(t *testing.T) {
232 cases := []struct{ line, want string }{
233 {"keyboard.repeat-rate = 0", "keyboard.repeat-rate"},
234 {"keyboard.cmd = meta", "keyboard.cmd"},
235 {"terminal =", "terminal"},
236 {"xwayland = a b", "xwayland"},
237 {"background = red", "background"},
238 {"layout.gaps = 201", "layout.gaps"},
239 {"layout.max-columns = 0", "layout.max-columns"},
240 {"bind.cmd+x = focus-workspace 0", "focus-workspace"},
241 {"layout.presets = 0px", "layout.presets"},
242 {"log.level = trace", "log.level"},
243 {"log.debug = nope", "log.debug"},
244 {"output.DP-2 = big", "output.DP-2"},
245 {"output.DP-2 = 1920x1080@0", "output.DP-2"},
246 {"output.DP-2.scale = 0.5", "between 1 and 4"},
247 {"output.DP-2.primary = yes", "output.DP-2.primary"},
248 {"output.DP-2.scale = 3/0", "between 1 and 4"},
249 {"bind.cmd+code:x = quit", "evdev key code"},
250 {"nope = 1", "nope"},
251 {"just text", "expected key = value"},
252 {"bind.cmd+ = quit", "missing key"},
253 {"bind.hyper+x = quit", "unknown modifier"},
254 {"bind.cmd+x = bogus", "unknown action"},
255 {"bind.cmd+x = set-column-width 10%", "set-column-width needs"},
256 {"bind.cmd+x = set-column-width +0%", "set-column-width needs"},
257 {"bind.cmd+x = set-window-height -101%", "set-window-height needs"},
258 {"bind.cmd+x = set-window-height +5", "set-window-height needs"},
259 {"bind.cmd+x = spawn ", "unknown action"},
260 }
261 for _, tc := range cases {
262 t.Run(tc.line, func(t *testing.T) {
263 c, w := parseString(t, tc.line)
264 if len(w) != 1 || w[0].Line != 1 || !strings.Contains(w[0].Msg, tc.want) {
265 t.Fatalf("warnings %v, want %q", w, tc.want)
266 }
267 if !reflect.DeepEqual(c, Defaults()) {
268 t.Fatalf("config changed: %+v", c)
269 }
270 })
271 }
272}
273
274func TestXwayland(t *testing.T) {
275 if Defaults().Xwayland != "xwayland-satellite" {
276 t.Fatal(Defaults().Xwayland)
277 }
278 if c, w := parseString(t, "xwayland = off"); c.Xwayland != "" || len(w) != 0 {
279 t.Fatal(c.Xwayland, w)
280 }
281 if c, w := parseString(t, "xwayland = /opt/xwls"); c.Xwayland != "/opt/xwls" || len(w) != 0 {
282 t.Fatal(c.Xwayland, w)
283 }
284}
285
286func TestDuplicateKeyLastWins(t *testing.T) {
287 c, w := parseString(t, "background = #000000\nbackground = #111111\n")
288 if c.Background.Color != "#111111" || len(w) != 1 || w[0].Line != 2 {
289 t.Fatal(c.Background, w)
290 }
291}
292
293func TestBinds(t *testing.T) {
294 c, w := parseString(t, `
295bind.CTRL+Cmd+E = spawn foo --bar
296bind.cmd+; = close-window
297bind.cmd+é = quit
298bind.cmd+semicolon = quit
299bind.cmd+equal = spawn-terminal
300bind.cmd+# = none
301bind.cmd++ = quit
302bind.alt+f5 = quit
303bind.cmd+return = none
304bind.cmd+w = focus-workspace 12
305`)
306 want := map[string]string{
307 "Cmd+Ctrl+e": "spawn foo --bar",
308 "Cmd+semicolon": "quit",
309 "Cmd+eacute": "quit",
310 "Cmd+equal": "spawn-terminal",
311 "Cmd+plus": "quit",
312 "Alt+F5": "quit",
313 }
314 for combo, action := range want {
315 if c.Binds[combo] != action {
316 t.Errorf("%s = %q, want %q", combo, c.Binds[combo], action)
317 }
318 }
319 if _, ok := c.Binds["Cmd+Return"]; ok {
320 t.Error("none did not remove the default")
321 }
322 if _, ok := c.Binds["Cmd+numbersign"]; ok {
323 t.Error("none kept")
324 }
325 // cmd+; then cmd+semicolon is the same key: warn and keep the last.
326 if len(w) != 1 || w[0].Line != 5 || !strings.Contains(w[0].Msg, "overrides line 3") {
327 t.Fatal(w)
328 }
329}
330
331func TestBindReservedCharacter(t *testing.T) {
332 _, w := parseString(t, "bind.cmd+= = quit\n")
333 if len(w) != 1 || !strings.Contains(w[0].Msg, "cmd+equal") {
334 t.Fatal(w)
335 }
336}
337
338func TestBindResolvedCollision(t *testing.T) {
339 // The later line wins, like any duplicate key.
340 c, w := parseString(t, "keyboard.cmd = ctrl\nbind.ctrl+x = quit\nbind.cmd+x = close-window\nbind.cmd+ctrl+y = quit\n")
341 if c.Binds["Cmd+x"] != "close-window" {
342 t.Fatal(c.Binds)
343 }
344 if _, ok := c.Binds["Ctrl+x"]; ok {
345 t.Fatal(c.Binds)
346 }
347 if _, ok := c.Binds["Cmd+Ctrl+y"]; ok {
348 t.Fatal(c.Binds)
349 }
350 if len(w) != 2 || w[0].Line != 4 || w[1].Line != 2 || !strings.Contains(w[1].Msg, "overridden by bind.Cmd+x") {
351 t.Fatal(w)
352 }
353 // A user bind replaces a default with the same resolved keys, without a warning.
354 c, w = parseString(t, "bind.super+q = quit\n")
355 if c.Binds["Super+q"] != "quit" || c.Binds["Cmd+q"] != "" || len(w) != 0 {
356 t.Fatal(c.Binds, w)
357 }
358}
359
360func TestInvalidDuplicateKeepsEarlierValue(t *testing.T) {
361 c, w := parseString(t, "background = #000000\nbackground = red\nbackground = #111111\n")
362 if c.Background.Color != "#111111" || len(w) != 2 || w[0].Line != 2 || w[1].Line != 3 || !strings.Contains(w[1].Msg, "overrides line 1") {
363 t.Fatal(c.Background, w)
364 }
365}
366
367func TestBindCharacters(t *testing.T) {
368 c, w := parseString(t, "bind.cmd+É = quit\nbind.cmd+ж = quit\n")
369 if c.Binds["Cmd+eacute"] != "quit" {
370 t.Fatal(c.Binds)
371 }
372 if len(w) != 1 || w[0].Line != 2 || !strings.Contains(w[0].Msg, "keysym name") {
373 t.Fatal(w)
374 }
375}
376
377func TestParseMode(t *testing.T) {
378 w, h, hz, err := ParseMode("3440x1440")
379 if err != nil || w != 3440 || h != 1440 || hz != 0 {
380 t.Fatalf("%d %d %v %v", w, h, hz, err)
381 }
382 if _, _, hz, err := ParseMode("1920x1080@59.94"); err != nil || hz != 59.94 {
383 t.Fatal(hz, err)
384 }
385}
386
387func TestDigitBindReplacesPhysicalDefault(t *testing.T) {
388 c, w := parseString(t, "bind.cmd+1 = none\nbind.cmd+shift+2 = quit\n")
389 if len(w) != 0 {
390 t.Fatal(w)
391 }
392 if _, ok := c.Binds["Cmd+code:2"]; ok {
393 t.Fatal("default cmd+code:2 kept")
394 }
395 if _, ok := c.Binds["Cmd+Shift+code:3"]; ok || c.Binds["Cmd+Shift+2"] != "quit" {
396 t.Fatal(c.Binds)
397 }
398 if c.Binds["Cmd+code:4"] != "focus-workspace 3" {
399 t.Fatal("other defaults must stay")
400 }
401}
402
403func TestWorkspaces(t *testing.T) {
404 c, w := parseString(t, `layout.overflow = fixed
405workspace.dev.hidden = on
406workspace.dev.max-columns = 3
407workspace.web.overflow = scroll
408workspace.dev.monitor = DP-2
409workspace.bad name.hidden = on
410workspace.dev.color = red
411workspace.web.overflow = spiral
412bind.cmd+d = workspace dev
413bind.cmd+w = workspace
414bind.cmd+x = workspace typo
415workspace.lost.monitor = DP-1
416`)
417 want := []ports.WorkspaceConfig{
418 {Name: "dev", Monitor: "DP-2", MaxColumns: 3},
419 {Name: "web", Overflow: "scroll"},
420 {Name: "lost", Monitor: "DP-1"},
421 }
422 if c.Layout.Overflow != "fixed" || !reflect.DeepEqual(c.Workspaces, want) {
423 t.Fatalf("%q %+v", c.Layout.Overflow, c.Workspaces)
424 }
425 if c.Binds["Cmd+d"] != "workspace dev" {
426 t.Fatal(c.Binds["Cmd+d"])
427 }
428 lines := []int{}
429 for _, x := range w {
430 lines = append(lines, x.Line)
431 }
432 if !reflect.DeepEqual(lines, []int{2, 4, 6, 7, 8, 10, 11, 12}) {
433 t.Fatal(w)
434 }
435}
436
437func TestLayoutPerOutput(t *testing.T) {
438 c, w := parseString(t, `layout.max-columns = 2
439layout.HDMI-A-1.max-columns = 3
440layout.HDMI-A-1.overflow = fixed
441layout.LG Electronics 27GR95UM 123.overflow = scroll
442layout.DP-2.overflow = spiral
443layout.DP-2.color = red
444`)
445 want := []ports.OutputLayout{
446 {Output: "HDMI-A-1", MaxColumns: 3, Overflow: "fixed"},
447 {Output: "LG Electronics 27GR95UM 123", Overflow: "scroll"},
448 }
449 if c.Layout.MaxColumns != 2 || !reflect.DeepEqual(c.Layout.Outputs, want) || len(w) != 2 {
450 t.Fatalf("%+v %v", c.Layout.Outputs, w)
451 }
452}
453
454func TestFixedOutputWarnsSlotWidths(t *testing.T) {
455 _, w := parseString(t, `layout.DP-2.overflow = fixed
456workspace.dev.monitor = DP-2
457workspace.dev.column.1 = 50%, foot
458workspace.web.column.1 = 50%, foot
459`)
460 if len(w) != 3 || w[1].Line != 3 {
461 t.Fatal(w)
462 }
463}
464
465func TestWorkspaceSlots(t *testing.T) {
466 c, w := parseString(t, `workspace.dev.monitor = DP-2
467workspace.dev.column.2 = 33%, foot --title x
468workspace.dev.column.1 = 67%, code --new-window
469workspace.dev.column.2 = 1/3, kitty
470workspace.dev.column.0 = 50%, foot
471workspace.dev.column.3 = 50%
472workspace.dev.column.4 = huge, foot
473bind.cmd+d = workspace dev
474`)
475 want := []ports.SlotConfig{
476 {Index: 1, Width: "67%", Argv: []string{"code", "--new-window"}},
477 {Index: 2, Width: "1/3", Argv: []string{"kitty"}},
478 }
479 if len(c.Workspaces) != 1 || !reflect.DeepEqual(c.Workspaces[0].Slots, want) {
480 t.Fatalf("%+v", c.Workspaces)
481 }
482 lines := []int{}
483 for _, x := range w {
484 lines = append(lines, x.Line)
485 }
486 if !reflect.DeepEqual(lines, []int{4, 5, 6, 7}) {
487 t.Fatal(w)
488 }
489}
490
491func TestSlotWidthsIgnoredWithFixedOverflow(t *testing.T) {
492 _, w := parseString(t, `workspace.dev.overflow = fixed
493workspace.dev.column.1 = 67%, foot
494`)
495 if len(w) != 2 || w[1].Line != 2 || !strings.Contains(w[1].Msg, "ignored") {
496 t.Fatal(w)
497 }
498}
499
500func TestTerminalResolutionAndAutoOpen(t *testing.T) {
501 t.Setenv("TERMINAL", "ghostty --new-window")
502 c, w := parseString(t, "terminal.auto-open = all\n")
503 if len(w) != 0 || !reflect.DeepEqual(c.Terminal.Command, []string{"ghostty", "--new-window"}) || c.Terminal.AutoOpen != "all" {
504 t.Fatal(c.Terminal, w)
505 }
506 c, w = parseString(t, "terminal = foot --server\nterminal.auto-open = off\n")
507 if len(w) != 0 || !reflect.DeepEqual(c.Terminal.Command, []string{"foot", "--server"}) || c.Terminal.AutoOpen != "off" {
508 t.Fatal(c.Terminal, w)
509 }
510 c, w = parseString(t, "terminal.auto-open = invalid\n")
511 if len(w) != 1 || c.Terminal.AutoOpen != "first" {
512 t.Fatal(c.Terminal, w)
513 }
514 t.Setenv("TERMINAL", " ")
515 if got := Defaults().Terminal.Command; !reflect.DeepEqual(got, []string{"foot"}) {
516 t.Fatal(got)
517 }
518}
519
520func TestStash(t *testing.T) {
521 if d := Defaults(); d.Stash.Width != 80 || d.Stash.Gap != 2 || d.Stash.Dim != 0.5 {
522 t.Fatalf("defaults %+v", d.Stash)
523 }
524 for _, tc := range []struct {
525 line string
526 width int
527 gap int
528 dim float64
529 warning bool
530 }{
531 {"stash.width = 10", 10, 2, 0.5, false}, {"stash.width = 70%", 70, 2, 0.5, false}, {"stash.width = 90", 90, 2, 0.5, false},
532 {"stash.width = 9", 80, 2, 0.5, true}, {"stash.width = 91", 80, 2, 0.5, true}, {"stash.width = 0", 80, 2, 0.5, true}, {"stash.width = 1/2", 80, 2, 0.5, true},
533 {"stash.gap = 0", 80, 0, 0.5, false}, {"stash.gap = 10", 80, 10, 0.5, false},
534 {"stash.gap = 11", 80, 2, 0.5, true}, {"stash.gap = -1", 80, 2, 0.5, true}, {"stash.gap = 2.5", 80, 2, 0.5, true},
535 {"stash.dim = 0.8", 80, 2, 0.8, false}, {"stash.dim = 1.5", 80, 2, 0.5, true}, {"stash.dim = NaN", 80, 2, 0.5, true},
536 } {
537 t.Run(tc.line, func(t *testing.T) {
538 c, warnings := parseString(t, tc.line)
539 if c.Stash.Width != tc.width || c.Stash.Gap != tc.gap || c.Stash.Dim != tc.dim || (len(warnings) != 0) != tc.warning {
540 t.Fatalf("stash %+v, warnings %v", c.Stash, warnings)
541 }
542 })
543 }
544}
545
546func TestStashCapture(t *testing.T) {
547 if c, _ := parseString(t, ""); !c.Stash.Capture {
548 t.Fatal("stash.capture is on by default")
549 }
550 if c, w := parseString(t, "stash.capture = off"); c.Stash.Capture || len(w) != 0 {
551 t.Fatalf("off: %v %v", c.Stash.Capture, w)
552 }
553 if c, w := parseString(t, "stash.capture = yes"); !c.Stash.Capture || len(w) == 0 {
554 t.Fatalf("invalid: %v %v", c.Stash.Capture, w)
555 }
556}
557
558func TestVRRFlipGap(t *testing.T) {
559 for _, tc := range []struct {
560 value string
561 want time.Duration
562 warning bool
563 }{
564 {"0", 0, false}, {"0ms", 0, false}, {"500us", 500 * time.Microsecond, false}, {"2ms", 2 * time.Millisecond, false}, {"10ms", 10 * time.Millisecond, false},
565 {"11ms", time.Millisecond, true}, {"-1ms", time.Millisecond, true}, {"1", time.Millisecond, true}, {"fast", time.Millisecond, true},
566 } {
567 t.Run(tc.value, func(t *testing.T) {
568 c, warnings := parseString(t, "render.vrr-flip-gap = "+tc.value)
569 if c.Render.VRRFlipGap != tc.want || (len(warnings) != 0) != tc.warning {
570 t.Fatalf("gap %v, warnings %v", c.Render.VRRFlipGap, warnings)
571 }
572 })
573 }
574}
575
576func TestCursorHideAfter(t *testing.T) {
577 if d := Defaults().Cursor.HideAfter; d != 5*time.Second {
578 t.Fatalf("default %v", d)
579 }
580 for _, tc := range []struct {
581 value string
582 want time.Duration
583 warning bool
584 }{
585 {"off", 0, false}, {"100ms", 100 * time.Millisecond, false}, {"2s", 2 * time.Second, false}, {"1h", time.Hour, false},
586 {"0", 5 * time.Second, true}, {"50ms", 5 * time.Second, true}, {"2h", 5 * time.Second, true}, {"on", 5 * time.Second, true},
587 } {
588 t.Run(tc.value, func(t *testing.T) {
589 c, warnings := parseString(t, "cursor.hide-after = "+tc.value)
590 if c.Cursor.HideAfter != tc.want || (len(warnings) != 0) != tc.warning {
591 t.Fatalf("hide-after %v, warnings %v", c.Cursor.HideAfter, warnings)
592 }
593 })
594 }
595}
596
597func TestFloatingDim(t *testing.T) {
598 for _, tc := range []struct {
599 value string
600 want float64
601 warning bool
602 }{
603 {"0", 0, false}, {"0.75", 0.75, false}, {"1", 1, false},
604 {"-0.1", 0.3, true}, {"1.1", 0.3, true}, {"NaN", 0.3, true}, {"invalid", 0.3, true},
605 } {
606 t.Run(tc.value, func(t *testing.T) {
607 c, warnings := parseString(t, "floating.dim = "+tc.value)
608 if c.Floating.Dim != tc.want || (len(warnings) != 0) != tc.warning {
609 t.Fatalf("dim %v, warnings %v", c.Floating.Dim, warnings)
610 }
611 })
612 }
613}
614
615func TestOutputRelations(t *testing.T) {
616 c, w := parseString(t, `
617output.DP-2.right-of = DP-1
618output.DP-2.offset = 180
619output.DP-3.left-of = DP-1
620output.DP-3.offset = -40
621output.DP-4.above = DP-1
622output.DP-5.below = DP-1
623output.DP-5.offset = 7
624`)
625 if len(w) != 0 {
626 t.Fatal(w)
627 }
628 want := map[string]ports.OutputAnchor{
629 "DP-2": {Relation: ports.RelationRightOf, To: "DP-1", Offset: 180},
630 "DP-3": {Relation: ports.RelationLeftOf, To: "DP-1", Offset: -40},
631 "DP-4": {Relation: ports.RelationAbove, To: "DP-1"},
632 "DP-5": {Relation: ports.RelationBelow, To: "DP-1", Offset: 7},
633 }
634 if len(c.Outputs) != len(want) {
635 t.Fatalf("%+v", c.Outputs)
636 }
637 for _, o := range c.Outputs {
638 if o.Anchor != want[o.Name] || !o.ScaleOnly {
639 t.Fatalf("%s: %+v", o.Name, o)
640 }
641 }
642}
643
644func TestOutputTransform(t *testing.T) {
645 names := []string{"normal", "90", "180", "270", "flipped", "flipped-90", "flipped-180", "flipped-270"}
646 var text strings.Builder
647 for i, n := range names {
648 fmt.Fprintf(&text, "output.O%d.transform = %s\n", i, n)
649 }
650 c, w := parseString(t, text.String())
651 if len(w) != 0 || len(c.Outputs) != len(names) {
652 t.Fatalf("%v %+v", w, c.Outputs)
653 }
654 for i, o := range c.Outputs {
655 if o.Transform != ports.BufferTransform(i) || !o.ScaleOnly {
656 t.Fatalf("%s: %+v", names[i], o)
657 }
658 }
659
660 c, w = parseString(t, "output.DP-1.transform = 45\n")
661 if len(w) != 1 || !strings.Contains(w[0].Msg, "invalid transform") {
662 t.Fatalf("warnings %v", w)
663 }
664 if len(c.Outputs) != 0 {
665 t.Fatalf("%+v", c.Outputs)
666 }
667
668 // A later mode line keeps the transform.
669 c, w = parseString(t, "output.DP-1.transform = 90\noutput.DP-1 = 1920x1080\n")
670 if len(w) != 0 || len(c.Outputs) != 1 || c.Outputs[0].Transform != 1 || c.Outputs[0].ScaleOnly {
671 t.Fatalf("%v %+v", w, c.Outputs)
672 }
673}
674
675func TestOutputRelationOverride(t *testing.T) {
676 c, w := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.below = DP-3\n")
677 if len(w) != 1 || w[0].Line != 2 || !strings.Contains(w[0].Msg, "overrides line 1") {
678 t.Fatalf("warnings %v", w)
679 }
680 if got := c.Outputs[0].Anchor; got != (ports.OutputAnchor{Relation: ports.RelationBelow, To: "DP-3"}) {
681 t.Fatalf("%+v", got)
682 }
683}
684
685func TestOutputRelationWarnings(t *testing.T) {
686 const rangeMsg = "must be an integer between -65536 and 65536"
687 rel := "output.DP-2.right-of = DP-1\n"
688 cases := []struct {
689 name, text string
690 want []string // expected warning messages (substring), in order
691 lines []int // expected warning lines, in order
692 anchored []string // outputs that keep a relation
693 }{
694 {"self", "output.DP-2.right-of = DP-2", []string{"relative to itself"}, []int{1}, nil},
695 {"empty", "output.DP-2.above =", []string{"expected an output name"}, []int{1}, nil},
696 {"bad offset", rel + "output.DP-2.offset = abc", []string{rangeMsg}, []int{2}, []string{"DP-2"}},
697 {"offset too large", rel + "output.DP-2.offset = 65537", []string{rangeMsg}, []int{2}, []string{"DP-2"}},
698 {"offset too small", rel + "output.DP-2.offset = -65537", []string{rangeMsg}, []int{2}, []string{"DP-2"}},
699 {"offset overflow", rel + "output.DP-2.offset = 99999999999999999999", []string{rangeMsg}, []int{2}, []string{"DP-2"}},
700 {"offset alone", "output.DP-2.offset = 10", []string{"without a relation"}, []int{1}, nil},
701 {"two cycle", "output.A.right-of = B\noutput.B.below = A", []string{"relation cycle", "relation cycle"}, []int{1, 2}, nil},
702 {"three cycle", "output.A.right-of = B\noutput.B.right-of = C\noutput.C.above = A", []string{"relation cycle", "relation cycle", "relation cycle"}, []int{1, 2, 3}, nil},
703 {"tail into cycle", "output.A.right-of = B\noutput.B.right-of = A\noutput.C.left-of = A", []string{"relation cycle", "relation cycle"}, []int{1, 2}, []string{"C"}},
704 {"relation overridden twice", "output.DP-2.right-of = A\noutput.DP-2.below = B\noutput.DP-2.right-of = C", []string{"overrides line 1", "overrides line 2"}, []int{2, 3}, []string{"DP-2"}},
705 {"relation overridden three times", "output.DP-2.right-of = A\noutput.DP-2.below = B\noutput.DP-2.right-of = C\noutput.DP-2.below = D", []string{"overrides line 1", "overrides line 2", "overrides line 3"}, []int{2, 3, 4}, []string{"DP-2"}},
706 {"same relation on two outputs", "output.DP-2.right-of = DP-1\noutput.DP-3.right-of = DP-1", []string{"output.DP-3: same relation as output.DP-2; both get the same position"}, []int{2}, []string{"DP-2", "DP-3"}},
707 {"same relation and offset on three outputs", "output.A.below = R\noutput.A.offset = 5\noutput.B.below = R\noutput.B.offset = 5\noutput.C.below = R\noutput.C.offset = 5", []string{"output.B: same relation as output.A", "output.C: same relation as output.A"}, []int{3, 5}, []string{"A", "B", "C"}},
708 {"same reference, other offset", "output.DP-2.right-of = DP-1\noutput.DP-3.right-of = DP-1\noutput.DP-3.offset = 10", nil, nil, []string{"DP-2", "DP-3"}},
709 {"same reference, other relation", "output.DP-2.right-of = DP-1\noutput.DP-3.left-of = DP-1", nil, nil, []string{"DP-2", "DP-3"}},
710 {"same relation twice", "output.DP-2.below = A\noutput.DP-2.below = B", []string{"overrides line 1"}, []int{2}, []string{"DP-2"}},
711 }
712 for _, tc := range cases {
713 t.Run(tc.name, func(t *testing.T) {
714 c, w := parseString(t, tc.text)
715 if len(w) != len(tc.want) {
716 t.Fatalf("warnings %v, want %q", w, tc.want)
717 }
718 for i, x := range w {
719 if !strings.Contains(x.Msg, tc.want[i]) || x.Line != tc.lines[i] {
720 t.Fatalf("warning %d = %+v, want %q at line %d (all: %v)", i, x, tc.want[i], tc.lines[i], w)
721 }
722 }
723 var got []string
724 for _, o := range c.Outputs {
725 if o.Anchor.Relation != ports.RelationNone {
726 got = append(got, o.Name)
727 }
728 }
729 if !reflect.DeepEqual(got, tc.anchored) {
730 t.Fatalf("anchored %v, want %v: %+v", got, tc.anchored, c.Outputs)
731 }
732 })
733 }
734}
735
736func TestDefaultMonitorBinds(t *testing.T) {
737 d := Defaults()
738 for combo, action := range map[string]string{
739 "Cmd+Ctrl+Up": "focus-monitor-up", "Cmd+Ctrl+k": "focus-monitor-up",
740 "Cmd+Ctrl+Down": "focus-monitor-down", "Cmd+Ctrl+j": "focus-monitor-down",
741 "Cmd+Ctrl+Left": "focus-monitor-left", "Cmd+Ctrl+l": "focus-monitor-right",
742 } {
743 if d.Binds[combo] != action {
744 t.Errorf("%s = %q, want %q", combo, d.Binds[combo], action)
745 }
746 }
747 // The up/down move-workspace-to-monitor actions have no default bind but
748 // load from a config file.
749 c, w := parseString(t, "bind.cmd+ctrl+alt+up = move-workspace-to-monitor-up\nbind.cmd+ctrl+alt+down = move-workspace-to-monitor-down\nbind.cmd+alt+u = focus-monitor-up\n")
750 if len(w) != 0 || c.Binds["Alt+Cmd+Ctrl+Up"] != "move-workspace-to-monitor-up" || c.Binds["Alt+Cmd+Ctrl+Down"] != "move-workspace-to-monitor-down" {
751 t.Fatalf("%v %v", w, c.Binds)
752 }
753}
754
755func TestOutputOffsetKeepsEarlierValueWhenInvalid(t *testing.T) {
756 c, _ := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.offset = 40\noutput.DP-2.offset = 65537\n")
757 if got := c.Outputs[0].Anchor.Offset; got != 40 {
758 t.Fatalf("offset %d, want 40", got)
759 }
760 c, w := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.offset = 65536\noutput.DP-2.offset = -65536\n")
761 if len(w) != 1 || c.Outputs[0].Anchor.Offset != -65536 {
762 t.Fatalf("%v %+v", w, c.Outputs)
763 }
764}
765
766func TestOutputModeLineKeepsAnchor(t *testing.T) {
767 c, w := parseString(t, "output.DP-2.right-of = DP-1\noutput.DP-2.offset = 50\noutput.DP-2 = 1920x1080\n")
768 if len(w) != 0 {
769 t.Fatal(w)
770 }
771 o := c.Outputs[0]
772 if o.Mode != "1920x1080" || o.Anchor != (ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-1", Offset: 50}) {
773 t.Fatalf("%+v", o)
774 }
775}