✅ | Add tests
Some checks failed
SonarQube Code Quality Scan / SonarQube Scan (push) Failing after 2m37s
Some checks failed
SonarQube Code Quality Scan / SonarQube Scan (push) Failing after 2m37s
This commit is contained in:
277
tests/test_player_helpers.py
Normal file
277
tests/test_player_helpers.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""Tests for helper functions in the player module (player.py)."""
|
||||
|
||||
import os
|
||||
import struct
|
||||
import math
|
||||
import tempfile
|
||||
|
||||
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)
|
||||
|
||||
with open(os.path.join(td, "vid123.mp3"), "w") as f:
|
||||
pass # empty
|
||||
|
||||
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"] == ""
|
||||
Reference in New Issue
Block a user