v0.7.2 71ebe61

runtime_test.go RawHistoryFolder

1package ui
2
3import (
4	"context"
5	"testing"
6	"time"
7)
8
9func waitEntered(t *testing.T, entered <-chan struct{}) {
10	t.Helper()
11	select {
12	case <-entered:
13	case <-time.After(time.Second):
14		t.Fatal("view did not start")
15	}
16}
17
18func TestIdentityAndEvents(t *testing.T) {
19	r := newRuntime()
20	order := []string{"a", "b"}
21	var received []string
22	view := func(f *Frame) {
23		root := f.Root()
24		for _, key := range order {
25			if root.Button(key, Key(key)).Activated() {
26				received = append(received, key)
27			}
28		}
29	}
30	r.Build(view)
31	id := r.Target(1)
32	order = []string{"b", "a"}
33	r.Queue(inputEvent{Target: id, Kind: "activate"})
34	r.Queue(inputEvent{Target: id, Kind: "activate"})
35	if err := r.Wait(context.Background()); err != nil {
36		t.Fatal(err)
37	}
38	builds := 0
39	r.Build(func(f *Frame) { builds++; view(f) })
40	if builds != 1 || len(received) != 1 || received[0] != "b" {
41		t.Fatalf("batch: %v builds %d target=%+v first=%+v", received, builds, id, r.Target(0))
42	}
43	r.Redraw()
44	r.Build(view)
45	if len(received) != 1 {
46		t.Fatalf("expired event: %v", received)
47	}
48}
49func TestImplicitIdentity(t *testing.T) {
50	r := newRuntime()
51	r.Build(func(f *Frame) { f.Root().Box().Text("x") })
52	first := r.Target(0)
53	r.Redraw()
54	r.Build(func(f *Frame) { f.Root().Box().Text("y") })
55	if !first.same(r.Target(0)) {
56		t.Fatal("position/type identity changed")
57	}
58	r.Redraw()
59	r.Build(func(f *Frame) {
60		f.Root().Row()
61		if debugDiagnostics && len(f.Diagnostics()) != 1 {
62			t.Fatalf("missing unkeyed change diagnostic: %v", f.Diagnostics())
63		}
64	})
65	if first.same(r.Target(0)) {
66		t.Fatal("type omitted from identity")
67	}
68}
69func TestStaleAndDuplicates(t *testing.T) {
70	r := newRuntime()
71	var old Node
72	r.Build(func(f *Frame) {
73		old = f.Root()
74		old.Box(Key("dup"))
75		old.Box(Key("dup"))
76		if debugDiagnostics && len(f.Diagnostics()) != 1 {
77			t.Fatal(f.Diagnostics())
78		}
79	})
80	if old.valid() {
81		t.Fatal("stale node accepted")
82	}
83	if debugDiagnostics {
84		defer func() {
85			if recover() == nil {
86				t.Error("missing stale panic")
87			}
88		}()
89		old.Box()
90	}
91}
92func TestWake(t *testing.T) {
93	r := newRuntime()
94	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
95	defer cancel()
96	r.Redraw()
97	if err := r.Wait(ctx); err != nil {
98		t.Fatal(err)
99	}
100}
101
102// TestCommittedLayoutSurvivesNextBuild guards the layout arena rotation: the
103// view reads the committed layout while the next one is built, and a frame
104// that does not commit must leave it intact.
105func TestCommittedLayoutSurvivesNextBuild(t *testing.T) {
106	r := newRuntime()
107	view := func(label string) func(*Frame) {
108		return func(f *Frame) { f.Root().Box(Key(label), Inline("width:10px;height:10px;background:#fff")) }
109	}
110	// Warm both arenas: a fresh one allocates detached results, which would
111	// hide an overwrite.
112	for _, label := range []string{"w1", "w2", "a"} {
113		r.Redraw()
114		if !r.Build(view(label)) {
115			t.Fatalf("warm-up build %q skipped", label)
116		}
117	}
118	snapshot := func() (string, int) {
119		leaf := r.output.Tree
120		for len(leaf.Children) > 0 {
121			leaf = leaf.Children[0]
122		}
123		return leaf.ID, len(r.output.Display)
124	}
125	id, cmds := snapshot()
126	// Building the next frame must not write into the previous output, which
127	// the view, hit-testing and the renderer read until the commit.
128	prevTree, prevDisplay := r.output.Tree, r.output.Display
129	first := prevDisplay[0].ID
130	r.Redraw()
131	r.Build(view("b"))
132	leaf := prevTree
133	for len(leaf.Children) > 0 {
134		leaf = leaf.Children[0]
135	}
136	if leaf.ID != id || len(prevDisplay) != cmds || prevDisplay[0].ID != first {
137		t.Fatalf("next build overwrote the previous layout: leaf %q (want %q), %d commands (want %d), first %q (want %q)", leaf.ID, id, len(prevDisplay), cmds, prevDisplay[0].ID, first)
138	}
139	idB, _ := snapshot()
140	if idB == id {
141		t.Fatalf("second build not committed: %q", idB)
142	}
143	// A panicking view does not commit: the output stays readable and intact.
144	func() {
145		defer func() { _ = recover() }()
146		r.Redraw()
147		r.Build(func(f *Frame) { view("c")(f); panic("view failure") })
148	}()
149	if got, _ := snapshot(); got != idB {
150		t.Fatalf("panicked frame replaced the output: %q, want %q", got, idB)
151	}
152	// A failed layout writes into the free arena but commits nothing.
153	r.Redraw()
154	if r.Build(func(f *Frame) {
155		n := f.Root()
156		for range 300 { // deeper than layout allows
157			n = n.Box()
158		}
159	}) {
160		t.Fatal("too deep layout committed")
161	}
162	if got, _ := snapshot(); got != idB {
163		t.Fatalf("failed layout replaced the output: %q, want %q", got, idB)
164	}
165	// Rotation still alternates after both failures: each build keeps the
166	// previously committed layout intact.
167	for _, label := range []string{"d", "e"} {
168		prevTree, prevDisplay, before := r.output.Tree, r.output.Display, idB
169		first, cmds := prevDisplay[0].ID, len(prevDisplay)
170		r.Redraw()
171		if !r.Build(view(label)) {
172			t.Fatalf("build %q skipped", label)
173		}
174		leaf := prevTree
175		for len(leaf.Children) > 0 {
176			leaf = leaf.Children[0]
177		}
178		if leaf.ID != before || len(prevDisplay) != cmds || prevDisplay[0].ID != first {
179			t.Fatalf("build %q overwrote the previous layout: leaf %q (want %q), first command %q (want %q)", label, leaf.ID, before, prevDisplay[0].ID, first)
180		}
181		idB, _ = snapshot()
182	}
183}
184
185func TestConcurrentBuildDefersCommit(t *testing.T) {
186	r := newRuntime()
187	entered := make(chan struct{})
188	release := make(chan struct{})
189	firstDone := make(chan bool, 1)
190	secondDone := make(chan bool, 1)
191	go func() { firstDone <- r.Build(func(f *Frame) { f.Root().Box(Key("first")); close(entered); <-release }) }()
192	waitEntered(t, entered)
193	go func() {
194		secondDone <- r.Build(func(f *Frame) { t.Error("busy view executed"); f.Root().Box(Key("second")) })
195	}()
196	select {
197	case built := <-secondDone:
198		if built {
199			t.Fatal("busy build committed")
200		}
201	case <-time.After(time.Second):
202		t.Fatal("concurrent Build blocked")
203	}
204	if r.Target(0) != nil {
205		t.Fatal("uncommitted tree visible")
206	}
207	close(release)
208	select {
209	case built := <-firstDone:
210		if !built {
211			t.Fatal("first build skipped")
212		}
213	case <-time.After(time.Second):
214		t.Fatal("first build did not finish")
215	}
216	if id := r.Target(0); id == nil || id.key != "first" {
217		t.Fatalf("commit out of order: %+v", id)
218	}
219	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
220	defer cancel()
221	if err := r.Wait(ctx); err != nil {
222		t.Fatalf("lost wake: %v", err)
223	}
224	if !r.Build(func(f *Frame) { f.Root().Box(Key("second")) }) {
225		t.Fatal("redraw skipped")
226	}
227	if id := r.Target(0); id == nil || id.key != "second" {
228		t.Fatalf("follow-up commit missing: %+v", id)
229	}
230}
231
232func TestReentrantBuildSchedulesQueuedEvents(t *testing.T) {
233	r := newRuntime()
234	r.Build(func(f *Frame) { f.Root().Button("target", Key("target")) })
235	target := r.Target(0)
236	r.Redraw()
237	finished := make(chan bool, 1)
238	go func() {
239		finished <- r.Build(func(f *Frame) {
240			f.Root().Button("target", Key("target"))
241			r.Queue(inputEvent{Target: target, Kind: "activate"})
242			if r.Build(func(*Frame) { t.Error("reentrant view executed") }) {
243				t.Error("reentrant build committed")
244			}
245		})
246	}()
247	select {
248	case built := <-finished:
249		if !built {
250			t.Fatal("outer build skipped")
251		}
252	case <-time.After(time.Second):
253		t.Fatal("reentrant Build deadlocked")
254	}
255	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
256	defer cancel()
257	if err := r.Wait(ctx); err != nil {
258		t.Fatalf("follow-up wake lost: %v", err)
259	}
260	if !r.Build(func(f *Frame) {
261		if !f.Root().Button("target", Key("target")).Activated() {
262			t.Error("queued event not delivered")
263		}
264	}) {
265		t.Fatal("follow-up skipped")
266	}
267	assertOneSettleFrame(t, r, func(f *Frame) { f.Root().Button("target", Key("target")) })
268}
269
270// assertOneSettleFrame checks that an event batch schedules exactly one
271// event-free follow-up build, and that this build schedules nothing more.
272func assertOneSettleFrame(t *testing.T, r *runtime, view func(*Frame)) {
273	t.Helper()
274	settled := false
275	if !r.Build(func(f *Frame) {
276		settled = true
277		if len(f.events) != 0 {
278			t.Error("settle frame carried events")
279		}
280		view(f)
281	}) || !settled {
282		t.Fatal("no settle frame after events")
283	}
284	if r.Build(func(*Frame) { t.Error("extra view executed") }) {
285		t.Fatal("extra redraw after settle frame")
286	}
287}
288
289func TestQueueDuringConstruction(t *testing.T) {
290	r := newRuntime()
291	r.Build(func(f *Frame) { f.Root().Button("target", Key("target")) })
292	target := r.Target(0)
293	r.Queue(inputEvent{Target: target, Kind: "activate"})
294	entered := make(chan struct{})
295	release := make(chan struct{})
296	result := make(chan bool, 1)
297	go func() {
298		result <- r.Build(func(f *Frame) {
299			root := f.Root()
300			if !root.Button("target", Key("target")).Activated() {
301				t.Error("first batch missing")
302			}
303			close(entered)
304			<-release
305		})
306	}()
307	waitEntered(t, entered)
308	r.Queue(inputEvent{Target: target, Kind: "activate"})
309	r.Redraw()
310	close(release)
311	if !<-result {
312		t.Fatal("first batch skipped")
313	}
314	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
315	defer cancel()
316	if err := r.Wait(ctx); err != nil {
317		t.Fatalf("lost wake during build: %v", err)
318	}
319	if !r.Build(func(f *Frame) {
320		if !f.Root().Button("target", Key("target")).Activated() {
321			t.Error("event queued during construction lost")
322		}
323	}) {
324		t.Fatal("second batch skipped")
325	}
326	assertOneSettleFrame(t, r, func(f *Frame) { f.Root().Button("target", Key("target")) })
327}
328
329func TestCheckboxElementAndActivation(t *testing.T) {
330	r := newRuntime()
331	checked := false
332	build := func(f *Frame) ChangeEvent { root := f.Root(); return root.Checkbox("accept", &checked, Key("accept")) }
333	r.Build(func(f *Frame) {
334		if build(f).Changed() {
335			t.Fatal("spurious change")
336		}
337	})
338	if e := r.committed.children[0]; e.typ != "checkbox" || e.role != "checkbox" || e.text != "accept" {
339		t.Fatalf("wrong semantics: %+v", e)
340	}
341	r.Queue(inputEvent{Target: r.Target(0), Kind: "activate"})
342	r.Build(func(f *Frame) {
343		ev := build(f)
344		changed := ev.Changed()
345		if !changed || ev.Changed() != changed || !checked {
346			t.Fatal("checkbox activation missing")
347		}
348	})
349	if e := r.committed.children[0]; e.typ != "checkbox" || e.role != "checkbox" {
350		t.Fatalf("wrong semantics after activation: %+v", e)
351	}
352	r.Queue(inputEvent{Target: r.Target(0), Kind: "activate"})
353	r.Build(func(f *Frame) { f.Root().Checkbox("accept", &checked, Key("accept"), Disabled(true)) })
354	if !checked {
355		t.Fatal("disabled checkbox mutated model")
356	}
357}
358
359func TestHeadingLevel(t *testing.T) {
360	r := newRuntime()
361	r.Build(func(f *Frame) { f.Root().Header().Heading("title", Level(1), Class("logo")) })
362	e := r.committed.children[0].children[0]
363	if e.typ != "heading" || e.role != "heading" || e.level != 1 || e.text != "title" || len(e.classes) != 1 || e.classes[0] != "logo" {
364		t.Fatalf("heading metadata: %+v", e)
365	}
366	defer func() {
367		if recover() == nil {
368			t.Error("invalid level accepted")
369		}
370	}()
371	Level(7)
372}

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 12

↑↓ moveenter openesc close