feat: add favorites management (CLI + TUI) and in-filter server search
Some checks failed
gobuild / build (push) Failing after 5s

- New `gostations fav` subcommand with `list`, `add`, `del` (supports index, URL, or search flags)
- TUI favorites toggle via `f` key; favorites auto-detected on load for "Your Favorites" title
- Pressing ENTER while filtering performs a server-side search and refreshes the list
- Default TUI now loads favorites if present, otherwise falls back to broad station lookup
- Added `sortedForDisplay` for stable fav list ordering and index-based deletion
- Tests for favorites title heuristic, sorted display, and fav del-by-index integration
This commit is contained in:
Greg Gauthier 2026-06-05 22:30:50 +01:00
parent 3924aae93b
commit e0f45bdd5e
5 changed files with 591 additions and 10 deletions

Binary file not shown.

View File

@ -1,6 +1,7 @@
package ui package ui
import ( import (
"context"
"fmt" "fmt"
"io" "io"
"log" "log"
@ -61,8 +62,10 @@ func (d listDelegate) Render(w io.Writer, m list.Model, index int, listItem list
// App is the root model. Currently focused on selection (playback integration later). // App is the root model. Currently focused on selection (playback integration later).
type App struct { type App struct {
list list.Model list list.Model
quitting bool quitting bool
currentSearchTerm string
favs *data.Favorites
} }
func NewApp(initial []radio.Station) *App { func NewApp(initial []radio.Station) *App {
@ -83,8 +86,23 @@ func NewApp(initial []radio.Station) *App {
items[i] = item{station: s, isFavorite: isFav} items[i] = item{station: s, isFavorite: isFav}
} }
// Heuristic: if every provided initial station is a favorite, this was a "favorites" initial load
isFavoritesInitial := len(initial) > 0
if isFavoritesInitial {
for _, s := range initial {
if !favSet[s.Url] {
isFavoritesInitial = false
break
}
}
}
l := list.New(items, listDelegate{}, 60, 20) l := list.New(items, listDelegate{}, 60, 20)
l.Title = "GoStations - Radio Browser (new TUI • ★ = favorite)" title := "GoStations - Radio Browser (new TUI • ★ = favorite)"
if isFavoritesInitial {
title = "GoStations - Your Favorites"
}
l.Title = title
l.SetShowStatusBar(true) l.SetShowStatusBar(true)
l.SetFilteringEnabled(true) l.SetFilteringEnabled(true)
l.Styles.Title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")) l.Styles.Title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63"))
@ -120,7 +138,7 @@ func NewApp(initial []radio.Station) *App {
return ranks return ranks
} }
return &App{list: l} return &App{list: l, favs: favs}
} }
func (a *App) Init() tea.Cmd { func (a *App) Init() tea.Cmd {
@ -135,12 +153,54 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.quitting = true a.quitting = true
return a, tea.Quit return a, tea.Quit
case "enter": case "enter":
if a.list.FilterState() == list.Filtering || a.list.FilterState() == list.FilterApplied {
// User pressed enter while in filter: treat the filter term as a new lookup/search
term := strings.TrimSpace(a.list.FilterValue())
if term != "" {
a.currentSearchTerm = term
a.list.Title = fmt.Sprintf(`Searching for "%s"...`, term)
return a, searchCmd(term)
}
}
if i, ok := a.list.SelectedItem().(item); ok { if i, ok := a.list.SelectedItem().(item); ok {
// Placeholder: for now just show what would be played. // Placeholder: for now just show what would be played.
// Later: switch to playback model + use player.Play // Later: switch to playback model + use player.Play
a.list.Title = "Would play: " + i.station.Name + " (press q)" a.list.Title = "Would play: " + i.station.Name + " (press q)"
} }
return a, nil return a, nil
case "f":
// Toggle favorite on the currently selected item
if sel, ok := a.list.SelectedItem().(item); ok {
masterIdx := a.list.GlobalIndex()
if a.favs == nil {
var ferr error
a.favs, ferr = data.NewFavorites()
if ferr != nil {
statusCmd := a.list.NewStatusMessage("Favorites unavailable: " + ferr.Error())
return a, statusCmd
}
}
url := sel.station.Url
wasFav := sel.isFavorite
if wasFav {
a.favs.Remove(url)
} else {
a.favs.Add(sel.station)
}
if saveErr := a.favs.Save(); saveErr != nil {
statusCmd := a.list.NewStatusMessage("Failed to save favorites: " + saveErr.Error())
return a, statusCmd
}
sel.isFavorite = !wasFav
setCmd := a.list.SetItem(masterIdx, sel)
status := "Removed from favorites"
if sel.isFavorite {
status = "Added to favorites ★"
}
statusCmd := a.list.NewStatusMessage(status)
return a, tea.Batch(setCmd, statusCmd)
}
return a, nil
} }
// Auto-start filter on first alphanumeric character (better UX than requiring / first) // Auto-start filter on first alphanumeric character (better UX than requiring / first)
@ -161,6 +221,43 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
a.list.SetSize(msg.Width-4, msg.Height-4) a.list.SetSize(msg.Width-4, msg.Height-4)
case searchResultsMsg:
if msg.err != nil {
a.list.Title = fmt.Sprintf("Search error: %v (press / to search again)", msg.err)
return a, nil
}
// Rebuild items (preserve favorites)
var favs *data.Favorites
var ferr error
if a.favs != nil {
favs = a.favs
// reload to get latest persisted state (in case of external edits)
favs, ferr = data.NewFavorites()
if ferr != nil {
favs = a.favs // fallback to in-memory
}
} else {
favs, ferr = data.NewFavorites()
}
a.favs = favs
favSet := map[string]bool{}
if favs != nil {
for _, fs := range favs.List() {
favSet[fs.Url] = true
}
}
newItems := make([]list.Item, len(msg.stations))
for i, s := range msg.stations {
newItems[i] = item{station: s, isFavorite: favSet[s.Url]}
}
setCmd := a.list.SetItems(newItems)
title := "GoStations - Radio Browser (new TUI • ★ = favorite)"
if a.currentSearchTerm != "" {
title = fmt.Sprintf("GoStations - Results for %q (%d)", a.currentSearchTerm, len(newItems))
}
a.list.Title = title
a.list.ResetFilter()
return a, setCmd
} }
var cmd tea.Cmd var cmd tea.Cmd
@ -172,7 +269,21 @@ func (a *App) View() string {
if a.quitting { if a.quitting {
return "Thanks for using GoStations!\n" return "Thanks for using GoStations!\n"
} }
return "\n" + a.list.View() + "\n\n(type to filter • enter=play stub • / or letters for filter • q quit • --legacy for old UI)\n" return "\n" + a.list.View() + "\n\n(type to filter current list • ENTER while filtering = server search for term & refresh list • f = toggle ★ favorite • q quit • --legacy for old UI)\n"
}
// searchCmd performs an async station search (used for in-TUI lookups via the filter box).
func searchCmd(name string) tea.Cmd {
return func() tea.Msg {
stations, err := radio.Search(context.Background(), name, "", "", "", false)
return searchResultsMsg{stations: stations, err: err}
}
}
// searchResultsMsg is sent when a background search completes.
type searchResultsMsg struct {
stations []radio.Station
err error
} }
// Run starts the TUI (alt screen). // Run starts the TUI (alt screen).

