275 lines
7.9 KiB
Go
275 lines
7.9 KiB
Go
// 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)
|
|
}
|
|
}
|