package ipc import ( "testing" "time" "github.com/bjarneo/cliamp/internal/playback" ) // captureDispatcher records the last message sent via Send and optionally // auto-replies on reply channels so handler cases with a Reply can be tested // without spinning up a real UI goroutine. type captureDispatcher struct { last any autoReply Response } func (c *captureDispatcher) Send(msg any) { c.last = msg switch m := msg.(type) { case LoadMsg: m.Reply <- c.autoReply case ThemeMsg: m.Reply <- c.autoReply case VisMsg: m.Reply <- c.autoReply case ShuffleMsg: m.Reply <- c.autoReply case RepeatMsg: m.Reply <- c.autoReply case MonoMsg: m.Reply <- c.autoReply case SpeedMsg: m.Reply <- c.autoReply case EQMsg: m.Reply <- c.autoReply case DeviceMsg: m.Reply <- c.autoReply case StatusRequestMsg: m.Reply <- c.autoReply } } func newTestServer(disp Dispatcher) *Server { return &Server{disp: disp, done: make(chan struct{})} } func TestDispatchSimpleCommands(t *testing.T) { tests := []struct { cmd string check func(t *testing.T, got any) okWant bool }{ {"play", func(t *testing.T, got any) { if _, ok := got.(PlayMsg); !ok { t.Errorf("got %T, want PlayMsg", got) } }, true}, {"pause", func(t *testing.T, got any) { if _, ok := got.(PauseMsg); !ok { t.Errorf("got %T, want PauseMsg", got) } }, true}, {"toggle", func(t *testing.T, got any) { if _, ok := got.(playback.PlayPauseMsg); !ok { t.Errorf("got %T, want playback.PlayPauseMsg", got) } }, true}, {"stop", func(t *testing.T, got any) { if _, ok := got.(playback.StopMsg); !ok { t.Errorf("got %T, want playback.StopMsg", got) } }, true}, {"next", func(t *testing.T, got any) { if _, ok := got.(playback.NextMsg); !ok { t.Errorf("got %T, want playback.NextMsg", got) } }, true}, {"prev", func(t *testing.T, got any) { if _, ok := got.(playback.PrevMsg); !ok { t.Errorf("got %T, want playback.PrevMsg", got) } }, true}, } for _, tt := range tests { t.Run(tt.cmd, func(t *testing.T) { disp := &captureDispatcher{} s := newTestServer(disp) resp := s.dispatch(Request{Cmd: tt.cmd}) if resp.OK != tt.okWant { t.Errorf("OK = %v, want %v (err=%q)", resp.OK, tt.okWant, resp.Error) } tt.check(t, disp.last) }) } } func TestDispatchUppercaseCommand(t *testing.T) { disp := &captureDispatcher{} s := newTestServer(disp) resp := s.dispatch(Request{Cmd: "PLAY"}) if !resp.OK { t.Fatalf("uppercase cmd PLAY should still be accepted, got err=%q", resp.Error) } if _, ok := disp.last.(PlayMsg); !ok { t.Errorf("got %T, want PlayMsg", disp.last) } } func TestDispatchVolume(t *testing.T) { disp := &captureDispatcher{} s := newTestServer(disp) resp := s.dispatch(Request{Cmd: "volume", Value: -3.5}) if !resp.OK { t.Fatalf("OK = false, err=%q", resp.Error) } got, ok := disp.last.(VolumeMsg) if !ok { t.Fatalf("got %T, want VolumeMsg", disp.last) } if got.DB != -3.5 { t.Errorf("DB = %f, want -3.5", got.DB) } } func TestDispatchSeek(t *testing.T) { disp := &captureDispatcher{} s := newTestServer(disp) resp := s.dispatch(Request{Cmd: "seek", Value: 1.5}) if !resp.OK { t.Fatalf("OK = false, err=%q", resp.Error) } got, ok := disp.last.(SeekMsg) if !ok { t.Fatalf("got %T, want SeekMsg", disp.last) } if got.Offset != 1500*time.Millisecond { t.Errorf("Offset = %v, want 1.5s", got.Offset) } } func TestDispatchLoadMissingPlaylist(t *testing.T) { s := newTestServer(&captureDispatcher{}) resp := s.dispatch(Request{Cmd: "load"}) if resp.OK { t.Error("load without playlist should return !OK") } if resp.Error == "" { t.Error("error message should be set") } } func TestDispatchLoadWithReply(t *testing.T) { disp := &captureDispatcher{autoReply: Response{OK: true, Playlist: "main"}} s := newTestServer(disp) resp := s.dispatch(Request{Cmd: "load", Playlist: "main"}) if !resp.OK { t.Fatalf("OK = false, err=%q", resp.Error) } if resp.Playlist != "main" { t.Errorf("Playlist = %q, want main", resp.Playlist) } } func TestDispatchQueueMissingPath(t *testing.T) { s := newTestServer(&captureDispatcher{}) resp := s.dispatch(Request{Cmd: "queue"}) if resp.OK { t.Error("queue without path should return !OK") } } func TestDispatchQueue(t *testing.T) { disp := &captureDispatcher{} s := newTestServer(disp) resp := s.dispatch(Request{Cmd: "queue", Path: "/music/song.mp3"}) if !resp.OK { t.Fatalf("OK = false, err=%q", resp.Error) } got, ok := disp.last.(QueueMsg) if !ok { t.Fatalf("got %T, want QueueMsg", disp.last) } if got.Path != "/music/song.mp3" { t.Errorf("Path = %q, want /music/song.mp3", got.Path) } } func TestDispatchThemeMissingName(t *testing.T) { s := newTestServer(&captureDispatcher{}) resp := s.dispatch(Request{Cmd: "theme"}) if resp.OK { t.Error("theme without name should return !OK") } } func TestDispatchThemeWithReply(t *testing.T) { disp := &captureDispatcher{autoReply: Response{OK: true}} s := newTestServer(disp) resp := s.dispatch(Request{Cmd: "theme", Name: "dracula"}) if !resp.OK { t.Fatalf("OK = false, err=%q", resp.Error) } got, ok := disp.last.(ThemeMsg) if !ok { t.Fatalf("got %T, want ThemeMsg", disp.last) } if got.Name != "dracula" { t.Errorf("Name = %q, want dracula", got.Name) } } func TestDispatchVisMissingName(t *testing.T) { s := newTestServer(&captureDispatcher{}) resp := s.dispatch(Request{Cmd: "vis"}) if resp.OK { t.Error("vis without name should return !OK") } } func TestDispatchVisWithReply(t *testing.T) { disp := &captureDispatcher{autoReply: Response{OK: true, Visualizer: "bars"}} s := newTestServer(disp) resp := s.dispatch(Request{Cmd: "vis", Name: "bars"}) if !resp.OK { t.Fatalf("OK = false, err=%q", resp.Error) } if resp.Visualizer != "bars" { t.Errorf("Visualizer = %q, want bars", resp.Visualizer) } } func TestDispatchShuffle(t *testing.T) { trueBool := true disp := &captureDispatcher{autoReply: Response{OK: true, Shuffle: &trueBool}} s := newTestServer(disp) resp := s.dispatch(Request{Cmd: "shuffle", Name: "on"}) if !resp.OK { t.Fatalf("OK = false, err=%q", resp.Error) } got, ok := disp.last.(ShuffleMsg) if !ok { t.Fatalf("got %T, want ShuffleMsg", disp.last) } if got.Name != "on" { t.Errorf("Name = %q, want on", got.Name) } } func TestDispatchRepeat(t *testing.T) { disp := &captureDispatcher{autoReply: Response{OK: true, Repeat: "all"}} s := newTestServer(disp) resp := s.dispatch(Request{Cmd: "repeat", Name: "all"}) if !resp.OK { t.Fatalf("OK = false, err=%q", resp.Error) } if _, ok := disp.last.(RepeatMsg); !ok { t.Errorf("got %T, want RepeatMsg", disp.last) } } func TestDispatchMono(t *testing.T) { trueBool := true disp := &captureDispatcher{autoReply: Response{OK: true, Mono: &trueBool}} s := newTestServer(disp) resp := s.dispatch(Request{Cmd: "mono", Name: "toggle"}) if !resp.OK { t.Fatalf("OK = false, err=%q", resp.Error) } if _, ok := disp.last.(MonoMsg); !ok { t.Errorf("got %T, want MonoMsg", disp.last) } } func TestDispatchSpeedInvalid(t *testing.T) { tests := []float64{0, -1, -0.5} for _, v := range tests { s := newTestServer(&captureDispatcher{}) resp := s.dispatch(Request{Cmd: "speed", Value: v}) if resp.OK { t.Errorf("speed with value=%v should return !OK", v) } } } func TestDispatchSpeedValid(t *testing.T) { disp := &captureDispatcher{autoReply: Response{OK: true, Speed: 1.25}} s := newTestServer(disp) resp := s.dispatch(Request{Cmd: "speed", Value: 1.25}) if !resp.OK { t.Fatalf("OK = false, err=%q", resp.Error) } got, ok := disp.last.(SpeedMsg) if !ok { t.Fatalf("got %T, want SpeedMsg", disp.last) } if got.Speed != 1.25 { t.Errorf("Speed = %f, want 1.25", got.Speed) } } func TestDispatchEQ(t *testing.T) { disp := &captureDispatcher{autoReply: Response{OK: true}} s := newTestServer(disp) resp := s.dispatch(Request{Cmd: "eq", Name: "rock", Band: 3, Value: 4.5}) if !resp.OK { t.Fatalf("OK = false, err=%q", resp.Error) } got, ok := disp.last.(EQMsg) if !ok { t.Fatalf("got %T, want EQMsg", disp.last) } if got.Name != "rock" || got.Band != 3 || got.Value != 4.5 { t.Errorf("EQMsg = %+v, want Name=rock Band=3 Value=4.5", got) } } func TestDispatchDeviceMissingName(t *testing.T) { s := newTestServer(&captureDispatcher{}) resp := s.dispatch(Request{Cmd: "device"}) if resp.OK { t.Error("device without name should return !OK") } } func TestDispatchDevice(t *testing.T) { disp := &captureDispatcher{autoReply: Response{OK: true, Device: "alsa:default"}} s := newTestServer(disp) resp := s.dispatch(Request{Cmd: "device", Name: "list"}) if !resp.OK { t.Fatalf("OK = false, err=%q", resp.Error) } if resp.Device != "alsa:default" { t.Errorf("Device = %q, want alsa:default", resp.Device) } } func TestDispatchStatus(t *testing.T) { disp := &captureDispatcher{autoReply: Response{OK: true, State: "playing", Position: 30}} s := newTestServer(disp) resp := s.dispatch(Request{Cmd: "status"}) if !resp.OK { t.Fatalf("OK = false, err=%q", resp.Error) } if resp.State != "playing" { t.Errorf("State = %q, want playing", resp.State) } if _, ok := disp.last.(StatusRequestMsg); !ok { t.Errorf("got %T, want StatusRequestMsg", disp.last) } } func TestDispatchUnknownCmd(t *testing.T) { s := newTestServer(&captureDispatcher{}) resp := s.dispatch(Request{Cmd: "dostuff"}) if resp.OK { t.Error("unknown cmd should return !OK") } if resp.Error == "" { t.Error("error should be set for unknown cmd") } } // deadDispatcher never replies on the Reply channel — triggers the timeout branch. type deadDispatcher struct{} func (deadDispatcher) Send(msg any) {} func TestDispatchReplyTimeout(t *testing.T) { // Replace time.After with a fast timeout to keep this test cheap. // The production code uses 3s; a test using real time would be slow, // so we exercise the shutdown-path via s.done instead. s := newTestServer(deadDispatcher{}) close(s.done) // simulate server shutting down tests := []Request{ {Cmd: "load", Playlist: "main"}, {Cmd: "theme", Name: "dracula"}, {Cmd: "vis", Name: "bars"}, {Cmd: "shuffle", Name: "on"}, {Cmd: "repeat", Name: "all"}, {Cmd: "mono", Name: "toggle"}, {Cmd: "speed", Value: 1.0}, {Cmd: "eq", Name: "rock"}, {Cmd: "device", Name: "list"}, {Cmd: "status"}, } for _, r := range tests { t.Run(r.Cmd, func(t *testing.T) { resp := s.dispatch(r) if resp.OK { t.Errorf("%s should return !OK during shutdown", r.Cmd) } if resp.Error == "" { t.Errorf("%s should set error message during shutdown", r.Cmd) } }) } }