v0.1.0 3b1b6ef

decode_test.go RawHistoryFolder

1package kvconf
2
3import (
4	"errors"
5	"fmt"
6	"os"
7	"path/filepath"
8	"reflect"
9	"strings"
10	"testing"
11	"time"
12)
13
14type mode string
15
16type output struct {
17	Scale   float64 `kv:"scale" min:"0.25" max:"4"`
18	HDR     bool    `kv:"hdr"`
19	Primary bool    `kv:"primary"`
20	Bright  int     `kv:"sdr-brightness" min:"80" max:"1000"`
21}
22
23func (o *output) SetDefaults() { o.Scale, o.Bright = 1, 203 }
24
25type testConfig struct {
26	Name    string        `kv:"name,nonempty"`
27	Enabled bool          `kv:"enabled"`
28	Count   int           `kv:"count" min:"1" max:"10"`
29	Size    uint16        `kv:"size"`
30	Ratio   float32       `kv:"ratio"`
31	After   time.Duration `kv:"lock.after,off" max:"1h"`
32	Mode    mode          `kv:"mode" oneof:"auto|dark|light"`
33	Tags    []string      `kv:"tags"`
34	Ports   []int         `kv:"ports" sep:"|" min:"1" max:"65535"`
35	Dir     Path          `kv:"dir"`
36	Fade    struct {
37		After    time.Duration `kv:"after"`
38		Duration time.Duration `kv:"duration"`
39	} `kv:"fade"`
40	Inline struct {
41		Flag bool `kv:"flag"`
42	} `kv:",inline"`
43	Binds   map[string]string `kv:"bind.*"`
44	Outputs map[string]output `kv:"output.*"`
45	Codes   map[string]int    `kv:"code.*" min:"0"`
46	Ignored string            `kv:"-"`
47	Rest    []Entry           `kv:"*"`
48	Plain   string            // untagged: ignored
49}
50
51func defaults() testConfig {
52	c := testConfig{Name: "x", Count: 5, Mode: "auto", After: 5 * time.Minute}
53	return c
54}
55
56func TestDecode(t *testing.T) {
57	home, _ := os.UserHomeDir()
58	tests := []struct {
59		name     string
60		in       string
61		opts     Options
62		check    func(t *testing.T, c *testConfig)
63		warnings []string
64		err      string
65	}{
66		{
67			name: "scalars",
68			in: `name = vev
69enabled = on
70count = 7
71size = 512
72ratio = 0.5
73lock.after = 10m
74mode = dark
75flag = yes
76`,
77			check: func(t *testing.T, c *testConfig) {
78				want := defaults()
79				want.Name, want.Enabled, want.Count, want.Size, want.Ratio = "vev", true, 7, 512, 0.5
80				want.After, want.Mode, want.Inline.Flag = 10*time.Minute, "dark", true
81				want.Rest = nil
82				if !reflect.DeepEqual(*c, want) {
83					t.Errorf("got %+v\nwant %+v", *c, want)
84				}
85			},
86		},
87		{
88			name:  "absent keys keep defaults",
89			in:    "",
90			check: func(t *testing.T, c *testConfig) { eq(t, c.Count, 5); eq(t, c.After, 5*time.Minute) },
91		},
92		{
93			name:  "off is zero",
94			in:    "lock.after = off\n",
95			check: func(t *testing.T, c *testConfig) { eq(t, c.After, time.Duration(0)) },
96		},
97		{
98			name: "nested struct",
99			in:   "fade.after = 1s\nfade.duration = 250ms\n",
100			check: func(t *testing.T, c *testConfig) {
101				eq(t, c.Fade.After, time.Second)
102				eq(t, c.Fade.Duration, 250*time.Millisecond)
103			},
104		},
105		{
106			name: "lists",
107			in:   "tags = a, b ,,c\nports = 80|443\n",
108			check: func(t *testing.T, c *testConfig) {
109				eq(t, c.Tags, []string{"a", "b", "c"})
110				eq(t, c.Ports, []int{80, 443})
111			},
112		},
113		{
114			name:  "empty list",
115			in:    "tags =\n",
116			check: func(t *testing.T, c *testConfig) { eq(t, c.Tags, []string{}) },
117		},
118		{
119			name:  "path",
120			in:    "dir = ~/shots/../pics\n",
121			check: func(t *testing.T, c *testConfig) { eq(t, c.Dir, Path(filepath.Join(home, "pics"))) },
122		},
123		{
124			name: "map of strings",
125			in:   "bind.cmd+q = close\nbind.cmd+return = spawn foot\n",
126			check: func(t *testing.T, c *testConfig) {
127				eq(t, c.Binds, map[string]string{"cmd+q": "close", "cmd+return": "spawn foot"})
128			},
129		},
130		{
131			name: "map of structs with dotted names and defaults",
132			in:   "output.DP-1.scale = 1.5\noutput.eDP.1.hdr = on\noutput.DP-1.primary = on\n",
133			check: func(t *testing.T, c *testConfig) {
134				eq(t, c.Outputs, map[string]output{
135					"DP-1":  {Scale: 1.5, Primary: true, Bright: 203},
136					"eDP.1": {Scale: 1, HDR: true, Bright: 203},
137				})
138			},
139		},
140		{
141			name:     "map of structs rejects bad value without creating the entry",
142			in:       "output.DP-1.scale = 9\n",
143			check:    func(t *testing.T, c *testConfig) { eq(t, len(c.Outputs), 0) },
144			warnings: []string{"line 1: output.DP-1.scale: must be at most 4"},
145		},
146		{
147			name:  "map of ints",
148			in:    "code.foo = 3\n",
149			check: func(t *testing.T, c *testConfig) { eq(t, c.Codes, map[string]int{"foo": 3}) },
150		},
151		{
152			name: "catch-all keeps unknown keys in order",
153			in:   "zz = 1\naa = 2\noutput.DP-1.nope = 3\n",
154			check: func(t *testing.T, c *testConfig) {
155				eq(t, c.Rest, []Entry{{"zz", "1", 1}, {"aa", "2", 2}, {"output.DP-1.nope", "3", 3}})
156			},
157		},
158		{
159			name: "invalid values keep the previous value",
160			in: `count = 0
161count = 11
162count = x
163enabled = maybe
164mode = blue
165lock.after = 2h
166lock.after = soon
167name =
168size = -1
169ratio = big
170ports = 80|0
171dir = rel/path
172`,
173			check: func(t *testing.T, c *testConfig) {
174				want := defaults()
175				if !reflect.DeepEqual(*c, want) {
176					t.Errorf("got %+v\nwant %+v", *c, want)
177				}
178			},
179			warnings: []string{
180				"line 2: count: overrides line 1",
181				"line 3: count: overrides line 2",
182				"line 7: lock.after: overrides line 6",
183				"line 1: count: must be at least 1",
184				"line 2: count: must be at most 10",
185				"line 3: count: must be an integer",
186				"line 4: enabled: must be on or off",
187				"line 5: mode: must be auto, dark or light",
188				"line 6: lock.after: must be at most 1h",
189				"line 7: lock.after: must be a duration such as 500ms, 5m or 1h30m",
190				"line 8: name: must not be empty",
191				"line 9: size: must be a non-negative integer",
192				"line 10: ratio: must be a number",
193				"line 11: ports: item 2: must be at least 1",
194				"line 12: dir: must be an absolute path or start with ~/",
195			},
196		},
197		{
198			name:  "last valid value wins",
199			in:    "count = 2\ncount = 3\n",
200			opts:  Options{AllowDuplicates: true},
201			check: func(t *testing.T, c *testConfig) { eq(t, c.Count, 3) },
202		},
203		{
204			name:     "strict fails on first bad value",
205			in:       "count = 2\ncount = 99\nmode = x\n",
206			opts:     Options{Strict: true, AllowDuplicates: true},
207			warnings: []string{"line 2: count: must be at most 10"},
208			err:      "line 2: count: must be at most 10",
209		},
210		{
211			name:     "messages never contain the value",
212			in:       "count = hunter2\n",
213			warnings: []string{"line 1: count: must be an integer"},
214			check:    func(t *testing.T, c *testConfig) {},
215		},
216	}
217	for _, tt := range tests {
218		t.Run(tt.name, func(t *testing.T) {
219			c := defaults()
220			warnings, err := Unmarshal([]byte(tt.in), &c, tt.opts)
221			if tt.err != "" {
222				if err == nil || err.Error() != tt.err {
223					t.Fatalf("err = %v, want %q", err, tt.err)
224				}
225			} else if err != nil {
226				t.Fatalf("err = %v", err)
227			}
228			if got := warningStrings(warnings); !reflect.DeepEqual(got, tt.warnings) {
229				t.Errorf("warnings =\n%q\nwant\n%q", got, tt.warnings)
230			}
231			if tt.check != nil {
232				tt.check(t, &c)
233			}
234		})
235	}
236}
237
238type plain struct {
239	A int `kv:"a"`
240}
241
242type validated struct {
243	Min int `kv:"min"`
244	Max int `kv:"max"`
245}
246
247func (v *validated) Validate() error {
248	if v.Min > v.Max {
249		return errors.New("min must not exceed max")
250	}
251	return nil
252}
253
254func TestDecodeOptionsAndValidate(t *testing.T) {
255	tests := []struct {
256		name     string
257		in       string
258		target   any
259		opts     Options
260		warnings []string
261		err      string
262	}{
263		{name: "unknown key warns", in: "b = 1\n", target: &plain{}, warnings: []string{"line 1: b: unknown key"}},
264		{name: "unknown key allowed", in: "b = 1\n", target: &plain{}, opts: Options{AllowUnknown: true}},
265		{name: "unknown key strict", in: "b = 1\n", target: &plain{}, opts: Options{Strict: true},
266			warnings: []string{"line 1: b: unknown key"}, err: "line 1: b: unknown key"},
267		{name: "validate passes", in: "min = 1\nmax = 2\n", target: &validated{}},
268		{name: "validate fails", in: "min = 3\nmax = 2\n", target: &validated{}, err: "min must not exceed max"},
269		{name: "not a pointer", target: plain{}, err: errTarget.Error()},
270		{name: "nil pointer", target: (*plain)(nil), err: errTarget.Error()},
271	}
272	for _, tt := range tests {
273		t.Run(tt.name, func(t *testing.T) {
274			warnings, err := Unmarshal([]byte(tt.in), tt.target, tt.opts)
275			if gotErr := errString(err); gotErr != tt.err {
276				t.Fatalf("err = %q, want %q", gotErr, tt.err)
277			}
278			if got := warningStrings(warnings); !reflect.DeepEqual(got, tt.warnings) {
279				t.Errorf("warnings = %q, want %q", got, tt.warnings)
280			}
281		})
282	}
283}
284
285func errString(err error) string {
286	if err == nil {
287		return ""
288	}
289	return err.Error()
290}
291
292func TestSchemaErrors(t *testing.T) {
293	tests := []struct {
294		name   string
295		target any
296		want   string
297	}{
298		{"unknown option", &struct {
299			A int `kv:"a,bogus"`
300		}{}, `unknown tag option "bogus"`},
301		{"unsupported type", &struct {
302			A chan int `kv:"a"`
303		}{}, "unsupported type chan int"},
304		{"duplicate key", &struct {
305			A int `kv:"a"`
306			B int `kv:"a"`
307		}{}, `key "a" declared twice`},
308		{"map without string keys", &struct {
309			A map[int]int `kv:"a.*"`
310		}{}, "needs a map with string keys"},
311		{"bad catch-all", &struct {
312			A int `kv:"*"`
313		}{}, `needs []kvconf.Entry or map[string]string`},
314		{"unnamed struct", &struct {
315			A struct{} `kv:""`
316		}{}, "needs a name or inline"},
317		{"bad bound", &struct {
318			A int `kv:"a" min:"x"`
319		}{}, `bad min "x"`},
320		{"nested list", &struct {
321			A [][]int `kv:"a"`
322		}{}, "nested lists are not supported"},
323	}
324	for _, tt := range tests {
325		t.Run(tt.name, func(t *testing.T) {
326			_, err := Unmarshal(nil, tt.target, Options{})
327			if err == nil || !strings.Contains(err.Error(), tt.want) {
328				t.Fatalf("err = %v, want it to contain %q", err, tt.want)
329			}
330		})
331	}
332}
333
334// leaky mimics a third-party type whose errors quote the input.
335type leaky string
336
337func (l *leaky) UnmarshalText(b []byte) error { return fmt.Errorf("bad input %q", b) }
338
339// explained returns a ValueError, which is shown.
340type explained string
341
342func (e *explained) UnmarshalText([]byte) error { return ValueError("must be a colour") }
343
344func TestDecodeNeverLeaksValues(t *testing.T) {
345	type cfg struct {
346		L     leaky     `kv:"l"`
347		E     explained `kv:"e"`
348		Ratio float64   `kv:"ratio" min:"0" max:"1"`
349		Any   float64   `kv:"any"`
350	}
351	tests := []struct{ in, want string }{
352		{"l = hunter2", "line 1: l: invalid kvconf.leaky"},
353		{"e = hunter2", "line 1: e: must be a colour"},
354		{"ratio = NaN", "line 1: ratio: must be a finite number"},
355		{"ratio = +Inf", "line 1: ratio: must be a finite number"},
356		{"any = NaN", "line 1: any: must be a finite number"},
357	}
358	for _, tt := range tests {
359		t.Run(tt.in, func(t *testing.T) {
360			var c cfg
361			w, err := Unmarshal([]byte(tt.in+"\n"), &c, Options{})
362			must(t, err)
363			eq(t, warningStrings(w), []string{tt.want})
364			if strings.Contains(tt.want, "hunter2") {
365				t.Fatal("value leaked")
366			}
367		})
368	}
369}
370
371func TestParseBool(t *testing.T) {
372	tests := []struct {
373		in   string
374		want bool
375		err  bool
376	}{
377		{"on", true, false}, {"ON", true, false}, {"true", true, false}, {"Yes", true, false}, {"1", true, false},
378		{"off", false, false}, {"False", false, false}, {"no", false, false}, {"0", false, false},
379		{"", false, true}, {"2", false, true}, {"enable", false, true},
380	}
381	for _, tt := range tests {
382		t.Run(tt.in, func(t *testing.T) {
383			got, err := ParseBool(tt.in)
384			if (err != nil) != tt.err || got != tt.want {
385				t.Fatalf("ParseBool(%q) = %v, %v", tt.in, got, err)
386			}
387		})
388	}
389}
390
391func eq[T any](t *testing.T, got, want T) {
392	t.Helper()
393	if !reflect.DeepEqual(got, want) {
394		t.Errorf("got %#v, want %#v", got, want)
395	}
396}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 3

↑↓ moveenter openesc close