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}