editor_test.go

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