🎉 live server seems to be working now
This commit is contained in:
274
third_party/sendspin-go/internal/ui/device_picker_test.go
vendored
Normal file
274
third_party/sendspin-go/internal/ui/device_picker_test.go
vendored
Normal file
@@ -0,0 +1,274 @@
|
||||
// ABOUTME: Tests for the device-picker state machine — pure functions, no TUI loop required
|
||||
package ui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/audio/output"
|
||||
)
|
||||
|
||||
func fixtureDevices() []output.PlaybackDevice {
|
||||
return []output.PlaybackDevice{
|
||||
{Name: "USB Audio"},
|
||||
{Name: "Built-in Analog", IsDefault: true},
|
||||
{Name: "HDMI"},
|
||||
}
|
||||
}
|
||||
|
||||
func listOK() ([]output.PlaybackDevice, error) {
|
||||
return fixtureDevices(), nil
|
||||
}
|
||||
|
||||
func TestNewDevicePicker_SeedsIndexFromCurrent(t *testing.T) {
|
||||
s := newDevicePicker(listOK, "HDMI", true)
|
||||
if s.index != 2 {
|
||||
t.Errorf("index = %d, want 2 (HDMI)", s.index)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDevicePicker_SeedsIndexFromDefaultWhenCurrentMissing(t *testing.T) {
|
||||
s := newDevicePicker(listOK, "No such device", true)
|
||||
if s.index != 1 {
|
||||
t.Errorf("index = %d, want 1 (the IsDefault entry)", s.index)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDevicePicker_SeedsZeroWhenNoDefaultAndNoCurrent(t *testing.T) {
|
||||
listNoDefault := func() ([]output.PlaybackDevice, error) {
|
||||
return []output.PlaybackDevice{
|
||||
{Name: "One"},
|
||||
{Name: "Two"},
|
||||
}, nil
|
||||
}
|
||||
s := newDevicePicker(listNoDefault, "", true)
|
||||
if s.index != 0 {
|
||||
t.Errorf("index = %d, want 0", s.index)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDevicePicker_PropagatesLoadError(t *testing.T) {
|
||||
listErr := func() ([]output.PlaybackDevice, error) {
|
||||
return nil, errors.New("no audio stack")
|
||||
}
|
||||
s := newDevicePicker(listErr, "", true)
|
||||
if s.loadErr == nil {
|
||||
t.Error("loadErr should be set when enumerator fails")
|
||||
}
|
||||
if s.selected() != nil {
|
||||
t.Error("selected() should be nil when load failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePickerKey_UpDownClamp(t *testing.T) {
|
||||
s := newDevicePicker(listOK, "USB Audio", true) // index 0
|
||||
|
||||
// Up at the top clamps.
|
||||
s, act := s.handlePickerKey("up")
|
||||
if s.index != 0 || act != pickerNoop {
|
||||
t.Errorf("up at top: index=%d act=%d", s.index, act)
|
||||
}
|
||||
|
||||
// Two downs.
|
||||
s, _ = s.handlePickerKey("down")
|
||||
s, _ = s.handlePickerKey("down")
|
||||
if s.index != 2 {
|
||||
t.Fatalf("after 2 downs, index = %d, want 2", s.index)
|
||||
}
|
||||
|
||||
// Down at the bottom clamps.
|
||||
s, _ = s.handlePickerKey("down")
|
||||
if s.index != 2 {
|
||||
t.Errorf("down at bottom: index = %d, want 2", s.index)
|
||||
}
|
||||
|
||||
// One up.
|
||||
s, _ = s.handlePickerKey("up")
|
||||
if s.index != 1 {
|
||||
t.Errorf("up from 2: index = %d, want 1", s.index)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePickerKey_EnterSavesWhenEnabled(t *testing.T) {
|
||||
s := newDevicePicker(listOK, "", true)
|
||||
_, act := s.handlePickerKey("enter")
|
||||
if act != pickerSave {
|
||||
t.Errorf("enter action = %d, want pickerSave", act)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePickerKey_EnterNoopWhenSaveDisabled(t *testing.T) {
|
||||
s := newDevicePicker(listOK, "", false)
|
||||
_, act := s.handlePickerKey("enter")
|
||||
if act != pickerNoop {
|
||||
t.Errorf("enter with save disabled action = %d, want pickerNoop", act)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePickerKey_EnterNoopOnEmptyList(t *testing.T) {
|
||||
s := newDevicePicker(func() ([]output.PlaybackDevice, error) { return nil, nil }, "", true)
|
||||
_, act := s.handlePickerKey("enter")
|
||||
if act != pickerNoop {
|
||||
t.Errorf("enter on empty action = %d, want pickerNoop", act)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePickerKey_EscAndQClose(t *testing.T) {
|
||||
for _, key := range []string{"esc", "q"} {
|
||||
s := newDevicePicker(listOK, "", true)
|
||||
_, act := s.handlePickerKey(key)
|
||||
if act != pickerClose {
|
||||
t.Errorf("%q action = %d, want pickerClose", key, act)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePickerKey_UnknownPropagates(t *testing.T) {
|
||||
s := newDevicePicker(listOK, "", true)
|
||||
_, act := s.handlePickerKey("x")
|
||||
if act != pickerPropagate {
|
||||
t.Errorf("unknown key action = %d, want pickerPropagate", act)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelected_ReturnsCurrentRow(t *testing.T) {
|
||||
s := newDevicePicker(listOK, "HDMI", true)
|
||||
got := s.selected()
|
||||
if got == nil {
|
||||
t.Fatal("selected() = nil")
|
||||
}
|
||||
if got.Name != "HDMI" {
|
||||
t.Errorf("selected().Name = %q, want HDMI", got.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPicker_ShowsDefaultAndCurrentAnnotations(t *testing.T) {
|
||||
s := newDevicePicker(listOK, "HDMI", true)
|
||||
out := renderPicker(s, 80)
|
||||
// Built-in Analog carries (default), HDMI carries (current).
|
||||
if !strings.Contains(out, "Built-in Analog (default)") {
|
||||
t.Errorf("expected '(default)' annotation; got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "HDMI (current)") {
|
||||
t.Errorf("expected '(current)' annotation on HDMI; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPicker_AnnotatesBothWhenCurrentIsDefault(t *testing.T) {
|
||||
s := newDevicePicker(listOK, "Built-in Analog", true)
|
||||
out := renderPicker(s, 80)
|
||||
if !strings.Contains(out, "Built-in Analog (default, current)") {
|
||||
t.Errorf("expected combined annotation; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPicker_EmptyAndErrorStates(t *testing.T) {
|
||||
empty := newDevicePicker(func() ([]output.PlaybackDevice, error) { return nil, nil }, "", true)
|
||||
emptyOut := renderPicker(empty, 80)
|
||||
if !strings.Contains(emptyOut, "no playback devices found") {
|
||||
t.Errorf("empty state did not render expected message; got:\n%s", emptyOut)
|
||||
}
|
||||
|
||||
errState := newDevicePicker(func() ([]output.PlaybackDevice, error) {
|
||||
return nil, errors.New("enumeration boom")
|
||||
}, "", true)
|
||||
errOut := renderPicker(errState, 80)
|
||||
if !strings.Contains(errOut, "enumeration boom") {
|
||||
t.Errorf("error state did not render error; got:\n%s", errOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPicker_SaveUnavailableHint(t *testing.T) {
|
||||
s := newDevicePicker(listOK, "", false)
|
||||
out := renderPicker(s, 80)
|
||||
if !strings.Contains(out, "save unavailable") {
|
||||
t.Errorf("expected 'save unavailable' hint when saveEnabled=false; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// The two tests below integrate picker state with the Model's key dispatch
|
||||
// to verify the end-to-end "press o → navigate → save" flow without
|
||||
// standing up a full bubbletea runtime.
|
||||
|
||||
func TestModel_OKeyOpensPicker(t *testing.T) {
|
||||
m := NewModel(Config{ConfigPath: "/tmp/test-player.yaml"})
|
||||
m.listPlaybackDevices = listOK
|
||||
m.width = 80
|
||||
|
||||
next, _ := m.handleKey(fakeKeyMsg("o"))
|
||||
nm := next.(Model)
|
||||
if nm.mode != modeDevicePicker {
|
||||
t.Errorf("mode = %v, want modeDevicePicker", nm.mode)
|
||||
}
|
||||
if len(nm.picker.devices) != 3 {
|
||||
t.Errorf("picker.devices len = %d, want 3", len(nm.picker.devices))
|
||||
}
|
||||
}
|
||||
|
||||
func TestModel_EnterInPickerSavesAndClosesModal(t *testing.T) {
|
||||
var savedPath, savedKey, savedValue string
|
||||
writer := func(path, key, value string) error {
|
||||
savedPath, savedKey, savedValue = path, key, value
|
||||
return nil
|
||||
}
|
||||
|
||||
// Seed the current device so the picker opens highlighted on "USB Audio"
|
||||
// (index 0). One "down" then lands on the IsDefault entry at index 1.
|
||||
m := NewModel(Config{ConfigPath: "/tmp/test-player.yaml", AudioDevice: "USB Audio"})
|
||||
m.listPlaybackDevices = listOK
|
||||
m.writeConfigKey = writer
|
||||
m.width = 80
|
||||
|
||||
// Open picker.
|
||||
next, _ := m.handleKey(fakeKeyMsg("o"))
|
||||
m = next.(Model)
|
||||
|
||||
// Navigate down to Built-in Analog (index 1).
|
||||
next, _ = m.handleKey(fakeKeyMsg("down"))
|
||||
m = next.(Model)
|
||||
|
||||
// Enter saves. Returns a Cmd (the 3s expire tick); we don't invoke it.
|
||||
next, cmd := m.handleKey(fakeKeyMsg("enter"))
|
||||
m = next.(Model)
|
||||
|
||||
if m.mode != modeNormal {
|
||||
t.Errorf("mode after save = %v, want modeNormal", m.mode)
|
||||
}
|
||||
if savedPath != "/tmp/test-player.yaml" || savedKey != "audio_device" || savedValue != "Built-in Analog" {
|
||||
t.Errorf("write recorded (%q, %q, %q); want (/tmp/test-player.yaml, audio_device, Built-in Analog)",
|
||||
savedPath, savedKey, savedValue)
|
||||
}
|
||||
if m.transientMsg == "" {
|
||||
t.Error("transient banner should be set after save")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("save should schedule a transient-expire tick")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModel_EnterWhenSelectionMatchesSkipsWrite(t *testing.T) {
|
||||
writeCalled := false
|
||||
writer := func(path, key, value string) error {
|
||||
writeCalled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Current is the HDMI entry; picker seeds selection there.
|
||||
m := NewModel(Config{ConfigPath: "/tmp/test.yaml", AudioDevice: "HDMI"})
|
||||
m.listPlaybackDevices = listOK
|
||||
m.writeConfigKey = writer
|
||||
m.width = 80
|
||||
|
||||
next, _ := m.handleKey(fakeKeyMsg("o"))
|
||||
m = next.(Model)
|
||||
next, _ = m.handleKey(fakeKeyMsg("enter"))
|
||||
m = next.(Model)
|
||||
|
||||
if writeCalled {
|
||||
t.Error("writeConfigKey should not be called when selection matches current")
|
||||
}
|
||||
if m.mode != modeNormal {
|
||||
t.Errorf("mode = %v, want modeNormal", m.mode)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user