1package uiterm
2
3import (
4 "context"
5 "errors"
6 "io"
7 "testing"
8 "time"
9
10 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/ports"
12)
13
14func TestTerminalPublishesOwnedSnapshotAtFlush(t *testing.T) {
15 terminal := newTestTerminal(t, 4, 2)
16 terminal.BeginOutput(ports.UIContext{AttachmentHandle: "attachment", Generation: 2, Status: ports.UIStatusAttached})
17 if _, err := terminal.Write([]byte("\x1b[1;31;48;2;1;2;3mA界")); err != nil {
18 t.Fatal(err)
19 }
20 if err := terminal.Flush(); err != nil {
21 t.Fatal(err)
22 }
23 if _, err := terminal.Snapshot(); err == nil {
24 t.Fatal("nested flush exposed an output transaction")
25 }
26 terminal.EndOutput(true)
27
28 snapshot, err := terminal.Snapshot()
29 if err != nil {
30 t.Fatal(err)
31 }
32 if snapshot.Revision != 1 || snapshot.Columns != 4 || snapshot.Rows != 2 {
33 t.Fatalf("snapshot metadata = %#v", snapshot)
34 }
35 if snapshot.Context.Generation != 2 || snapshot.Cells[0].Text != "A" || !snapshot.Cells[0].Style.Bold {
36 t.Fatalf("first cell/context = %#v / %#v", snapshot.Cells[0], snapshot.Context)
37 }
38 if snapshot.Cells[0].Style.Foreground.Kind != ports.UIColorIndexed || snapshot.Cells[0].Style.Foreground.Index != 1 {
39 t.Fatalf("foreground = %#v", snapshot.Cells[0].Style.Foreground)
40 }
41 if snapshot.Cells[0].Style.Background != (ports.UIColor{Kind: ports.UIColorRGB, R: 1, G: 2, B: 3}) {
42 t.Fatalf("background = %#v", snapshot.Cells[0].Style.Background)
43 }
44 if snapshot.Cells[1].Text != "界" || snapshot.Cells[1].Width != 2 || !snapshot.Cells[2].Continuation {
45 t.Fatalf("wide cells = %#v %#v", snapshot.Cells[1], snapshot.Cells[2])
46 }
47
48 snapshot.Cells[0].Text = "mutated"
49 again, err := terminal.Snapshot()
50 if err != nil {
51 t.Fatal(err)
52 }
53 if again.Cells[0].Text != "A" {
54 t.Fatal("caller mutated published snapshot")
55 }
56}
57
58func TestTerminalEnterRawUsesVisualInitialization(t *testing.T) {
59 terminal := newTestTerminal(t, 4, 2)
60 restore, err := terminal.EnterRaw()
61 if err != nil {
62 t.Fatal(err)
63 }
64 snapshot, err := terminal.Snapshot()
65 if err != nil {
66 t.Fatal(err)
67 }
68 if snapshot.AutoWrap || snapshot.Cursor.Visible {
69 t.Fatalf("enter modes = autoWrap %v cursorVisible %v", snapshot.AutoWrap, snapshot.Cursor.Visible)
70 }
71 if err := restore(); err != nil {
72 t.Fatal(err)
73 }
74 if err := restore(); err != nil {
75 t.Fatalf("second restore = %v", err)
76 }
77}
78
79func TestTerminalQueuesTerminalResponsesOutsideScreenWrite(t *testing.T) {
80 terminal := newTestTerminal(t, 4, 2)
81 if _, err := terminal.Write([]byte("\x1b[?7$p")); err != nil {
82 t.Fatal(err)
83 }
84
85 result := make(chan string, 1)
86 go func() {
87 buffer := make([]byte, len("\x1b[?7;1$y"))
88 _, err := io.ReadFull(terminal.In(), buffer)
89 if err != nil {
90 result <- err.Error()
91 return
92 }
93 result <- string(buffer)
94 }()
95 select {
96 case got := <-result:
97 if got != "\x1b[?7;1$y" {
98 t.Fatalf("response = %q", got)
99 }
100 case <-time.After(time.Second):
101 t.Fatal("terminal response was not drained")
102 }
103}
104
105func TestTerminalPublishesGeometryChangesImmediatelyOutsideOutputTransaction(t *testing.T) {
106 terminal := newTestTerminal(t, 4, 2)
107 terminal.ObserveTerminalResize(domain.Geometry{Size: domain.Size{Cols: 6, Rows: 3}})
108 snapshot, err := terminal.Snapshot()
109 if err != nil {
110 t.Fatal(err)
111 }
112 if snapshot.Columns != 6 || snapshot.Rows != 3 || snapshot.Revision == 0 {
113 t.Fatalf("resize snapshot = %#v", snapshot)
114 }
115}
116
117func TestTerminalRecoversAfterOversizedGeometry(t *testing.T) {
118 terminal := newTestTerminal(t, 4, 2)
119 terminal.ObserveTerminalResize(domain.Geometry{Size: domain.Size{Cols: MaxColumns + 1, Rows: 2}})
120 _, err := terminal.Snapshot()
121 var uiErr *ports.UIError
122 if !errors.As(err, &uiErr) || uiErr.Code != ports.UIErrCaptureTooLarge {
123 t.Fatalf("oversized Snapshot() error = %v, want capture_too_large", err)
124 }
125
126 terminal.ObserveTerminalResize(domain.Geometry{Size: domain.Size{Cols: 6, Rows: 3}})
127 snapshot, err := terminal.Snapshot()
128 if err != nil {
129 t.Fatal(err)
130 }
131 if snapshot.Columns != 6 || snapshot.Rows != 3 || snapshot.Revision == 0 {
132 t.Fatalf("recovered resize snapshot = %#v", snapshot)
133 }
134}
135
136func TestTerminalSignalsCaptureUnavailable(t *testing.T) {
137 terminal := newTestTerminal(t, 4, 2)
138 terminal.BeginOutput(ports.UIContext{})
139 _, _ = terminal.Write([]byte("data"))
140 terminal.EndOutput(false)
141 select {
142 case <-terminal.Changes():
143 case <-time.After(time.Second):
144 t.Fatal("capture unavailability did not signal Changes")
145 }
146}
147
148func TestTerminalFailedTransactionMakesCaptureUnavailable(t *testing.T) {
149 terminal := newTestTerminal(t, 4, 2)
150 terminal.BeginOutput(ports.UIContext{})
151 _, _ = terminal.Write([]byte("data"))
152 terminal.EndOutput(false)
153 if _, err := terminal.Snapshot(); !errors.Is(err, ports.ErrUIUnavailable) {
154 t.Fatalf("Snapshot error = %v", err)
155 }
156}
157
158func TestMirrorReportsOversizedGeometryWithoutRejectingTheTerminal(t *testing.T) {
159 ctx := context.Background()
160 mirror, err := NewMirror(ctx, domain.Geometry{Size: domain.Size{Cols: MaxColumns + 1, Rows: 2}}, "attachment")
161 if err != nil {
162 t.Fatal(err)
163 }
164 defer mirror.Close()
165
166 if _, err := mirror.Write([]byte("still interactive")); err != nil {
167 t.Fatalf("Write() error = %v", err)
168 }
169 if err := mirror.Flush(); err != nil {
170 t.Fatalf("Flush() error = %v", err)
171 }
172 _, err = mirror.Snapshot()
173 var uiErr *ports.UIError
174 if !errors.As(err, &uiErr) || uiErr.Code != ports.UIErrCaptureTooLarge {
175 t.Fatalf("Snapshot() error = %v, want capture_too_large", err)
176 }
177
178 validMirror, err := NewMirror(ctx, domain.Geometry{Size: domain.Size{Cols: 4, Rows: 2}}, "attachment")
179 if err != nil {
180 t.Fatal(err)
181 }
182 defer validMirror.Close()
183 validMirror.ObserveTerminalResize(domain.Geometry{Size: domain.Size{Cols: MaxColumns + 1, Rows: 2}})
184 if _, err := validMirror.Write([]byte("output while oversized")); err != nil {
185 t.Fatalf("oversized mirror Write() error = %v", err)
186 }
187 if err := validMirror.Flush(); err != nil {
188 t.Fatalf("oversized mirror Flush() error = %v", err)
189 }
190 validMirror.ObserveTerminalResize(domain.Geometry{Size: domain.Size{Cols: 4, Rows: 2}})
191 _, err = validMirror.Snapshot()
192 if !errors.As(err, &uiErr) || uiErr.Code != ports.UIErrCaptureTooLarge {
193 t.Fatalf("recovered mirror Snapshot() error = %v, want capture_too_large", err)
194 }
195}
196
197func TestTerminalRejectsInvalidGeometry(t *testing.T) {
198 tests := []domain.Geometry{
199 {Size: domain.Size{Cols: 0, Rows: 1}},
200 {Size: domain.Size{Cols: 1, Rows: 0}},
201 {Size: domain.Size{Cols: MaxColumns + 1, Rows: 1}},
202 {Size: domain.Size{Cols: 1, Rows: MaxRows + 1}},
203 }
204 for _, geometry := range tests {
205 if terminal, err := New(context.Background(), geometry, "handle"); err == nil {
206 terminal.Close()
207 t.Fatalf("New(%#v) succeeded", geometry)
208 }
209 }
210}
211
212func newTestTerminal(t *testing.T, columns, rows int) *Terminal {
213 t.Helper()
214 terminal, err := New(context.Background(), domain.Geometry{Size: domain.Size{Cols: columns, Rows: rows}}, "attachment")
215 if err != nil {
216 t.Fatal(err)
217 }
218 t.Cleanup(terminal.Close)
219 return terminal
220}
221
222// TestTerminalUnattachedPublicationKeepsStatedIdentity pins the published-context
223// shape rule at the terminal: only an Attached publication inherits a missing
224// handle or generation. A Picker or Connecting publication is recorded exactly
225// as stated, so an unattached capture never shows an old attachment's handle or
226// a generation no attachment committed.
227func TestTerminalUnattachedPublicationKeepsStatedIdentity(t *testing.T) {
228 terminal := newTestTerminal(t, 4, 2)
229
230 // Establish an attached context with a real handle and generation.
231 if err := terminal.PublishContext(ports.UIContext{AttachmentHandle: "attached-handle", Generation: 6, Status: ports.UIStatusAttached}); err != nil {
232 t.Fatal(err)
233 }
234
235 if err := terminal.PublishContext(ports.UIContext{AttachmentHandle: "picker-handle", Status: ports.UIStatusPicker}); err != nil {
236 t.Fatal(err)
237 }
238 snapshot, err := terminal.Snapshot()
239 if err != nil {
240 t.Fatal(err)
241 }
242 if snapshot.Context.AttachmentHandle != "picker-handle" || snapshot.Context.Generation != 0 || snapshot.Context.Status != ports.UIStatusPicker {
243 t.Fatalf("picker context = %#v", snapshot.Context)
244 }
245
246 // An Attached publication with a missing handle and generation still inherits
247 // them, exactly as the attachment foreground relies on.
248 if err := terminal.PublishContext(ports.UIContext{Status: ports.UIStatusAttached, OutputState: 3}); err != nil {
249 t.Fatal(err)
250 }
251 snapshot, err = terminal.Snapshot()
252 if err != nil {
253 t.Fatal(err)
254 }
255 if snapshot.Context.AttachmentHandle != "picker-handle" || snapshot.Context.Generation != 0 {
256 t.Fatalf("attached continuity context = %#v", snapshot.Context)
257 }
258}