v0.2.1 7e79979

workspaces_test.go RawHistoryFolder

1package wayland
2
3import (
4	"context"
5	"strconv"
6	"testing"
7	"time"
8
9	"github.com/stretchr/testify/require"
10
11	"github.com/bnema/nefercap/internal/adapters/wayland/testserver"
12	"github.com/bnema/nefercap/internal/ports"
13)
14
15func wsConfig(ws ...testserver.WorkspaceSpec) testserver.Config {
16	return testserver.Config{
17		Outputs:       []testserver.OutputSpec{{Name: "TEST-1", Width: 8, Height: 4, Scale: 1}},
18		HasWorkspaces: true, Workspaces: ws,
19	}
20}
21
22func listWorkspaces(t *testing.T, s *Source) []ports.Workspace {
23	t.Helper()
24	ws, err := s.Workspaces(context.Background())
25	require.NoError(t, err)
26	return ws
27}
28
29func TestWorkspacesAbsent(t *testing.T) {
30	s, _ := newSource(t, testserver.Config{})
31	require.Nil(t, listWorkspaces(t, s))
32	require.False(t, s.Capabilities().Workspaces)
33}
34
35func TestWorkspacesListing(t *testing.T) {
36	s, _ := newSource(t, wsConfig(
37		testserver.WorkspaceSpec{ID: "name:dev", Name: "dev", Output: "TEST-1", Active: true},
38		testserver.WorkspaceSpec{ID: "7f3a-12", Name: "2", Output: "TEST-1"},
39		testserver.WorkspaceSpec{ID: "7f3a-13", Name: "3", Output: "TEST-1", Hidden: true},
40	))
41	out := firstOutput(t, s)
42	ws := listWorkspaces(t, s)
43	require.Equal(t, []ports.Workspace{
44		{ID: 1, OutputID: out.ID, Name: "dev", Region: ports.Region{Width: 8, Height: 4}, Active: true},
45		{ID: 2, OutputID: out.ID, Name: "2", Region: ports.Region{Width: 8, Height: 4}},
46		{ID: 3, OutputID: out.ID, Name: "3", Region: ports.Region{Width: 8, Height: 4}},
47	}, ws, "id strings are interned into Source-local numbers, in announcement order")
48	require.Equal(t, ports.Capabilities{Workspaces: true}, s.Capabilities(), "no hidden workspaces without the extension")
49}
50
51func TestWorkspaceIdentityOpaqueAndTemporary(t *testing.T) {
52	s, _ := newSource(t, wsConfig(
53		testserver.WorkspaceSpec{ID: "name:main", Name: "main", Output: "TEST-1", Active: true},
54		testserver.WorkspaceSpec{Name: "tmp1", Output: "TEST-1"},
55		testserver.WorkspaceSpec{Name: "tmp2", Output: "TEST-1"},
56	))
57	ws := listWorkspaces(t, s)
58	require.Len(t, ws, 3)
59	seen := map[uint64]bool{}
60	for _, w := range ws {
61		require.NotZero(t, w.ID)
62		require.False(t, seen[w.ID], "identities are unique")
63		seen[w.ID] = true
64	}
65	again := listWorkspaces(t, s)
66	require.Equal(t, ws, again, "identities are stable for the connection")
67}
68
69func TestWorkspaceStateChangesAndRemoval(t *testing.T) {
70	s, srv := newSource(t, wsConfig(
71		testserver.WorkspaceSpec{ID: "name:a", Name: "a", Output: "TEST-1", Active: true},
72		testserver.WorkspaceSpec{ID: "7f3a-2", Name: "b", Output: "TEST-1"},
73	))
74	srv.SetActive("a", false)
75	srv.SetActive("b", true)
76	ws := listWorkspaces(t, s)
77	require.False(t, ws[0].Active)
78	require.True(t, ws[1].Active)
79	srv.RemoveWorkspace("a")
80	ws = listWorkspaces(t, s)
81	require.Len(t, ws, 1)
82	require.Equal(t, "b", ws[0].Name)
83}
84
85func TestWorkspaceCaptureActiveOnlyWithoutExtension(t *testing.T) {
86	s, srv := newSource(t, wsConfig(
87		testserver.WorkspaceSpec{ID: "name:a", Name: "a", Output: "TEST-1", Active: true},
88		testserver.WorkspaceSpec{ID: "7f3a-2", Name: "b", Output: "TEST-1"},
89	))
90	out := firstOutput(t, s)
91	f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, WorkspaceID: 1})
92	require.NoError(t, err)
93	require.Equal(t, [2]int{8, 4}, [2]int{f.Width, f.Height})
94	require.Equal(t, "output", srv.Stats().LastSource, "the active workspace is captured as its output")
95	_, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID, WorkspaceID: 2})
96	require.ErrorIs(t, err, ports.ErrWorkspaceUnavailable, "a hidden workspace is never captured as the output")
97	_, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID, WorkspaceID: 99})
98	require.ErrorIs(t, err, ports.ErrWorkspaceUnavailable)
99	_, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID, WorkspaceID: 1, Region: ports.Region{Width: 1, Height: 1}})
100	require.ErrorIs(t, err, ports.ErrInvalidRegion)
101}
102
103func TestWorkspaceCaptureUsesExtensionSource(t *testing.T) {
104	cfg := wsConfig(
105		testserver.WorkspaceSpec{ID: "name:a", Name: "a", Output: "TEST-1", Active: true},
106		testserver.WorkspaceSpec{ID: "7f3a-2", Name: "b", Output: "TEST-1", Hidden: true},
107	)
108	cfg.NeferwlSource = true
109	s, srv := newSource(t, cfg)
110	require.Equal(t, ports.Capabilities{Workspaces: true}, s.Capabilities())
111	out := firstOutput(t, s)
112	f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, WorkspaceID: 2})
113	require.NoError(t, err, "a hidden workspace is captured through the extension")
114	require.NoError(t, f.Validate())
115	require.Equal(t, "workspace", srv.Stats().LastSource)
116	// it follows the workspace: still captured after the active one changes
117	srv.SetActive("a", false)
118	srv.SetActive("b", true)
119	_, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID, WorkspaceID: 2})
120	require.NoError(t, err)
121	require.Equal(t, 1, srv.Stats().Sessions, "the same session serves the workspace")
122}
123
124// With NeferWL's extension a workspace lists its own frame, not the output.
125func TestWorkspaceRegionIsTheFrameFromTheExtension(t *testing.T) {
126	cfg := wsConfig(
127		testserver.WorkspaceSpec{ID: "name:a", Name: "a", Output: "TEST-1", Active: true, Frame: [4]int32{2, 1, 4, 2}},
128		testserver.WorkspaceSpec{ID: "7f3a-2", Name: "b", Output: "TEST-1", Hidden: true, Frame: [4]int32{0, 0, 8, 4}},
129		testserver.WorkspaceSpec{ID: "7f3a-3", Name: "c", Output: "TEST-1", Frame: [4]int32{6, 3, 9, 9}},
130		testserver.WorkspaceSpec{ID: "7f3a-4", Name: "d", Output: "TEST-1"},
131	)
132	cfg.NeferwlSource = true
133	s, srv := newSource(t, cfg)
134	regions := func() map[string]ports.Region {
135		m := map[string]ports.Region{}
136		for _, w := range listWorkspaces(t, s) {
137			m[w.Name] = w.Region
138		}
139		return m
140	}
141	require.Equal(t, map[string]ports.Region{
142		"a": {X: 2, Y: 1, Width: 4, Height: 2},
143		"b": {Width: 8, Height: 4},
144		"c": {X: 6, Y: 3, Width: 2, Height: 1}, // clipped to the output
145		"d": {Width: 8, Height: 4},             // no frame sent: the whole output
146	}, regions())
147	require.Equal(t, 4, srv.Stats().WorkspaceFrames)
148	// Listing again asks for nothing more, and a change is followed.
149	srv.SetFrame("a", [4]int32{1, 1, 3, 3})
150	require.Equal(t, ports.Region{X: 1, Y: 1, Width: 3, Height: 3}, regions()["a"])
151	require.Equal(t, 4, srv.Stats().WorkspaceFrames)
152	// A removed workspace releases its frame object.
153	srv.RemoveWorkspace("a")
154	require.NotContains(t, regions(), "a")
155	require.Eventually(t, func() bool { return srv.Stats().DestroyedWorkspaceFrames == 1 }, 2*time.Second, time.Millisecond)
156}
157
158// An off-screen frame, or one that is nowhere on the output, falls back to the
159// whole output rather than offering an empty workspace.
160func TestWorkspaceRegionFallsBackToTheOutput(t *testing.T) {
161	cfg := wsConfig(testserver.WorkspaceSpec{ID: "name:a", Name: "a", Output: "TEST-1", Active: true, Frame: [4]int32{20, 20, 4, 4}})
162	cfg.NeferwlSource = true
163	s, _ := newSource(t, cfg)
164	require.Equal(t, ports.Region{Width: 8, Height: 4}, listWorkspaces(t, s)[0].Region)
165}
166
167func TestWorkspaceRegionIsTheOutputWithoutTheExtension(t *testing.T) {
168	s, srv := newSource(t, wsConfig(testserver.WorkspaceSpec{ID: "name:a", Name: "a", Output: "TEST-1", Active: true, Frame: [4]int32{2, 1, 4, 2}}))
169	require.Equal(t, ports.Region{Width: 8, Height: 4}, listWorkspaces(t, s)[0].Region)
170	require.Zero(t, srv.Stats().WorkspaceFrames)
171}
172
173func TestWorkspaceLimit(t *testing.T) {
174	var specs []testserver.WorkspaceSpec
175	for i := range maxWorkspaces + 4 {
176		specs = append(specs, testserver.WorkspaceSpec{ID: strconv.Itoa(i + 1), Name: "w", Output: "TEST-1"})
177	}
178	s, _ := newSource(t, wsConfig(specs...))
179	require.Nil(t, listWorkspaces(t, s), "an inventory beyond the bound is rejected, not truncated")
180}
181
182// The id event is an opaque string: it is interned, never parsed.
183func TestWorkspaceIdentityIsInterned(t *testing.T) {
184	ws := &workspaceState{interned: map[string]uint64{}, byID: map[uint64]*workspace{}}
185	a, b := ws.intern("7f3a-5"), ws.intern("name:5")
186	require.NotEqual(t, a, b, "both id forms are just strings")
187	require.Equal(t, a, ws.intern("7f3a-5"), "the same string keeps its identity")
188	require.Equal(t, uint64(1), a, "identities come from one counter")
189	require.Equal(t, uint64(2), b)
190	require.NotContains(t, []uint64{a, b}, ws.intern("5"), "a bare decimal is another string too")
191	ws.byID[a] = &workspace{}
192	dup := ws.intern("7f3a-5")
193	require.NotEqual(t, a, dup, "an id held by a live workspace is not shared")
194	require.Equal(t, uint64(4), dup)
195	t1, t2 := ws.intern(""), ws.intern("")
196	require.NotEqual(t, t1, t2, "workspaces without an id are temporary")
197	delete(ws.byID, a)
198	require.Equal(t, a, ws.intern("7f3a-5"), "a returning workspace gets its identity back")
199	require.False(t, validName("a\nb"))
200	require.False(t, validName("\u202e"))
201	require.True(t, validName("Bureau 2"))
202}
203
204func TestWorkspaceInternTableIsBounded(t *testing.T) {
205	ws := &workspaceState{interned: map[string]uint64{}, byID: map[uint64]*workspace{}}
206	for i := range maxInterned + 10 {
207		ws.intern("p-" + strconv.Itoa(i))
208	}
209	require.Len(t, ws.interned, maxInterned, "the string table is bounded")
210}
211
212// Hotplug: an output that goes away leaves no stale group entry, and no
213// workspace is listed on it.
214func TestOutputHotplugPurgesWorkspaceAndXdgState(t *testing.T) {
215	cfg := testserver.Config{
216		Outputs: []testserver.OutputSpec{
217			{Name: "A", Width: 8, Height: 4, Scale: 1, LogicalWidth: 8, LogicalHeight: 4},
218			{Name: "B", Width: 16, Height: 8, Scale: 1, LogicalWidth: 16, LogicalHeight: 8},
219		},
220		HasWorkspaces: true,
221		Workspaces:    []testserver.WorkspaceSpec{{ID: "name:a", Name: "a", Output: "A", Active: true}},
222	}
223	s, srv := newSource(t, cfg)
224	ws := listWorkspaces(t, s)
225	require.Len(t, ws, 2, "one entry per output of the group")
226	srv.RemoveOutput("B")
227	ws = listWorkspaces(t, s)
228	require.Len(t, ws, 1)
229	require.Equal(t, "A", outputNameOf(t, s, ws[0].OutputID))
230	for _, g := range s.ws.groups {
231		require.Len(t, g.outputs, 1, "the removed output proxy is purged from the group")
232	}
233	require.Equal(t, 1, srv.Stats().DestroyedXdgOutputs, "the zxdg_output_v1 of the removed output is destroyed")
234}
235
236func outputNameOf(t *testing.T, s *Source, id uint32) string {
237	t.Helper()
238	outs, err := s.Outputs(context.Background())
239	require.NoError(t, err)
240	for _, o := range outs {
241		if o.ID == id {
242			return o.Name
243		}
244	}
245	return ""
246}

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