"""Tests for the config module (config.py).""" import json import os import tempfile from pathlib import Path from unittest import mock import pytest # We import the module under test after patching CONFIG_DIR so that # the module-level SETTINGS dict is loaded from a temp location. @pytest.fixture(autouse=True) def _patch_config_paths(monkeypatch, tmp_path): """Redirect CONFIG_DIR to a tmp_path so tests don't touch ~/.config.""" config_dir = tmp_path / ".config" / "tunetti" monkeypatch.setattr("config.CONFIG_DIR", config_dir) monkeypatch.setattr("config.CONFIG_FILE", config_dir / "config.json") monkeypatch.setattr("config._LEGACY_FILE", tmp_path / "tunetti_config.json") # Also reset SETTINGS and DEFAULT_CONFIG after patching import config as cfg cfg.SETTINGS = cfg.load_config() return cfg # ── Tests ───────────────────────────────────────────────────────────────────── class TestLoadConfig: def test_first_launch_creates_defaults(self, _patch_config_paths): """On first launch, no config exists → defaults are written to disk.""" cfg = _patch_config_paths assert cfg.CONFIG_FILE.exists() assert cfg.SETTINGS["volume"] == 50 assert cfg.SETTINGS["max_history"] == 5000 assert cfg.SETTINGS["discord_rpc_enabled"] is True def test_loads_existing_config(self, _patch_config_paths): """Loading an existing config merges user values over defaults.""" cfg = _patch_config_paths cfg.CONFIG_DIR.mkdir(parents=True, exist_ok=True) user_config = {"volume": 75, "max_history": 100} cfg.CONFIG_FILE.write_text(json.dumps(user_config)) loaded = cfg.load_config() assert loaded["volume"] == 75 assert loaded["max_history"] == 100 # default keys still present assert loaded["discord_rpc_enabled"] is True def test_corrupt_config_falls_back_to_defaults(self, _patch_config_paths): """Corrupt JSON should be ignored and defaults returned.""" cfg = _patch_config_paths cfg.CONFIG_DIR.mkdir(parents=True, exist_ok=True) cfg.CONFIG_FILE.write_text("not valid json") loaded = cfg.load_config() assert loaded["volume"] == 50 # default # The corrupt file is not overwritten, but defaults are returned class TestMigrateLegacy: """Tests for the legacy config migration path.""" def test_legacy_migration(self, _patch_config_paths): """Legacy config in project dir is migrated to XDG location.""" cfg = _patch_config_paths # Remove the config that was created by the fixture so migration runs cfg.CONFIG_FILE.unlink(missing_ok=True) cfg.SETTINGS = dict(cfg.DEFAULT_CONFIG) legacy = {**cfg.DEFAULT_CONFIG, "volume": 90} # Legacy file location is the parent of the "real" config dir (tmp_path) cfg._LEGACY_FILE.write_text(json.dumps(legacy)) loaded = cfg.load_config() assert loaded["volume"] == 90 # Legacy file should be renamed assert not cfg._LEGACY_FILE.exists() assert cfg._LEGACY_FILE.with_suffix(".json.migrated").exists() def test_legacy_migration_corrupt_json_ignored(self, _patch_config_paths): """Corrupt legacy JSON is silently ignored (covers JSONDecodeError).""" cfg = _patch_config_paths cfg.CONFIG_FILE.unlink(missing_ok=True) cfg.SETTINGS = dict(cfg.DEFAULT_CONFIG) cfg._LEGACY_FILE.write_text("not valid json at all") loaded = cfg.load_config() # should not crash assert loaded["volume"] == 50 # defaults returned def test_legacy_oserror_handled_gracefully(self, _patch_config_paths, monkeypatch): """OSError during migration is silently ignored.""" cfg = _patch_config_paths cfg.CONFIG_FILE.unlink(missing_ok=True) cfg.SETTINGS = dict(cfg.DEFAULT_CONFIG) cfg._LEGACY_FILE.write_text(json.dumps({"volume": 80})) # Make ``os.rename`` fail (used by Path.rename) def _fail_rename(src, dst): raise OSError("Permission denied") monkeypatch.setattr(os, "rename", _fail_rename) # load_config should catch the OSError loaded = cfg.load_config() # save_config runs BEFORE the failing rename, so the value IS persisted assert loaded["volume"] == 80 def test_legacy_db_migration(self, _patch_config_paths): """Database file from legacy project dir is copied to XDG location.""" cfg = _patch_config_paths cfg.CONFIG_FILE.unlink(missing_ok=True) cfg.SETTINGS = dict(cfg.DEFAULT_CONFIG) # The migration only copies the db if old_db_path.parent matches # the config.py directory. Create the legacy db there. config_dir = Path(cfg.__file__).parent legacy_db = config_dir / "music_history.db" legacy_db.write_text("fake db content") legacy = {**cfg.DEFAULT_CONFIG, "db_path": str(legacy_db)} cfg._LEGACY_FILE.write_text(json.dumps(legacy)) loaded = cfg.load_config() # db_path should point to the new XDG location assert loaded["db_path"] == str(cfg.CONFIG_DIR / "music_history.db") assert cfg.CONFIG_DIR.exists() # Original file should still exist assert legacy_db.exists() # Clean up legacy_db.unlink(missing_ok=True) class TestSaveConfig: def test_save_config_persists(self, _patch_config_paths): """save_config writes merged config to disk.""" cfg = _patch_config_paths cfg.save_config({"volume": 30, "visualizer_enabled": False}) data = json.loads(cfg.CONFIG_FILE.read_text()) assert data["volume"] == 30 assert data["visualizer_enabled"] is False def test_save_volume_clamps(self, _patch_config_paths): """save_volume clamps values between 0 and 100.""" cfg = _patch_config_paths cfg.save_volume(-10) assert cfg.SETTINGS["volume"] == 0 cfg.save_volume(150) assert cfg.SETTINGS["volume"] == 100 cfg.save_volume(42) assert cfg.SETTINGS["volume"] == 42 def test_save_setting_updates_memory_and_disk(self, _patch_config_paths): """save_setting updates both in-memory SETTINGS and persisted config.""" cfg = _patch_config_paths cfg.save_setting("max_history", 999) assert cfg.SETTINGS["max_history"] == 999 data = json.loads(cfg.CONFIG_FILE.read_text()) assert data["max_history"] == 999 class TestGetSetting: def test_returns_set_value(self, _patch_config_paths): """get_setting returns the current in-memory value.""" cfg = _patch_config_paths cfg.SETTINGS["volume"] = 88 assert cfg.get_setting("volume") == 88 def test_returns_default_for_missing(self, _patch_config_paths): """get_setting falls back to DEFAULT_CONFIG for missing keys.""" cfg = _patch_config_paths # Remove from SETTINGS cfg.SETTINGS.pop("volume", None) assert cfg.get_setting("volume") == cfg.DEFAULT_CONFIG["volume"] def test_nonexistent_key_returns_none(self, _patch_config_paths): """get_setting returns None for keys not in either dict.""" cfg = _patch_config_paths assert cfg.get_setting("nonexistent_key") is None