package config import ( "os" "path/filepath" "strconv" "testing" ) func TestDefaultConfig(t *testing.T) { cfg := defaultConfig() if cfg.Repeat != "off" { t.Errorf("Repeat = %q, want off", cfg.Repeat) } if cfg.Speed != 1.0 { t.Errorf("Speed = %f, want 1.0", cfg.Speed) } if cfg.SeekStepLarge != 30 { t.Errorf("SeekStepLarge = %d, want 30", cfg.SeekStepLarge) } if cfg.SampleRate != 0 { t.Errorf("SampleRate = %d, want 0 (auto)", cfg.SampleRate) } if cfg.BufferMs != 100 { t.Errorf("BufferMs = %d, want 100", cfg.BufferMs) } if cfg.ResampleQuality != 4 { t.Errorf("ResampleQuality = %d, want 4", cfg.ResampleQuality) } if cfg.BitDepth != 16 { t.Errorf("BitDepth = %d, want 16", cfg.BitDepth) } if cfg.PaddingH != 3 { t.Errorf("PaddingH = %d, want 3", cfg.PaddingH) } if cfg.PaddingV != 1 { t.Errorf("PaddingV = %d, want 1", cfg.PaddingV) } if cfg.Spotify.Bitrate != 320 { t.Errorf("Spotify.Bitrate = %d, want 320", cfg.Spotify.Bitrate) } if cfg.AutoPlay { t.Error("AutoPlay should be false by default") } if cfg.Shuffle { t.Error("Shuffle should be false by default") } if cfg.Mono { t.Error("Mono should be false by default") } } func TestClampVolume(t *testing.T) { tests := []struct { name string vol float64 want float64 }{ {"within range", -10, -10}, {"too low", -60, -50}, {"too high", 20, 6}, {"min boundary", -50, -50}, {"max boundary", 6, 6}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := defaultConfig() cfg.Volume = tt.vol cfg.clamp() if cfg.Volume != tt.want { t.Errorf("Volume = %f, want %f", cfg.Volume, tt.want) } }) } } func TestClampVolumeMin(t *testing.T) { tests := []struct { name string min float64 want float64 }{ {"default", -50, -50}, {"custom valid", -70, -70}, {"too low", -100, -90}, {"too high positive", 5, 0}, {"zero boundary", 0, 0}, {"low boundary", -90, -90}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := defaultConfig() cfg.VolumeMin = tt.min cfg.clamp() if cfg.VolumeMin != tt.want { t.Errorf("VolumeMin = %f, want %f", cfg.VolumeMin, tt.want) } }) } } func TestVolumeClampedToVolumeMin(t *testing.T) { tests := []struct { name string volumeMin float64 volume float64 want float64 }{ {"below floor", -30, -40, -30}, {"at floor", -30, -30, -30}, {"above floor", -30, -10, -10}, {"custom floor -60", -60, -70, -60}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := defaultConfig() cfg.VolumeMin = tt.volumeMin cfg.Volume = tt.volume cfg.clamp() if cfg.Volume != tt.want { t.Errorf("Volume = %f, want %f", cfg.Volume, tt.want) } }) } } func TestClampSpeed(t *testing.T) { tests := []struct { name string speed float64 want float64 }{ {"normal", 1.0, 1.0}, {"valid slow", 0.5, 0.5}, {"valid fast", 1.5, 1.5}, {"too slow", 0.1, 1.0}, {"too fast", 3.0, 1.0}, {"min boundary", 0.25, 0.25}, {"max boundary", 2.0, 2.0}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := defaultConfig() cfg.Speed = tt.speed cfg.clamp() if cfg.Speed != tt.want { t.Errorf("Speed = %f, want %f", cfg.Speed, tt.want) } }) } } func TestClampSampleRate(t *testing.T) { tests := []struct { input int want int }{ {0, 0}, // auto-detect preserved {44100, 44100}, // exact match {48000, 48000}, {96000, 96000}, {30000, 22050}, // rounds to nearest {45000, 44100}, {50000, 48000}, {100000, 96000}, {200000, 192000}, } for _, tt := range tests { got := clampSampleRate(tt.input) if got != tt.want { t.Errorf("clampSampleRate(%d) = %d, want %d", tt.input, got, tt.want) } } } func TestClampBitDepth(t *testing.T) { tests := []struct { input int want int }{ {8, 16}, {16, 16}, {24, 32}, {32, 32}, } for _, tt := range tests { got := clampBitDepth(tt.input) if got != tt.want { t.Errorf("clampBitDepth(%d) = %d, want %d", tt.input, got, tt.want) } } } func TestClampSpotifyBitrate(t *testing.T) { tests := []struct { input int want int }{ {-1, 320}, {0, 320}, {96, 96}, {160, 160}, {320, 320}, {120, 96}, {128, 96}, {200, 160}, {240, 160}, {500, 320}, } for _, tt := range tests { got := clampSpotifyBitrate(tt.input) if got != tt.want { t.Errorf("clampSpotifyBitrate(%d) = %d, want %d", tt.input, got, tt.want) } } } func TestClampBufferMs(t *testing.T) { tests := []struct { input int want int }{ {100, 100}, {10, 50}, {600, 500}, {50, 50}, {500, 500}, } for _, tt := range tests { cfg := defaultConfig() cfg.BufferMs = tt.input cfg.clamp() if cfg.BufferMs != tt.want { t.Errorf("BufferMs(%d) = %d, want %d", tt.input, cfg.BufferMs, tt.want) } } } func TestClampResampleQuality(t *testing.T) { tests := []struct { input int want int }{ {0, 1}, {1, 1}, {4, 4}, {5, 4}, } for _, tt := range tests { cfg := defaultConfig() cfg.ResampleQuality = tt.input cfg.clamp() if cfg.ResampleQuality != tt.want { t.Errorf("ResampleQuality(%d) = %d, want %d", tt.input, cfg.ResampleQuality, tt.want) } } } func TestClampPadding(t *testing.T) { tests := []struct { name string inH, inV int wantH, wantV int }{ {"negative clamped to 0", -1, -1, 0, 0}, {"over max clamped", 20, 10, 10, 5}, {"within range", 3, 1, 3, 1}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := defaultConfig() cfg.PaddingH = tt.inH cfg.PaddingV = tt.inV cfg.clamp() if cfg.PaddingH != tt.wantH { t.Errorf("PaddingH = %d, want %d", cfg.PaddingH, tt.wantH) } if cfg.PaddingV != tt.wantV { t.Errorf("PaddingV = %d, want %d", cfg.PaddingV, tt.wantV) } }) } } func TestClampSeekStepLarge(t *testing.T) { tests := []struct { input int want int }{ {30, 30}, {1, 6}, {700, 600}, {6, 6}, {600, 600}, } for _, tt := range tests { cfg := defaultConfig() cfg.SeekStepLarge = tt.input cfg.clamp() if cfg.SeekStepLarge != tt.want { t.Errorf("SeekStepLarge(%d) = %d, want %d", tt.input, cfg.SeekStepLarge, tt.want) } } } func TestParseEQ(t *testing.T) { tests := []struct { name string val string want [10]float64 }{ { name: "all zeros", val: "[0, 0, 0, 0, 0, 0, 0, 0, 0, 0]", want: [10]float64{}, }, { name: "mixed values", val: "[3, -2, 0, 1.5, -5, 0, 0, 0, 0, 0]", want: [10]float64{3, -2, 0, 1.5, -5, 0, 0, 0, 0, 0}, }, { name: "clamped to range", val: "[15, -20, 0, 0, 0, 0, 0, 0, 0, 0]", want: [10]float64{12, -12, 0, 0, 0, 0, 0, 0, 0, 0}, }, { name: "fewer than 10", val: "[1, 2, 3]", want: [10]float64{1, 2, 3, 0, 0, 0, 0, 0, 0, 0}, }, { name: "empty", val: "[]", want: [10]float64{}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := parseEQ(tt.val) if got != tt.want { t.Errorf("parseEQ(%q) = %v, want %v", tt.val, got, tt.want) } }) } } func TestNavidromeIsSet(t *testing.T) { tests := []struct { name string cfg NavidromeConfig want bool }{ {"all set", NavidromeConfig{URL: "https://x", User: "u", Password: "p"}, true}, {"missing URL", NavidromeConfig{User: "u", Password: "p"}, false}, {"missing user", NavidromeConfig{URL: "https://x", Password: "p"}, false}, {"missing password", NavidromeConfig{URL: "https://x", User: "u"}, false}, {"all empty", NavidromeConfig{}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := tt.cfg.IsSet(); got != tt.want { t.Errorf("IsSet() = %v, want %v", got, tt.want) } }) } } func TestSpotifyIsSet(t *testing.T) { tests := []struct { name string cfg SpotifyConfig want bool }{ {"section + custom id", SpotifyConfig{Enabled: true, ClientID: "abc"}, true}, {"section only (uses fallback)", SpotifyConfig{Enabled: true}, true}, {"no section", SpotifyConfig{}, false}, {"disabled", SpotifyConfig{Disabled: true, Enabled: true, ClientID: "abc"}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := tt.cfg.IsSet(); got != tt.want { t.Errorf("IsSet() = %v, want %v", got, tt.want) } }) } } func TestSpotifyResolveClientID(t *testing.T) { const fallback = "FALLBACK" tests := []struct { name string cfg SpotifyConfig want string }{ {"user id wins", SpotifyConfig{ClientID: "user-id"}, "user-id"}, {"empty falls back", SpotifyConfig{}, fallback}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := tt.cfg.ResolveClientID(fallback); got != tt.want { t.Errorf("ResolveClientID() = %q, want %q", got, tt.want) } }) } } func TestLoadSpotifyBitrate(t *testing.T) { tests := []struct { name string bitrate int want int }{ {"exact supported value", 160, 160}, {"rounded to nearest supported value", 200, 160}, {"non-positive value", 0, 320}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Setenv("HOME", t.TempDir()) path := filepath.Join(os.Getenv("HOME"), ".config", "cliamp", "config.toml") if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("MkdirAll: %v", err) } data := []byte("[spotify]\nbitrate = " + strconv.Itoa(tt.bitrate) + "\n") if err := os.WriteFile(path, data, 0o644); err != nil { t.Fatalf("WriteFile: %v", err) } cfg, err := Load() if err != nil { t.Fatalf("Load() error = %v", err) } if cfg.Spotify.Bitrate != tt.want { t.Fatalf("Spotify.Bitrate = %d, want %d", cfg.Spotify.Bitrate, tt.want) } }) } } func TestQobuzIsSet(t *testing.T) { tests := []struct { name string cfg QobuzConfig want bool }{ {"section present", QobuzConfig{Enabled: true}, true}, {"explicitly disabled", QobuzConfig{Enabled: true, Disabled: true}, false}, {"absent", QobuzConfig{}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := tt.cfg.IsSet(); got != tt.want { t.Errorf("IsSet() = %v, want %v", got, tt.want) } }) } } func TestLoadQobuz(t *testing.T) { tests := []struct { name string body string wantIsSet bool wantQuality int }{ {"section enables, default quality", "[qobuz]\n", true, 6}, {"explicit quality", "[qobuz]\nquality = 27\n", true, 27}, {"disabled", "[qobuz]\nenabled = false\n", false, 6}, {"absent", "", false, 6}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Setenv("HOME", t.TempDir()) path := filepath.Join(os.Getenv("HOME"), ".config", "cliamp", "config.toml") if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("MkdirAll: %v", err) } if err := os.WriteFile(path, []byte(tt.body), 0o644); err != nil { t.Fatalf("WriteFile: %v", err) } cfg, err := Load() if err != nil { t.Fatalf("Load() error = %v", err) } if got := cfg.Qobuz.IsSet(); got != tt.wantIsSet { t.Errorf("Qobuz.IsSet() = %v, want %v", got, tt.wantIsSet) } if cfg.Qobuz.Quality != tt.wantQuality { t.Errorf("Qobuz.Quality = %d, want %d", cfg.Qobuz.Quality, tt.wantQuality) } }) } } func TestPlexIsSet(t *testing.T) { tests := []struct { name string cfg PlexConfig want bool }{ {"both set", PlexConfig{URL: "http://x", Token: "t"}, true}, {"no URL", PlexConfig{Token: "t"}, false}, {"no token", PlexConfig{URL: "http://x"}, false}, {"empty", PlexConfig{}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := tt.cfg.IsSet(); got != tt.want { t.Errorf("IsSet() = %v, want %v", got, tt.want) } }) } } func TestJellyfinIsSet(t *testing.T) { tests := []struct { name string cfg JellyfinConfig want bool }{ {"token auth", JellyfinConfig{URL: "http://x", Token: "t"}, true}, {"password auth", JellyfinConfig{URL: "http://x", User: "u", Password: "p"}, true}, {"no URL", JellyfinConfig{Token: "t"}, false}, {"no auth", JellyfinConfig{URL: "http://x"}, false}, {"user without password", JellyfinConfig{URL: "http://x", User: "u"}, false}, {"empty", JellyfinConfig{}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := tt.cfg.IsSet(); got != tt.want { t.Errorf("IsSet() = %v, want %v", got, tt.want) } }) } } func TestYouTubeMusicIsSetOrFallback(t *testing.T) { hasFallback := func() (string, string) { return "id", "secret" } noFallback := func() (string, string) { return "", "" } tests := []struct { name string cfg YouTubeMusicConfig fallbackFn func() (string, string) want bool }{ {"enabled section", YouTubeMusicConfig{Enabled: true}, nil, true}, {"disabled", YouTubeMusicConfig{Disabled: true}, hasFallback, false}, {"fallback available", YouTubeMusicConfig{}, hasFallback, true}, {"no fallback", YouTubeMusicConfig{}, noFallback, false}, {"nil fallback", YouTubeMusicConfig{}, nil, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := tt.cfg.IsSetOrFallback(tt.fallbackFn); got != tt.want { t.Errorf("IsSetOrFallback() = %v, want %v", got, tt.want) } }) } } func TestYouTubeMusicResolveCredentials(t *testing.T) { fallback := func() (string, string) { return "fb_id", "fb_secret" } tests := []struct { name string cfg YouTubeMusicConfig fallbackFn func() (string, string) wantID string wantSecret string }{ {"user credentials take priority", YouTubeMusicConfig{ClientID: "my_id", ClientSecret: "my_secret"}, fallback, "my_id", "my_secret"}, {"falls back when empty", YouTubeMusicConfig{}, fallback, "fb_id", "fb_secret"}, {"nil fallback returns empty", YouTubeMusicConfig{}, nil, "", ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { id, secret := tt.cfg.ResolveCredentials(tt.fallbackFn) if id != tt.wantID || secret != tt.wantSecret { t.Errorf("got (%q, %q), want (%q, %q)", id, secret, tt.wantID, tt.wantSecret) } }) } } func TestOverridesApply(t *testing.T) { cfg := defaultConfig() vol := -15.0 shuffle := true repeat := "all" mono := true theme := "dark" compact := true sr := 48000 play := true overrides := Overrides{ Volume: &vol, Shuffle: &shuffle, Repeat: &repeat, Mono: &mono, Theme: &theme, Compact: &compact, SampleRate: &sr, Play: &play, } overrides.Apply(&cfg) if cfg.Volume != -15 { t.Errorf("Volume = %f, want -15", cfg.Volume) } if !cfg.Shuffle { t.Error("Shuffle should be true") } if cfg.Repeat != "all" { t.Errorf("Repeat = %q, want all", cfg.Repeat) } if !cfg.Mono { t.Error("Mono should be true") } if cfg.Theme != "dark" { t.Errorf("Theme = %q, want dark", cfg.Theme) } if !cfg.Compact { t.Error("Compact should be true") } if cfg.SampleRate != 48000 { t.Errorf("SampleRate = %d, want 48000", cfg.SampleRate) } if !cfg.AutoPlay { t.Error("AutoPlay should be true") } } func TestOverridesApplyNil(t *testing.T) { cfg := defaultConfig() original := cfg overrides := Overrides{} // all nil overrides.Apply(&cfg) // Nothing should change except clamp effects if cfg.Volume != original.Volume { t.Error("nil overrides changed Volume") } if cfg.Shuffle != original.Shuffle { t.Error("nil overrides changed Shuffle") } } func TestOverridesApplyClamps(t *testing.T) { cfg := defaultConfig() vol := 100.0 // out of range overrides := Overrides{Volume: &vol} overrides.Apply(&cfg) if cfg.Volume != 6 { t.Errorf("Volume should be clamped to 6, got %f", cfg.Volume) } } func TestOverridesApplyLowPower(t *testing.T) { cfg := defaultConfig() cfg.Visualizer = "Bars" lowPower := true Overrides{LowPower: &lowPower}.Apply(&cfg) if !cfg.LowPower { t.Fatal("LowPower = false, want true") } if cfg.Visualizer != "none" { t.Fatalf("Visualizer = %q, want none", cfg.Visualizer) } } // Mock player for ApplyPlayer tests type mockPlayer struct { volumeMin float64 volume float64 speed float64 eq [10]float64 mono bool } func (m *mockPlayer) SetVolumeMin(db float64) { m.volumeMin = db } func (m *mockPlayer) SetVolume(db float64) { m.volume = db } func (m *mockPlayer) SetSpeed(ratio float64) { m.speed = ratio } func (m *mockPlayer) SetEQBand(band int, dB float64) { m.eq[band] = dB } func (m *mockPlayer) ToggleMono() { m.mono = !m.mono } func TestApplyPlayer(t *testing.T) { cfg := defaultConfig() cfg.VolumeMin = -70 cfg.Volume = -10 cfg.Speed = 1.5 cfg.EQ = [10]float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} cfg.EQPreset = "" // Custom cfg.Mono = true p := &mockPlayer{} cfg.ApplyPlayer(p) if p.volumeMin != -70 { t.Errorf("volumeMin = %f, want -70", p.volumeMin) } if p.volume != -10 { t.Errorf("volume = %f, want -10", p.volume) } if p.speed != 1.5 { t.Errorf("speed = %f, want 1.5", p.speed) } for i, want := range cfg.EQ { if p.eq[i] != want { t.Errorf("eq[%d] = %f, want %f", i, p.eq[i], want) } } if !p.mono { t.Error("mono should be true") } } func TestApplyPlayerDefaultSpeed(t *testing.T) { cfg := defaultConfig() cfg.Speed = 1.0 // default, should NOT call SetSpeed p := &mockPlayer{} cfg.ApplyPlayer(p) if p.speed != 0 { t.Errorf("speed = %f, should not have been set for 1.0x", p.speed) } } func TestApplyPlayerWithPreset(t *testing.T) { cfg := defaultConfig() cfg.EQPreset = "Rock" cfg.EQ = [10]float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} p := &mockPlayer{} cfg.ApplyPlayer(p) // With a preset set (not Custom/""), individual EQ bands should NOT be applied for i, v := range p.eq { if v != 0 { t.Errorf("eq[%d] = %f, want 0 (preset should skip band apply)", i, v) } } } // Mock playlist for ApplyPlaylist tests type mockPlaylist struct { repeatCycles int shuffled bool } func (m *mockPlaylist) CycleRepeat() { m.repeatCycles++ } func (m *mockPlaylist) ToggleShuffle() { m.shuffled = !m.shuffled } func TestApplyPlaylist(t *testing.T) { tests := []struct { name string repeat string shuffle bool wantCycles int wantShuffle bool }{ {"off no shuffle", "off", false, 0, false}, {"all", "all", false, 1, false}, {"one", "one", false, 2, false}, {"shuffle", "off", true, 0, true}, {"all + shuffle", "all", true, 1, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := defaultConfig() cfg.Repeat = tt.repeat cfg.Shuffle = tt.shuffle pl := &mockPlaylist{} cfg.ApplyPlaylist(pl) if pl.repeatCycles != tt.wantCycles { t.Errorf("repeat cycles = %d, want %d", pl.repeatCycles, tt.wantCycles) } if pl.shuffled != tt.wantShuffle { t.Errorf("shuffled = %v, want %v", pl.shuffled, tt.wantShuffle) } }) } }