Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
"github.com/mattn/go-isatty"
"github.com/spf13/cobra"

"github.com/decampsrenan/spm/internal/audio"
"github.com/decampsrenan/spm/internal/audio/playback"
"github.com/decampsrenan/spm/internal/detector"
"github.com/decampsrenan/spm/internal/ecosystem"
"github.com/decampsrenan/spm/internal/progress"
Expand Down Expand Up @@ -142,7 +142,7 @@ var playSoundCmd = &cobra.Command{
Hidden: true,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return audio.PlaySound(args[0])
return playback.PlaySound(args[0])
},
}

Expand All @@ -155,7 +155,7 @@ var playMusicCmd = &cobra.Command{
if err != nil {
return fmt.Errorf("invalid fade-in duration: %w", err)
}
return audio.PlayMusicAndWait(time.Duration(secs) * time.Second)
return playback.PlayMusicAndWait(time.Duration(secs) * time.Second)
},
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ func runUpgrade() error {

ui.Println(ui.Dim(fmt.Sprintf("Downloading %s...", result.LatestVersion)))

if err := updater.Execute(result); err != nil {
if err := updater.Execute(&updater.HTTPDownloader{}, result); err != nil {
return err
}

Expand Down
20 changes: 0 additions & 20 deletions internal/audio/audio_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,6 @@ import (
"testing"
)

func TestNewPlayer(t *testing.T) {
p := NewPlayer()
if p == nil {
t.Fatal("NewPlayer returned nil")
}
}

func TestStopWithoutPlay(t *testing.T) {
p := NewPlayer()
// Stop on a player that was never started should not panic.
p.Stop()
}

func TestDoubleStop(t *testing.T) {
p := NewPlayer()
// Double stop should not panic.
p.Stop()
p.Stop()
}

func TestVibesProcessStopImmediatelyNilCmd(t *testing.T) {
// StopImmediately on a VibesProcess with nil cmd should not panic.
// This is the case when SPM_DISABLE_AUDIO=1.
Expand Down
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//go:build darwin || cgo

package audio
package playback

import (
"bytes"
Expand All @@ -19,6 +19,8 @@ import (
"github.com/gopxl/beep/v2/effects"
"github.com/gopxl/beep/v2/mp3"
"github.com/gopxl/beep/v2/speaker"

"github.com/decampsrenan/spm/internal/audio"
)

//go:embed tashkent.mp3
Expand Down Expand Up @@ -230,12 +232,12 @@ func PlayMusicAndWait(fadeIn time.Duration) error {
// This is intended to be called from the hidden _play-sound subcommand.
func PlaySound(name string) error {
var data []byte
switch SoundName(name) {
case SoundSuccess:
switch audio.SoundName(name) {
case audio.SoundSuccess:
data = successSoundData
case SoundError:
case audio.SoundError:
data = errorSoundData
case SoundDing:
case audio.SoundDing:
data = dingSoundData
default:
return fmt.Errorf("unknown sound: %s", name)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
//go:build !darwin && !cgo

package audio
package playback

import (
"fmt"
"time"

"github.com/decampsrenan/spm/internal/audio"
)

// Player is a no-op stub used when audio playback is not supported.
Expand All @@ -27,8 +29,8 @@ func PlayMusicAndWait(fadeIn time.Duration) error { return nil }

// PlaySound is a no-op on unsupported platforms.
func PlaySound(name string) error {
switch SoundName(name) {
case SoundSuccess, SoundError, SoundDing:
switch audio.SoundName(name) {
case audio.SoundSuccess, audio.SoundError, audio.SoundDing:
return nil
default:
return fmt.Errorf("unknown sound: %s", name)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,31 @@
//go:build darwin || cgo

package audio
package playback

import (
"testing"
)

func TestNewPlayer(t *testing.T) {
p := NewPlayer()
if p == nil {
t.Fatal("NewPlayer returned nil")
}
}

func TestStopWithoutPlay(t *testing.T) {
p := NewPlayer()
// Stop on a player that was never started should not panic.
p.Stop()
}

func TestDoubleStop(t *testing.T) {
p := NewPlayer()
// Double stop should not panic.
p.Stop()
p.Stop()
}

func TestTrackDataEmbedded(t *testing.T) {
if len(trackData) == 0 {
t.Fatal("embedded track data is empty")
Expand Down
File renamed without changes.
166 changes: 166 additions & 0 deletions internal/progress/model_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package progress

import (
"strings"
"testing"
"time"

tea "charm.land/bubbletea/v2"
)

func TestAddLine_AppendsBelowLimit(t *testing.T) {
m := model{}
m.addLine("one")
m.addLine("two")
if len(m.lines) != 2 {
t.Fatalf("expected 2 lines, got %d", len(m.lines))
}
if m.lines[0] != "one" || m.lines[1] != "two" {
t.Errorf("unexpected lines: %v", m.lines)
}
}

func TestAddLine_RingBufferDropsOldest(t *testing.T) {
m := model{}
for i := 0; i < maxLogLines+3; i++ {
m.addLine("line-" + string(rune('A'+i)))
}
if len(m.lines) != maxLogLines {
t.Fatalf("expected %d lines (ring buffer), got %d", maxLogLines, len(m.lines))
}
// First line should be the (maxLogLines+3)-th added, i.e. index 3.
want := "line-" + string(rune('A'+3))
if m.lines[0] != want {
t.Errorf("expected oldest line %q, got %q", want, m.lines[0])
}
}

func TestUpdate_OutputLine_AppendsAndReturnsCmd(t *testing.T) {
ch := make(chan tea.Msg, 1)
m := newProgressModel(ch)
next, cmd := m.Update(outputLineMsg("hello world"))
nm := next.(model)
if len(nm.lines) != 1 || nm.lines[0] != "hello world" {
t.Errorf("expected line appended, got %v", nm.lines)
}
if cmd == nil {
t.Error("expected a follow-up Cmd to continue listening")
}
close(ch)
}

func TestUpdate_OutputLine_IgnoresEmpty(t *testing.T) {
ch := make(chan tea.Msg, 1)
m := newProgressModel(ch)
next, _ := m.Update(outputLineMsg(" "))
nm := next.(model)
if len(nm.lines) != 0 {
t.Errorf("expected empty line to be dropped, got %v", nm.lines)
}
close(ch)
}

func TestUpdate_DoneMsg_SetsFieldsAndQuits(t *testing.T) {
ch := make(chan tea.Msg)
m := newProgressModel(ch)
next, cmd := m.Update(doneMsg{exitCode: 42, err: nil})
nm := next.(model)
if !nm.done {
t.Error("expected done=true")
}
if nm.exitCode != 42 {
t.Errorf("expected exitCode=42, got %d", nm.exitCode)
}
if cmd == nil {
t.Error("expected tea.Quit cmd")
}
}

func TestUpdate_WindowSize_UpdatesWidth(t *testing.T) {
m := newProgressModel(make(chan tea.Msg))
next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
if next.(model).width != 120 {
t.Errorf("expected width=120, got %d", next.(model).width)
}
}

func TestUpdate_TimerTick_WhileRunning_ReturnsCmd(t *testing.T) {
m := newProgressModel(make(chan tea.Msg))
_, cmd := m.Update(timerTickMsg(time.Now()))
if cmd == nil {
t.Error("expected timer tick to schedule next tick while running")
}
}

func TestUpdate_TimerTick_WhenDone_NoCmd(t *testing.T) {
m := newProgressModel(make(chan tea.Msg))
m.done = true
_, cmd := m.Update(timerTickMsg(time.Now()))
if cmd != nil {
t.Error("expected no follow-up tick when done")
}
}

func TestView_SuccessShowsInstalled(t *testing.T) {
m := newProgressModel(make(chan tea.Msg))
m.done = true
m.exitCode = 0
out := m.View().Content
if !strings.Contains(out, "Installed") {
t.Errorf("expected 'Installed' in success view, got %q", out)
}
}

func TestView_FailureShowsFailed(t *testing.T) {
m := newProgressModel(make(chan tea.Msg))
m.done = true
m.exitCode = 1
out := m.View().Content
if !strings.Contains(out, "Failed") {
t.Errorf("expected 'Failed' in failure view, got %q", out)
}
}

func TestView_RunningShowsInstalling(t *testing.T) {
m := newProgressModel(make(chan tea.Msg))
out := m.View().Content
if !strings.Contains(out, "Installing") {
t.Errorf("expected 'Installing' while running, got %q", out)
}
}

func TestView_TruncatesLongLines(t *testing.T) {
m := newProgressModel(make(chan tea.Msg))
m.width = 20
m.addLine(strings.Repeat("x", 100))
out := m.View().Content
if !strings.Contains(out, "…") {
t.Errorf("expected truncation marker in output, got %q", out)
}
}

func TestInit_ReturnsBatch(t *testing.T) {
m := newProgressModel(make(chan tea.Msg))
if cmd := m.Init(); cmd == nil {
t.Error("Init returned nil cmd")
}
}

func TestListenCh_ReturnsMsg(t *testing.T) {
ch := make(chan tea.Msg, 1)
ch <- outputLineMsg("x")
cmd := listenCh(ch)
msg := cmd()
if _, ok := msg.(outputLineMsg); !ok {
t.Errorf("expected outputLineMsg, got %T", msg)
}
}

func TestListenCh_ClosedChannelReturnsNil(t *testing.T) {
ch := make(chan tea.Msg)
close(ch)
cmd := listenCh(ch)
if msg := cmd(); msg != nil {
t.Errorf("expected nil from closed channel, got %v", msg)
}
}
35 changes: 24 additions & 11 deletions internal/prompt/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,17 @@ func Select(detections []detector.Detection) (detector.Detection, error) {
return detector.Detection{}, err
}

return findDetectionByPM(detections, choice)
}

// findDetectionByPM returns the detection whose PM matches the given name.
func findDetectionByPM(detections []detector.Detection, pm string) (detector.Detection, error) {
for _, d := range detections {
if string(d.PM) == choice {
if string(d.PM) == pm {
return d, nil
}
}

return detector.Detection{}, fmt.Errorf("unexpected selection: %s", choice)
return detector.Detection{}, fmt.Errorf("unexpected selection: %s", pm)
}

// SelectScript asks the user to pick a script from the available list.
Expand All @@ -80,16 +84,9 @@ func SelectScript(scriptNames []string, scriptCmds []string) (string, error) {
return "", fmt.Errorf("no script specified and stdin is not a TTY — cannot prompt")
}

const maxCmdLen = 40

options := make([]huh.Option[int], len(scriptNames))
for i, name := range scriptNames {
cmd := scriptCmds[i]
if len(cmd) > maxCmdLen {
cmd = cmd[:maxCmdLen-1] + "…"
}
label := fmt.Sprintf("%s — %s", name, ui.Dim(cmd))
options[i] = huh.NewOption(label, i)
options[i] = huh.NewOption(scriptOptionLabel(name, scriptCmds[i]), i)
}

var choice int
Expand All @@ -115,6 +112,22 @@ func SelectScript(scriptNames []string, scriptCmds []string) (string, error) {
return scriptNames[choice], nil
}

// scriptOptionLabel formats a script entry as "name — cmd", truncating the
// command to scriptCmdMaxLen with an ellipsis if necessary.
func scriptOptionLabel(name, cmd string) string {
return fmt.Sprintf("%s — %s", name, ui.Dim(truncateCmd(cmd, scriptCmdMaxLen)))
}

const scriptCmdMaxLen = 40

// truncateCmd shortens cmd to at most maxLen runes, ending with "…" when cut.
func truncateCmd(cmd string, maxLen int) string {
if len(cmd) <= maxLen {
return cmd
}
return cmd[:maxLen-1] + "…"
}

// SelectPM asks the user to pick a package manager (used by spm init).
func SelectPM() (ecosystem.PackageManager, error) {
if !isatty.IsTerminal(os.Stdin.Fd()) && !isatty.IsCygwinTerminal(os.Stdin.Fd()) {
Expand Down
Loading
Loading