editor_test.go

  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}