🎉 live server seems to be working now
This commit is contained in:
561
third_party/sendspin-go/internal/ui/model_test.go
vendored
Normal file
561
third_party/sendspin-go/internal/ui/model_test.go
vendored
Normal file
@@ -0,0 +1,561 @@
|
||||
// ABOUTME: Tests for TUI model and state management
|
||||
// ABOUTME: Tests status updates, message handling, and state transitions
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/sync"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
func TestNewModel(t *testing.T) {
|
||||
model := NewModel(Config{}) // VolumeControl and TransportControl are optional for testing
|
||||
|
||||
// Check initial state
|
||||
if model.connected {
|
||||
t.Error("expected connected to be false initially")
|
||||
}
|
||||
|
||||
if model.volume != 100 {
|
||||
t.Errorf("expected default volume 100, got %d", model.volume)
|
||||
}
|
||||
|
||||
if model.muted {
|
||||
t.Error("expected muted to be false initially")
|
||||
}
|
||||
|
||||
if model.showDebug {
|
||||
t.Error("expected showDebug to be false initially")
|
||||
}
|
||||
|
||||
if model.playbackState != "idle" {
|
||||
t.Errorf("expected playbackState 'idle', got '%s'", model.playbackState)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusMsgConnected(t *testing.T) {
|
||||
model := NewModel(Config{})
|
||||
|
||||
connected := true
|
||||
msg := StatusMsg{
|
||||
Connected: &connected,
|
||||
ServerName: "test-server",
|
||||
}
|
||||
|
||||
model.applyStatus(msg)
|
||||
|
||||
if !model.connected {
|
||||
t.Error("expected connected to be true after status update")
|
||||
}
|
||||
|
||||
if model.serverName != "test-server" {
|
||||
t.Errorf("expected serverName 'test-server', got '%s'", model.serverName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusMsgDisconnected(t *testing.T) {
|
||||
model := NewModel(Config{})
|
||||
|
||||
// First connect
|
||||
connected := true
|
||||
model.applyStatus(StatusMsg{Connected: &connected})
|
||||
|
||||
// Then disconnect
|
||||
disconnected := false
|
||||
model.applyStatus(StatusMsg{Connected: &disconnected})
|
||||
|
||||
if model.connected {
|
||||
t.Error("expected connected to be false after disconnect")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusMsgSyncStats(t *testing.T) {
|
||||
model := NewModel(Config{})
|
||||
|
||||
msg := StatusMsg{
|
||||
SyncRTT: 5000,
|
||||
SyncQuality: sync.QualityGood,
|
||||
}
|
||||
|
||||
model.applyStatus(msg)
|
||||
|
||||
if model.syncRTT != 5000 {
|
||||
t.Errorf("expected syncRTT 5000, got %d", model.syncRTT)
|
||||
}
|
||||
|
||||
if model.syncQuality != sync.QualityGood {
|
||||
t.Errorf("expected QualityGood, got %v", model.syncQuality)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusMsgStreamInfo(t *testing.T) {
|
||||
model := NewModel(Config{})
|
||||
|
||||
msg := StatusMsg{
|
||||
Codec: "opus",
|
||||
SampleRate: 48000,
|
||||
Channels: 2,
|
||||
BitDepth: 16,
|
||||
}
|
||||
|
||||
model.applyStatus(msg)
|
||||
|
||||
if model.codec != "opus" {
|
||||
t.Errorf("expected codec 'opus', got '%s'", model.codec)
|
||||
}
|
||||
|
||||
if model.sampleRate != 48000 {
|
||||
t.Errorf("expected sampleRate 48000, got %d", model.sampleRate)
|
||||
}
|
||||
|
||||
if model.channels != 2 {
|
||||
t.Errorf("expected channels 2, got %d", model.channels)
|
||||
}
|
||||
|
||||
if model.bitDepth != 16 {
|
||||
t.Errorf("expected bitDepth 16, got %d", model.bitDepth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusMsgMetadata(t *testing.T) {
|
||||
model := NewModel(Config{})
|
||||
|
||||
msg := StatusMsg{
|
||||
Title: "Test Song",
|
||||
Artist: "Test Artist",
|
||||
Album: "Test Album",
|
||||
}
|
||||
|
||||
model.applyStatus(msg)
|
||||
|
||||
if model.title != "Test Song" {
|
||||
t.Errorf("expected title 'Test Song', got '%s'", model.title)
|
||||
}
|
||||
|
||||
if model.artist != "Test Artist" {
|
||||
t.Errorf("expected artist 'Test Artist', got '%s'", model.artist)
|
||||
}
|
||||
|
||||
if model.album != "Test Album" {
|
||||
t.Errorf("expected album 'Test Album', got '%s'", model.album)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusMsgArtworkPath(t *testing.T) {
|
||||
model := NewModel(Config{})
|
||||
|
||||
msg := StatusMsg{
|
||||
ArtworkPath: "/tmp/artwork.jpg",
|
||||
}
|
||||
|
||||
model.applyStatus(msg)
|
||||
|
||||
if model.artworkPath != "/tmp/artwork.jpg" {
|
||||
t.Errorf("expected artworkPath '/tmp/artwork.jpg', got '%s'", model.artworkPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusMsgVolume(t *testing.T) {
|
||||
model := NewModel(Config{})
|
||||
|
||||
msg := StatusMsg{
|
||||
Volume: 75,
|
||||
}
|
||||
|
||||
model.applyStatus(msg)
|
||||
|
||||
if model.volume != 75 {
|
||||
t.Errorf("expected volume 75, got %d", model.volume)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusMsgStats(t *testing.T) {
|
||||
model := NewModel(Config{})
|
||||
|
||||
msg := StatusMsg{
|
||||
Received: 1000,
|
||||
Played: 950,
|
||||
Dropped: 50,
|
||||
BufferDepth: 300,
|
||||
}
|
||||
|
||||
model.applyStatus(msg)
|
||||
|
||||
if model.received != 1000 {
|
||||
t.Errorf("expected received 1000, got %d", model.received)
|
||||
}
|
||||
|
||||
if model.played != 950 {
|
||||
t.Errorf("expected played 950, got %d", model.played)
|
||||
}
|
||||
|
||||
if model.dropped != 50 {
|
||||
t.Errorf("expected dropped 50, got %d", model.dropped)
|
||||
}
|
||||
|
||||
if model.bufferDepth != 300 {
|
||||
t.Errorf("expected bufferDepth 300, got %d", model.bufferDepth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusMsgRuntimeStats(t *testing.T) {
|
||||
model := NewModel(Config{})
|
||||
|
||||
msg := StatusMsg{
|
||||
Goroutines: 42,
|
||||
}
|
||||
|
||||
model.applyStatus(msg)
|
||||
|
||||
if model.goroutines != 42 {
|
||||
t.Errorf("expected goroutines 42, got %d", model.goroutines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipleStatusUpdates(t *testing.T) {
|
||||
model := NewModel(Config{})
|
||||
|
||||
// First update
|
||||
connected := true
|
||||
model.applyStatus(StatusMsg{
|
||||
Connected: &connected,
|
||||
Codec: "opus",
|
||||
})
|
||||
|
||||
if model.codec != "opus" {
|
||||
t.Error("first update failed")
|
||||
}
|
||||
|
||||
// Second update (partial) - sampleRate requires Codec to be in same message
|
||||
model.applyStatus(StatusMsg{
|
||||
Codec: "opus",
|
||||
SampleRate: 48000,
|
||||
})
|
||||
|
||||
// Previous values should be retained
|
||||
if model.codec != "opus" {
|
||||
t.Error("previous codec value was lost")
|
||||
}
|
||||
|
||||
if model.sampleRate != 48000 {
|
||||
t.Error("new sampleRate not applied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusMsgZeroValues(t *testing.T) {
|
||||
model := NewModel(Config{})
|
||||
|
||||
// Set some non-zero values first
|
||||
model.applyStatus(StatusMsg{
|
||||
Volume: 75,
|
||||
Received: 100,
|
||||
})
|
||||
|
||||
// Apply zero values - Volume should not update (0 is ignored), but stats should
|
||||
model.applyStatus(StatusMsg{
|
||||
Volume: 0,
|
||||
Received: 0,
|
||||
})
|
||||
|
||||
// Volume should NOT be updated to 0 (0 is special case)
|
||||
if model.volume == 0 {
|
||||
t.Error("volume should not be updated to 0")
|
||||
}
|
||||
|
||||
// Stats should be updated (0 is valid)
|
||||
if model.received != 0 {
|
||||
t.Error("received stats should be updated to 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateFunction(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
maxLen int
|
||||
expected string
|
||||
}{
|
||||
{"short", 10, "short"},
|
||||
{"exactly ten c", 14, "exactly ten c"},
|
||||
{"this is longer than allowed", 10, "this is..."},
|
||||
{"this is longer than allowed", 15, "this is long..."}, // 15-3=12 chars + "..."
|
||||
{"", 10, ""},
|
||||
{"a", 10, "a"},
|
||||
{"abc", 3, "abc"},
|
||||
{"abcd", 4, "abcd"},
|
||||
{"abcde", 4, "a..."},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := truncate(tt.input, tt.maxLen)
|
||||
if result != tt.expected {
|
||||
t.Errorf("truncate(%q, %d) = %q, expected %q",
|
||||
tt.input, tt.maxLen, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelNameFunction(t *testing.T) {
|
||||
tests := []struct {
|
||||
channels int
|
||||
expected string
|
||||
}{
|
||||
{1, "Mono"},
|
||||
{2, "Stereo"},
|
||||
{3, "Stereo"}, // Function only distinguishes 1 vs other
|
||||
{6, "Stereo"},
|
||||
{0, "Stereo"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := channelName(tt.channels)
|
||||
if result != tt.expected {
|
||||
t.Errorf("channelName(%d) = %q, expected %q",
|
||||
tt.channels, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncQualityDisplay(t *testing.T) {
|
||||
model := NewModel(Config{})
|
||||
|
||||
// Test different quality levels
|
||||
// Note: quality is only applied when SyncRTT is non-zero
|
||||
qualities := []sync.Quality{
|
||||
sync.QualityGood,
|
||||
sync.QualityDegraded,
|
||||
sync.QualityLost,
|
||||
}
|
||||
|
||||
for _, q := range qualities {
|
||||
model.applyStatus(StatusMsg{
|
||||
SyncQuality: q,
|
||||
SyncRTT: 1000, // Must include RTT for quality to be applied
|
||||
})
|
||||
|
||||
if model.syncQuality != q {
|
||||
t.Errorf("quality not updated to %v", q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataClearing(t *testing.T) {
|
||||
model := NewModel(Config{})
|
||||
|
||||
// Set metadata
|
||||
model.applyStatus(StatusMsg{
|
||||
Title: "Song",
|
||||
Artist: "Artist",
|
||||
Album: "Album",
|
||||
})
|
||||
|
||||
// Clear metadata with empty strings
|
||||
model.applyStatus(StatusMsg{
|
||||
Title: "",
|
||||
Artist: "",
|
||||
Album: "",
|
||||
})
|
||||
|
||||
// Empty strings should not clear (only non-empty values are applied)
|
||||
if model.title != "Song" {
|
||||
t.Error("title should not be cleared by empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackStateApplication(t *testing.T) {
|
||||
model := NewModel(Config{})
|
||||
|
||||
// Initial state should be idle
|
||||
if model.playbackState != "idle" {
|
||||
t.Errorf("expected initial playbackState 'idle', got '%s'", model.playbackState)
|
||||
}
|
||||
|
||||
// Apply playing state
|
||||
model.applyStatus(StatusMsg{PlaybackState: "playing"})
|
||||
if model.playbackState != "playing" {
|
||||
t.Errorf("expected playbackState 'playing', got '%s'", model.playbackState)
|
||||
}
|
||||
|
||||
// Apply paused state
|
||||
model.applyStatus(StatusMsg{PlaybackState: "paused"})
|
||||
if model.playbackState != "paused" {
|
||||
t.Errorf("expected playbackState 'paused', got '%s'", model.playbackState)
|
||||
}
|
||||
|
||||
// Empty string should not overwrite
|
||||
model.applyStatus(StatusMsg{PlaybackState: ""})
|
||||
if model.playbackState != "paused" {
|
||||
t.Errorf("expected playbackState to remain 'paused', got '%s'", model.playbackState)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportKeyToggle(t *testing.T) {
|
||||
tc := NewTransportControl()
|
||||
model := NewModel(Config{TransportCtrl: tc})
|
||||
model.width = 80
|
||||
model.height = 24
|
||||
|
||||
// Simulate space key
|
||||
msg := fakeKeyMsg(" ")
|
||||
model.handleKey(msg)
|
||||
|
||||
select {
|
||||
case cmd := <-tc.Commands:
|
||||
if cmd.Command != "toggle" {
|
||||
t.Errorf("expected 'toggle' command, got '%s'", cmd.Command)
|
||||
}
|
||||
default:
|
||||
t.Error("expected toggle command on transport channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportKeyNext(t *testing.T) {
|
||||
tc := NewTransportControl()
|
||||
model := NewModel(Config{TransportCtrl: tc})
|
||||
model.width = 80
|
||||
model.height = 24
|
||||
|
||||
msg := fakeKeyMsg("n")
|
||||
model.handleKey(msg)
|
||||
|
||||
select {
|
||||
case cmd := <-tc.Commands:
|
||||
if cmd.Command != "next" {
|
||||
t.Errorf("expected 'next' command, got '%s'", cmd.Command)
|
||||
}
|
||||
default:
|
||||
t.Error("expected next command on transport channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportKeyPrevious(t *testing.T) {
|
||||
tc := NewTransportControl()
|
||||
model := NewModel(Config{TransportCtrl: tc})
|
||||
model.width = 80
|
||||
model.height = 24
|
||||
|
||||
msg := fakeKeyMsg("p")
|
||||
model.handleKey(msg)
|
||||
|
||||
select {
|
||||
case cmd := <-tc.Commands:
|
||||
if cmd.Command != "previous" {
|
||||
t.Errorf("expected 'previous' command, got '%s'", cmd.Command)
|
||||
}
|
||||
default:
|
||||
t.Error("expected previous command on transport channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportKeyReconnect(t *testing.T) {
|
||||
tc := NewTransportControl()
|
||||
model := NewModel(Config{TransportCtrl: tc})
|
||||
model.width = 80
|
||||
model.height = 24
|
||||
|
||||
msg := fakeKeyMsg("r")
|
||||
model.handleKey(msg)
|
||||
|
||||
select {
|
||||
case cmd := <-tc.Commands:
|
||||
if cmd.Command != "reconnect" {
|
||||
t.Errorf("expected 'reconnect' command, got '%s'", cmd.Command)
|
||||
}
|
||||
default:
|
||||
t.Error("expected reconnect command on transport channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportNilSafe(t *testing.T) {
|
||||
// Transport keys should not panic when transportCtrl is nil
|
||||
model := NewModel(Config{})
|
||||
model.width = 80
|
||||
model.height = 24
|
||||
|
||||
// These should not panic
|
||||
model.handleKey(fakeKeyMsg(" "))
|
||||
model.handleKey(fakeKeyMsg("n"))
|
||||
model.handleKey(fakeKeyMsg("p"))
|
||||
model.handleKey(fakeKeyMsg("r"))
|
||||
}
|
||||
|
||||
func TestPlaybackStateDisplay(t *testing.T) {
|
||||
tests := []struct {
|
||||
state string
|
||||
expected string
|
||||
}{
|
||||
{"playing", "\u25b6 Playing"},
|
||||
{"paused", "\u23f8 Paused"},
|
||||
{"stopped", "\u23f9 Stopped"},
|
||||
{"idle", "\u25cf Idle"},
|
||||
{"reconnecting", "\u21bb Reconnecting..."},
|
||||
{"unknown", "\u25cf Idle"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := playbackStateDisplay(tt.state)
|
||||
if result != tt.expected {
|
||||
t.Errorf("playbackStateDisplay(%q) = %q, expected %q",
|
||||
tt.state, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSampleRate(t *testing.T) {
|
||||
tests := []struct {
|
||||
rate int
|
||||
expected string
|
||||
}{
|
||||
{44100, "44.1kHz"},
|
||||
{48000, "48kHz"},
|
||||
{96000, "96kHz"},
|
||||
{192000, "192kHz"},
|
||||
{88200, "88.2kHz"},
|
||||
{176400, "176.4kHz"},
|
||||
{32000, "32kHz"},
|
||||
{22050, "22.1kHz"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := formatSampleRate(tt.rate)
|
||||
if result != tt.expected {
|
||||
t.Errorf("formatSampleRate(%d) = %q, expected %q",
|
||||
tt.rate, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCodecDisplay(t *testing.T) {
|
||||
tests := []struct {
|
||||
codec string
|
||||
sampleRate int
|
||||
bitDepth int
|
||||
expected string
|
||||
}{
|
||||
{"pcm", 192000, 24, "PCM 192kHz/24bit lossless"},
|
||||
{"opus", 48000, 16, "OPUS 48kHz/16bit"},
|
||||
{"flac", 48000, 24, "FLAC 48kHz/24bit lossless"},
|
||||
{"flac", 44100, 16, "FLAC 44.1kHz/16bit lossless"},
|
||||
{"aac", 44100, 16, "AAC 44.1kHz/16bit"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := formatCodecDisplay(tt.codec, tt.sampleRate, tt.bitDepth)
|
||||
if result != tt.expected {
|
||||
t.Errorf("formatCodecDisplay(%q, %d, %d) = %q, expected %q",
|
||||
tt.codec, tt.sampleRate, tt.bitDepth, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fakeKeyMsg creates a tea.KeyMsg for testing.
|
||||
func fakeKeyMsg(key string) tea.KeyMsg {
|
||||
if key == " " {
|
||||
return tea.KeyMsg{Type: tea.KeySpace, Runes: []rune(key)}
|
||||
}
|
||||
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}
|
||||
}
|
||||
|
||||
// NOTE: TestConcurrentStatusUpdates was removed because Bubble Tea
|
||||
// guarantees sequential Update() calls - the Model is never accessed
|
||||
// concurrently in real usage, so testing concurrent access is unrealistic.
|
||||
Reference in New Issue
Block a user