Skip to content

Commit 8114e9f

Browse files
authored
test: Add unit tests for UI Model Update logic (#17)
* test: Add unit tests for UI Model Update logic Introduce comprehensive unit tests for the main Bubbletea model (`internal/ui/Model`), ensuring correct state transitions and command generation upon receiving various messages. Includes mock implementations for: * Git client interaction * LLM provider API calls * Sub-models (`CommitView`, `PromptView`) The dependency `github.com/stretchr/objx` was added to support the test utilities. * test: Check textinput Blink command in model Updates the assertion in the `TestModel_Update` for the `regenerateMsg` handler to verify that the command returned is the expected `textinput.Blink()` initialization command. * test: Remove unnecessary commit view mocking The tests for `Model.Update` handling `llmResultMsg` no longer require setting up and asserting calls on a mocked commit view. Instead, we now assert directly that the resulting command is of type `textarea.Blink()`, which confirms the state transition and command generation correctly occurred. This simplifies the test implementation. * test: Refine mock expectations in model tests Updates tests within `internal/ui/model_test.go`: * Makes the mocked `commitView` update expectation explicit when testing `Ctrl+C` handling. * Removes redundant assertion on the updated model within the spinner tick message test, focusing solely on validating that a command is returned. * test: Handle missing args in mockTeaModel Refactor the `mockTeaModel` methods to safely retrieve return values from the underlying mocking framework (`m.Called`). This ensures the mock does not panic if expected arguments are missing or of the incorrect type, defaulting to `nil` or the mock receiver itself where appropriate.
1 parent 600e787 commit 8114e9f

File tree

3 files changed

+338
-0
lines changed

3 files changed

+338
-0
lines changed

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ require (
4747
github.com/rivo/uniseg v0.4.7 // indirect
4848
github.com/rogpeppe/go-internal v1.14.1 // indirect
4949
github.com/spf13/pflag v1.0.10 // indirect
50+
github.com/stretchr/objx v0.5.2 // indirect
5051
github.com/tidwall/gjson v1.18.0 // indirect
5152
github.com/tidwall/match v1.2.0 // indirect
5253
github.com/tidwall/pretty v1.2.1 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
146146
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
147147
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
148148
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
149+
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
150+
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
149151
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
150152
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
151153
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=

internal/ui/model_test.go

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
package ui
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/charmbracelet/bubbles/spinner"
8+
"github.com/charmbracelet/bubbles/textarea"
9+
"github.com/charmbracelet/bubbles/textinput"
10+
tea "github.com/charmbracelet/bubbletea"
11+
"github.com/cockroachdb/errors"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/mock"
14+
15+
"github.com/rm-hull/git-commit-summary/internal/interfaces"
16+
llmprovider "github.com/rm-hull/git-commit-summary/internal/llm_provider"
17+
)
18+
19+
// MockLLMProvider is a mock implementation of llmprovider.Provider
20+
type MockLLMProvider struct {
21+
mock.Mock
22+
}
23+
24+
func (m *MockLLMProvider) Call(ctx context.Context, model string, prompt string) (string, error) {
25+
args := m.Called(ctx, model, prompt)
26+
return args.String(0), args.Error(1)
27+
}
28+
29+
func (m *MockLLMProvider) Model() string {
30+
args := m.Called()
31+
return args.String(0)
32+
}
33+
34+
// MockGitClient is a mock implementation of interfaces.GitClient
35+
type MockGitClient struct {
36+
mock.Mock
37+
}
38+
39+
func (m *MockGitClient) IsInWorkTree() error {
40+
args := m.Called()
41+
return args.Error(0)
42+
}
43+
44+
func (m *MockGitClient) StagedFiles() ([]string, error) {
45+
args := m.Called()
46+
return args.Get(0).([]string), args.Error(1)
47+
}
48+
49+
func (m *MockGitClient) Diff() (string, error) {
50+
args := m.Called()
51+
return args.String(0), args.Error(1)
52+
}
53+
54+
func (m *MockGitClient) Commit(message string) error {
55+
args := m.Called(message)
56+
return args.Error(0)
57+
}
58+
59+
func TestModel_Update(t *testing.T) {
60+
ctx := context.Background()
61+
mockLLM := new(MockLLMProvider)
62+
mockGit := new(MockGitClient)
63+
64+
// Common setup for InitialModel
65+
initialModel := func() *Model {
66+
// Explicitly use the types to avoid "imported and not used" warnings
67+
var _ interfaces.GitClient = mockGit
68+
var _ llmprovider.Provider = mockLLM
69+
return InitialModel(ctx, mockLLM, mockGit, "system prompt", "user message")
70+
}
71+
72+
t.Run("tea.KeyMsg - CtrlC in showSpinner state", func(t *testing.T) {
73+
m := initialModel()
74+
m.state = showSpinner // Ensure initial state is showSpinner
75+
76+
updatedModel, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC})
77+
78+
assert.Equal(t, Abort, updatedModel.(*Model).action)
79+
assert.NotNil(t, cmd)
80+
assert.IsType(t, tea.QuitMsg{}, cmd())
81+
})
82+
83+
t.Run("tea.KeyMsg - CtrlC in other states", func(t *testing.T) {
84+
m := initialModel()
85+
m.state = showCommitView // Set to a state other than showSpinner
86+
87+
// Mock the sub-model's Update method
88+
mockCommitView := new(mockTeaModel)
89+
mockCommitView.On("Update", tea.KeyMsg{Type: tea.KeyCtrlC}).Return(mockCommitView, (tea.Cmd)(nil))
90+
m.commitView = mockCommitView
91+
92+
updatedModel, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC})
93+
94+
assert.Equal(t, None, updatedModel.(*Model).action) // Action should not be Abort
95+
assert.Nil(t, cmd) // No tea.Quit command
96+
mockCommitView.AssertCalled(t, "Update", tea.KeyMsg{Type: tea.KeyCtrlC})
97+
})
98+
99+
t.Run("gitCheckMsg - empty (no staged changes)", func(t *testing.T) {
100+
m := initialModel()
101+
m.state = showSpinner // Ensure initial state is showSpinner
102+
103+
updatedModel, cmd := m.Update(gitCheckMsg{})
104+
105+
assert.NotNil(t, updatedModel.(*Model).err)
106+
assert.NotNil(t, cmd)
107+
assert.IsType(t, tea.QuitMsg{}, cmd())
108+
})
109+
110+
t.Run("gitCheckMsg - non-empty (staged changes)", func(t *testing.T) {
111+
m := initialModel()
112+
m.state = showSpinner // Ensure initial state is showSpinner
113+
114+
mockGit.On("Diff").Return("mocked diff content", nil).Once()
115+
116+
updatedModel, cmd := m.Update(gitCheckMsg{"file1.go", "file2.go"})
117+
118+
assert.Nil(t, updatedModel.(*Model).err)
119+
assert.NotNil(t, cmd)
120+
msg := cmd()
121+
assert.IsType(t, gitDiffMsg(""), msg)
122+
assert.Equal(t, gitDiffMsg("mocked diff content"), msg)
123+
mockGit.AssertExpectations(t)
124+
})
125+
126+
t.Run("gitDiffMsg", func(t *testing.T) {
127+
m := initialModel()
128+
m.state = showSpinner // Ensure initial state is showSpinner
129+
mockLLM.On("Model").Return("test-model").Once()
130+
// The command returned by Update will execute llmProvider.Call later.
131+
// No need to set mockLLM.On("Call") here.
132+
133+
diffContent := "diff --git a/file.go b/file.go"
134+
updatedModel, cmd := m.Update(gitDiffMsg(diffContent))
135+
136+
assert.Equal(t, diffContent, updatedModel.(*Model).diff)
137+
assert.Contains(t, updatedModel.(*Model).spinnerMessage, "Generating commit summary (using: test-model)")
138+
assert.IsType(t, tea.Batch(nil), cmd)
139+
mockLLM.AssertExpectations(t)
140+
})
141+
142+
t.Run("llmResultMsg - with user message", func(t *testing.T) {
143+
m := initialModel()
144+
m.state = showSpinner // Ensure initial state is showSpinner
145+
llmResult := "This is a summary from LLM."
146+
userMsg := "Additional user message."
147+
148+
m.userMessage = userMsg // Set user message for this test case
149+
150+
updatedModel, cmd := m.Update(llmResultMsg(llmResult))
151+
152+
assert.Equal(t, showCommitView, updatedModel.(*Model).state)
153+
// Assert that the commitView is set, but not its content directly from Update
154+
assert.NotNil(t, updatedModel.(*Model).commitView)
155+
assert.NotNil(t, cmd)
156+
assert.IsType(t, textarea.Blink(), cmd())
157+
})
158+
159+
t.Run("llmResultMsg - without user message", func(t *testing.T) {
160+
m := initialModel()
161+
m.state = showSpinner // Ensure initial state is showSpinner
162+
llmResult := "This is a summary from LLM."
163+
m.userMessage = "" // Ensure no user message
164+
165+
updatedModel, cmd := m.Update(llmResultMsg(llmResult))
166+
167+
assert.Equal(t, showCommitView, updatedModel.(*Model).state)
168+
// Assert that the commitView is set, but not its content directly from Update
169+
assert.NotNil(t, updatedModel.(*Model).commitView)
170+
assert.NotNil(t, cmd)
171+
assert.IsType(t, textarea.Blink(), cmd())
172+
})
173+
174+
t.Run("commitMsg", func(t *testing.T) {
175+
m := initialModel()
176+
m.state = showCommitView // Ensure state is showCommitView
177+
178+
commitContent := "feat: new feature"
179+
updatedModel, cmd := m.Update(commitMsg(commitContent))
180+
181+
assert.Equal(t, Commit, updatedModel.(*Model).action)
182+
assert.Equal(t, commitContent, updatedModel.(*Model).commitMessage)
183+
assert.NotNil(t, cmd)
184+
assert.IsType(t, tea.QuitMsg{}, cmd())
185+
})
186+
187+
t.Run("regenerateMsg", func(t *testing.T) {
188+
m := initialModel()
189+
m.state = showCommitView // Ensure state is showCommitView
190+
191+
updatedModel, cmd := m.Update(regenerateMsg{})
192+
193+
assert.Equal(t, showRegeneratePrompt, updatedModel.(*Model).state)
194+
assert.NotNil(t, updatedModel.(*Model).promptView)
195+
assert.NotNil(t, cmd)
196+
assert.IsType(t, textinput.Blink(), cmd())
197+
})
198+
199+
t.Run("userResponseMsg", func(t *testing.T) {
200+
m := initialModel()
201+
m.state = showRegeneratePrompt // Ensure state is showRegeneratePrompt
202+
mockLLM.On("Model").Return("test-model").Once()
203+
// The command returned by Update will execute llmProvider.Call later.
204+
// No need to set mockLLM.On("Call") here.
205+
206+
userResponse := "make it shorter"
207+
updatedModel, cmd := m.Update(userResponseMsg(userResponse))
208+
209+
assert.Equal(t, showSpinner, updatedModel.(*Model).state)
210+
assert.Contains(t, updatedModel.(*Model).spinnerMessage, "Re-generating commit summary (using: test-model)")
211+
assert.IsType(t, tea.Batch(nil), cmd) // Should return tea.Batch(m.spinner.Tick, m.generateSummary)
212+
mockLLM.AssertExpectations(t)
213+
})
214+
215+
t.Run("cancelRegenPromptMsg", func(t *testing.T) {
216+
m := initialModel()
217+
m.state = showRegeneratePrompt // Ensure state is showRegeneratePrompt
218+
219+
// Mock the sub-model's Init method
220+
mockCommitView := new(mockTeaModel)
221+
mockCommitView.On("Init").Return((tea.Cmd)(nil)).Once()
222+
m.commitView = mockCommitView
223+
224+
updatedModel, cmd := m.Update(cancelRegenPromptMsg{})
225+
226+
assert.Equal(t, showCommitView, updatedModel.(*Model).state)
227+
assert.Nil(t, cmd) // Should return m.commitView.Init() which is mocked to return nil
228+
mockCommitView.AssertExpectations(t)
229+
})
230+
231+
t.Run("errMsg", func(t *testing.T) {
232+
m := initialModel()
233+
m.state = showSpinner // Ensure state is showSpinner
234+
235+
testErr := errors.New("something went wrong")
236+
updatedModel, cmd := m.Update(errMsg{err: testErr})
237+
238+
assert.Equal(t, testErr, updatedModel.(*Model).err)
239+
assert.NotNil(t, cmd)
240+
assert.IsType(t, tea.QuitMsg{}, cmd())
241+
})
242+
243+
t.Run("abortMsg", func(t *testing.T) {
244+
m := initialModel()
245+
m.state = showCommitView // Ensure state is showCommitView
246+
247+
updatedModel, cmd := m.Update(abortMsg{})
248+
249+
assert.Equal(t, Abort, updatedModel.(*Model).action)
250+
assert.NotNil(t, cmd)
251+
assert.IsType(t, tea.QuitMsg{}, cmd())
252+
})
253+
254+
t.Run("spinner.Update for showSpinner state", func(t *testing.T) {
255+
m := initialModel()
256+
m.state = showSpinner
257+
// Spinner's Update method is tested by charmbracelet/bubbles,
258+
// here we just ensure it's called and returns its cmd.
259+
// We can't easily mock spinner.Model directly, so we'll check the cmd.
260+
_, cmd := m.Update(spinner.TickMsg{})
261+
assert.NotNil(t, cmd)
262+
assert.IsType(t, spinner.TickMsg{}, cmd())
263+
})
264+
265+
t.Run("commitView.Update for showCommitView state", func(t *testing.T) {
266+
m := initialModel()
267+
m.state = showCommitView
268+
mockCommitView := new(mockTeaModel)
269+
mockCommitView.On("Update", mock.Anything).Return(mockCommitView, (tea.Cmd)(nil)).Once()
270+
m.commitView = mockCommitView
271+
272+
testMsg := tea.KeyMsg{Type: tea.KeyEnter}
273+
updatedModel, cmd := m.Update(testMsg)
274+
275+
assert.NotNil(t, updatedModel)
276+
assert.Nil(t, cmd) // Mock returns nil cmd
277+
mockCommitView.AssertCalled(t, "Update", testMsg)
278+
})
279+
280+
t.Run("promptView.Update for showRegeneratePrompt state", func(t *testing.T) {
281+
m := initialModel()
282+
m.state = showRegeneratePrompt
283+
mockPromptView := new(mockTeaModel)
284+
mockPromptView.On("Update", mock.Anything).Return(mockPromptView, (tea.Cmd)(nil)).Once()
285+
m.promptView = mockPromptView
286+
287+
testMsg := tea.KeyMsg{Type: tea.KeyEnter}
288+
updatedModel, cmd := m.Update(testMsg)
289+
290+
assert.NotNil(t, updatedModel)
291+
assert.Nil(t, cmd) // Mock returns nil cmd
292+
mockPromptView.AssertCalled(t, "Update", testMsg)
293+
})
294+
}
295+
296+
// mockTeaModel is a generic mock for tea.Model interface
297+
type mockTeaModel struct {
298+
mock.Mock
299+
}
300+
301+
func (m *mockTeaModel) Init() tea.Cmd {
302+
args := m.Called()
303+
if len(args) > 0 {
304+
if cmd, ok := args.Get(0).(tea.Cmd); ok {
305+
return cmd
306+
}
307+
}
308+
return nil
309+
}
310+
311+
func (m *mockTeaModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
312+
args := m.Called(msg)
313+
var model tea.Model = m
314+
var cmd tea.Cmd
315+
316+
if len(args) > 0 {
317+
if m, ok := args.Get(0).(tea.Model); ok {
318+
model = m
319+
}
320+
}
321+
if len(args) > 1 {
322+
if c, ok := args.Get(1).(tea.Cmd); ok {
323+
cmd = c
324+
}
325+
}
326+
return model, cmd
327+
}
328+
329+
func (m *mockTeaModel) View() string {
330+
args := m.Called()
331+
if len(args) > 0 {
332+
return args.String(0)
333+
}
334+
return ""
335+
}

0 commit comments

Comments
 (0)