Removes t.Parallel() from TestRadioMenu_Unit, TestShowVersion_Unit, and TestPrecheck_Unit. These tests mutate globals, redirect os.Stdout, or call external dependencies, causing races when run concurrently with other tests under -race.
257 lines
7.7 KiB
Go
257 lines
7.7 KiB
Go
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) {
|
|
// Do not mark Parallel: this test mutates the package-level "version" shim
|
|
// (and internal/version vars) and redirects os.Stdout. Running it concurrently
|
|
// with other legacy tests (esp. those calling RadioMenu which does fmt + wmenu)
|
|
// produces data races under -race.
|
|
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) {
|
|
// Do not mark Parallel: precheck reads config + calls external IsInstalled,
|
|
// and in some paths can have side effects. Keep it serialized with other
|
|
// main-package legacy tests to avoid races on shared state under -race.
|
|
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")
|
|
}
|
|
}
|