// 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.