Files
yellowjacket/.planning/phases/04-queue-config-player-tests/04-02-PLAN.md
T

301 lines
14 KiB
Markdown

---
phase: 04-queue-config-player-tests
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
- backend/config/config_test.go
- backend/theme/config_test.go
- backend/tracklist/config_test.go
- backend/favorites/config_test.go
- backend/library/config_test.go
- backend/player/volume_test.go
autonomous: true
requirements: [TEST-04, TEST-05]
must_haves:
truths:
- "Config load/save roundtrip preserves all fields without data loss"
- "Sub-config validators reject invalid values and accept valid ones"
- "Missing config file is handled gracefully (created with defaults)"
- "UserVolume↔Volume conversion is mathematically correct at all boundary values"
- "stateToMediaControls maps all player states correctly"
- "All config and player tests pass with -race flag"
artifacts:
- path: "backend/config/config_test.go"
provides: "Tests for Load/Save roundtrip, Validate composition, missing file handling, defaults"
min_lines: 80
- path: "backend/theme/config_test.go"
provides: "Tests for theme validation (hex color, background shade)"
min_lines: 40
- path: "backend/tracklist/config_test.go"
provides: "Tests for tracklist validation (valid/invalid/duplicate columns)"
min_lines: 40
- path: "backend/favorites/config_test.go"
provides: "Tests for favorites validation (icon style)"
min_lines: 30
- path: "backend/library/config_test.go"
provides: "Tests for library validation (directory existence, scan concurrency)"
min_lines: 40
- path: "backend/player/volume_test.go"
provides: "Tests for volume conversion, clamp, state mapping"
min_lines: 60
key_links:
- from: "backend/config/config_test.go"
to: "backend/config/config.go"
via: "Load/Save roundtrip with t.TempDir()"
pattern: "Save|Load"
- from: "backend/player/volume_test.go"
to: "backend/player/volume.go"
via: "ToVolume/ToUserVolume conversion"
pattern: "ToVolume|ToUserVolume"
---
<objective>
Write unit tests for the config package (including all sub-config validators) and player pure logic (volume conversion, state mapping).
Purpose: Config tests verify roundtrip fidelity and validation rules, which are essential before any config format changes. Player logic tests characterize the volume conversion math and state mapping as a safety net for any future player refactoring.
Output: 6 test files — 5 for config/sub-configs (~8-10 tests) and 1 for player (~5-6 tests).
</objective>
<execution_context>
@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
From backend/config/config.go:
```go
type Config struct {
ctx context.Context // unexported
logger *slog.Logger // unexported
filePath string // unexported — set by NewConfig or manually for tests
Library *library.Config `toml:"Library"`
Theme *theme.Config `toml:"Theme"`
Window *WindowConfig `toml:"Window"`
TrackList *tracklist.Config `toml:"TrackList"`
Favorites *favorites.Config `toml:"Favorites"`
}
func NewConfig(logger *slog.Logger) (*Config, error) // reads from system config dir — NOT usable in tests
func (c *Config) Validate() error // delegates to sub-configs
func (c *Config) Load() error // reads from c.filePath
func (c *Config) Save() error // writes to c.filePath with 0o644
func (c *Config) applyDefaults() // unexported — fills nil sub-configs
```
From backend/theme/config.go:
```go
type BackgroundShade string // "darker", "dark", "light"
type Config struct { AccentColor string; BackgroundShade BackgroundShade }
func (c *Config) ApplyDefaults()
func (c *Config) Validate() error // checks hex color regex + shade enum
const DefaultAccentColor = "#ffd43b"
const DefaultBackgroundShade = BackgroundDark
```
From backend/tracklist/config.go:
```go
type ColumnID string // 16 valid values
type Column struct { ID ColumnID }
type Config struct { Columns []Column }
func (c *Config) ApplyDefaults()
func (c *Config) Validate() error // checks valid IDs + no duplicates
var DefaultColumns = []Column{{ColTrackName}, {ColArtistName}, {ColTrackLength}}
```
From backend/favorites/config.go:
```go
type IconStyle string // "heart", "star"
type Config struct { PlaylistID int64; IconStyle; PinDefault bool }
func (c *Config) ApplyDefaults()
func (c *Config) Validate() error // checks icon style enum
const DefaultIconStyle = IconHeart
```
From backend/library/config.go:
```go
type ScanConcurrency string // "auto", "ssd", "hdd"
type Directory string
type Config struct { DirectoryPath Directory; ScanConcurrency }
func (c *Config) Validate() error // checks dir exists on filesystem + mode enum
const DefaultScanConcurrency = ScanConcurrencyAuto
```
From backend/player/volume.go:
```go
type UserVolume int // 0-100
type Volume float64 // -5 to 0
const MinUserVol UserVolume = 0, MaxUserVol = 100, DefaultUserVol = 50
const MinVol Volume = -5, MaxVol = 0
func (uv UserVolume) ToVolume() Volume
func (v Volume) ToUserVolume() UserVolume
func clampVolume(v UserVolume) UserVolume // unexported
```
From backend/player/player.go:
```go
type State string
const Playing State = "playing", Paused = "paused", Stopped = "stopped"
func stateToMediaControls(s State) mediacontrols.PlaybackState // unexported
```
From backend/mediacontrols/mediacontrols.go:
```go
type PlaybackState int
const StateStopped PlaybackState = 0, StatePlaying = 1, StatePaused = 2
```
From backend/config/window.go:
```go
type WindowConfig struct { Width int; Height int }
func NewDefaultWindowConfig() *WindowConfig // returns &WindowConfig{Width: 1024, Height: 768}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Config and sub-config validation tests</name>
<files>backend/config/config_test.go, backend/theme/config_test.go, backend/tracklist/config_test.go, backend/favorites/config_test.go, backend/library/config_test.go</files>
<action>
Create 5 test files for config and all sub-config packages. All use internal test packages (same package name). Follow established conventions: t.Parallel(), table-driven subtests, stdlib testing only, no assertion libraries, t.Helper() on helpers.
**backend/theme/config_test.go** (package theme) — ~3 tests:
- `TestThemeConfig_Validate_ValidValues` — table-driven: valid hex colors ("#fff", "#ffd43b", "#000000") with valid shades ("darker", "dark", "light") all pass
- `TestThemeConfig_Validate_InvalidHexColor` — table-driven: invalid colors ("fff", "#gg0000", "#12345", "red", "") all return error containing "invalid hex color"
- `TestThemeConfig_Validate_InvalidBackgroundShade` — shade "neon" returns error containing "unknown background shade"
- `TestThemeConfig_ApplyDefaults` — verify zero-value Config gets DefaultAccentColor and DefaultBackgroundShade
**backend/tracklist/config_test.go** (package tracklist) — ~3 tests:
- `TestTrackListConfig_Validate_ValidColumns` — valid column IDs pass
- `TestTrackListConfig_Validate_UnknownColumnID` — unknown ID returns error containing "unknown track-list column ID"
- `TestTrackListConfig_Validate_DuplicateColumn` — duplicate ID returns error containing "duplicate column ID"
- `TestTrackListConfig_ApplyDefaults` — verify zero-value Config gets DefaultColumns
**backend/favorites/config_test.go** (package favorites) — ~2-3 tests:
- `TestFavoritesConfig_Validate_ValidIconStyles` — table-driven: "heart", "star" both pass
- `TestFavoritesConfig_Validate_InvalidIconStyle` — "diamond" returns error containing "unknown favorites icon style"
- `TestFavoritesConfig_ApplyDefaults` — verify zero-value gets DefaultIconStyle
**backend/library/config_test.go** (package library) — ~3-4 tests:
- `TestLibraryConfig_Validate_ValidDirectory` — use t.TempDir() as directory, all scan concurrency modes ("auto", "ssd", "hdd") pass
- `TestLibraryConfig_Validate_NonexistentDirectory` — "/nonexistent/path/xyz" returns error
- `TestLibraryConfig_Validate_InvalidScanConcurrency` — "turbo" returns error containing "unknown scan concurrency"
- `TestLibraryConfig_Validate_EmptyDirectory` — empty DirectoryPath with valid scan concurrency passes (no dir check when empty)
- `TestLibraryConfig_ApplyDefaults` — verify zero-value ScanConcurrency gets DefaultScanConcurrency
**backend/config/config_test.go** (package config) — ~3-4 tests:
- `TestConfig_LoadSave_Roundtrip` — The critical roundtrip test:
1. Create Config struct directly with `filePath` set to `filepath.Join(t.TempDir(), "config.toml")`
2. Set all sub-configs to non-default values: theme accent "#ff0000", shade "light", tracklist columns with 5 columns, favorites icon "star", library directory set to a second t.TempDir(), library scan concurrency "ssd", window 800x600
3. Call applyDefaults() then Save()
4. Create NEW Config struct with same filePath, call Load()
5. Verify ALL fields match the original values
Note: Set `logger` to `slog.Default()` on the Config struct for both instances.
- `TestConfig_Load_MissingFile` — Config with filePath pointing to nonexistent file. Load() should create the file with defaults (current behavior). Verify file exists after Load().
- `TestConfig_Validate_ComposesSubConfigErrors` — Config with invalid theme (bad hex) AND invalid tracklist (unknown column) returns an error. Verify both error messages are present (errors.Join behavior).
- `TestConfig_ApplyDefaults_NilSubConfigs` — Config with all nil sub-configs, call applyDefaults(), verify all sub-configs are non-nil with sensible defaults.
For the roundtrip test, import sub-config packages: theme, tracklist, favorites, library. Access unexported fields (filePath, logger) directly since this is an internal test (package config).
</action>
<verify>
<automated>cd backend && go test -race -count=1 ./config/ ./theme/ ./tracklist/ ./favorites/ ./library/ -run "TestTheme|TestTrackList|TestFavorites|TestLibrary|TestConfig" -v 2>&amp;1 | tail -40</automated>
</verify>
<done>5 test files exist covering: theme hex+shade validation, tracklist column validation, favorites icon validation, library dir+concurrency validation, and config load/save roundtrip. All pass with -race.</done>
</task>
<task type="auto">
<name>Task 2: Player volume and state mapping tests</name>
<files>backend/player/volume_test.go</files>
<action>
Create volume_test.go in the player package (internal, package player). Follow established conventions: t.Parallel(), table-driven subtests, stdlib testing only.
Write these test functions:
- `TestUserVolume_ToVolume` — table-driven with cases:
| UserVolume | Expected Volume |
|------------|-----------------|
| 0 (MinUserVol) | -5.0 (MinVol) |
| 100 (MaxUserVol) | 0.0 (MaxVol) |
| 50 (DefaultUserVol) | -2.5 (midpoint) |
| 25 | -3.75 |
| 75 | -1.25 |
For each: verify `uv.ToVolume()` matches expected within a tolerance of 0.001 (use math.Abs for float comparison).
- `TestVolume_ToUserVolume` — table-driven with inverse cases:
| Volume | Expected UserVolume |
|--------|---------------------|
| -5.0 (MinVol) | 0 (MinUserVol) |
| 0.0 (MaxVol) | 100 (MaxUserVol) |
| -2.5 | 50 |
| -3.75 | 25 |
| -1.25 | 75 |
For each: verify `v.ToUserVolume()` matches expected exactly (int comparison).
- `TestUserVolume_ToVolume_OutOfRange` — table-driven: values outside [0,100] like -1, 101, 200, -50. Verify ToVolume() returns zero-value Volume (0.0) per current implementation (the `if` guard fails, returns uninitialized `newVol`).
- `TestVolume_ToUserVolume_OutOfRange` — values outside [-5,0] like -6.0, 1.0, -10.0. Verify ToUserVolume() returns zero-value UserVolume (0) per current implementation.
- `TestUserVolume_ToVolume_Roundtrip` — for every UserVolume from 0 to 100, convert to Volume and back. Verify roundtrip matches original value. This is the characterization test — if the math changes, this breaks.
- `TestClampVolume` — table-driven:
| Input | Expected |
|-------|----------|
| -10 | 0 (MinUserVol) |
| 0 | 0 |
| 50 | 50 |
| 100 | 100 |
| 150 | 100 (MaxUserVol) |
- `TestStateToMediaControls` — table-driven:
| State | Expected PlaybackState |
|-------|------------------------|
| Playing | mediacontrols.StatePlaying (1) |
| Paused | mediacontrols.StatePaused (2) |
| Stopped | mediacontrols.StateStopped (0) |
| State("unknown") | mediacontrols.StateStopped (0) — default case |
Import "yellowjacket/backend/mediacontrols" for the PlaybackState constants. Use `math` for float comparison tolerance.
</action>
<verify>
<automated>cd backend && go test -race -count=1 -run "TestUserVolume|TestVolume|TestClamp|TestState" ./player/ -v 2>&amp;1 | tail -20</automated>
</verify>
<done>volume_test.go has ~7 tests covering ToVolume/ToUserVolume conversion at all boundaries, out-of-range behavior, full roundtrip 0-100, clamp, and state mapping. All pass with -race.</done>
</task>
</tasks>
<verification>
```bash
cd backend && go test -race -count=1 ./config/ ./theme/ ./tracklist/ ./favorites/ ./library/ ./player/ -v
```
All config and player tests pass with -race flag. Expected ~15-17 tests total.
</verification>
<success_criteria>
- 5 config test files exist covering all sub-config validators + composed Config
- Config load/save roundtrip preserves all non-default values
- Missing config file handled gracefully
- volume_test.go exists with ~7 tests for volume conversion + state mapping
- ToVolume/ToUserVolume roundtrip is verified for all values 0-100
- All tests pass with `go test -race`
</success_criteria>
<output>
After completion, create `.planning/phases/04-queue-config-player-tests/04-02-SUMMARY.md`
</output>