Files
Tunetti/tests/test_config.py
NikkeDoy d8ebd6186c
Some checks failed
SonarQube Code Quality Scan / SonarQube Scan (push) Failing after 1m20s
🐛 | Improve test coverage for SonarQube quality gate
- Restructure conftest to try real Qt first, fall back to stubs
  only if that fails — ensures try/except guards in player.py and
  gui.py are exercised in CI, improving new_coverage
- Add tests for _migrate_legacy error paths (JSONDecodeError,
  OSError, db migration) — config.py now 100% covered
- Add tests for _disconnect, _send_presence exception handlers —
  discord_rpc.py now 97% covered
- Add tests for _cleanup_path and _tmp_dir helpers — player.py
  coverage bumped to 33%
2026-06-03 19:35:20 +03:00

185 lines
7.3 KiB
Python

"""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