gostations/stations_test.go

252 lines
7.2 KiB
Go
Raw Normal View History

package main
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/gmgauthier/gostations/internal/config"
playerpkg "github.com/gmgauthier/gostations/internal/player"
"github.com/gmgauthier/gostations/internal/radio"
ver "github.com/gmgauthier/gostations/internal/version"
)
func TestShowVersion_Unit(t *testing.T) {
t.Parallel()
t.Log("✓ Fast showVersion unit test")
tests := []struct {
name string
version string
expected string
}{
{"default version", "1.0.0", "1.0.0\n"},
{"empty version", "", "\n"},
{"dev version", "dev", "dev\n"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test legacy main var path
originalVersion := version
version = tt.version
defer func() { version = originalVersion }()
// Safe stdout capture using pipe (no os.Stdout reassignment)
origStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
showVersion()
w.Close()
os.Stdout = origStdout
var buf bytes.Buffer
_, _ = buf.ReadFrom(r)
got := buf.String()
if got != tt.expected {
t.Errorf("showVersion() legacy = %q, want %q", got, tt.expected)
}
// Test new internal/version path (preferred for 2.0+ builds)
origV := ver.Version
origC := ver.Commit
ver.Version = tt.version
ver.Commit = ""
defer func() { ver.Version = origV; ver.Commit = origC }()
origStdout = os.Stdout
r, w, _ = os.Pipe()
os.Stdout = w
showVersion()
w.Close()
os.Stdout = origStdout
buf.Reset()
_, _ = buf.ReadFrom(r)
got = buf.String()
if got != tt.expected {
t.Errorf("showVersion() package = %q, want %q", got, tt.expected)
}
})
}
}
func TestPrecheck_Unit(t *testing.T) {
t.Parallel()
t.Log("✓ Fast precheck unit test")
p := "mpv"
if v, err := config.Get("player.command"); err == nil && v != "" {
p = v
}
if !playerpkg.IsInstalled(p) {
t.Skipf("%s is either not installed, or not on your $PATH; skipping real precheck() call in unit test (see TestPrecheck_Live)", p)
}
// Simple smoke test — calls the real precheck() in the common "player installed" path
// (no mocking of globals — that's not allowed in Go)
precheck()
t.Log("✓ precheck ran without panic (happy path)")
}
func TestPrecheck_Live(t *testing.T) {
if testing.Short() {
t.Skip("skipping live integration test (use without -short)")
}
t.Log("🧪 Running live precheck integration test...")
// Real precheck with whatever player is configured on this system
precheck()
t.Log("✓ Live precheck passed (no early exit)")
}
// TestFavDelByIndex_Integration exercises `fav list` numbering and `fav del N` (1-based, stable sort).
// Uses a temp XDG dir and a built binary so we can exec the subcommands as users would.
// Skips under -short (like other live integration tests).
func TestFavDelByIndex_Integration(t *testing.T) {
if testing.Short() {
t.Skip("skipping live integration test for fav del by index (use without -short)")
}
t.Log("🧪 Running fav del-by-index integration test...")
td := t.TempDir()
bin := filepath.Join(td, "gostations-favtest")
// Build a dedicated binary from current source (self-contained, no reliance on external /tmp state)
buildCmd := exec.Command("go", "build", "-o", bin, ".")
buildCmd.Dir = "."
if out, err := buildCmd.CombinedOutput(); err != nil {
t.Fatalf("failed to build test binary: %v\n%s", err, string(out))
}
xdg := filepath.Join(td, "xdg-home")
favDir := filepath.Join(xdg, "gostations")
if err := os.MkdirAll(favDir, 0750); err != nil {
t.Fatalf("mkdir xdg: %v", err)
}
// Pre-populate a minimal config to suppress "config file missing" logs during config.Init in add/del
ini := filepath.Join(favDir, "radiostations.ini")
iniContent := `[DEFAULT]
radio_browser.api=all.api.radio-browser.info
player.command=mpv
player.options=--no-video
menu_items.max=50
`
if err := os.WriteFile(ini, []byte(iniContent), 0644); err != nil {
t.Fatalf("write ini: %v", err)
}
// Helper to invoke the subcommand with isolated XDG
run := func(args ...string) (stdout, stderr string, exitErr error) {
c := exec.Command(bin, args...)
c.Env = append(os.Environ(), "XDG_CONFIG_HOME="+xdg)
var so, se bytes.Buffer
c.Stdout = &so
c.Stderr = &se
exitErr = c.Run()
return so.String(), se.String(), exitErr
}
// Seed two favorites via direct URLs (no network). Names will be the URLs themselves.
// After sort by Name (which == URL here) order will be a < b.
if _, _, err := run("fav", "add", "http://ex.com/b"); err != nil {
t.Fatalf("fav add b: %v", err)
}
if _, _, err := run("fav", "add", "http://ex.com/a"); err != nil {
t.Fatalf("fav add a: %v", err)
}
// List should show stable 1-based numbering, sorted by name/URL
listOut, _, err := run("fav", "list")
if err != nil {
t.Fatalf("fav list after adds: %v", err)
}
if !strings.Contains(listOut, "1) http://ex.com/a") || !strings.Contains(listOut, "2) http://ex.com/b") {
t.Fatalf("expected numbered sorted list with 1=a 2=b, got:\n%s", listOut)
}
// Delete by index 1 (should remove the first in sorted order, i.e. "a")
delOut, delErrOut, err := run("fav", "del", "1")
if err != nil {
t.Fatalf("fav del 1 failed: %v\nstderr: %s", err, delErrOut)
}
if !strings.Contains(delOut, "Removed from favorites: http://ex.com/a") {
t.Fatalf("unexpected del output: %s", delOut)
}
// Re-list: only b remains, now at position 1
listOut, _, err = run("fav", "list")
if err != nil {
t.Fatalf("fav list after del: %v", err)
}
if strings.Contains(listOut, "http://ex.com/a") {
t.Errorf("a should have been deleted, list:\n%s", listOut)
}
if !strings.Contains(listOut, "1) http://ex.com/b") || strings.Contains(listOut, "2)") {
t.Errorf("after deleting first, b should be renumbered to 1 only; got:\n%s", listOut)
}
// Out of range should error and not mutate
_, delErrOut, err = run("fav", "del", "99")
if err == nil {
t.Error("expected non-zero exit for out-of-range del")
}
if !strings.Contains(delErrOut, "index 99 out of range (1-1)") {
t.Errorf("expected range error message, got stderr: %s", delErrOut)
}
// Final list still has exactly the one item
listOut, _, _ = run("fav", "list")
if !strings.Contains(listOut, "1) http://ex.com/b") {
t.Errorf("list mutated by bad del index? got:\n%s", listOut)
}
t.Log("✓ fav del-by-index integration test passed")
}
// TestSortedForDisplay_Unit verifies the stable Name-then-URL ordering used for fav list indices.
func TestSortedForDisplay_Unit(t *testing.T) {
t.Parallel()
in := []radio.Station{
{Name: "Zeta", Url: "u3"},
{Name: "Alpha", Url: "u2"},
{Name: "Alpha", Url: "u1"}, // same name, lower URL should come first
{Name: "Beta", Url: "u0"},
}
out := sortedForDisplay(in)
if len(out) != 4 {
t.Fatalf("len=%d", len(out))
}
// Expected order: Alpha/u1 , Alpha/u2 , Beta , Zeta
if out[0].Name != "Alpha" || out[0].Url != "u1" {
t.Errorf("pos0: want Alpha/u1 got %s/%s", out[0].Name, out[0].Url)
}
if out[1].Name != "Alpha" || out[1].Url != "u2" {
t.Errorf("pos1: want Alpha/u2 got %s/%s", out[1].Name, out[1].Url)
}
if out[2].Name != "Beta" {
t.Errorf("pos2: want Beta got %s", out[2].Name)
}
if out[3].Name != "Zeta" {
t.Errorf("pos3: want Zeta got %s", out[3].Name)
}
// Ensure input slice not mutated (we copy)
if in[0].Name != "Zeta" {
t.Error("input was mutated")
}
}