Refactor and improve test coverage

This commit is contained in:
IamTheFij 2024-10-23 14:00:45 -07:00
parent 7729646093
commit c86f080bc7
2 changed files with 232 additions and 112 deletions

228
main.go
View File

@ -14,10 +14,9 @@ import (
"github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"github.com/charmbracelet/huh"
) )
type screen int type screen int
@ -34,12 +33,19 @@ const (
) )
type model struct { type model struct {
// View state
form *huh.Form form *huh.Form
progressBar progress.Model progressBar progress.Model
state string state string
intervalNum int intervalNum int
remaining time.Duration
currentScreen screen currentScreen screen
isFocus bool
fullscreen bool
width int
height int
// Timer state
remaining time.Duration
intervals int intervals int
breakTime time.Duration breakTime time.Duration
focusTime time.Duration focusTime time.Duration
@ -47,15 +53,28 @@ type model struct {
onFocusStart []string onFocusStart []string
onFocusEnd []string onFocusEnd []string
onIntervalEnd []string onIntervalEnd []string
isFocus bool
fullscreen bool
width int
height int
err error err error
shellrunner *tortoise.ShellRunner shellrunner *tortoise.ShellRunner
} }
func initialModel(fullscreen bool, colorLeft string, colorRight string, defaultFocusTime, defaultBreakTime, defaultIntervals *string) model { // Message that signals to start the timer
type startTimerMsg struct{}
// Command that triggers start message
func startTimer() tea.Msg {
return startTimerMsg{}
}
// Message that signals to get values from the form
type formSubmitMsg struct{}
// Command that triggers form submit message
func formSubmit() tea.Msg {
return formSubmitMsg{}
}
// Create input form with default values if provided
func createInputForm(defaultFocusTime, defaultBreakTime, defaultIntervals *string) *huh.Form {
// Ceate validation functions for input // Ceate validation functions for input
validateDuration := func(text string) error { validateDuration := func(text string) error {
_, err := parseDuration(text) _, err := parseDuration(text)
@ -70,10 +89,6 @@ func initialModel(fullscreen bool, colorLeft string, colorRight string, defaultF
return nil return nil
} }
if colorLeft == "" || colorRight == "" {
slog.Panicf("Color flags can't be empty")
}
// Create form fields // Create form fields
focusTime := huh.NewInput(). focusTime := huh.NewInput().
Key(FORM_FOCUS). Key(FORM_FOCUS).
@ -108,8 +123,19 @@ func initialModel(fullscreen bool, colorLeft string, colorRight string, defaultF
intervals = intervals.Value(defaultIntervals) intervals = intervals.Value(defaultIntervals)
} }
f := huh.NewForm(huh.NewGroup(focusTime, breakTime, intervals)).WithShowErrors(true)
f.SubmitCmd = formSubmit
return f
}
func newModelBasic(fullscreen bool, colorLeft, colorRight string) model {
if colorLeft == "" || colorRight == "" {
slog.Panicf("Color flags can't be empty")
}
return model{ return model{
form: huh.NewForm(huh.NewGroup(focusTime, breakTime, intervals)).WithShowErrors(true), form: nil,
progressBar: progress.New(progress.WithScaledGradient(colorLeft, colorRight)), progressBar: progress.New(progress.WithScaledGradient(colorLeft, colorRight)),
state: "stopped", state: "stopped",
intervalNum: 1, intervalNum: 1,
@ -121,11 +147,51 @@ func initialModel(fullscreen bool, colorLeft string, colorRight string, defaultF
} }
} }
func newModel(fullscreen bool, colorLeft, colorRight string, focusTime, breakTime time.Duration, intervals int, onFocusStart, onFocusEnd, onIntervalEnd []string) model {
m := newModelBasic(fullscreen, colorLeft, colorRight)
if focusTime != 0 && breakTime != 0 && intervals != 0 {
// All values provided, initialize timer
m.focusTime = focusTime
m.breakTime = breakTime
m.intervals = intervals
m.currentScreen = TIMER_SCREEN
} else {
// Prompt for input
var defaultFocusTime, defaultBreakTime, defaultIntervals *string
if value := focusTime.String(); value != "0s" {
defaultFocusTime = &value
}
if value := breakTime.String(); value != "0s" {
defaultBreakTime = &value
}
if intervals != 0 {
intervalsString := strconv.Itoa(intervals)
defaultIntervals = &intervalsString
}
m.form = createInputForm(defaultFocusTime, defaultBreakTime, defaultIntervals)
m.currentScreen = INPUT_SCREEN
}
// Collect command flags
m.onFocusStart = onFocusStart
m.onFocusEnd = onFocusEnd
m.onIntervalEnd = onIntervalEnd
return m
}
func (m model) Init() tea.Cmd { func (m model) Init() tea.Cmd {
// Handle Ctrl+C for graceful exit // Handle Ctrl+C for graceful exit
c := make(chan os.Signal, 1) c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt) signal.Notify(c, os.Interrupt)
cmds := []tea.Cmd{tea.WindowSize(), textinput.Blink}
go func() { go func() {
<-c <-c
@ -135,10 +201,20 @@ func (m model) Init() tea.Cmd {
os.Exit(0) os.Exit(0)
}() }()
m.form.Init() if m.form != nil {
m.shellrunner.Start() // Start input form if it exists
cmds = append(cmds, m.form.Init())
} else {
// Start timer if no form
cmds = append(cmds, startTimer)
}
return tea.Batch(tea.WindowSize(), textinput.Blink) // Start shell runner if there are any commands to run
if len(m.onFocusStart) > 0 || len(m.onFocusEnd) > 0 || len(m.onIntervalEnd) > 0 {
m.shellrunner.Start()
}
return tea.Batch(cmds...)
} }
// totalTime returns the total time for the current period (focus or break) // totalTime returns the total time for the current period (focus or break)
@ -154,52 +230,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds := []tea.Cmd{} cmds := []tea.Cmd{}
// Handle input screen // Handle input screen
if m.currentScreen == INPUT_SCREEN { if m.currentScreen == INPUT_SCREEN && m.form != nil {
form, cmd := m.form.Update(msg) form, cmd := m.form.Update(msg)
if f, ok := form.(*huh.Form); ok { if f, ok := form.(*huh.Form); ok {
m.form = f m.form = f
} }
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
if m.form.State == huh.StateCompleted {
// Kick off the timer
focusTime, err := parseDuration(m.form.GetString(FORM_FOCUS))
if err != nil {
m.err = fmt.Errorf("error parsing focus time duration: %w", err)
slog.Fatalf("Error parsing focus time: %v", err)
return m, tea.Quit
}
breakTime, err := parseDuration(m.form.GetString(FORM_BREAK))
if err != nil {
m.err = fmt.Errorf("error parsing break time duration: %w", err)
slog.Fatalf("Error parsing break time: %v", err)
return m, tea.Quit
}
m.intervals, err = strconv.Atoi(m.form.GetString(FORM_INTERVALS))
if err != nil {
m.err = fmt.Errorf("error parsing interval: %w", err)
slog.Fatalf("Error parsing interval: %v", err)
return m, tea.Quit
}
m.focusTime = focusTime
m.breakTime = breakTime
m.remaining = focusTime
m.state = "Focus"
m.currentScreen = TIMER_SCREEN
m.startTime = time.Now()
// Run onFocusStart commands
m.startCommands(m.onFocusStart)
return m, tick()
}
} }
// Handle any uncaptured input screen updates // Handle any uncaptured input screen updates
@ -217,9 +254,52 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
} }
case formSubmitMsg:
// Get values from the form and send start timer command
var err error
m.focusTime, err = parseDuration(m.form.GetString(FORM_FOCUS))
if err != nil {
m.err = fmt.Errorf("error parsing focus time duration: %w", err)
slog.Fatalf("Error parsing focus time: %v", err)
return m, tea.Quit
}
m.breakTime, err = parseDuration(m.form.GetString(FORM_BREAK))
if err != nil {
m.err = fmt.Errorf("error parsing break time duration: %w", err)
slog.Fatalf("Error parsing break time: %v", err)
return m, tea.Quit
}
m.intervals, err = strconv.Atoi(m.form.GetString(FORM_INTERVALS))
if err != nil {
m.err = fmt.Errorf("error parsing interval: %w", err)
slog.Fatalf("Error parsing interval: %v", err)
return m, tea.Quit
}
return m, startTimer
case startTimerMsg:
// Start the timer
m.currentScreen = TIMER_SCREEN
// TODO: Could maybe set startTime to 0 and then send a tick command to start the timer
// since much of this is duplicate of below
m.startTime = time.Now()
m.remaining = m.focusTime
m.state = "Focus"
m.startCommands(m.onFocusStart)
return m, tick()
case timeMsg: case timeMsg:
// Handle timer update for each second // Handle timer update for each second
m.remaining = m.totalTime() - time.Since(m.startTime) m.remaining = m.totalTime() - time.Since(m.startTime)
// Check if we've reached a new period
if m.remaining < 0 { if m.remaining < 0 {
if m.isFocus { if m.isFocus {
// Focus period ends, switch to break // Focus period ends, switch to break
@ -257,6 +337,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.height = msg.Height m.height = msg.Height
} }
// Get errors from shellrunner
for { for {
result := m.shellrunner.GetResults() result := m.shellrunner.GetResults()
if result == nil { if result == nil {
@ -304,6 +385,10 @@ func (m model) View() string {
// View for input screen // View for input screen
func (m model) inputScreenView() string { func (m model) inputScreenView() string {
if m.form == nil {
return "Loading..."
}
var builder strings.Builder var builder strings.Builder
builder.WriteString("Enter your Pomodoro settings:\n\n") builder.WriteString("Enter your Pomodoro settings:\n\n")
@ -415,34 +500,17 @@ func main() {
return nil return nil
} }
// Set focus, break, and interval values if provided m := newModel(
var defaultFocusTime, defaultBreakTime, defaultIntervals *string
if focusTime := c.Duration("focus").String(); focusTime != "0s" {
defaultFocusTime = &focusTime
}
if breakTime := c.Duration("break").String(); breakTime != "0s" {
defaultBreakTime = &breakTime
}
if intervals := c.Int("intervals"); intervals != 0 {
intervalsString := strconv.Itoa(intervals)
defaultIntervals = &intervalsString
}
m := initialModel(
c.Bool("fullscreen"), c.Bool("fullscreen"),
c.String("color-left"), c.String("color-left"),
c.String("color-right"), c.String("color-right"),
defaultFocusTime, c.Duration("focus"),
defaultBreakTime, c.Duration("break"),
defaultIntervals, c.Int("intervals"),
c.StringSlice("on-focus-start"),
c.StringSlice("on-focus-end"),
c.StringSlice("on-interval-end"),
) )
// Collect command flags
m.onFocusStart = c.StringSlice("on-focus-start")
m.onFocusEnd = c.StringSlice("on-focus-end")
m.onIntervalEnd = c.StringSlice("on-interval-end")
// Start tea program // Start tea program
options := []tea.ProgramOption{} options := []tea.ProgramOption{}

View File

@ -1,6 +1,10 @@
package main package main
import ( import (
"fmt"
"reflect"
"runtime"
"strings"
"testing" "testing"
"time" "time"
@ -60,23 +64,45 @@ func TestParseDuration(t *testing.T) {
// TestRunCommands tests the runCommands function // TestRunCommands tests the runCommands function
func TestRunCommands(t *testing.T) { func TestRunCommands(t *testing.T) {
m := initialModel(false, "#ffdd57", "#57ddff", nil, nil, nil) m := newModelBasic(false, "#ffdd57", "#57ddff")
m.onFocusStart = []string{"echo Focus Start"}
m.Init() m.Init()
m.startCommands(m.onFocusStart)
m.startCommands([]string{"echo Hello, World!"})
m.shellrunner.Stop()
} }
func sendKeys(m model, keys ...tea.KeyType) (model, tea.Cmd) { func keyMsgs(keys ...interface{}) []tea.Msg {
keyMessages := []tea.Msg{}
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(m model, keys ...interface{}) (model, tea.Cmd) {
var updatedModel tea.Model var updatedModel tea.Model
var cmd tea.Cmd var cmd tea.Cmd
for _, key := range keys { for _, key := range keyMsgs(keys...) {
updatedModel, cmd = m.Update(tea.KeyMsg{Type: key}) updatedModel, cmd = m.Update(key)
m = updatedModel.(model) m = updatedModel.(model)
m.form.UpdateFieldPositions()
if cmd != nil {
updatedModel, cmd = m.Update(cmd())
m = updatedModel.(model)
}
} }
return m, cmd return m, cmd
@ -84,44 +110,70 @@ func sendKeys(m model, keys ...tea.KeyType) (model, tea.Cmd) {
// TestInputView tests the Update method of the model for the input view // TestInputView tests the Update method of the model for the input view
func TestInputView(t *testing.T) { func TestInputView(t *testing.T) {
focusInput := "10m" m := newModel(false, "#ffdd57", "#57ddff", 0, 0, 0, []string{}, []string{}, []string{})
breakInput := "5m"
intervalInput := "1"
m := initialModel(false, "#ffdd57", "#57ddff", &focusInput, &breakInput, &intervalInput) var updatedModel tea.Model
m.View()
assertEqual(t, m.currentScreen, INPUT_SCREEN, "Expected currentScreen to be inputScreen")
var resultCmd tea.Cmd var resultCmd tea.Cmd
m, resultCmd = sendKeys(m, tea.KeyTab, tea.KeyTab, tea.KeyTab, tea.KeyEnter)
updatedModel, _ = m.Update(m.Init())
m = updatedModel.(model)
// Verify we're on the input screen
assertEqual(t, m.currentScreen, INPUT_SCREEN, "Expected currentScreen to be inputScreen")
assertFunc(t, strings.Contains, m.View(), "Break time", "Expected view to contain 'enter next'")
// Fill the form
m, resultCmd = sendKeys(m, "10m", tea.KeyEnter, "10m", tea.KeyEnter, "2", tea.KeyEnter)
assertNotEqual(t, resultCmd, nil, "Expected resultCmd to be not nil") assertNotEqual(t, resultCmd, nil, "Expected resultCmd to be not nil")
/* // Apply final next result
* assertEqual(t, m.form.State, huh.StateCompleted, fmt.Sprintf("Expected form state to be completed: %s", m.form.View())) updatedModel, resultCmd = m.Update(resultCmd())
* assertEqual(t, m.currentScreen, TIMER_SCREEN, "Expected currentScreen to be timerScreen") m = updatedModel.(model)
* assertEqual(t, m.remaining.Round(time.Second), 10*time.Minute, "Expected remaining to be 10 minutes")
*/ // Make sure the next command is to submit the form
assertNotEqual(t, resultCmd, nil, "Expected resultCmd to be not nil")
assertEqual(
t,
reflect.ValueOf(resultCmd).Pointer(),
reflect.ValueOf(formSubmit).Pointer(),
fmt.Sprintf("Expected resultCmd to be formSubmit, found %v", runtime.FuncForPC(reflect.ValueOf(resultCmd).Pointer()).Name()),
)
// Apply submit form command
updatedModel, resultCmd = m.Update(resultCmd())
m = updatedModel.(model)
assertEqual(t, m.err, nil, "Expected no error") assertEqual(t, m.err, nil, "Expected no error")
assertNotEqual(t, resultCmd, nil, "Expected resultCmd to be not nil")
assertEqual(
t,
reflect.ValueOf(resultCmd).Pointer(),
reflect.ValueOf(startTimer).Pointer(),
fmt.Sprintf("Expected resultCmd to be startTimer, found %v", runtime.FuncForPC(reflect.ValueOf(resultCmd).Pointer()).Name()),
)
} }
func TestTimerView(t *testing.T) { func TestTimerView(t *testing.T) {
m := initialModel(false, "#ffdd57", "#57ddff", nil, nil, nil) m := newModel(false, "#ffdd57", "#57ddff", 10*time.Minute, 10*time.Minute, 2, []string{}, []string{}, []string{})
m.View()
m.focusTime = 10 * time.Minute var updatedModel tea.Model
m.breakTime = 10 * time.Minute
m.intervals = 2 // Init model with timer values
m.state = "Focus" updatedModel, _ = m.Update(m.Init())
m.currentScreen = TIMER_SCREEN m = updatedModel.(model)
m.startTime = time.Now()
// Start timer (batch result from above doesn't apply here)
updatedModel, _ = m.Update(startTimer())
m = updatedModel.(model)
// Test timer view // Test timer view
m.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'")
oneSec := timeMsg(time.Now().Add(1 * time.Second)) oneSec := timeMsg(time.Now().Add(1 * time.Second))
updatedModel, _ := m.Update(oneSec) updatedModel, _ = m.Update(oneSec)
m = updatedModel.(model) m = updatedModel.(model)
assertEqual(t, m.state, "Focus", "Expected state to be 'Focus'") assertEqual(t, m.state, "Focus", "Expected state to be 'Focus'")