changelog generator & diff tool stormlightlabs.github.io/git-storm/
changelog changeset markdown golang git
at main 13 kB view raw
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}