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}