View File

@ -1,6 +1,9 @@
package ui package ui
import ( import (
"encoding/json"
"os"
"path/filepath"
"testing" "testing"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@ -90,3 +93,48 @@ func TestApp_AutoFilterOnTyping(t *testing.T) {
} }
} }
} }
func TestNewAppTitleForFavorites(t *testing.T) {
// Create a temp XDG so NewApp's internal data.NewFavorites() will see our favs
tmpDir := t.TempDir()
os.Setenv("XDG_CONFIG_HOME", tmpDir)
defer os.Unsetenv("XDG_CONFIG_HOME")
// Write a real favorites.json so favSet inside NewApp will contain them
favDir := filepath.Join(tmpDir, "gostations")
if err := os.MkdirAll(favDir, 0755); err != nil {
t.Fatal(err)
}
favData := []radio.Station{
{Name: "Fav1", Url: "http://fav1", Codec: "MP3", Bitrate: "128"},
{Name: "Fav2", Url: "http://fav2", Codec: "AAC", Bitrate: "64"},
}
b, _ := json.MarshalIndent(favData, "", " ")
if err := os.WriteFile(filepath.Join(favDir, "favorites.json"), b, 0644); err != nil {
t.Fatal(err)
}
favStations := []radio.Station{
{Name: "Fav1", Url: "http://fav1", Codec: "MP3", Bitrate: "128"},
{Name: "Fav2", Url: "http://fav2", Codec: "AAC", Bitrate: "64"},
}
// Now NewApp will load the favs we just wrote, so heuristic will see them
app := NewApp(favStations)
if app.list.Title != "GoStations - Your Favorites" {
t.Errorf("expected 'Your Favorites' title when initial == all favs, got %q", app.list.Title)
}
// Non-fav: generic
nonFav := []radio.Station{{Name: "Random", Url: "http://rand", Codec: "MP3", Bitrate: "128"}}
app2 := NewApp(nonFav)
if app2.list.Title != "GoStations - Radio Browser (new TUI • ★ = favorite)" {
t.Errorf("expected generic title, got %q", app2.list.Title)
}
app3 := NewApp(nil)
if app3.list.Title != "GoStations - Radio Browser (new TUI • ★ = favorite)" {
t.Errorf("expected generic for empty, got %q", app3.list.Title)
}
}

