feat/hints a471fa4

source_test.go RawHistoryFolder

1package atspi
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"testing"
8	"time"
9
10	"github.com/stretchr/testify/mock"
11	"github.com/stretchr/testify/require"
12
13	"github.com/bnema/neferwl/internal/ports"
14)
15
16const (
17	stActive  = 1 << stateActive
18	stShowing = 1 << stateShowing
19)
20
21// tree answers calls like an app: registry with one app (:1.5, pid 77) with
22// one showing frame whose links sit at window-relative extents.
23func answerTree(frameExt [4]int32, matches []ref, ext map[string][4]int32, roles map[string]uint32, noCollection bool) func(time.Time, time.Duration, []call) []reply {
24	return func(_ time.Time, _ time.Duration, calls []call) []reply {
25		out := make([]reply, len(calls))
26		for i, c := range calls {
27			switch {
28			case c.op == opChildren && c.name == registryName:
29				out[i].refs = []ref{{":1.5", "/root"}, {":1.9", "/root"}}
30			case c.op == opPID:
31				out[i].u = map[string]uint32{":1.5": 77, ":1.9": 99}[c.name]
32			case c.op == opChildren && c.path == "/root":
33				out[i].refs = []ref{{":1.5", "/frame"}}
34			case c.op == opRole && c.path == "/frame":
35				out[i].u = roleFrame
36			case c.op == opState && c.path == "/frame":
37				out[i].state = stShowing | stActive
38			case c.op == opExtents && c.path == "/frame":
39				out[i].rect = frameExt
40			case c.op == opMatches:
41				if noCollection {
42					out[i].err = &busError{name: "org.freedesktop.DBus.Error.UnknownMethod"}
43				} else {
44					out[i].refs = matches
45				}
46			case c.op == opChildren && c.path == "/frame":
47				out[i].refs = matches
48			case c.op == opChildren:
49			case c.op == opRole:
50				out[i].u = roles[c.path]
51			case c.op == opState:
52				out[i].state = stShowing | 1<<stateVisible
53			case c.op == opExtents:
54				out[i].rect = ext[c.path]
55			}
56		}
57		return out
58	}
59}
60
61func TestTargetsCoversWindowAndConverts(t *testing.T) {
62	tr := newMocktransport(t)
63	matches := []ref{{":1.5", "/a"}, {":1.5", "/b"}, {":1.5", "/zero"}}
64	tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree(
65		[4]int32{4, 30, 800, 600}, matches,
66		map[string][4]int32{"/a": {14, 130, 50, 16}, "/b": {104, 230, 20, 20}, "/zero": {5, 5, 0, 0}},
67		map[string]uint32{"/a": roleLink, "/b": rolePushButton, "/zero": roleLink}, false))
68	s := &Source{tr: tr}
69
70	windows := []ports.HintWindow{
71		{ID: 1, PID: 77, Rect: ports.Rect{X: 100, Y: 200, W: 800, H: 600}, Focused: true},
72		{ID: 2, PID: 5, Rect: ports.Rect{X: 0, Y: 0, W: 50, H: 50}},
73	}
74	targets, covered, err := s.Targets(context.Background(), windows, false)
75	require.NoError(t, err)
76	require.Equal(t, map[ports.WindowID]bool{1: true}, covered)
77	require.Equal(t, []ports.HintTarget{
78		{Rect: ports.Rect{X: 110, Y: 300, W: 50, H: 16}, Kind: ports.HintLink, Window: 1},
79		{Rect: ports.Rect{X: 200, Y: 400, W: 20, H: 20}, Kind: ports.HintButton, Window: 1},
80	}, targets)
81}
82
83func TestTargetsLinksOnlyDropsButtons(t *testing.T) {
84	tr := newMocktransport(t)
85	matches := []ref{{":1.5", "/a"}, {":1.5", "/b"}}
86	tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree(
87		[4]int32{0, 0, 800, 600}, matches,
88		map[string][4]int32{"/a": {14, 130, 50, 16}, "/b": {104, 230, 20, 20}},
89		map[string]uint32{"/a": roleLink, "/b": rolePushButton}, false))
90	s := &Source{tr: tr}
91	targets, _, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, PID: 77, Rect: ports.Rect{W: 800, H: 600}}}, true)
92	require.NoError(t, err)
93	require.Len(t, targets, 1)
94	require.Equal(t, ports.HintLink, targets[0].Kind)
95}
96
97func TestTargetsWalkWithoutCollection(t *testing.T) {
98	tr := newMocktransport(t)
99	matches := []ref{{":1.5", "/a"}}
100	tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree(
101		[4]int32{0, 0, 800, 600}, matches,
102		map[string][4]int32{"/a": {14, 130, 50, 16}},
103		map[string]uint32{"/a": roleLink}, true))
104	s := &Source{tr: tr}
105	targets, covered, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, PID: 77, Rect: ports.Rect{W: 800, H: 600}}}, false)
106	require.NoError(t, err)
107	require.Len(t, targets, 1)
108	require.True(t, covered[1])
109}
110
111func TestTargetsNoPIDMatchCoversNothing(t *testing.T) {
112	tr := newMocktransport(t)
113	tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree([4]int32{}, nil, nil, nil, false))
114	s := &Source{tr: tr}
115	targets, covered, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, PID: 1234, Rect: ports.Rect{W: 8, H: 6}}}, false)
116	require.NoError(t, err)
117	require.Empty(t, targets)
118	require.Empty(t, covered)
119}
120
121func TestTargetsWithoutPIDsAsksNothing(t *testing.T) {
122	s := &Source{tr: newMocktransport(t)}
123	_, covered, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: 8, H: 6}}}, false)
124	require.NoError(t, err)
125	require.Empty(t, covered)
126}
127
128func TestCoverageRequiresLinkOrDocument(t *testing.T) {
129	for _, collection := range []bool{true, false} {
130		for _, role := range []uint32{rolePushButton, roleDocumentFrame, roleDocumentWeb, roleLink} {
131			t.Run(fmt.Sprintf("collection=%t/role=%d", collection, role), func(t *testing.T) {
132				tr := newMocktransport(t)
133				tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree(
134					[4]int32{0, 0, 800, 600}, []ref{{":1.5", "/a"}}, map[string][4]int32{"/a": {10, 20, 30, 40}}, map[string]uint32{"/a": role}, !collection))
135				s := &Source{tr: tr}
136				targets, covered, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, PID: 77, Rect: ports.Rect{W: 800, H: 600}}}, false)
137				require.NoError(t, err)
138				require.Equal(t, role != rolePushButton, covered[1])
139				if isDocument(role) {
140					require.Empty(t, targets)
141				} else {
142					require.Len(t, targets, 1)
143				}
144			})
145		}
146	}
147}
148
149var oneWindow = []ports.HintWindow{{ID: 1, PID: 77, Rect: ports.Rect{W: 800, H: 600}}}
150
151// New does not touch the bus; the first Targets call connects, once.
152func TestConnectsLazilyOnFirstTargets(t *testing.T) {
153	tr := newMocktransport(t)
154	tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree(
155		[4]int32{0, 0, 800, 600}, []ref{{":1.5", "/a"}}, map[string][4]int32{"/a": {1, 1, 9, 9}}, map[string]uint32{"/a": roleLink}, false))
156	c := newMockconnector(t)
157	c.EXPECT().Connect(mock.Anything).Return(tr, nil).Once()
158	s := &Source{dial: c}
159	for range 2 {
160		targets, _, err := s.Targets(context.Background(), oneWindow, false)
161		require.NoError(t, err)
162		require.Len(t, targets, 1)
163	}
164}
165
166// Without PIDs nothing is asked and nothing is connected.
167func TestNoConnectWithoutWindows(t *testing.T) {
168	s := &Source{dial: newMockconnector(t)}
169	_, _, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1}}, false)
170	require.NoError(t, err)
171}
172
173// A failed connection is retried no sooner than retryEvery; meanwhile no
174// targets are returned.
175func TestConnectRetriesWithBackoff(t *testing.T) {
176	c := newMockconnector(t)
177	c.EXPECT().Connect(mock.Anything).Return(nil, errors.New("down")).Once()
178	s := &Source{dial: c}
179	for range 3 {
180		targets, covered, err := s.Targets(context.Background(), oneWindow, false)
181		require.NoError(t, err)
182		require.Empty(t, targets)
183		require.Empty(t, covered)
184	}
185	tr := newMocktransport(t)
186	tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree(
187		[4]int32{0, 0, 800, 600}, []ref{{":1.5", "/a"}}, map[string][4]int32{"/a": {1, 1, 9, 9}}, map[string]uint32{"/a": roleLink}, false))
188	c.EXPECT().Connect(mock.Anything).Return(tr, nil).Once()
189	s.retryAt = time.Now().Add(-time.Second)
190	targets, _, err := s.Targets(context.Background(), oneWindow, false)
191	require.NoError(t, err)
192	require.Len(t, targets, 1)
193}
194
195// A broken connection is closed and dropped; the next attempt reconnects.
196func TestBrokenConnectionIsDropped(t *testing.T) {
197	tr := newMocktransport(t)
198	tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
199		func(_ time.Time, _ time.Duration, calls []call) []reply {
200			out := make([]reply, len(calls))
201			for i := range out {
202				out[i].err = ErrBroken
203			}
204			return out
205		}).Once()
206	tr.EXPECT().Close().Return(nil).Once()
207	c := newMockconnector(t)
208	c.EXPECT().Connect(mock.Anything).Return(tr, nil).Once()
209	s := &Source{dial: c}
210	_, _, err := s.Targets(context.Background(), oneWindow, false)
211	require.Error(t, err)
212	require.Nil(t, s.tr)
213	targets, _, err := s.Targets(context.Background(), oneWindow, false)
214	require.NoError(t, err)
215	require.Empty(t, targets) // backoff: no new attempt yet
216}
217
218const testWait = 20 * time.Millisecond
219
220// blockedConnector returns a connector whose Connect blocks until release is
221func blockedConnector(t *testing.T, release <-chan struct{}, tr transport, err error, times int) *mockconnector {
222	t.Helper()
223	c := newMockconnector(t)
224	c.EXPECT().Connect(mock.Anything).RunAndReturn(func(context.Context) (transport, error) {
225		<-release
226		return tr, err
227	}).Times(times)
228	return c
229}
230
231// A silent service cannot hold Targets beyond the wait, is not doubled while
232// in flight, and its late transport is used.
233func TestConnectIsBoundedAndNotDoubled(t *testing.T) {
234	release := make(chan struct{})
235	tr := newMocktransport(t)
236	tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree(
237		[4]int32{0, 0, 800, 600}, []ref{{":1.5", "/a"}}, map[string][4]int32{"/a": {1, 1, 9, 9}}, map[string]uint32{"/a": roleLink}, false))
238	c := blockedConnector(t, release, tr, nil, 1)
239	s := &Source{dial: c, wait: testWait}
240	t0 := time.Now()
241	targets, _, err := s.Targets(context.Background(), oneWindow, false)
242	require.NoError(t, err)
243	require.Empty(t, targets)
244	require.Less(t, time.Since(t0), testWait+200*time.Millisecond)
245	require.NotNil(t, s.attempt)
246	// Still in flight: no second Connect (Times(1)), and no blocking.
247	targets, _, err = s.Targets(context.Background(), oneWindow, false)
248	require.NoError(t, err)
249	require.Empty(t, targets)
250	// It connects late: the next call uses it.
251	close(release)
252	require.Eventually(t, func() bool { return len(s.attempt) == 1 }, time.Second, time.Millisecond)
253	targets, _, err = s.Targets(context.Background(), oneWindow, false)
254	require.NoError(t, err)
255	require.Len(t, targets, 1)
256	require.Nil(t, s.attempt)
257}
258
259// An abandoned attempt that fails applies the backoff, then one new attempt.
260func TestAbandonedAttemptErrorBacksOff(t *testing.T) {
261	release := make(chan struct{})
262	tr := newMocktransport(t)
263	tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree(
264		[4]int32{0, 0, 800, 600}, []ref{{":1.5", "/a"}}, map[string][4]int32{"/a": {1, 1, 9, 9}}, map[string]uint32{"/a": roleLink}, false))
265	c := newMockconnector(t)
266	c.EXPECT().Connect(mock.Anything).RunAndReturn(func(context.Context) (transport, error) {
267		<-release
268		return nil, errors.New("down")
269	}).Once()
270	c.EXPECT().Connect(mock.Anything).Return(tr, nil).Once()
271	s := &Source{dial: c, wait: testWait}
272	targets, _, err := s.Targets(context.Background(), oneWindow, false)
273	require.NoError(t, err)
274	require.Empty(t, targets)
275	close(release)
276	require.Eventually(t, func() bool { return len(s.attempt) == 1 }, time.Second, time.Millisecond)
277	// The error is collected; the backoff runs from its finish time.
278	targets, _, err = s.Targets(context.Background(), oneWindow, false)
279	require.NoError(t, err)
280	require.Empty(t, targets)
281	require.Nil(t, s.attempt)
282	require.WithinDuration(t, time.Now().Add(retryEvery), s.retryAt, time.Second)
283	targets, _, err = s.Targets(context.Background(), oneWindow, false) // backoff: no Connect
284	require.NoError(t, err)
285	require.Empty(t, targets)
286	s.retryAt = time.Now().Add(-time.Second)
287	targets, _, err = s.Targets(context.Background(), oneWindow, false)
288	require.NoError(t, err)
289	require.Len(t, targets, 1)
290}
291
292// Close with an attempt in flight closes the late transport; afterwards
293// Targets finds nothing and never connects.
294func TestCloseReapsLateTransport(t *testing.T) {
295	release := make(chan struct{})
296	closed := make(chan struct{})
297	tr := newMocktransport(t)
298	tr.EXPECT().Close().RunAndReturn(func() error { close(closed); return nil }).Once()
299	c := blockedConnector(t, release, tr, nil, 1)
300	s := &Source{dial: c, wait: testWait}
301	_, _, err := s.Targets(context.Background(), oneWindow, false)
302	require.NoError(t, err)
303	require.NoError(t, s.Close())
304	close(release)
305	select {
306	case <-closed:
307	case <-time.After(5 * time.Second):
308		t.Fatal("late transport not closed")
309	}
310	targets, covered, err := s.Targets(context.Background(), oneWindow, false)
311	require.NoError(t, err)
312	require.Empty(t, targets)
313	require.Empty(t, covered)
314	require.NoError(t, s.Close()) // idempotent
315}

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 23

Tags 10

↑↓ moveenter openesc close