1package config
2
3import (
4 "math"
5 "os"
6 "path/filepath"
7 "testing"
8)
9
10func TestSaveOutputScale(t *testing.T) {
11 for _, tc := range []struct {
12 name, in, want string
13 scale float64
14 }{
15 {"missing file", "", "output.DP-2.scale = 1.5\n", 1.5},
16 {"append", "# my config\nborder.width = 3", "# my config\nborder.width = 3\noutput.DP-2.scale = 1.5\n", 1.5},
17 {"replace in place", "output.DP-2.scale = 2 # big\n# end\n", "output.DP-2.scale = 1.25 # big\n# end\n", 1.25},
18 {"enable commented value", "# output.DP-2.scale = 4/3 # 4K display\nrender.vrr = on\n", "output.DP-2.scale = 1.5 # 4K display\nrender.vrr = on\n", 1.5},
19 {"enable indented CRLF example", " # output.DP-2.scale = 2 # note\r\nrender.vrr = on\r\n", " output.DP-2.scale = 1.5 # note\r\nrender.vrr = on\r\n", 1.5},
20 {"insert with same output group", "output.DP-2.hdr = off\noutput.DP-1 = off\n", "output.DP-2.hdr = off\noutput.DP-2.scale = 1.5\noutput.DP-1 = off\n", 1.5},
21 {"insert with output group", "output.DP-1 = off\nrender.vrr = on\n", "output.DP-1 = off\noutput.DP-2.scale = 1.5\nrender.vrr = on\n", 1.5},
22 {"enable example before bind", "# Scale setting\n# output.DP-2.scale = 4/3\n\nbind.cmd+return = spawn-terminal\n", "# Scale setting\noutput.DP-2.scale = 1.25\n\nbind.cmd+return = spawn-terminal\n", 1.25},
23 {"insert beside output", "output.DP-2 = preferred\noutput.DP-1 = off\nrender.vrr = on\n", "output.DP-2 = preferred\noutput.DP-2.scale = 1.5\noutput.DP-1 = off\nrender.vrr = on\n", 1.5},
24 {"active beats commented example", "output.DP-2.scale = 2\n# output.DP-2.scale = 4/3\n", "output.DP-2.scale = 1.5\n# output.DP-2.scale = 4/3\n", 1.5},
25 {"keeps CRLF", "output.DP-2.scale = 2\r\nborder.width = 3\r\n", "output.DP-2.scale = 1.5\r\nborder.width = 3\r\n", 1.5},
26 {"comment with = and #", "output.DP-2.scale = 2 # was a=b #1\n", "output.DP-2.scale = 1.5 # was a=b #1\n", 1.5},
27 {"keeps spacing", "output.DP-2.scale=2\n", "output.DP-2.scale=4/3\n", 4.0 / 3},
28 {"last line wins", "output.DP-2.scale = 2\noutput.DP-2.scale = 3\n", "output.DP-2.scale = 2\noutput.DP-2.scale = 5/3\n", 5.0 / 3},
29 {"ignores other outputs", "# output.DP-1.scale = 2\noutput.DP-1.scale = 2\n", "# output.DP-1.scale = 2\noutput.DP-1.scale = 2\noutput.DP-2.scale = 1\n", 1},
30 } {
31 t.Run(tc.name, func(t *testing.T) {
32 path := filepath.Join(t.TempDir(), "neferwl", "config")
33 if tc.in != "" {
34 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
35 t.Fatal(err)
36 }
37 if err := os.WriteFile(path, []byte(tc.in), 0o600); err != nil {
38 t.Fatal(err)
39 }
40 }
41 if err := (ScaleStore{Path: path}).SaveOutputScale("DP-2", tc.scale); err != nil {
42 t.Fatal(err)
43 }
44 got, err := os.ReadFile(path)
45 if err != nil {
46 t.Fatal(err)
47 }
48 if string(got) != tc.want {
49 t.Fatalf("got\n%s\nwant\n%s", got, tc.want)
50 }
51 // The written value parses back to the same scale.
52 cfg, _, err := Load(path)
53 if err != nil {
54 t.Fatal(err)
55 }
56 found := false
57 for _, o := range cfg.Outputs {
58 if o.Name == "DP-2" {
59 found = true
60 if o.Scale != tc.scale {
61 t.Fatalf("parsed %v, want %v", o.Scale, tc.scale)
62 }
63 }
64 }
65 if !found {
66 t.Fatal("saved output missing from parsed config")
67 }
68 // Existing files keep their mode; new ones are private.
69 {
70 if info, _ := os.Stat(path); info.Mode().Perm() != 0o600 {
71 t.Fatalf("mode %v", info.Mode().Perm())
72 }
73 }
74 })
75 }
76}
77
78// A symlinked config (dotfiles) stays a symlink; its target gets the value.
79func TestSaveOutputScaleSymlink(t *testing.T) {
80 dir := t.TempDir()
81 target := filepath.Join(dir, "dotfiles-config")
82 if err := os.WriteFile(target, []byte("border.width = 3\n"), 0o644); err != nil {
83 t.Fatal(err)
84 }
85 link := filepath.Join(dir, "config")
86 if err := os.Symlink(target, link); err != nil {
87 t.Fatal(err)
88 }
89 if err := (ScaleStore{Path: link}).SaveOutputScale("DP-2", 2); err != nil {
90 t.Fatal(err)
91 }
92 if info, err := os.Lstat(link); err != nil || info.Mode()&os.ModeSymlink == 0 {
93 t.Fatal("symlink replaced", err)
94 }
95 if got, _ := os.ReadFile(target); string(got) != "border.width = 3\noutput.DP-2.scale = 2\n" {
96 t.Fatalf("%q", got)
97 }
98 // A dangling link is an error and stays in place.
99 if err := os.Remove(target); err != nil {
100 t.Fatal(err)
101 }
102 if err := (ScaleStore{Path: link}).SaveOutputScale("DP-2", 2); err == nil {
103 t.Fatal("dangling link saved")
104 }
105 if info, err := os.Lstat(link); err != nil || info.Mode()&os.ModeSymlink == 0 {
106 t.Fatal("dangling symlink replaced", err)
107 }
108}
109
110// Every clean scale is written in a form the parser reads back exactly.
111func TestFormatScaleRoundTrip(t *testing.T) {
112 for n := 120; n <= 480; n++ {
113 s := float64(n) / 120
114 got, err := parseScale(formatScale(s))
115 if err != nil || math.Round(got*120) != float64(n) {
116 t.Fatalf("%d/120: %q parsed as %v, %v", n, formatScale(s), got, err)
117 }
118 }
119}
120
121// Output names may hold spaces (EDID model names).
122func TestSaveOutputScaleSpacedName(t *testing.T) {
123 path := filepath.Join(t.TempDir(), "config")
124 if err := os.WriteFile(path, []byte("output.LG Electronics 27GR95UM.scale = 2\n"), 0o600); err != nil {
125 t.Fatal(err)
126 }
127 if err := (ScaleStore{Path: path}).SaveOutputScale("LG Electronics 27GR95UM", 1.5); err != nil {
128 t.Fatal(err)
129 }
130 if got, _ := os.ReadFile(path); string(got) != "output.LG Electronics 27GR95UM.scale = 1.5\n" {
131 t.Fatalf("%q", got)
132 }
133}