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") } }