From 60359c4f318525a5fbb4e73b621cb1f60b59d0cd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 16:09:01 +0000 Subject: [PATCH 01/32] Abstract local timer functionality behind LocalTimer interface Introduce the LocalTimer interface with StartTimer and StartBreakTimer methods, and implement it with ProcessLocalTimer (background OS processes). The internal startTimer/startBreakTimer functions now accept a LocalTimer parameter, enabling future alternative implementations to be injected. https://claude.ai/code/session_01DTB35xCmhRgW5SQUsYAtAq --- localtimer.go | 30 ++++++++++++++++++++++++++++++ timer.go | 12 ++++++------ timer_test.go | 12 ++++++------ 3 files changed, 42 insertions(+), 12 deletions(-) create mode 100644 localtimer.go diff --git a/localtimer.go b/localtimer.go new file mode 100644 index 00000000..d109103b --- /dev/null +++ b/localtimer.go @@ -0,0 +1,30 @@ +package main + +// LocalTimer abstracts the local timer functionality so different implementations can be used. +type LocalTimer interface { + StartTimer(timeoutInMinutes int, voiceMessage string, voiceCommand string, notifyMessage string, notifyCommand string) error + StartBreakTimer(timeoutInMinutes int, voiceCommand string, notifyCommand string) error +} + +// ProcessLocalTimer is the default LocalTimer implementation that uses background OS processes. +type ProcessLocalTimer struct{} + +func (t ProcessLocalTimer) StartTimer(timeoutInMinutes int, voiceMessage string, voiceCommand string, notifyMessage string, notifyCommand string) error { + timeoutInSeconds := timeoutInMinutes * 60 + return executeCommandsInBackgroundProcess( + getSleepCommand(timeoutInSeconds), + getVoiceCommand(voiceMessage, voiceCommand), + getNotifyCommand(notifyMessage, notifyCommand), + "echo \"mobTimer\"", + ) +} + +func (t ProcessLocalTimer) StartBreakTimer(timeoutInMinutes int, voiceCommand string, notifyCommand string) error { + timeoutInSeconds := timeoutInMinutes * 60 + return executeCommandsInBackgroundProcess( + getSleepCommand(timeoutInSeconds), + getVoiceCommand("mob start", voiceCommand), + getNotifyCommand("mob start", notifyCommand), + "echo \"mobTimer\"", + ) +} diff --git a/timer.go b/timer.go index 0896eec1..8fd06237 100644 --- a/timer.go +++ b/timer.go @@ -15,12 +15,12 @@ import ( ) func StartTimer(timerInMinutes string, configuration config.Configuration) { - if err := startTimer(timerInMinutes, configuration); err != nil { + if err := startTimer(timerInMinutes, configuration, ProcessLocalTimer{}); err != nil { exit.Exit(1) } } -func startTimer(timerInMinutes string, configuration config.Configuration) error { +func startTimer(timerInMinutes string, configuration config.Configuration, localTimer LocalTimer) error { err, timeoutInMinutes := toMinutes(timerInMinutes) if err != nil { return err @@ -50,7 +50,7 @@ func startTimer(timerInMinutes string, configuration config.Configuration) error } if startLocalTimer { - err := executeCommandsInBackgroundProcess(getSleepCommand(timeoutInSeconds), getVoiceCommand(configuration.VoiceMessage, configuration.VoiceCommand), getNotifyCommand(configuration.NotifyMessage, configuration.NotifyCommand), "echo \"mobTimer\"") + err := localTimer.StartTimer(timeoutInMinutes, configuration.VoiceMessage, configuration.VoiceCommand, configuration.NotifyMessage, configuration.NotifyCommand) if err != nil { say.Error(fmt.Sprintf("timer couldn't be started on your system (%s)", runtime.GOOS)) @@ -89,12 +89,12 @@ func getMobTimerRoom(configuration config.Configuration) string { } func StartBreakTimer(timerInMinutes string, configuration config.Configuration) { - if err := startBreakTimer(timerInMinutes, configuration); err != nil { + if err := startBreakTimer(timerInMinutes, configuration, ProcessLocalTimer{}); err != nil { exit.Exit(1) } } -func startBreakTimer(timerInMinutes string, configuration config.Configuration) error { +func startBreakTimer(timerInMinutes string, configuration config.Configuration, localTimer LocalTimer) error { err, timeoutInMinutes := toMinutes(timerInMinutes) if err != nil { return err @@ -125,7 +125,7 @@ func startBreakTimer(timerInMinutes string, configuration config.Configuration) } if startLocalTimer { - err := executeCommandsInBackgroundProcess(getSleepCommand(timeoutInSeconds), getVoiceCommand("mob start", configuration.VoiceCommand), getNotifyCommand("mob start", configuration.NotifyCommand), "echo \"mobTimer\"") + err := localTimer.StartBreakTimer(timeoutInMinutes, configuration.VoiceCommand, configuration.NotifyCommand) if err != nil { say.Error(fmt.Sprintf("break timer couldn't be started on your system (%s)", runtime.GOOS)) diff --git a/timer_test.go b/timer_test.go index cb190cbc..b02a68c8 100644 --- a/timer_test.go +++ b/timer_test.go @@ -36,7 +36,7 @@ func TestOpenTimerInBrowserError(t *testing.T) { func TestTimerNumberLessThen1(t *testing.T) { output, configuration := setup(t) - err := startTimer("0", configuration) + err := startTimer("0", configuration, ProcessLocalTimer{}) assertError(t, err, "The parameter must be an integer number greater then zero") assertOutputContains(t, output, "The parameter must be an integer number greater then zero") @@ -45,7 +45,7 @@ func TestTimerNumberLessThen1(t *testing.T) { func TestTimerNotANumber(t *testing.T) { output, configuration := setup(t) - err := startTimer("NotANumber", configuration) + err := startTimer("NotANumber", configuration, ProcessLocalTimer{}) assertError(t, err, "The parameter must be an integer number greater then zero") assertOutputContains(t, output, "The parameter must be an integer number greater then zero") @@ -56,7 +56,7 @@ func TestTimer(t *testing.T) { configuration.NotifyCommand = "" configuration.VoiceCommand = "" - err := startTimer("1", configuration) + err := startTimer("1", configuration, ProcessLocalTimer{}) assertNoError(t, err) assertOutputContains(t, output, "1 min timer ends at approx.") @@ -77,7 +77,7 @@ func TestTimerExportFunction(t *testing.T) { func TestBreakTimerNumberLessThen1(t *testing.T) { output, configuration := setup(t) - err := startBreakTimer("0", configuration) + err := startBreakTimer("0", configuration, ProcessLocalTimer{}) assertError(t, err, "The parameter must be an integer number greater then zero") assertOutputContains(t, output, "The parameter must be an integer number greater then zero") @@ -86,7 +86,7 @@ func TestBreakTimerNumberLessThen1(t *testing.T) { func TestBreakTimerNotANumber(t *testing.T) { output, configuration := setup(t) - err := startBreakTimer("NotANumber", configuration) + err := startBreakTimer("NotANumber", configuration, ProcessLocalTimer{}) assertError(t, err, "The parameter must be an integer number greater then zero") assertOutputContains(t, output, "The parameter must be an integer number greater then zero") @@ -97,7 +97,7 @@ func TestBreakTimer(t *testing.T) { configuration.NotifyCommand = "" configuration.VoiceCommand = "" - err := startBreakTimer("1", configuration) + err := startBreakTimer("1", configuration, ProcessLocalTimer{}) assertNoError(t, err) assertOutputContains(t, output, "1 min break timer ends at approx.") From d62c5501e74e4f22e3af52355132cc5513b15ece Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 16:17:01 +0000 Subject: [PATCH 02/32] Pass Configuration into LocalTimer interface instead of individual fields Simplifies the interface by passing the full Configuration struct to StartTimer and StartBreakTimer, instead of extracting individual voice/notify fields at the call site. https://claude.ai/code/session_01DTB35xCmhRgW5SQUsYAtAq --- localtimer.go | 18 ++++++++++-------- timer.go | 4 ++-- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/localtimer.go b/localtimer.go index d109103b..a48d5837 100644 --- a/localtimer.go +++ b/localtimer.go @@ -1,30 +1,32 @@ package main +import config "github.com/remotemobprogramming/mob/v5/configuration" + // LocalTimer abstracts the local timer functionality so different implementations can be used. type LocalTimer interface { - StartTimer(timeoutInMinutes int, voiceMessage string, voiceCommand string, notifyMessage string, notifyCommand string) error - StartBreakTimer(timeoutInMinutes int, voiceCommand string, notifyCommand string) error + StartTimer(timeoutInMinutes int, configuration config.Configuration) error + StartBreakTimer(timeoutInMinutes int, configuration config.Configuration) error } // ProcessLocalTimer is the default LocalTimer implementation that uses background OS processes. type ProcessLocalTimer struct{} -func (t ProcessLocalTimer) StartTimer(timeoutInMinutes int, voiceMessage string, voiceCommand string, notifyMessage string, notifyCommand string) error { +func (t ProcessLocalTimer) StartTimer(timeoutInMinutes int, configuration config.Configuration) error { timeoutInSeconds := timeoutInMinutes * 60 return executeCommandsInBackgroundProcess( getSleepCommand(timeoutInSeconds), - getVoiceCommand(voiceMessage, voiceCommand), - getNotifyCommand(notifyMessage, notifyCommand), + getVoiceCommand(configuration.VoiceMessage, configuration.VoiceCommand), + getNotifyCommand(configuration.NotifyMessage, configuration.NotifyCommand), "echo \"mobTimer\"", ) } -func (t ProcessLocalTimer) StartBreakTimer(timeoutInMinutes int, voiceCommand string, notifyCommand string) error { +func (t ProcessLocalTimer) StartBreakTimer(timeoutInMinutes int, configuration config.Configuration) error { timeoutInSeconds := timeoutInMinutes * 60 return executeCommandsInBackgroundProcess( getSleepCommand(timeoutInSeconds), - getVoiceCommand("mob start", voiceCommand), - getNotifyCommand("mob start", notifyCommand), + getVoiceCommand("mob start", configuration.VoiceCommand), + getNotifyCommand("mob start", configuration.NotifyCommand), "echo \"mobTimer\"", ) } diff --git a/timer.go b/timer.go index 8fd06237..8362ff5b 100644 --- a/timer.go +++ b/timer.go @@ -50,7 +50,7 @@ func startTimer(timerInMinutes string, configuration config.Configuration, local } if startLocalTimer { - err := localTimer.StartTimer(timeoutInMinutes, configuration.VoiceMessage, configuration.VoiceCommand, configuration.NotifyMessage, configuration.NotifyCommand) + err := localTimer.StartTimer(timeoutInMinutes, configuration) if err != nil { say.Error(fmt.Sprintf("timer couldn't be started on your system (%s)", runtime.GOOS)) @@ -125,7 +125,7 @@ func startBreakTimer(timerInMinutes string, configuration config.Configuration, } if startLocalTimer { - err := localTimer.StartBreakTimer(timeoutInMinutes, configuration.VoiceCommand, configuration.NotifyCommand) + err := localTimer.StartBreakTimer(timeoutInMinutes, configuration) if err != nil { say.Error(fmt.Sprintf("break timer couldn't be started on your system (%s)", runtime.GOOS)) From 41e8981d953880c708dd71f7925de394b3f5e2a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 16:20:19 +0000 Subject: [PATCH 03/32] Rename timeoutInMinutes to minutes in LocalTimer interface https://claude.ai/code/session_01DTB35xCmhRgW5SQUsYAtAq --- localtimer.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/localtimer.go b/localtimer.go index a48d5837..7eb60c45 100644 --- a/localtimer.go +++ b/localtimer.go @@ -4,15 +4,15 @@ import config "github.com/remotemobprogramming/mob/v5/configuration" // LocalTimer abstracts the local timer functionality so different implementations can be used. type LocalTimer interface { - StartTimer(timeoutInMinutes int, configuration config.Configuration) error - StartBreakTimer(timeoutInMinutes int, configuration config.Configuration) error + StartTimer(minutes int, configuration config.Configuration) error + StartBreakTimer(minutes int, configuration config.Configuration) error } // ProcessLocalTimer is the default LocalTimer implementation that uses background OS processes. type ProcessLocalTimer struct{} -func (t ProcessLocalTimer) StartTimer(timeoutInMinutes int, configuration config.Configuration) error { - timeoutInSeconds := timeoutInMinutes * 60 +func (t ProcessLocalTimer) StartTimer(minutes int, configuration config.Configuration) error { + timeoutInSeconds := minutes * 60 return executeCommandsInBackgroundProcess( getSleepCommand(timeoutInSeconds), getVoiceCommand(configuration.VoiceMessage, configuration.VoiceCommand), @@ -21,8 +21,8 @@ func (t ProcessLocalTimer) StartTimer(timeoutInMinutes int, configuration config ) } -func (t ProcessLocalTimer) StartBreakTimer(timeoutInMinutes int, configuration config.Configuration) error { - timeoutInSeconds := timeoutInMinutes * 60 +func (t ProcessLocalTimer) StartBreakTimer(minutes int, configuration config.Configuration) error { + timeoutInSeconds := minutes * 60 return executeCommandsInBackgroundProcess( getSleepCommand(timeoutInSeconds), getVoiceCommand("mob start", configuration.VoiceCommand), From abef7e01f9708af3e6ec836ad26c5529e1d0d4b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 16:24:50 +0000 Subject: [PATCH 04/32] Rename LocalTimer interface to Timer https://claude.ai/code/session_01DTB35xCmhRgW5SQUsYAtAq --- localtimer.go | 6 +++--- timer.go | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/localtimer.go b/localtimer.go index 7eb60c45..219725a8 100644 --- a/localtimer.go +++ b/localtimer.go @@ -2,13 +2,13 @@ package main import config "github.com/remotemobprogramming/mob/v5/configuration" -// LocalTimer abstracts the local timer functionality so different implementations can be used. -type LocalTimer interface { +// Timer abstracts the local timer functionality so different implementations can be used. +type Timer interface { StartTimer(minutes int, configuration config.Configuration) error StartBreakTimer(minutes int, configuration config.Configuration) error } -// ProcessLocalTimer is the default LocalTimer implementation that uses background OS processes. +// ProcessLocalTimer is the default Timer implementation that uses background OS processes. type ProcessLocalTimer struct{} func (t ProcessLocalTimer) StartTimer(minutes int, configuration config.Configuration) error { diff --git a/timer.go b/timer.go index 8362ff5b..01351ae4 100644 --- a/timer.go +++ b/timer.go @@ -20,7 +20,7 @@ func StartTimer(timerInMinutes string, configuration config.Configuration) { } } -func startTimer(timerInMinutes string, configuration config.Configuration, localTimer LocalTimer) error { +func startTimer(timerInMinutes string, configuration config.Configuration, localTimer Timer) error { err, timeoutInMinutes := toMinutes(timerInMinutes) if err != nil { return err @@ -94,7 +94,7 @@ func StartBreakTimer(timerInMinutes string, configuration config.Configuration) } } -func startBreakTimer(timerInMinutes string, configuration config.Configuration, localTimer LocalTimer) error { +func startBreakTimer(timerInMinutes string, configuration config.Configuration, localTimer Timer) error { err, timeoutInMinutes := toMinutes(timerInMinutes) if err != nil { return err From 08e479419aae847da042149ebc1fa12c0a91bbb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 16:29:34 +0000 Subject: [PATCH 05/32] Implement WebTimer and introduce getTimers() as central timer selection - Add WebTimer implementing the Timer interface via HTTP PUT requests - Add getTimers() as the single place that decides which timers are active (WebTimer when a room is configured, ProcessLocalTimer when TimerLocal=true, both can run simultaneously) - Simplify startTimer/startBreakTimer to iterate over the active timers - Move httpPutTimer/httpPutBreakTimer into webtimer.go - Wrap errors in timer implementations with descriptive messages https://claude.ai/code/session_01DTB35xCmhRgW5SQUsYAtAq --- localtimer.go | 21 +++++++++--- timer.go | 91 +++++++++++++-------------------------------------- timer_test.go | 12 +++---- webtimer.go | 50 ++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 79 deletions(-) create mode 100644 webtimer.go diff --git a/localtimer.go b/localtimer.go index 219725a8..3ee7cc27 100644 --- a/localtimer.go +++ b/localtimer.go @@ -1,6 +1,11 @@ package main -import config "github.com/remotemobprogramming/mob/v5/configuration" +import ( + "fmt" + "runtime" + + config "github.com/remotemobprogramming/mob/v5/configuration" +) // Timer abstracts the local timer functionality so different implementations can be used. type Timer interface { @@ -13,20 +18,26 @@ type ProcessLocalTimer struct{} func (t ProcessLocalTimer) StartTimer(minutes int, configuration config.Configuration) error { timeoutInSeconds := minutes * 60 - return executeCommandsInBackgroundProcess( + if err := executeCommandsInBackgroundProcess( getSleepCommand(timeoutInSeconds), getVoiceCommand(configuration.VoiceMessage, configuration.VoiceCommand), getNotifyCommand(configuration.NotifyMessage, configuration.NotifyCommand), "echo \"mobTimer\"", - ) + ); err != nil { + return fmt.Errorf("timer couldn't be started on your system (%s): %w", runtime.GOOS, err) + } + return nil } func (t ProcessLocalTimer) StartBreakTimer(minutes int, configuration config.Configuration) error { timeoutInSeconds := minutes * 60 - return executeCommandsInBackgroundProcess( + if err := executeCommandsInBackgroundProcess( getSleepCommand(timeoutInSeconds), getVoiceCommand("mob start", configuration.VoiceCommand), getNotifyCommand("mob start", configuration.NotifyCommand), "echo \"mobTimer\"", - ) + ); err != nil { + return fmt.Errorf("break timer couldn't be started on your system (%s): %w", runtime.GOOS, err) + } + return nil } diff --git a/timer.go b/timer.go index 01351ae4..2576c054 100644 --- a/timer.go +++ b/timer.go @@ -1,26 +1,23 @@ package main import ( - "encoding/json" "errors" "fmt" - "runtime" "strconv" "time" config "github.com/remotemobprogramming/mob/v5/configuration" "github.com/remotemobprogramming/mob/v5/exit" - "github.com/remotemobprogramming/mob/v5/httpclient" "github.com/remotemobprogramming/mob/v5/say" ) func StartTimer(timerInMinutes string, configuration config.Configuration) { - if err := startTimer(timerInMinutes, configuration, ProcessLocalTimer{}); err != nil { + if err := startTimer(timerInMinutes, configuration); err != nil { exit.Exit(1) } } -func startTimer(timerInMinutes string, configuration config.Configuration, localTimer Timer) error { +func startTimer(timerInMinutes string, configuration config.Configuration) error { err, timeoutInMinutes := toMinutes(timerInMinutes) if err != nil { return err @@ -30,30 +27,14 @@ func startTimer(timerInMinutes string, configuration config.Configuration, local timeOfTimeout := time.Now().Add(time.Minute * time.Duration(timeoutInMinutes)).Format("15:04") say.Debug(fmt.Sprintf("Starting timer at %s for %d minutes = %d seconds (parsed from user input %s)", timeOfTimeout, timeoutInMinutes, timeoutInSeconds, timerInMinutes)) - room := getMobTimerRoom(configuration) - startRemoteTimer := room != "" - startLocalTimer := configuration.TimerLocal - - if !startRemoteTimer && !startLocalTimer { + timers := getTimers(configuration) + if len(timers) == 0 { say.Error("No timer configured, not starting timer") exit.Exit(1) } - if startRemoteTimer { - timerUser := getUserForMobTimer(configuration.TimerUser) - err := httpPutTimer(timeoutInMinutes, room, timerUser, configuration.TimerUrl, configuration.TimerInsecure) - if err != nil { - say.Error("remote timer couldn't be started") - say.Error(err.Error()) - exit.Exit(1) - } - } - - if startLocalTimer { - err := localTimer.StartTimer(timeoutInMinutes, configuration) - - if err != nil { - say.Error(fmt.Sprintf("timer couldn't be started on your system (%s)", runtime.GOOS)) + for _, timer := range timers { + if err := timer.StartTimer(timeoutInMinutes, configuration); err != nil { say.Error(err.Error()) exit.Exit(1) } @@ -89,12 +70,12 @@ func getMobTimerRoom(configuration config.Configuration) string { } func StartBreakTimer(timerInMinutes string, configuration config.Configuration) { - if err := startBreakTimer(timerInMinutes, configuration, ProcessLocalTimer{}); err != nil { + if err := startBreakTimer(timerInMinutes, configuration); err != nil { exit.Exit(1) } } -func startBreakTimer(timerInMinutes string, configuration config.Configuration, localTimer Timer) error { +func startBreakTimer(timerInMinutes string, configuration config.Configuration) error { err, timeoutInMinutes := toMinutes(timerInMinutes) if err != nil { return err @@ -104,31 +85,14 @@ func startBreakTimer(timerInMinutes string, configuration config.Configuration, timeOfTimeout := time.Now().Add(time.Minute * time.Duration(timeoutInMinutes)).Format("15:04") say.Debug(fmt.Sprintf("Starting break timer at %s for %d minutes = %d seconds (parsed from user input %s)", timeOfTimeout, timeoutInMinutes, timeoutInSeconds, timerInMinutes)) - room := getMobTimerRoom(configuration) - startRemoteTimer := room != "" - startLocalTimer := configuration.TimerLocal - - if !startRemoteTimer && !startLocalTimer { + timers := getTimers(configuration) + if len(timers) == 0 { say.Error("No break timer configured, not starting break timer") exit.Exit(1) } - if startRemoteTimer { - timerUser := getUserForMobTimer(configuration.TimerUser) - err := httpPutBreakTimer(timeoutInMinutes, room, timerUser, configuration.TimerUrl, configuration.TimerInsecure) - - if err != nil { - say.Error("remote break timer couldn't be started") - say.Error(err.Error()) - exit.Exit(1) - } - } - - if startLocalTimer { - err := localTimer.StartBreakTimer(timeoutInMinutes, configuration) - - if err != nil { - say.Error(fmt.Sprintf("break timer couldn't be started on your system (%s)", runtime.GOOS)) + for _, timer := range timers { + if err := timer.StartBreakTimer(timeoutInMinutes, configuration); err != nil { say.Error(err.Error()) exit.Exit(1) } @@ -138,6 +102,17 @@ func startBreakTimer(timerInMinutes string, configuration config.Configuration, return nil } +func getTimers(configuration config.Configuration) []Timer { + var timers []Timer + if getMobTimerRoom(configuration) != "" { + timers = append(timers, WebTimer{}) + } + if configuration.TimerLocal { + timers = append(timers, ProcessLocalTimer{}) + } + return timers +} + func getUserForMobTimer(userOverride string) string { if userOverride == "" { return gitUserName() @@ -154,26 +129,6 @@ func toMinutes(timerInMinutes string) (error, int) { return nil, timeoutInMinutes } -func httpPutTimer(timeoutInMinutes int, room string, user string, timerService string, disableSSLVerification bool) error { - putBody, _ := json.Marshal(map[string]interface{}{ - "timer": timeoutInMinutes, - "user": user, - }) - client := httpclient.CreateHttpClient(disableSSLVerification) - _, err := client.SendRequest(putBody, "PUT", timerService+room) - return err -} - -func httpPutBreakTimer(timeoutInMinutes int, room string, user string, timerService string, disableSSLVerification bool) error { - putBody, _ := json.Marshal(map[string]interface{}{ - "breaktimer": timeoutInMinutes, - "user": user, - }) - client := httpclient.CreateHttpClient(disableSSLVerification) - _, err := client.SendRequest(putBody, "PUT", timerService+room) - return err -} - func getSleepCommand(timeoutInSeconds int) string { return fmt.Sprintf("sleep %d", timeoutInSeconds) } diff --git a/timer_test.go b/timer_test.go index b02a68c8..cb190cbc 100644 --- a/timer_test.go +++ b/timer_test.go @@ -36,7 +36,7 @@ func TestOpenTimerInBrowserError(t *testing.T) { func TestTimerNumberLessThen1(t *testing.T) { output, configuration := setup(t) - err := startTimer("0", configuration, ProcessLocalTimer{}) + err := startTimer("0", configuration) assertError(t, err, "The parameter must be an integer number greater then zero") assertOutputContains(t, output, "The parameter must be an integer number greater then zero") @@ -45,7 +45,7 @@ func TestTimerNumberLessThen1(t *testing.T) { func TestTimerNotANumber(t *testing.T) { output, configuration := setup(t) - err := startTimer("NotANumber", configuration, ProcessLocalTimer{}) + err := startTimer("NotANumber", configuration) assertError(t, err, "The parameter must be an integer number greater then zero") assertOutputContains(t, output, "The parameter must be an integer number greater then zero") @@ -56,7 +56,7 @@ func TestTimer(t *testing.T) { configuration.NotifyCommand = "" configuration.VoiceCommand = "" - err := startTimer("1", configuration, ProcessLocalTimer{}) + err := startTimer("1", configuration) assertNoError(t, err) assertOutputContains(t, output, "1 min timer ends at approx.") @@ -77,7 +77,7 @@ func TestTimerExportFunction(t *testing.T) { func TestBreakTimerNumberLessThen1(t *testing.T) { output, configuration := setup(t) - err := startBreakTimer("0", configuration, ProcessLocalTimer{}) + err := startBreakTimer("0", configuration) assertError(t, err, "The parameter must be an integer number greater then zero") assertOutputContains(t, output, "The parameter must be an integer number greater then zero") @@ -86,7 +86,7 @@ func TestBreakTimerNumberLessThen1(t *testing.T) { func TestBreakTimerNotANumber(t *testing.T) { output, configuration := setup(t) - err := startBreakTimer("NotANumber", configuration, ProcessLocalTimer{}) + err := startBreakTimer("NotANumber", configuration) assertError(t, err, "The parameter must be an integer number greater then zero") assertOutputContains(t, output, "The parameter must be an integer number greater then zero") @@ -97,7 +97,7 @@ func TestBreakTimer(t *testing.T) { configuration.NotifyCommand = "" configuration.VoiceCommand = "" - err := startBreakTimer("1", configuration, ProcessLocalTimer{}) + err := startBreakTimer("1", configuration) assertNoError(t, err) assertOutputContains(t, output, "1 min break timer ends at approx.") diff --git a/webtimer.go b/webtimer.go new file mode 100644 index 00000000..e8ba4242 --- /dev/null +++ b/webtimer.go @@ -0,0 +1,50 @@ +package main + +import ( + "encoding/json" + "fmt" + + config "github.com/remotemobprogramming/mob/v5/configuration" + "github.com/remotemobprogramming/mob/v5/httpclient" +) + +// WebTimer is a Timer implementation that notifies a remote timer service via HTTP. +type WebTimer struct{} + +func (t WebTimer) StartTimer(minutes int, configuration config.Configuration) error { + room := getMobTimerRoom(configuration) + timerUser := getUserForMobTimer(configuration.TimerUser) + if err := httpPutTimer(minutes, room, timerUser, configuration.TimerUrl, configuration.TimerInsecure); err != nil { + return fmt.Errorf("remote timer couldn't be started: %w", err) + } + return nil +} + +func (t WebTimer) StartBreakTimer(minutes int, configuration config.Configuration) error { + room := getMobTimerRoom(configuration) + timerUser := getUserForMobTimer(configuration.TimerUser) + if err := httpPutBreakTimer(minutes, room, timerUser, configuration.TimerUrl, configuration.TimerInsecure); err != nil { + return fmt.Errorf("remote break timer couldn't be started: %w", err) + } + return nil +} + +func httpPutTimer(timeoutInMinutes int, room string, user string, timerService string, disableSSLVerification bool) error { + putBody, _ := json.Marshal(map[string]interface{}{ + "timer": timeoutInMinutes, + "user": user, + }) + client := httpclient.CreateHttpClient(disableSSLVerification) + _, err := client.SendRequest(putBody, "PUT", timerService+room) + return err +} + +func httpPutBreakTimer(timeoutInMinutes int, room string, user string, timerService string, disableSSLVerification bool) error { + putBody, _ := json.Marshal(map[string]interface{}{ + "breaktimer": timeoutInMinutes, + "user": user, + }) + client := httpclient.CreateHttpClient(disableSSLVerification) + _, err := client.SendRequest(putBody, "PUT", timerService+room) + return err +} From 26cd6ecfa92d39828c801d16136b6bfa93a1e951 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 16:48:02 +0000 Subject: [PATCH 06/32] Extract timer code into its own timer/ package - Move Timer interface, ProcessLocalTimer, WebTimer, GetTimers, RunTimer, RunBreakTimer into github.com/remotemobprogramming/mob/v5/timer - WebTimer now holds Room and TimerUser as struct fields (resolved by main before constructing the timer) - ExecuteCommandsInBackgroundProcess and VoiceCommand are exported from the timer package so main's moo() can use them - main/timer.go becomes a thin bridge: computes room/timerUser from git context, then delegates to timerpkg.RunTimer / timerpkg.RunBreakTimer - Remove executeCommandsInBackgroundProcess from mob.go (now in timer/) https://claude.ai/code/session_01DTB35xCmhRgW5SQUsYAtAq --- localtimer.go | 43 ------------ mob.go | 22 +------ timer.go | 110 ++++--------------------------- timer/localtimer.go | 106 +++++++++++++++++++++++++++++ timer/timer.go | 100 ++++++++++++++++++++++++++++ webtimer.go => timer/webtimer.go | 15 ++--- 6 files changed, 227 insertions(+), 169 deletions(-) delete mode 100644 localtimer.go create mode 100644 timer/localtimer.go create mode 100644 timer/timer.go rename webtimer.go => timer/webtimer.go (74%) diff --git a/localtimer.go b/localtimer.go deleted file mode 100644 index 3ee7cc27..00000000 --- a/localtimer.go +++ /dev/null @@ -1,43 +0,0 @@ -package main - -import ( - "fmt" - "runtime" - - config "github.com/remotemobprogramming/mob/v5/configuration" -) - -// Timer abstracts the local timer functionality so different implementations can be used. -type Timer interface { - StartTimer(minutes int, configuration config.Configuration) error - StartBreakTimer(minutes int, configuration config.Configuration) error -} - -// ProcessLocalTimer is the default Timer implementation that uses background OS processes. -type ProcessLocalTimer struct{} - -func (t ProcessLocalTimer) StartTimer(minutes int, configuration config.Configuration) error { - timeoutInSeconds := minutes * 60 - if err := executeCommandsInBackgroundProcess( - getSleepCommand(timeoutInSeconds), - getVoiceCommand(configuration.VoiceMessage, configuration.VoiceCommand), - getNotifyCommand(configuration.NotifyMessage, configuration.NotifyCommand), - "echo \"mobTimer\"", - ); err != nil { - return fmt.Errorf("timer couldn't be started on your system (%s): %w", runtime.GOOS, err) - } - return nil -} - -func (t ProcessLocalTimer) StartBreakTimer(minutes int, configuration config.Configuration) error { - timeoutInSeconds := minutes * 60 - if err := executeCommandsInBackgroundProcess( - getSleepCommand(timeoutInSeconds), - getVoiceCommand("mob start", configuration.VoiceCommand), - getNotifyCommand("mob start", configuration.NotifyCommand), - "echo \"mobTimer\"", - ); err != nil { - return fmt.Errorf("break timer couldn't be started on your system (%s): %w", runtime.GOOS, err) - } - return nil -} diff --git a/mob.go b/mob.go index f07c2a0f..1804faf4 100644 --- a/mob.go +++ b/mob.go @@ -21,6 +21,7 @@ import ( "github.com/remotemobprogramming/mob/v5/help" "github.com/remotemobprogramming/mob/v5/open" "github.com/remotemobprogramming/mob/v5/say" + timerpkg "github.com/remotemobprogramming/mob/v5/timer" "github.com/remotemobprogramming/mob/v5/workdir" ) @@ -446,32 +447,13 @@ func injectCommandWithMessage(command string, message string) string { return fmt.Sprintf(command, message) } -func executeCommandsInBackgroundProcess(commands ...string) (err error) { - cmds := make([]string, 0) - for _, c := range commands { - if len(c) > 0 { - cmds = append(cmds, c) - } - } - say.Debug(fmt.Sprintf("Operating System %s", runtime.GOOS)) - switch runtime.GOOS { - case "windows": - _, err = startCommand("powershell", "-command", fmt.Sprintf("start-process powershell -NoNewWindow -ArgumentList '-command \"%s\"'", strings.Join(cmds, ";"))) - case "darwin", "linux": - _, err = startCommand("sh", "-c", fmt.Sprintf("(%s) &", strings.Join(cmds, ";"))) - default: - say.Warning(fmt.Sprintf("Cannot execute background commands on your os: %s", runtime.GOOS)) - } - return err -} - func currentTime() string { return time.Now().Format("15:04") } func moo(configuration config.Configuration) { voiceMessage := "moo" - err := executeCommandsInBackgroundProcess(getVoiceCommand(voiceMessage, configuration.VoiceCommand)) + err := timerpkg.ExecuteCommandsInBackgroundProcess(timerpkg.VoiceCommand(voiceMessage, configuration.VoiceCommand)) if err != nil { say.Warning(fmt.Sprintf("can't run voice command on your system (%s)", runtime.GOOS)) diff --git a/timer.go b/timer.go index 2576c054..93bd7986 100644 --- a/timer.go +++ b/timer.go @@ -1,14 +1,9 @@ package main import ( - "errors" - "fmt" - "strconv" - "time" - config "github.com/remotemobprogramming/mob/v5/configuration" "github.com/remotemobprogramming/mob/v5/exit" - "github.com/remotemobprogramming/mob/v5/say" + timerpkg "github.com/remotemobprogramming/mob/v5/timer" ) func StartTimer(timerInMinutes string, configuration config.Configuration) { @@ -18,35 +13,25 @@ func StartTimer(timerInMinutes string, configuration config.Configuration) { } func startTimer(timerInMinutes string, configuration config.Configuration) error { - err, timeoutInMinutes := toMinutes(timerInMinutes) - if err != nil { - return err - } - - timeoutInSeconds := timeoutInMinutes * 60 - timeOfTimeout := time.Now().Add(time.Minute * time.Duration(timeoutInMinutes)).Format("15:04") - say.Debug(fmt.Sprintf("Starting timer at %s for %d minutes = %d seconds (parsed from user input %s)", timeOfTimeout, timeoutInMinutes, timeoutInSeconds, timerInMinutes)) + room := getMobTimerRoom(configuration) + timerUser := getUserForMobTimer(configuration.TimerUser) + return timerpkg.RunTimer(timerInMinutes, room, timerUser, configuration) +} - timers := getTimers(configuration) - if len(timers) == 0 { - say.Error("No timer configured, not starting timer") +func StartBreakTimer(timerInMinutes string, configuration config.Configuration) { + if err := startBreakTimer(timerInMinutes, configuration); err != nil { exit.Exit(1) } +} - for _, timer := range timers { - if err := timer.StartTimer(timeoutInMinutes, configuration); err != nil { - say.Error(err.Error()) - exit.Exit(1) - } - } - - say.Info("It's now " + currentTime() + ". " + fmt.Sprintf("%d min timer ends at approx. %s", timeoutInMinutes, timeOfTimeout) + ". Happy collaborating! :)") - return nil +func startBreakTimer(timerInMinutes string, configuration config.Configuration) error { + room := getMobTimerRoom(configuration) + timerUser := getUserForMobTimer(configuration.TimerUser) + return timerpkg.RunBreakTimer(timerInMinutes, room, timerUser, configuration) } func getMobTimerRoom(configuration config.Configuration) string { if !isGit() { - say.Debug("timer not in git repository, using MOB_TIMER_ROOM for room name") return configuration.TimerRoom } @@ -62,57 +47,12 @@ func getMobTimerRoom(configuration config.Configuration) string { } if configuration.TimerRoomUseWipBranchQualifier && currentWipBranchQualifier != "" { - say.Info("Using wip branch qualifier for room name") return currentWipBranchQualifier } return configuration.TimerRoom } -func StartBreakTimer(timerInMinutes string, configuration config.Configuration) { - if err := startBreakTimer(timerInMinutes, configuration); err != nil { - exit.Exit(1) - } -} - -func startBreakTimer(timerInMinutes string, configuration config.Configuration) error { - err, timeoutInMinutes := toMinutes(timerInMinutes) - if err != nil { - return err - } - - timeoutInSeconds := timeoutInMinutes * 60 - timeOfTimeout := time.Now().Add(time.Minute * time.Duration(timeoutInMinutes)).Format("15:04") - say.Debug(fmt.Sprintf("Starting break timer at %s for %d minutes = %d seconds (parsed from user input %s)", timeOfTimeout, timeoutInMinutes, timeoutInSeconds, timerInMinutes)) - - timers := getTimers(configuration) - if len(timers) == 0 { - say.Error("No break timer configured, not starting break timer") - exit.Exit(1) - } - - for _, timer := range timers { - if err := timer.StartBreakTimer(timeoutInMinutes, configuration); err != nil { - say.Error(err.Error()) - exit.Exit(1) - } - } - - say.Info("It's now " + currentTime() + ". " + fmt.Sprintf("%d min break timer ends at approx. %s", timeoutInMinutes, timeOfTimeout) + ". So take a break now! :)") - return nil -} - -func getTimers(configuration config.Configuration) []Timer { - var timers []Timer - if getMobTimerRoom(configuration) != "" { - timers = append(timers, WebTimer{}) - } - if configuration.TimerLocal { - timers = append(timers, ProcessLocalTimer{}) - } - return timers -} - func getUserForMobTimer(userOverride string) string { if userOverride == "" { return gitUserName() @@ -120,29 +60,3 @@ func getUserForMobTimer(userOverride string) string { return userOverride } -func toMinutes(timerInMinutes string) (error, int) { - timeoutInMinutes, err := strconv.Atoi(timerInMinutes) - if err != nil || timeoutInMinutes < 1 { - say.Error(fmt.Sprintf("The parameter must be an integer number greater then zero")) - return errors.New("The parameter must be an integer number greater then zero"), 0 - } - return nil, timeoutInMinutes -} - -func getSleepCommand(timeoutInSeconds int) string { - return fmt.Sprintf("sleep %d", timeoutInSeconds) -} - -func getVoiceCommand(message string, voiceCommand string) string { - if len(voiceCommand) == 0 { - return "" - } - return injectCommandWithMessage(voiceCommand, message) -} - -func getNotifyCommand(message string, notifyCommand string) string { - if len(notifyCommand) == 0 { - return "" - } - return injectCommandWithMessage(notifyCommand, message) -} diff --git a/timer/localtimer.go b/timer/localtimer.go new file mode 100644 index 00000000..2d5f3dbb --- /dev/null +++ b/timer/localtimer.go @@ -0,0 +1,106 @@ +package timer + +import ( + "fmt" + "os/exec" + "runtime" + "strings" + + config "github.com/remotemobprogramming/mob/v5/configuration" + "github.com/remotemobprogramming/mob/v5/exit" + "github.com/remotemobprogramming/mob/v5/say" + "github.com/remotemobprogramming/mob/v5/workdir" +) + +// ProcessLocalTimer is a Timer implementation that uses background OS processes. +type ProcessLocalTimer struct{} + +func (t ProcessLocalTimer) StartTimer(minutes int, configuration config.Configuration) error { + timeoutInSeconds := minutes * 60 + if err := ExecuteCommandsInBackgroundProcess( + sleepCommand(timeoutInSeconds), + VoiceCommand(configuration.VoiceMessage, configuration.VoiceCommand), + notifyCommand(configuration.NotifyMessage, configuration.NotifyCommand), + "echo \"mobTimer\"", + ); err != nil { + return fmt.Errorf("timer couldn't be started on your system (%s): %w", runtime.GOOS, err) + } + return nil +} + +func (t ProcessLocalTimer) StartBreakTimer(minutes int, configuration config.Configuration) error { + timeoutInSeconds := minutes * 60 + if err := ExecuteCommandsInBackgroundProcess( + sleepCommand(timeoutInSeconds), + VoiceCommand("mob start", configuration.VoiceCommand), + notifyCommand("mob start", configuration.NotifyCommand), + "echo \"mobTimer\"", + ); err != nil { + return fmt.Errorf("break timer couldn't be started on your system (%s): %w", runtime.GOOS, err) + } + return nil +} + +func sleepCommand(timeoutInSeconds int) string { + return fmt.Sprintf("sleep %d", timeoutInSeconds) +} + +// VoiceCommand builds the shell command string for the voice notification. +// Exported because it is also used by the moo feature in the main package. +func VoiceCommand(message string, voiceCommand string) string { + if len(voiceCommand) == 0 { + return "" + } + return injectCommandWithMessage(voiceCommand, message) +} + +func notifyCommand(message string, notifyCommand string) string { + if len(notifyCommand) == 0 { + return "" + } + return injectCommandWithMessage(notifyCommand, message) +} + +func injectCommandWithMessage(command string, message string) string { + placeHolders := strings.Count(command, "%s") + if placeHolders > 1 { + say.Error(fmt.Sprintf("Too many placeholders (%d) in format command string: %s", placeHolders, command)) + exit.Exit(1) + } + if placeHolders == 0 { + return fmt.Sprintf("%s %s", command, message) + } + return fmt.Sprintf(command, message) +} + +// ExecuteCommandsInBackgroundProcess runs the given shell commands in a background OS process. +// Exported because it is also used by the moo feature in the main package. +func ExecuteCommandsInBackgroundProcess(commands ...string) error { + cmds := make([]string, 0) + for _, c := range commands { + if len(c) > 0 { + cmds = append(cmds, c) + } + } + say.Debug(fmt.Sprintf("Operating System %s", runtime.GOOS)) + var err error + switch runtime.GOOS { + case "windows": + err = runInBackground("powershell", "-command", fmt.Sprintf("start-process powershell -NoNewWindow -ArgumentList '-command \"%s\"'", strings.Join(cmds, ";"))) + case "darwin", "linux": + err = runInBackground("sh", "-c", fmt.Sprintf("(%s) &", strings.Join(cmds, ";"))) + default: + say.Warning(fmt.Sprintf("Cannot execute background commands on your os: %s", runtime.GOOS)) + } + return err +} + +func runInBackground(name string, args ...string) error { + command := exec.Command(name, args...) + if len(workdir.Path) > 0 { + command.Dir = workdir.Path + } + commandString := strings.Join(command.Args, " ") + say.Debug("Starting command " + commandString) + return command.Start() +} diff --git a/timer/timer.go b/timer/timer.go new file mode 100644 index 00000000..403feac4 --- /dev/null +++ b/timer/timer.go @@ -0,0 +1,100 @@ +package timer + +import ( + "errors" + "fmt" + "strconv" + "time" + + config "github.com/remotemobprogramming/mob/v5/configuration" + "github.com/remotemobprogramming/mob/v5/exit" + "github.com/remotemobprogramming/mob/v5/say" +) + +// Timer abstracts timer functionality so different implementations can be used. +type Timer interface { + StartTimer(minutes int, configuration config.Configuration) error + StartBreakTimer(minutes int, configuration config.Configuration) error +} + +// GetTimers returns the list of active timers based on room and configuration. +// Both WebTimer and ProcessLocalTimer can be active simultaneously. +func GetTimers(room string, timerUser string, configuration config.Configuration) []Timer { + var timers []Timer + if room != "" { + timers = append(timers, WebTimer{Room: room, TimerUser: timerUser}) + } + if configuration.TimerLocal { + timers = append(timers, ProcessLocalTimer{}) + } + return timers +} + +// RunTimer parses timerInMinutes, starts all configured timers and returns any error. +func RunTimer(timerInMinutes string, room string, timerUser string, configuration config.Configuration) error { + err, timeoutInMinutes := toMinutes(timerInMinutes) + if err != nil { + return err + } + + timeoutInSeconds := timeoutInMinutes * 60 + timeOfTimeout := time.Now().Add(time.Minute * time.Duration(timeoutInMinutes)).Format("15:04") + say.Debug(fmt.Sprintf("Starting timer at %s for %d minutes = %d seconds (parsed from user input %s)", timeOfTimeout, timeoutInMinutes, timeoutInSeconds, timerInMinutes)) + + timers := GetTimers(room, timerUser, configuration) + if len(timers) == 0 { + say.Error("No timer configured, not starting timer") + exit.Exit(1) + } + + for _, t := range timers { + if err := t.StartTimer(timeoutInMinutes, configuration); err != nil { + say.Error(err.Error()) + exit.Exit(1) + } + } + + say.Info("It's now " + currentTime() + ". " + fmt.Sprintf("%d min timer ends at approx. %s", timeoutInMinutes, timeOfTimeout) + ". Happy collaborating! :)") + return nil +} + +// RunBreakTimer parses timerInMinutes, starts all configured break timers and returns any error. +func RunBreakTimer(timerInMinutes string, room string, timerUser string, configuration config.Configuration) error { + err, timeoutInMinutes := toMinutes(timerInMinutes) + if err != nil { + return err + } + + timeoutInSeconds := timeoutInMinutes * 60 + timeOfTimeout := time.Now().Add(time.Minute * time.Duration(timeoutInMinutes)).Format("15:04") + say.Debug(fmt.Sprintf("Starting break timer at %s for %d minutes = %d seconds (parsed from user input %s)", timeOfTimeout, timeoutInMinutes, timeoutInSeconds, timerInMinutes)) + + timers := GetTimers(room, timerUser, configuration) + if len(timers) == 0 { + say.Error("No break timer configured, not starting break timer") + exit.Exit(1) + } + + for _, t := range timers { + if err := t.StartBreakTimer(timeoutInMinutes, configuration); err != nil { + say.Error(err.Error()) + exit.Exit(1) + } + } + + say.Info("It's now " + currentTime() + ". " + fmt.Sprintf("%d min break timer ends at approx. %s", timeoutInMinutes, timeOfTimeout) + ". So take a break now! :)") + return nil +} + +func toMinutes(timerInMinutes string) (error, int) { + timeoutInMinutes, err := strconv.Atoi(timerInMinutes) + if err != nil || timeoutInMinutes < 1 { + say.Error(fmt.Sprintf("The parameter must be an integer number greater then zero")) + return errors.New("The parameter must be an integer number greater then zero"), 0 + } + return nil, timeoutInMinutes +} + +func currentTime() string { + return time.Now().Format("15:04") +} diff --git a/webtimer.go b/timer/webtimer.go similarity index 74% rename from webtimer.go rename to timer/webtimer.go index e8ba4242..289c4016 100644 --- a/webtimer.go +++ b/timer/webtimer.go @@ -1,4 +1,4 @@ -package main +package timer import ( "encoding/json" @@ -9,21 +9,20 @@ import ( ) // WebTimer is a Timer implementation that notifies a remote timer service via HTTP. -type WebTimer struct{} +type WebTimer struct { + Room string + TimerUser string +} func (t WebTimer) StartTimer(minutes int, configuration config.Configuration) error { - room := getMobTimerRoom(configuration) - timerUser := getUserForMobTimer(configuration.TimerUser) - if err := httpPutTimer(minutes, room, timerUser, configuration.TimerUrl, configuration.TimerInsecure); err != nil { + if err := httpPutTimer(minutes, t.Room, t.TimerUser, configuration.TimerUrl, configuration.TimerInsecure); err != nil { return fmt.Errorf("remote timer couldn't be started: %w", err) } return nil } func (t WebTimer) StartBreakTimer(minutes int, configuration config.Configuration) error { - room := getMobTimerRoom(configuration) - timerUser := getUserForMobTimer(configuration.TimerUser) - if err := httpPutBreakTimer(minutes, room, timerUser, configuration.TimerUrl, configuration.TimerInsecure); err != nil { + if err := httpPutBreakTimer(minutes, t.Room, t.TimerUser, configuration.TimerUrl, configuration.TimerInsecure); err != nil { return fmt.Errorf("remote break timer couldn't be started: %w", err) } return nil From 864f2b6e8ea5f0559d70cd12356403a665ebfca8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Feb 2026 08:20:36 +0000 Subject: [PATCH 07/32] Refactor Timer interface: constructor injection and IsActive() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Timer interface now has IsActive() bool, StartTimer(minutes int), StartBreakTimer(minutes int) — configuration no longer passed per call - ProcessLocalTimer and WebTimer receive config via constructor (NewProcessLocalTimer / NewWebTimer) - ProcessLocalTimer.IsActive() returns configuration.TimerLocal - WebTimer.IsActive() returns room != "" - GetTimers() constructs all implementations and filters by IsActive(), so each implementation decides itself whether it should run https://claude.ai/code/session_01DTB35xCmhRgW5SQUsYAtAq --- timer/localtimer.go | 24 +++++++++++++++++------- timer/timer.go | 31 +++++++++++++++++-------------- timer/webtimer.go | 27 +++++++++++++++++++++------ 3 files changed, 55 insertions(+), 27 deletions(-) diff --git a/timer/localtimer.go b/timer/localtimer.go index 2d5f3dbb..33ea7d47 100644 --- a/timer/localtimer.go +++ b/timer/localtimer.go @@ -13,14 +13,24 @@ import ( ) // ProcessLocalTimer is a Timer implementation that uses background OS processes. -type ProcessLocalTimer struct{} +type ProcessLocalTimer struct { + configuration config.Configuration +} + +func NewProcessLocalTimer(configuration config.Configuration) ProcessLocalTimer { + return ProcessLocalTimer{configuration: configuration} +} + +func (t ProcessLocalTimer) IsActive() bool { + return t.configuration.TimerLocal +} -func (t ProcessLocalTimer) StartTimer(minutes int, configuration config.Configuration) error { +func (t ProcessLocalTimer) StartTimer(minutes int) error { timeoutInSeconds := minutes * 60 if err := ExecuteCommandsInBackgroundProcess( sleepCommand(timeoutInSeconds), - VoiceCommand(configuration.VoiceMessage, configuration.VoiceCommand), - notifyCommand(configuration.NotifyMessage, configuration.NotifyCommand), + VoiceCommand(t.configuration.VoiceMessage, t.configuration.VoiceCommand), + notifyCommand(t.configuration.NotifyMessage, t.configuration.NotifyCommand), "echo \"mobTimer\"", ); err != nil { return fmt.Errorf("timer couldn't be started on your system (%s): %w", runtime.GOOS, err) @@ -28,12 +38,12 @@ func (t ProcessLocalTimer) StartTimer(minutes int, configuration config.Configur return nil } -func (t ProcessLocalTimer) StartBreakTimer(minutes int, configuration config.Configuration) error { +func (t ProcessLocalTimer) StartBreakTimer(minutes int) error { timeoutInSeconds := minutes * 60 if err := ExecuteCommandsInBackgroundProcess( sleepCommand(timeoutInSeconds), - VoiceCommand("mob start", configuration.VoiceCommand), - notifyCommand("mob start", configuration.NotifyCommand), + VoiceCommand("mob start", t.configuration.VoiceCommand), + notifyCommand("mob start", t.configuration.NotifyCommand), "echo \"mobTimer\"", ); err != nil { return fmt.Errorf("break timer couldn't be started on your system (%s): %w", runtime.GOOS, err) diff --git a/timer/timer.go b/timer/timer.go index 403feac4..38900ff8 100644 --- a/timer/timer.go +++ b/timer/timer.go @@ -13,24 +13,27 @@ import ( // Timer abstracts timer functionality so different implementations can be used. type Timer interface { - StartTimer(minutes int, configuration config.Configuration) error - StartBreakTimer(minutes int, configuration config.Configuration) error + IsActive() bool + StartTimer(minutes int) error + StartBreakTimer(minutes int) error } -// GetTimers returns the list of active timers based on room and configuration. -// Both WebTimer and ProcessLocalTimer can be active simultaneously. +// GetTimers returns all timers that report themselves as active. func GetTimers(room string, timerUser string, configuration config.Configuration) []Timer { - var timers []Timer - if room != "" { - timers = append(timers, WebTimer{Room: room, TimerUser: timerUser}) + all := []Timer{ + NewWebTimer(room, timerUser, configuration), + NewProcessLocalTimer(configuration), } - if configuration.TimerLocal { - timers = append(timers, ProcessLocalTimer{}) + var active []Timer + for _, t := range all { + if t.IsActive() { + active = append(active, t) + } } - return timers + return active } -// RunTimer parses timerInMinutes, starts all configured timers and returns any error. +// RunTimer parses timerInMinutes, starts all active timers and returns any error. func RunTimer(timerInMinutes string, room string, timerUser string, configuration config.Configuration) error { err, timeoutInMinutes := toMinutes(timerInMinutes) if err != nil { @@ -48,7 +51,7 @@ func RunTimer(timerInMinutes string, room string, timerUser string, configuratio } for _, t := range timers { - if err := t.StartTimer(timeoutInMinutes, configuration); err != nil { + if err := t.StartTimer(timeoutInMinutes); err != nil { say.Error(err.Error()) exit.Exit(1) } @@ -58,7 +61,7 @@ func RunTimer(timerInMinutes string, room string, timerUser string, configuratio return nil } -// RunBreakTimer parses timerInMinutes, starts all configured break timers and returns any error. +// RunBreakTimer parses timerInMinutes, starts all active break timers and returns any error. func RunBreakTimer(timerInMinutes string, room string, timerUser string, configuration config.Configuration) error { err, timeoutInMinutes := toMinutes(timerInMinutes) if err != nil { @@ -76,7 +79,7 @@ func RunBreakTimer(timerInMinutes string, room string, timerUser string, configu } for _, t := range timers { - if err := t.StartBreakTimer(timeoutInMinutes, configuration); err != nil { + if err := t.StartBreakTimer(timeoutInMinutes); err != nil { say.Error(err.Error()) exit.Exit(1) } diff --git a/timer/webtimer.go b/timer/webtimer.go index 289c4016..d07a1bb3 100644 --- a/timer/webtimer.go +++ b/timer/webtimer.go @@ -10,19 +10,34 @@ import ( // WebTimer is a Timer implementation that notifies a remote timer service via HTTP. type WebTimer struct { - Room string - TimerUser string + room string + timerUser string + timerUrl string + timerInsecure bool } -func (t WebTimer) StartTimer(minutes int, configuration config.Configuration) error { - if err := httpPutTimer(minutes, t.Room, t.TimerUser, configuration.TimerUrl, configuration.TimerInsecure); err != nil { +func NewWebTimer(room string, timerUser string, configuration config.Configuration) WebTimer { + return WebTimer{ + room: room, + timerUser: timerUser, + timerUrl: configuration.TimerUrl, + timerInsecure: configuration.TimerInsecure, + } +} + +func (t WebTimer) IsActive() bool { + return t.room != "" +} + +func (t WebTimer) StartTimer(minutes int) error { + if err := httpPutTimer(minutes, t.room, t.timerUser, t.timerUrl, t.timerInsecure); err != nil { return fmt.Errorf("remote timer couldn't be started: %w", err) } return nil } -func (t WebTimer) StartBreakTimer(minutes int, configuration config.Configuration) error { - if err := httpPutBreakTimer(minutes, t.Room, t.TimerUser, configuration.TimerUrl, configuration.TimerInsecure); err != nil { +func (t WebTimer) StartBreakTimer(minutes int) error { + if err := httpPutBreakTimer(minutes, t.room, t.timerUser, t.timerUrl, t.timerInsecure); err != nil { return fmt.Errorf("remote break timer couldn't be started: %w", err) } return nil From 03692a82954cffb3bc79839618bd0708fc12f439 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Feb 2026 09:11:36 +0000 Subject: [PATCH 08/32] WebTimer reads room and timerUser from configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NewWebTimer now takes only config.Configuration; room and timerUser are read from configuration.TimerRoom / configuration.TimerUser - GetTimers, RunTimer, RunBreakTimer drop the separate room/timerUser parameters — just configuration is passed through - main resolves the effective room (incl. WipBranchQualifier logic) and timerUser into the configuration copy before calling the timer package https://claude.ai/code/session_01DTB35xCmhRgW5SQUsYAtAq --- timer.go | 13 ++++++------- timer/timer.go | 12 ++++++------ timer/webtimer.go | 6 +++--- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/timer.go b/timer.go index 93bd7986..7ab87ced 100644 --- a/timer.go +++ b/timer.go @@ -13,9 +13,9 @@ func StartTimer(timerInMinutes string, configuration config.Configuration) { } func startTimer(timerInMinutes string, configuration config.Configuration) error { - room := getMobTimerRoom(configuration) - timerUser := getUserForMobTimer(configuration.TimerUser) - return timerpkg.RunTimer(timerInMinutes, room, timerUser, configuration) + configuration.TimerRoom = getMobTimerRoom(configuration) + configuration.TimerUser = getUserForMobTimer(configuration.TimerUser) + return timerpkg.RunTimer(timerInMinutes, configuration) } func StartBreakTimer(timerInMinutes string, configuration config.Configuration) { @@ -25,9 +25,9 @@ func StartBreakTimer(timerInMinutes string, configuration config.Configuration) } func startBreakTimer(timerInMinutes string, configuration config.Configuration) error { - room := getMobTimerRoom(configuration) - timerUser := getUserForMobTimer(configuration.TimerUser) - return timerpkg.RunBreakTimer(timerInMinutes, room, timerUser, configuration) + configuration.TimerRoom = getMobTimerRoom(configuration) + configuration.TimerUser = getUserForMobTimer(configuration.TimerUser) + return timerpkg.RunBreakTimer(timerInMinutes, configuration) } func getMobTimerRoom(configuration config.Configuration) string { @@ -59,4 +59,3 @@ func getUserForMobTimer(userOverride string) string { } return userOverride } - diff --git a/timer/timer.go b/timer/timer.go index 38900ff8..fbd24a1d 100644 --- a/timer/timer.go +++ b/timer/timer.go @@ -19,9 +19,9 @@ type Timer interface { } // GetTimers returns all timers that report themselves as active. -func GetTimers(room string, timerUser string, configuration config.Configuration) []Timer { +func GetTimers(configuration config.Configuration) []Timer { all := []Timer{ - NewWebTimer(room, timerUser, configuration), + NewWebTimer(configuration), NewProcessLocalTimer(configuration), } var active []Timer @@ -34,7 +34,7 @@ func GetTimers(room string, timerUser string, configuration config.Configuration } // RunTimer parses timerInMinutes, starts all active timers and returns any error. -func RunTimer(timerInMinutes string, room string, timerUser string, configuration config.Configuration) error { +func RunTimer(timerInMinutes string, configuration config.Configuration) error { err, timeoutInMinutes := toMinutes(timerInMinutes) if err != nil { return err @@ -44,7 +44,7 @@ func RunTimer(timerInMinutes string, room string, timerUser string, configuratio timeOfTimeout := time.Now().Add(time.Minute * time.Duration(timeoutInMinutes)).Format("15:04") say.Debug(fmt.Sprintf("Starting timer at %s for %d minutes = %d seconds (parsed from user input %s)", timeOfTimeout, timeoutInMinutes, timeoutInSeconds, timerInMinutes)) - timers := GetTimers(room, timerUser, configuration) + timers := GetTimers(configuration) if len(timers) == 0 { say.Error("No timer configured, not starting timer") exit.Exit(1) @@ -62,7 +62,7 @@ func RunTimer(timerInMinutes string, room string, timerUser string, configuratio } // RunBreakTimer parses timerInMinutes, starts all active break timers and returns any error. -func RunBreakTimer(timerInMinutes string, room string, timerUser string, configuration config.Configuration) error { +func RunBreakTimer(timerInMinutes string, configuration config.Configuration) error { err, timeoutInMinutes := toMinutes(timerInMinutes) if err != nil { return err @@ -72,7 +72,7 @@ func RunBreakTimer(timerInMinutes string, room string, timerUser string, configu timeOfTimeout := time.Now().Add(time.Minute * time.Duration(timeoutInMinutes)).Format("15:04") say.Debug(fmt.Sprintf("Starting break timer at %s for %d minutes = %d seconds (parsed from user input %s)", timeOfTimeout, timeoutInMinutes, timeoutInSeconds, timerInMinutes)) - timers := GetTimers(room, timerUser, configuration) + timers := GetTimers(configuration) if len(timers) == 0 { say.Error("No break timer configured, not starting break timer") exit.Exit(1) diff --git a/timer/webtimer.go b/timer/webtimer.go index d07a1bb3..4dfca74d 100644 --- a/timer/webtimer.go +++ b/timer/webtimer.go @@ -16,10 +16,10 @@ type WebTimer struct { timerInsecure bool } -func NewWebTimer(room string, timerUser string, configuration config.Configuration) WebTimer { +func NewWebTimer(configuration config.Configuration) WebTimer { return WebTimer{ - room: room, - timerUser: timerUser, + room: configuration.TimerRoom, + timerUser: configuration.TimerUser, timerUrl: configuration.TimerUrl, timerInsecure: configuration.TimerInsecure, } From 9a8f74b87953b8907b111cf14dc04713bb843dbc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Feb 2026 09:17:39 +0000 Subject: [PATCH 09/32] Extract timer implementations into self-registering sub-packages - timer/localtimer/ and timer/webtimer/ are now independent packages that register themselves via init() using timer.Register(Factory) - timer package no longer imports its implementations; dependency arrows are reversed: implementations depend on the timer package, not vice versa - timer.go (main) blank-imports both packages to trigger registration - mob.go uses localtimer.ExecuteCommandsInBackgroundProcess/VoiceCommand directly for the moo feature https://claude.ai/code/session_01DTB35xCmhRgW5SQUsYAtAq --- mob.go | 4 ++-- timer.go | 2 ++ timer/{ => localtimer}/localtimer.go | 9 ++++++++- timer/timer.go | 20 ++++++++++++++------ timer/{ => webtimer}/webtimer.go | 17 ++++++++++++----- 5 files changed, 38 insertions(+), 14 deletions(-) rename timer/{ => localtimer}/localtimer.go (94%) rename timer/{ => webtimer}/webtimer.go (86%) diff --git a/mob.go b/mob.go index 1804faf4..81fe4971 100644 --- a/mob.go +++ b/mob.go @@ -21,7 +21,7 @@ import ( "github.com/remotemobprogramming/mob/v5/help" "github.com/remotemobprogramming/mob/v5/open" "github.com/remotemobprogramming/mob/v5/say" - timerpkg "github.com/remotemobprogramming/mob/v5/timer" + "github.com/remotemobprogramming/mob/v5/timer/localtimer" "github.com/remotemobprogramming/mob/v5/workdir" ) @@ -453,7 +453,7 @@ func currentTime() string { func moo(configuration config.Configuration) { voiceMessage := "moo" - err := timerpkg.ExecuteCommandsInBackgroundProcess(timerpkg.VoiceCommand(voiceMessage, configuration.VoiceCommand)) + err := localtimer.ExecuteCommandsInBackgroundProcess(localtimer.VoiceCommand(voiceMessage, configuration.VoiceCommand)) if err != nil { say.Warning(fmt.Sprintf("can't run voice command on your system (%s)", runtime.GOOS)) diff --git a/timer.go b/timer.go index 7ab87ced..c06342ac 100644 --- a/timer.go +++ b/timer.go @@ -4,6 +4,8 @@ import ( config "github.com/remotemobprogramming/mob/v5/configuration" "github.com/remotemobprogramming/mob/v5/exit" timerpkg "github.com/remotemobprogramming/mob/v5/timer" + _ "github.com/remotemobprogramming/mob/v5/timer/localtimer" + _ "github.com/remotemobprogramming/mob/v5/timer/webtimer" ) func StartTimer(timerInMinutes string, configuration config.Configuration) { diff --git a/timer/localtimer.go b/timer/localtimer/localtimer.go similarity index 94% rename from timer/localtimer.go rename to timer/localtimer/localtimer.go index 33ea7d47..b3635a22 100644 --- a/timer/localtimer.go +++ b/timer/localtimer/localtimer.go @@ -1,4 +1,4 @@ -package timer +package localtimer import ( "fmt" @@ -9,9 +9,16 @@ import ( config "github.com/remotemobprogramming/mob/v5/configuration" "github.com/remotemobprogramming/mob/v5/exit" "github.com/remotemobprogramming/mob/v5/say" + "github.com/remotemobprogramming/mob/v5/timer" "github.com/remotemobprogramming/mob/v5/workdir" ) +func init() { + timer.Register(func(configuration config.Configuration) timer.Timer { + return NewProcessLocalTimer(configuration) + }) +} + // ProcessLocalTimer is a Timer implementation that uses background OS processes. type ProcessLocalTimer struct { configuration config.Configuration diff --git a/timer/timer.go b/timer/timer.go index fbd24a1d..73bef221 100644 --- a/timer/timer.go +++ b/timer/timer.go @@ -18,14 +18,22 @@ type Timer interface { StartBreakTimer(minutes int) error } -// GetTimers returns all timers that report themselves as active. +// Factory creates a Timer for the given configuration. +type Factory func(configuration config.Configuration) Timer + +var factories []Factory + +// Register adds a Timer factory to the registry. +// Implementation packages call this in their init() function. +func Register(f Factory) { + factories = append(factories, f) +} + +// GetTimers returns all registered timers that report themselves as active. func GetTimers(configuration config.Configuration) []Timer { - all := []Timer{ - NewWebTimer(configuration), - NewProcessLocalTimer(configuration), - } var active []Timer - for _, t := range all { + for _, f := range factories { + t := f(configuration) if t.IsActive() { active = append(active, t) } diff --git a/timer/webtimer.go b/timer/webtimer/webtimer.go similarity index 86% rename from timer/webtimer.go rename to timer/webtimer/webtimer.go index 4dfca74d..ad6b19bd 100644 --- a/timer/webtimer.go +++ b/timer/webtimer/webtimer.go @@ -1,4 +1,4 @@ -package timer +package webtimer import ( "encoding/json" @@ -6,14 +6,21 @@ import ( config "github.com/remotemobprogramming/mob/v5/configuration" "github.com/remotemobprogramming/mob/v5/httpclient" + "github.com/remotemobprogramming/mob/v5/timer" ) +func init() { + timer.Register(func(configuration config.Configuration) timer.Timer { + return NewWebTimer(configuration) + }) +} + // WebTimer is a Timer implementation that notifies a remote timer service via HTTP. type WebTimer struct { - room string - timerUser string - timerUrl string - timerInsecure bool + room string + timerUser string + timerUrl string + timerInsecure bool } func NewWebTimer(configuration config.Configuration) WebTimer { From cb6878cb0c1bfe086d42fb73afd8e50ca34e6f31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joshua=20T=C3=B6pfer?= Date: Fri, 20 Feb 2026 15:49:05 +0100 Subject: [PATCH 10/32] Remove IsActive() from Timer interface - factories return nil instead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Timer implementations now control their activation by returning nil from their factory functions when not configured, eliminating the need for the IsActive() method in the Timer interface. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- timer/localtimer/localtimer.go | 7 +++---- timer/timer.go | 12 +++++------- timer/webtimer/webtimer.go | 7 +++---- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/timer/localtimer/localtimer.go b/timer/localtimer/localtimer.go index b3635a22..34c50f91 100644 --- a/timer/localtimer/localtimer.go +++ b/timer/localtimer/localtimer.go @@ -15,6 +15,9 @@ import ( func init() { timer.Register(func(configuration config.Configuration) timer.Timer { + if !configuration.TimerLocal { + return nil + } return NewProcessLocalTimer(configuration) }) } @@ -28,10 +31,6 @@ func NewProcessLocalTimer(configuration config.Configuration) ProcessLocalTimer return ProcessLocalTimer{configuration: configuration} } -func (t ProcessLocalTimer) IsActive() bool { - return t.configuration.TimerLocal -} - func (t ProcessLocalTimer) StartTimer(minutes int) error { timeoutInSeconds := minutes * 60 if err := ExecuteCommandsInBackgroundProcess( diff --git a/timer/timer.go b/timer/timer.go index 73bef221..befb8bc3 100644 --- a/timer/timer.go +++ b/timer/timer.go @@ -13,12 +13,12 @@ import ( // Timer abstracts timer functionality so different implementations can be used. type Timer interface { - IsActive() bool StartTimer(minutes int) error StartBreakTimer(minutes int) error } // Factory creates a Timer for the given configuration. +// Returns nil if the timer should not be active. type Factory func(configuration config.Configuration) Timer var factories []Factory @@ -29,12 +29,12 @@ func Register(f Factory) { factories = append(factories, f) } -// GetTimers returns all registered timers that report themselves as active. +// GetTimers returns all registered timers that are active for the given configuration. func GetTimers(configuration config.Configuration) []Timer { var active []Timer for _, f := range factories { t := f(configuration) - if t.IsActive() { + if t != nil { active = append(active, t) } } @@ -48,9 +48,8 @@ func RunTimer(timerInMinutes string, configuration config.Configuration) error { return err } - timeoutInSeconds := timeoutInMinutes * 60 timeOfTimeout := time.Now().Add(time.Minute * time.Duration(timeoutInMinutes)).Format("15:04") - say.Debug(fmt.Sprintf("Starting timer at %s for %d minutes = %d seconds (parsed from user input %s)", timeOfTimeout, timeoutInMinutes, timeoutInSeconds, timerInMinutes)) + say.Debug(fmt.Sprintf("Starting timer at %s for %d minutes (parsed from user input %s)", timeOfTimeout, timeoutInMinutes, timerInMinutes)) timers := GetTimers(configuration) if len(timers) == 0 { @@ -76,9 +75,8 @@ func RunBreakTimer(timerInMinutes string, configuration config.Configuration) er return err } - timeoutInSeconds := timeoutInMinutes * 60 timeOfTimeout := time.Now().Add(time.Minute * time.Duration(timeoutInMinutes)).Format("15:04") - say.Debug(fmt.Sprintf("Starting break timer at %s for %d minutes = %d seconds (parsed from user input %s)", timeOfTimeout, timeoutInMinutes, timeoutInSeconds, timerInMinutes)) + say.Debug(fmt.Sprintf("Starting break timer at %s for %d minutes (parsed from user input %s)", timeOfTimeout, timeoutInMinutes, timerInMinutes)) timers := GetTimers(configuration) if len(timers) == 0 { diff --git a/timer/webtimer/webtimer.go b/timer/webtimer/webtimer.go index ad6b19bd..603b5c9b 100644 --- a/timer/webtimer/webtimer.go +++ b/timer/webtimer/webtimer.go @@ -11,6 +11,9 @@ import ( func init() { timer.Register(func(configuration config.Configuration) timer.Timer { + if configuration.TimerRoom == "" { + return nil + } return NewWebTimer(configuration) }) } @@ -32,10 +35,6 @@ func NewWebTimer(configuration config.Configuration) WebTimer { } } -func (t WebTimer) IsActive() bool { - return t.room != "" -} - func (t WebTimer) StartTimer(minutes int) error { if err := httpPutTimer(minutes, t.room, t.timerUser, t.timerUrl, t.timerInsecure); err != nil { return fmt.Errorf("remote timer couldn't be started: %w", err) From 7df582edc03288113c5b09643d3759553ceaefe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joshua=20T=C3=B6pfer?= Date: Fri, 20 Feb 2026 17:58:34 +0100 Subject: [PATCH 11/32] Move getUserForMobTimer to WebTimer and use git package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The getUserForMobTimer function is now encapsulated within WebTimer where it's actually used. The function now uses the git.Client instead of direct os/exec calls for better consistency with the codebase. Also includes refactoring: improved variable naming and string formatting in timer package. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- timer.go | 9 --------- timer/timer.go | 14 +++++++------- timer/webtimer/webtimer.go | 11 ++++++++++- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/timer.go b/timer.go index c06342ac..7119a669 100644 --- a/timer.go +++ b/timer.go @@ -16,7 +16,6 @@ func StartTimer(timerInMinutes string, configuration config.Configuration) { func startTimer(timerInMinutes string, configuration config.Configuration) error { configuration.TimerRoom = getMobTimerRoom(configuration) - configuration.TimerUser = getUserForMobTimer(configuration.TimerUser) return timerpkg.RunTimer(timerInMinutes, configuration) } @@ -28,7 +27,6 @@ func StartBreakTimer(timerInMinutes string, configuration config.Configuration) func startBreakTimer(timerInMinutes string, configuration config.Configuration) error { configuration.TimerRoom = getMobTimerRoom(configuration) - configuration.TimerUser = getUserForMobTimer(configuration.TimerUser) return timerpkg.RunBreakTimer(timerInMinutes, configuration) } @@ -54,10 +52,3 @@ func getMobTimerRoom(configuration config.Configuration) string { return configuration.TimerRoom } - -func getUserForMobTimer(userOverride string) string { - if userOverride == "" { - return gitUserName() - } - return userOverride -} diff --git a/timer/timer.go b/timer/timer.go index befb8bc3..97180944 100644 --- a/timer/timer.go +++ b/timer/timer.go @@ -31,14 +31,14 @@ func Register(f Factory) { // GetTimers returns all registered timers that are active for the given configuration. func GetTimers(configuration config.Configuration) []Timer { - var active []Timer - for _, f := range factories { - t := f(configuration) + var timers []Timer + for _, createTimer := range factories { + t := createTimer(configuration) if t != nil { - active = append(active, t) + timers = append(timers, t) } } - return active + return timers } // RunTimer parses timerInMinutes, starts all active timers and returns any error. @@ -64,7 +64,7 @@ func RunTimer(timerInMinutes string, configuration config.Configuration) error { } } - say.Info("It's now " + currentTime() + ". " + fmt.Sprintf("%d min timer ends at approx. %s", timeoutInMinutes, timeOfTimeout) + ". Happy collaborating! :)") + say.Info(fmt.Sprintf("It's now %s. %d min timer ends at approx. %s. Happy collaborating! :)", currentTime(), timeoutInMinutes, timeOfTimeout)) return nil } @@ -91,7 +91,7 @@ func RunBreakTimer(timerInMinutes string, configuration config.Configuration) er } } - say.Info("It's now " + currentTime() + ". " + fmt.Sprintf("%d min break timer ends at approx. %s", timeoutInMinutes, timeOfTimeout) + ". So take a break now! :)") + say.Info(fmt.Sprintf("It's now %s. %d min break timer ends at approx. %s. So take a break now! :)", currentTime(), timeoutInMinutes, timeOfTimeout)) return nil } diff --git a/timer/webtimer/webtimer.go b/timer/webtimer/webtimer.go index 603b5c9b..78837dc5 100644 --- a/timer/webtimer/webtimer.go +++ b/timer/webtimer/webtimer.go @@ -5,6 +5,7 @@ import ( "fmt" config "github.com/remotemobprogramming/mob/v5/configuration" + "github.com/remotemobprogramming/mob/v5/git" "github.com/remotemobprogramming/mob/v5/httpclient" "github.com/remotemobprogramming/mob/v5/timer" ) @@ -29,12 +30,20 @@ type WebTimer struct { func NewWebTimer(configuration config.Configuration) WebTimer { return WebTimer{ room: configuration.TimerRoom, - timerUser: configuration.TimerUser, + timerUser: getUserForMobTimer(configuration.TimerUser), timerUrl: configuration.TimerUrl, timerInsecure: configuration.TimerInsecure, } } +func getUserForMobTimer(userOverride string) string { + if userOverride == "" { + gitClient := &git.Client{} + return gitClient.UserName() + } + return userOverride +} + func (t WebTimer) StartTimer(minutes int) error { if err := httpPutTimer(minutes, t.room, t.timerUser, t.timerUrl, t.timerInsecure); err != nil { return fmt.Errorf("remote timer couldn't be started: %w", err) From 66c906c86dbcd3e20ee5276762d2da1b7e5fe5c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joshua=20T=C3=B6pfer?= Date: Fri, 20 Feb 2026 19:57:50 +0100 Subject: [PATCH 12/32] Move timer room logic to WebTimer constructor and enrich config with branch qualifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactors getMobTimerRoom() into two responsibilities: 1. enrichConfigurationWithBranchQualifier() in mob.go extracts the qualifier from the current WIP branch and writes it to the configuration 2. WebTimer constructor determines the effective timer room based on TimerRoomUseWipBranchQualifier setting This fixes the bug where the extracted qualifier was not written to the configuration, making it unavailable for other commands. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- mob.go | 18 ++++++++++++++++++ timer.go | 27 ++------------------------- timer/webtimer/webtimer.go | 8 +++++++- 3 files changed, 27 insertions(+), 26 deletions(-) diff --git a/mob.go b/mob.go index 81fe4971..6b0e7428 100644 --- a/mob.go +++ b/mob.go @@ -435,6 +435,24 @@ func determineBranches(currentBranch Branch, localBranches []string, configurati return } +func enrichConfigurationWithBranchQualifier(configuration config.Configuration) config.Configuration { + if !isGit() { + return configuration + } + + if configuration.WipBranchQualifier == "" { + currentBranch := gitCurrentBranch() + currentBaseBranch, _ := determineBranches(currentBranch, gitBranches(), configuration) + + if currentBranch.IsWipBranch(configuration) { + wipBranchWithoutWipPrefix := currentBranch.removeWipPrefix(configuration).Name + configuration.WipBranchQualifier = removePrefix(removePrefix(wipBranchWithoutWipPrefix, currentBaseBranch.Name), configuration.WipBranchQualifierSeparator) + } + } + + return configuration +} + func injectCommandWithMessage(command string, message string) string { placeHolders := strings.Count(command, "%s") if placeHolders > 1 { diff --git a/timer.go b/timer.go index 7119a669..bdcf9430 100644 --- a/timer.go +++ b/timer.go @@ -15,7 +15,7 @@ func StartTimer(timerInMinutes string, configuration config.Configuration) { } func startTimer(timerInMinutes string, configuration config.Configuration) error { - configuration.TimerRoom = getMobTimerRoom(configuration) + configuration = enrichConfigurationWithBranchQualifier(configuration) return timerpkg.RunTimer(timerInMinutes, configuration) } @@ -26,29 +26,6 @@ func StartBreakTimer(timerInMinutes string, configuration config.Configuration) } func startBreakTimer(timerInMinutes string, configuration config.Configuration) error { - configuration.TimerRoom = getMobTimerRoom(configuration) + configuration = enrichConfigurationWithBranchQualifier(configuration) return timerpkg.RunBreakTimer(timerInMinutes, configuration) } - -func getMobTimerRoom(configuration config.Configuration) string { - if !isGit() { - return configuration.TimerRoom - } - - currentWipBranchQualifier := configuration.WipBranchQualifier - if currentWipBranchQualifier == "" { - currentBranch := gitCurrentBranch() - currentBaseBranch, _ := determineBranches(currentBranch, gitBranches(), configuration) - - if currentBranch.IsWipBranch(configuration) { - wipBranchWithoutWipPrefix := currentBranch.removeWipPrefix(configuration).Name - currentWipBranchQualifier = removePrefix(removePrefix(wipBranchWithoutWipPrefix, currentBaseBranch.Name), configuration.WipBranchQualifierSeparator) - } - } - - if configuration.TimerRoomUseWipBranchQualifier && currentWipBranchQualifier != "" { - return currentWipBranchQualifier - } - - return configuration.TimerRoom -} diff --git a/timer/webtimer/webtimer.go b/timer/webtimer/webtimer.go index 78837dc5..86242f4e 100644 --- a/timer/webtimer/webtimer.go +++ b/timer/webtimer/webtimer.go @@ -28,8 +28,14 @@ type WebTimer struct { } func NewWebTimer(configuration config.Configuration) WebTimer { + // Determine the effective timer room + room := configuration.TimerRoom + if configuration.TimerRoomUseWipBranchQualifier && configuration.WipBranchQualifier != "" { + room = configuration.WipBranchQualifier + } + return WebTimer{ - room: configuration.TimerRoom, + room: room, timerUser: getUserForMobTimer(configuration.TimerUser), timerUrl: configuration.TimerUrl, timerInsecure: configuration.TimerInsecure, From c14e53c3b2a435871f6eacc9844630cec07665fe Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 14:47:14 +0000 Subject: [PATCH 13/32] Replace registry/init() with direct instantiation in timer/timer.go - timer/timer.go imports sub-packages directly and instantiates all known timers; GetTimers filters by IsActive(), RunTimer/RunBreakTimer use the first active timer - IsActive() is back in the Timer interface; each implementation decides itself (WebTimer: room != "", ProcessLocalTimer: TimerLocal flag) - Remove init()/timer.Register() from localtimer and webtimer packages; they no longer import the timer package - Remove blank imports from main/timer.go https://claude.ai/code/session_01DTB35xCmhRgW5SQUsYAtAq --- timer.go | 2 -- timer/localtimer/localtimer.go | 14 +++------ timer/timer.go | 52 ++++++++++++++-------------------- timer/webtimer/webtimer.go | 16 +++-------- 4 files changed, 29 insertions(+), 55 deletions(-) diff --git a/timer.go b/timer.go index bdcf9430..88775c1c 100644 --- a/timer.go +++ b/timer.go @@ -4,8 +4,6 @@ import ( config "github.com/remotemobprogramming/mob/v5/configuration" "github.com/remotemobprogramming/mob/v5/exit" timerpkg "github.com/remotemobprogramming/mob/v5/timer" - _ "github.com/remotemobprogramming/mob/v5/timer/localtimer" - _ "github.com/remotemobprogramming/mob/v5/timer/webtimer" ) func StartTimer(timerInMinutes string, configuration config.Configuration) { diff --git a/timer/localtimer/localtimer.go b/timer/localtimer/localtimer.go index 34c50f91..b35e99e1 100644 --- a/timer/localtimer/localtimer.go +++ b/timer/localtimer/localtimer.go @@ -9,19 +9,9 @@ import ( config "github.com/remotemobprogramming/mob/v5/configuration" "github.com/remotemobprogramming/mob/v5/exit" "github.com/remotemobprogramming/mob/v5/say" - "github.com/remotemobprogramming/mob/v5/timer" "github.com/remotemobprogramming/mob/v5/workdir" ) -func init() { - timer.Register(func(configuration config.Configuration) timer.Timer { - if !configuration.TimerLocal { - return nil - } - return NewProcessLocalTimer(configuration) - }) -} - // ProcessLocalTimer is a Timer implementation that uses background OS processes. type ProcessLocalTimer struct { configuration config.Configuration @@ -31,6 +21,10 @@ func NewProcessLocalTimer(configuration config.Configuration) ProcessLocalTimer return ProcessLocalTimer{configuration: configuration} } +func (t ProcessLocalTimer) IsActive() bool { + return t.configuration.TimerLocal +} + func (t ProcessLocalTimer) StartTimer(minutes int) error { timeoutInSeconds := minutes * 60 if err := ExecuteCommandsInBackgroundProcess( diff --git a/timer/timer.go b/timer/timer.go index 97180944..14b24f9b 100644 --- a/timer/timer.go +++ b/timer/timer.go @@ -9,39 +9,33 @@ import ( config "github.com/remotemobprogramming/mob/v5/configuration" "github.com/remotemobprogramming/mob/v5/exit" "github.com/remotemobprogramming/mob/v5/say" + "github.com/remotemobprogramming/mob/v5/timer/localtimer" + "github.com/remotemobprogramming/mob/v5/timer/webtimer" ) // Timer abstracts timer functionality so different implementations can be used. type Timer interface { + IsActive() bool StartTimer(minutes int) error StartBreakTimer(minutes int) error } -// Factory creates a Timer for the given configuration. -// Returns nil if the timer should not be active. -type Factory func(configuration config.Configuration) Timer - -var factories []Factory - -// Register adds a Timer factory to the registry. -// Implementation packages call this in their init() function. -func Register(f Factory) { - factories = append(factories, f) -} - -// GetTimers returns all registered timers that are active for the given configuration. +// GetTimers returns all timers that report themselves as active. func GetTimers(configuration config.Configuration) []Timer { - var timers []Timer - for _, createTimer := range factories { - t := createTimer(configuration) - if t != nil { - timers = append(timers, t) + all := []Timer{ + webtimer.NewWebTimer(configuration), + localtimer.NewProcessLocalTimer(configuration), + } + var active []Timer + for _, t := range all { + if t.IsActive() { + active = append(active, t) } } - return timers + return active } -// RunTimer parses timerInMinutes, starts all active timers and returns any error. +// RunTimer parses timerInMinutes and starts the first active timer. func RunTimer(timerInMinutes string, configuration config.Configuration) error { err, timeoutInMinutes := toMinutes(timerInMinutes) if err != nil { @@ -57,18 +51,16 @@ func RunTimer(timerInMinutes string, configuration config.Configuration) error { exit.Exit(1) } - for _, t := range timers { - if err := t.StartTimer(timeoutInMinutes); err != nil { - say.Error(err.Error()) - exit.Exit(1) - } + if err := timers[0].StartTimer(timeoutInMinutes); err != nil { + say.Error(err.Error()) + exit.Exit(1) } say.Info(fmt.Sprintf("It's now %s. %d min timer ends at approx. %s. Happy collaborating! :)", currentTime(), timeoutInMinutes, timeOfTimeout)) return nil } -// RunBreakTimer parses timerInMinutes, starts all active break timers and returns any error. +// RunBreakTimer parses timerInMinutes and starts the first active break timer. func RunBreakTimer(timerInMinutes string, configuration config.Configuration) error { err, timeoutInMinutes := toMinutes(timerInMinutes) if err != nil { @@ -84,11 +76,9 @@ func RunBreakTimer(timerInMinutes string, configuration config.Configuration) er exit.Exit(1) } - for _, t := range timers { - if err := t.StartBreakTimer(timeoutInMinutes); err != nil { - say.Error(err.Error()) - exit.Exit(1) - } + if err := timers[0].StartBreakTimer(timeoutInMinutes); err != nil { + say.Error(err.Error()) + exit.Exit(1) } say.Info(fmt.Sprintf("It's now %s. %d min break timer ends at approx. %s. So take a break now! :)", currentTime(), timeoutInMinutes, timeOfTimeout)) diff --git a/timer/webtimer/webtimer.go b/timer/webtimer/webtimer.go index 86242f4e..6d06190e 100644 --- a/timer/webtimer/webtimer.go +++ b/timer/webtimer/webtimer.go @@ -7,18 +7,8 @@ import ( config "github.com/remotemobprogramming/mob/v5/configuration" "github.com/remotemobprogramming/mob/v5/git" "github.com/remotemobprogramming/mob/v5/httpclient" - "github.com/remotemobprogramming/mob/v5/timer" ) -func init() { - timer.Register(func(configuration config.Configuration) timer.Timer { - if configuration.TimerRoom == "" { - return nil - } - return NewWebTimer(configuration) - }) -} - // WebTimer is a Timer implementation that notifies a remote timer service via HTTP. type WebTimer struct { room string @@ -28,12 +18,10 @@ type WebTimer struct { } func NewWebTimer(configuration config.Configuration) WebTimer { - // Determine the effective timer room room := configuration.TimerRoom if configuration.TimerRoomUseWipBranchQualifier && configuration.WipBranchQualifier != "" { room = configuration.WipBranchQualifier } - return WebTimer{ room: room, timerUser: getUserForMobTimer(configuration.TimerUser), @@ -50,6 +38,10 @@ func getUserForMobTimer(userOverride string) string { return userOverride } +func (t WebTimer) IsActive() bool { + return t.room != "" +} + func (t WebTimer) StartTimer(minutes int) error { if err := httpPutTimer(minutes, t.room, t.timerUser, t.timerUrl, t.timerInsecure); err != nil { return fmt.Errorf("remote timer couldn't be started: %w", err) From da5f2c52f592c3d2cd2140474efc6af842440d83 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 07:30:28 +0000 Subject: [PATCH 14/32] Make getTimers private since it is only used internally https://claude.ai/code/session_01DTB35xCmhRgW5SQUsYAtAq --- timer/timer.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/timer/timer.go b/timer/timer.go index 14b24f9b..b02ea5ae 100644 --- a/timer/timer.go +++ b/timer/timer.go @@ -20,8 +20,7 @@ type Timer interface { StartBreakTimer(minutes int) error } -// GetTimers returns all timers that report themselves as active. -func GetTimers(configuration config.Configuration) []Timer { +func getTimers(configuration config.Configuration) []Timer { all := []Timer{ webtimer.NewWebTimer(configuration), localtimer.NewProcessLocalTimer(configuration), @@ -45,7 +44,7 @@ func RunTimer(timerInMinutes string, configuration config.Configuration) error { timeOfTimeout := time.Now().Add(time.Minute * time.Duration(timeoutInMinutes)).Format("15:04") say.Debug(fmt.Sprintf("Starting timer at %s for %d minutes (parsed from user input %s)", timeOfTimeout, timeoutInMinutes, timerInMinutes)) - timers := GetTimers(configuration) + timers := getTimers(configuration) if len(timers) == 0 { say.Error("No timer configured, not starting timer") exit.Exit(1) @@ -70,7 +69,7 @@ func RunBreakTimer(timerInMinutes string, configuration config.Configuration) er timeOfTimeout := time.Now().Add(time.Minute * time.Duration(timeoutInMinutes)).Format("15:04") say.Debug(fmt.Sprintf("Starting break timer at %s for %d minutes (parsed from user input %s)", timeOfTimeout, timeoutInMinutes, timerInMinutes)) - timers := GetTimers(configuration) + timers := getTimers(configuration) if len(timers) == 0 { say.Error("No break timer configured, not starting break timer") exit.Exit(1) From 1d11296e8c90dd73aeb488201d4f87f020017cc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 07:52:56 +0000 Subject: [PATCH 15/32] Rename getTimers to getActiveTimers and log active timers on debug https://claude.ai/code/session_01DTB35xCmhRgW5SQUsYAtAq --- timer/timer.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/timer/timer.go b/timer/timer.go index b02ea5ae..7342b771 100644 --- a/timer/timer.go +++ b/timer/timer.go @@ -20,7 +20,7 @@ type Timer interface { StartBreakTimer(minutes int) error } -func getTimers(configuration config.Configuration) []Timer { +func getActiveTimers(configuration config.Configuration) []Timer { all := []Timer{ webtimer.NewWebTimer(configuration), localtimer.NewProcessLocalTimer(configuration), @@ -31,6 +31,11 @@ func getTimers(configuration config.Configuration) []Timer { active = append(active, t) } } + names := make([]string, len(active)) + for i, t := range active { + names[i] = fmt.Sprintf("%T", t) + } + say.Debug(fmt.Sprintf("Active timers: %v", names)) return active } @@ -44,7 +49,7 @@ func RunTimer(timerInMinutes string, configuration config.Configuration) error { timeOfTimeout := time.Now().Add(time.Minute * time.Duration(timeoutInMinutes)).Format("15:04") say.Debug(fmt.Sprintf("Starting timer at %s for %d minutes (parsed from user input %s)", timeOfTimeout, timeoutInMinutes, timerInMinutes)) - timers := getTimers(configuration) + timers := getActiveTimers(configuration) if len(timers) == 0 { say.Error("No timer configured, not starting timer") exit.Exit(1) @@ -69,7 +74,7 @@ func RunBreakTimer(timerInMinutes string, configuration config.Configuration) er timeOfTimeout := time.Now().Add(time.Minute * time.Duration(timeoutInMinutes)).Format("15:04") say.Debug(fmt.Sprintf("Starting break timer at %s for %d minutes (parsed from user input %s)", timeOfTimeout, timeoutInMinutes, timerInMinutes)) - timers := getTimers(configuration) + timers := getActiveTimers(configuration) if len(timers) == 0 { say.Error("No break timer configured, not starting break timer") exit.Exit(1) From bb90ab4cc606578321cf87db6258fb229fbed66c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 07:54:50 +0000 Subject: [PATCH 16/32] Simplify getActiveTimer to return a single Timer instead of a slice https://claude.ai/code/session_01DTB35xCmhRgW5SQUsYAtAq --- timer/timer.go | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/timer/timer.go b/timer/timer.go index 7342b771..1e132ab1 100644 --- a/timer/timer.go +++ b/timer/timer.go @@ -20,23 +20,19 @@ type Timer interface { StartBreakTimer(minutes int) error } -func getActiveTimers(configuration config.Configuration) []Timer { +func getActiveTimer(configuration config.Configuration) Timer { all := []Timer{ webtimer.NewWebTimer(configuration), localtimer.NewProcessLocalTimer(configuration), } - var active []Timer for _, t := range all { if t.IsActive() { - active = append(active, t) + say.Debug(fmt.Sprintf("Active timer: %T", t)) + return t } } - names := make([]string, len(active)) - for i, t := range active { - names[i] = fmt.Sprintf("%T", t) - } - say.Debug(fmt.Sprintf("Active timers: %v", names)) - return active + say.Debug("No active timer found") + return nil } // RunTimer parses timerInMinutes and starts the first active timer. @@ -49,13 +45,13 @@ func RunTimer(timerInMinutes string, configuration config.Configuration) error { timeOfTimeout := time.Now().Add(time.Minute * time.Duration(timeoutInMinutes)).Format("15:04") say.Debug(fmt.Sprintf("Starting timer at %s for %d minutes (parsed from user input %s)", timeOfTimeout, timeoutInMinutes, timerInMinutes)) - timers := getActiveTimers(configuration) - if len(timers) == 0 { + timer := getActiveTimer(configuration) + if timer == nil { say.Error("No timer configured, not starting timer") exit.Exit(1) } - if err := timers[0].StartTimer(timeoutInMinutes); err != nil { + if err := timer.StartTimer(timeoutInMinutes); err != nil { say.Error(err.Error()) exit.Exit(1) } @@ -74,13 +70,13 @@ func RunBreakTimer(timerInMinutes string, configuration config.Configuration) er timeOfTimeout := time.Now().Add(time.Minute * time.Duration(timeoutInMinutes)).Format("15:04") say.Debug(fmt.Sprintf("Starting break timer at %s for %d minutes (parsed from user input %s)", timeOfTimeout, timeoutInMinutes, timerInMinutes)) - timers := getActiveTimers(configuration) - if len(timers) == 0 { + timer := getActiveTimer(configuration) + if timer == nil { say.Error("No break timer configured, not starting break timer") exit.Exit(1) } - if err := timers[0].StartBreakTimer(timeoutInMinutes); err != nil { + if err := timer.StartBreakTimer(timeoutInMinutes); err != nil { say.Error(err.Error()) exit.Exit(1) } From 9f0700df233a9d90a8ade68922b33b8ef8e6c171 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 07:56:20 +0000 Subject: [PATCH 17/32] Log all active timers and the selected one in getActiveTimer https://claude.ai/code/session_01DTB35xCmhRgW5SQUsYAtAq --- timer/timer.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/timer/timer.go b/timer/timer.go index 1e132ab1..9879b09e 100644 --- a/timer/timer.go +++ b/timer/timer.go @@ -25,14 +25,19 @@ func getActiveTimer(configuration config.Configuration) Timer { webtimer.NewWebTimer(configuration), localtimer.NewProcessLocalTimer(configuration), } + var active []string + var first Timer for _, t := range all { if t.IsActive() { - say.Debug(fmt.Sprintf("Active timer: %T", t)) - return t + active = append(active, fmt.Sprintf("%T", t)) + if first == nil { + first = t + } } } - say.Debug("No active timer found") - return nil + say.Debug(fmt.Sprintf("Active timers: %v", active)) + say.Debug(fmt.Sprintf("Using timer: %T", first)) + return first } // RunTimer parses timerInMinutes and starts the first active timer. From eb808dbaacbc0b212942e36ac85792183a153dca Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 08:03:05 +0000 Subject: [PATCH 18/32] Remove unnecessary timerpkg alias, import timer package by its own name https://claude.ai/code/session_01DTB35xCmhRgW5SQUsYAtAq --- timer.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/timer.go b/timer.go index 88775c1c..c35c1113 100644 --- a/timer.go +++ b/timer.go @@ -3,7 +3,7 @@ package main import ( config "github.com/remotemobprogramming/mob/v5/configuration" "github.com/remotemobprogramming/mob/v5/exit" - timerpkg "github.com/remotemobprogramming/mob/v5/timer" + "github.com/remotemobprogramming/mob/v5/timer" ) func StartTimer(timerInMinutes string, configuration config.Configuration) { @@ -14,7 +14,7 @@ func StartTimer(timerInMinutes string, configuration config.Configuration) { func startTimer(timerInMinutes string, configuration config.Configuration) error { configuration = enrichConfigurationWithBranchQualifier(configuration) - return timerpkg.RunTimer(timerInMinutes, configuration) + return timer.RunTimer(timerInMinutes, configuration) } func StartBreakTimer(timerInMinutes string, configuration config.Configuration) { @@ -25,5 +25,5 @@ func StartBreakTimer(timerInMinutes string, configuration config.Configuration) func startBreakTimer(timerInMinutes string, configuration config.Configuration) error { configuration = enrichConfigurationWithBranchQualifier(configuration) - return timerpkg.RunBreakTimer(timerInMinutes, configuration) + return timer.RunBreakTimer(timerInMinutes, configuration) } From 3656b778460d5a1549fd5829b36d0c7bdf399eec Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 08:24:25 +0000 Subject: [PATCH 19/32] Add tests for timer, localtimer, and webtimer packages https://claude.ai/code/session_01DTB35xCmhRgW5SQUsYAtAq --- timer/localtimer/localtimer_test.go | 54 ++++++++++++ timer/timer_test.go | 86 +++++++++++++++++++ timer/webtimer/webtimer_test.go | 123 ++++++++++++++++++++++++++++ 3 files changed, 263 insertions(+) create mode 100644 timer/localtimer/localtimer_test.go create mode 100644 timer/timer_test.go create mode 100644 timer/webtimer/webtimer_test.go diff --git a/timer/localtimer/localtimer_test.go b/timer/localtimer/localtimer_test.go new file mode 100644 index 00000000..b0925165 --- /dev/null +++ b/timer/localtimer/localtimer_test.go @@ -0,0 +1,54 @@ +package localtimer_test + +import ( + "testing" + + config "github.com/remotemobprogramming/mob/v5/configuration" + "github.com/remotemobprogramming/mob/v5/timer/localtimer" +) + +func TestIsActiveWhenTimerLocalTrue(t *testing.T) { + cfg := config.GetDefaultConfiguration() + cfg.TimerLocal = true + + timer := localtimer.NewProcessLocalTimer(cfg) + + if !timer.IsActive() { + t.Error("expected timer to be active when TimerLocal is true") + } +} + +func TestIsInactiveWhenTimerLocalFalse(t *testing.T) { + cfg := config.GetDefaultConfiguration() + cfg.TimerLocal = false + + timer := localtimer.NewProcessLocalTimer(cfg) + + if timer.IsActive() { + t.Error("expected timer to be inactive when TimerLocal is false") + } +} + +func TestVoiceCommandReturnsEmptyWhenCommandNotConfigured(t *testing.T) { + result := localtimer.VoiceCommand("mob next", "") + + if result != "" { + t.Errorf("expected empty string, got %q", result) + } +} + +func TestVoiceCommandInjectsMessageWithPlaceholder(t *testing.T) { + result := localtimer.VoiceCommand("mob next", "say %s") + + if result != "say mob next" { + t.Errorf("expected %q, got %q", "say mob next", result) + } +} + +func TestVoiceCommandAppendsMessageWithoutPlaceholder(t *testing.T) { + result := localtimer.VoiceCommand("mob next", "say") + + if result != "say mob next" { + t.Errorf("expected %q, got %q", "say mob next", result) + } +} diff --git a/timer/timer_test.go b/timer/timer_test.go new file mode 100644 index 00000000..8fc918f3 --- /dev/null +++ b/timer/timer_test.go @@ -0,0 +1,86 @@ +package timer_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + config "github.com/remotemobprogramming/mob/v5/configuration" + "github.com/remotemobprogramming/mob/v5/timer" +) + +func TestRunTimerReturnsErrorForZeroMinutes(t *testing.T) { + cfg := config.GetDefaultConfiguration() + + err := timer.RunTimer("0", cfg) + + if err == nil { + t.Error("expected error for zero minutes") + } +} + +func TestRunTimerReturnsErrorForNonNumericInput(t *testing.T) { + cfg := config.GetDefaultConfiguration() + + err := timer.RunTimer("NotANumber", cfg) + + if err == nil { + t.Error("expected error for non-numeric input") + } +} + +func TestRunBreakTimerReturnsErrorForZeroMinutes(t *testing.T) { + cfg := config.GetDefaultConfiguration() + + err := timer.RunBreakTimer("0", cfg) + + if err == nil { + t.Error("expected error for zero minutes") + } +} + +func TestRunBreakTimerReturnsErrorForNonNumericInput(t *testing.T) { + cfg := config.GetDefaultConfiguration() + + err := timer.RunBreakTimer("NotANumber", cfg) + + if err == nil { + t.Error("expected error for non-numeric input") + } +} + +func TestRunTimerSucceedsWithWebTimer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + cfg := config.GetDefaultConfiguration() + cfg.TimerLocal = false + cfg.TimerRoom = "testroom" + cfg.TimerUrl = server.URL + "/" + + err := timer.RunTimer("1", cfg) + + if err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +func TestRunBreakTimerSucceedsWithWebTimer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + cfg := config.GetDefaultConfiguration() + cfg.TimerLocal = false + cfg.TimerRoom = "testroom" + cfg.TimerUrl = server.URL + "/" + + err := timer.RunBreakTimer("1", cfg) + + if err != nil { + t.Errorf("unexpected error: %v", err) + } +} diff --git a/timer/webtimer/webtimer_test.go b/timer/webtimer/webtimer_test.go new file mode 100644 index 00000000..82a96f54 --- /dev/null +++ b/timer/webtimer/webtimer_test.go @@ -0,0 +1,123 @@ +package webtimer_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + config "github.com/remotemobprogramming/mob/v5/configuration" + "github.com/remotemobprogramming/mob/v5/timer/webtimer" +) + +func TestIsActiveWhenRoomIsSet(t *testing.T) { + cfg := config.GetDefaultConfiguration() + cfg.TimerRoom = "testroom" + + timer := webtimer.NewWebTimer(cfg) + + if !timer.IsActive() { + t.Error("expected timer to be active when TimerRoom is set") + } +} + +func TestIsInactiveWhenRoomIsEmpty(t *testing.T) { + cfg := config.GetDefaultConfiguration() + cfg.TimerRoom = "" + + timer := webtimer.NewWebTimer(cfg) + + if timer.IsActive() { + t.Error("expected timer to be inactive when TimerRoom is empty") + } +} + +func TestUsesWipBranchQualifierAsRoom(t *testing.T) { + cfg := config.GetDefaultConfiguration() + cfg.TimerRoom = "" + cfg.TimerRoomUseWipBranchQualifier = true + cfg.WipBranchQualifier = "feature-x" + + timer := webtimer.NewWebTimer(cfg) + + if !timer.IsActive() { + t.Error("expected timer to be active when WipBranchQualifier is used as room") + } +} + +func TestUsesTimerRoomWhenWipBranchQualifierIsEmpty(t *testing.T) { + cfg := config.GetDefaultConfiguration() + cfg.TimerRoom = "myroom" + cfg.TimerRoomUseWipBranchQualifier = true + cfg.WipBranchQualifier = "" + + timer := webtimer.NewWebTimer(cfg) + + if !timer.IsActive() { + t.Error("expected timer to use TimerRoom when WipBranchQualifier is empty") + } +} + +func TestStartTimerSendsPutWithTimerAndUser(t *testing.T) { + var capturedBody []byte + var capturedMethod string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedMethod = r.Method + capturedBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + cfg := config.GetDefaultConfiguration() + cfg.TimerRoom = "testroom" + cfg.TimerUser = "testuser" + cfg.TimerUrl = server.URL + "/" + timer := webtimer.NewWebTimer(cfg) + + err := timer.StartTimer(10) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if capturedMethod != "PUT" { + t.Errorf("expected PUT, got %s", capturedMethod) + } + var body map[string]interface{} + json.Unmarshal(capturedBody, &body) + if body["timer"] != float64(10) { + t.Errorf("expected timer=10, got %v", body["timer"]) + } + if body["user"] != "testuser" { + t.Errorf("expected user=testuser, got %v", body["user"]) + } +} + +func TestStartBreakTimerSendsPutWithBreakTimerAndUser(t *testing.T) { + var capturedBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + cfg := config.GetDefaultConfiguration() + cfg.TimerRoom = "testroom" + cfg.TimerUser = "testuser" + cfg.TimerUrl = server.URL + "/" + timer := webtimer.NewWebTimer(cfg) + + err := timer.StartBreakTimer(5) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var body map[string]interface{} + json.Unmarshal(capturedBody, &body) + if body["breaktimer"] != float64(5) { + t.Errorf("expected breaktimer=5, got %v", body["breaktimer"]) + } + if body["user"] != "testuser" { + t.Errorf("expected user=testuser, got %v", body["user"]) + } +} From 8ced134bf6765f86ae1eef899e2fc91a3e8065ec Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 08:38:07 +0000 Subject: [PATCH 20/32] Separate timer construction from selection for testability with mocks getActiveTimer now receives a []Timer instead of building them itself. buildTimers creates the real list from configuration. Tests use mockTimer without needing an HTTP server. https://claude.ai/code/session_01DTB35xCmhRgW5SQUsYAtAq --- timer/timer.go | 13 +++--- timer/timer_test.go | 99 ++++++++++++++++++++++----------------------- 2 files changed, 57 insertions(+), 55 deletions(-) diff --git a/timer/timer.go b/timer/timer.go index 9879b09e..160b60cf 100644 --- a/timer/timer.go +++ b/timer/timer.go @@ -20,14 +20,17 @@ type Timer interface { StartBreakTimer(minutes int) error } -func getActiveTimer(configuration config.Configuration) Timer { - all := []Timer{ +func buildTimers(configuration config.Configuration) []Timer { + return []Timer{ webtimer.NewWebTimer(configuration), localtimer.NewProcessLocalTimer(configuration), } +} + +func getActiveTimer(timers []Timer) Timer { var active []string var first Timer - for _, t := range all { + for _, t := range timers { if t.IsActive() { active = append(active, fmt.Sprintf("%T", t)) if first == nil { @@ -50,7 +53,7 @@ func RunTimer(timerInMinutes string, configuration config.Configuration) error { timeOfTimeout := time.Now().Add(time.Minute * time.Duration(timeoutInMinutes)).Format("15:04") say.Debug(fmt.Sprintf("Starting timer at %s for %d minutes (parsed from user input %s)", timeOfTimeout, timeoutInMinutes, timerInMinutes)) - timer := getActiveTimer(configuration) + timer := getActiveTimer(buildTimers(configuration)) if timer == nil { say.Error("No timer configured, not starting timer") exit.Exit(1) @@ -75,7 +78,7 @@ func RunBreakTimer(timerInMinutes string, configuration config.Configuration) er timeOfTimeout := time.Now().Add(time.Minute * time.Duration(timeoutInMinutes)).Format("15:04") say.Debug(fmt.Sprintf("Starting break timer at %s for %d minutes (parsed from user input %s)", timeOfTimeout, timeoutInMinutes, timerInMinutes)) - timer := getActiveTimer(configuration) + timer := getActiveTimer(buildTimers(configuration)) if timer == nil { say.Error("No break timer configured, not starting break timer") exit.Exit(1) diff --git a/timer/timer_test.go b/timer/timer_test.go index 8fc918f3..51ddd313 100644 --- a/timer/timer_test.go +++ b/timer/timer_test.go @@ -1,86 +1,85 @@ -package timer_test +package timer import ( - "net/http" - "net/http/httptest" "testing" config "github.com/remotemobprogramming/mob/v5/configuration" - "github.com/remotemobprogramming/mob/v5/timer" ) -func TestRunTimerReturnsErrorForZeroMinutes(t *testing.T) { - cfg := config.GetDefaultConfiguration() +type mockTimer struct { + active bool + startTimerCalled bool + startBreakTimerCalled bool +} + +func (m *mockTimer) IsActive() bool { return m.active } +func (m *mockTimer) StartTimer(_ int) error { + m.startTimerCalled = true + return nil +} +func (m *mockTimer) StartBreakTimer(_ int) error { + m.startBreakTimerCalled = true + return nil +} - err := timer.RunTimer("0", cfg) +func TestGetActiveTimerReturnsFirstActiveTimer(t *testing.T) { + inactive := &mockTimer{active: false} + active := &mockTimer{active: true} - if err == nil { - t.Error("expected error for zero minutes") + result := getActiveTimer([]Timer{inactive, active}) + + if result != active { + t.Error("expected the first active timer to be returned") } } -func TestRunTimerReturnsErrorForNonNumericInput(t *testing.T) { - cfg := config.GetDefaultConfiguration() - - err := timer.RunTimer("NotANumber", cfg) +func TestGetActiveTimerReturnsNilWhenNoneActive(t *testing.T) { + result := getActiveTimer([]Timer{&mockTimer{active: false}}) - if err == nil { - t.Error("expected error for non-numeric input") + if result != nil { + t.Error("expected nil when no timer is active") } } -func TestRunBreakTimerReturnsErrorForZeroMinutes(t *testing.T) { - cfg := config.GetDefaultConfiguration() +func TestGetActiveTimerPrefersFirstOverSecond(t *testing.T) { + first := &mockTimer{active: true} + second := &mockTimer{active: true} + + result := getActiveTimer([]Timer{first, second}) - err := timer.RunBreakTimer("0", cfg) + if result != first { + t.Error("expected the first active timer to take priority") + } +} + +func TestRunTimerReturnsErrorForZeroMinutes(t *testing.T) { + err := RunTimer("0", config.GetDefaultConfiguration()) if err == nil { t.Error("expected error for zero minutes") } } -func TestRunBreakTimerReturnsErrorForNonNumericInput(t *testing.T) { - cfg := config.GetDefaultConfiguration() - - err := timer.RunBreakTimer("NotANumber", cfg) +func TestRunTimerReturnsErrorForNonNumericInput(t *testing.T) { + err := RunTimer("NotANumber", config.GetDefaultConfiguration()) if err == nil { t.Error("expected error for non-numeric input") } } -func TestRunTimerSucceedsWithWebTimer(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - cfg := config.GetDefaultConfiguration() - cfg.TimerLocal = false - cfg.TimerRoom = "testroom" - cfg.TimerUrl = server.URL + "/" - - err := timer.RunTimer("1", cfg) +func TestRunBreakTimerReturnsErrorForZeroMinutes(t *testing.T) { + err := RunBreakTimer("0", config.GetDefaultConfiguration()) - if err != nil { - t.Errorf("unexpected error: %v", err) + if err == nil { + t.Error("expected error for zero minutes") } } -func TestRunBreakTimerSucceedsWithWebTimer(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - cfg := config.GetDefaultConfiguration() - cfg.TimerLocal = false - cfg.TimerRoom = "testroom" - cfg.TimerUrl = server.URL + "/" - - err := timer.RunBreakTimer("1", cfg) +func TestRunBreakTimerReturnsErrorForNonNumericInput(t *testing.T) { + err := RunBreakTimer("NotANumber", config.GetDefaultConfiguration()) - if err != nil { - t.Errorf("unexpected error: %v", err) + if err == nil { + t.Error("expected error for non-numeric input") } } From 3a5e2064a5d4a0b5541c7b41107a7373d8e4499b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 08:44:08 +0000 Subject: [PATCH 21/32] Verify minutes are passed correctly to timer via mock in tests Extract runWith/runBreakWith so tests can inject a mockTimer and assert the exact minutes value passed to StartTimer/StartBreakTimer. https://claude.ai/code/session_01DTB35xCmhRgW5SQUsYAtAq --- timer/timer.go | 32 ++++++++++++++++++++------------ timer/timer_test.go | 32 ++++++++++++++++++++++++++------ 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/timer/timer.go b/timer/timer.go index 160b60cf..b08022c0 100644 --- a/timer/timer.go +++ b/timer/timer.go @@ -45,51 +45,59 @@ func getActiveTimer(timers []Timer) Timer { // RunTimer parses timerInMinutes and starts the first active timer. func RunTimer(timerInMinutes string, configuration config.Configuration) error { - err, timeoutInMinutes := toMinutes(timerInMinutes) + return runWith(buildTimers(configuration), timerInMinutes) +} + +func runWith(timers []Timer, timerInMinutes string) error { + err, minutes := toMinutes(timerInMinutes) if err != nil { return err } - timeOfTimeout := time.Now().Add(time.Minute * time.Duration(timeoutInMinutes)).Format("15:04") - say.Debug(fmt.Sprintf("Starting timer at %s for %d minutes (parsed from user input %s)", timeOfTimeout, timeoutInMinutes, timerInMinutes)) + timeOfTimeout := time.Now().Add(time.Minute * time.Duration(minutes)).Format("15:04") + say.Debug(fmt.Sprintf("Starting timer at %s for %d minutes (parsed from user input %s)", timeOfTimeout, minutes, timerInMinutes)) - timer := getActiveTimer(buildTimers(configuration)) + timer := getActiveTimer(timers) if timer == nil { say.Error("No timer configured, not starting timer") exit.Exit(1) } - if err := timer.StartTimer(timeoutInMinutes); err != nil { + if err := timer.StartTimer(minutes); err != nil { say.Error(err.Error()) exit.Exit(1) } - say.Info(fmt.Sprintf("It's now %s. %d min timer ends at approx. %s. Happy collaborating! :)", currentTime(), timeoutInMinutes, timeOfTimeout)) + say.Info(fmt.Sprintf("It's now %s. %d min timer ends at approx. %s. Happy collaborating! :)", currentTime(), minutes, timeOfTimeout)) return nil } // RunBreakTimer parses timerInMinutes and starts the first active break timer. func RunBreakTimer(timerInMinutes string, configuration config.Configuration) error { - err, timeoutInMinutes := toMinutes(timerInMinutes) + return runBreakWith(buildTimers(configuration), timerInMinutes) +} + +func runBreakWith(timers []Timer, timerInMinutes string) error { + err, minutes := toMinutes(timerInMinutes) if err != nil { return err } - timeOfTimeout := time.Now().Add(time.Minute * time.Duration(timeoutInMinutes)).Format("15:04") - say.Debug(fmt.Sprintf("Starting break timer at %s for %d minutes (parsed from user input %s)", timeOfTimeout, timeoutInMinutes, timerInMinutes)) + timeOfTimeout := time.Now().Add(time.Minute * time.Duration(minutes)).Format("15:04") + say.Debug(fmt.Sprintf("Starting break timer at %s for %d minutes (parsed from user input %s)", timeOfTimeout, minutes, timerInMinutes)) - timer := getActiveTimer(buildTimers(configuration)) + timer := getActiveTimer(timers) if timer == nil { say.Error("No break timer configured, not starting break timer") exit.Exit(1) } - if err := timer.StartBreakTimer(timeoutInMinutes); err != nil { + if err := timer.StartBreakTimer(minutes); err != nil { say.Error(err.Error()) exit.Exit(1) } - say.Info(fmt.Sprintf("It's now %s. %d min break timer ends at approx. %s. So take a break now! :)", currentTime(), timeoutInMinutes, timeOfTimeout)) + say.Info(fmt.Sprintf("It's now %s. %d min break timer ends at approx. %s. So take a break now! :)", currentTime(), minutes, timeOfTimeout)) return nil } diff --git a/timer/timer_test.go b/timer/timer_test.go index 51ddd313..ed078ffe 100644 --- a/timer/timer_test.go +++ b/timer/timer_test.go @@ -8,17 +8,17 @@ import ( type mockTimer struct { active bool - startTimerCalled bool - startBreakTimerCalled bool + startTimerMinutes int + startBreakTimerMinutes int } func (m *mockTimer) IsActive() bool { return m.active } -func (m *mockTimer) StartTimer(_ int) error { - m.startTimerCalled = true +func (m *mockTimer) StartTimer(minutes int) error { + m.startTimerMinutes = minutes return nil } -func (m *mockTimer) StartBreakTimer(_ int) error { - m.startBreakTimerCalled = true +func (m *mockTimer) StartBreakTimer(minutes int) error { + m.startBreakTimerMinutes = minutes return nil } @@ -52,6 +52,26 @@ func TestGetActiveTimerPrefersFirstOverSecond(t *testing.T) { } } +func TestRunWithPassesMinutesToStartTimer(t *testing.T) { + mock := &mockTimer{active: true} + + runWith([]Timer{mock}, "5") + + if mock.startTimerMinutes != 5 { + t.Errorf("expected StartTimer to be called with 5, got %d", mock.startTimerMinutes) + } +} + +func TestRunBreakWithPassesMinutesToStartBreakTimer(t *testing.T) { + mock := &mockTimer{active: true} + + runBreakWith([]Timer{mock}, "10") + + if mock.startBreakTimerMinutes != 10 { + t.Errorf("expected StartBreakTimer to be called with 10, got %d", mock.startBreakTimerMinutes) + } +} + func TestRunTimerReturnsErrorForZeroMinutes(t *testing.T) { err := RunTimer("0", config.GetDefaultConfiguration()) From ddc5699389f00238f4f6be0ebc3787720d2c7931 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 08:51:22 +0000 Subject: [PATCH 22/32] Use project test library in localtimer tests https://claude.ai/code/session_01DTB35xCmhRgW5SQUsYAtAq --- timer/localtimer/localtimer_test.go | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/timer/localtimer/localtimer_test.go b/timer/localtimer/localtimer_test.go index b0925165..7c4cb59d 100644 --- a/timer/localtimer/localtimer_test.go +++ b/timer/localtimer/localtimer_test.go @@ -4,6 +4,7 @@ import ( "testing" config "github.com/remotemobprogramming/mob/v5/configuration" + "github.com/remotemobprogramming/mob/v5/test" "github.com/remotemobprogramming/mob/v5/timer/localtimer" ) @@ -13,9 +14,7 @@ func TestIsActiveWhenTimerLocalTrue(t *testing.T) { timer := localtimer.NewProcessLocalTimer(cfg) - if !timer.IsActive() { - t.Error("expected timer to be active when TimerLocal is true") - } + test.Equals(t, true, timer.IsActive()) } func TestIsInactiveWhenTimerLocalFalse(t *testing.T) { @@ -24,31 +23,23 @@ func TestIsInactiveWhenTimerLocalFalse(t *testing.T) { timer := localtimer.NewProcessLocalTimer(cfg) - if timer.IsActive() { - t.Error("expected timer to be inactive when TimerLocal is false") - } + test.Equals(t, false, timer.IsActive()) } func TestVoiceCommandReturnsEmptyWhenCommandNotConfigured(t *testing.T) { result := localtimer.VoiceCommand("mob next", "") - if result != "" { - t.Errorf("expected empty string, got %q", result) - } + test.Equals(t, "", result) } func TestVoiceCommandInjectsMessageWithPlaceholder(t *testing.T) { result := localtimer.VoiceCommand("mob next", "say %s") - if result != "say mob next" { - t.Errorf("expected %q, got %q", "say mob next", result) - } + test.Equals(t, "say mob next", result) } func TestVoiceCommandAppendsMessageWithoutPlaceholder(t *testing.T) { result := localtimer.VoiceCommand("mob next", "say") - if result != "say mob next" { - t.Errorf("expected %q, got %q", "say mob next", result) - } + test.Equals(t, "say mob next", result) } From a575676631d2e4e47190cbd16b79226acb03dc3c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 08:55:00 +0000 Subject: [PATCH 23/32] Move Moo to localtimer package and make VoiceCommand/ExecuteCommandsInBackgroundProcess private https://claude.ai/code/session_01DTB35xCmhRgW5SQUsYAtAq --- mob.go | 14 +------------- timer/localtimer/localtimer.go | 27 +++++++++++++++++---------- timer/localtimer/localtimer_test.go | 13 ++++++------- 3 files changed, 24 insertions(+), 30 deletions(-) diff --git a/mob.go b/mob.go index 6b0e7428..01b8523b 100644 --- a/mob.go +++ b/mob.go @@ -330,7 +330,7 @@ func execute(command string, parameter []string, configuration config.Configurat help.Help(configuration) } case "moo": - moo(configuration) + localtimer.Moo(configuration) case "sw", "squash-wip": if len(parameter) > 1 && parameter[0] == "--git-editor" { squashWipGitEditor(parameter[1], configuration) @@ -469,18 +469,6 @@ func currentTime() string { return time.Now().Format("15:04") } -func moo(configuration config.Configuration) { - voiceMessage := "moo" - err := localtimer.ExecuteCommandsInBackgroundProcess(localtimer.VoiceCommand(voiceMessage, configuration.VoiceCommand)) - - if err != nil { - say.Warning(fmt.Sprintf("can't run voice command on your system (%s)", runtime.GOOS)) - say.Warning(err.Error()) - return - } - - say.Info(voiceMessage) -} func reset(configuration config.Configuration) { if configuration.ResetDeleteRemoteWipBranch { diff --git a/timer/localtimer/localtimer.go b/timer/localtimer/localtimer.go index b35e99e1..e1793ab7 100644 --- a/timer/localtimer/localtimer.go +++ b/timer/localtimer/localtimer.go @@ -27,9 +27,9 @@ func (t ProcessLocalTimer) IsActive() bool { func (t ProcessLocalTimer) StartTimer(minutes int) error { timeoutInSeconds := minutes * 60 - if err := ExecuteCommandsInBackgroundProcess( + if err := executeCommandsInBackgroundProcess( sleepCommand(timeoutInSeconds), - VoiceCommand(t.configuration.VoiceMessage, t.configuration.VoiceCommand), + voiceCommand(t.configuration.VoiceMessage, t.configuration.VoiceCommand), notifyCommand(t.configuration.NotifyMessage, t.configuration.NotifyCommand), "echo \"mobTimer\"", ); err != nil { @@ -40,9 +40,9 @@ func (t ProcessLocalTimer) StartTimer(minutes int) error { func (t ProcessLocalTimer) StartBreakTimer(minutes int) error { timeoutInSeconds := minutes * 60 - if err := ExecuteCommandsInBackgroundProcess( + if err := executeCommandsInBackgroundProcess( sleepCommand(timeoutInSeconds), - VoiceCommand("mob start", t.configuration.VoiceCommand), + voiceCommand("mob start", t.configuration.VoiceCommand), notifyCommand("mob start", t.configuration.NotifyCommand), "echo \"mobTimer\"", ); err != nil { @@ -51,13 +51,22 @@ func (t ProcessLocalTimer) StartBreakTimer(minutes int) error { return nil } +func Moo(configuration config.Configuration) { + voiceMessage := "moo" + err := executeCommandsInBackgroundProcess(voiceCommand(voiceMessage, configuration.VoiceCommand)) + if err != nil { + say.Warning(fmt.Sprintf("can't run voice command on your system (%s)", runtime.GOOS)) + say.Warning(err.Error()) + return + } + say.Info(voiceMessage) +} + func sleepCommand(timeoutInSeconds int) string { return fmt.Sprintf("sleep %d", timeoutInSeconds) } -// VoiceCommand builds the shell command string for the voice notification. -// Exported because it is also used by the moo feature in the main package. -func VoiceCommand(message string, voiceCommand string) string { +func voiceCommand(message string, voiceCommand string) string { if len(voiceCommand) == 0 { return "" } @@ -83,9 +92,7 @@ func injectCommandWithMessage(command string, message string) string { return fmt.Sprintf(command, message) } -// ExecuteCommandsInBackgroundProcess runs the given shell commands in a background OS process. -// Exported because it is also used by the moo feature in the main package. -func ExecuteCommandsInBackgroundProcess(commands ...string) error { +func executeCommandsInBackgroundProcess(commands ...string) error { cmds := make([]string, 0) for _, c := range commands { if len(c) > 0 { diff --git a/timer/localtimer/localtimer_test.go b/timer/localtimer/localtimer_test.go index 7c4cb59d..68b0bf75 100644 --- a/timer/localtimer/localtimer_test.go +++ b/timer/localtimer/localtimer_test.go @@ -1,18 +1,17 @@ -package localtimer_test +package localtimer import ( "testing" config "github.com/remotemobprogramming/mob/v5/configuration" "github.com/remotemobprogramming/mob/v5/test" - "github.com/remotemobprogramming/mob/v5/timer/localtimer" ) func TestIsActiveWhenTimerLocalTrue(t *testing.T) { cfg := config.GetDefaultConfiguration() cfg.TimerLocal = true - timer := localtimer.NewProcessLocalTimer(cfg) + timer := NewProcessLocalTimer(cfg) test.Equals(t, true, timer.IsActive()) } @@ -21,25 +20,25 @@ func TestIsInactiveWhenTimerLocalFalse(t *testing.T) { cfg := config.GetDefaultConfiguration() cfg.TimerLocal = false - timer := localtimer.NewProcessLocalTimer(cfg) + timer := NewProcessLocalTimer(cfg) test.Equals(t, false, timer.IsActive()) } func TestVoiceCommandReturnsEmptyWhenCommandNotConfigured(t *testing.T) { - result := localtimer.VoiceCommand("mob next", "") + result := voiceCommand("mob next", "") test.Equals(t, "", result) } func TestVoiceCommandInjectsMessageWithPlaceholder(t *testing.T) { - result := localtimer.VoiceCommand("mob next", "say %s") + result := voiceCommand("mob next", "say %s") test.Equals(t, "say mob next", result) } func TestVoiceCommandAppendsMessageWithoutPlaceholder(t *testing.T) { - result := localtimer.VoiceCommand("mob next", "say") + result := voiceCommand("mob next", "say") test.Equals(t, "say mob next", result) } From f60bb8984710bc370320a61374571de2170bf89b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 09:01:13 +0000 Subject: [PATCH 24/32] Use project test library in timer tests https://claude.ai/code/session_01DTB35xCmhRgW5SQUsYAtAq --- timer/timer_test.go | 37 ++++++++++--------------------------- 1 file changed, 10 insertions(+), 27 deletions(-) diff --git a/timer/timer_test.go b/timer/timer_test.go index ed078ffe..4e8c3fbe 100644 --- a/timer/timer_test.go +++ b/timer/timer_test.go @@ -4,6 +4,7 @@ import ( "testing" config "github.com/remotemobprogramming/mob/v5/configuration" + "github.com/remotemobprogramming/mob/v5/test" ) type mockTimer struct { @@ -28,17 +29,13 @@ func TestGetActiveTimerReturnsFirstActiveTimer(t *testing.T) { result := getActiveTimer([]Timer{inactive, active}) - if result != active { - t.Error("expected the first active timer to be returned") - } + test.Equals(t, active, result) } func TestGetActiveTimerReturnsNilWhenNoneActive(t *testing.T) { result := getActiveTimer([]Timer{&mockTimer{active: false}}) - if result != nil { - t.Error("expected nil when no timer is active") - } + test.Equals(t, nil, result) } func TestGetActiveTimerPrefersFirstOverSecond(t *testing.T) { @@ -47,9 +44,7 @@ func TestGetActiveTimerPrefersFirstOverSecond(t *testing.T) { result := getActiveTimer([]Timer{first, second}) - if result != first { - t.Error("expected the first active timer to take priority") - } + test.Equals(t, first, result) } func TestRunWithPassesMinutesToStartTimer(t *testing.T) { @@ -57,9 +52,7 @@ func TestRunWithPassesMinutesToStartTimer(t *testing.T) { runWith([]Timer{mock}, "5") - if mock.startTimerMinutes != 5 { - t.Errorf("expected StartTimer to be called with 5, got %d", mock.startTimerMinutes) - } + test.Equals(t, 5, mock.startTimerMinutes) } func TestRunBreakWithPassesMinutesToStartBreakTimer(t *testing.T) { @@ -67,39 +60,29 @@ func TestRunBreakWithPassesMinutesToStartBreakTimer(t *testing.T) { runBreakWith([]Timer{mock}, "10") - if mock.startBreakTimerMinutes != 10 { - t.Errorf("expected StartBreakTimer to be called with 10, got %d", mock.startBreakTimerMinutes) - } + test.Equals(t, 10, mock.startBreakTimerMinutes) } func TestRunTimerReturnsErrorForZeroMinutes(t *testing.T) { err := RunTimer("0", config.GetDefaultConfiguration()) - if err == nil { - t.Error("expected error for zero minutes") - } + test.NotEquals(t, nil, err) } func TestRunTimerReturnsErrorForNonNumericInput(t *testing.T) { err := RunTimer("NotANumber", config.GetDefaultConfiguration()) - if err == nil { - t.Error("expected error for non-numeric input") - } + test.NotEquals(t, nil, err) } func TestRunBreakTimerReturnsErrorForZeroMinutes(t *testing.T) { err := RunBreakTimer("0", config.GetDefaultConfiguration()) - if err == nil { - t.Error("expected error for zero minutes") - } + test.NotEquals(t, nil, err) } func TestRunBreakTimerReturnsErrorForNonNumericInput(t *testing.T) { err := RunBreakTimer("NotANumber", config.GetDefaultConfiguration()) - if err == nil { - t.Error("expected error for non-numeric input") - } + test.NotEquals(t, nil, err) } From 8f216273f359aa5a74d2bddc472f9b2ec7b5826d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 09:03:14 +0000 Subject: [PATCH 25/32] Use project test library in webtimer tests https://claude.ai/code/session_01DTB35xCmhRgW5SQUsYAtAq --- timer/webtimer/webtimer_test.go | 45 +++++++++------------------------ 1 file changed, 12 insertions(+), 33 deletions(-) diff --git a/timer/webtimer/webtimer_test.go b/timer/webtimer/webtimer_test.go index 82a96f54..e373d30e 100644 --- a/timer/webtimer/webtimer_test.go +++ b/timer/webtimer/webtimer_test.go @@ -8,6 +8,7 @@ import ( "testing" config "github.com/remotemobprogramming/mob/v5/configuration" + "github.com/remotemobprogramming/mob/v5/test" "github.com/remotemobprogramming/mob/v5/timer/webtimer" ) @@ -17,9 +18,7 @@ func TestIsActiveWhenRoomIsSet(t *testing.T) { timer := webtimer.NewWebTimer(cfg) - if !timer.IsActive() { - t.Error("expected timer to be active when TimerRoom is set") - } + test.Equals(t, true, timer.IsActive()) } func TestIsInactiveWhenRoomIsEmpty(t *testing.T) { @@ -28,9 +27,7 @@ func TestIsInactiveWhenRoomIsEmpty(t *testing.T) { timer := webtimer.NewWebTimer(cfg) - if timer.IsActive() { - t.Error("expected timer to be inactive when TimerRoom is empty") - } + test.Equals(t, false, timer.IsActive()) } func TestUsesWipBranchQualifierAsRoom(t *testing.T) { @@ -41,9 +38,7 @@ func TestUsesWipBranchQualifierAsRoom(t *testing.T) { timer := webtimer.NewWebTimer(cfg) - if !timer.IsActive() { - t.Error("expected timer to be active when WipBranchQualifier is used as room") - } + test.Equals(t, true, timer.IsActive()) } func TestUsesTimerRoomWhenWipBranchQualifierIsEmpty(t *testing.T) { @@ -54,9 +49,7 @@ func TestUsesTimerRoomWhenWipBranchQualifierIsEmpty(t *testing.T) { timer := webtimer.NewWebTimer(cfg) - if !timer.IsActive() { - t.Error("expected timer to use TimerRoom when WipBranchQualifier is empty") - } + test.Equals(t, true, timer.IsActive()) } func TestStartTimerSendsPutWithTimerAndUser(t *testing.T) { @@ -77,20 +70,12 @@ func TestStartTimerSendsPutWithTimerAndUser(t *testing.T) { err := timer.StartTimer(10) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if capturedMethod != "PUT" { - t.Errorf("expected PUT, got %s", capturedMethod) - } var body map[string]interface{} json.Unmarshal(capturedBody, &body) - if body["timer"] != float64(10) { - t.Errorf("expected timer=10, got %v", body["timer"]) - } - if body["user"] != "testuser" { - t.Errorf("expected user=testuser, got %v", body["user"]) - } + test.Equals(t, nil, err) + test.Equals(t, "PUT", capturedMethod) + test.Equals(t, float64(10), body["timer"]) + test.Equals(t, "testuser", body["user"]) } func TestStartBreakTimerSendsPutWithBreakTimerAndUser(t *testing.T) { @@ -109,15 +94,9 @@ func TestStartBreakTimerSendsPutWithBreakTimerAndUser(t *testing.T) { err := timer.StartBreakTimer(5) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } var body map[string]interface{} json.Unmarshal(capturedBody, &body) - if body["breaktimer"] != float64(5) { - t.Errorf("expected breaktimer=5, got %v", body["breaktimer"]) - } - if body["user"] != "testuser" { - t.Errorf("expected user=testuser, got %v", body["user"]) - } + test.Equals(t, nil, err) + test.Equals(t, float64(5), body["breaktimer"]) + test.Equals(t, "testuser", body["user"]) } From 1c4e3d66d9aae08eafd8142dbe6e666a8576a2c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 09:09:35 +0000 Subject: [PATCH 26/32] Add background process and info log tests to localtimer https://claude.ai/code/session_01DTB35xCmhRgW5SQUsYAtAq --- timer/localtimer/localtimer_test.go | 44 +++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/timer/localtimer/localtimer_test.go b/timer/localtimer/localtimer_test.go index 68b0bf75..13b5bd97 100644 --- a/timer/localtimer/localtimer_test.go +++ b/timer/localtimer/localtimer_test.go @@ -1,6 +1,8 @@ package localtimer import ( + "os" + "path/filepath" "testing" config "github.com/remotemobprogramming/mob/v5/configuration" @@ -42,3 +44,45 @@ func TestVoiceCommandAppendsMessageWithoutPlaceholder(t *testing.T) { test.Equals(t, "say mob next", result) } + +func TestStartTimerExecutesBackgroundProcess(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "timer_ran") + cfg := config.GetDefaultConfiguration() + cfg.VoiceCommand = "touch " + tmpFile + cfg.NotifyCommand = "" + timer := NewProcessLocalTimer(cfg) + + err := timer.StartTimer(0) + + test.Equals(t, nil, err) + test.Await(t, func() bool { + _, err := os.Stat(tmpFile) + return err == nil + }, "timer voice command created file") +} + +func TestStartBreakTimerExecutesBackgroundProcess(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "break_timer_ran") + cfg := config.GetDefaultConfiguration() + cfg.VoiceCommand = "touch " + tmpFile + cfg.NotifyCommand = "" + timer := NewProcessLocalTimer(cfg) + + err := timer.StartBreakTimer(0) + + test.Equals(t, nil, err) + test.Await(t, func() bool { + _, err := os.Stat(tmpFile) + return err == nil + }, "break timer voice command created file") +} + +func TestMooLogsInfoMessage(t *testing.T) { + output := test.CaptureOutput(t) + cfg := config.GetDefaultConfiguration() + cfg.VoiceCommand = "echo" + + Moo(cfg) + + test.AssertOutputContains(t, output, "moo") +} From eaebac259d2a0c45c34233228865d364b51d31b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 09:11:16 +0000 Subject: [PATCH 27/32] Fix side-effect files in localtimer tests by appending '; true' to voice command https://claude.ai/code/session_01DTB35xCmhRgW5SQUsYAtAq --- timer/localtimer/localtimer_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/timer/localtimer/localtimer_test.go b/timer/localtimer/localtimer_test.go index 13b5bd97..2a8cb100 100644 --- a/timer/localtimer/localtimer_test.go +++ b/timer/localtimer/localtimer_test.go @@ -48,7 +48,7 @@ func TestVoiceCommandAppendsMessageWithoutPlaceholder(t *testing.T) { func TestStartTimerExecutesBackgroundProcess(t *testing.T) { tmpFile := filepath.Join(t.TempDir(), "timer_ran") cfg := config.GetDefaultConfiguration() - cfg.VoiceCommand = "touch " + tmpFile + cfg.VoiceCommand = "touch " + tmpFile + "; true" cfg.NotifyCommand = "" timer := NewProcessLocalTimer(cfg) @@ -64,7 +64,7 @@ func TestStartTimerExecutesBackgroundProcess(t *testing.T) { func TestStartBreakTimerExecutesBackgroundProcess(t *testing.T) { tmpFile := filepath.Join(t.TempDir(), "break_timer_ran") cfg := config.GetDefaultConfiguration() - cfg.VoiceCommand = "touch " + tmpFile + cfg.VoiceCommand = "touch " + tmpFile + "; true" cfg.NotifyCommand = "" timer := NewProcessLocalTimer(cfg) From 9753c4573ff7f9ccdba063ba25b0785de661bcf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 09:33:18 +0000 Subject: [PATCH 28/32] Assert 'Starting command' debug log in StartTimer and StartBreakTimer tests https://claude.ai/code/session_01DTB35xCmhRgW5SQUsYAtAq --- timer/localtimer/localtimer_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/timer/localtimer/localtimer_test.go b/timer/localtimer/localtimer_test.go index 2a8cb100..4cabefdd 100644 --- a/timer/localtimer/localtimer_test.go +++ b/timer/localtimer/localtimer_test.go @@ -6,6 +6,7 @@ import ( "testing" config "github.com/remotemobprogramming/mob/v5/configuration" + "github.com/remotemobprogramming/mob/v5/say" "github.com/remotemobprogramming/mob/v5/test" ) @@ -46,6 +47,8 @@ func TestVoiceCommandAppendsMessageWithoutPlaceholder(t *testing.T) { } func TestStartTimerExecutesBackgroundProcess(t *testing.T) { + say.TurnOnDebugging() + output := test.CaptureOutput(t) tmpFile := filepath.Join(t.TempDir(), "timer_ran") cfg := config.GetDefaultConfiguration() cfg.VoiceCommand = "touch " + tmpFile + "; true" @@ -55,6 +58,7 @@ func TestStartTimerExecutesBackgroundProcess(t *testing.T) { err := timer.StartTimer(0) test.Equals(t, nil, err) + test.AssertOutputContains(t, output, "Starting command") test.Await(t, func() bool { _, err := os.Stat(tmpFile) return err == nil @@ -62,6 +66,8 @@ func TestStartTimerExecutesBackgroundProcess(t *testing.T) { } func TestStartBreakTimerExecutesBackgroundProcess(t *testing.T) { + say.TurnOnDebugging() + output := test.CaptureOutput(t) tmpFile := filepath.Join(t.TempDir(), "break_timer_ran") cfg := config.GetDefaultConfiguration() cfg.VoiceCommand = "touch " + tmpFile + "; true" @@ -71,6 +77,7 @@ func TestStartBreakTimerExecutesBackgroundProcess(t *testing.T) { err := timer.StartBreakTimer(0) test.Equals(t, nil, err) + test.AssertOutputContains(t, output, "Starting command") test.Await(t, func() bool { _, err := os.Stat(tmpFile) return err == nil From 0b769092b7cdbc1ecdf38f4379e7c8fb210a31db Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 09:35:04 +0000 Subject: [PATCH 29/32] Assert info and error logs in timer tests https://claude.ai/code/session_01DTB35xCmhRgW5SQUsYAtAq --- timer/timer_test.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/timer/timer_test.go b/timer/timer_test.go index 4e8c3fbe..adac20ad 100644 --- a/timer/timer_test.go +++ b/timer/timer_test.go @@ -48,41 +48,57 @@ func TestGetActiveTimerPrefersFirstOverSecond(t *testing.T) { } func TestRunWithPassesMinutesToStartTimer(t *testing.T) { + output := test.CaptureOutput(t) mock := &mockTimer{active: true} runWith([]Timer{mock}, "5") test.Equals(t, 5, mock.startTimerMinutes) + test.AssertOutputContains(t, output, "Happy collaborating!") } func TestRunBreakWithPassesMinutesToStartBreakTimer(t *testing.T) { + output := test.CaptureOutput(t) mock := &mockTimer{active: true} runBreakWith([]Timer{mock}, "10") test.Equals(t, 10, mock.startBreakTimerMinutes) + test.AssertOutputContains(t, output, "So take a break now!") } func TestRunTimerReturnsErrorForZeroMinutes(t *testing.T) { + output := test.CaptureOutput(t) + err := RunTimer("0", config.GetDefaultConfiguration()) test.NotEquals(t, nil, err) + test.AssertOutputContains(t, output, "The parameter must be an integer number greater then zero") } func TestRunTimerReturnsErrorForNonNumericInput(t *testing.T) { + output := test.CaptureOutput(t) + err := RunTimer("NotANumber", config.GetDefaultConfiguration()) test.NotEquals(t, nil, err) + test.AssertOutputContains(t, output, "The parameter must be an integer number greater then zero") } func TestRunBreakTimerReturnsErrorForZeroMinutes(t *testing.T) { + output := test.CaptureOutput(t) + err := RunBreakTimer("0", config.GetDefaultConfiguration()) test.NotEquals(t, nil, err) + test.AssertOutputContains(t, output, "The parameter must be an integer number greater then zero") } func TestRunBreakTimerReturnsErrorForNonNumericInput(t *testing.T) { + output := test.CaptureOutput(t) + err := RunBreakTimer("NotANumber", config.GetDefaultConfiguration()) test.NotEquals(t, nil, err) + test.AssertOutputContains(t, output, "The parameter must be an integer number greater then zero") } From 49ee484f36ca46d91f371ebfb163f049f545f04b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 09:41:16 +0000 Subject: [PATCH 30/32] Extract newCapturingServer helper and assert PUT method in break timer test https://claude.ai/code/session_01DTB35xCmhRgW5SQUsYAtAq --- timer/webtimer/webtimer_test.go | 36 +++++++++++++++++---------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/timer/webtimer/webtimer_test.go b/timer/webtimer/webtimer_test.go index e373d30e..83aa32e0 100644 --- a/timer/webtimer/webtimer_test.go +++ b/timer/webtimer/webtimer_test.go @@ -12,6 +12,19 @@ import ( "github.com/remotemobprogramming/mob/v5/timer/webtimer" ) +func newCapturingServer(t *testing.T) (*httptest.Server, *string, *[]byte) { + t.Helper() + var capturedMethod string + var capturedBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedMethod = r.Method + capturedBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(server.Close) + return server, &capturedMethod, &capturedBody +} + func TestIsActiveWhenRoomIsSet(t *testing.T) { cfg := config.GetDefaultConfiguration() cfg.TimerRoom = "testroom" @@ -53,14 +66,7 @@ func TestUsesTimerRoomWhenWipBranchQualifierIsEmpty(t *testing.T) { } func TestStartTimerSendsPutWithTimerAndUser(t *testing.T) { - var capturedBody []byte - var capturedMethod string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedMethod = r.Method - capturedBody, _ = io.ReadAll(r.Body) - w.WriteHeader(http.StatusOK) - })) - defer server.Close() + server, capturedMethod, capturedBody := newCapturingServer(t) cfg := config.GetDefaultConfiguration() cfg.TimerRoom = "testroom" @@ -71,20 +77,15 @@ func TestStartTimerSendsPutWithTimerAndUser(t *testing.T) { err := timer.StartTimer(10) var body map[string]interface{} - json.Unmarshal(capturedBody, &body) + json.Unmarshal(*capturedBody, &body) test.Equals(t, nil, err) - test.Equals(t, "PUT", capturedMethod) + test.Equals(t, "PUT", *capturedMethod) test.Equals(t, float64(10), body["timer"]) test.Equals(t, "testuser", body["user"]) } func TestStartBreakTimerSendsPutWithBreakTimerAndUser(t *testing.T) { - var capturedBody []byte - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedBody, _ = io.ReadAll(r.Body) - w.WriteHeader(http.StatusOK) - })) - defer server.Close() + server, capturedMethod, capturedBody := newCapturingServer(t) cfg := config.GetDefaultConfiguration() cfg.TimerRoom = "testroom" @@ -95,8 +96,9 @@ func TestStartBreakTimerSendsPutWithBreakTimerAndUser(t *testing.T) { err := timer.StartBreakTimer(5) var body map[string]interface{} - json.Unmarshal(capturedBody, &body) + json.Unmarshal(*capturedBody, &body) test.Equals(t, nil, err) + test.Equals(t, "PUT", *capturedMethod) test.Equals(t, float64(5), body["breaktimer"]) test.Equals(t, "testuser", body["user"]) } From 1eda63257ace3f4224d7dfa55b51d3db41bc92ed Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 09:55:03 +0000 Subject: [PATCH 31/32] Also verify notify command is executed in StartTimer and StartBreakTimer tests https://claude.ai/code/session_01DTB35xCmhRgW5SQUsYAtAq --- timer/localtimer/localtimer_test.go | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/timer/localtimer/localtimer_test.go b/timer/localtimer/localtimer_test.go index 4cabefdd..45f437f5 100644 --- a/timer/localtimer/localtimer_test.go +++ b/timer/localtimer/localtimer_test.go @@ -49,10 +49,11 @@ func TestVoiceCommandAppendsMessageWithoutPlaceholder(t *testing.T) { func TestStartTimerExecutesBackgroundProcess(t *testing.T) { say.TurnOnDebugging() output := test.CaptureOutput(t) - tmpFile := filepath.Join(t.TempDir(), "timer_ran") + voiceFile := filepath.Join(t.TempDir(), "timer_voice") + notifyFile := filepath.Join(t.TempDir(), "timer_notify") cfg := config.GetDefaultConfiguration() - cfg.VoiceCommand = "touch " + tmpFile + "; true" - cfg.NotifyCommand = "" + cfg.VoiceCommand = "touch " + voiceFile + "; true" + cfg.NotifyCommand = "touch " + notifyFile + "; true" timer := NewProcessLocalTimer(cfg) err := timer.StartTimer(0) @@ -60,18 +61,23 @@ func TestStartTimerExecutesBackgroundProcess(t *testing.T) { test.Equals(t, nil, err) test.AssertOutputContains(t, output, "Starting command") test.Await(t, func() bool { - _, err := os.Stat(tmpFile) + _, err := os.Stat(voiceFile) return err == nil }, "timer voice command created file") + test.Await(t, func() bool { + _, err := os.Stat(notifyFile) + return err == nil + }, "timer notify command created file") } func TestStartBreakTimerExecutesBackgroundProcess(t *testing.T) { say.TurnOnDebugging() output := test.CaptureOutput(t) - tmpFile := filepath.Join(t.TempDir(), "break_timer_ran") + voiceFile := filepath.Join(t.TempDir(), "break_timer_voice") + notifyFile := filepath.Join(t.TempDir(), "break_timer_notify") cfg := config.GetDefaultConfiguration() - cfg.VoiceCommand = "touch " + tmpFile + "; true" - cfg.NotifyCommand = "" + cfg.VoiceCommand = "touch " + voiceFile + "; true" + cfg.NotifyCommand = "touch " + notifyFile + "; true" timer := NewProcessLocalTimer(cfg) err := timer.StartBreakTimer(0) @@ -79,9 +85,13 @@ func TestStartBreakTimerExecutesBackgroundProcess(t *testing.T) { test.Equals(t, nil, err) test.AssertOutputContains(t, output, "Starting command") test.Await(t, func() bool { - _, err := os.Stat(tmpFile) + _, err := os.Stat(voiceFile) return err == nil }, "break timer voice command created file") + test.Await(t, func() bool { + _, err := os.Stat(notifyFile) + return err == nil + }, "break timer notify command created file") } func TestMooLogsInfoMessage(t *testing.T) { From edcffdb38073aea0541191ae43b7943a39627639 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 09:59:23 +0000 Subject: [PATCH 32/32] Extract AwaitFileCreated into test package https://claude.ai/code/session_01DTB35xCmhRgW5SQUsYAtAq --- test/test.go | 8 ++++++++ timer/localtimer/localtimer_test.go | 21 ++++----------------- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/test/test.go b/test/test.go index 761174f0..62359274 100644 --- a/test/test.go +++ b/test/test.go @@ -82,6 +82,14 @@ func AssertOutputNotContains(t *testing.T, output *string, notContains string) { } } +func AwaitFileCreated(t *testing.T, path string) { + t.Helper() + Await(t, func() bool { + _, err := os.Stat(path) + return err == nil + }, "file created: "+path) +} + func Await(t *testing.T, until func() bool, awaitedState string) { AwaitBlocking(t, AWAIT_DEFAULT_POLL_INTERVAL, AWAIT_DEFAULT_AT_MOST, until, awaitedState) } diff --git a/timer/localtimer/localtimer_test.go b/timer/localtimer/localtimer_test.go index 45f437f5..85135d8f 100644 --- a/timer/localtimer/localtimer_test.go +++ b/timer/localtimer/localtimer_test.go @@ -1,7 +1,6 @@ package localtimer import ( - "os" "path/filepath" "testing" @@ -60,14 +59,8 @@ func TestStartTimerExecutesBackgroundProcess(t *testing.T) { test.Equals(t, nil, err) test.AssertOutputContains(t, output, "Starting command") - test.Await(t, func() bool { - _, err := os.Stat(voiceFile) - return err == nil - }, "timer voice command created file") - test.Await(t, func() bool { - _, err := os.Stat(notifyFile) - return err == nil - }, "timer notify command created file") + test.AwaitFileCreated(t, voiceFile) + test.AwaitFileCreated(t, notifyFile) } func TestStartBreakTimerExecutesBackgroundProcess(t *testing.T) { @@ -84,14 +77,8 @@ func TestStartBreakTimerExecutesBackgroundProcess(t *testing.T) { test.Equals(t, nil, err) test.AssertOutputContains(t, output, "Starting command") - test.Await(t, func() bool { - _, err := os.Stat(voiceFile) - return err == nil - }, "break timer voice command created file") - test.Await(t, func() bool { - _, err := os.Stat(notifyFile) - return err == nil - }, "break timer notify command created file") + test.AwaitFileCreated(t, voiceFile) + test.AwaitFileCreated(t, notifyFile) } func TestMooLogsInfoMessage(t *testing.T) {