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
309// type mockSrv struct {}
310
311func TestEditor_StepForwardOverHistoryDoesNotTouchExistingInputValue(t *testing.T) {
312	mEditor := editorCmp{}
313	fakeHistory := []string{
314		"First message user sent",
315		"Second message user sent",
316		"Third message user sent",
317		"Current value in the input field",
318	}
319
320	history := func(ctx context.Context) ([]string, error) {
321		return fakeHistory, nil
322	}
323	forwardDir := func() direction {
324		return next
325	}
326	previousDir := func() direction {
327		return previous
328	}
329
330	// NOTE(tauraamui): if forward is the first direction the user goes in, the current message should be left alone/the same
331	assert.Equal(t, "Current value in the input field", mEditor.stepOverHistory(history, forwardDir))
332	assert.Equal(t, "Current value in the input field", mEditor.stepOverHistory(history, forwardDir))
333	assert.Equal(t, "Third message user sent", mEditor.stepOverHistory(history, previousDir))
334	assert.Equal(t, "Second message user sent", mEditor.stepOverHistory(history, previousDir))
335	assert.Equal(t, "First message user sent", mEditor.stepOverHistory(history, previousDir))
336	assert.Equal(t, "First message user sent", mEditor.stepOverHistory(history, previousDir))
337}
338
339// TestHelperFunctions demonstrates how to use the batch message helpers
340func TestHelperFunctions(t *testing.T) {
341	testEditor := newEditor(&app.App{}, mockDirLister([]string{"file1.txt", "file2.txt"}))
342	require.NotNil(t, testEditor)
343
344	// Simulate pressing the '/' key
345	testEditor.Focus()
346	keyPressMsg := tea.KeyPressMsg{
347		Text: "/",
348	}
349
350	_, cmds := testEditor.Update(keyPressMsg)
351
352	// Execute the command and check if it returns a BatchMsg
353	msg := cmds()
354	if batchMsg, ok := msg.(tea.BatchMsg); ok {
355		// Test our helper functions
356		found := assertBatchContainsMessage(t, batchMsg, completions.OpenCompletionsMsg{})
357		assert.True(t, found, "Expected to find OpenCompletionsMsg in batched messages")
358
359		// Test exact message helper
360		foundExact := assertBatchContainsExactMessage(t, batchMsg, completions.OpenCompletionsMsg{})
361		assert.True(t, foundExact, "Expected to find exact OpenCompletionsMsg in batched messages")
362
363		// Test specific completions helper
364		msg, foundSpecific := assertBatchContainsOpenCompletionsMsg(t, batchMsg, nil) // Just check type
365		assert.NotNil(t, msg)
366		assert.True(t, foundSpecific, "Expected to find OpenCompletionsMsg in batched messages")
367	} else {
368		t.Fatal("Expected BatchMsg from cmds()")
369	}
370}