changelog generator & diff tool stormlightlabs.github.io/git-storm/
changelog changeset markdown golang git
at main 14 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/go-git/go-git/v6/plumbing" 11 "github.com/go-git/go-git/v6/plumbing/object" 12 "github.com/stormlightlabs/git-storm/internal/gitlog" 13) 14 15type mockParser struct{} 16 17func (p *mockParser) Parse(hash, subject, body string, date time.Time) (gitlog.CommitMeta, error) { 18 meta := gitlog.CommitMeta{ 19 Type: "feat", 20 Scope: "", 21 Description: subject, 22 Body: body, 23 Breaking: false, 24 Footers: make(map[string]string), 25 } 26 return meta, nil 27} 28 29func (p *mockParser) IsValidType(kind gitlog.CommitKind) bool { 30 return kind != gitlog.CommitTypeUnknown 31} 32 33func (p *mockParser) Categorize(meta gitlog.CommitMeta) string { 34 switch meta.Type { 35 case "feat": 36 return "added" 37 case "fix": 38 return "fixed" 39 default: 40 return "changed" 41 } 42} 43 44func createMockCommit(hash, message string, when time.Time) *object.Commit { 45 return &object.Commit{ 46 Hash: plumbing.NewHash(hash), 47 Message: message, 48 Author: object.Signature{ 49 Name: "Test Author", 50 Email: "test@example.com", 51 When: when, 52 }, 53 } 54} 55 56func TestCommitSelectorModel_Init(t *testing.T) { 57 commits := []*object.Commit{ 58 createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: add feature", time.Now()), 59 } 60 61 parser := &mockParser{} 62 model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 63 64 cmd := model.Init() 65 if cmd != nil { 66 t.Errorf("Init() should return nil, got %v", cmd) 67 } 68} 69 70func TestCommitSelectorModel_AutoSelect(t *testing.T) { 71 now := time.Now() 72 commits := []*object.Commit{ 73 createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: add feature", now), 74 createMockCommit("b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", "fix: bug fix", now), 75 } 76 77 parser := &gitlog.ConventionalParser{} 78 model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 79 80 selectedItems := model.GetSelectedItems() 81 if len(selectedItems) != 2 { 82 t.Errorf("Expected 2 auto-selected items, got %d", len(selectedItems)) 83 } 84} 85 86func TestCommitSelectorModel_GetSelectedCommits(t *testing.T) { 87 now := time.Now() 88 commits := []*object.Commit{ 89 createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: feature 1", now), 90 createMockCommit("b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", "feat: feature 2", now), 91 createMockCommit("c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "feat: feature 3", now), 92 } 93 94 parser := &mockParser{} 95 model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 96 97 model.items[1].Selected = false 98 99 selected := model.GetSelectedCommits() 100 if len(selected) != 2 { 101 t.Errorf("Expected 2 selected commits, got %d", len(selected)) 102 } 103 104 if selected[0].Hash != commits[0].Hash { 105 t.Error("First selected commit should match first commit") 106 } 107 if selected[1].Hash != commits[2].Hash { 108 t.Error("Second selected commit should match third commit") 109 } 110} 111 112func TestCommitSelectorModel_ToggleSelection(t *testing.T) { 113 now := time.Now() 114 commits := []*object.Commit{ 115 createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: test", now), 116 } 117 118 parser := &mockParser{} 119 model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 120 121 initialSelected := model.items[0].Selected 122 123 updatedModel, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) 124 model = updatedModel.(CommitSelectorModel) 125 126 updatedModel, _ = model.Update(tea.KeyMsg{Type: tea.KeySpace}) 127 model = updatedModel.(CommitSelectorModel) 128 129 if model.items[0].Selected == initialSelected { 130 t.Error("Selection should have been toggled") 131 } 132} 133 134func TestCommitSelectorModel_SelectAll(t *testing.T) { 135 now := time.Now() 136 commits := []*object.Commit{ 137 createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: test 1", now), 138 createMockCommit("b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", "feat: test 2", now), 139 createMockCommit("c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "feat: test 3", now), 140 } 141 142 parser := &mockParser{} 143 model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 144 145 for i := range model.items { 146 model.items[i].Selected = false 147 } 148 149 updatedModel, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) 150 model = updatedModel.(CommitSelectorModel) 151 152 updatedModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) 153 model = updatedModel.(CommitSelectorModel) 154 155 for i, item := range model.items { 156 if !item.Selected { 157 t.Errorf("Item %d should be selected", i) 158 } 159 } 160} 161 162func TestCommitSelectorModel_DeselectAll(t *testing.T) { 163 now := time.Now() 164 commits := []*object.Commit{ 165 createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: test 1", now), 166 createMockCommit("b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", "feat: test 2", now), 167 } 168 169 parser := &mockParser{} 170 model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 171 172 updatedModel, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) 173 model = updatedModel.(CommitSelectorModel) 174 175 updatedModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'A'}}) 176 model = updatedModel.(CommitSelectorModel) 177 178 for i, item := range model.items { 179 if item.Selected { 180 t.Errorf("Item %d should be deselected", i) 181 } 182 } 183} 184 185func TestCommitSelectorModel_Navigation(t *testing.T) { 186 now := time.Now() 187 commits := make([]*object.Commit, 50) 188 for i := range commits { 189 hash := strings.Repeat("a", 40) 190 commits[i] = createMockCommit(hash, "feat: test", now) 191 } 192 193 parser := &mockParser{} 194 model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 195 196 tm := teatest.NewTestModel(t, model, teatest.WithInitialTermSize(100, 20)) 197 198 tm.Send(tea.KeyMsg{Type: tea.KeyDown}) 199 tm.Send(tea.KeyMsg{Type: tea.KeyUp}) 200 tm.Send(tea.KeyMsg{Type: tea.KeyPgDown}) 201 tm.Send(tea.KeyMsg{Type: tea.KeyPgUp}) 202 tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) 203 204 tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) 205} 206 207func TestCommitSelectorModel_TopBottom(t *testing.T) { 208 now := time.Now() 209 commits := make([]*object.Commit, 20) 210 for i := range commits { 211 hash := strings.Repeat("a", 40) 212 commits[i] = createMockCommit(hash, "feat: test", now) 213 } 214 215 parser := &mockParser{} 216 model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 217 218 updatedModel, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 20}) 219 model = updatedModel.(CommitSelectorModel) 220 221 updatedModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}}) 222 model = updatedModel.(CommitSelectorModel) 223 224 if model.cursor != len(commits)-1 { 225 t.Errorf("Cursor should be at bottom (index %d), got %d", len(commits)-1, model.cursor) 226 } 227 228 updatedModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}}) 229 model = updatedModel.(CommitSelectorModel) 230 231 if model.cursor != 0 { 232 t.Errorf("Cursor should be at top (index 0), got %d", model.cursor) 233 } 234} 235 236func TestCommitSelectorModel_Confirm(t *testing.T) { 237 now := time.Now() 238 commits := []*object.Commit{ 239 createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: test", now), 240 } 241 242 parser := &mockParser{} 243 model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 244 245 tm := teatest.NewTestModel(t, model) 246 247 tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) 248 tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) 249 250 finalModel := tm.FinalModel(t, teatest.WithFinalTimeout(time.Second)) 251 selectorModel, ok := finalModel.(CommitSelectorModel) 252 if !ok { 253 t.Fatal("Expected CommitSelectorModel") 254 } 255 256 if !selectorModel.IsConfirmed() { 257 t.Error("Model should be confirmed after pressing enter") 258 } 259 if selectorModel.IsCancelled() { 260 t.Error("Model should not be cancelled") 261 } 262} 263 264func TestCommitSelectorModel_QuitKeys(t *testing.T) { 265 now := time.Now() 266 commits := []*object.Commit{ 267 createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: test", now), 268 } 269 270 quitKeys := []struct { 271 name string 272 keyType tea.KeyType 273 runes []rune 274 }{ 275 {"q", tea.KeyRunes, []rune{'q'}}, 276 {"esc", tea.KeyEsc, nil}, 277 {"ctrl+c", tea.KeyCtrlC, nil}, 278 } 279 280 for _, tc := range quitKeys { 281 t.Run(tc.name, func(t *testing.T) { 282 parser := &mockParser{} 283 model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 284 tm := teatest.NewTestModel(t, model) 285 286 var msg tea.Msg 287 if tc.runes != nil { 288 msg = tea.KeyMsg{Type: tc.keyType, Runes: tc.runes} 289 } else { 290 msg = tea.KeyMsg{Type: tc.keyType} 291 } 292 293 tm.Send(msg) 294 tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) 295 296 finalModel := tm.FinalModel(t, teatest.WithFinalTimeout(time.Second)) 297 selectorModel, ok := finalModel.(CommitSelectorModel) 298 if !ok { 299 t.Fatal("Expected CommitSelectorModel") 300 } 301 302 if !selectorModel.IsCancelled() { 303 t.Error("Model should be cancelled after quit key") 304 } 305 if selectorModel.IsConfirmed() { 306 t.Error("Model should not be confirmed") 307 } 308 }) 309 } 310} 311 312func TestCommitSelectorModel_View(t *testing.T) { 313 now := time.Now() 314 commits := []*object.Commit{ 315 createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: add feature", now), 316 } 317 318 parser := &mockParser{} 319 model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 320 321 updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) 322 model = updated.(CommitSelectorModel) 323 324 view := model.View() 325 326 if !strings.Contains(view, "v1.0.0") { 327 t.Error("View should contain fromRef") 328 } 329 if !strings.Contains(view, "HEAD") { 330 t.Error("View should contain toRef") 331 } 332 if !strings.Contains(view, "a1b2c3d") { 333 t.Error("View should contain commit hash") 334 } 335} 336 337func TestCommitSelectorModel_RenderHeader(t *testing.T) { 338 now := time.Now() 339 commits := []*object.Commit{ 340 createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: test", now), 341 } 342 343 parser := &mockParser{} 344 model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 345 model.width = 100 346 347 header := model.renderCommitHeader() 348 349 if !strings.Contains(header, "v1.0.0") { 350 t.Error("Header should contain fromRef") 351 } 352 if !strings.Contains(header, "HEAD") { 353 t.Error("Header should contain toRef") 354 } 355 if !strings.Contains(header, "Select commits") { 356 t.Error("Header should contain instruction text") 357 } 358} 359 360func TestCommitSelectorModel_RenderFooter(t *testing.T) { 361 now := time.Now() 362 commits := []*object.Commit{ 363 createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: test 1", now), 364 createMockCommit("b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", "feat: test 2", now), 365 } 366 367 parser := &mockParser{} 368 model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 369 model.width = 100 370 371 footer := model.renderCommitFooter() 372 373 if !strings.Contains(footer, "navigate") { 374 t.Error("Footer should contain navigation help") 375 } 376 if !strings.Contains(footer, "toggle") { 377 t.Error("Footer should contain toggle help") 378 } 379 if !strings.Contains(footer, "confirm") { 380 t.Error("Footer should contain confirm help") 381 } 382 if !strings.Contains(footer, "selected") { 383 t.Error("Footer should contain selection count") 384 } 385} 386 387func TestCommitSelectorModel_RenderCommitLine(t *testing.T) { 388 now := time.Now() 389 commits := []*object.Commit{ 390 createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: add feature", now), 391 } 392 393 parser := &mockParser{} 394 model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 395 model.width = 100 396 397 line := model.renderCommitLine(0, model.items[0]) 398 399 if !strings.Contains(line, "[") && !strings.Contains(line, "]") { 400 t.Error("Line should contain checkbox") 401 } 402 if !strings.Contains(line, "a1b2c3d") { 403 t.Error("Line should contain short commit hash") 404 } 405 if !strings.Contains(line, "added") { 406 t.Error("Line should contain category") 407 } 408} 409 410func TestFormatTimeAgo(t *testing.T) { 411 now := time.Now() 412 413 tests := []struct { 414 name string 415 time time.Time 416 expected string 417 }{ 418 {"just now", now, "just now"}, 419 {"minutes ago", now.Add(-5 * time.Minute), "5m ago"}, 420 {"hours ago", now.Add(-2 * time.Hour), "2h ago"}, 421 {"days ago", now.Add(-3 * 24 * time.Hour), "3d ago"}, 422 {"months ago", now.Add(-45 * 24 * time.Hour), "1mo ago"}, 423 {"years ago", now.Add(-400 * 24 * time.Hour), "1y ago"}, 424 } 425 426 for _, tc := range tests { 427 t.Run(tc.name, func(t *testing.T) { 428 result := fmtTimeAgo(tc.time) 429 if result != tc.expected { 430 t.Errorf("Expected %q, got %q", tc.expected, result) 431 } 432 }) 433 } 434} 435 436func TestCommitSelectorModel_EmptyCommits(t *testing.T) { 437 commits := []*object.Commit{} 438 439 parser := &mockParser{} 440 model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 441 442 if len(model.items) != 0 { 443 t.Errorf("Expected 0 items, got %d", len(model.items)) 444 } 445 446 selected := model.GetSelectedCommits() 447 if len(selected) != 0 { 448 t.Errorf("Expected 0 selected commits, got %d", len(selected)) 449 } 450} 451 452func TestCommitSelectorModel_WindowResize(t *testing.T) { 453 now := time.Now() 454 commits := []*object.Commit{ 455 createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: test", now), 456 } 457 458 parser := &mockParser{} 459 model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) 460 461 updated, _ := model.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) 462 model = updated.(CommitSelectorModel) 463 464 if model.width != 80 || model.height != 24 { 465 t.Errorf("Expected dimensions 80x24, got %dx%d", model.width, model.height) 466 } 467 if !model.ready { 468 t.Error("Model should be ready after window size message") 469 } 470 471 updated, _ = model.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) 472 model = updated.(CommitSelectorModel) 473 474 if model.width != 120 || model.height != 40 { 475 t.Errorf("Expected dimensions 120x40, got %dx%d", model.width, model.height) 476 } 477}