Some checks failed
gobuild / build (push) Failing after 6s
Implement mpv JSON IPC backend for non-blocking playback, streamed metadata (media-title/icy-title), and runtime controls (pause, mute, volume, next/prev). Extend Player interface and wire a two-stage TUI that switches to a dedicated playback view with keyboard shortcuts and a styled hint bar. Fallback to legacy player when mpv is unavailable.
253 lines
8.3 KiB
Go
253 lines
8.3 KiB
Go
package ui
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/muesli/termenv"
|
|
|
|
"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.width != 80 {
|
|
t.Errorf("expected app.width=80 after WindowSizeMsg, got %d", a.width)
|
|
}
|
|
if a.list.Width() == 0 {
|
|
t.Log("list size not updated (may be ok in test)")
|
|
}
|
|
}
|
|
|
|
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"},
|
|
})
|
|
|
|
// Simulate typing 'W' (auto enter filter). Drive any returned cmds.
|
|
model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'W'}})
|
|
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 != "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'")
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestRenderHint_Visual(t *testing.T) {
|
|
// Force color output so lipgloss always emits the bg/fg ANSI codes for the key badges
|
|
// (in real TUI this happens automatically on a pty).
|
|
prevProfile := lipgloss.ColorProfile()
|
|
lipgloss.SetColorProfile(termenv.TrueColor)
|
|
defer lipgloss.SetColorProfile(prevProfile)
|
|
|
|
app := NewApp(nil)
|
|
app.width = 120 // simulate a typical terminal width to exercise full-width bar
|
|
h := app.renderHint()
|
|
|
|
// Basic sanity: all the documented trigger keys are present in output.
|
|
for _, want := range []string{"[/]", "[ENTER]", "[Q]", "[f]"} {
|
|
if !strings.Contains(h, want) {
|
|
t.Errorf("renderHint missing %s in %q", want, h)
|
|
}
|
|
}
|
|
if !strings.Contains(h, "\x1b[") {
|
|
t.Errorf("expected ANSI color codes from hintKeyStyle (bg+fg), got plain: %q", h)
|
|
}
|
|
// Show what a user will see (the escapes will be interpreted by the terminal in real run).
|
|
visible := strings.ReplaceAll(h, "\x1b", "\\x1b")
|
|
t.Logf("HINT ROW RENDERED (with forced color profile, width=120): %s", visible)
|
|
|
|
// Rough check that the bar filled to (near) requested width (after ANSI codes).
|
|
// We strip the known key/badge escapes for a simple length heuristic on the plain text + pads.
|
|
plainish := strings.ReplaceAll(h, "\x1b[1;97;48;5;63m", "")
|
|
plainish = strings.ReplaceAll(plainish, "\x1b[0m", "")
|
|
plainish = strings.ReplaceAll(plainish, "\x1b[38;5;245m", "")
|
|
if len(plainish) < 100 {
|
|
t.Errorf("expected bar to be nearly full width (len after basic strip ~120), got %d: %q", len(plainish), plainish)
|
|
}
|
|
}
|
|
|
|
// stubPlayer is a no-op player for unit tests (avoids real mpv exec + socket in tests).
|
|
type stubPlayer struct{}
|
|
|
|
func (stubPlayer) Play(url string, extra ...string) error { return nil }
|
|
func (stubPlayer) Stop() error { return nil }
|
|
func (stubPlayer) Metadata() string { return "Fake Song Title [stream]" }
|
|
func (stubPlayer) Pause() error { return nil }
|
|
func (stubPlayer) Resume() error { return nil }
|
|
func (stubPlayer) Mute() error { return nil }
|
|
func (stubPlayer) Unmute() error { return nil }
|
|
func (stubPlayer) Next() error { return nil }
|
|
func (stubPlayer) Prev() error { return nil }
|
|
func (stubPlayer) VolumeUp() error { return nil }
|
|
func (stubPlayer) VolumeDown() error { return nil }
|
|
|
|
func TestApp_PlaybackView(t *testing.T) {
|
|
stations := []radio.Station{
|
|
{Name: "Test Radio", Url: "http://example.com/stream", Codec: "MP3", Bitrate: "128"},
|
|
}
|
|
app := NewApp(stations)
|
|
// override with stub so no real process in test
|
|
app.player = stubPlayer{}
|
|
|
|
// size so render works
|
|
app.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
|
|
|
|
// press enter on first (only) item
|
|
app.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
|
|
|
if !app.playing {
|
|
t.Fatal("expected to be in playing state after enter")
|
|
}
|
|
if app.playingItem.station.Name != "Test Radio" {
|
|
t.Errorf("wrong station: %s", app.playingItem.station.Name)
|
|
}
|
|
if app.nowPlaying == "" {
|
|
t.Error("nowPlaying should be initialized")
|
|
}
|
|
|
|
// poll would have set it
|
|
app.Update(metadataMsg{title: "Fake Song Title [stream]"})
|
|
if !strings.Contains(app.nowPlaying, "Fake") {
|
|
t.Errorf("metadata not applied: %s", app.nowPlaying)
|
|
}
|
|
|
|
// exercise volume keys (no-op on stub, but covers the handler)
|
|
app.Update(tea.KeyMsg{Type: tea.KeyUp})
|
|
app.Update(tea.KeyMsg{Type: tea.KeyDown})
|
|
|
|
// render while still playing (with metadata)
|
|
v := app.renderPlayback()
|
|
if !strings.Contains(v, "Test Radio") || !strings.Contains(v, "NOW PLAYING") {
|
|
t.Errorf("playback render missing expected content: %s", v)
|
|
}
|
|
if !strings.Contains(v, "Fake Song") {
|
|
t.Logf("note: metadata may not be in this render snapshot")
|
|
}
|
|
|
|
// check playing-mode hint bar includes volume
|
|
app.playing = true
|
|
h := app.renderHint()
|
|
if !strings.Contains(h, "Vol") || !strings.Contains(h, "↑↓") {
|
|
t.Errorf("playing hint missing volume info: %s", h)
|
|
}
|
|
|
|
// press s to stop
|
|
app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}})
|
|
if app.playing {
|
|
t.Error("expected stopped after 's'")
|
|
}
|
|
}
|
|
|