v0.1.0 3b1b6ef

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	}{
43		{name: "missing file gives defaults", want: liveConfig{"dark", time.Minute}},
44		{name: "missing file required", opts: Options{RequireFile: true}, err: "config"},
45		{name: "file over defaults", content: ptr("theme = light\n"), want: liveConfig{"light", time.Minute}},
46		{name: "lenient bad value", content: ptr("theme = blue\nafter = 2m\n"), want: liveConfig{"dark", 2 * time.Minute}, warnings: 1},
47		{name: "strict bad value", content: ptr("theme = blue\n"), opts: Options{Strict: true}, err: "line 1: theme: must be dark or light"},
48	}
49	for _, tt := range tests {
50		t.Run(tt.name, func(t *testing.T) {
51			path := filepath.Join(t.TempDir(), "config")
52			if tt.content != nil {
53				must(t, os.WriteFile(path, []byte(*tt.content), 0o600))
54			}
55			cfg, warnings, err := Load[liveConfig](path, tt.opts)
56			if tt.err != "" {
57				if err == nil || !strings.Contains(err.Error(), tt.err) {
58					t.Fatalf("err = %v, want %q", err, tt.err)
59				}
60				return
61			}
62			must(t, err)
63			eq(t, *cfg, tt.want)
64			eq(t, len(warnings), tt.warnings)
65		})
66	}
67}
68
69func TestLoadValidatesDefaults(t *testing.T) {
70	_, _, err := Load[strictPair](filepath.Join(t.TempDir(), "none"), Options{})
71	must(t, err)
72	path := writeTemp(t, t.TempDir(), "config", "a = 3\nb = 2\n")
73	if _, _, err := Load[strictPair](path, Options{}); err == nil || err.Error() != path+": a must not exceed b" {
74		t.Fatalf("err = %v", err)
75	}
76}
77
78// liveRecorder collects what a Live reports.
79type liveRecorder struct {
80	mu      sync.Mutex
81	changes []Change[liveConfig]
82	errs    []error
83	signal  chan struct{}
84}
85
86func newRecorder() *liveRecorder { return &liveRecorder{signal: make(chan struct{}, 16)} }
87
88func (r *liveRecorder) opts() LiveOptions[liveConfig] {
89	return LiveOptions[liveConfig]{
90		Watch: WatchOptions{Debounce: 20 * time.Millisecond},
91		OnChange: func(c Change[liveConfig]) {
92			r.mu.Lock()
93			r.changes = append(r.changes, c)
94			r.mu.Unlock()
95			r.signal <- struct{}{}
96		},
97		OnError: func(err error) {
98			r.mu.Lock()
99			r.errs = append(r.errs, err)
100			r.mu.Unlock()
101			r.signal <- struct{}{}
102		},
103	}
104}
105
106func (r *liveRecorder) wait(t *testing.T, why string) {
107	t.Helper()
108	select {
109	case <-r.signal:
110	case <-time.After(5 * time.Second):
111		t.Fatalf("nothing reported after %s", why)
112	}
113}
114
115func (r *liveRecorder) quiet(t *testing.T, why string) {
116	t.Helper()
117	select {
118	case <-r.signal:
119		t.Fatalf("unexpected report after %s", why)
120	case <-time.After(quiet):
121	}
122}
123
124func (r *liveRecorder) last() (Change[liveConfig], error) {
125	r.mu.Lock()
126	defer r.mu.Unlock()
127	var c Change[liveConfig]
128	var err error
129	if len(r.changes) > 0 {
130		c = r.changes[len(r.changes)-1]
131	}
132	if len(r.errs) > 0 {
133		err = r.errs[len(r.errs)-1]
134	}
135	return c, err
136}
137
138func TestLive(t *testing.T) {
139	tests := []struct {
140		name    string
141		initial *string
142		steps   func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder)
143	}{
144		{
145			name:    "reload on change",
146			initial: ptr("theme = dark\n"),
147			steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
148				old := l.Get()
149				must(t, WriteFile(path, []byte("theme = light\n"), 0o600))
150				r.wait(t, "a change")
151				c, _ := r.last()
152				eq(t, c.Keys, []string{"theme"})
153				eq(t, c.Old, old)
154				eq(t, l.Get().Theme, "light")
155				eq(t, old.Theme, "dark") // a published config is never modified
156			},
157		},
158		{
159			name:    "comment and spacing only",
160			initial: ptr("theme = light\n"),
161			steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
162				must(t, os.WriteFile(path, []byte("# note\ntheme   =   light   # same\n"), 0o600))
163				r.quiet(t, "a save that changes no value")
164			},
165		},
166		{
167			name:    "broken file keeps the config",
168			initial: ptr("theme = light\n"),
169			steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
170				must(t, os.WriteFile(path, []byte("theme = light\n\xff\n"), 0o600))
171				r.wait(t, "an invalid file")
172				_, err := r.last()
173				if !errors.Is(err, ErrNotUTF8) {
174					t.Fatalf("err = %v", err)
175				}
176				eq(t, l.Get().Theme, "light")
177			},
178		},
179		{
180			name:    "removed file keeps the config",
181			initial: ptr("theme = light\n"),
182			steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
183				must(t, os.Remove(path))
184				r.wait(t, "a removal")
185				_, err := r.last()
186				if !errors.Is(err, fs.ErrNotExist) {
187					t.Fatalf("err = %v", err)
188				}
189				eq(t, l.Get().Theme, "light")
190				must(t, os.WriteFile(path, []byte("theme = dark\n"), 0o600))
191				r.wait(t, "the file came back")
192				eq(t, l.Get().Theme, "dark")
193			},
194		},
195		{
196			name: "file created after start",
197			steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
198				eq(t, l.Get().Theme, "dark")
199				must(t, os.WriteFile(path, []byte("after = 5s\n"), 0o600))
200				r.wait(t, "creation")
201				eq(t, *l.Get(), liveConfig{"dark", 5 * time.Second})
202			},
203		},
204		{
205			name:    "repeated key with an invalid last value",
206			initial: ptr("after = 5s\nafter = bad\n"),
207			steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
208				eq(t, l.Get().After, 5*time.Second)
209				must(t, WriteFile(path, []byte("after = 6s\nafter = bad\n"), 0o600))
210				r.wait(t, "a change of the valid value")
211				eq(t, l.Get().After, 6*time.Second)
212				c, _ := r.last()
213				eq(t, c.Keys, []string{"after"})
214			},
215		},
216		{
217			name:    "warnings are reported with the change",
218			initial: ptr("after = 1s\n"),
219			steps: func(t *testing.T, path string, l *Live[liveConfig], r *liveRecorder) {
220				must(t, WriteFile(path, []byte("after = 2s\ntheme = blue\n"), 0o600))
221				r.wait(t, "a change with a bad value")
222				c, _ := r.last()
223				eq(t, warningStrings(c.Warnings), []string{"line 2: theme: must be dark or light"})
224				eq(t, l.Get().After, 2*time.Second)
225			},
226		},
227	}
228	for _, tt := range tests {
229		t.Run(tt.name, func(t *testing.T) {
230			t.Parallel()
231			path := filepath.Join(t.TempDir(), "config")
232			if tt.initial != nil {
233				must(t, os.WriteFile(path, []byte(*tt.initial), 0o600))
234			}
235			r := newRecorder()
236			opts := r.opts()
237			ready := make(chan struct{})
238			opts.Watch.Ready = ready
239			l, _, err := Open(path, opts)
240			must(t, err)
241			ctx, cancel := context.WithCancel(context.Background())
242			done := make(chan error, 1)
243			go func() { done <- l.Run(ctx) }()
244			<-ready
245			tt.steps(t, path, l, r)
246			cancel()
247			must(t, <-done)
248		})
249	}
250}
251
252func TestLiveSeesSaveBetweenOpenAndRun(t *testing.T) {
253	path := writeTemp(t, t.TempDir(), "config", "theme = dark\n")
254	r := newRecorder()
255	l, _, err := Open(path, r.opts())
256	must(t, err)
257	must(t, os.WriteFile(path, []byte("theme = light\n"), 0o600))
258	ctx, cancel := context.WithCancel(context.Background())
259	defer cancel()
260	go func() { _ = l.Run(ctx) }()
261	r.wait(t, "a save before Run")
262	eq(t, l.Get().Theme, "light")
263}
264
265func TestLiveRunTwice(t *testing.T) {
266	l, _, err := Open(filepath.Join(t.TempDir(), "config"), LiveOptions[liveConfig]{})
267	must(t, err)
268	ctx, cancel := context.WithCancel(context.Background())
269	defer cancel()
270	go func() { _ = l.Run(ctx) }()
271	time.Sleep(50 * time.Millisecond)
272	if err := l.Run(ctx); err == nil {
273		t.Fatal("a second Run was accepted")
274	}
275}
276
277func TestLiveConcurrentGet(t *testing.T) {
278	path := writeTemp(t, t.TempDir(), "config", "after = 1s\n")
279	r := newRecorder()
280	l, _, err := Open(path, r.opts())
281	must(t, err)
282	ctx, cancel := context.WithCancel(context.Background())
283	defer cancel()
284	go func() { _ = l.Run(ctx) }()
285	var wg sync.WaitGroup
286	stop := make(chan struct{})
287	for range 4 {
288		wg.Go(func() {
289			for {
290				select {
291				case <-stop:
292					return
293				default:
294					if l.Get().Theme == "" {
295						t.Error("saw a half-built config")
296					}
297				}
298			}
299		})
300	}
301	for i := range 5 {
302		must(t, WriteFile(path, []byte("after = "+time.Duration(i+2).String()+"\n"), 0o600))
303		r.wait(t, "a change")
304	}
305	close(stop)
306	wg.Wait()
307}

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