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/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}