changelog generator & diff tool
stormlightlabs.github.io/git-storm/
changelog
changeset
markdown
golang
git
1package ui
2
3import (
4 "strings"
5 "testing"
6 "time"
7
8 tea "github.com/charmbracelet/bubbletea"
9 "github.com/charmbracelet/x/exp/teatest"
10 "github.com/stormlightlabs/git-storm/internal/changeset"
11)
12
13func createMockEntry(filename, entryType, scope, summary string) changeset.EntryWithFile {
14 return changeset.EntryWithFile{
15 Entry: changeset.Entry{
16 Type: entryType,
17 Scope: scope,
18 Summary: summary,
19 Breaking: false,
20 },
21 Filename: filename,
22 }
23}
24
25func TestChangesetReviewModel_Init(t *testing.T) {
26 entries := []changeset.EntryWithFile{
27 createMockEntry("test.md", "added", "cli", "Test entry"),
28 }
29
30 model := NewChangesetReviewModel(entries)
31
32 cmd := model.Init()
33 if cmd != nil {
34 t.Errorf("Init() should return nil, got %v", cmd)
35 }
36}
37
38func TestChangesetReviewModel_DefaultActions(t *testing.T) {
39 entries := []changeset.EntryWithFile{
40 createMockEntry("test1.md", "added", "cli", "Test entry 1"),
41 createMockEntry("test2.md", "fixed", "", "Test entry 2"),
42 }
43
44 model := NewChangesetReviewModel(entries)
45
46 for i, item := range model.items {
47 if item.Action != ActionKeep {
48 t.Errorf("Item %d should default to ActionKeep, got %v", i, item.Action)
49 }
50 }
51}
52
53func TestChangesetReviewModel_MarkDelete(t *testing.T) {
54 entries := []changeset.EntryWithFile{
55 createMockEntry("test.md", "added", "cli", "Test entry"),
56 }
57
58 model := NewChangesetReviewModel(entries)
59
60 updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
61 model = updated.(ChangesetReviewModel)
62
63 updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
64 model = updated.(ChangesetReviewModel)
65
66 if model.items[0].Action != ActionDelete {
67 t.Errorf("Item should be marked for deletion, got action %v", model.items[0].Action)
68 }
69}
70
71func TestChangesetReviewModel_MarkEdit(t *testing.T) {
72 entries := []changeset.EntryWithFile{
73 createMockEntry("test.md", "added", "cli", "Test entry"),
74 }
75
76 model := NewChangesetReviewModel(entries)
77
78 updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
79 model = updated.(ChangesetReviewModel)
80
81 updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}})
82 model = updated.(ChangesetReviewModel)
83
84 if model.items[0].Action != ActionEdit {
85 t.Errorf("Item should be marked for editing, got action %v", model.items[0].Action)
86 }
87}
88
89func TestChangesetReviewModel_KeepAction(t *testing.T) {
90 entries := []changeset.EntryWithFile{
91 createMockEntry("test.md", "added", "cli", "Test entry"),
92 }
93
94 model := NewChangesetReviewModel(entries)
95
96 updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
97 model = updated.(ChangesetReviewModel)
98
99 updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
100 model = updated.(ChangesetReviewModel)
101
102 if model.items[0].Action != ActionDelete {
103 t.Fatal("Setup failed: item should be marked for deletion")
104 }
105
106 updated, _ = model.Update(tea.KeyMsg{Type: tea.KeySpace})
107 model = updated.(ChangesetReviewModel)
108
109 if model.items[0].Action != ActionKeep {
110 t.Errorf("Item should be marked for keeping, got action %v", model.items[0].Action)
111 }
112}
113
114func TestChangesetReviewModel_Navigation(t *testing.T) {
115 entries := []changeset.EntryWithFile{
116 createMockEntry("test1.md", "added", "cli", "Test 1"),
117 createMockEntry("test2.md", "fixed", "", "Test 2"),
118 createMockEntry("test3.md", "changed", "api", "Test 3"),
119 }
120
121 model := NewChangesetReviewModel(entries)
122
123 updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
124 model = updated.(ChangesetReviewModel)
125
126 if model.cursor != 0 {
127 t.Error("Cursor should start at 0")
128 }
129
130 updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown})
131 model = updated.(ChangesetReviewModel)
132
133 if model.cursor != 1 {
134 t.Errorf("Cursor should be at 1 after down, got %d", model.cursor)
135 }
136
137 updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyUp})
138 model = updated.(ChangesetReviewModel)
139
140 if model.cursor != 0 {
141 t.Errorf("Cursor should be at 0 after up, got %d", model.cursor)
142 }
143}
144
145func TestChangesetReviewModel_TopBottom(t *testing.T) {
146 entries := make([]changeset.EntryWithFile, 10)
147 for i := range entries {
148 entries[i] = createMockEntry("test.md", "added", "", "Test")
149 }
150
151 model := NewChangesetReviewModel(entries)
152
153 updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
154 model = updated.(ChangesetReviewModel)
155
156 updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}})
157 model = updated.(ChangesetReviewModel)
158
159 if model.cursor != len(entries)-1 {
160 t.Errorf("Cursor should be at bottom (index %d), got %d", len(entries)-1, model.cursor)
161 }
162
163 updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}})
164 model = updated.(ChangesetReviewModel)
165
166 if model.cursor != 0 {
167 t.Errorf("Cursor should be at top (index 0), got %d", model.cursor)
168 }
169}
170
171func TestChangesetReviewModel_Confirm(t *testing.T) {
172 entries := []changeset.EntryWithFile{
173 createMockEntry("test.md", "added", "cli", "Test entry"),
174 }
175
176 model := NewChangesetReviewModel(entries)
177 tm := teatest.NewTestModel(t, model)
178
179 tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
180 tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second))
181
182 finalModel := tm.FinalModel(t, teatest.WithFinalTimeout(time.Second))
183 reviewModel, ok := finalModel.(ChangesetReviewModel)
184 if !ok {
185 t.Fatal("Expected ChangesetReviewModel")
186 }
187
188 if !reviewModel.IsConfirmed() {
189 t.Error("Model should be confirmed after pressing enter")
190 }
191 if reviewModel.IsCancelled() {
192 t.Error("Model should not be cancelled")
193 }
194}
195
196func TestChangesetReviewModel_QuitKeys(t *testing.T) {
197 entries := []changeset.EntryWithFile{
198 createMockEntry("test.md", "added", "cli", "Test entry"),
199 }
200
201 quitKeys := []struct {
202 name string
203 keyType tea.KeyType
204 runes []rune
205 }{
206 {"q", tea.KeyRunes, []rune{'q'}},
207 {"esc", tea.KeyEsc, nil},
208 {"ctrl+c", tea.KeyCtrlC, nil},
209 }
210
211 for _, tc := range quitKeys {
212 t.Run(tc.name, func(t *testing.T) {
213 model := NewChangesetReviewModel(entries)
214 tm := teatest.NewTestModel(t, model)
215
216 var msg tea.Msg
217 if tc.runes != nil {
218 msg = tea.KeyMsg{Type: tc.keyType, Runes: tc.runes}
219 } else {
220 msg = tea.KeyMsg{Type: tc.keyType}
221 }
222
223 tm.Send(msg)
224 tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second))
225
226 finalModel := tm.FinalModel(t, teatest.WithFinalTimeout(time.Second))
227 reviewModel, ok := finalModel.(ChangesetReviewModel)
228 if !ok {
229 t.Fatal("Expected ChangesetReviewModel")
230 }
231
232 if !reviewModel.IsCancelled() {
233 t.Error("Model should be cancelled after quit key")
234 }
235 if reviewModel.IsConfirmed() {
236 t.Error("Model should not be confirmed")
237 }
238 })
239 }
240}
241
242func TestChangesetReviewModel_GetReviewedItems(t *testing.T) {
243 entries := []changeset.EntryWithFile{
244 createMockEntry("test1.md", "added", "cli", "Test 1"),
245 createMockEntry("test2.md", "fixed", "", "Test 2"),
246 createMockEntry("test3.md", "changed", "api", "Test 3"),
247 }
248
249 model := NewChangesetReviewModel(entries)
250
251 updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
252 model = updated.(ChangesetReviewModel)
253
254 updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
255 model = updated.(ChangesetReviewModel)
256
257 updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown})
258 model = updated.(ChangesetReviewModel)
259
260 updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}})
261 model = updated.(ChangesetReviewModel)
262
263 items := model.GetReviewedItems()
264
265 if len(items) != 3 {
266 t.Errorf("Expected 3 items, got %d", len(items))
267 }
268
269 if items[0].Action != ActionDelete {
270 t.Errorf("First item should be ActionDelete, got %v", items[0].Action)
271 }
272
273 if items[1].Action != ActionEdit {
274 t.Errorf("Second item should be ActionEdit, got %v", items[1].Action)
275 }
276
277 if items[2].Action != ActionKeep {
278 t.Errorf("Third item should be ActionKeep, got %v", items[2].Action)
279 }
280}
281
282func TestChangesetReviewModel_View(t *testing.T) {
283 entries := []changeset.EntryWithFile{
284 createMockEntry("test.md", "added", "cli", "Test entry"),
285 }
286
287 model := NewChangesetReviewModel(entries)
288
289 updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
290 model = updated.(ChangesetReviewModel)
291
292 view := model.View()
293
294 if !strings.Contains(view, "Review unreleased changes") {
295 t.Error("View should contain header text")
296 }
297 if !strings.Contains(view, "navigate") {
298 t.Error("View should contain navigation help")
299 }
300 if !strings.Contains(view, "keep") {
301 t.Error("View should contain action counts")
302 }
303}
304
305func TestChangesetReviewModel_RenderHeader(t *testing.T) {
306 entries := []changeset.EntryWithFile{
307 createMockEntry("test1.md", "added", "cli", "Test 1"),
308 createMockEntry("test2.md", "fixed", "", "Test 2"),
309 }
310
311 model := NewChangesetReviewModel(entries)
312 model.width = 100
313
314 header := model.renderReviewHeader()
315
316 if !strings.Contains(header, "Review unreleased changes") {
317 t.Error("Header should contain title")
318 }
319 if !strings.Contains(header, "2") {
320 t.Error("Header should contain entry count")
321 }
322}
323
324func TestChangesetReviewModel_RenderFooter(t *testing.T) {
325 entries := []changeset.EntryWithFile{
326 createMockEntry("test1.md", "added", "cli", "Test 1"),
327 createMockEntry("test2.md", "fixed", "", "Test 2"),
328 createMockEntry("test3.md", "changed", "api", "Test 3"),
329 }
330
331 model := NewChangesetReviewModel(entries)
332 model.width = 100
333
334 updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
335 model = updated.(ChangesetReviewModel)
336
337 updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
338 model = updated.(ChangesetReviewModel)
339
340 updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown})
341 model = updated.(ChangesetReviewModel)
342
343 updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}})
344 model = updated.(ChangesetReviewModel)
345
346 footer := model.renderReviewFooter()
347
348 if !strings.Contains(footer, "keep: 1") {
349 t.Error("Footer should show 1 keep action")
350 }
351 if !strings.Contains(footer, "delete: 1") {
352 t.Error("Footer should show 1 delete action")
353 }
354 if !strings.Contains(footer, "edit: 1") {
355 t.Error("Footer should show 1 edit action")
356 }
357}
358
359func TestChangesetReviewModel_RenderReviewLine(t *testing.T) {
360 entries := []changeset.EntryWithFile{
361 createMockEntry("test.md", "added", "cli", "Test entry"),
362 }
363
364 model := NewChangesetReviewModel(entries)
365 model.width = 100
366
367 line := model.renderReviewLine(0, model.items[0])
368
369 if !strings.Contains(line, "[") {
370 t.Error("Line should contain action icon")
371 }
372 if !strings.Contains(line, "added") {
373 t.Error("Line should contain type")
374 }
375 if !strings.Contains(line, "cli") {
376 t.Error("Line should contain scope")
377 }
378 if !strings.Contains(line, "Test entry") {
379 t.Error("Line should contain summary")
380 }
381}
382
383func TestChangesetReviewModel_EmptyEntries(t *testing.T) {
384 entries := []changeset.EntryWithFile{}
385
386 model := NewChangesetReviewModel(entries)
387
388 if len(model.items) != 0 {
389 t.Errorf("Expected 0 items, got %d", len(model.items))
390 }
391
392 items := model.GetReviewedItems()
393 if len(items) != 0 {
394 t.Errorf("Expected 0 reviewed items, got %d", len(items))
395 }
396}
397
398func TestChangesetReviewModel_WindowResize(t *testing.T) {
399 entries := []changeset.EntryWithFile{
400 createMockEntry("test.md", "added", "cli", "Test entry"),
401 }
402
403 model := NewChangesetReviewModel(entries)
404
405 updated, _ := model.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
406 model = updated.(ChangesetReviewModel)
407
408 if model.width != 80 || model.height != 24 {
409 t.Errorf("Expected dimensions 80x24, got %dx%d", model.width, model.height)
410 }
411 if !model.ready {
412 t.Error("Model should be ready after window size message")
413 }
414
415 updated, _ = model.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
416 model = updated.(ChangesetReviewModel)
417
418 if model.width != 120 || model.height != 40 {
419 t.Errorf("Expected dimensions 120x40, got %dx%d", model.width, model.height)
420 }
421}
422
423func TestChangesetReviewModel_ActionCounts(t *testing.T) {
424 entries := []changeset.EntryWithFile{
425 createMockEntry("test1.md", "added", "cli", "Test 1"),
426 createMockEntry("test2.md", "fixed", "", "Test 2"),
427 createMockEntry("test3.md", "changed", "api", "Test 3"),
428 createMockEntry("test4.md", "removed", "", "Test 4"),
429 }
430
431 model := NewChangesetReviewModel(entries)
432
433 updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
434 model = updated.(ChangesetReviewModel)
435
436 updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
437 model = updated.(ChangesetReviewModel)
438
439 updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown})
440 model = updated.(ChangesetReviewModel)
441
442 updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
443 model = updated.(ChangesetReviewModel)
444
445 updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown})
446 model = updated.(ChangesetReviewModel)
447
448 updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}})
449 model = updated.(ChangesetReviewModel)
450
451 items := model.GetReviewedItems()
452
453 deleteCount := 0
454 editCount := 0
455 keepCount := 0
456
457 for _, item := range items {
458 switch item.Action {
459 case ActionDelete:
460 deleteCount++
461 case ActionEdit:
462 editCount++
463 case ActionKeep:
464 keepCount++
465 }
466 }
467
468 if deleteCount != 2 {
469 t.Errorf("Expected 2 delete actions, got %d", deleteCount)
470 }
471 if editCount != 1 {
472 t.Errorf("Expected 1 edit action, got %d", editCount)
473 }
474 if keepCount != 1 {
475 t.Errorf("Expected 1 keep action, got %d", keepCount)
476 }
477}