v0.3.0 99dc266

pane_rearrange_test.go RawHistoryFolder

1package daemon
2
3import (
4	"context"
5	"io"
6	"maps"
7	"sync"
8	"testing"
9	"time"
10
11	"github.com/stretchr/testify/require"
12
13	"github.com/bnema/vev/internal/domain"
14	"github.com/bnema/vev/internal/ports"
15	portsmocks "github.com/bnema/vev/internal/ports/mocks"
16	"github.com/bnema/vev/internal/usecase/keys"
17	"github.com/bnema/vev/internal/usecase/layout"
18)
19
20func TestMutateTargetLayoutPublishesOnlyChangedCandidates(t *testing.T) {
21	tests := []struct {
22		name            string
23		mutate          func(*layout.Tree, domain.Rect) (bool, error)
24		wantChanged     bool
25		wantGeneration  uint64
26		wantSnapshotGen uint64
27		wantResizes     bool
28	}{
29		{
30			name: "changed candidate publishes once",
31			mutate: func(candidate *layout.Tree, area domain.Rect) (bool, error) {
32				return candidate.ConsumeOrExpelPane("pane-1", layout.Right, area)
33			},
34			wantChanged:     true,
35			wantGeneration:  1,
36			wantSnapshotGen: 1,
37			wantResizes:     true,
38		},
39		{
40			name: "unchanged candidate is discarded",
41			mutate: func(candidate *layout.Tree, _ domain.Rect) (bool, error) {
42				candidate.Focus = "pane-2"
43				return false, nil
44			},
45		},
46	}
47
48	for _, tt := range tests {
49		t.Run(tt.name, func(t *testing.T) {
50			h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
51			before := h.tab.tree.Clone()
52
53			changed, err := h.daemon.mutateTargetLayoutChanged(h.target("pane-1"), true, tt.mutate)
54
55			require.NoError(t, err)
56			require.Equal(t, tt.wantChanged, changed)
57			h.tab.mu.Lock()
58			require.Equal(t, tt.wantGeneration, h.tab.layoutGeneration)
59			if tt.wantChanged {
60				require.NotEqual(t, before, h.tab.tree)
61			} else {
62				require.Equal(t, before, h.tab.tree, "changed=false must not publish a mutated candidate")
63			}
64			h.tab.mu.Unlock()
65			h.session.snapshotMu.Lock()
66			require.Equal(t, tt.wantSnapshotGen, h.session.snapshotGeneration)
67			h.session.snapshotMu.Unlock()
68			if tt.wantResizes {
69				require.NotZero(t, h.totalResizes())
70			} else {
71				require.Zero(t, h.totalResizes())
72			}
73		})
74	}
75}
76
77func TestMutateTargetLayoutRejectsStaleTargetsBeforeMutation(t *testing.T) {
78	tests := []struct {
79		name  string
80		stale func(*paneRearrangeHarness) daemonActionTarget
81	}{
82		{
83			name: "session",
84			stale: func(h *paneRearrangeHarness) daemonActionTarget {
85				target := h.target("pane-1")
86				h.daemon.sessions[h.session.id] = &session{sessionCore: sessionCore{id: h.session.id}}
87				return target
88			},
89		},
90		{
91			name: "tab",
92			stale: func(h *paneRearrangeHarness) daemonActionTarget {
93				target := h.target("pane-1")
94				h.session.tabs = []*tab{newTab(nil, h.tab.size)}
95				return target
96			},
97		},
98		{
99			name: "pane identity",
100			stale: func(h *paneRearrangeHarness) daemonActionTarget {
101				target := h.target("pane-1")
102				h.tab.panes[target.pane.id] = newPane(target.pane.id, nil, h.tab.size)
103				return target
104			},
105		},
106	}
107
108	for _, tt := range tests {
109		t.Run(tt.name, func(t *testing.T) {
110			h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
111			before := h.tab.tree.Clone()
112			called := false
113
114			changed, err := h.daemon.mutateTargetLayoutChanged(tt.stale(h), true, func(*layout.Tree, domain.Rect) (bool, error) {
115				called = true
116				return true, nil
117			})
118
119			require.False(t, changed)
120			require.ErrorIs(t, err, layout.ErrNotFound)
121			require.False(t, called, "stale identity must be rejected before invoking the mutation")
122			require.Equal(t, before, h.tab.tree)
123			require.Zero(t, h.tab.layoutGeneration)
124			require.Zero(t, h.totalResizes())
125		})
126	}
127}
128
129func TestPaneRearrangeResizeRunsWithoutDaemonStateLocks(t *testing.T) {
130	h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
131	locksAvailable := make(chan bool, 8)
132	for _, tracked := range h.ptys {
133		tracked.onResize = func() { locksAvailable <- h.stateLocksAvailable() }
134	}
135
136	done := make(chan error, 1)
137	go func() {
138		done <- (daemonActions{d: h.daemon}).Run(daemonActionRequest{
139			kind: daemonActionConsumeOrExpelPane, target: h.target("pane-1"), direction: layout.Right,
140		})
141	}()
142
143	select {
144	case err := <-done:
145		require.NoError(t, err)
146	case <-time.After(2 * time.Second):
147		t.Fatal("pane rearrange blocked while PTY.Resize attempted to re-enter daemon state locks")
148	}
149	require.NotZero(t, h.totalResizes())
150	close(locksAvailable)
151	for available := range locksAvailable {
152		require.True(t, available, "PTY.Resize ran while a daemon, session, tab, or pane state lock was held")
153	}
154}
155
156func TestPaneRearrangeUsesExplicitTargetAndPreservesPaneIdentity(t *testing.T) {
157	h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
158	h.tab.mu.Lock()
159	h.tab.tree.Focus = "pane-3"
160	before := make(map[layout.PaneID]*pane, len(h.tab.panes))
161	maps.Copy(before, h.tab.panes)
162	h.tab.mu.Unlock()
163
164	err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{
165		kind: daemonActionConsumeOrExpelPane, target: h.target("pane-1"), direction: layout.Right,
166	})
167
168	require.NoError(t, err)
169	h.tab.mu.Lock()
170	require.Equal(t, layout.PaneID("pane-1"), h.tab.tree.Focus, "the explicit non-focused target becomes focused")
171	require.Len(t, h.tab.panes, len(before))
172	for id, p := range before {
173		require.Same(t, p, h.tab.panes[id], "pane %s identity changed", id)
174	}
175	h.tab.mu.Unlock()
176	h.factory.AssertNotCalled(t, "Open")
177	for _, tracked := range h.ptys {
178		require.Zero(t, tracked.closeCount())
179	}
180}
181
182func TestPaneRearrangeEdgeNoopHasExactSilentInvariants(t *testing.T) {
183	h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
184	ac := &attachedClient{}
185	ac.setSession(h.session)
186	h.session.mu.Lock()
187	h.session.registerAttachmentLocked(ac)
188	h.session.mu.Unlock()
189	invalidations := make(chan renderInvalidation, 2)
190	rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
191	rc.attach(ac)
192	h.session.installRenderCoordinator(rc)
193	before := h.snapshot()
194	request := daemonActionRequest{
195		kind: daemonActionConsumeOrExpelPane, target: h.target("pane-1"), direction: layout.Left,
196	}
197
198	err := (daemonActions{d: h.daemon}).Run(request)
199	require.ErrorIs(t, err, errDaemonActionNoChange, "the daemon action must retain the package no-change sentinel")
200
201	require.Equal(t, before, h.snapshot(), "edge no-op changed tree, generations, snapshot state, pane identity, geometry, or VT size")
202	require.Zero(t, h.totalResizes())
203	h.factory.AssertNotCalled(t, "Open")
204	for _, tracked := range h.ptys {
205		require.Zero(t, tracked.closeCount())
206	}
207	require.Empty(t, h.daemon.notices.history())
208	requireNoInvalidation(t, invalidations)
209}
210
211func TestPaneRearrangeMapsUnsupportedAndTooSmallToUserNoticesAtomically(t *testing.T) {
212	tests := []struct {
213		name    string
214		size    domain.Size
215		tree    *layout.Tree
216		pane    layout.PaneID
217		dir     layout.Direction
218		cause   error
219		wantMsg string
220	}{
221		{
222			name: "unsupported layout",
223			size: domain.Size{Cols: 80, Rows: 22},
224			tree: &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Vertical, Children: []*layout.Node{
225				layout.NewLeaf("pane-1"),
226				{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3")}},
227			}}, Focus: "pane-1"},
228			pane: "pane-1", dir: layout.Right, cause: layout.ErrUnsupportedColumnLayout,
229			wantMsg: "pane rearrangement requires a column layout",
230		},
231		{
232			name: "too small",
233			size: domain.Size{Cols: 40, Rows: 5},
234			tree: &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Vertical, Children: []*layout.Node{
235				layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2"),
236			}}, Focus: "pane-1"},
237			pane: "pane-1", dir: layout.Right, cause: layout.ErrTooSmall,
238			wantMsg: "not enough space to rearrange pane",
239		},
240	}
241
242	for _, tt := range tests {
243		t.Run(tt.name, func(t *testing.T) {
244			h := newPaneRearrangeHarness(t, tt.size, tt.tree)
245			before := h.snapshot()
246
247			err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{
248				kind: daemonActionConsumeOrExpelPane, target: h.target(tt.pane), direction: tt.dir,
249			})
250
251			require.ErrorIs(t, err, tt.cause)
252			var userErr *domain.UserError
253			require.ErrorAs(t, err, &userErr)
254			require.Equal(t, domain.NoticeLayoutTooSmall, userErr.Code)
255			require.Equal(t, domain.NoticeWarn, userErr.Severity)
256			require.Equal(t, tt.wantMsg, userErr.Msg)
257			require.Equal(t, before, h.snapshot(), "failed rearrange must be atomic")
258			require.Zero(t, h.totalResizes())
259			h.factory.AssertNotCalled(t, "Open")
260			for _, tracked := range h.ptys {
261				require.Zero(t, tracked.closeCount())
262			}
263		})
264	}
265}
266
267func TestPaneRearrangeNoChangeAdapterSideEffects(t *testing.T) {
268	tests := []struct {
269		name                    string
270		invoke                  func(*paneRearrangeHarness, *attachedClient) error
271		wantCleanupInvalidation bool
272	}{
273		{
274			name: "palette cleans up its closed overlay",
275			invoke: func(h *paneRearrangeHarness, ac *attachedClient) error {
276				return (paletteExec{d: h.daemon, sess: h.session, ac: ac, redrawClosedPalette: true}).ConsumeOrExpelPaneLeft()
277			},
278			wantCleanupInvalidation: true,
279		},
280		{
281			name: "control remains silent",
282			invoke: func(h *paneRearrangeHarness, _ *attachedClient) error {
283				return (controlExec{d: h.daemon, sess: h.session, target: h.target("pane-1")}).ConsumeOrExpelPaneLeft()
284			},
285		},
286		{
287			name: "key remains silent",
288			invoke: func(h *paneRearrangeHarness, ac *attachedClient) error {
289				daemonKeyHandler{d: h.daemon, ac: ac}.Action(keys.ActionConsumeOrExpelPaneLeft, nil)
290				return nil
291			},
292		},
293	}
294
295	for _, tt := range tests {
296		t.Run(tt.name, func(t *testing.T) {
297			h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
298			ac := &attachedClient{}
299			ac.setSession(h.session)
300			h.session.mu.Lock()
301			h.session.registerAttachmentLocked(ac)
302			h.session.mu.Unlock()
303			invalidations := make(chan renderInvalidation, 2)
304			rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
305			rc.attach(ac)
306			h.session.installRenderCoordinator(rc)
307			before := h.snapshot()
308
309			require.NoError(t, tt.invoke(h, ac))
310
311			require.Equal(t, before, h.snapshot(), "adapter no-change must not publish layout state")
312			require.Empty(t, h.daemon.notices.history())
313			if tt.wantCleanupInvalidation {
314				invalidation := awaitInvalidation(t, invalidations)
315				require.Equal(t, "palette.go", invalidation.producer)
316				require.True(t, invalidation.reset)
317			}
318			requireNoInvalidation(t, invalidations)
319		})
320	}
321}
322
323type paneRearrangeHarness struct {
324	daemon  *Daemon
325	session *session
326	tab     *tab
327	panes   map[layout.PaneID]*pane
328	ptys    map[layout.PaneID]*paneRearrangePTY
329	factory *portsmocks.MockPTYFactory
330}
331
332type paneRearrangeSnapshot struct {
333	tree               *layout.Tree
334	layoutGeneration   uint64
335	snapshotGeneration uint64
336	snapshotDirty      bool
337	panes              map[layout.PaneID]*pane
338	rects              map[layout.PaneID]domain.Rect
339	screens            map[layout.PaneID]domain.Size
340}
341
342func newPaneRearrangeHarness(t *testing.T, size domain.Size, tree *layout.Tree) *paneRearrangeHarness {
343	t.Helper()
344	factory := portsmocks.NewMockPTYFactory(t)
345	d := newTestDaemon(t, factory, stubClock{})
346	ids := layout.LeafIDs(tree.Root)
347	require.NotEmpty(t, ids)
348	ptys := make(map[layout.PaneID]*paneRearrangePTY, len(ids))
349	panes := make(map[layout.PaneID]*pane, len(ids))
350	for _, id := range ids {
351		ptys[id] = &paneRearrangePTY{}
352	}
353	tb := newTab(ptys[ids[0]], size)
354	first := tb.panes["pane-1"]
355	delete(tb.panes, "pane-1")
356	first.id = ids[0]
357	panes[ids[0]] = first
358	tb.panes[ids[0]] = first
359	for _, id := range ids[1:] {
360		p := newPane(id, ptys[id], size)
361		panes[id] = p
362		tb.panes[id] = p
363	}
364	tb.tree = tree.Clone()
365	placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: size.Cols, Height: size.Rows})
366	require.True(t, ok, "test fixture layout must solve")
367	for _, placement := range placements {
368		p := tb.panes[placement.ID]
369		p.rect = placement.Content
370		p.screen.Resize(placement.Content.Width, placement.Content.Height)
371	}
372	tb.ctx, tb.cancel = contextWithTestCleanup(t, d.serveCtx)
373	for _, p := range tb.panes {
374		p.ctx, p.cancel = contextWithTestCleanup(t, tb.ctx)
375	}
376	sess := &session{sessionCore: sessionCore{id: "s", name: "work"}, cwd: "/work", tabs: []*tab{tb}, ctx: d.serveCtx, cancel: func() {}}
377	sess.snapEligible.Store(true)
378	d.sessions[sess.id] = sess
379	return &paneRearrangeHarness{daemon: d, session: sess, tab: tb, panes: panes, ptys: ptys, factory: factory}
380}
381
382func contextWithTestCleanup(t *testing.T, parent context.Context) (context.Context, context.CancelFunc) {
383	t.Helper()
384	ctx, cancel := context.WithCancel(parent)
385	t.Cleanup(cancel)
386	return ctx, cancel
387}
388
389func (h *paneRearrangeHarness) target(id layout.PaneID) daemonActionTarget {
390	return daemonActionTarget{session: h.session, tab: h.tab, pane: h.panes[id]}
391}
392
393func (h *paneRearrangeHarness) totalResizes() int {
394	total := 0
395	for _, tracked := range h.ptys {
396		total += tracked.resizeCount()
397	}
398	return total
399}
400
401func (h *paneRearrangeHarness) snapshot() paneRearrangeSnapshot {
402	h.tab.mu.Lock()
403	snapshot := paneRearrangeSnapshot{
404		tree:             h.tab.tree.Clone(),
405		layoutGeneration: h.tab.layoutGeneration,
406		panes:            make(map[layout.PaneID]*pane, len(h.tab.panes)),
407		rects:            make(map[layout.PaneID]domain.Rect, len(h.tab.panes)),
408		screens:          make(map[layout.PaneID]domain.Size, len(h.tab.panes)),
409	}
410	for id, p := range h.tab.panes {
411		snapshot.panes[id] = p
412		p.mu.Lock()
413		snapshot.rects[id] = p.rect
414		snapshot.screens[id] = domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}
415		p.mu.Unlock()
416	}
417	h.tab.mu.Unlock()
418	h.session.snapshotMu.Lock()
419	snapshot.snapshotGeneration = h.session.snapshotGeneration
420	snapshot.snapshotDirty = h.session.snapDirty.Load()
421	h.session.snapshotMu.Unlock()
422	return snapshot
423}
424
425func (h *paneRearrangeHarness) stateLocksAvailable() bool {
426	done := make(chan struct{})
427	go func() {
428		h.daemon.mu.Lock()
429		h.session.mu.Lock()
430		h.tab.mu.Lock()
431		for _, p := range h.panes {
432			p.mu.Lock()
433		}
434		for _, p := range h.panes {
435			p.mu.Unlock()
436		}
437		h.tab.mu.Unlock()
438		h.session.mu.Unlock()
439		h.daemon.mu.Unlock()
440		close(done)
441	}()
442	select {
443	case <-done:
444		return true
445	case <-time.After(250 * time.Millisecond):
446		return false
447	}
448}
449
450func threeColumnTree() *layout.Tree {
451	return &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
452		layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3"),
453	}}, Focus: "pane-1"}
454}
455
456type paneRearrangePTY struct {
457	mu       sync.Mutex
458	resizes  []domain.Size
459	closes   int
460	onResize func()
461}
462
463func (*paneRearrangePTY) Read([]byte) (int, error)    { return 0, io.EOF }
464func (*paneRearrangePTY) Write(b []byte) (int, error) { return len(b), nil }
465func (p *paneRearrangePTY) Resize(size domain.Size) error {
466	p.mu.Lock()
467	p.resizes = append(p.resizes, size)
468	hook := p.onResize
469	p.mu.Unlock()
470	if hook != nil {
471		hook()
472	}
473	return nil
474}
475func (p *paneRearrangePTY) Close() error {
476	p.mu.Lock()
477	p.closes++
478	p.mu.Unlock()
479	return nil
480}
481func (*paneRearrangePTY) Pid() int                     { return 0 }
482func (*paneRearrangePTY) ForegroundPgid() (int, error) { return 0, nil }
483
484func (p *paneRearrangePTY) resizeCount() int {
485	p.mu.Lock()
486	defer p.mu.Unlock()
487	return len(p.resizes)
488}
489
490func (p *paneRearrangePTY) closeCount() int {
491	p.mu.Lock()
492	defer p.mu.Unlock()
493	return p.closes
494}
495
496var _ ports.PTY = (*paneRearrangePTY)(nil)

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 12

Tags 11

↑↓ moveenter openesc close