🐛 | Improve test coverage for SonarQube quality gate
Some checks failed
SonarQube Code Quality Scan / SonarQube Scan (push) Failing after 1m20s
Some checks failed
SonarQube Code Quality Scan / SonarQube Scan (push) Failing after 1m20s
- 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%
This commit is contained in:
@@ -5,11 +5,18 @@ import modules that unconditionally load PySide6 / Qt native libraries at the
|
|||||||
top level. In CI environments (Docker without PulseAudio, EGL, etc.) these
|
top level. In CI environments (Docker without PulseAudio, EGL, etc.) these
|
||||||
imports fail with ``ImportError`` for ``libpulse.so.0`` or ``libEGL.so.1``.
|
imports fail with ``ImportError`` for ``libpulse.so.0`` or ``libEGL.so.1``.
|
||||||
|
|
||||||
This conftest pre-populates ``sys.modules`` with minimal **real** stub classes
|
Strategy
|
||||||
(not ``MagicMock``) for the key Qt base types, so that modules can be imported
|
--------
|
||||||
and class hierarchies defined without crashing. Integration tests that
|
1. First try to import PySide6/Qt for real.
|
||||||
actually exercise Qt widgets should use
|
2. If that succeeds → Qt is available (local dev machine). The try/except
|
||||||
``pytest.importorskip("PySide6.QtMultimedia")`` at the call site.
|
guards in ``player.py`` / ``gui.py`` will hit the ``try`` branch. No
|
||||||
|
stubs needed.
|
||||||
|
3. If the real import fails → we install lightweight stubs in ``sys.modules``
|
||||||
|
so the modules can be imported and pure helper functions tested. The
|
||||||
|
``except`` branches in the production guards will be exercised instead.
|
||||||
|
|
||||||
|
This way **both** branches of the guarded imports get coverage depending on
|
||||||
|
the runtime environment, and ``new_coverage`` on SonarQube stops complaining.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -19,11 +26,7 @@ from unittest import mock
|
|||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# Minimal stub classes for PySide6 types that are used as base classes or
|
# Stub definitions (used only when real Qt is unavailable)
|
||||||
# decorators. Using real classes (not MagicMock) ensures that:
|
|
||||||
# class Foo(QObject): ...
|
|
||||||
# produces a real class with the methods defined in its body, rather than
|
|
||||||
# a MagicMock that doesn't forward attribute access.
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
class _StubQObject:
|
class _StubQObject:
|
||||||
@@ -71,29 +74,24 @@ def _stub_slot(*types):
|
|||||||
|
|
||||||
Handles ``@Slot``, ``@Slot()``, and ``@Slot(int, dict)`` usage.
|
Handles ``@Slot``, ``@Slot()``, and ``@Slot(int, dict)`` usage.
|
||||||
"""
|
"""
|
||||||
# @Slot without parentheses → the function itself is passed
|
|
||||||
if len(types) == 1 and callable(types[0]) and not isinstance(types[0], type):
|
if len(types) == 1 and callable(types[0]) and not isinstance(types[0], type):
|
||||||
return types[0]
|
return types[0]
|
||||||
# @Slot(int, dict) or @Slot() → return a passthrough decorator
|
|
||||||
return lambda f: f
|
return lambda f: f
|
||||||
|
|
||||||
|
|
||||||
def _install_stubs() -> None:
|
def _install_stubs() -> None:
|
||||||
"""Populate sys.modules with stubs for platform-dependent packages.
|
"""Populate sys.modules with stubs for platform-dependent packages.
|
||||||
|
|
||||||
Uses *instances* of MagicMock (``MagicMock()``) for types that need
|
Called only when PySide6 failed to import for real — guarantees that
|
||||||
nested attribute access (e.g. ``QMediaPlayer.PlaybackState``). Uses
|
pure helper-function tests can still import ``player.py``/``gui.py``
|
||||||
real stub *classes* (``_StubQObject``) for types that are used as
|
and access their standalone utility functions.
|
||||||
base classes in ``class Foo(QObject):`` definitions.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# ── Helper: a fresh MagicMock instance with a readable __name__ ─────
|
|
||||||
def _stub(name: str) -> mock.MagicMock:
|
def _stub(name: str) -> mock.MagicMock:
|
||||||
m = mock.MagicMock()
|
m = mock.MagicMock()
|
||||||
m.__name__ = name
|
m.__name__ = name
|
||||||
return m
|
return m
|
||||||
|
|
||||||
# ── Module-name constants (extracted to avoid S1192 duplicates) ──
|
|
||||||
MOD_QTCORE = "PySide6.QtCore"
|
MOD_QTCORE = "PySide6.QtCore"
|
||||||
MOD_QTGUI = "PySide6.QtGui"
|
MOD_QTGUI = "PySide6.QtGui"
|
||||||
MOD_QTMULTIMEDIA = "PySide6.QtMultimedia"
|
MOD_QTMULTIMEDIA = "PySide6.QtMultimedia"
|
||||||
@@ -113,21 +111,17 @@ def _install_stubs() -> None:
|
|||||||
(MOD_QTCORE, "QEasingCurve"): _stub("QEasingCurve"),
|
(MOD_QTCORE, "QEasingCurve"): _stub("QEasingCurve"),
|
||||||
(MOD_QTCORE, "QLoggingCategory"): _stub("QLoggingCategory"),
|
(MOD_QTCORE, "QLoggingCategory"): _stub("QLoggingCategory"),
|
||||||
(MOD_QTCORE, "QEvent"): _stub("QEvent"),
|
(MOD_QTCORE, "QEvent"): _stub("QEvent"),
|
||||||
# ── QtGui ───────────────────────────────────────────────────
|
|
||||||
(MOD_QTGUI, "QFont"): _stub("QFont"),
|
(MOD_QTGUI, "QFont"): _stub("QFont"),
|
||||||
(MOD_QTGUI, "QPixmap"): _stub("QPixmap"),
|
(MOD_QTGUI, "QPixmap"): _stub("QPixmap"),
|
||||||
(MOD_QTGUI, "QPainter"): _stub("QPainter"),
|
(MOD_QTGUI, "QPainter"): _stub("QPainter"),
|
||||||
(MOD_QTGUI, "QColor"): _stub("QColor"),
|
(MOD_QTGUI, "QColor"): _stub("QColor"),
|
||||||
(MOD_QTGUI, "QBrush"): _stub("QBrush"),
|
(MOD_QTGUI, "QBrush"): _stub("QBrush"),
|
||||||
# ── QtMultimedia ────────────────────────────────────────────
|
|
||||||
(MOD_QTMULTIMEDIA, "QMediaPlayer"): _stub("QMediaPlayer"),
|
(MOD_QTMULTIMEDIA, "QMediaPlayer"): _stub("QMediaPlayer"),
|
||||||
(MOD_QTMULTIMEDIA, "QAudioOutput"): _stub("QAudioOutput"),
|
(MOD_QTMULTIMEDIA, "QAudioOutput"): _stub("QAudioOutput"),
|
||||||
(MOD_QTMULTIMEDIA, "QAudioBufferOutput"): _stub("QAudioBufferOutput"),
|
(MOD_QTMULTIMEDIA, "QAudioBufferOutput"): _stub("QAudioBufferOutput"),
|
||||||
# ── QtNetwork ───────────────────────────────────────────────
|
|
||||||
(MOD_QTNETWORK, "QNetworkAccessManager"): _stub("QNetworkAccessManager"),
|
(MOD_QTNETWORK, "QNetworkAccessManager"): _stub("QNetworkAccessManager"),
|
||||||
(MOD_QTNETWORK, "QNetworkRequest"): _stub("QNetworkRequest"),
|
(MOD_QTNETWORK, "QNetworkRequest"): _stub("QNetworkRequest"),
|
||||||
(MOD_QTNETWORK, "QNetworkReply"): _stub("QNetworkReply"),
|
(MOD_QTNETWORK, "QNetworkReply"): _stub("QNetworkReply"),
|
||||||
# ── QtWidgets ───────────────────────────────────────────────
|
|
||||||
(MOD_QTWIDGETS, "QApplication"): _stub("QApplication"),
|
(MOD_QTWIDGETS, "QApplication"): _stub("QApplication"),
|
||||||
(MOD_QTWIDGETS, "QCheckBox"): _stub("QCheckBox"),
|
(MOD_QTWIDGETS, "QCheckBox"): _stub("QCheckBox"),
|
||||||
(MOD_QTWIDGETS, "QDialog"): _stub("QDialog"),
|
(MOD_QTWIDGETS, "QDialog"): _stub("QDialog"),
|
||||||
@@ -150,12 +144,11 @@ def _install_stubs() -> None:
|
|||||||
(MOD_QTWIDGETS, "QSizePolicy"): _stub("QSizePolicy"),
|
(MOD_QTWIDGETS, "QSizePolicy"): _stub("QSizePolicy"),
|
||||||
}
|
}
|
||||||
|
|
||||||
for (mod_name, attr_name), stub_value in _PYSIDE_STUBS.items():
|
for mod_name, attr_name in _PYSIDE_STUBS:
|
||||||
if mod_name not in sys.modules:
|
if mod_name not in sys.modules:
|
||||||
mod = mock.MagicMock(__name__=mod_name.rsplit(".", 1)[-1])
|
mod = mock.MagicMock(__name__=mod_name.rsplit(".", 1)[-1])
|
||||||
sys.modules[mod_name] = mod
|
sys.modules[mod_name] = mod
|
||||||
mod = sys.modules[mod_name]
|
setattr(sys.modules[mod_name], attr_name, _PYSIDE_STUBS[(mod_name, attr_name)])
|
||||||
setattr(mod, attr_name, stub_value)
|
|
||||||
|
|
||||||
# ── yt-dlp ─────────────────────────────────────────────────────────
|
# ── yt-dlp ─────────────────────────────────────────────────────────
|
||||||
if "yt_dlp" not in sys.modules:
|
if "yt_dlp" not in sys.modules:
|
||||||
@@ -167,8 +160,6 @@ def _install_stubs() -> None:
|
|||||||
# ── pypresence ─────────────────────────────────────────────────────
|
# ── pypresence ─────────────────────────────────────────────────────
|
||||||
if "pypresence" not in sys.modules:
|
if "pypresence" not in sys.modules:
|
||||||
pypresence_mock = mock.MagicMock(__name__="pypresence")
|
pypresence_mock = mock.MagicMock(__name__="pypresence")
|
||||||
# DiscordNotFound must be a real exception so ``except DiscordNotFound:``
|
|
||||||
# in the production code doesn't raise ``TypeError``.
|
|
||||||
pypresence_mock.DiscordNotFound = type("DiscordNotFound", (Exception,), {})
|
pypresence_mock.DiscordNotFound = type("DiscordNotFound", (Exception,), {})
|
||||||
sys.modules["pypresence"] = pypresence_mock
|
sys.modules["pypresence"] = pypresence_mock
|
||||||
if "pypresence.types" not in sys.modules:
|
if "pypresence.types" not in sys.modules:
|
||||||
@@ -177,15 +168,31 @@ def _install_stubs() -> None:
|
|||||||
sys.modules["pypresence.types"] = types_mock
|
sys.modules["pypresence.types"] = types_mock
|
||||||
|
|
||||||
|
|
||||||
_install_stubs()
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# Bootstrap: try real Qt first, fall back to stubs only if that fails.
|
||||||
|
# This ensures the try/except guards in player.py / gui.py are exercised
|
||||||
|
# in both Qt-available and Qt-unavailable environments.
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
_QT_AVAILABLE = False
|
||||||
|
try:
|
||||||
|
# Attempt real import — this will fail in headless CI.
|
||||||
|
import PySide6 # noqa: F401
|
||||||
|
import PySide6.QtCore # noqa: F401
|
||||||
|
import PySide6.QtMultimedia # noqa: F401
|
||||||
|
_QT_AVAILABLE = True
|
||||||
|
except (ImportError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not _QT_AVAILABLE:
|
||||||
|
_install_stubs()
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# Pytest configuration hooks
|
# Pytest hooks
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def pytest_configure(config):
|
def pytest_configure(config):
|
||||||
"""Register custom markers."""
|
|
||||||
config.addinivalue_line(
|
config.addinivalue_line(
|
||||||
"markers",
|
"markers",
|
||||||
"qt: mark test as requiring a real Qt runtime (skipped if PySide6 unavailable).",
|
"qt: mark test as requiring a real Qt runtime (skipped if PySide6 unavailable).",
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ class TestLoadConfig:
|
|||||||
assert loaded["volume"] == 50 # default
|
assert loaded["volume"] == 50 # default
|
||||||
# The corrupt file is not overwritten, but defaults are returned
|
# 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):
|
def test_legacy_migration(self, _patch_config_paths):
|
||||||
"""Legacy config in project dir is migrated to XDG location."""
|
"""Legacy config in project dir is migrated to XDG location."""
|
||||||
cfg = _patch_config_paths
|
cfg = _patch_config_paths
|
||||||
@@ -76,8 +80,63 @@ class TestLoadConfig:
|
|||||||
assert not cfg._LEGACY_FILE.exists()
|
assert not cfg._LEGACY_FILE.exists()
|
||||||
assert cfg._LEGACY_FILE.with_suffix(".json.migrated").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:
|
class TestSaveConfig:
|
||||||
|
|
||||||
def test_save_config_persists(self, _patch_config_paths):
|
def test_save_config_persists(self, _patch_config_paths):
|
||||||
"""save_config writes merged config to disk."""
|
"""save_config writes merged config to disk."""
|
||||||
cfg = _patch_config_paths
|
cfg = _patch_config_paths
|
||||||
|
|||||||
@@ -174,3 +174,60 @@ class TestDiscordRPC:
|
|||||||
# Should not crash, just log a warning
|
# Should not crash, just log a warning
|
||||||
assert drpc._connected is False
|
assert drpc._connected is False
|
||||||
drpc.stop()
|
drpc.stop()
|
||||||
|
|
||||||
|
def test_disconnect_close_exception_handled(self):
|
||||||
|
"""_disconnect handles close() failure gracefully (covers lines 128-129)."""
|
||||||
|
with mock.patch("discord_rpc.Presence") as mock_presence_cls:
|
||||||
|
mock_instance = mock.MagicMock()
|
||||||
|
mock_instance.close.side_effect = RuntimeError("close failed")
|
||||||
|
mock_presence_cls.return_value = mock_instance
|
||||||
|
|
||||||
|
from discord_rpc import DiscordRPC
|
||||||
|
drpc = DiscordRPC(client_id="test")
|
||||||
|
drpc._rpc = mock_instance
|
||||||
|
drpc._connected = True
|
||||||
|
drpc._disconnect()
|
||||||
|
assert drpc._connected is False
|
||||||
|
assert drpc._rpc is None
|
||||||
|
|
||||||
|
def test_send_presence_clear_exception_handled(self):
|
||||||
|
"""_send_presence handles clear() failure gracefully (covers lines 142-143)."""
|
||||||
|
with mock.patch("discord_rpc.Presence") as mock_presence_cls:
|
||||||
|
mock_instance = mock.MagicMock()
|
||||||
|
mock_instance.clear.side_effect = RuntimeError("clear failed")
|
||||||
|
mock_presence_cls.return_value = mock_instance
|
||||||
|
|
||||||
|
from discord_rpc import DiscordRPC
|
||||||
|
drpc = DiscordRPC(client_id="test")
|
||||||
|
drpc._rpc = mock_instance
|
||||||
|
drpc._connected = True
|
||||||
|
drpc._song = None # no song -> goes to clear branch
|
||||||
|
drpc._send_presence()
|
||||||
|
assert drpc._connected is False
|
||||||
|
|
||||||
|
def test_send_presence_update_exception_handled(self):
|
||||||
|
"""_send_presence handles update() failure gracefully (covers lines 160-167)."""
|
||||||
|
with mock.patch("discord_rpc.Presence") as mock_presence_cls:
|
||||||
|
mock_instance = mock.MagicMock()
|
||||||
|
mock_instance.update.side_effect = RuntimeError("update failed")
|
||||||
|
mock_presence_cls.return_value = mock_instance
|
||||||
|
|
||||||
|
from discord_rpc import DiscordRPC
|
||||||
|
drpc = DiscordRPC(client_id="test")
|
||||||
|
drpc._rpc = mock_instance
|
||||||
|
drpc._connected = True
|
||||||
|
drpc._song = {"title": "Test", "artists": [{"name": "A"}], "duration": 100}
|
||||||
|
drpc._start_ts = 1000
|
||||||
|
drpc._send_presence()
|
||||||
|
assert drpc._connected is False
|
||||||
|
|
||||||
|
def test_send_presence_rpc_is_none(self):
|
||||||
|
"""_send_presence does nothing when _rpc is None (covers line ~155)."""
|
||||||
|
from discord_rpc import DiscordRPC
|
||||||
|
drpc = DiscordRPC(client_id="test")
|
||||||
|
drpc._rpc = None
|
||||||
|
drpc._song = {"title": "Test"}
|
||||||
|
drpc._start_ts = 1000
|
||||||
|
# Should not crash
|
||||||
|
drpc._send_presence()
|
||||||
|
assert True
|
||||||
|
|||||||
@@ -275,3 +275,69 @@ class TestBuildResolvedDict:
|
|||||||
assert resolved["duration"] == 99
|
assert resolved["duration"] == 99
|
||||||
assert resolved["title"] == "Fallback"
|
assert resolved["title"] == "Fallback"
|
||||||
assert resolved["thumbnail"] == ""
|
assert resolved["thumbnail"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ── _cleanup_path ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestCleanupPath:
|
||||||
|
"""Tests for DownloadWorker._cleanup_path."""
|
||||||
|
|
||||||
|
def _import(self):
|
||||||
|
from player import DownloadWorker
|
||||||
|
return DownloadWorker._cleanup_path
|
||||||
|
|
||||||
|
def test_removes_existing_file(self, tmp_path):
|
||||||
|
"""_cleanup_path removes a file that exists."""
|
||||||
|
fn = self._import()
|
||||||
|
path = tmp_path / "to_delete.txt"
|
||||||
|
path.write_text("hello")
|
||||||
|
assert path.exists()
|
||||||
|
fn(str(path))
|
||||||
|
assert not path.exists()
|
||||||
|
|
||||||
|
def test_noop_for_missing_file(self):
|
||||||
|
"""_cleanup_path does nothing for non-existent paths."""
|
||||||
|
fn = self._import()
|
||||||
|
fn("/nonexistent/path/file.txt") # should not raise
|
||||||
|
assert True
|
||||||
|
|
||||||
|
def test_noop_for_none(self):
|
||||||
|
"""_cleanup_path does nothing when path is None."""
|
||||||
|
fn = self._import()
|
||||||
|
fn(None)
|
||||||
|
assert True
|
||||||
|
|
||||||
|
def test_oserror_handled_gracefully(self, tmp_path, monkeypatch):
|
||||||
|
"""_cleanup_path handles OSError gracefully."""
|
||||||
|
fn = self._import()
|
||||||
|
path = tmp_path / "locked.txt"
|
||||||
|
path.write_text("data")
|
||||||
|
|
||||||
|
def _fail_unlink(p):
|
||||||
|
raise OSError("Permission denied")
|
||||||
|
|
||||||
|
monkeypatch.setattr(os, "unlink", _fail_unlink)
|
||||||
|
fn(str(path)) # should not raise
|
||||||
|
assert True
|
||||||
|
|
||||||
|
|
||||||
|
# ── _tmp_dir ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestTmpDir:
|
||||||
|
"""Tests for the _tmp_dir helper."""
|
||||||
|
|
||||||
|
def _import(self):
|
||||||
|
from player import _tmp_dir
|
||||||
|
return _tmp_dir
|
||||||
|
|
||||||
|
def test_returns_string(self):
|
||||||
|
"""_tmp_dir returns a string path."""
|
||||||
|
fn = self._import()
|
||||||
|
path = fn()
|
||||||
|
assert isinstance(path, str)
|
||||||
|
assert "tunetti" in path
|
||||||
|
|
||||||
|
def test_idempotent(self):
|
||||||
|
"""Calling _tmp_dir multiple times returns the same path."""
|
||||||
|
fn = self._import()
|
||||||
|
assert fn() == fn()
|
||||||
|
|||||||
Reference in New Issue
Block a user