ui_presentation_test.go RawHistoryFolder

1package client
2
3import (
4	"context"
5	"testing"
6	"time"
7
8	"github.com/bnema/vev/internal/adapters/uiterm"
9	"github.com/bnema/vev/internal/domain"
10	"github.com/bnema/vev/internal/ports"
11	"github.com/stretchr/testify/require"
12)
13
14// newPresentationTestUI builds one UI service over a real VT terminal with no
15// foreground bound, exactly as a composition does before any attachment.
16func newPresentationTestUI(t *testing.T) (*UI, *uiterm.Terminal) {
17	t.Helper()
18	ctx, cancel := context.WithCancel(context.Background())
19	t.Cleanup(cancel)
20	terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: 8, Rows: 2}}, "")
21	require.NoError(t, err)
22	t.Cleanup(terminal.Close)
23	return NewUI(terminal, systemClock{}), terminal
24}
25
26// TestUIPublishPresentationUnattachedShape pins the published context of the two
27// unattached presentations: the run's stable handle and the status alone. Picker
28// and Connecting carry no session identity, no committed output boundary, and no
29// actionable generation, and publishing them never advances the internal action
30// generation counter, so no caller can ever fence an action against an invented
31// generation.
32func TestUIPublishPresentationUnattachedShape(t *testing.T) {
33	ui, _ := newPresentationTestUI(t)
34	for _, status := range []ports.UIPresentationStatus{ports.UIStatusPicker, ports.UIStatusConnecting} {
35		t.Run(string(status), func(t *testing.T) {
36			require.NoError(t, ui.PublishPresentation(status))
37			snapshot, err := ui.Capture(ui.Handle())
38			require.NoError(t, err)
39			require.Equal(t, ports.UIContext{AttachmentHandle: ui.Handle(), Status: status}, snapshot.Context)
40			ui.mu.Lock()
41			generation := ui.generation
42			ui.mu.Unlock()
43			require.Zero(t, generation, "an unattached publication never allocates an action generation")
44		})
45	}
46
47	// Attached is never publishable through the composition seam: only the
48	// admitted attachment foreground may publish it.
49	require.Error(t, ui.PublishPresentation(ports.UIStatusAttached))
50}
51
52// TestUIActionsRefusedOutsideAttached pins that keys and text are attachment
53// actions: they are refused with unavailable while the published presentation is
54// Picker or Connecting, even when a generation-shaped request is supplied.
55func TestUIActionsRefusedOutsideAttached(t *testing.T) {
56	ui, _ := newPresentationTestUI(t)
57	for _, status := range []ports.UIPresentationStatus{ports.UIStatusPicker, ports.UIStatusConnecting} {
58		t.Run(string(status), func(t *testing.T) {
59			require.NoError(t, ui.PublishPresentation(status))
60			for _, request := range []ports.UIActionRequest{
61				{Attachment: ui.Handle(), Generation: 1, Keys: []string{"Escape"}},
62				{Attachment: ui.Handle(), Generation: 1, Text: "hello"},
63			} {
64				_, err := ui.Action(t.Context(), request)
65				var uiErr *ports.UIError
66				require.ErrorAs(t, err, &uiErr)
67				require.Equal(t, ports.UIErrUnavailable, uiErr.Code)
68			}
69		})
70	}
71}
72
73// TestUICaptureAndWaitWorkInEveryPresentation pins that capture and wait are
74// available in all three presentations: an unattached presentation is a state a
75// caller may observe and wait for, and a wait may name it as its predicate.
76func TestUICaptureAndWaitWorkInEveryPresentation(t *testing.T) {
77	ui, _ := newPresentationTestUI(t)
78	picker := ports.UIStatusPicker
79	require.NoError(t, ui.PublishPresentation(picker))
80	require.NotEmpty(t, presentationCapture(t, ui).Context.AttachmentHandle)
81	matched, err := ui.Wait(t.Context(), ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &picker}})
82	require.NoError(t, err)
83	require.Equal(t, picker, matched.Context.Status)
84
85	connecting := ports.UIStatusConnecting
86	require.NoError(t, ui.PublishPresentation(connecting))
87	require.NotEmpty(t, presentationCapture(t, ui).Context.AttachmentHandle)
88	matched, err = ui.Wait(t.Context(), ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &connecting}})
89	require.NoError(t, err)
90	require.Equal(t, connecting, matched.Context.Status)
91	require.Zero(t, matched.Context.Generation)
92
93	attached := ports.UIStatusAttached
94	require.NoError(t, ui.state.(ports.UIOutputTransaction).PublishContext(ports.UIContext{
95		AttachmentHandle: ui.Handle(), Generation: 7, Status: attached, OutputEpoch: 1, OutputState: 1, ViewPublication: 1,
96	}))
97	require.NotEmpty(t, presentationCapture(t, ui).Context.AttachmentHandle)
98	matched, err = ui.Wait(t.Context(), ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &attached}})
99	require.NoError(t, err)
100	require.Equal(t, attached, matched.Context.Status)
101	require.Equal(t, uint64(7), matched.Context.Generation)
102}
103
104func presentationCapture(t *testing.T, ui *UI) ports.UISnapshot {
105	t.Helper()
106	snapshot, err := ui.Capture(ui.Handle())
107	require.NoError(t, err)
108	return snapshot
109}
110
111// TestUIWaitWakesOnPresentationOnlyPublication pins that a wait predicate is
112// woken by a presentation-only publication, not merely by daemon frames: the UI
113// observation signal covers committed contexts.
114func TestUIWaitWakesOnPresentationOnlyPublication(t *testing.T) {
115	ui, _ := newPresentationTestUI(t)
116	ctx, cancel := context.WithCancel(t.Context())
117	done := make(chan struct{})
118	go func() { defer close(done); ui.Observe(ctx) }()
119	t.Cleanup(func() {
120		cancel()
121		select {
122		case <-done:
123		case <-time.After(time.Second):
124			t.Error("the observation worker did not stop")
125		}
126	})
127
128	connecting := ports.UIStatusConnecting
129	result := make(chan ports.UIWaitResult, 1)
130	go func() {
131		matched, err := ui.Wait(t.Context(), ports.UIWaitRequest{Attachment: ui.Handle(), Expect: ports.UIExpect{Status: &connecting}})
132		if err == nil {
133			result <- matched
134		}
135	}()
136	require.Eventually(t, func() bool {
137		ui.mu.Lock()
138		waits := ui.waits
139		ui.mu.Unlock()
140		return waits == 1
141	}, time.Second, time.Millisecond, "the wait was never admitted")
142	require.NoError(t, ui.PublishPresentation(connecting))
143	select {
144	case matched := <-result:
145		require.Equal(t, connecting, matched.Context.Status)
146	case <-time.After(time.Second):
147		t.Fatal("a presentation-only publication did not wake the wait")
148	}
149}

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