View File

@ -6,8 +6,11 @@ import (
"flag" "flag"
"fmt" "fmt"
"os" "os"
"sort"
"strconv"
"github.com/gmgauthier/gostations/internal/config" "github.com/gmgauthier/gostations/internal/config"
"github.com/gmgauthier/gostations/internal/data"
playerpkg "github.com/gmgauthier/gostations/internal/player" playerpkg "github.com/gmgauthier/gostations/internal/player"
"github.com/gmgauthier/gostations/internal/radio" "github.com/gmgauthier/gostations/internal/radio"
"github.com/gmgauthier/gostations/internal/ui" "github.com/gmgauthier/gostations/internal/ui"
@ -150,6 +153,242 @@ func runPlay(args []string) {
} }
} }
// runFav dispatches fav subcommands: list, add, del.
func runFav(args []string) {
if len(args) == 0 {
printFavHelp()
return
}
switch args[0] {
case "list":
runFavList(args[1:])
case "add":
runFavAdd(args[1:])
case "del", "delete", "rm", "remove":
runFavDel(args[1:])
case "-h", "--help", "help":
printFavHelp()
default:
fmt.Fprintf(os.Stderr, "unknown fav subcommand: %s\n\n", args[0])
printFavHelp()
os.Exit(1)
}
}
func printFavHelp() {
fmt.Printf(`Usage: gostations fav <subcommand> [options]
Subcommands:
list List your favorites (supports -j/--json)
add Add a station (by search flags like find, or direct URL)
del Remove a station (by index from list, search flags, or direct URL)
Examples:
gostations fav list
gostations fav list -j
gostations fav add -n "WFMT"
gostations fav add http://stream.example.com/radio.mp3
gostations fav del 3
gostations fav del -n "WFMT"
gostations fav del http://stream.example.com/radio.mp3
Search options for add/del are the same as for "find" / "play".
When deleting by index, the numbering matches the sorted order from "fav list".
`)
}
// sortedForDisplay returns a copy sorted by Name asc, then URL asc for stable 1-based indices in `fav list` / `fav del N`.
func sortedForDisplay(stations []radio.Station) []radio.Station {
out := append([]radio.Station(nil), stations...)
sort.Slice(out, func(i, j int) bool {
if out[i].Name != out[j].Name {
return out[i].Name < out[j].Name
}
return out[i].Url < out[j].Url
})
return out
}
// runFavList lists the current favorites.
func runFavList(args []string) {
fs := flag.NewFlagSet("fav list", flag.ExitOnError)
var jsonOut bool
fs.BoolVar(&jsonOut, "j", false, "Output as JSON array (for scripting)")
fs.BoolVar(&jsonOut, "json", false, "Output as JSON array (for scripting)")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: gostations fav list [options]\n\nOptions:\n")
fs.PrintDefaults()
}
_ = fs.Parse(args)
favs, err := data.NewFavorites()
if err != nil {
fmt.Fprintf(os.Stderr, "error loading favorites: %v\n", err)
os.Exit(1)
}
list := sortedForDisplay(favs.List())
if jsonOut {
b, _ := json.MarshalIndent(list, "", " ")
fmt.Println(string(b))
return
}
if len(list) == 0 {
fmt.Println("(no favorites)")
return
}
fmt.Println("Favorites:")
for i, s := range list {
listing := fmt.Sprintf("%-40s %-5s %-5s %s", Short(s.Name, 40), s.Codec, s.Bitrate, s.Url)
fmt.Printf("%d) %s\n", i+1, listing)
}
}
// runFavAdd adds a station to favorites.
func runFavAdd(args []string) {
fs := flag.NewFlagSet("fav add", flag.ExitOnError)
var (
name string
country string
state string
tags string
notok bool
)
fs.StringVar(&name, "n", "", "Station name (or identifier).")
fs.StringVar(&country, "c", "", "Home country.")
fs.StringVar(&state, "s", "", "Home state (if in the United States).")
fs.StringVar(&tags, "t", "", "Tag (or comma-separated tag list)")
fs.BoolVar(&notok, "x", false, "If toggled, will show stations that are down")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: gostations fav add [options] [direct-url]\n\nOptions:\n")
fs.PrintDefaults()
}
_ = fs.Parse(args)
pos := fs.Args()
// no precheck or config needed for fav add, but call for consistency if search
if err := config.Init(); err != nil {
fmt.Fprintf(os.Stderr, "config: %v\n", err)
os.Exit(1)
}
var toAdd radio.Station
if len(pos) > 0 {
url := pos[0]
toAdd = radio.Station{Url: url, Name: url}
} else {
stations, err := radio.Search(context.Background(), name, country, state, tags, notok)
if err != nil {
fmt.Fprintf(os.Stderr, "search: %v\n", err)
os.Exit(1)
}
if len(stations) == 0 {
fmt.Fprintln(os.Stderr, "no stations found to add")
os.Exit(1)
}
toAdd = stations[0]
fmt.Printf("Adding first match: %s\n", toAdd.Name)
}
favs, err := data.NewFavorites()
if err != nil {
fmt.Fprintf(os.Stderr, "error loading favorites: %v\n", err)
os.Exit(1)
}
favs.Add(toAdd)
if err := favs.Save(); err != nil {
fmt.Fprintf(os.Stderr, "error saving favorites: %v\n", err)
os.Exit(1)
}
fmt.Printf("Added to favorites: %s (%s)\n", toAdd.Name, toAdd.Url)
}
// runFavDel removes a station from favorites.
// Supports: index (1-based, matching `fav list` order), direct URL, or search flags.
func runFavDel(args []string) {
fs := flag.NewFlagSet("fav del", flag.ExitOnError)
var (
name string
country string
state string
tags string
notok bool
)
fs.StringVar(&name, "n", "", "Station name (or identifier).")
fs.StringVar(&country, "c", "", "Home country.")
fs.StringVar(&state, "s", "", "Home state (if in the United States).")
fs.StringVar(&tags, "t", "", "Tag (or comma-separated tag list)")
fs.BoolVar(&notok, "x", false, "If toggled, will show stations that are down")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: gostations fav del [options] [index|direct-url]\n\nOptions:\n")
fs.PrintDefaults()
}
_ = fs.Parse(args)
pos := fs.Args()
if err := config.Init(); err != nil {
fmt.Fprintf(os.Stderr, "config: %v\n", err)
os.Exit(1)
}
favs, err := data.NewFavorites()
if err != nil {
fmt.Fprintf(os.Stderr, "error loading favorites: %v\n", err)
os.Exit(1)
}
var urlToDel string
var deletedName string
if len(pos) > 0 {
arg := pos[0]
if idx, err := strconv.Atoi(arg); err == nil && idx > 0 {
// Delete by 1-based index (matches sorted order from `fav list`)
list := sortedForDisplay(favs.List())
if idx > len(list) {
fmt.Fprintf(os.Stderr, "index %d out of range (1-%d)\n", idx, len(list))
os.Exit(1)
}
station := list[idx-1]
urlToDel = station.Url
deletedName = station.Name
} else {
// Treat as direct URL
urlToDel = arg
deletedName = arg
}
} else {
// Use search flags to find station(s) to delete
stations, err := radio.Search(context.Background(), name, country, state, tags, notok)
if err != nil {
fmt.Fprintf(os.Stderr, "search: %v\n", err)
os.Exit(1)
}
if len(stations) == 0 {
fmt.Fprintln(os.Stderr, "no stations found to delete")
os.Exit(1)
}
urlToDel = stations[0].Url
deletedName = stations[0].Name
fmt.Printf("Deleting first match: %s\n", deletedName)
}
favs.Remove(urlToDel)
if err := favs.Save(); err != nil {
fmt.Fprintf(os.Stderr, "error saving favorites: %v\n", err)
os.Exit(1)
}
fmt.Printf("Removed from favorites: %s\n", deletedName)
}
func main() { func main() {
// Early version / help for top level // Early version / help for top level
if len(os.Args) > 1 { if len(os.Args) > 1 {
@ -160,6 +399,9 @@ func main() {
case "play": case "play":
runPlay(os.Args[2:]) runPlay(os.Args[2:])
return return
case "fav":
runFav(os.Args[2:])
return
case "-v", "--version", "version": case "-v", "--version", "version":
showVersion() showVersion()
return return
@ -205,10 +447,29 @@ func main() {
os.Exit(1) os.Exit(1)
} }
stations, err := radio.Search(context.Background(), name, country, state, tags, notok) var stations []radio.Station
if err != nil { var loadErr error
fmt.Printf("warning: station search: %v\n", err) searchParamsProvided := name != "" || country != "" || state != "" || tags != "" || notok
stations = nil if searchParamsProvided {
// CLI flags specify a search: use them (targeted lookup)
stations, loadErr = radio.Search(context.Background(), name, country, state, tags, notok)
if loadErr != nil {
fmt.Printf("warning: station search: %v\n", loadErr)
stations = nil
}
} else {
// No explicit search params: decide based on favorites (per user request)
favs, ferr := data.NewFavorites()
if ferr == nil && favs != nil && len(favs.List()) > 0 {
stations = favs.List()
} else {
// no favorites: do the default broad lookup (pruned)
stations, loadErr = radio.Search(context.Background(), "", "", "", "", false)
if loadErr != nil {
fmt.Printf("warning: default station load: %v\n", loadErr)
stations = nil
}
}
} }
if legacy { if legacy {
@ -230,9 +491,10 @@ func main() {
func printTopHelp() { func printTopHelp() {
fmt.Printf(`Usage: fmt.Printf(`Usage:
gostations [options] # Launch interactive TUI (default) gostations [options] # Launch interactive TUI (default: your Favorites if any, else a default set of stations)
gostations find [options] # Non-interactive station search (scripting) gostations find [options] # Non-interactive station search (scripting)
gostations play [options] [url] # Direct playback (scripting) gostations play [options] [url] # Direct playback (scripting)
gostations fav ... # Manage favorites (list / add / del)
gostations -h | --help gostations -h | --help
gostations -v | --version gostations -v | --version
@ -249,6 +511,20 @@ Subcommand examples:
gostations find -c "United Kingdom" -t "news" -j gostations find -c "United Kingdom" -t "news" -j
gostations play -c "Gambia" gostations play -c "Gambia"
gostations play http://stream.example.com/radio.mp3 gostations play http://stream.example.com/radio.mp3
gostations fav list
gostations fav add -n "WFMT"
gostations fav del 3
gostations fav del http://stream.example.com/radio.mp3
gostations fav list -j
Initial TUI view:
- If you have saved favorites: shows your Favorites (starred).
- Otherwise: performs a default station lookup (broad results).
- In either case, use / to filter the current list, or type a term and press ENTER while filtering to perform a fresh server-side search/lookup (replaces the list with new results; overlapping favorites get ).
CLI favorites management (fav subcommand):
- gostations fav list (sorted output, supports -j)
- gostations fav add / del (support index from list, search flags like -n, or direct URL)
Old top-level behavior without subcommand now defaults to the new TUI. Old top-level behavior without subcommand now defaults to the new TUI.
Use --legacy to force the classic wmenu flow. Use --legacy to force the classic wmenu flow.

View File

@ -3,7 +3,12 @@ package main
import ( import (
"bytes" "bytes"
"os" "os"
"os/exec"
"path/filepath"
"strings"
"testing" "testing"
"github.com/gmgauthier/gostations/internal/radio"
) )
func TestShowVersion_Unit(t *testing.T) { func TestShowVersion_Unit(t *testing.T) {
@ -67,3 +72,144 @@ func TestPrecheck_Live(t *testing.T) {
precheck() precheck()
t.Log("✓ Live precheck passed (no early exit)") 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")
}
}