feat(cli): default to bubbles TUI, add find/play subcmds + JSON favorites
- implement internal/ui with bubbles list + ★ fav markers, filter, enter stub
- add data/favorites (JSON roundtrip, Add/Remove, XDG), config tests
- wire subcommands: `find -j`, `play [url|search]`
- gate legacy wmenu behind --legacy; keep old flags for seeding
- fix inverted -short guards, format string, go.mod deps
- add unit tests for radio prune, player IsInstalled, ui keys, etc.
2026-06-05 20:23:11 +00:00
|
|
|
package ui
|
|
|
|
|
|
|
|
|
|
import (
|
2026-06-05 21:30:50 +00:00
|
|
|
"encoding/json"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
feat(cli): default to bubbles TUI, add find/play subcmds + JSON favorites
- implement internal/ui with bubbles list + ★ fav markers, filter, enter stub
- add data/favorites (JSON roundtrip, Add/Remove, XDG), config tests
- wire subcommands: `find -j`, `play [url|search]`
- gate legacy wmenu behind --legacy; keep old flags for seeding
- fix inverted -short guards, format string, go.mod deps
- add unit tests for radio prune, player IsInstalled, ui keys, etc.
2026-06-05 20:23:11 +00:00
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
|
|
|
|
|
|
"github.com/gmgauthier/gostations/internal/radio"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestApp_BasicKeyHandling(t *testing.T) {
|
|
|
|
|
app := NewApp([]radio.Station{
|
|
|
|
|
{Name: "Test1", Url: "http://a", Codec: "MP3", Bitrate: "128"},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Send q
|
|
|
|
|
model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
|
|
|
|
|
if !model.(*App).quitting {
|
|
|
|
|
t.Error("q did not set quitting")
|
|
|
|
|
}
|
|
|
|
|
_ = cmd
|
|
|
|
|
|
|
|
|
|
// Send window size
|
|
|
|
|
model, _ = app.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
|
|
|
|
|
a := model.(*App)
|
|
|
|
|
if a.list.Width() == 0 {
|
|
|
|
|
t.Log("list size not updated (may be ok in test)")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-05 20:36:49 +00:00
|
|
|
|
|
|
|
|
func TestApp_AutoFilterOnTyping(t *testing.T) {
|
|
|
|
|
app := NewApp([]radio.Station{
|
|
|
|
|
{Name: "WFMT 98.7", Url: "http://wfmt", Codec: "MP3", Bitrate: "128", Tags: "chicago,classical"},
|
|
|
|
|
{Name: "Other Station", Url: "http://other", Codec: "AAC", Bitrate: "64", Tags: "news"},
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-05 20:46:04 +00:00
|
|
|
// Simulate typing 'W' (auto enter filter). Drive any returned cmds.
|
|
|
|
|
model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'W'}})
|
2026-06-05 20:36:49 +00:00
|
|
|
a := model.(*App)
|
2026-06-05 20:46:04 +00:00
|
|
|
for cmd != nil {
|
|
|
|
|
msg := cmd()
|
|
|
|
|
if msg == nil {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
model, cmd = a.Update(msg.(tea.Msg))
|
|
|
|
|
a = model.(*App)
|
|
|
|
|
}
|
2026-06-05 20:36:49 +00:00
|
|
|
|
|
|
|
|
fv := a.list.FilterValue()
|
|
|
|
|
if fv != "W" {
|
|
|
|
|
t.Errorf("expected filter value 'W' after typing W, got %q", fv)
|
|
|
|
|
}
|
|
|
|
|
visible := a.list.VisibleItems()
|
|
|
|
|
if len(visible) == 0 {
|
|
|
|
|
t.Error("expected some visible items after filter 'W'")
|
|
|
|
|
}
|
2026-06-05 20:46:04 +00:00
|
|
|
|
|
|
|
|
// Now type the rest of "WFMT", driving cmds each time
|
|
|
|
|
for _, r := range "FMT" {
|
|
|
|
|
model, cmd = a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
|
|
|
|
a = model.(*App)
|
|
|
|
|
for cmd != nil {
|
|
|
|
|
msg := cmd()
|
|
|
|
|
if msg == nil {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
model, cmd = a.Update(msg.(tea.Msg))
|
|
|
|
|
a = model.(*App)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fv = a.list.FilterValue()
|
|
|
|
|
if fv != "WFMT" {
|
|
|
|
|
t.Errorf("expected filter value 'WFMT', got %q", fv)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Note: in live typing simulation, state may stay 'filtering' and filteredItems update may depend on internal cmd processing.
|
|
|
|
|
// For verifying the custom substring filter logic itself, use SetFilterText which synchronously applies.
|
|
|
|
|
a.list.SetFilterText("WFMT")
|
|
|
|
|
|
|
|
|
|
visible = a.list.VisibleItems()
|
|
|
|
|
if len(visible) != 1 {
|
|
|
|
|
t.Errorf("expected exactly 1 item for 'WFMT' substring filter, got %d", len(visible))
|
|
|
|
|
}
|
|
|
|
|
if len(visible) > 0 {
|
|
|
|
|
if it, ok := visible[0].(item); ok {
|
|
|
|
|
if it.station.Name != "WFMT 98.7" {
|
|
|
|
|
t.Errorf("expected 'WFMT 98.7' to be the match, got %q", it.station.Name)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-05 20:36:49 +00:00
|
|
|
}
|
2026-06-05 21:30:50 +00:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|