Files
Tunetti/tests/test_player_helpers.py
NikkeDoy daf9c5739c
All checks were successful
SonarQube Code Quality Scan / SonarQube Scan (push) Successful in 1m43s
🐛 | Code quality fixes
- config.py: move 'import shutil' to module level
- player.py: unify type hints, narrow except, add debug log
- discord_rpc.py: replace broad except Exception with specific types
- gui.py: remove unused import; replace lambda closures with method refs; fix closure scope bug; extract _populate_list_page() to remove duplicate code; add public accessors; make flush_volume public; add AudioVisualizer.reset_activity()
- tests: replace unused _f variable with Path.touch()
2026-06-04 18:27:44 +03:00

346 lines
12 KiB
Python

"""Tests for helper functions in the player module (player.py)."""
import os
import struct
import math
import tempfile
from pathlib import Path
import pytest
# ── _best_thumbnail ───────────────────────────────────────────────────────────
class TestBestThumbnail:
"""Tests for the _best_thumbnail helper."""
def _import(self):
from player import _best_thumbnail
return _best_thumbnail
def test_none_or_empty(self):
fn = self._import()
assert fn(None) == ""
assert fn([]) == ""
def test_string_returned_as_is(self):
fn = self._import()
assert fn("https://example.com/thumb.jpg") == "https://example.com/thumb.jpg"
def test_list_returns_last_url(self):
fn = self._import()
thumbs = [
{"url": "https://example.com/small.jpg"},
{"url": "https://example.com/medium.jpg"},
{"url": "https://example.com/large.jpg"},
]
assert fn(thumbs) == "https://example.com/large.jpg"
def test_list_without_url_keys(self):
fn = self._import()
thumbs = [{"width": 100}, {"width": 200}]
assert fn(thumbs) == ""
def test_list_with_non_dict_items(self):
fn = self._import()
assert fn(["string_thumb"]) == "string_thumb"
def test_non_list_non_string(self):
fn = self._import()
assert fn(123) == "123"
# ── _build_song_dict ─────────────────────────────────────────────────────────
class TestBuildSongDict:
"""Tests for the _build_song_dict helper."""
def _import(self):
from player import _build_song_dict
return _build_song_dict
def test_minimal_result(self):
fn = self._import()
result = {"videoId": "abc123", "title": "Test Song"}
song = fn(result)
assert song["videoId"] == "abc123"
assert song["title"] == "Test Song"
assert song["artists"] == []
assert song["album"] is None
assert song["duration"] == 0
assert song["duration_label"] == "?"
assert song["thumbnail"] == ""
assert song["videoType"] == ""
assert song["resultType"] == "song"
def test_full_result(self):
fn = self._import()
result = {
"videoId": "xyz789",
"title": "Full Song",
"artists": [{"name": "Artist", "id": "a1"}],
"album": {"name": "Album", "id": "al1"},
"duration_seconds": 300,
"duration": "5:00",
"thumbnails": [{"url": "https://ex.com/t.jpg"}],
"videoType": "MUSIC_VIDEO_TYPE_ATV",
"resultType": "video",
}
song = fn(result)
assert song["videoId"] == "xyz789"
assert song["title"] == "Full Song"
assert song["artists"] == [{"name": "Artist", "id": "a1"}]
assert song["album"] == {"name": "Album", "id": "al1"}
assert song["duration"] == 300
assert song["duration_label"] == "5:00"
assert song["thumbnail"] == "https://ex.com/t.jpg"
assert song["videoType"] == "MUSIC_VIDEO_TYPE_ATV"
assert song["resultType"] == "video"
def test_missing_video_id_defaults_to_empty(self):
fn = self._import()
song = fn({})
assert song["videoId"] == ""
assert song["title"] == "Unknown"
assert song["duration"] == 0
def test_thumbnail_from_thumbnails_list(self):
fn = self._import()
result = {
"thumbnails": [
{"url": "https://ex.com/small.jpg"},
{"url": "https://ex.com/large.jpg"},
]
}
song = fn(result)
assert song["thumbnail"] == "https://ex.com/large.jpg"
# ── _generate_test_wav ───────────────────────────────────────────────────────
class TestGenerateTestWav:
"""Tests for the _generate_test_wav helper."""
def _import(self):
from player import _generate_test_wav
return _generate_test_wav
def test_generates_valid_wav_file(self, tmp_path):
"""Generated WAV has valid RIFF header and correct size."""
fn = self._import()
wav_path = str(tmp_path / "test.wav")
fn(wav_path, duration_s=1)
with open(wav_path, "rb") as f:
data = f.read()
# RIFF header
assert data[:4] == b"RIFF"
assert data[8:12] == b"WAVE"
assert data[12:16] == b"fmt "
# PCM format
(audio_format, num_channels, sample_rate, byte_rate,
block_align, bits_per_sample) = struct.unpack_from("<HHIIHH", data, 20)
assert audio_format == 1 # PCM
assert num_channels == 1 # mono
assert sample_rate == 44100
assert bits_per_sample == 16
# data chunk: "data" marker at offset 36, size at offset 40
data_size = struct.unpack_from("<I", data, 40)[0]
expected_size = 44100 * 2 # 1 sec * 2 bytes per sample
assert data_size == expected_size
def test_generates_sine_wave_content(self, tmp_path):
"""Generated WAV contains a recognizable 440 Hz sine wave."""
fn = self._import()
wav_path = str(tmp_path / "test.wav")
fn(wav_path, duration_s=1)
with open(wav_path, "rb") as f:
f.seek(44)
samples = f.read()
# Decode first 100 samples
first_samples = []
for i in range(100):
sample = struct.unpack_from("<h", samples, i * 2)[0]
first_samples.append(sample)
# The samples should alternate (sine wave), not be all zero
assert any(s != 0 for s in first_samples), "Sine wave should produce non-zero samples"
# First sample should be 0 (sine starts at 0)
assert abs(first_samples[0]) < 3276, "Sine wave should start near 0"
# ── _stale_files ─────────────────────────────────────────────────────────────
class TestStaleFiles:
"""Tests for the _stale_files helper."""
def _import_fixtures(self):
from player import _tmp_dir, _stale_files
return _tmp_dir, _stale_files
def test_returns_empty_when_no_files(self, monkeypatch):
"""No files in tmp dir → empty list."""
_tmp_dir, _stale_files = self._import_fixtures()
monkeypatch.setattr("player._TUNETTI_TMP", tempfile.mkdtemp(prefix="tunetti_test_"))
result = _stale_files("nonexistent_video")
assert result == []
def test_returns_matching_files_ordered_by_mtime(self, monkeypatch):
"""Matching files are returned newest first."""
_tmp_dir, _stale_files = self._import_fixtures()
import time
td = tempfile.mkdtemp(prefix="tunetti_test_")
monkeypatch.setattr("player._TUNETTI_TMP", td)
# Create two files with different mtimes
old_file = os.path.join(td, "vid123.mp3")
new_file = os.path.join(td, "vid123.m4a")
with open(old_file, "w") as f:
f.write("data")
old_mtime = time.time() - 10
os.utime(old_file, (old_mtime, old_mtime))
with open(new_file, "w") as f:
f.write("data")
result = _stale_files("vid123")
assert len(result) == 2
# Newest first
assert os.path.basename(result[0]) == "vid123.m4a"
assert os.path.basename(result[1]) == "vid123.mp3"
def test_ignores_temp_files(self, monkeypatch):
"""Files with '.temp.' in name are excluded."""
_tmp_dir, _stale_files = self._import_fixtures()
td = tempfile.mkdtemp(prefix="tunetti_test_")
monkeypatch.setattr("player._TUNETTI_TMP", td)
# A "temp" download in progress and a completed file
with open(os.path.join(td, "vid123.temp.xyz.part"), "w") as f:
f.write("incomplete")
with open(os.path.join(td, "vid123.mp3"), "w") as f:
f.write("complete")
result = _stale_files("vid123")
assert len(result) == 1
assert "vid123.mp3" in result[0]
def test_ignores_empty_files(self, monkeypatch):
"""Zero-byte files are excluded."""
_tmp_dir, _stale_files = self._import_fixtures()
td = tempfile.mkdtemp(prefix="tunetti_test_")
monkeypatch.setattr("player._TUNETTI_TMP", td)
# Create an empty file to verify it's excluded
empty_path = os.path.join(td, "vid123.mp3")
Path(empty_path).touch()
result = _stale_files("vid123")
assert result == []
# ── _build_resolved_dict ──────────────────────────────────────────────────────
class TestBuildResolvedDict:
"""Tests for DownloadWorker._build_resolved_dict."""
def _import(self):
from player import DownloadWorker
return DownloadWorker._build_resolved_dict
def test_builds_resolved_dict(self):
fn = self._import()
song = {"videoId": "abc", "title": "Original"}
info = {"duration": 120, "thumbnail": "https://ex.com/t.jpg", "title": "Actual Title"}
temp_path = "/tmp/tunetti_abc.mp3"
resolved = fn(song, info, temp_path)
assert resolved["_local_path"] == temp_path
assert resolved["duration"] == 120
assert resolved["thumbnail"] == "https://ex.com/t.jpg"
assert resolved["title"] == "Actual Title"
assert resolved["videoId"] == "abc"
def test_falls_back_to_song_metadata(self):
fn = self._import()
song = {"videoId": "abc", "duration": 99, "title": "Fallback"}
info = {} # empty info
resolved = fn(song, info, "/tmp/t.mp3")
assert resolved["duration"] == 99
assert resolved["title"] == "Fallback"
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()