diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..bcb4231 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,26 @@ +name: Test + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + go-version: [1.21] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + + - name: Run tests + run: make test diff --git a/.gitignore b/.gitignore index 849ddff..047d873 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ dist/ +.bats/ +pomodoro diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..69e7382 --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +BATS_VERSION := v1.11.0 +BATS_DIR := .bats +BATS := $(BATS_DIR)/bin/bats +BINARY := pomodoro + +GO_SOURCES := $(shell find . -name '*.go' -not -path './vendor/*') + +.PHONY: test +test: $(BINARY) $(BATS) + $(BATS) test/ + +$(BINARY): $(GO_SOURCES) go.mod go.sum + go build -o $(BINARY) . + +$(BATS): + @mkdir -p $(BATS_DIR) + @echo "Downloading bats-core $(BATS_VERSION)..." + @curl -sSL https://github.com/bats-core/bats-core/archive/$(BATS_VERSION).tar.gz | tar xz -C $(BATS_DIR) --strip-components=1 + @chmod +x $(BATS_DIR)/bin/bats + +.PHONY: clean +clean: + rm -rf $(BATS_DIR) $(BINARY) diff --git a/cmd/root.go b/cmd/root.go index 0be0557..782e578 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -44,16 +44,18 @@ func init() { viper.AutomaticEnv() - var err error + RootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { + var err error - client, err = openpomodoro.NewClient(directoryFlag) - if err != nil { - log.Fatalf("Could not create client: %v", err) - } + client, err = openpomodoro.NewClient(directoryFlag) + if err != nil { + log.Fatalf("Could not create client: %v", err) + } - settings, err = client.Settings() - if err != nil { - log.Fatalf("Could not retrieve settings: %v", err) + settings, err = client.Settings() + if err != nil { + log.Fatalf("Could not retrieve settings: %v", err) + } } } diff --git a/cmd/start.go b/cmd/start.go index b2a34e5..18f8d7c 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -27,8 +27,7 @@ func init() { "time ago this Pomodoro started") command.Flags().IntVarP( - &durationFlag, "duration", "d", - int(settings.DefaultPomodoroDuration.Minutes()), + &durationFlag, "duration", "d", 0, "duration for this Pomodoro") command.Flags().StringArrayVarP( @@ -43,7 +42,11 @@ func startCmd(cmd *cobra.Command, args []string) error { p := openpomodoro.NewPomodoro() p.Description = description - p.Duration = time.Duration(durationFlag) * time.Minute + if durationFlag == 0 { + p.Duration = settings.DefaultPomodoroDuration + } else { + p.Duration = time.Duration(durationFlag) * time.Minute + } p.StartTime = time.Now().Add(-agoFlag) p.Tags = tagsFlag diff --git a/test/amend.bats b/test/amend.bats new file mode 100644 index 0000000..f9f9da7 --- /dev/null +++ b/test/amend.bats @@ -0,0 +1,47 @@ +#!/usr/bin/env bats + +load test_helper + +@test "amend changes description of current pomodoro" { + pomodoro start "Original task" + run pomodoro amend "Amended task" + [ "$status" -eq 0 ] + assert_file_contains "current" "Amended task" +} + +@test "amend adds tags to current pomodoro" { + pomodoro start "Task" + run pomodoro amend -t "work,urgent" + [ "$status" -eq 0 ] + assert_file_contains "current" "tags=work,urgent" +} + +@test "amend changes duration of current pomodoro" { + pomodoro start "Task" + run pomodoro amend -d 45 + [ "$status" -eq 0 ] + assert_file_contains "current" "duration=45" +} + + +@test "amend creates new current when no current exists" { + pomodoro start "Task" --ago 5m + pomodoro finish + run pomodoro amend "New task" + [ "$status" -eq 0 ] + assert_file_contains "current" "New task" +} + +@test "amend outputs current pomodoro status" { + pomodoro start "Task" + run pomodoro amend "Amended task" + [ "$status" -eq 0 ] + [[ "$output" =~ "Amended task" ]] +} + +@test "amend with no arguments succeeds" { + pomodoro start "Task" + run pomodoro amend + [ "$status" -eq 0 ] + assert_file_contains "current" "Task" +} diff --git a/test/break.bats b/test/break.bats new file mode 100644 index 0000000..6e8f8c0 --- /dev/null +++ b/test/break.bats @@ -0,0 +1,26 @@ +#!/usr/bin/env bats + +load test_helper + +@test "break executes break hook before starting timer" { + create_hook "break" 'echo "BREAK_HOOK" >> "$TEST_DIR/hook_log"; exit 1' + + run pomodoro break + [ "$status" -ne 0 ] + + assert_hook_contains "BREAK_HOOK" +} + +@test "break with custom duration parses correctly" { + create_hook "break" 'echo "BREAK_STARTED" >> "$TEST_DIR/hook_log"; exit 1' + + run pomodoro break "10" + [ "$status" -ne 0 ] + + assert_hook_contains "BREAK_STARTED" +} + +@test "break with invalid duration fails" { + run pomodoro break "invalid" + [ "$status" -ne 0 ] +} diff --git a/test/cancel.bats b/test/cancel.bats new file mode 100644 index 0000000..5638c73 --- /dev/null +++ b/test/cancel.bats @@ -0,0 +1,35 @@ +#!/usr/bin/env bats + +load test_helper + +@test "cancel empties current file and removes from history" { + pomodoro start "Task to cancel" + run pomodoro cancel + [ "$status" -eq 0 ] + assert_file_empty "current" + assert_file_empty "history" +} + +@test "cancel preserves existing history but removes current" { + pomodoro start "First task" --ago 5m + pomodoro finish + pomodoro start "Task to cancel" + run pomodoro cancel + [ "$status" -eq 0 ] + + assert_file_contains "history" "First task" + assert_file_empty "current" +} + +@test "cancel with no current pomodoro succeeds" { + run pomodoro cancel + [ "$status" -eq 0 ] + assert_file_empty "current" +} + +@test "cancel produces no output" { + pomodoro start "Test task" + run pomodoro cancel + [ "$status" -eq 0 ] + [ -z "$output" ] +} diff --git a/test/clear.bats b/test/clear.bats new file mode 100644 index 0000000..f88b0d1 --- /dev/null +++ b/test/clear.bats @@ -0,0 +1,27 @@ +#!/usr/bin/env bats + +load test_helper + +@test "clear empties current file and does not affect history" { + pomodoro start "First task" --ago 5m + pomodoro finish + pomodoro start "Second task" + run pomodoro clear + [ "$status" -eq 0 ] + + assert_file_contains "history" "First task" + assert_file_empty "current" +} + +@test "clear with no current pomodoro succeeds" { + run pomodoro clear + [ "$status" -eq 0 ] + assert_file_empty "current" +} + +@test "clear produces no output" { + pomodoro start "Test task" + run pomodoro clear + [ "$status" -eq 0 ] + [ -z "$output" ] +} diff --git a/test/finish.bats b/test/finish.bats new file mode 100644 index 0000000..25b3f81 --- /dev/null +++ b/test/finish.bats @@ -0,0 +1,45 @@ +#!/usr/bin/env bats + +load test_helper + +@test "finish moves current pomodoro to history" { + pomodoro start "Work session" + run pomodoro finish + [ "$status" -eq 0 ] + + assert_file_empty "current" + assert_file_contains "history" "Work session" +} + +@test "finish records actual elapsed time in history" { + pomodoro start "Work session" -d 30 --ago 10m + run pomodoro finish + [ "$status" -eq 0 ] + + assert_file_contains "history" "Work session" + assert_file_contains "history" "duration=10" +} + +@test "finish appends to existing history" { + pomodoro start "First task" --ago 5m + pomodoro finish + pomodoro start "Second task" --ago 3m + run pomodoro finish + [ "$status" -eq 0 ] + + assert_file_contains "history" "First task" + assert_file_contains "history" "Second task" +} + +@test "finish outputs elapsed time" { + pomodoro start "Test task" --ago 5m + run pomodoro finish + [ "$status" -eq 0 ] + [[ "$output" =~ "5:" ]] +} + +@test "finish with no current pomodoro succeeds" { + run pomodoro finish + [ "$status" -eq 0 ] + assert_file_empty "current" +} diff --git a/test/history.bats b/test/history.bats new file mode 100644 index 0000000..8ed713b --- /dev/null +++ b/test/history.bats @@ -0,0 +1,59 @@ +#!/usr/bin/env bats + +load test_helper + +@test "history shows nothing when no history exists" { + run pomodoro history + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "history shows completed pomodoros" { + pomodoro start "First task" --ago 10m + pomodoro finish + pomodoro start "Second task" --ago 5m + pomodoro finish + + run pomodoro history + [ "$status" -eq 0 ] + [[ "$output" =~ "First task" ]] + [[ "$output" =~ "Second task" ]] +} + +@test "history limit flag restricts output" { + pomodoro start "Task 1" --ago 15m + pomodoro finish + pomodoro start "Task 2" --ago 10m + pomodoro finish + pomodoro start "Task 3" --ago 5m + pomodoro finish + + run pomodoro history --limit 2 + [ "$status" -eq 0 ] + [[ "$output" =~ "Task 2" ]] + [[ "$output" =~ "Task 3" ]] + [[ ! "$output" =~ "Task 1" ]] +} + +@test "history shows timestamps and durations" { + pomodoro start "Test task" --ago 10m + pomodoro finish + + run pomodoro history + [ "$status" -eq 0 ] + [[ "$output" =~ "Test task" ]] + [[ "$output" =~ "$(date '+%Y-%m-%d')" ]] + [[ "$output" =~ "duration=10" ]] +} + +@test "history with zero limit shows all entries" { + pomodoro start "Task 1" --ago 10m + pomodoro finish + pomodoro start "Task 2" --ago 5m + pomodoro finish + + run pomodoro history --limit 0 + [ "$status" -eq 0 ] + [[ "$output" =~ "Task 1" ]] + [[ "$output" =~ "Task 2" ]] +} diff --git a/test/hooks.bats b/test/hooks.bats new file mode 100644 index 0000000..e7af5c6 --- /dev/null +++ b/test/hooks.bats @@ -0,0 +1,92 @@ +#!/usr/bin/env bats + +load test_helper + +@test "start hook executes when starting pomodoro" { + create_hook "start" 'echo "START_HOOK_EXECUTED" >> "$TEST_DIR/hook_log"' + + run pomodoro start "Test task" + [ "$status" -eq 0 ] + + assert_hook_contains "START_HOOK_EXECUTED" +} + +@test "stop hook executes when finishing pomodoro" { + create_hook "stop" 'echo "STOP_HOOK_EXECUTED" >> "$TEST_DIR/hook_log"' + + pomodoro start "Test task" --ago 5m + run pomodoro finish + [ "$status" -eq 0 ] + + assert_hook_contains "STOP_HOOK_EXECUTED" +} + +@test "stop hook executes when cancelling pomodoro" { + create_hook "stop" 'echo "CANCEL_HOOK_EXECUTED" >> "$TEST_DIR/hook_log"' + + pomodoro start "Test task" + run pomodoro cancel + [ "$status" -eq 0 ] + + assert_hook_contains "CANCEL_HOOK_EXECUTED" +} + +@test "stop hook executes when clearing pomodoro" { + create_hook "stop" 'echo "CLEAR_HOOK_EXECUTED" >> "$TEST_DIR/hook_log"' + + pomodoro start "Test task" + run pomodoro clear + [ "$status" -eq 0 ] + + assert_hook_contains "CLEAR_HOOK_EXECUTED" +} + +@test "start hook executes when repeating pomodoro" { + pomodoro start "Original task" --ago 5m + pomodoro finish + + create_hook "start" 'echo "REPEAT_HOOK_EXECUTED" >> "$TEST_DIR/hook_log"' + + run pomodoro repeat + [ "$status" -eq 0 ] + + assert_hook_contains "REPEAT_HOOK_EXECUTED" +} + +@test "non-executable hook causes command to fail" { + mkdir -p "$TEST_DIR/hooks" + echo 'echo "SHOULD_NOT_RUN" >> "$TEST_DIR/hook_log"' > "$TEST_DIR/hooks/start" + + run pomodoro start "Test task" + [ "$status" -ne 0 ] + + [ ! -f "$TEST_DIR/hook_log" ] +} + +@test "missing hook does not cause error" { + run pomodoro start "Test task" + [ "$status" -eq 0 ] + + [ ! -f "$TEST_DIR/hook_log" ] +} + +@test "hook failure does not prevent command from succeeding" { + create_hook "start" 'echo "HOOK_RAN" >> "$TEST_DIR/hook_log"; exit 1' + + run pomodoro start "Test task" + [ "$status" -ne 0 ] + + assert_hook_contains "HOOK_RAN" +} + +@test "multiple hooks execute in sequence" { + create_hook "start" 'echo "START_EXECUTED" >> "$TEST_DIR/hook_log"' + create_hook "stop" 'echo "STOP_EXECUTED" >> "$TEST_DIR/hook_log"' + + pomodoro start "Test task" --ago 5m + run pomodoro finish + [ "$status" -eq 0 ] + + assert_hook_contains "START_EXECUTED" + assert_hook_contains "STOP_EXECUTED" +} diff --git a/test/repeat.bats b/test/repeat.bats new file mode 100644 index 0000000..713c2c3 --- /dev/null +++ b/test/repeat.bats @@ -0,0 +1,49 @@ +#!/usr/bin/env bats + +load test_helper + +@test "repeat copies description from last history entry" { + pomodoro start "Original task" --ago 5m + pomodoro finish + run pomodoro repeat + [ "$status" -eq 0 ] + assert_file_contains "current" "Original task" +} + +@test "repeat copies tags from last history entry" { + pomodoro start "Task with tags" -t "work,urgent" --ago 5m + pomodoro finish + run pomodoro repeat + [ "$status" -eq 0 ] + assert_file_contains "current" "work,urgent" +} + +@test "repeat uses default duration" { + pomodoro start "Task" -d 45 --ago 10m + pomodoro finish + run pomodoro repeat + [ "$status" -eq 0 ] + assert_file_contains "current" "duration=25" +} + +@test "repeat creates new timestamp" { + pomodoro start "Task" --ago 5m + pomodoro finish + run pomodoro repeat + [ "$status" -eq 0 ] + assert_file_contains "current" "$(date '+%Y-%m-%d')" +} + + +@test "repeat outputs current pomodoro status" { + pomodoro start "Repeated task" --ago 5m + pomodoro finish + run pomodoro repeat + [ "$status" -eq 0 ] + [[ "$output" =~ "Repeated task" ]] +} + +@test "repeat with no history fails" { + run pomodoro repeat + [ "$status" -ne 0 ] +} diff --git a/test/settings.bats b/test/settings.bats new file mode 100644 index 0000000..c2dd87e --- /dev/null +++ b/test/settings.bats @@ -0,0 +1,66 @@ +#!/usr/bin/env bats + +load test_helper + +@test "start uses default pomodoro duration from settings" { + create_settings "default_pomodoro_duration=45" + pomodoro start "Task with custom default" + assert_file_contains "current" "duration=45" +} + +@test "start uses 25 minutes when no settings file exists" { + pomodoro start "Task with system default" + assert_file_contains "current" "duration=25" +} + +@test "start explicit duration overrides settings default" { + create_settings "default_pomodoro_duration=45" + pomodoro start "Task" -d 30 + assert_file_contains "current" "duration=30" +} + +@test "repeat uses default pomodoro duration from settings" { + create_settings "default_pomodoro_duration=50" + pomodoro start "Original task" --ago 5m + pomodoro finish + run pomodoro repeat + [ "$status" -eq 0 ] + assert_file_contains "current" "duration=50" +} + +@test "break uses default break duration from settings" { + create_settings "default_break_duration=10" + create_hook "break" 'echo "BREAK_HOOK_RAN" >> "$TEST_DIR/hook_log"; exit 1' + + run pomodoro break + [ "$status" -ne 0 ] + assert_hook_contains "BREAK_HOOK_RAN" +} + +@test "--directory flag uses settings from specified directory" { + ALT_DIR="$(mktemp -d)" + create_settings_in "$ALT_DIR/settings" "default_pomodoro_duration=60" + + run "$POMODORO_BIN" --directory "$ALT_DIR" start "Task in alt dir" + [ "$status" -eq 0 ] + + grep -q "duration=60" "$ALT_DIR/current" || { + echo "Expected duration=60 in $ALT_DIR/current" + echo "File contents:" + cat "$ALT_DIR/current" + rm -rf "$ALT_DIR" + return 1 + } + + rm -rf "$ALT_DIR" +} + +@test "settings with multiple values are parsed correctly" { + create_settings \ + "default_pomodoro_duration=35" \ + "default_break_duration=7" \ + "daily_goal=10" + + pomodoro start "Multi-setting task" + assert_file_contains "current" "duration=35" +} diff --git a/test/start.bats b/test/start.bats new file mode 100644 index 0000000..433ddfd --- /dev/null +++ b/test/start.bats @@ -0,0 +1,48 @@ +#!/usr/bin/env bats + +load test_helper + +@test "start creates current file" { + run pomodoro start + [ "$status" -eq 0 ] + assert_file_exists "current" +} + +@test "start with description writes description to current file" { + run pomodoro start "Important work" + [ "$status" -eq 0 ] + assert_file_contains "current" 'description="Important work"' +} + +@test "start with tags writes tags to current file" { + run pomodoro start -t "work,urgent" + [ "$status" -eq 0 ] + assert_file_contains "current" 'tags=work,urgent' +} + +@test "start with custom duration writes duration to current file" { + run pomodoro start --duration 30 + [ "$status" -eq 0 ] + assert_file_contains "current" 'duration=30' +} + + +@test "start writes timestamp to current file" { + run pomodoro start + [ "$status" -eq 0 ] + assert_file_contains "current" "$(date '+%Y-%m-%d')" +} + +@test "start replaces existing current pomodoro" { + pomodoro start "First task" + run pomodoro start "Second task" + [ "$status" -eq 0 ] + assert_file_contains "current" "Second task" + ! grep -q "First task" "$TEST_DIR/current" +} + +@test "start outputs current pomodoro status" { + run pomodoro start "Test task" + [ "$status" -eq 0 ] + [[ "$output" =~ "Test task" ]] +} diff --git a/test/status.bats b/test/status.bats new file mode 100644 index 0000000..8755e18 --- /dev/null +++ b/test/status.bats @@ -0,0 +1,37 @@ +#!/usr/bin/env bats + +load test_helper + +@test "status shows nothing when no current pomodoro" { + run pomodoro status + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "status shows current pomodoro description" { + pomodoro start "Current task" + run pomodoro status + [ "$status" -eq 0 ] + [[ "$output" =~ "Current task" ]] +} + +@test "status shows current pomodoro tags" { + pomodoro start "Task" -t "work,urgent" + run pomodoro status + [ "$status" -eq 0 ] + [[ "$output" =~ "work, urgent" ]] +} + +@test "status shows remaining time for active pomodoro" { + pomodoro start "Task" --ago 5m + run pomodoro status + [ "$status" -eq 0 ] + [[ "$output" =~ "19:" ]] || [[ "$output" =~ "20:" ]] +} + +@test "status shows exclamation for overdue pomodoro" { + pomodoro start "Task" --ago 30m + run pomodoro status + [ "$status" -eq 0 ] + [[ "$output" =~ "❗️" ]] +} diff --git a/test/test_helper.bash b/test/test_helper.bash new file mode 100644 index 0000000..5522f8e --- /dev/null +++ b/test/test_helper.bash @@ -0,0 +1,94 @@ +#!/usr/bin/env bash + +export POMODORO_BIN="${BATS_TEST_DIRNAME}/../pomodoro" + +setup() { + export TEST_DIR="$(mktemp -d)" +} + +teardown() { + rm -rf "$TEST_DIR" +} + +pomodoro() { + "$POMODORO_BIN" --directory "$TEST_DIR" "$@" +} + +assert_file_exists() { + local file="$TEST_DIR/$1" + [ -f "$file" ] || { + echo "File $file does not exist" + return 1 + } +} + +assert_file_contains() { + local file="$TEST_DIR/$1" + local content="$2" + grep -q "$content" "$file" || { + echo "File $file does not contain: $content" + echo "File contents:" + cat "$file" + return 1 + } +} + +assert_file_empty() { + local file="$TEST_DIR/$1" + [ ! -s "$file" ] || { + echo "File $file is not empty" + echo "Contents:" + cat "$file" + return 1 + } +} + +create_hook() { + local hook_name="$1" + local hook_content="$2" + + mkdir -p "$TEST_DIR/hooks" + cat > "$TEST_DIR/hooks/$hook_name" << EOF +#!/bin/bash +$hook_content +EOF + chmod +x "$TEST_DIR/hooks/$hook_name" +} + +assert_hook_executed() { + [ -f "$TEST_DIR/hook_log" ] || { + echo "Hook log file not found" + return 1 + } +} + +assert_hook_contains() { + assert_hook_executed + grep -q "$1" "$TEST_DIR/hook_log" || { + echo "Hook log does not contain: $1" + echo "Hook log contents:" + cat "$TEST_DIR/hook_log" + return 1 + } +} + +create_settings() { + local settings_file="$TEST_DIR/settings" + local setting_line + + for setting_line in "$@"; do + echo "$setting_line" >> "$settings_file" + done +} + +create_settings_in() { + local settings_file="$1" + shift + local setting_line + + > "$settings_file" + + for setting_line in "$@"; do + echo "$setting_line" >> "$settings_file" + done +}