package mixer import ( "testing" "time" "rhemawavetech.com/rhemawave/internal/db" ) // Regression tests for critical audio bugs (v0.5) // Regression: Stop() used to return early when AudioEnabled was false, // leaving music playing after the user toggled audio off mid-playback. func TestStopCleansUpWhenAudioDisabled(t *testing.T) { mu.Lock() wasInited := inited inited = true deviceOpen = false currentPath = "/some/track.wav" currentLoops = -1 mu.Unlock() defer func() { mu.Lock() inited = wasInited currentPath = "" currentLoops = 0 mu.Unlock() }() cfg := &db.AppConfig{AudioEnabled: false} Stop(0, cfg) mu.Lock() defer mu.Unlock() if currentPath != "" { t.Errorf("currentPath = %q, want empty after Stop with AudioEnabled=false", currentPath) } if currentLoops != 0 { t.Errorf("currentLoops = %d, want 0 after Stop with AudioEnabled=false", currentLoops) } } // Regression: SwitchDevice used to convert oldDevice="" to "system default" // and pass that string to openDeviceLocked, which SDL does not recognize. func TestSwitchDeviceReturnsErrorWhenUninitialized(t *testing.T) { mu.Lock() wasInited := inited inited = false deviceOpen = false mu.Unlock() defer func() { mu.Lock() inited = wasInited mu.Unlock() }() cfg := &db.AppConfig{AudioEnabled: true, SavedVolume: 0.5} err := SwitchDevice("Some Device", cfg) if err == nil { t.Fatal("SwitchDevice on uninitialized mixer should return error") } } // Regression: SwitchDevice with nil config must not panic when attempting // to restore volume after reopening the device. func TestSwitchDeviceNilConfigDoesNotPanic(t *testing.T) { mu.Lock() wasInited := inited inited = false deviceOpen = false mu.Unlock() defer func() { mu.Lock() inited = wasInited mu.Unlock() }() err := SwitchDevice("Some Device", nil) if err == nil { t.Fatal("SwitchDevice on uninitialized mixer should return error") } } // Regression: SwitchDevice with empty device name (system default) should // not be converted to "system default" before passing to openDeviceLocked. func TestSwitchDeviceAcceptsEmptyDeviceName(t *testing.T) { mu.Lock() wasInited := inited inited = false deviceOpen = false mu.Unlock() defer func() { mu.Lock() inited = wasInited mu.Unlock() }() cfg := &db.AppConfig{SavedVolume: 0.75} err := SwitchDevice("", cfg) // Returns error because mixer is not initialized, but should not panic // and should handle the empty name gracefully. if err == nil { t.Fatal("SwitchDevice on uninitialized mixer should return error") } } // TestPlayDoesNotDeadlockWhenUninitialized verifies that Play() returns an error // (not a deadlock) when the mixer has not been initialized. // The old code would deadlock because Play() held mu then called SwitchDevice() // which also tried to acquire mu. func TestPlayDoesNotDeadlockWhenUninitialized(t *testing.T) { // Ensure mixer is in uninitialized state mu.Lock() wasInited := inited inited = false deviceOpen = false mu.Unlock() defer func() { mu.Lock() inited = wasInited mu.Unlock() }() cfg := &db.AppConfig{AudioEnabled: true} done := make(chan error, 1) go func() { done <- Play("nonexistent.wav", false, cfg) }() select { case err := <-done: if err == nil { t.Fatal("Play() returned nil error on uninitialized mixer, want error") } case <-time.After(5 * time.Second): t.Fatal("Play() deadlocked on uninitialized mixer (did not return within 5s)") } } func TestParseSinkLine(t *testing.T) { tests := []struct { name string input string wantOK bool wantID int wantName string wantVol float64 wantDef bool }{ { name: "default sink with volume", input: "*51. USB Audio [vol: 1.00]", wantOK: true, wantID: 51, wantName: "USB Audio", wantVol: 1.00, wantDef: true, }, { name: "non-default sink with volume", input: "33. Built-in Audio Analog Stereo [vol: 0.65]", wantOK: true, wantID: 33, wantName: "Built-in Audio Analog Stereo", wantVol: 0.65, wantDef: false, }, { name: "default sink with muted tag", input: "*51. USB Audio [vol: 0.50 MUTED]", wantOK: true, wantID: 51, wantName: "USB Audio", wantVol: 0.50, wantDef: true, }, { name: "sink without volume field", input: "33. Built-in Audio Analog Stereo", wantOK: true, wantID: 33, wantName: "Built-in Audio Analog Stereo", wantVol: 0.0, wantDef: false, }, { name: "empty string", input: "", wantOK: false, }, { name: "no dot separator", input: "no dot here", wantOK: false, }, { name: "non-numeric ID", input: "abc. Some Device [vol: 0.80]", wantOK: false, }, { name: "zero volume", input: "1. Muted Device [vol: 0.00]", wantOK: true, wantID: 1, wantName: "Muted Device", wantVol: 0.0, wantDef: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { sink, ok := parseSinkLine(tt.input) if ok != tt.wantOK { t.Fatalf("parseSinkLine(%q) ok = %v, want %v", tt.input, ok, tt.wantOK) } if !ok { return } if sink.ID != tt.wantID { t.Errorf("ID = %d, want %d", sink.ID, tt.wantID) } if sink.Name != tt.wantName { t.Errorf("Name = %q, want %q", sink.Name, tt.wantName) } if sink.Volume != tt.wantVol { t.Errorf("Volume = %f, want %f", sink.Volume, tt.wantVol) } if sink.IsDefault != tt.wantDef { t.Errorf("IsDefault = %v, want %v", sink.IsDefault, tt.wantDef) } }) } } func TestParseSinks(t *testing.T) { tests := []struct { name string input string wantCount int wantNames []string }{ { name: "two sinks", input: `Audio Sinks: *51. USB Audio [vol: 1.00] 33. Built-in Audio Analog Stereo [vol: 0.65] Sources: 52. Microphone [vol: 0.80]`, wantCount: 2, wantNames: []string{"USB Audio", "Built-in Audio Analog Stereo"}, }, { name: "no sinks section", input: `Audio Sources: 52. Microphone [vol: 0.80]`, wantCount: 0, }, { name: "sinks with tree characters", input: `Audio ├─Sinks: │ ├─*51. USB Audio [vol: 1.00] │ └─33. Built-in Audio Analog Stereo [vol: 0.65] └─Sources: └─52. Microphone [vol: 0.80]`, wantCount: 2, wantNames: []string{"USB Audio", "Built-in Audio Analog Stereo"}, }, { name: "empty input", input: ``, wantCount: 0, }, { name: "video section ignored", input: `Video Sinks: 100. HDMI Output [vol: 1.00] Audio Sinks: 51. USB Audio [vol: 1.00]`, wantCount: 1, wantNames: []string{"USB Audio"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { sinks, err := parseSinks([]byte(tt.input)) if err != nil { t.Fatalf("parseSinks returned error: %v", err) } if len(sinks) != tt.wantCount { t.Fatalf("got %d sinks, want %d", len(sinks), tt.wantCount) } for i, name := range tt.wantNames { if sinks[i].Name != name { t.Errorf("sink[%d].Name = %q, want %q", i, sinks[i].Name, name) } } }) } }