editor_test.go

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