package main import ( "fmt" "strings" "testing" "time" tea "github.com/charmbracelet/bubbletea" ) func notThis[T comparable](f compareFunc[T]) compareFunc[T] { return func(a, b T) bool { return !f(a, b) } } type compareFunc[T comparable] func(T, T) bool func assertFunc[T comparable](t *testing.T, compare compareFunc[T], actual, expected T, msg string) { t.Helper() if !compare(actual, expected) { t.Errorf("%s: expected %v, got %v", msg, expected, actual) } } // assertEqual checks if two values are equal and reports an error if they are not. func assertEqual(t *testing.T, actual, expected interface{}, msg string) { t.Helper() if actual != expected { t.Errorf("%s: expected %v, got %v", msg, expected, actual) } } // assertNotEqual checks if two values are not equal and reports an error if they are. func assertNotEqual(t *testing.T, actual, expected interface{}, msg string) { t.Helper() if actual == expected { t.Errorf("%s: expected %v to be different from %v", msg, actual, expected) } } // keyMsgs converts a series of strings or KeyTypes into a slice of KeyMsgs func keyMsgs(keys ...interface{}) []tea.KeyMsg { keyMessages := []tea.KeyMsg{} for _, key := range keys { switch keyType := key.(type) { case tea.KeyType: keyMessages = append(keyMessages, tea.KeyMsg{Type: keyType}) case string: for _, r := range key.(string) { keyMessages = append(keyMessages, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) } default: panic(fmt.Sprintf("Unknown key type: %v", key)) } } return keyMessages } func sendKeys[T tea.Model](m T, keys ...interface{}) (T, []tea.Cmd) { var updatedModel tea.Model var cmd tea.Cmd var cmds []tea.Cmd for _, key := range keyMsgs(keys...) { updatedModel, cmd = m.Update(key) m = updatedModel.(T) if cmd != nil { m, cmds, _ = batchCmdUpdate(m, cmd) } } return m, cmds } func batchCmdUpdate[T tea.Model](m T, cmd tea.Cmd) (T, []tea.Cmd, error) { var updatedModel tea.Model var resultCmd tea.Cmd var cmds []tea.Cmd resultMsg := cmd() batchMsg, ok := resultMsg.(tea.BatchMsg) if !ok { updatedModel, resultCmd = m.Update(resultMsg) m = updatedModel.(T) cmds = append(cmds, resultCmd) return m, cmds, fmt.Errorf("Expected a batch message, got %v", resultMsg) } // Apply batch messages for _, cmd := range batchMsg { updatedModel, resultCmd = m.Update(cmd()) m = updatedModel.(T) cmds = append(cmds, resultCmd) } return m, cmds, nil } type mockModel struct { messages []tea.Msg } func (m mockModel) Init() tea.Cmd { return nil } func (m mockModel) View() string { s := "" for _, msg := range m.messages { if keyMsg, ok := msg.(tea.KeyMsg); ok { if keyMsg.Type == tea.KeyRunes { s += string(keyMsg.Runes) } } } return s } func (m mockModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd m.messages = append(m.messages, msg) if msg, ok := msg.(tea.KeyMsg); ok { if msg.Type == tea.KeyEnter { cmds = append(cmds, func() tea.Msg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'\n'}} }) } } return m, tea.Batch(cmds...) } func TestBatchCmdUpdate(t *testing.T) { m := mockModel{} cmds := []tea.Cmd{ func() tea.Msg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}} }, func() tea.Msg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'b'}} }, } m, _, err := batchCmdUpdate(m, tea.Batch(cmds...)) if err != nil { t.Fatalf("Expected no error, got %v", err) } if len(m.messages) != 2 { t.Fatalf("Expected 2 messages, got %v", len(m.messages)) } if m.View() != "ab" { t.Fatalf("Expected 'ab', got %s, %+v", m.View(), m) } m, _, err = batchCmdUpdate(m, func() tea.Msg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}} }) if err == nil { t.Fatalf("Expected error, got nil") } if len(m.messages) != 3 { t.Fatalf("Expected 3 messages, got %v", len(m.messages)) } if m.View() != "abc" { t.Fatalf("Expected 'abc', got %v", m.View()) } } func TestSendKeys(t *testing.T) { m := mockModel{} m, _ = sendKeys(m, "ab") if len(m.messages) != 2 { t.Fatalf("Expected 2 messages, got %v", len(m.messages)) } if m.View() != "ab" { t.Fatalf("Expected 'ab', got %s", m.View()) } m, _ = sendKeys(m, "c", tea.KeyEnter) // We expect 5 because enter key should create a new message for the newline if len(m.messages) != 5 { t.Fatalf("Expected 5 messages, got %v", len(m.messages)) } if m.View() != "abc\n" { t.Fatalf("Expected 'abc\n', got %s", m.View()) } } // TestParseDuration tests the parseDuration function func TestParseDuration(t *testing.T) { tests := []struct { input string expected time.Duration hasError bool }{ {"10", 10 * time.Minute, false}, {"1h", 1 * time.Hour, false}, {"invalid", 0, true}, } for _, test := range tests { result, err := parseDuration(test.input) if test.hasError { assertNotEqual(t, err, nil, "Expected an error for input "+test.input) } else { assertEqual(t, err, nil, "Did not expect an error for input "+test.input) assertEqual(t, result, test.expected, "Expected duration for input "+test.input) } } } // TestRunCommands tests the runCommands function func TestRunCommands(t *testing.T) { m := newModelBasic(false, "#ffdd57", "#57ddff") m.onFocusStart = []string{"echo Focus Start"} m.Init() m.startCommands(m.onFocusStart) } // TestInputView tests the Update method of the model for the input view func TestInputView(t *testing.T) { m := newModel(false, "#ffdd57", "#57ddff", 0, 0, 0, []string{}, []string{}, []string{}) var err error m, _, err = batchCmdUpdate(m, m.Init()) if err != nil { t.Fatalf("Expected batch command after init: %v", err) } // Verify we're on the input screen assertEqual(t, m.currentScreen, INPUT_SCREEN, "Expected currentScreen to be INPUT_SCREEN") assertFunc(t, strings.Contains, m.View(), "Break time", "Expected view to contain 'Break time'") // Fill the form m, _ = sendKeys(m, "10m", tea.KeyEnter, "10m", tea.KeyEnter, "2", tea.KeyEnter) // Pass on submit command, doesn't happen because we have multiple layers of batching at the end m, _, _ = batchCmdUpdate(m, formSubmit) assertEqual(t, m.focusTime, 10*time.Minute, "Expected focus time to be 10 minutes") assertEqual(t, m.breakTime, 10*time.Minute, "Expected break time to be 10 minutes") assertEqual(t, m.intervals, 2, "Expected rounds to be 2") // Pass on start command, doesn't happen because we have multiple layers of batching at the end m, _, _ = batchCmdUpdate(m, startTimer) assertFunc(t, notThis(strings.Contains), m.View(), "Break time", "Expected view to NOT contain 'Break time'") assertEqual(t, m.currentScreen, TIMER_SCREEN, "Expected currentScreen to be TIMER_SCREEN") } func TestTimerView(t *testing.T) { m := newModel(false, "#ffdd57", "#57ddff", 10*time.Minute, 10*time.Minute, 2, []string{}, []string{}, []string{}) // Init model with timer values m, _, err := batchCmdUpdate(m, m.Init()) if err != nil { t.Fatalf("Expected batch command after init: %v", err) } // Start timer (batch result from above doesn't apply here) m, _, _ = batchCmdUpdate(m, startTimer) // Test timer view assertEqual(t, m.currentScreen, TIMER_SCREEN, "Expected currentScreen to be timerScreen") assertFunc(t, strings.Contains, m.View(), "Focus", "Expected view to contain 'Focus'") assertEqual(t, m.state, "Focus", "Expected state to be 'Focus'") oneSecCmd := func() tea.Msg { return timeMsg(time.Now().Add(1 * time.Second)) } m, _, _ = batchCmdUpdate(m, oneSecCmd) assertEqual(t, m.state, "Focus", "Expected state to be 'Focus'") // Test switch to break time m.startTime = m.startTime.Add(-10 * time.Minute) m, _, _ = batchCmdUpdate(m, oneSecCmd) assertEqual(t, m.state, "Break", "Expected state to be 'Break'") // Switch back to focus time m.startTime = m.startTime.Add(-10 * time.Minute) m, _, _ = batchCmdUpdate(m, oneSecCmd) assertEqual(t, m.state, "Focus", "Expected state to be 'Focus'") t.Logf("Incorrect state %+v", m) }