Some checks failed
gobuild / build (push) Failing after 7s
Add Volume() to Player interface and mpvPlayer implementation to expose current volume level. Poll volume periodically and render a vertical bar next to the metadata display during playback. Update UI state and tests to support the new volume indicator.
254 lines
8.4 KiB
Go
254 lines
8.4 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 (stubPlayer) Volume() int { return 65 }
|
|
|
|
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'")
|
|
}
|
|
}
|
|
|