feat/freeze-key 753c87f

fx_test.go RawHistoryFolder

1package selection
2
3import (
4	"context"
5	"testing"
6	"testing/synctest"
7	"time"
8
9	"github.com/stretchr/testify/assert"
10	"github.com/stretchr/testify/require"
11
12	"github.com/bnema/nefercap/internal/core"
13	"github.com/bnema/nefercap/internal/ports"
14)
15
16func TestTargetKeysShowToastAndPulse(t *testing.T) {
17	m := newTestModel(t)
18	for _, c := range []struct {
19		key  string
20		kind core.PickKind
21		text string
22	}{{"m", core.PickMonitor, toastMonitor}, {"r", core.PickRegion, toastRegion}, {"m", core.PickMonitor, toastMonitor}} {
23		assert.True(t, m.input(key(c.key)), c.key)
24		assert.Equal(t, c.kind, m.picker.Kind())
25		assert.Equal(t, c.text, m.toast.text)
26		assert.False(t, m.toast.warn)
27		assert.True(t, m.pulse.on)
28	}
29}
30
31func TestWorkspaceKeyUsesActiveWorkspace(t *testing.T) {
32	m, _ := newBareModel()
33	m.setWorkspaces([]ports.Workspace{{ID: 11, OutputID: 7, Active: true, Region: ports.Region{Y: 30, Width: 1920, Height: 1050}}})
34	m.resize(1920, 1080, 1)
35	m.input(key("w"))
36	assert.Equal(t, core.PickWorkspace, m.picker.Kind())
37	assert.Equal(t, toastWorkspace, m.toast.text)
38	assert.False(t, m.toast.warn)
39}
40
41func TestWorkspaceKeyWithoutWorkspaceFallsBackToMonitor(t *testing.T) {
42	m := newTestModel(t)
43	assert.True(t, m.input(key("w")))
44	assert.Equal(t, core.PickMonitor, m.picker.Kind())
45	assert.Equal(t, toastNoWS, m.toast.text)
46	assert.True(t, m.toast.warn)
47	m.input(key("Return"))
48	require.Equal(t, endAccepted, m.end)
49	res, ok, err := m.sess.outcome(context.Background())
50	require.NoError(t, err)
51	require.True(t, ok)
52	assert.Equal(t, core.PickMonitor, res.Kind)
53	assert.Equal(t, uint32(7), res.OutputID)
54}
55
56func TestTargetKeysIgnoredWhileDragging(t *testing.T) {
57	m := newTestModel(t)
58	m.input(button(100, 100, true))
59	m.input(motion(300, 300))
60	require.True(t, m.picker.Dragging())
61	for _, k := range []string{"m", "w", "r"} {
62		m.input(key(k))
63		assert.True(t, m.picker.Dragging())
64		assert.False(t, m.toast.on, k)
65	}
66}
67
68func TestTabShowsModeToastOnEveryOverlay(t *testing.T) {
69	sess, _ := testSession()
70	a := newModel(ports.Screenshot, testOutputs, 0, sess, false)
71	b := newModel(ports.Screenshot, testOutputs, 1, sess, false)
72	a.toggle, b.toggle = true, true
73	a.resize(1920, 1080, 1)
74	b.resize(1280, 720, 1)
75	a.input(key("Tab"))
76	assert.Equal(t, toastRec, a.toast.text)
77	b.syncMode() // what b's view does when woken
78	assert.Equal(t, toastRec, b.toast.text)
79	assert.True(t, b.pulse.on)
80	a.input(key("Tab"))
81	assert.Equal(t, toastShot, a.toast.text)
82}
83
84func TestToastHoldsFadesAndEnds(t *testing.T) {
85	m := newTestModel(t)
86	m.input(key("m"))
87	t0 := time.Unix(1000, 0)
88	m.animate(t0)
89	assert.Equal(t, 0, m.toast.step)
90	assert.Greater(t, m.scene().toast.w, 0.0)
91	m.animate(t0.Add(targetHold))
92	assert.Equal(t, 0, m.toast.step, "held at full opacity")
93	m.animate(t0.Add(targetHold + fxStep))
94	assert.Equal(t, 2, m.toast.step)
95	m.animate(t0.Add(targetHold + toastFade - time.Millisecond))
96	assert.Equal(t, len(opacityClasses)-1, m.toast.step)
97	m.animate(t0.Add(targetHold + toastFade))
98	assert.False(t, m.toast.on)
99	assert.Equal(t, frect{}, m.scene().toast)
100}
101
102func TestPulseThickensThenRestoresTheBorder(t *testing.T) {
103	m := newTestModel(t)
104	m.input(key("m"))
105	t0 := time.Unix(1000, 0)
106	m.animate(t0)
107	assert.Equal(t, edge+pulseExtra, m.edgeW)
108	assert.Equal(t, frect{0, 0, 1920, edge + pulseExtra}, m.scene().edges[0])
109	m.animate(t0.Add(pulseSpan / 2))
110	assert.Greater(t, m.edgeW, edge)
111	assert.Less(t, m.edgeW, edge+pulseExtra)
112	m.animate(t0.Add(pulseSpan))
113	assert.Equal(t, edge, m.edgeW)
114	assert.Equal(t, frect{0, 0, 1920, edge}, m.scene().edges[0])
115}
116
117func TestPulseNeverExceedsATinyRegion(t *testing.T) {
118	m := newTestModel(t)
119	m.input(button(100, 100, true))
120	m.input(motion(103, 103))
121	m.pulseBorder()
122	m.animate(time.Unix(1000, 0))
123	for _, e := range m.scene().edges {
124		assert.LessOrEqual(t, e.w, 3.0)
125		assert.LessOrEqual(t, e.h, 3.0)
126	}
127}
128
129func TestChipsShowModeAndTarget(t *testing.T) {
130	m := toggleModel(t, ports.Screenshot, true)
131	s := m.scene()
132	assert.Greater(t, s.modes[0].w, 0.0, "SHOT chip")
133	assert.Greater(t, s.modes[1].w, 0.0, "REC chip offered with Tab")
134	for i := range s.kinds {
135		assert.Greater(t, s.kinds[i].w, 0.0, targetTexts[i])
136	}
137	assert.Equal(t, s.badge.y, s.target.y, "one row on a wide output")
138	assert.False(t, overlaps(s.badge, s.target))
139	assert.False(t, overlaps(s.target, s.header))
140
141	fixed := toggleModel(t, ports.Record, false)
142	s = fixed.scene()
143	assert.Zero(t, s.modes[0].w, "without Tab only the current mode shows")
144	assert.Greater(t, s.modes[1].w, 0.0)
145
146	assert.Equal(t, "shot", modeClass(ports.Screenshot))
147	assert.Equal(t, "rec", modeClass(ports.Record))
148	assert.Contains(t, styleSheet, "box.chip.shot")
149	assert.Contains(t, styleSheet, "box.chip.rec")
150	assert.Contains(t, styleSheet, "box.edge.rec")
151}
152
153func TestChipsStayOnSurfaceAndClearOfHeader(t *testing.T) {
154	for _, w := range []int{3840, 1920, 1024, 640, 320, 200, 100, 40} {
155		m := toggleModel(t, ports.Screenshot, true)
156		m.resize(w, 480, 1)
157		s := m.scene()
158		for _, r := range append(s.modes[:], s.kinds[:]...) {
159			if r.w == 0 {
160				continue
161			}
162			assert.GreaterOrEqual(t, r.x, 0.0)
163			assert.LessOrEqual(t, r.x+r.w, float64(w)+1e-9, "width %d", w)
164			assert.LessOrEqual(t, r.y+r.h, 480.0)
165		}
166		if w >= 640 {
167			assert.False(t, overlaps(s.target, s.header), "target/header at %d", w)
168		}
169	}
170}
171
172func TestEffectsAllocations(t *testing.T) {
173	if raceEnabled {
174		t.Skip("the race detector adds allocations")
175	}
176	// Each run triggers both effects once, then draws them through the hold,
177	// the fade, the pulse decay and the end, like the frames of one key press.
178	m := newTestModel(t)
179	m.input(key("w")) // falls back to the monitor: later presses keep the legend
180	steps := [...]time.Duration{0, fxStep, pulseSpan / 2, pulseSpan, warnHold, warnHold + 4*fxStep, warnHold + toastFade}
181	now := time.Unix(1000, 0)
182	n := testing.AllocsPerRun(100, func() {
183		m.input(key("w"))
184		start := now
185		for _, d := range steps {
186			m.animate(start.Add(d))
187			_ = m.scene()
188		}
189		now = now.Add(time.Second)
190	})
191	assert.Zero(t, n, "toasts, pulses and their frames allocate nothing")
192
193	// A full HUD fade out and back in, with a toast fading along, allocates
194	// nothing per frame. The fade restarts every run, so warm-up does not
195	// leave it settled.
196	m.input(button(100, 100, true))
197	m.input(motion(400, 300))
198	n = testing.AllocsPerRun(100, func() {
199		m.hud = hudFade{}
200		m.showToast(toastMonitor, targetHold, false)
201		for i := range hudSteps + 1 {
202			m.animate(now.Add(time.Duration(i) * fxStep))
203			_ = m.scene()
204			_, _ = m.hudClass(), m.toastClass()
205		}
206		m.hud.hiding = false // as after a rejected release
207		for i := range hudSteps + 1 {
208			m.animate(now.Add(time.Second + time.Duration(i)*fxStep))
209			_ = m.scene()
210			_, _ = m.hudClass(), m.toastClass()
211		}
212		m.hud.hiding = true
213		now = now.Add(2 * time.Second)
214	})
215	assert.Zero(t, n, "HUD fade frames allocate nothing")
216	m.input(motion(102, 102))
217	m.input(button(102, 102, false))
218	m.animate(now.Add(time.Minute))
219	m.animate(now.Add(2 * time.Minute))
220	assert.False(t, m.toast.on)
221	assert.False(t, m.pulse.on)
222
223	n = testing.AllocsPerRun(100, func() {
224		m.input(key("r"))
225		m.input(key("m"))
226	})
227	t.Logf("target switch allocs/run: %v", n)
228	// Measured on main before the effects: 3 (two legends and a size label).
229	assert.LessOrEqual(t, n, 3.0, "effects add nothing to a target switch")
230}
231
232func TestAnimatorWakesOnlyWhileEffectsRun(t *testing.T) {
233	synctest.Test(t, func(t *testing.T) {
234		a := newAnimator()
235		wake := make(chan struct{}, 1)
236		ctx, cancel := context.WithCancel(t.Context())
237		done := make(chan struct{})
238		go func() { a.run(ctx, wake); close(done) }()
239
240		time.Sleep(10 * fxStep)
241		synctest.Wait()
242		assert.Empty(t, wake, "an idle animator never wakes the overlay")
243
244		a.extend(time.Now().Add(2 * fxStep))
245		time.Sleep(fxStep)
246		synctest.Wait()
247		require.Len(t, wake, 1, "a running effect wakes the overlay")
248		<-wake
249
250		time.Sleep(5 * fxStep) // past the end and its final frame
251		synctest.Wait()
252		for len(wake) > 0 {
253			<-wake
254		}
255		time.Sleep(10 * fxStep)
256		synctest.Wait()
257		assert.Empty(t, wake, "an ended effect stops waking the overlay")
258
259		cancel()
260		synctest.Wait()
261		select {
262		case <-done:
263		default:
264			t.Fatal("animator did not stop with its context")
265		}
266	})
267}
268
269func TestNilAnimatorIsSafe(t *testing.T) {
270	var a *animator
271	a.extend(time.Now())
272}
273
274func TestChipStatesFollowModeAndTarget(t *testing.T) {
275	assert.Equal(t, "SHOT", badgeText(ports.Screenshot))
276	assert.Equal(t, "REC", badgeText(ports.Record))
277	m := toggleModel(t, ports.Screenshot, true)
278	s := m.scene()
279	assert.Equal(t, [2]string{"shot", "idle"}, s.modeState)
280	assert.Equal(t, [3]string{"shot", "idle", "off"}, s.kindState, "region active, no workspace here")
281	m.input(key("m"))
282	m.input(key("Tab"))
283	s = m.scene()
284	assert.Equal(t, [2]string{"idle", "rec"}, s.modeState)
285	assert.Equal(t, [3]string{"idle", "rec", "off"}, s.kindState)
286}
287
288func TestDragFadesTheHUDOutAndBackIn(t *testing.T) {
289	m := toggleModel(t, ports.Screenshot, true)
290	m.grid = true
291	m.layoutGrid()
292	t0 := time.Unix(1000, 0)
293	m.input(button(100, 100, true))
294	m.animate(t0)
295	assert.Equal(t, 0, m.hud.level, "a press alone keeps the HUD")
296
297	m.input(motion(400, 300))
298	require.True(t, m.picker.Dragging())
299	m.input(key("Tab")) // its toast fades along with the HUD
300	levels := []int{}
301	for i := range hudSteps + 1 {
302		m.animate(t0.Add(time.Duration(i) * fxStep))
303		levels = append(levels, m.hud.level)
304	}
305	assert.Equal(t, []int{1, 2, 3, 4, 4}, levels, "one step per frame, then hidden")
306	s := m.scene()
307	assert.True(t, m.hudHidden())
308	assert.Equal(t, frect{}, s.header)
309	assert.Equal(t, frect{}, s.footer)
310	assert.Equal(t, frect{}, s.toast)
311	assert.Equal(t, [2]frect{}, s.modes)
312	assert.Equal(t, [3]frect{}, s.kinds)
313	assert.Greater(t, s.edges[0].w, 0.0, "the border stays")
314	assert.Greater(t, s.dim[0].w, 0.0, "the dimming stays")
315	assert.Greater(t, s.label.w, 0.0, "the size stays")
316	assert.Greater(t, s.grid[0].h, 0.0, "the guides stay")
317
318	// Releasing a too-small drag keeps picking: the HUD fades back in.
319	m.input(motion(103, 103))
320	m.input(button(103, 103, false))
321	require.Equal(t, endNone, m.end)
322	t1 := t0.Add(time.Second)
323	levels = levels[:0]
324	for i := range hudSteps + 1 {
325		m.animate(t1.Add(time.Duration(i) * fxStep))
326		levels = append(levels, m.hud.level)
327	}
328	assert.Equal(t, []int{3, 2, 1, 0, 0}, levels)
329	assert.Greater(t, m.scene().header.w, 0.0)
330	assert.Equal(t, "o0", m.hudClass())
331}
332
333func TestShortDragReversesTheFadeWithoutAJump(t *testing.T) {
334	m := newTestModel(t)
335	t0 := time.Unix(1000, 0)
336	m.input(button(100, 100, true))
337	m.input(motion(400, 300))
338	m.animate(t0)
339	m.animate(t0.Add(fxStep))
340	require.Equal(t, 2, m.hud.level)
341	m.input(motion(103, 103))
342	m.input(button(103, 103, false))
343	m.animate(t0.Add(2 * fxStep))
344	assert.Equal(t, 1, m.hud.level, "fades back from where it was")
345}
346
347func TestFadingHUDKeepsTheDragLabelInPlace(t *testing.T) {
348	m := toggleModel(t, ports.Screenshot, true)
349	m.input(button(100, 50, true))
350	m.input(motion(300, 250))
351	m.animate(time.Unix(1000, 0)) // level 1: chips still drawn
352	assert.Equal(t, frect{100, 50 - labelH - 4, labelW, labelH}, m.scene().label)
353}
354
355func TestToastFadesWithTheHUD(t *testing.T) {
356	m := newTestModel(t)
357	t0 := time.Unix(1000, 0)
358	m.input(key("m"))
359	m.animate(t0)
360	require.Equal(t, "o0", m.toastClass())
361
362	m.input(button(100, 100, true))
363	m.input(motion(400, 300))
364	classes := []string{}
365	for i := range hudSteps - 1 {
366		m.animate(t0.Add(time.Duration(i) * fxStep))
367		classes = append(classes, m.toastClass())
368		assert.Greater(t, m.scene().toast.w, 0.0, "drawn until the HUD is gone")
369	}
370	assert.Equal(t, []string{"o3", "o5", "o7"}, classes)
371	m.animate(t0.Add((hudSteps - 1) * fxStep))
372	assert.True(t, m.hudHidden())
373	assert.Equal(t, frect{}, m.scene().toast)
374
375	m.input(motion(103, 103))
376	m.input(button(103, 103, false))
377	t1 := t0.Add(5 * fxStep)
378	m.animate(t1)
379	assert.Equal(t, "o7", m.toastClass(), "comes back faint, not at once")
380	m.animate(t1.Add(fxStep))
381	assert.Equal(t, "o5", m.toastClass())
382}

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 2

Tags 9

↑↓ moveenter openesc close