🎉 live server seems to be working now
This commit is contained in:
397
third_party/sendspin-go/pkg/sendspin/config_test.go
vendored
Normal file
397
third_party/sendspin-go/pkg/sendspin/config_test.go
vendored
Normal file
@@ -0,0 +1,397 @@
|
||||
// ABOUTME: Tests for YAML config loading, env/file overlay precedence, and write-back round-trip
|
||||
package sendspin
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadPlayerConfig_ExplicitPathWithAllKeys(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "player.yaml")
|
||||
body := `# My config
|
||||
name: "Kitchen"
|
||||
server: "192.168.1.100:8927"
|
||||
port: 8999
|
||||
buffer_ms: 250
|
||||
static_delay_ms: 10
|
||||
log_file: "custom.log"
|
||||
no_tui: true
|
||||
stream_logs: false
|
||||
product_name: "Test Speaker"
|
||||
manufacturer: "Acme"
|
||||
no_reconnect: true
|
||||
daemon: false
|
||||
preferred_codec: "flac"
|
||||
buffer_capacity: 2097152
|
||||
client_id: "aa:bb:cc:dd:ee:ff"
|
||||
audio_device: "USB Audio Device"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
cfg, used, err := LoadPlayerConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if used != path {
|
||||
t.Errorf("used path = %q, want %q", used, path)
|
||||
}
|
||||
if cfg == nil {
|
||||
t.Fatal("cfg is nil")
|
||||
}
|
||||
if cfg.Name != "Kitchen" || cfg.Server != "192.168.1.100:8927" {
|
||||
t.Errorf("string keys: %+v", cfg)
|
||||
}
|
||||
if cfg.Port == nil || *cfg.Port != 8999 {
|
||||
t.Errorf("port = %v, want 8999", cfg.Port)
|
||||
}
|
||||
if cfg.NoTUI == nil || !*cfg.NoTUI {
|
||||
t.Errorf("no_tui = %v, want true", cfg.NoTUI)
|
||||
}
|
||||
if cfg.StreamLogs == nil || *cfg.StreamLogs {
|
||||
t.Errorf("stream_logs = %v, want false", cfg.StreamLogs)
|
||||
}
|
||||
if cfg.ClientID != "aa:bb:cc:dd:ee:ff" {
|
||||
t.Errorf("client_id = %q", cfg.ClientID)
|
||||
}
|
||||
if cfg.AudioDevice != "USB Audio Device" {
|
||||
t.Errorf("audio_device = %q", cfg.AudioDevice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPlayerConfig_MissingFileIsNotAnError(t *testing.T) {
|
||||
cfg, used, err := LoadPlayerConfig(filepath.Join(t.TempDir(), "does-not-exist.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if cfg != nil || used != "" {
|
||||
t.Errorf("expected nil/empty for missing file, got cfg=%v path=%q", cfg, used)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPlayerConfig_InvalidYAMLIsAnError(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "player.yaml")
|
||||
if err := os.WriteFile(path, []byte("not: valid: : yaml"), 0o600); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
_, _, err := LoadPlayerConfig(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected parse error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPlayerConfig_EnvPathHonored(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, "from-env.yaml")
|
||||
if err := os.WriteFile(envPath, []byte("name: EnvName\n"), 0o600); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
t.Setenv("SENDSPIN_PLAYER_CONFIG", envPath)
|
||||
|
||||
cfg, used, err := LoadPlayerConfig("")
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if used != envPath {
|
||||
t.Errorf("used = %q, want %q", used, envPath)
|
||||
}
|
||||
if cfg.Name != "EnvName" {
|
||||
t.Errorf("name = %q", cfg.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// applyPlayerFlags builds a flag.FlagSet mirroring the real player CLI so
|
||||
// ApplyEnvAndFile can be tested end-to-end.
|
||||
func newTestFlagSet() (*flag.FlagSet, map[string]*string, map[string]*int, map[string]*bool) {
|
||||
fs := flag.NewFlagSet("player", flag.ContinueOnError)
|
||||
strs := map[string]*string{
|
||||
"name": fs.String("name", "", "player name"),
|
||||
"server": fs.String("server", "", "server addr"),
|
||||
"log-file": fs.String("log-file", "default.log", "log file"),
|
||||
"product-name": fs.String("product-name", "", "product name"),
|
||||
"manufacturer": fs.String("manufacturer", "", "mfg"),
|
||||
"preferred-codec": fs.String("preferred-codec", "", "codec"),
|
||||
"client-id": fs.String("client-id", "", "client id"),
|
||||
}
|
||||
ints := map[string]*int{
|
||||
"port": fs.Int("port", 8927, "port"),
|
||||
"buffer-ms": fs.Int("buffer-ms", 150, "buffer ms"),
|
||||
"static-delay-ms": fs.Int("static-delay-ms", 0, "static delay"),
|
||||
"buffer-capacity": fs.Int("buffer-capacity", 1048576, "buffer cap"),
|
||||
"max-sample-rate": fs.Int("max-sample-rate", 0, "max sample rate"),
|
||||
"max-bit-depth": fs.Int("max-bit-depth", 0, "max bit depth"),
|
||||
}
|
||||
bools := map[string]*bool{
|
||||
"no-tui": fs.Bool("no-tui", false, "no tui"),
|
||||
"stream-logs": fs.Bool("stream-logs", false, "stream logs"),
|
||||
"no-reconnect": fs.Bool("no-reconnect", false, "no reconnect"),
|
||||
"daemon": fs.Bool("daemon", false, "daemon"),
|
||||
}
|
||||
return fs, strs, ints, bools
|
||||
}
|
||||
|
||||
func TestApplyEnvAndFile_FileFillsUnsetFlags(t *testing.T) {
|
||||
fs, strs, ints, bools := newTestFlagSet()
|
||||
// Simulate user passing only "-name Foo"
|
||||
if err := fs.Parse([]string{"-name", "CLI-Name"}); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
setByUser := map[string]bool{"name": true}
|
||||
|
||||
port := 9000
|
||||
noTUI := true
|
||||
cfg := &PlayerConfigFile{
|
||||
Server: "file.example:1234",
|
||||
Port: &port,
|
||||
NoTUI: &noTUI,
|
||||
}
|
||||
|
||||
if err := ApplyEnvAndFile(fs, setByUser, PlayerEnvPrefix, cfg.AsStringMap()); err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if *strs["name"] != "CLI-Name" {
|
||||
t.Errorf("CLI should win: name = %q", *strs["name"])
|
||||
}
|
||||
if *strs["server"] != "file.example:1234" {
|
||||
t.Errorf("file should fill: server = %q", *strs["server"])
|
||||
}
|
||||
if *ints["port"] != 9000 {
|
||||
t.Errorf("file should fill: port = %d", *ints["port"])
|
||||
}
|
||||
if !*bools["no-tui"] {
|
||||
t.Errorf("file should fill: no_tui = %v", *bools["no-tui"])
|
||||
}
|
||||
// Unset elsewhere keeps default
|
||||
if *ints["buffer-ms"] != 150 {
|
||||
t.Errorf("default should stick: buffer-ms = %d", *ints["buffer-ms"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnvAndFile_EnvBeatsFile(t *testing.T) {
|
||||
fs, strs, ints, _ := newTestFlagSet()
|
||||
if err := fs.Parse(nil); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
t.Setenv("SENDSPIN_PLAYER_SERVER", "env.example:9999")
|
||||
t.Setenv("SENDSPIN_PLAYER_PORT", "5555")
|
||||
|
||||
port := 9000
|
||||
cfg := &PlayerConfigFile{
|
||||
Server: "file.example:1234",
|
||||
Port: &port,
|
||||
}
|
||||
|
||||
if err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, cfg.AsStringMap()); err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if *strs["server"] != "env.example:9999" {
|
||||
t.Errorf("env should beat file: server = %q", *strs["server"])
|
||||
}
|
||||
if *ints["port"] != 5555 {
|
||||
t.Errorf("env should beat file: port = %d", *ints["port"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnvAndFile_NilConfigStillHonorsEnv(t *testing.T) {
|
||||
fs, strs, _, _ := newTestFlagSet()
|
||||
if err := fs.Parse(nil); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
t.Setenv("SENDSPIN_PLAYER_NAME", "env-only")
|
||||
|
||||
if err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, nil); err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if *strs["name"] != "env-only" {
|
||||
t.Errorf("name = %q, want env-only", *strs["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnvAndFile_InvalidEnvReturnsError(t *testing.T) {
|
||||
fs, _, _, _ := newTestFlagSet()
|
||||
if err := fs.Parse(nil); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
t.Setenv("SENDSPIN_PLAYER_PORT", "not-a-number")
|
||||
|
||||
err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on invalid env int")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "port") {
|
||||
t.Errorf("error should mention the offending flag: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyEnvAndFile_MaxSampleRateAndBitDepth covers the output-capability
|
||||
// override keys end-to-end: file value flows through, env beats file, CLI
|
||||
// beats env. Same precedence as every other key, but worth a dedicated test:
|
||||
// these are the operator's escape hatch when the auto-probe is wrong, and a
|
||||
// silent bug here means users can't override.
|
||||
func TestApplyEnvAndFile_MaxSampleRateAndBitDepth(t *testing.T) {
|
||||
t.Run("file fills both", func(t *testing.T) {
|
||||
fs, _, ints, _ := newTestFlagSet()
|
||||
if err := fs.Parse(nil); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
rate, depth := 48000, 16
|
||||
cfg := &PlayerConfigFile{MaxSampleRate: &rate, MaxBitDepth: &depth}
|
||||
if err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, cfg.AsStringMap()); err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if *ints["max-sample-rate"] != 48000 {
|
||||
t.Errorf("max-sample-rate = %d, want 48000", *ints["max-sample-rate"])
|
||||
}
|
||||
if *ints["max-bit-depth"] != 16 {
|
||||
t.Errorf("max-bit-depth = %d, want 16", *ints["max-bit-depth"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("env beats file", func(t *testing.T) {
|
||||
fs, _, ints, _ := newTestFlagSet()
|
||||
if err := fs.Parse(nil); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
t.Setenv("SENDSPIN_PLAYER_MAX_SAMPLE_RATE", "96000")
|
||||
rate := 48000
|
||||
cfg := &PlayerConfigFile{MaxSampleRate: &rate}
|
||||
if err := ApplyEnvAndFile(fs, map[string]bool{}, PlayerEnvPrefix, cfg.AsStringMap()); err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if *ints["max-sample-rate"] != 96000 {
|
||||
t.Errorf("env should beat file: max-sample-rate = %d", *ints["max-sample-rate"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CLI beats env and file", func(t *testing.T) {
|
||||
fs, _, ints, _ := newTestFlagSet()
|
||||
if err := fs.Parse([]string{"-max-sample-rate", "192000"}); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
setByUser := map[string]bool{"max-sample-rate": true}
|
||||
t.Setenv("SENDSPIN_PLAYER_MAX_SAMPLE_RATE", "96000")
|
||||
rate := 48000
|
||||
cfg := &PlayerConfigFile{MaxSampleRate: &rate}
|
||||
if err := ApplyEnvAndFile(fs, setByUser, PlayerEnvPrefix, cfg.AsStringMap()); err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if *ints["max-sample-rate"] != 192000 {
|
||||
t.Errorf("CLI should win: max-sample-rate = %d", *ints["max-sample-rate"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPlayerConfigFile_AsStringMap_OmitsUnsetCaps guards a subtle precedence
|
||||
// bug: if AsStringMap emitted "0" for an unset *int field, that "0" would
|
||||
// flow through ApplyEnvAndFile as an explicit value and clobber any flag
|
||||
// default, env override, or CLI flag the user actually set. The pointer-vs-
|
||||
// nil distinction in the YAML field is the only thing keeping the precedence
|
||||
// chain honest, and AsStringMap must respect it.
|
||||
func TestPlayerConfigFile_AsStringMap_OmitsUnsetCaps(t *testing.T) {
|
||||
cfg := &PlayerConfigFile{} // all fields nil/empty
|
||||
m := cfg.AsStringMap()
|
||||
if _, ok := m["max_sample_rate"]; ok {
|
||||
t.Error("max_sample_rate should be absent when not set in YAML")
|
||||
}
|
||||
if _, ok := m["max_bit_depth"]; ok {
|
||||
t.Error("max_bit_depth should be absent when not set in YAML")
|
||||
}
|
||||
|
||||
// Set explicitly to zero — the user genuinely wants 0, which means
|
||||
// "no cap, auto-probe". Pointer is non-nil so the key DOES appear.
|
||||
zero := 0
|
||||
cfg = &PlayerConfigFile{MaxSampleRate: &zero, MaxBitDepth: &zero}
|
||||
m = cfg.AsStringMap()
|
||||
if got := m["max_sample_rate"]; got != "0" {
|
||||
t.Errorf("explicit zero should round-trip; got %q", got)
|
||||
}
|
||||
if got := m["max_bit_depth"]; got != "0" {
|
||||
t.Errorf("explicit zero should round-trip; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStringKey_CreatesFileWithSingleKey(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "nested", "player.yaml")
|
||||
|
||||
if err := WriteStringKey(path, "client_id", "aa:bb:cc:dd:ee:ff"); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
cfg, _, err := LoadPlayerConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load after write: %v", err)
|
||||
}
|
||||
if cfg.ClientID != "aa:bb:cc:dd:ee:ff" {
|
||||
t.Errorf("client_id = %q", cfg.ClientID)
|
||||
}
|
||||
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
|
||||
t.Errorf("tempfile should be cleaned up: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStringKey_UpdatesExistingKeyPreservingOthersAndComments(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "player.yaml")
|
||||
initial := `# Living room speaker
|
||||
name: "Living Room"
|
||||
server: "192.168.1.100:8927"
|
||||
client_id: "old-id"
|
||||
buffer_ms: 250
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(initial), 0o600); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
if err := WriteStringKey(path, "client_id", "new-id"); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
text := string(got)
|
||||
|
||||
if !strings.Contains(text, `client_id: "new-id"`) && !strings.Contains(text, `client_id: new-id`) {
|
||||
t.Errorf("client_id not updated in file:\n%s", text)
|
||||
}
|
||||
if strings.Contains(text, "old-id") {
|
||||
t.Errorf("old value still present:\n%s", text)
|
||||
}
|
||||
// Other keys preserved.
|
||||
for _, want := range []string{"Living Room", "192.168.1.100:8927", "buffer_ms"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("missing preserved content %q in:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
// The leading comment survives round-trip.
|
||||
if !strings.Contains(text, "# Living room speaker") {
|
||||
t.Errorf("leading comment lost:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStringKey_AppendsKeyWhenAbsent(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "player.yaml")
|
||||
initial := "name: Kitchen\n"
|
||||
if err := os.WriteFile(path, []byte(initial), 0o600); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
if err := WriteStringKey(path, "client_id", "new-value"); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
cfg, _, err := LoadPlayerConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if cfg.Name != "Kitchen" {
|
||||
t.Errorf("original key lost: name = %q", cfg.Name)
|
||||
}
|
||||
if cfg.ClientID != "new-value" {
|
||||
t.Errorf("new key missing: client_id = %q", cfg.ClientID)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user