1package editor
2
3import (
4 "os"
5 "path/filepath"
6 "testing"
7 "testing/fstest"
8
9 tea "github.com/charmbracelet/bubbletea/v2"
10 "github.com/charmbracelet/crush/internal/app"
11 "github.com/charmbracelet/crush/internal/fsext"
12 "github.com/charmbracelet/crush/internal/message"
13 "github.com/charmbracelet/crush/internal/tui/components/completions"
14 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
15 "github.com/charmbracelet/crush/internal/tui/util"
16 "github.com/stretchr/testify/assert"
17 "github.com/stretchr/testify/require"
18)
19
20// executeBatchCommands executes all commands in a BatchMsg and returns the resulting messages
21func executeBatchCommands(batchMsg tea.BatchMsg) []tea.Msg {
22 var messages []tea.Msg
23 for _, cmd := range batchMsg {
24 if cmd != nil {
25 msg := cmd()
26 messages = append(messages, msg)
27 }
28 }
29 return messages
30}
31
32// assertBatchContainsMessage checks if a BatchMsg contains a message of the specified type
33func assertBatchContainsMessage(t *testing.T, batchMsg tea.BatchMsg, expectedType any) bool {
34 t.Helper()
35 messages := executeBatchCommands(batchMsg)
36
37 for _, msg := range messages {
38 switch expectedType.(type) {
39 case completions.OpenCompletionsMsg:
40 if _, ok := msg.(completions.OpenCompletionsMsg); ok {
41 return true
42 }
43 }
44 }
45 return false
46}
47
48// assertBatchContainsExactMessage checks if a BatchMsg contains a message with exact field values
49func assertBatchContainsExactMessage(t *testing.T, batchMsg tea.BatchMsg, expected any) bool {
50 t.Helper()
51 messages := executeBatchCommands(batchMsg)
52
53 for _, msg := range messages {
54 switch expected := expected.(type) {
55 case completions.OpenCompletionsMsg:
56 if actual, ok := msg.(completions.OpenCompletionsMsg); ok {
57 // If no specific completions are expected, just match the type
58 if len(expected.Completions) == 0 {
59 return true
60 }
61 // Compare completions if specified
62 if len(actual.Completions) == len(expected.Completions) {
63 // For simplicity, just check the count for now
64 // A more complete implementation would compare each completion
65 return true
66 }
67 }
68 default:
69 // Fallback to type checking only
70 if _, ok := msg.(completions.OpenCompletionsMsg); ok {
71 return true
72 }
73 }
74 }
75 return false
76}
77
78// assertBatchContainsOpenCompletionsMsg checks if a BatchMsg contains an OpenCompletionsMsg
79// with the expected completions. If expectedCompletions is nil, only the message type is checked.
80func assertBatchContainsOpenCompletionsMsg(t *testing.T, batchMsg tea.BatchMsg, expectedCompletions []string) (*completions.OpenCompletionsMsg, bool) {
81 t.Helper()
82 messages := executeBatchCommands(batchMsg)
83
84 for _, msg := range messages {
85 if actual, ok := msg.(completions.OpenCompletionsMsg); ok {
86 if expectedCompletions == nil {
87 return &actual, true
88 }
89
90 // Convert actual completions to string titles for comparison
91 actualTitles := make([]string, len(actual.Completions))
92 for i, comp := range actual.Completions {
93 actualTitles[i] = comp.Title
94 }
95
96 // Check if we have the same number of completions
97 if len(actualTitles) != len(expectedCompletions) {
98 continue
99 }
100
101 // For now, just check that we have the same count
102 // A more sophisticated implementation would check the actual values
103 return &actual, true
104 }
105 }
106 return nil, false
107}
108
109func mockDirLister(paths []string) fsext.DirectoryListerResolver {
110 return func() fsext.DirectoryLister {
111 return func(initialPath string, ignorePatterns []string) ([]string, bool, error) {
112 return paths, false, nil
113 }
114 }
115}
116
117type noopEvent struct{}
118
119type updater interface {
120 Update(msg tea.Msg) (tea.Model, tea.Cmd)
121}
122
123func simulateUpdate(up updater, msg tea.Msg) (updater, tea.Msg) {
124 up, cmd := up.Update(msg)
125 if cmd != nil {
126 return up, cmd()
127 }
128 return up, noopEvent{}
129}
130
131var pngMagicNumberData = []byte("\x89PNG\x0D\x0A\x1A\x0A")
132
133func TestEditorTypingForwardSlashOpensCompletions(t *testing.T) {
134 testEditor := newEditor(&app.App{}, mockDirLister([]string{}))
135 require.NotNil(t, testEditor)
136
137 // Simulate pressing the '/' key
138 keyPressMsg := tea.KeyPressMsg{
139 Text: "/",
140 }
141
142 m, cmds := testEditor.Update(keyPressMsg)
143 testEditor = m.(*editorCmp)
144 cmds()
145
146 assert.True(t, testEditor.isCompletionsOpen)
147 assert.Equal(t, "/", testEditor.textarea.Value())
148}
149
150func TestEditorAutocompletionWithEmptyInput(t *testing.T) {
151 testEditor := newEditor(&app.App{}, mockDirLister([]string{}))
152 require.NotNil(t, testEditor)
153
154 // First, give the editor focus
155 testEditor.Focus()
156
157 // Simulate pressing the '/' key when the editor is empty
158 // This should trigger the completions to open
159 keyPressMsg := tea.KeyPressMsg{
160 Text: "/",
161 }
162
163 m, cmds := testEditor.Update(keyPressMsg)
164 testEditor = m.(*editorCmp)
165 cmds()
166
167 // completions menu is open
168 assert.True(t, testEditor.isCompletionsOpen)
169 assert.Equal(t, "/", testEditor.textarea.Value())
170
171 // the query is empty (since we just opened it)
172 assert.Equal(t, "", testEditor.currentQuery)
173}
174
175func TestEditorAutoCompletion_OnNonImageFileFullPathInsertedFromQuery(t *testing.T) {
176 entriesForAutoComplete := mockDirLister([]string{"image.png", "random.txt"})
177 testEditor := newEditor(&app.App{}, entriesForAutoComplete)
178 require.NotNil(t, testEditor)
179
180 // open the completions menu by simulating a '/' key press
181 testEditor.Focus()
182 keyPressMsg := tea.KeyPressMsg{
183 Text: "/",
184 }
185
186 m, msg := simulateUpdate(testEditor, keyPressMsg)
187 testEditor = m.(*editorCmp)
188
189 var openCompletionsMsg *completions.OpenCompletionsMsg
190 if batchMsg, ok := msg.(tea.BatchMsg); ok {
191 // Use our enhanced helper to check for OpenCompletionsMsg with specific completions
192 var found bool
193 openCompletionsMsg, found = assertBatchContainsOpenCompletionsMsg(t, batchMsg, []string{"image.png", "random.txt"})
194 assert.True(t, found, "Expected to find OpenCompletionsMsg with specific completions in batched messages")
195 } else {
196 t.Fatal("Expected BatchMsg from cmds()")
197 }
198
199 assert.NotNil(t, openCompletionsMsg)
200 require.True(t, testEditor.IsCompletionsOpen())
201
202 testEditor.textarea.SetValue("I am looking for a file called /random.tx")
203
204 keyPressMsg = tea.KeyPressMsg{
205 Text: "t",
206 }
207 m, _ = simulateUpdate(testEditor, keyPressMsg)
208 testEditor = m.(*editorCmp)
209
210 selectMsg := completions.SelectCompletionMsg{
211 Value: FileCompletionItem{
212 "./root/project/random.txt",
213 },
214 Insert: true,
215 }
216
217 m, msg = simulateUpdate(testEditor, selectMsg)
218 testEditor = m.(*editorCmp)
219
220 if _, ok := msg.(noopEvent); !ok {
221 t.Fatal("Expected noopEvent from cmds()")
222 }
223
224 assert.Equal(t, "I am looking for a file called ./root/project/random.txt", testEditor.textarea.Value())
225}
226
227func TestEditor_OnCompletionPathToImageEmitsAttachFileMessage(t *testing.T) {
228 entriesForAutoComplete := mockDirLister([]string{"image.png", "random.txt"})
229 fsys := fstest.MapFS{
230 "auto_completed_image.png": {
231 Data: pngMagicNumberData,
232 },
233 "random.txt": {
234 Data: []byte("Some content"),
235 },
236 }
237
238 modelHasImageSupport := func() (bool, string) {
239 return true, "TestModel"
240 }
241 testEditor := newEditor(&app.App{}, entriesForAutoComplete)
242 _, cmd := onCompletionItemSelect(fsys, modelHasImageSupport, FileCompletionItem{Path: "auto_completed_image.png"}, true, testEditor)
243
244 require.NotNil(t, cmd)
245 msg := cmd()
246 require.NotNil(t, msg)
247
248 var attachmentMsg message.Attachment
249 if fpickedMsg, ok := msg.(filepicker.FilePickedMsg); ok {
250 attachmentMsg = fpickedMsg.Attachment
251 }
252
253 assert.Equal(t, message.Attachment{
254 FilePath: "auto_completed_image.png",
255 FileName: "auto_completed_image.png",
256 MimeType: "image/png",
257 Content: pngMagicNumberData,
258 }, attachmentMsg)
259}
260
261func TestEditor_OnCompletionPathToImageEmitsWanrningMessageWhenModelDoesNotSupportImages(t *testing.T) {
262 entriesForAutoComplete := mockDirLister([]string{"image.png", "random.txt"})
263 fsys := fstest.MapFS{
264 "auto_completed_image.png": {
265 Data: pngMagicNumberData,
266 },
267 "random.txt": {
268 Data: []byte("Some content"),
269 },
270 }
271
272 modelHasImageSupport := func() (bool, string) {
273 return false, "TestModel"
274 }
275 testEditor := newEditor(&app.App{}, entriesForAutoComplete)
276 _, cmd := onCompletionItemSelect(fsys, modelHasImageSupport, FileCompletionItem{Path: "auto_completed_image.png"}, true, testEditor)
277
278 require.NotNil(t, cmd)
279 msg := cmd()
280 require.NotNil(t, msg)
281
282 warningMsg, ok := msg.(util.InfoMsg)
283 require.True(t, ok)
284 assert.Equal(t, util.InfoMsg{
285 Type: util.InfoTypeWarn,
286 Msg: "File attachments are not supported by the current model: TestModel",
287 }, warningMsg)
288}
289
290func TestEditor_OnCompletionPathToNonImageEmitsAttachFileMessage(t *testing.T) {
291 entriesForAutoComplete := mockDirLister([]string{"image.png", "random.txt"})
292 fsys := fstest.MapFS{
293 "auto_completed_image.png": {
294 Data: pngMagicNumberData,
295 },
296 "random.txt": {
297 Data: []byte("Some content"),
298 },
299 }
300
301 modelHasImageSupport := func() (bool, string) {
302 return true, "TestModel"
303 }
304 testEditor := newEditor(&app.App{}, entriesForAutoComplete)
305 _, cmd := onCompletionItemSelect(fsys, modelHasImageSupport, FileCompletionItem{Path: "random.txt"}, true, testEditor)
306
307 assert.Nil(t, cmd)
308}
309
310func TestEditor_OnPastePathToImageEmitsAttachFileMessage(t *testing.T) {
311 entriesForAutoComplete := mockDirLister([]string{"image.png", "random.txt"})
312
313 // Create a temporary directory and files for testing
314 tempDir := t.TempDir()
315
316 // Create test image file
317 imagePath := filepath.Join(tempDir, "image.png")
318 err := os.WriteFile(imagePath, pngMagicNumberData, 0o644)
319 require.NoError(t, err)
320
321 // Create test text file
322 textPath := filepath.Join(tempDir, "random.txt")
323 err = os.WriteFile(textPath, []byte("Some content"), 0o644)
324 require.NoError(t, err)
325
326 testEditor := newEditor(&app.App{}, entriesForAutoComplete)
327
328 // Change to temp directory so paths resolve correctly
329 originalWd, err := os.Getwd()
330 require.NoError(t, err)
331 defer os.Chdir(originalWd)
332 err = os.Chdir(tempDir)
333 require.NoError(t, err)
334
335 modelHasImageSupport := func() (bool, string) {
336 return true, "TestModel"
337 }
338 absRef := filepath.Abs
339 _, cmd := onPaste(absRef, modelHasImageSupport, testEditor, tea.PasteMsg("image.png"))
340
341 require.NotNil(t, cmd)
342 msg := cmd()
343 assert.NotNil(t, msg)
344
345 var attachmentMsg message.Attachment
346 if fpickedMsg, ok := msg.(filepicker.FilePickedMsg); ok {
347 attachmentMsg = fpickedMsg.Attachment
348 }
349
350 assert.NoError(t, err)
351
352 // Create a copy of the attachment for comparison, but use the actual FilePath from the message
353 // This handles the case on macOS where the path might have a "/private" prefix
354 expectedAttachment := message.Attachment{
355 FilePath: attachmentMsg.FilePath, // Use the actual path from the message
356 FileName: "image.png",
357 MimeType: "image/png",
358 Content: pngMagicNumberData,
359 }
360
361 assert.Equal(t, expectedAttachment, attachmentMsg)
362}
363
364func TestEditor_OnPastePathToNonImageEmitsAttachFileMessage(t *testing.T) {
365 entriesForAutoComplete := mockDirLister([]string{"image.png", "random.txt"})
366
367 // Create a temporary directory and files for testing
368 tempDir := t.TempDir()
369
370 // Create test image file
371 imagePath := filepath.Join(tempDir, "image.png")
372 err := os.WriteFile(imagePath, pngMagicNumberData, 0o644)
373 require.NoError(t, err)
374
375 // Create test text file
376 textPath := filepath.Join(tempDir, "random.txt")
377 err = os.WriteFile(textPath, []byte("Some content"), 0o644)
378 require.NoError(t, err)
379
380 testEditor := newEditor(&app.App{}, entriesForAutoComplete)
381
382 // Change to temp directory so paths resolve correctly
383 originalWd, err := os.Getwd()
384 require.NoError(t, err)
385 defer os.Chdir(originalWd)
386 err = os.Chdir(tempDir)
387 require.NoError(t, err)
388
389 modelHasImageSupport := func() (bool, string) {
390 return true, "TestModel"
391 }
392 _, cmd := onPaste(filepath.Abs, modelHasImageSupport, testEditor, tea.PasteMsg("random.txt"))
393
394 assert.Nil(t, cmd)
395}
396
397func TestEditor_OnPastePathToNonImageEmitsWanrningMessageWhenModelDoesNotSupportImages(t *testing.T) {
398 entriesForAutoComplete := mockDirLister([]string{"image.png", "random.txt"})
399
400 // Create a temporary directory and files for testing
401 tempDir := t.TempDir()
402
403 // Create test image file
404 imagePath := filepath.Join(tempDir, "image.png")
405 err := os.WriteFile(imagePath, pngMagicNumberData, 0o644)
406 require.NoError(t, err)
407
408 // Create test text file
409 textPath := filepath.Join(tempDir, "random.txt")
410 err = os.WriteFile(textPath, []byte("Some content"), 0o644)
411 require.NoError(t, err)
412
413 testEditor := newEditor(&app.App{}, entriesForAutoComplete)
414
415 // Change to temp directory so paths resolve correctly
416 originalWd, err := os.Getwd()
417 require.NoError(t, err)
418 defer os.Chdir(originalWd)
419 err = os.Chdir(tempDir)
420 require.NoError(t, err)
421
422 modelDoesNotHaveImageSupport := func() (bool, string) {
423 return false, "ImagesUnsupportedTestModel"
424 }
425 _, cmd := onPaste(filepath.Abs, modelDoesNotHaveImageSupport, testEditor, tea.PasteMsg("image.png"))
426
427 require.NotNil(t, cmd)
428 msg := cmd()
429 require.NotNil(t, msg)
430
431 warningMsg, ok := msg.(util.InfoMsg)
432 require.True(t, ok)
433 assert.Equal(t, util.InfoMsg{
434 Type: util.InfoTypeWarn,
435 Msg: "File attachments are not supported by the current model: ImagesUnsupportedTestModel",
436 }, warningMsg)
437}
438
439// TestHelperFunctions demonstrates how to use the batch message helpers
440func TestHelperFunctions(t *testing.T) {
441 testEditor := newEditor(&app.App{}, mockDirLister([]string{"file1.txt", "file2.txt"}))
442 require.NotNil(t, testEditor)
443
444 // Simulate pressing the '/' key
445 testEditor.Focus()
446 keyPressMsg := tea.KeyPressMsg{
447 Text: "/",
448 }
449
450 _, cmds := testEditor.Update(keyPressMsg)
451
452 // Execute the command and check if it returns a BatchMsg
453 msg := cmds()
454 if batchMsg, ok := msg.(tea.BatchMsg); ok {
455 // Test our helper functions
456 found := assertBatchContainsMessage(t, batchMsg, completions.OpenCompletionsMsg{})
457 assert.True(t, found, "Expected to find OpenCompletionsMsg in batched messages")
458
459 // Test exact message helper
460 foundExact := assertBatchContainsExactMessage(t, batchMsg, completions.OpenCompletionsMsg{})
461 assert.True(t, foundExact, "Expected to find exact OpenCompletionsMsg in batched messages")
462
463 // Test specific completions helper
464 msg, foundSpecific := assertBatchContainsOpenCompletionsMsg(t, batchMsg, nil) // Just check type
465 assert.NotNil(t, msg)
466 assert.True(t, foundSpecific, "Expected to find OpenCompletionsMsg in batched messages")
467 } else {
468 t.Fatal("Expected BatchMsg from cmds()")
469 }
470}