main b4521f0

decode_test.go RawHistoryFolder

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

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