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, limit int) ([]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 mockResolveAbs(path string) (string, error) {
134 return path, nil
135}
136
137func TestEditorTypingForwardSlashOpensCompletions(t *testing.T) {
138 testEditor := newEditor(&app.App{}, mockDirLister([]string{}))
139 require.NotNil(t, testEditor)
140
141 // Simulate pressing the '/' key
142 keyPressMsg := tea.KeyPressMsg{
143 Text: "/",
144 }
145
146 m, cmds := testEditor.Update(keyPressMsg)
147 testEditor = m.(*editorCmp)
148 cmds()
149
150 assert.True(t, testEditor.isCompletionsOpen)
151 assert.Equal(t, "/", testEditor.textarea.Value())
152}
153
154func TestEditorAutocompletionWithEmptyInput(t *testing.T) {
155 testEditor := newEditor(&app.App{}, mockDirLister([]string{}))
156 require.NotNil(t, testEditor)
157
158 // First, give the editor focus
159 testEditor.Focus()
160
161 // Simulate pressing the '/' key when the editor is empty
162 // This should trigger the completions to open
163 keyPressMsg := tea.KeyPressMsg{
164 Text: "/",
165 }
166
167 m, cmds := testEditor.Update(keyPressMsg)
168 testEditor = m.(*editorCmp)
169 cmds()
170
171 // completions menu is open
172 assert.True(t, testEditor.isCompletionsOpen)
173 assert.Equal(t, "/", testEditor.textarea.Value())
174
175 // the query is empty (since we just opened it)
176 assert.Equal(t, "", testEditor.currentQuery)
177}
178
179func TestEditorAutoCompletion_OnNonImageFileFullPathInsertedFromQuery(t *testing.T) {
180 entriesForAutoComplete := mockDirLister([]string{"image.png", "random.txt"})
181 testEditor := newEditor(&app.App{}, entriesForAutoComplete)
182 require.NotNil(t, testEditor)
183
184 // open the completions menu by simulating a '/' key press
185 testEditor.Focus()
186 keyPressMsg := tea.KeyPressMsg{
187 Text: "/",
188 }
189
190 m, msg := simulateUpdate(testEditor, keyPressMsg)
191 testEditor = m.(*editorCmp)
192
193 var openCompletionsMsg *completions.OpenCompletionsMsg
194 if batchMsg, ok := msg.(tea.BatchMsg); ok {
195 // Use our enhanced helper to check for OpenCompletionsMsg with specific completions
196 var found bool
197 openCompletionsMsg, found = assertBatchContainsOpenCompletionsMsg(t, batchMsg, []string{"image.png", "random.txt"})
198 assert.True(t, found, "Expected to find OpenCompletionsMsg with specific completions in batched messages")
199 } else {
200 t.Fatal("Expected BatchMsg from cmds()")
201 }
202
203 assert.NotNil(t, openCompletionsMsg)
204 require.True(t, testEditor.IsCompletionsOpen())
205
206 testEditor.textarea.SetValue("I am looking for a file called /random.tx")
207
208 keyPressMsg = tea.KeyPressMsg{
209 Text: "t",
210 }
211 m, _ = simulateUpdate(testEditor, keyPressMsg)
212 testEditor = m.(*editorCmp)
213
214 selectMsg := completions.SelectCompletionMsg{
215 Value: FileCompletionItem{
216 "./root/project/random.txt",
217 },
218 Insert: true,
219 }
220
221 m, msg = simulateUpdate(testEditor, selectMsg)
222 testEditor = m.(*editorCmp)
223
224 if _, ok := msg.(noopEvent); !ok {
225 t.Fatal("Expected noopEvent from cmds()")
226 }
227
228 assert.Equal(t, "I am looking for a file called ./root/project/random.txt", testEditor.textarea.Value())
229}
230
231func TestEditor_OnCompletionPathToImageEmitsAttachFileMessage(t *testing.T) {
232 entriesForAutoComplete := mockDirLister([]string{"image.png", "random.txt"})
233 fsys := fstest.MapFS{
234 "auto_completed_image.png": {
235 Data: pngMagicNumberData,
236 },
237 "random.txt": {
238 Data: []byte("Some content"),
239 },
240 }
241
242 modelHasImageSupport := func() (bool, string) {
243 return true, "TestModel"
244 }
245 testEditor := newEditor(&app.App{}, entriesForAutoComplete)
246 _, cmd := onCompletionItemSelect(fsys, modelHasImageSupport, FileCompletionItem{Path: "auto_completed_image.png"}, true, testEditor)
247
248 require.NotNil(t, cmd)
249 msg := cmd()
250 require.NotNil(t, msg)
251
252 var attachmentMsg message.Attachment
253 if fpickedMsg, ok := msg.(filepicker.FilePickedMsg); ok {
254 attachmentMsg = fpickedMsg.Attachment
255 }
256
257 assert.Equal(t, message.Attachment{
258 FilePath: "auto_completed_image.png",
259 FileName: "auto_completed_image.png",
260 MimeType: "image/png",
261 Content: pngMagicNumberData,
262 }, attachmentMsg)
263}
264
265func TestEditor_OnCompletionPathToImageEmitsWanrningMessageWhenModelDoesNotSupportImages(t *testing.T) {
266 entriesForAutoComplete := mockDirLister([]string{"image.png", "random.txt"})
267 fsys := fstest.MapFS{
268 "auto_completed_image.png": {
269 Data: pngMagicNumberData,
270 },
271 "random.txt": {
272 Data: []byte("Some content"),
273 },
274 }
275
276 modelHasImageSupport := func() (bool, string) {
277 return false, "TestModel"
278 }
279 testEditor := newEditor(&app.App{}, entriesForAutoComplete)
280 _, cmd := onCompletionItemSelect(fsys, modelHasImageSupport, FileCompletionItem{Path: "auto_completed_image.png"}, true, testEditor)
281
282 require.NotNil(t, cmd)
283 msg := cmd()
284 require.NotNil(t, msg)
285
286 warningMsg, ok := msg.(util.InfoMsg)
287 require.True(t, ok)
288 assert.Equal(t, util.InfoMsg{
289 Type: util.InfoTypeWarn,
290 Msg: "File attachments are not supported by the current model: TestModel",
291 }, warningMsg)
292}
293
294func TestEditor_OnCompletionPathToNonImageEmitsAttachFileMessage(t *testing.T) {
295 entriesForAutoComplete := mockDirLister([]string{"image.png", "random.txt"})
296 fsys := fstest.MapFS{
297 "auto_completed_image.png": {
298 Data: pngMagicNumberData,
299 },
300 "random.txt": {
301 Data: []byte("Some content"),
302 },
303 }
304
305 modelHasImageSupport := func() (bool, string) {
306 return true, "TestModel"
307 }
308 testEditor := newEditor(&app.App{}, entriesForAutoComplete)
309 _, cmd := onCompletionItemSelect(fsys, modelHasImageSupport, FileCompletionItem{Path: "random.txt"}, true, testEditor)
310
311 assert.Nil(t, cmd)
312}
313
314func TestEditor_OnPastePathToImageEmitsAttachFileMessage(t *testing.T) {
315 entriesForAutoComplete := mockDirLister([]string{"image.png", "random.txt"})
316
317 // Create a temporary directory and files for testing
318 tempDir := t.TempDir()
319
320 // Create test image file
321 imagePath := filepath.Join(tempDir, "image.png")
322 err := os.WriteFile(imagePath, pngMagicNumberData, 0o644)
323 require.NoError(t, err)
324
325 // Create test text file
326 textPath := filepath.Join(tempDir, "random.txt")
327 err = os.WriteFile(textPath, []byte("Some content"), 0o644)
328 require.NoError(t, err)
329
330 testEditor := newEditor(&app.App{}, entriesForAutoComplete)
331
332 // Change to temp directory so paths resolve correctly
333 originalWd, err := os.Getwd()
334 require.NoError(t, err)
335 defer os.Chdir(originalWd)
336 err = os.Chdir(tempDir)
337 require.NoError(t, err)
338
339 modelHasImageSupport := func() (bool, string) {
340 return true, "TestModel"
341 }
342 absRef := filepath.Abs
343 _, cmd := onPaste(absRef, modelHasImageSupport, testEditor, tea.PasteMsg("image.png"))
344
345 require.NotNil(t, cmd)
346 msg := cmd()
347 assert.NotNil(t, msg)
348
349 var attachmentMsg message.Attachment
350 if fpickedMsg, ok := msg.(filepicker.FilePickedMsg); ok {
351 attachmentMsg = fpickedMsg.Attachment
352 }
353
354 assert.NoError(t, err)
355
356 // Create a copy of the attachment for comparison, but use the actual FilePath from the message
357 // This handles the case on macOS where the path might have a "/private" prefix
358 expectedAttachment := message.Attachment{
359 FilePath: attachmentMsg.FilePath, // Use the actual path from the message
360 FileName: "image.png",
361 MimeType: "image/png",
362 Content: pngMagicNumberData,
363 }
364
365 assert.Equal(t, expectedAttachment, attachmentMsg)
366}
367
368func TestEditor_OnPastePathToNonImageEmitsAttachFileMessage(t *testing.T) {
369 entriesForAutoComplete := mockDirLister([]string{"image.png", "random.txt"})
370
371 // Create a temporary directory and files for testing
372 tempDir := t.TempDir()
373
374 // Create test image file
375 imagePath := filepath.Join(tempDir, "image.png")
376 err := os.WriteFile(imagePath, pngMagicNumberData, 0o644)
377 require.NoError(t, err)
378
379 // Create test text file
380 textPath := filepath.Join(tempDir, "random.txt")
381 err = os.WriteFile(textPath, []byte("Some content"), 0o644)
382 require.NoError(t, err)
383
384 testEditor := newEditor(&app.App{}, entriesForAutoComplete)
385
386 // Change to temp directory so paths resolve correctly
387 originalWd, err := os.Getwd()
388 require.NoError(t, err)
389 defer os.Chdir(originalWd)
390 err = os.Chdir(tempDir)
391 require.NoError(t, err)
392
393 modelHasImageSupport := func() (bool, string) {
394 return true, "TestModel"
395 }
396 _, cmd := onPaste(filepath.Abs, modelHasImageSupport, testEditor, tea.PasteMsg("random.txt"))
397
398 assert.Nil(t, cmd)
399}
400
401// TestHelperFunctions demonstrates how to use the batch message helpers
402func TestHelperFunctions(t *testing.T) {
403 testEditor := newEditor(&app.App{}, mockDirLister([]string{"file1.txt", "file2.txt"}))
404 require.NotNil(t, testEditor)
405
406 // Simulate pressing the '/' key
407 testEditor.Focus()
408 keyPressMsg := tea.KeyPressMsg{
409 Text: "/",
410 }
411
412 _, cmds := testEditor.Update(keyPressMsg)
413
414 // Execute the command and check if it returns a BatchMsg
415 msg := cmds()
416 if batchMsg, ok := msg.(tea.BatchMsg); ok {
417 // Test our helper functions
418 found := assertBatchContainsMessage(t, batchMsg, completions.OpenCompletionsMsg{})
419 assert.True(t, found, "Expected to find OpenCompletionsMsg in batched messages")
420
421 // Test exact message helper
422 foundExact := assertBatchContainsExactMessage(t, batchMsg, completions.OpenCompletionsMsg{})
423 assert.True(t, foundExact, "Expected to find exact OpenCompletionsMsg in batched messages")
424
425 // Test specific completions helper
426 msg, foundSpecific := assertBatchContainsOpenCompletionsMsg(t, batchMsg, nil) // Just check type
427 assert.NotNil(t, msg)
428 assert.True(t, foundSpecific, "Expected to find OpenCompletionsMsg in batched messages")
429 } else {
430 t.Fatal("Expected BatchMsg from cmds()")
431 }
432}