Files
wehub-resource-sync ead81af521
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

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)
}
}