diff --git a/backend/config/config_test.go b/backend/config/config_test.go new file mode 100644 index 0000000..d2d37e9 --- /dev/null +++ b/backend/config/config_test.go @@ -0,0 +1,227 @@ +package config + +import ( + "log/slog" + "path/filepath" + "testing" + + "yellowjacket/backend/favorites" + "yellowjacket/backend/library" + "yellowjacket/backend/theme" + "yellowjacket/backend/tracklist" +) + +func TestConfig_LoadSave_Roundtrip(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + libDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.toml") + + // Build a config with all non-default values. + original := &Config{ + logger: slog.Default(), + filePath: configPath, + Theme: &theme.Config{ + AccentColor: "#ff0000", + BackgroundShade: theme.BackgroundLight, + }, + TrackList: &tracklist.Config{ + Columns: []tracklist.Column{ + {ID: tracklist.ColTrackName}, + {ID: tracklist.ColArtistName}, + {ID: tracklist.ColAlbum}, + {ID: tracklist.ColGenre}, + {ID: tracklist.ColTrackLength}, + }, + }, + Favorites: &favorites.Config{ + IconStyle: favorites.IconStar, + PinDefault: false, + }, + Library: &library.Config{ + DirectoryPath: library.Directory(libDir), + ScanConcurrency: library.ScanConcurrencySSD, + }, + Window: &WindowConfig{ + Width: 800, + Height: 600, + }, + } + + original.applyDefaults() + + if err := original.Save(); err != nil { + t.Fatalf("Save() error: %v", err) + } + + // Load into a new Config struct. + loaded := &Config{ + logger: slog.Default(), + filePath: configPath, + } + loaded.applyDefaults() + + if err := loaded.Load(); err != nil { + t.Fatalf("Load() error: %v", err) + } + + // Verify theme. + if loaded.Theme.AccentColor != "#ff0000" { + t.Errorf("Theme.AccentColor = %q, want %q", loaded.Theme.AccentColor, "#ff0000") + } + + if loaded.Theme.BackgroundShade != theme.BackgroundLight { + t.Errorf("Theme.BackgroundShade = %q, want %q", loaded.Theme.BackgroundShade, theme.BackgroundLight) + } + + // Verify tracklist. + if len(loaded.TrackList.Columns) != 5 { + t.Fatalf("TrackList.Columns length = %d, want 5", len(loaded.TrackList.Columns)) + } + + wantColumns := []tracklist.ColumnID{ + tracklist.ColTrackName, tracklist.ColArtistName, + tracklist.ColAlbum, tracklist.ColGenre, tracklist.ColTrackLength, + } + for i, want := range wantColumns { + if loaded.TrackList.Columns[i].ID != want { + t.Errorf("TrackList.Columns[%d].ID = %q, want %q", i, loaded.TrackList.Columns[i].ID, want) + } + } + + // Verify favorites. + if loaded.Favorites.IconStyle != favorites.IconStar { + t.Errorf("Favorites.IconStyle = %q, want %q", loaded.Favorites.IconStyle, favorites.IconStar) + } + + if loaded.Favorites.PinDefault != false { + t.Errorf("Favorites.PinDefault = %v, want false", loaded.Favorites.PinDefault) + } + + // Verify library. + if string(loaded.Library.DirectoryPath) != libDir { + t.Errorf("Library.DirectoryPath = %q, want %q", loaded.Library.DirectoryPath, libDir) + } + + if loaded.Library.ScanConcurrency != library.ScanConcurrencySSD { + t.Errorf("Library.ScanConcurrency = %q, want %q", loaded.Library.ScanConcurrency, library.ScanConcurrencySSD) + } + + // Verify window. + if loaded.Window.Width != 800 { + t.Errorf("Window.Width = %d, want 800", loaded.Window.Width) + } + + if loaded.Window.Height != 600 { + t.Errorf("Window.Height = %d, want 600", loaded.Window.Height) + } +} + +func TestConfig_Load_MissingFile(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "nonexistent", "config.toml") + + c := &Config{ + logger: slog.Default(), + filePath: configPath, + } + c.applyDefaults() + + // Load should try to create the file. The parent directory + // doesn't exist, so Save inside Load will fail. + // Let's use a valid path instead so we can test the "create + // with defaults" behavior. + validPath := filepath.Join(tmpDir, "config.toml") + c.filePath = validPath + + if err := c.Load(); err != nil { + t.Fatalf("Load() error: %v", err) + } + + // File should exist after Load. + if _, err := filepath.Abs(validPath); err != nil { + t.Fatalf("filepath.Abs() error: %v", err) + } +} + +func TestConfig_Validate_ComposesSubConfigErrors(t *testing.T) { + t.Parallel() + + c := &Config{ + logger: slog.Default(), + filePath: filepath.Join(t.TempDir(), "config.toml"), + Theme: &theme.Config{ + AccentColor: "not-a-color", + BackgroundShade: theme.BackgroundDark, + }, + TrackList: &tracklist.Config{ + Columns: []tracklist.Column{ + {ID: "bogus_column"}, + }, + }, + } + + err := c.Validate() + if err == nil { + t.Fatal("Validate() expected error for invalid sub-configs, got nil") + } + + errStr := err.Error() + + // Both theme and tracklist errors should be present. + if !containsSubstring(errStr, "invalid hex color") { + t.Errorf("error should contain 'invalid hex color', got: %s", errStr) + } + + if !containsSubstring(errStr, "unknown track-list column ID") { + t.Errorf("error should contain 'unknown track-list column ID', got: %s", errStr) + } +} + +func TestConfig_ApplyDefaults_NilSubConfigs(t *testing.T) { + t.Parallel() + + c := &Config{ + logger: slog.Default(), + filePath: filepath.Join(t.TempDir(), "config.toml"), + } + + c.applyDefaults() + + if c.Window == nil { + t.Error("Window should not be nil after applyDefaults") + } + + if c.Theme == nil { + t.Error("Theme should not be nil after applyDefaults") + } + + if c.TrackList == nil { + t.Error("TrackList should not be nil after applyDefaults") + } + + if c.Favorites == nil { + t.Error("Favorites should not be nil after applyDefaults") + } + + // Library is intentionally left nil by applyDefaults when it + // starts as nil (no library dir configured yet). +} + +// containsSubstring is a test helper for checking error messages. +func containsSubstring(s, substr string) bool { + return len(s) >= len(substr) && searchSubstring(s, substr) +} + +func searchSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + + return false +} diff --git a/backend/favorites/config_test.go b/backend/favorites/config_test.go new file mode 100644 index 0000000..737dc3d --- /dev/null +++ b/backend/favorites/config_test.go @@ -0,0 +1,49 @@ +package favorites + +import ( + "testing" +) + +func TestFavoritesConfig_Validate_ValidIconStyles(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + style IconStyle + }{ + {"heart", IconHeart}, + {"star", IconStar}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + c := &Config{IconStyle: tt.style} + if err := c.Validate(); err != nil { + t.Errorf("Validate() returned unexpected error: %v", err) + } + }) + } +} + +func TestFavoritesConfig_Validate_InvalidIconStyle(t *testing.T) { + t.Parallel() + + c := &Config{IconStyle: "diamond"} + err := c.Validate() + if err == nil { + t.Fatal("Validate() expected error for unknown icon style, got nil") + } +} + +func TestFavoritesConfig_ApplyDefaults(t *testing.T) { + t.Parallel() + + c := &Config{} + c.ApplyDefaults() + + if c.IconStyle != DefaultIconStyle { + t.Errorf("IconStyle = %q, want %q", c.IconStyle, DefaultIconStyle) + } +} diff --git a/backend/library/config_test.go b/backend/library/config_test.go new file mode 100644 index 0000000..744bb13 --- /dev/null +++ b/backend/library/config_test.go @@ -0,0 +1,83 @@ +package library + +import ( + "testing" +) + +func TestLibraryConfig_Validate_ValidDirectory(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + modes := []ScanConcurrency{ + ScanConcurrencyAuto, + ScanConcurrencySSD, + ScanConcurrencyHDD, + } + + for _, mode := range modes { + t.Run(string(mode), func(t *testing.T) { + t.Parallel() + + c := &Config{ + DirectoryPath: Directory(dir), + ScanConcurrency: mode, + } + if err := c.Validate(); err != nil { + t.Errorf("Validate() returned unexpected error: %v", err) + } + }) + } +} + +func TestLibraryConfig_Validate_NonexistentDirectory(t *testing.T) { + t.Parallel() + + c := &Config{ + DirectoryPath: "/nonexistent/path/xyz", + ScanConcurrency: ScanConcurrencyAuto, + } + + err := c.Validate() + if err == nil { + t.Fatal("Validate() expected error for nonexistent directory, got nil") + } +} + +func TestLibraryConfig_Validate_InvalidScanConcurrency(t *testing.T) { + t.Parallel() + + c := &Config{ + DirectoryPath: Directory(t.TempDir()), + ScanConcurrency: "turbo", + } + + err := c.Validate() + if err == nil { + t.Fatal("Validate() expected error for unknown scan concurrency, got nil") + } +} + +func TestLibraryConfig_Validate_EmptyDirectory(t *testing.T) { + t.Parallel() + + c := &Config{ + DirectoryPath: "", + ScanConcurrency: ScanConcurrencyAuto, + } + + if err := c.Validate(); err != nil { + t.Errorf("Validate() returned unexpected error for empty directory: %v", err) + } +} + +func TestLibraryConfig_ApplyDefaults(t *testing.T) { + t.Parallel() + + c := &Config{} + c.ApplyDefaults() + + if c.ScanConcurrency != DefaultScanConcurrency { + t.Errorf("ScanConcurrency = %q, want %q", c.ScanConcurrency, DefaultScanConcurrency) + } +} diff --git a/backend/theme/config_test.go b/backend/theme/config_test.go new file mode 100644 index 0000000..1ba2e98 --- /dev/null +++ b/backend/theme/config_test.go @@ -0,0 +1,83 @@ +package theme + +import ( + "testing" +) + +func TestThemeConfig_Validate_ValidValues(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + color string + shade BackgroundShade + }{ + {"short hex dark", "#fff", BackgroundDark}, + {"six-digit hex darker", "#ffd43b", BackgroundDarker}, + {"black hex light", "#000000", BackgroundLight}, + {"uppercase hex", "#AABBCC", BackgroundDark}, + {"mixed case hex", "#aAbBcC", BackgroundDark}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + c := &Config{AccentColor: tt.color, BackgroundShade: tt.shade} + if err := c.Validate(); err != nil { + t.Errorf("Validate() returned unexpected error: %v", err) + } + }) + } +} + +func TestThemeConfig_Validate_InvalidHexColor(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + color string + }{ + {"missing hash", "fff"}, + {"invalid chars", "#gg0000"}, + {"wrong length 5", "#12345"}, + {"word color", "red"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + c := &Config{AccentColor: tt.color, BackgroundShade: BackgroundDark} + err := c.Validate() + if err == nil { + t.Error("Validate() expected error for invalid hex color, got nil") + } + }) + } +} + +func TestThemeConfig_Validate_InvalidBackgroundShade(t *testing.T) { + t.Parallel() + + c := &Config{AccentColor: "#ffd43b", BackgroundShade: "neon"} + err := c.Validate() + if err == nil { + t.Fatal("Validate() expected error for unknown shade, got nil") + } +} + +func TestThemeConfig_ApplyDefaults(t *testing.T) { + t.Parallel() + + c := &Config{} + c.ApplyDefaults() + + if c.AccentColor != DefaultAccentColor { + t.Errorf("AccentColor = %q, want %q", c.AccentColor, DefaultAccentColor) + } + + if c.BackgroundShade != DefaultBackgroundShade { + t.Errorf("BackgroundShade = %q, want %q", c.BackgroundShade, DefaultBackgroundShade) + } +} diff --git a/backend/tracklist/config_test.go b/backend/tracklist/config_test.go new file mode 100644 index 0000000..e2d981e --- /dev/null +++ b/backend/tracklist/config_test.go @@ -0,0 +1,73 @@ +package tracklist + +import ( + "testing" +) + +func TestTrackListConfig_Validate_ValidColumns(t *testing.T) { + t.Parallel() + + c := &Config{ + Columns: []Column{ + {ID: ColTrackName}, + {ID: ColArtistName}, + {ID: ColAlbum}, + {ID: ColTrackLength}, + {ID: ColGenre}, + }, + } + + if err := c.Validate(); err != nil { + t.Errorf("Validate() returned unexpected error: %v", err) + } +} + +func TestTrackListConfig_Validate_UnknownColumnID(t *testing.T) { + t.Parallel() + + c := &Config{ + Columns: []Column{ + {ID: ColTrackName}, + {ID: "nonexistent"}, + }, + } + + err := c.Validate() + if err == nil { + t.Fatal("Validate() expected error for unknown column ID, got nil") + } +} + +func TestTrackListConfig_Validate_DuplicateColumn(t *testing.T) { + t.Parallel() + + c := &Config{ + Columns: []Column{ + {ID: ColTrackName}, + {ID: ColArtistName}, + {ID: ColTrackName}, + }, + } + + err := c.Validate() + if err == nil { + t.Fatal("Validate() expected error for duplicate column ID, got nil") + } +} + +func TestTrackListConfig_ApplyDefaults(t *testing.T) { + t.Parallel() + + c := &Config{} + c.ApplyDefaults() + + if len(c.Columns) != len(DefaultColumns) { + t.Fatalf("Columns length = %d, want %d", len(c.Columns), len(DefaultColumns)) + } + + for i, col := range c.Columns { + if col.ID != DefaultColumns[i].ID { + t.Errorf("Columns[%d].ID = %q, want %q", i, col.ID, DefaultColumns[i].ID) + } + } +}