v0.1.1 ebc8127

live_test.go RawHistoryFolder

1package kvconf
2
3import (
4	"context"
5	"errors"
6	"io/fs"
7	"os"
8	"path/filepath"
9	"strings"
10	"sync"
11	"testing"
12	"time"
13)
14
15type liveConfig struct {
16	Theme string        `kv:"theme" oneof:"dark|light"`
17	After time.Duration `kv:"after"`
18}
19
20func (c *liveConfig) SetDefaults() { c.Theme, c.After = "dark", time.Minute }
21
22type strictPair struct {
23	A int `kv:"a"`
24	B int `kv:"b"`
25}
26
27func (p *strictPair) Validate() error {
28	if p.A > p.B {
29		return errors.New("a must not exceed b")
30	}
31	return nil
32}
33
34func TestLoad(t *testing.T) {
35	tests := []struct {
36		name     string
37		content  *string // nil: no file
38		opts     Options
39		want     liveConfig
40		warnings int
41		err      string
42		notExist bool
43	}{
44		{name: "missing file gives defaults", want: liveConfig{"dark", time.Minute}},
45		{name: "missing file required", opts: Options{RequireFile: true}, notExist: true},
46		{name: "file over defaults", content: ptr("theme = light\n"), want: liveConfig{"light", time.Minute}},
47		{name: "lenient bad value", content: ptr("theme = blue\nafter = 2m\n"), want: liveConfig{"dark", 2 * time.Minute}, warnings: 1},
48		{name: "strict bad value", content: ptr("theme = blue\n"), opts: Options{Strict: true}, err: "line 1: theme: must be dark or light"},
49	}
50	for _, tt := range tests {
51		t.Run(tt.name, func(t *testing.T) {
52			path := filepath.Join(t.TempDir(), "config")
53			if tt.content != nil {
54				must(t, os.WriteFile(path, []byte(*tt.content), 0o600))
55			}
56			cfg, warnings, err := Load[liveConfig](path, tt.opts)
57			if tt.notExist {
58				if !errors.Is(err, fs.ErrNotExist) {
59					t.Fatalf("err = %v, want fs.ErrNotExist", err)
60				}
61				return
62			}
63			if tt.err != "" {
64				if err == nil || !strings.Contains(err.Error(), tt.err) {
65					t.Fatalf("err = %v, want %q", err, tt.err)
66				}
67				return
68			}
69			must(t, err)
70			eq(t, *cfg, tt.want)
71			eq(t, len(warnings), tt.warnings)
72		})
73	}
74}
75
76// badDefaults has defaults that fail its own Validate.
77type badDefaults struct{ strictPair }
78
79func (p *badDefaults) SetDefaults()    { p.A, p.B = 3, 2 }
80func (p *badDefaults) Validate() error { return p.strictPair.Validate() }
81
82func TestLoadValidates(t *testing.T) {
83	dir := t.TempDir()
84	bad := writeTemp(t, dir, "bad", "a = 3\nb = 2\n")
85	tests := []struct {
86		name string
87		load func(path string) error
88		path string
89		err  string
90	}{
91		{name: "valid zero defaults", load: loadErr[strictPair], path: filepath.Join(dir, "none")},
92		{name: "invalid file", load: loadErr[strictPair], path: bad, err: bad + ": a must not exceed b"},
93		{name: "invalid defaults", load: loadErr[badDefaults], path: filepath.Join(dir, "none"), err: "a must not exceed b"},
94	}
95	for _, tt := range tests {
96		t.Run(tt.name, func(t *testing.T) {
97			err := tt.load(tt.path)
98			if tt.err == "" {
99				must(t, err)
100			} else if err == nil || err.Error() != tt.err {
101				t.Fatalf("err = %v, want %q", err, tt.err)
102			}
103		})
104	}
105}
106
107func loadErr[T any](path string) error {
108	_, _, err := Load[T](path, Options{})
109	return err
110}
111
112// liveRecorder collects what a Live reports.
113type liveRecorder struct {
114	mu      sync.Mutex
115	changes []Change[liveConfig]
116	errs    []error
117	signal  chan struct{}
118}
119
120func newRecorder() *liveRecorder { return &liveRecorder{signal: make(chan struct{}, 16)} }
121
122func (r *liveRecorder) opts() LiveOptions[liveConfig] {
123	return LiveOptions[liveConfig]{
124		Watch: WatchOptions{Debounce: 20 * time.Millisecond},
125		OnChange: func(c Change[liveConfig]) {
126			r.mu.Lock()
127			r.changes = append(r.changes, c)
128			r.mu.Unlock()
129			r.signal <- struct{}{}
130		},
131		OnError: func(err error) {
132			r.mu.Lock()
133			r.errs = append(r.errs, err)
134			r.mu.Unlock()
135			r.signal <- struct{}{}
136		},
137	}
138}
139
140func (r *liveRecorder) wait(t *testing.T, why string) {
141	t.Helper()
142	select {
143	case <-r.signal:
144	case <-time.After(5 * time.Second):
145		t.Fatalf("nothing reported after %s", why)
146	}
147}
148
149func (r *liveRecorder) quiet(t *testing.T, why string) {
150	t.Helper()
151	select {
152	case <-r.signal:
153		t.Fatalf("unexpected report after %s", why)
154	case <-time.After(quiet):
155	}
156}
157
158func (r *liveRecorder) last() (Change[liveConfig], error) {
159	r.mu.Lock()
160	defer r.mu.Unlock()
161	var c Change[liveConfig]
162	var err error
163	if len(r.changes) > 0 {
164		c = r.changes[len(r.changes)-1]
165	}
166	if len(r.errs) > 0 {
167		err = r.errs[len(r.errs)-1]
168	}
169	return c, err
170}
171
172func TestLive(t *testing.T) {
173	tests := []struct {
174		name    string
175		initial *string
176		steps   func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder)
177	}{
178		{
179			name:    "reload on change",
180			initial: ptr("theme = dark\n"),
181			steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
182				old := l.Get()
183				must(t, WriteFile(path, []byte("theme = light\n"), 0o600))
184				r.wait(t, "a change")
185				c, _ := r.last()
186				eq(t, c.Keys, []string{"theme"})
187				eq(t, c.Old, old)
188				eq(t, l.Get().Theme, "light")
189				eq(t, old.Theme, "dark") // a published config is never modified
190			},
191		},
192		{
193			name:    "comment and spacing only",
194			initial: ptr("theme = light\n"),
195			steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
196				must(t, os.WriteFile(path, []byte("# note\ntheme   =   light   # same\n"), 0o600))
197				r.quiet(t, "a save that changes no value")
198			},
199		},
200		{
201			name:    "reordered keys",
202			initial: ptr("theme = light\nafter = 5s\n"),
203			steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
204				old := l.Get()
205				must(t, os.WriteFile(path, []byte("after = 5s\ntheme = light\n"), 0o600))
206				r.quiet(t, "a save that only reorders keys")
207				if l.Get() != old {
208					t.Fatal("config swapped")
209				}
210			},
211		},
212		{
213			name: "empty file created then removed",
214			steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
215				must(t, os.WriteFile(path, nil, 0o600))
216				r.quiet(t, "an empty file")
217				must(t, os.Remove(path))
218				r.wait(t, "a removal")
219				if _, err := r.last(); !errors.Is(err, fs.ErrNotExist) {
220					t.Fatalf("err = %v", err)
221				}
222			},
223		},
224		{
225			name:    "broken file keeps the config",
226			initial: ptr("theme = light\n"),
227			steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
228				must(t, os.WriteFile(path, []byte("theme = light\n\xff\n"), 0o600))
229				r.wait(t, "an invalid file")
230				_, err := r.last()
231				if !errors.Is(err, ErrNotUTF8) {
232					t.Fatalf("err = %v", err)
233				}
234				eq(t, l.Get().Theme, "light")
235			},
236		},
237		{
238			name:    "removed file keeps the config",
239			initial: ptr("theme = light\n"),
240			steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
241				must(t, os.Remove(path))
242				r.wait(t, "a removal")
243				_, err := r.last()
244				if !errors.Is(err, fs.ErrNotExist) {
245					t.Fatalf("err = %v", err)
246				}
247				eq(t, l.Get().Theme, "light")
248				must(t, os.WriteFile(path, []byte("theme = dark\n"), 0o600))
249				r.wait(t, "the file came back")
250				eq(t, l.Get().Theme, "dark")
251			},
252		},
253		{
254			name: "file created after start",
255			steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
256				eq(t, l.Get().Theme, "dark")
257				must(t, os.WriteFile(path, []byte("after = 5s\n"), 0o600))
258				r.wait(t, "creation")
259				eq(t, *l.Get(), liveConfig{"dark", 5 * time.Second})
260			},
261		},
262		{
263			name:    "repeated key with an invalid last value",
264			initial: ptr("after = 5s\nafter = bad\n"),
265			steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
266				eq(t, l.Get().After, 5*time.Second)
267				must(t, WriteFile(path, []byte("after = 6s\nafter = bad\n"), 0o600))
268				r.wait(t, "a change of the valid value")
269				eq(t, l.Get().After, 6*time.Second)
270				c, _ := r.last()
271				eq(t, c.Keys, []string{"after"})
272			},
273		},
274		{
275			name:    "warnings are reported with the change",
276			initial: ptr("after = 1s\n"),
277			steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
278				must(t, WriteFile(path, []byte("after = 2s\ntheme = blue\n"), 0o600))
279				r.wait(t, "a change with a bad value")
280				c, _ := r.last()
281				eq(t, warningStrings(c.Warnings), []string{"line 2: theme: must be dark or light"})
282				eq(t, l.Get().After, 2*time.Second)
283			},
284		},
285	}
286	for _, tt := range tests {
287		t.Run(tt.name, func(t *testing.T) {
288			t.Parallel()
289			path := filepath.Join(t.TempDir(), "config")
290			if tt.initial != nil {
291				must(t, os.WriteFile(path, []byte(*tt.initial), 0o600))
292			}
293			r := newRecorder()
294			opts := r.opts()
295			ready := make(chan struct{})
296			opts.Watch.Ready = ready
297			l, _, err := Open(path, opts)
298			must(t, err)
299			ctx, cancel := context.WithCancel(context.Background())
300			done := make(chan error, 1)
301			go func() { done <- l.Run(ctx) }()
302			<-ready
303			tt.steps(t, path, l, r)
304			cancel()
305			must(t, <-done)
306		})
307	}
308}
309
310func TestLiveSeesSaveBetweenOpenAndRun(t *testing.T) {
311	path := writeTemp(t, t.TempDir(), "config", "theme = dark\n")
312	r := newRecorder()
313	l, _, err := Open(path, r.opts())
314	must(t, err)
315	must(t, os.WriteFile(path, []byte("theme = light\n"), 0o600))
316	ctx, cancel := context.WithCancel(context.Background())
317	defer cancel()
318	go func() { _ = l.Run(ctx) }()
319	r.wait(t, "a save before Run")
320	eq(t, l.Get().Theme, "light")
321}
322
323func TestLiveRunTwice(t *testing.T) {
324	ready := make(chan struct{})
325	l, _, err := Open(filepath.Join(t.TempDir(), "config"), LiveOptions[liveConfig]{Watch: WatchOptions{Ready: ready}})
326	must(t, err)
327	ctx, cancel := context.WithCancel(context.Background())
328	defer cancel()
329	go func() { _ = l.Run(ctx) }()
330	<-ready
331	if err := l.Run(ctx); err == nil {
332		t.Fatal("a second Run was accepted")
333	}
334}
335
336func TestLiveConcurrentGet(t *testing.T) {
337	path := writeTemp(t, t.TempDir(), "config", "after = 1s\n")
338	r := newRecorder()
339	l, _, err := Open(path, r.opts())
340	must(t, err)
341	ctx, cancel := context.WithCancel(context.Background())
342	defer cancel()
343	go func() { _ = l.Run(ctx) }()
344	var wg sync.WaitGroup
345	stop := make(chan struct{})
346	for range 4 {
347		wg.Go(func() {
348			for {
349				select {
350				case <-stop:
351					return
352				default:
353					if l.Get().Theme == "" {
354						t.Error("saw a half-built config")
355					}
356				}
357			}
358		})
359	}
360	for i := range 5 {
361		must(t, WriteFile(path, []byte("after = "+time.Duration(i+2).String()+"\n"), 0o600))
362		r.wait(t, "a change")
363	}
364	close(stop)
365	wg.Wait()
366}

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