253 lines
6.4 KiB
Go
253 lines
6.4 KiB
Go
package model
|
|
|
|
import (
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/bjarneo/cliamp/player"
|
|
"github.com/bjarneo/cliamp/playlist"
|
|
"github.com/bjarneo/cliamp/ui"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
)
|
|
|
|
var sharedPlayer player.Engine
|
|
|
|
func TestMain(m *testing.M) {
|
|
os.Unsetenv("CLIAMP_CONFIG_DIR")
|
|
os.Unsetenv("XDG_CONFIG_HOME")
|
|
|
|
sr := player.DeviceSampleRate()
|
|
if sr <= 0 {
|
|
sr = 44100
|
|
}
|
|
p, err := player.New(player.Quality{SampleRate: sr, BufferMs: 100, ResampleQuality: 1})
|
|
if err == nil {
|
|
sharedPlayer = p
|
|
defer p.Close()
|
|
}
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
// TestTickIntervalStoppedUsesIdle verifies that a fully-idle model (player
|
|
// stopped, no overlay, no pending status / reconnect) ticks at ui.TickIdle
|
|
// rather than ui.TickSlow / ui.TickFast. This is what lets the CPU sit in a
|
|
// low P-state between user actions (issue #92 and follow-ups).
|
|
func TestTickIntervalStoppedUsesIdle(t *testing.T) {
|
|
if sharedPlayer == nil {
|
|
t.Skip("audio hardware unavailable")
|
|
}
|
|
m := Model{
|
|
player: sharedPlayer,
|
|
vis: ui.NewVisualizer(float64(sharedPlayer.SampleRate())),
|
|
playlist: playlist.New(),
|
|
termTitle: terminalTitleState{},
|
|
}
|
|
|
|
if sharedPlayer.IsPlaying() {
|
|
t.Fatal("expected player to be stopped")
|
|
}
|
|
if !m.isFullyIdle() {
|
|
t.Fatal("isFullyIdle() = false on a fresh stopped model, want true")
|
|
}
|
|
if got := m.tickInterval(); got != ui.TickIdle {
|
|
t.Errorf("tickInterval() = %v, want %v (ui.TickIdle)", got, ui.TickIdle)
|
|
}
|
|
}
|
|
|
|
// TestTickIntervalPendingStatusUsesSlow verifies that a pending status
|
|
// message keeps us off the idle cadence — otherwise the message would linger
|
|
// up to TickIdle past its expiry.
|
|
func TestTickIntervalPendingStatusUsesSlow(t *testing.T) {
|
|
if sharedPlayer == nil {
|
|
t.Skip("audio hardware unavailable")
|
|
}
|
|
m := Model{
|
|
player: sharedPlayer,
|
|
vis: ui.NewVisualizer(float64(sharedPlayer.SampleRate())),
|
|
playlist: playlist.New(),
|
|
}
|
|
m.status.Show("hello", statusTTL(2*time.Second))
|
|
|
|
if m.isFullyIdle() {
|
|
t.Fatal("isFullyIdle() = true with pending status message, want false")
|
|
}
|
|
if got := m.tickInterval(); got == ui.TickIdle {
|
|
t.Errorf("tickInterval() = %v with pending status, want a faster cadence", got)
|
|
}
|
|
}
|
|
|
|
func TestTickIntervalPlayingUsesFastCadence(t *testing.T) {
|
|
p := &playbackFakeEngine{playing: true}
|
|
m := Model{
|
|
player: p,
|
|
vis: ui.NewVisualizer(float64(p.SampleRate())),
|
|
playlist: playlist.New(),
|
|
}
|
|
m.SetVisualizer("none")
|
|
|
|
if got := m.tickInterval(); got != ui.TickFast {
|
|
t.Fatalf("tickInterval() = %v, want %v", got, ui.TickFast)
|
|
}
|
|
}
|
|
|
|
func TestTickIntervalLowPowerPlayingUsesLowPowerCadence(t *testing.T) {
|
|
p := &playbackFakeEngine{playing: true}
|
|
m := Model{
|
|
player: p,
|
|
vis: ui.NewVisualizer(float64(p.SampleRate())),
|
|
playlist: playlist.New(),
|
|
}
|
|
m.SetVisualizer("none")
|
|
m.SetLowPower(true)
|
|
|
|
if got := m.tickInterval(); got != ui.TickLowPowerPlaying {
|
|
t.Fatalf("tickInterval() = %v, want %v", got, ui.TickLowPowerPlaying)
|
|
}
|
|
}
|
|
|
|
func TestInitialTickUsesFastCadence(t *testing.T) {
|
|
prev := teaTick
|
|
t.Cleanup(func() {
|
|
teaTick = prev
|
|
})
|
|
|
|
called := false
|
|
teaTick = func(d time.Duration, fn func(time.Time) tea.Msg) tea.Cmd {
|
|
called = true
|
|
if d != ui.TickFast {
|
|
t.Fatalf("tick duration = %v, want %v", d, ui.TickFast)
|
|
}
|
|
return func() tea.Msg {
|
|
return fn(time.Unix(0, 0))
|
|
}
|
|
}
|
|
|
|
msg := tickCmd()()
|
|
|
|
if _, ok := msg.(tickMsg); !ok {
|
|
t.Fatalf("tickCmd() message = %T, want tickMsg", msg)
|
|
}
|
|
if !called {
|
|
t.Fatal("tickCmd() did not schedule teaTick")
|
|
}
|
|
}
|
|
|
|
func TestRefreshVisualizerIfPendingConsumesOneShotRequest(t *testing.T) {
|
|
m := Model{
|
|
vis: ui.NewVisualizer(44100),
|
|
}
|
|
|
|
m.vis.RequestRefresh()
|
|
m.refreshVisualizerIfPending()
|
|
|
|
if m.vis.RefreshPending() {
|
|
t.Fatal("refreshPending = true after refreshVisualizerIfPending(), want false")
|
|
}
|
|
if m.vis.Frame() != 1 {
|
|
t.Fatalf("frame after refreshVisualizerIfPending() = %d, want 1", m.vis.Frame())
|
|
}
|
|
|
|
m.refreshVisualizerIfPending()
|
|
if m.vis.Frame() != 1 {
|
|
t.Fatalf("frame after second refreshVisualizerIfPending() = %d, want 1", m.vis.Frame())
|
|
}
|
|
}
|
|
|
|
func TestLyricsScreenKeepsVisualizerLive(t *testing.T) {
|
|
m := Model{
|
|
vis: ui.NewVisualizer(44100),
|
|
lyrics: lyricsState{
|
|
visible: true,
|
|
},
|
|
}
|
|
|
|
if got := m.activeScreen(); got != screenLyrics {
|
|
t.Fatalf("activeScreen() = %v, want %v", got, screenLyrics)
|
|
}
|
|
// Overlays now render inline over the live main view, so the visualizer is
|
|
// never treated as hidden.
|
|
if m.isOverlayActive() {
|
|
t.Fatal("isOverlayActive() = true, want false: overlays render inline")
|
|
}
|
|
if m.visualizerTickContext(time.Now()).OverlayActive {
|
|
t.Fatal("visualizerTickContext(...).OverlayActive = true, want false for inline lyrics")
|
|
}
|
|
|
|
m.vis.RequestRefresh()
|
|
m.refreshVisualizerIfPending()
|
|
|
|
if m.vis.RefreshPending() {
|
|
t.Fatal("refreshPending = true after refresh, want false: visualizer stays live under lyrics")
|
|
}
|
|
if m.vis.Frame() != 1 {
|
|
t.Fatalf("frame after refresh = %d, want 1 while lyrics open", m.vis.Frame())
|
|
}
|
|
}
|
|
|
|
func TestUpdateRequestsVisualizerRefreshWhenOverlayCloses(t *testing.T) {
|
|
m := Model{
|
|
vis: ui.NewVisualizer(44100),
|
|
keymap: keymapOverlay{
|
|
visible: true,
|
|
},
|
|
}
|
|
|
|
nextModel, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
|
|
if cmd != nil {
|
|
t.Fatalf("Update() cmd = %v, want nil", cmd)
|
|
}
|
|
|
|
next, ok := nextModel.(Model)
|
|
if !ok {
|
|
t.Fatalf("Update() model = %T, want Model", nextModel)
|
|
}
|
|
if next.keymap.visible {
|
|
t.Fatal("keymap overlay remained visible after escape")
|
|
}
|
|
if !next.vis.RefreshPending() {
|
|
t.Fatal("refreshPending = false after overlay close, want true")
|
|
}
|
|
}
|
|
|
|
func TestUpdateRequestsVisualizerRefreshWhenLyricsClose(t *testing.T) {
|
|
m := Model{
|
|
vis: ui.NewVisualizer(44100),
|
|
lyrics: lyricsState{
|
|
visible: true,
|
|
},
|
|
}
|
|
|
|
nextModel, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
|
|
if cmd != nil {
|
|
t.Fatalf("Update() cmd = %v, want nil", cmd)
|
|
}
|
|
|
|
next, ok := nextModel.(Model)
|
|
if !ok {
|
|
t.Fatalf("Update() model = %T, want Model", nextModel)
|
|
}
|
|
if next.lyrics.visible {
|
|
t.Fatal("lyrics overlay remained visible after escape")
|
|
}
|
|
if !next.vis.RefreshPending() {
|
|
t.Fatal("refreshPending = false after lyrics close, want true")
|
|
}
|
|
}
|
|
|
|
func TestAdvanceTickUnitsClearsElapsedWhenCounterCompletes(t *testing.T) {
|
|
ttl := 1
|
|
elapsed := time.Duration(0)
|
|
|
|
if got := advanceTickUnits(&ttl, &elapsed, 3*time.Second, ui.TickFast); got != 1 {
|
|
t.Fatalf("advanceTickUnits() steps = %d, want 1", got)
|
|
}
|
|
if ttl != 0 {
|
|
t.Fatalf("ttl after completion = %d, want 0", ttl)
|
|
}
|
|
if elapsed != 0 {
|
|
t.Fatalf("elapsed after completion = %v, want 0", elapsed)
|
|
}
|
|
}
|