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, 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
294
295func TestEditor_OnCompletionPathToNonImageEmitsAttachFileMessage(t *testing.T) {
296	entriesForAutoComplete := mockDirLister([]string{"image.png", "random.txt"})
297	fsys := fstest.MapFS{
298		"auto_completed_image.png": {
299			Data: pngMagicNumberData,
300		},
301		"random.txt": {
302			Data: []byte("Some content"),
303		},
304	}
305
306	modelHasImageSupport := func() (bool, string) {
307		return true, "TestModel"
308	} 
309	testEditor := newEditor(&app.App{}, entriesForAutoComplete)
310	_, cmd := onCompletionItemSelect(fsys, modelHasImageSupport, FileCompletionItem{Path: "random.txt"}, true, testEditor)
311
312	assert.Nil(t, cmd)
313}
314
315func TestEditor_OnPastePathToImageEmitsAttachFileMessage(t *testing.T) {
316	entriesForAutoComplete := mockDirLister([]string{"image.png", "random.txt"})
317
318	// Create a temporary directory and files for testing
319	tempDir := t.TempDir()
320
321	// Create test image file
322	imagePath := filepath.Join(tempDir, "image.png")
323	err := os.WriteFile(imagePath, pngMagicNumberData, 0o644)
324	require.NoError(t, err)
325
326	// Create test text file
327	textPath := filepath.Join(tempDir, "random.txt")
328	err = os.WriteFile(textPath, []byte("Some content"), 0o644)
329	require.NoError(t, err)
330
331	testEditor := newEditor(&app.App{}, entriesForAutoComplete)
332
333	// Change to temp directory so paths resolve correctly
334	originalWd, err := os.Getwd()
335	require.NoError(t, err)
336	defer os.Chdir(originalWd)
337	err = os.Chdir(tempDir)
338	require.NoError(t, err)
339
340	absRef := filepath.Abs
341	_, cmd := onPaste(absRef, testEditor, tea.PasteMsg("image.png"))
342
343	require.NotNil(t, cmd)
344	msg := cmd()
345	assert.NotNil(t, msg)
346
347	var attachmentMsg message.Attachment
348	if fpickedMsg, ok := msg.(filepicker.FilePickedMsg); ok {
349		attachmentMsg = fpickedMsg.Attachment
350	}
351
352	assert.NoError(t, err)
353
354	// Create a copy of the attachment for comparison, but use the actual FilePath from the message
355	// This handles the case on macOS where the path might have a "/private" prefix
356	expectedAttachment := message.Attachment{
357		FilePath: attachmentMsg.FilePath, // Use the actual path from the message
358		FileName: "image.png",
359		MimeType: "image/png",
360		Content:  pngMagicNumberData,
361	}
362
363	assert.Equal(t, expectedAttachment, attachmentMsg)
364}
365
366func TestEditor_OnPastePathToNonImageEmitsAttachFileMessage(t *testing.T) {
367	entriesForAutoComplete := mockDirLister([]string{"image.png", "random.txt"})
368
369	// Create a temporary directory and files for testing
370	tempDir := t.TempDir()
371
372	// Create test image file
373	imagePath := filepath.Join(tempDir, "image.png")
374	err := os.WriteFile(imagePath, pngMagicNumberData, 0o644)
375	require.NoError(t, err)
376
377	// Create test text file
378	textPath := filepath.Join(tempDir, "random.txt")
379	err = os.WriteFile(textPath, []byte("Some content"), 0o644)
380	require.NoError(t, err)
381
382	testEditor := newEditor(&app.App{}, entriesForAutoComplete)
383
384	// Change to temp directory so paths resolve correctly
385	originalWd, err := os.Getwd()
386	require.NoError(t, err)
387	defer os.Chdir(originalWd)
388	err = os.Chdir(tempDir)
389	require.NoError(t, err)
390
391	_, cmd := onPaste(filepath.Abs, testEditor, tea.PasteMsg("random.txt"))
392
393	assert.Nil(t, cmd)
394}
395
396// TestHelperFunctions demonstrates how to use the batch message helpers
397func TestHelperFunctions(t *testing.T) {
398	testEditor := newEditor(&app.App{}, mockDirLister([]string{"file1.txt", "file2.txt"}))
399	require.NotNil(t, testEditor)
400
401	// Simulate pressing the '/' key
402	testEditor.Focus()
403	keyPressMsg := tea.KeyPressMsg{
404		Text: "/",
405	}
406
407	_, cmds := testEditor.Update(keyPressMsg)
408
409	// Execute the command and check if it returns a BatchMsg
410	msg := cmds()
411	if batchMsg, ok := msg.(tea.BatchMsg); ok {
412		// Test our helper functions
413		found := assertBatchContainsMessage(t, batchMsg, completions.OpenCompletionsMsg{})
414		assert.True(t, found, "Expected to find OpenCompletionsMsg in batched messages")
415
416		// Test exact message helper
417		foundExact := assertBatchContainsExactMessage(t, batchMsg, completions.OpenCompletionsMsg{})
418		assert.True(t, foundExact, "Expected to find exact OpenCompletionsMsg in batched messages")
419
420		// Test specific completions helper
421		msg, foundSpecific := assertBatchContainsOpenCompletionsMsg(t, batchMsg, nil) // Just check type
422		assert.NotNil(t, msg)
423		assert.True(t, foundSpecific, "Expected to find OpenCompletionsMsg in batched messages")
424	} else {
425		t.Fatal("Expected BatchMsg from cmds()")
426	}
427}