返回 DeepSeek-Reasonix
chat_tui_clipboard_fallback_test.go
根目录 / internal / cli / chat_tui_clipboard_fallback_test.go
1 package cli
2
3 import (
4 "errors"
5 "fmt"
6 "runtime"
7 "strings"
8 "testing"
9
10 tea "charm.land/bubbletea/v2"
11
12 "reasonix/internal/control"
13 "reasonix/internal/i18n"
14 )
15
16 func stubEmptyImageClipboard(t *testing.T, text string, imageErrs ...error) {
17 t.Helper()
18 imageErr := control.ErrNoClipboardImage
19 if len(imageErrs) > 0 {
20 imageErr = imageErrs[0]
21 }
22 prevImage := readClipboardImage
23 prevText := readNativeClipboardText
24 t.Cleanup(func() {
25 readClipboardImage = prevImage
26 readNativeClipboardText = prevText
27 })
28 readClipboardImage = func() (string, error) { return "", imageErr }
29 readNativeClipboardText = func() (string, error) { return text, nil }
30 }
31
32 func imagePasteKey() tea.KeyPressMsg {
33 if runtime.GOOS == "windows" {
34 return tea.KeyPressMsg{Code: 'v', Mod: tea.ModAlt}
35 }
36 return tea.KeyPressMsg{Code: 'v', Mod: tea.ModCtrl}
37 }
38
39 func TestCtrlVUnsupportedImageFallsBackToText(t *testing.T) {
40 setLocalClipboardSession(t)
41 imageErr := fmt.Errorf("read clipboard image: unsupported image/bmp: %w", errors.Join(control.ErrNoClipboardImage, control.ErrUnsupportedClipboardImage))
42 stubEmptyImageClipboard(t, "https://example.com/x", imageErr)
43
44 m := newComposerMouseTestTUI(t, 60, 16)
45 m.input.SetValue("before ")
46
47 next, cmd := m.Update(imagePasteKey())
48 m = next.(chatTUI)
49 if cmd == nil {
50 t.Fatal("image paste shortcut produced no clipboard command")
51 }
52 next, cmd = m.Update(cmd())
53 m = next.(chatTUI)
54
55 if got := strings.Join(m.transcript, "\n"); strings.Contains(got, "wl-paste") {
56 t.Fatalf("empty image clipboard surfaced a tooling notice:\n%s", got)
57 }
58 result := clipboardTextPasteResultFromCmd(t, cmd)
59 next, _ = m.Update(result)
60 m = next.(chatTUI)
61
62 if got, want := m.input.Value(), "before https://example.com/x"; got != want {
63 t.Fatalf("clipboard text fallback produced %q, want %q", got, want)
64 }
65 if got := strings.Join(m.transcript, "\n"); strings.Contains(got, "image/bmp") {
66 t.Fatalf("successful text fallback surfaced an image error:\n%s", got)
67 }
68 }
69
70 func TestCtrlVDoesNotPasteTwiceWhenTerminalAlreadyPasted(t *testing.T) {
71 setLocalClipboardSession(t)
72 stubEmptyImageClipboard(t, "term text")
73
74 m := newComposerMouseTestTUI(t, 60, 16)
75 m.input.SetValue("before ")
76
77 next, cmd := m.Update(imagePasteKey())
78 m = next.(chatTUI)
79 if cmd == nil {
80 t.Fatal("image paste shortcut produced no clipboard command")
81 }
82 next, _ = m.Update(tea.PasteMsg{Content: "term text"})
83 m = next.(chatTUI)
84 if got, want := m.input.Value(), "before term text"; got != want {
85 t.Fatalf("bracketed paste produced %q, want %q", got, want)
86 }
87
88 next, cmd = m.Update(cmd())
89 m = next.(chatTUI)
90 if cmd != nil {
91 t.Fatal("fallback ran even though the terminal already pasted")
92 }
93 if got, want := m.input.Value(), "before term text"; got != want {
94 t.Fatalf("text was pasted twice: %q, want %q", got, want)
95 }
96 }
97
98 func TestRapidCtrlVDoesNotDropSecondTextFallback(t *testing.T) {
99 setLocalClipboardSession(t)
100 stubEmptyImageClipboard(t, "text")
101
102 m := newComposerMouseTestTUI(t, 60, 16)
103 next, firstImage := m.Update(imagePasteKey())
104 m = next.(chatTUI)
105 next, firstText := m.Update(firstImage())
106 m = next.(chatTUI)
107
108 next, secondImage := m.Update(imagePasteKey())
109 m = next.(chatTUI)
110 if secondImage == nil {
111 t.Fatal("second image paste shortcut did not start a probe")
112 }
113
114 firstResult := clipboardTextPasteResultFromCmd(t, firstText)
115 next, _ = m.Update(firstResult)
116 m = next.(chatTUI)
117
118 next, secondText := m.Update(secondImage())
119 m = next.(chatTUI)
120 if secondText == nil {
121 t.Fatal("second text fallback was mistaken for a terminal-owned paste")
122 }
123 secondResult := clipboardTextPasteResultFromCmd(t, secondText)
124 next, _ = m.Update(secondResult)
125 m = next.(chatTUI)
126
127 if got, want := m.input.Value(), "texttext"; got != want {
128 t.Fatalf("rapid clipboard fallbacks produced %q, want %q", got, want)
129 }
130 }
131
132 func TestOverlappingCtrlVDoesNotDropSecondTextFallback(t *testing.T) {
133 setLocalClipboardSession(t)
134 stubEmptyImageClipboard(t, "text")
135
136 m := newComposerMouseTestTUI(t, 60, 16)
137 next, imageProbe := m.Update(imagePasteKey())
138 m = next.(chatTUI)
139 next, duplicateProbe := m.Update(imagePasteKey())
140 m = next.(chatTUI)
141 if duplicateProbe != nil {
142 t.Fatal("overlapping clipboard requests should share one image probe")
143 }
144
145 next, textFallback := m.Update(imageProbe())
146 m = next.(chatTUI)
147 result := clipboardTextPasteResultFromCmd(t, textFallback)
148 next, _ = m.Update(result)
149 m = next.(chatTUI)
150
151 if got, want := m.input.Value(), "texttext"; got != want {
152 t.Fatalf("overlapping clipboard fallbacks produced %q, want %q", got, want)
153 }
154 }
155
156 func TestOverlappingCtrlVPreservesRequestNotOwnedByTerminal(t *testing.T) {
157 setLocalClipboardSession(t)
158 stubEmptyImageClipboard(t, "text")
159
160 m := newComposerMouseTestTUI(t, 60, 16)
161 next, imageProbe := m.Update(imagePasteKey())
162 m = next.(chatTUI)
163 next, _ = m.Update(imagePasteKey())
164 m = next.(chatTUI)
165
166 // One bracketed paste satisfies one request while the shared image probe is
167 // in flight. The other request must still use the native-text fallback.
168 next, _ = m.Update(tea.PasteMsg{Content: "text"})
169 m = next.(chatTUI)
170 next, textFallback := m.Update(imageProbe())
171 m = next.(chatTUI)
172 result := clipboardTextPasteResultFromCmd(t, textFallback)
173 next, _ = m.Update(result)
174 m = next.(chatTUI)
175
176 if got, want := m.input.Value(), "texttext"; got != want {
177 t.Fatalf("mixed terminal and clipboard fallbacks produced %q, want %q", got, want)
178 }
179 }
180
181 func TestOverlappingCtrlVStillAttachesImageOnce(t *testing.T) {
182 setLocalClipboardSession(t)
183 m := newComposerMouseTestTUI(t, 60, 16)
184
185 next, imageProbe := m.Update(imagePasteKey())
186 m = next.(chatTUI)
187 if imageProbe == nil {
188 t.Fatal("first image paste shortcut did not start a probe")
189 }
190 next, duplicateProbe := m.Update(imagePasteKey())
191 m = next.(chatTUI)
192 if duplicateProbe != nil {
193 t.Fatal("overlapping image paste started a duplicate probe")
194 }
195
196 next, _ = m.Update(clipboardImageMsg{path: ".reasonix/attachments/test.png"})
197 m = next.(chatTUI)
198 if got, want := m.input.Value(), "[image #1] "; got != want {
199 t.Fatalf("overlapping image paste produced %q, want %q", got, want)
200 }
201 if m.clipboardImagePending || m.clipboardImageRequests != 0 {
202 t.Fatalf("completed image paste kept pending state: pending=%v requests=%d", m.clipboardImagePending, m.clipboardImageRequests)
203 }
204 }
205
206 func TestLateTerminalPasteCancelsScheduledTextFallback(t *testing.T) {
207 for _, nativeText := range []string{"term text", ""} {
208 t.Run(fmt.Sprintf("native_text_%q", nativeText), func(t *testing.T) {
209 setLocalClipboardSession(t)
210 stubEmptyImageClipboard(t, nativeText)
211
212 m := newComposerMouseTestTUI(t, 60, 16)
213 next, imageProbe := m.Update(imagePasteKey())
214 m = next.(chatTUI)
215 next, textFallback := m.Update(imageProbe())
216 m = next.(chatTUI)
217 if textFallback == nil {
218 t.Fatal("empty image clipboard did not schedule a text fallback")
219 }
220
221 // The terminal paste arrives after the image result but before the native
222 // text read completes. It owns this paste and must cancel every fallback result.
223 next, _ = m.Update(tea.PasteMsg{Content: "term text"})
224 m = next.(chatTUI)
225 result := clipboardTextPasteResultFromCmd(t, textFallback)
226 next, _ = m.Update(result)
227 m = next.(chatTUI)
228
229 if got, want := m.input.Value(), "term text"; got != want {
230 t.Fatalf("late terminal paste was duplicated: %q, want %q", got, want)
231 }
232 if got := strings.Join(m.transcript, "\n"); strings.Contains(got, i18n.M.ClipboardPasteEmptyNotice) {
233 t.Fatalf("terminal-owned paste surfaced a stale empty notice:\n%s", got)
234 }
235 })
236 }
237 }
238
239 func TestClipboardImagePasteKeepsNoticeForRealFailures(t *testing.T) {
240 setLocalClipboardSession(t)
241 m := newComposerMouseTestTUI(t, 60, 16)
242 m.input.SetValue("before ")
243
244 next, cmd := m.Update(clipboardImageMsg{err: errors.New("clipboard image paste needs wl-paste \x1b]52;c;owned\a (Wayland) or xclip (X11)")})
245 m = next.(chatTUI)
246
247 if cmd != nil {
248 t.Fatal("a real clipboard failure must not trigger a text paste")
249 }
250 if got := strings.Join(m.transcript, "\n"); !strings.Contains(got, "wl-paste") {
251 t.Fatalf("a real clipboard failure lost its notice:\n%s", got)
252 } else if strings.ContainsAny(got, "\x1b\a") {
253 t.Fatalf("a real clipboard failure rendered terminal controls: %q", got)
254 }
255 if got := m.input.Value(); got != "before " {
256 t.Fatalf("a real clipboard failure changed the composer: %q", got)
257 }
258 }
259
260 func TestCtrlVEmptyImageProbeWithNoTextSurfacesNotice(t *testing.T) {
261 cases := []struct {
262 name string
263 imageErr error
264 want string
265 }{
266 {name: "no image", imageErr: control.ErrNoClipboardImage, want: i18n.M.ClipboardPasteEmptyNotice},
267 {name: "unsupported image", imageErr: fmt.Errorf("unsupported image/bmp: %w", errors.Join(control.ErrNoClipboardImage, control.ErrUnsupportedClipboardImage)), want: "image/bmp"},
268 }
269 for _, tc := range cases {
270 t.Run(tc.name, func(t *testing.T) {
271 setLocalClipboardSession(t)
272 stubEmptyImageClipboard(t, "", tc.imageErr)
273
274 m := newComposerMouseTestTUI(t, 60, 16)
275 next, cmd := m.Update(imagePasteKey())
276 m = next.(chatTUI)
277 next, cmd = m.Update(cmd())
278 m = next.(chatTUI)
279
280 result := clipboardTextPasteResultFromCmd(t, cmd)
281 next, _ = m.Update(result)
282 m = next.(chatTUI)
283 if got := strings.Join(m.transcript, "\n"); !strings.Contains(got, tc.want) {
284 t.Fatalf("empty image probe notice = %q, want %q", got, tc.want)
285 }
286 })
287 }
288 }
289
289 lines GO