EpisodeMatcher/tests/test_episode_renamer.py
Jarian f1aa2c4cda test: add test suite with 90% coverage
- 176 unit tests covering ConfigManager, FileClassifier, EpisodeRenamer,
  TVDBClient, MockTVDBClient, TVDBCache, and episode_matcher CLI
- Tests organized in tests/ with conftest.py shared fixtures
- pytest-cov configured with 90% coverage threshold in pyproject.toml
- CI workflow updated with COVERAGE_CORE=sysmon for Python 3.13 compat
- .gitignore updated for coverage artifacts and venv
2026-07-06 14:31:22 +00:00

540 lines
21 KiB
Python

"""Tests for src/Matcher/episode_renamer.py - EpisodeRenamer."""
import sys
from pathlib import Path
from unittest.mock import patch, MagicMock
sys.path.insert(0, str(Path(__file__).parent.parent))
import pytest
from src.Matcher.episode_renamer import EpisodeRenamer
class TestEpisodeRenamerInit:
def test_init_defaults(self):
r = EpisodeRenamer("/tmp/test", "Show Name", 1)
assert r.show_name == "Show Name"
assert r.season_number == 1
assert r.disc_mapping is None
assert r.auto_delete_duplicates is False
def test_init_with_disc_mapping(self):
r = EpisodeRenamer("/tmp/test", "Show", 2, disc_mapping={1: [1, 2], 2: [3, 4]})
assert r.disc_mapping == {1: [1, 2], 2: [3, 4]}
def test_init_auto_delete(self):
r = EpisodeRenamer("/tmp/test", "Show", 1, auto_delete_duplicates=True)
assert r.auto_delete_duplicates is True
def test_init_folder_paths(self):
r = EpisodeRenamer("/tmp/test", "Show", 1)
assert r.extras_folder == Path("/tmp/test/extras")
assert r.delete_folder == Path("/tmp/test/delete me")
class TestSanitizeFilename:
def test_remove_invalid_chars(self):
r = EpisodeRenamer("/tmp/t", "S", 1)
result = r._sanitize_filename('a<b>c:d"e/f\\g|h?i*j')
assert ":" not in result
assert "<" not in result
assert ">" not in result
assert '"' not in result
assert "/" not in result
assert "\\" not in result
assert "|" not in result
assert "?" not in result
assert "*" not in result
assert "a" in result and "b" in result and "c" in result
def test_collapse_spaces(self):
r = EpisodeRenamer("/tmp/t", "S", 1)
assert r._sanitize_filename("a b") == "a b"
def test_strip(self):
r = EpisodeRenamer("/tmp/t", "S", 1)
assert r._sanitize_filename(" hello ") == "hello"
def test_preserve_valid_chars(self):
r = EpisodeRenamer("/tmp/t", "S", 1)
assert r._sanitize_filename("hello_world-1.mkv") == "hello_world-1.mkv"
class TestGenerateEpisodeFilename:
def test_standard(self):
r = EpisodeRenamer("/tmp/t", "Show Name", 3)
assert r._generate_episode_filename(5, ".mkv") == "Show Name s03e05.mkv"
def test_single_digit_padding(self):
r = EpisodeRenamer("/tmp/t", "Show", 1)
assert r._generate_episode_filename(1, ".mp4") == "Show s01e01.mp4"
def test_double_digit_episode(self):
r = EpisodeRenamer("/tmp/t", "Show", 10)
assert r._generate_episode_filename(12, ".mkv") == "Show s10e12.mkv"
def test_sanitizes_output(self):
r = EpisodeRenamer("/tmp/t", "Show:Name", 1)
assert r._generate_episode_filename(1, ".mkv") == "ShowName s01e01.mkv"
class TestExtractDiscInfo:
def test_disc_with_underscore(self):
r = EpisodeRenamer("/tmp/t", "S", 1)
info = r._extract_disc_info("Disc 1_t01.mkv")
assert info["disc_number"] == 1
assert info["track_number"] == 1
def test_disk_variant(self):
r = EpisodeRenamer("/tmp/t", "S", 1)
info = r._extract_disc_info("Disk2_t03.mkv")
assert info["disc_number"] == 2
assert info["track_number"] == 3
def test_disc_hyphen(self):
r = EpisodeRenamer("/tmp/t", "S", 1)
info = r._extract_disc_info("disc-3_track05.mkv")
assert info["disc_number"] == 3
assert info["track_number"] == 5
def test_no_disc_info(self):
r = EpisodeRenamer("/tmp/t", "S", 1)
info = r._extract_disc_info("episode_01.mkv")
assert info["disc_number"] is None
assert info["track_number"] is None
def test_track_pattern(self):
r = EpisodeRenamer("/tmp/t", "S", 1)
info = r._extract_disc_info("file_t22.mkv")
assert info["track_number"] == 22
def test_disc_only(self):
r = EpisodeRenamer("/tmp/t", "S", 1)
info = r._extract_disc_info("Disc 5.mkv")
assert info["disc_number"] == 5
assert info["track_number"] is None
class TestComputeFileHash:
def test_hash_consistent(self, tmp_path):
f = tmp_path / "test.mkv"
f.write_bytes(b"\x00" * 1000)
r = EpisodeRenamer(str(tmp_path), "S", 1)
h1 = r._compute_file_hash(f)
h2 = r._compute_file_hash(f)
assert h1 == h2
assert len(h1) == 64
def test_hash_different_content(self, tmp_path):
f1 = tmp_path / "a.mkv"
f2 = tmp_path / "b.mkv"
f1.write_bytes(b"\x00" * 100)
f2.write_bytes(b"\xff" * 100)
r = EpisodeRenamer(str(tmp_path), "S", 1)
assert r._compute_file_hash(f1) != r._compute_file_hash(f2)
def test_hash_custom_bytes(self, tmp_path):
f = tmp_path / "test.mkv"
f.write_bytes(b"\x00" * 10000)
r = EpisodeRenamer(str(tmp_path), "S", 1)
h = r._compute_file_hash(f, num_bytes=512)
assert h
def test_hash_missing_file(self, tmp_path):
r = EpisodeRenamer(str(tmp_path), "S", 1)
h = r._compute_file_hash(tmp_path / "nope.mkv")
assert h == ""
class TestCreateFolders:
def test_create_extras_folder(self, tmp_path):
r = EpisodeRenamer(str(tmp_path), "S", 1)
assert r._create_extras_folder() is True
assert (tmp_path / "extras").exists()
def test_create_delete_folder(self, tmp_path):
r = EpisodeRenamer(str(tmp_path), "S", 1)
assert r._create_delete_folder() is True
assert (tmp_path / "delete me").exists()
def test_create_extras_already_exists(self, tmp_path):
(tmp_path / "extras").mkdir()
r = EpisodeRenamer(str(tmp_path), "S", 1)
assert r._create_extras_folder() is True
class TestMoveExtras:
def test_move_extras(self, tmp_path):
(tmp_path / "extra.mkv").write_bytes(b"\x00" * 100)
r = EpisodeRenamer(str(tmp_path), "S", 1)
extras = [{"path": tmp_path / "extra.mkv", "name": "extra.mkv"}]
moved = r.move_extras_to_folder(extras)
assert len(moved) == 1
assert (tmp_path / "extras" / "extra.mkv").exists()
assert not (tmp_path / "extra.mkv").exists()
def test_move_extras_skip_existing(self, tmp_path):
(tmp_path / "extras").mkdir()
(tmp_path / "extras" / "extra.mkv").write_bytes(b"\x00")
(tmp_path / "extra.mkv").write_bytes(b"\x00" * 100)
r = EpisodeRenamer(str(tmp_path), "S", 1)
extras = [{"path": tmp_path / "extra.mkv", "name": "extra.mkv"}]
moved = r.move_extras_to_folder(extras)
assert len(moved) == 0
def test_move_extras_empty(self, tmp_path):
r = EpisodeRenamer(str(tmp_path), "S", 1)
assert r.move_extras_to_folder([]) == []
class TestDuplicateDetection:
def test_detect_duplicates_same_duration(self, tmp_path):
f1 = tmp_path / "Disc 1_t01.mkv"
f2 = tmp_path / "Disc 1_t02.mkv"
f1.write_bytes(b"\x00" * 2000)
f2.write_bytes(b"\x00" * 2000)
eps = [
{"path": f1, "name": "Disc 1_t01.mkv", "size_gb": 2.0, "duration_minutes": 45.0},
{"path": f2, "name": "Disc 1_t02.mkv", "size_gb": 2.0, "duration_minutes": 45.0},
]
r = EpisodeRenamer(str(tmp_path), "S", 1)
result = r.detect_and_move_duplicates(eps)
assert isinstance(result, list)
def test_no_duplicates_different_duration(self, tmp_path):
f1 = tmp_path / "Disc 1_t01.mkv"
f2 = tmp_path / "Disc 2_t01.mkv"
f1.write_bytes(b"\x00" * 2000)
f2.write_bytes(b"\x01" * 3000)
eps = [
{"path": f1, "name": "Disc 1_t01.mkv", "size_gb": 2.0, "duration_minutes": 45.0},
{"path": f2, "name": "Disc 2_t01.mkv", "size_gb": 3.0, "duration_minutes": 55.0},
]
r = EpisodeRenamer(str(tmp_path), "S", 1)
r.detect_and_move_duplicates(eps)
def test_duplicate_moved_to_delete_me(self, tmp_path):
f1 = tmp_path / "Disc 1_t01.mkv"
f2 = tmp_path / "Disc 1_t02.mkv"
f1.write_bytes(b"\x00" * 2000)
f2.write_bytes(b"\x00" * 2000)
eps = [
{"path": f1, "name": "Disc 1_t01.mkv", "size_gb": 2.0, "duration_minutes": 45.0},
{"path": f2, "name": "Disc 1_t02.mkv", "size_gb": 2.0, "duration_minutes": 45.0},
]
r = EpisodeRenamer(str(tmp_path), "S", 1, auto_delete_duplicates=False)
result = r.detect_and_move_duplicates(eps)
assert (tmp_path / "delete me").exists()
class TestMatchEpisodes:
def test_disc_mapping_match(self, mock_episodes, mock_tvdb_episodes, disc_mapping):
r = EpisodeRenamer("/tmp/t", "Show", 1, disc_mapping=disc_mapping)
episodes_info = []
for ep in mock_episodes:
disc_info = r._extract_disc_info(ep["name"])
episodes_info.append({
"file_info": ep,
"disc_number": disc_info["disc_number"],
"track_number": disc_info["track_number"],
"filename": ep["name"],
"duration": ep["duration_minutes"],
})
matched = r._match_using_disc_mapping(episodes_info, mock_tvdb_episodes)
assert len(matched) > 0
ep_nums = [m["episode_number"] for m in matched]
assert len(set(ep_nums)) == len(ep_nums)
def test_fallback_sequential(self, mock_episodes, mock_tvdb_episodes):
r = EpisodeRenamer("/tmp/t", "Show", 1)
files = []
for ep in mock_episodes:
files.append({
"file_info": ep,
"filename": ep["name"],
"disc_number": None,
"duration": ep["duration_minutes"],
})
allowed = list(range(1, 11))
result = r._fallback_sequential(files, mock_tvdb_episodes, allowed)
assert len(result) > 0
def test_dp_match_episodes(self, mock_episodes, mock_tvdb_episodes):
r = EpisodeRenamer("/tmp/t", "Show", 1)
files = []
for ep in mock_episodes:
files.append({
"file_info": ep,
"filename": ep["name"],
"disc_number": None,
"duration": ep["duration_minutes"],
})
allowed = list(range(1, 11))
result = r._dp_match_episodes(files, mock_tvdb_episodes, allowed)
assert len(result) > 0
ep_nums = [m["episode_number"] for m in result]
assert len(set(ep_nums)) == len(ep_nums)
def test_precise_duration_match(self, mock_episodes, mock_tvdb_episodes):
r = EpisodeRenamer("/tmp/t", "Show", 1)
files = []
for ep in mock_episodes:
files.append({
"file_info": ep,
"filename": ep["name"],
"disc_number": None,
"duration": ep["duration_minutes"],
})
allowed = list(range(1, 7))
result = r._precise_duration_match(files, mock_tvdb_episodes, allowed)
assert len(result) > 0
def test_dp_match_empty_files(self, mock_tvdb_episodes):
r = EpisodeRenamer("/tmp/t", "Show", 1)
result = r._dp_match_episodes([], mock_tvdb_episodes, list(range(1, 11)))
assert isinstance(result, list)
def test_precise_match_empty(self, mock_episodes, mock_tvdb_episodes):
r = EpisodeRenamer("/tmp/t", "Show", 1)
files = [{"file_info": {}, "filename": "a.mkv", "duration": 45.0}]
result = r._precise_duration_match(files, [], [])
assert isinstance(result, list)
def test_fallback_sequential_empty(self, mock_tvdb_episodes):
r = EpisodeRenamer("/tmp/t", "Show", 1)
result = r._fallback_sequential([], mock_tvdb_episodes, [])
assert result == []
class TestRenameEpisodes:
def test_rename_no_episodes(self):
r = EpisodeRenamer("/tmp/t", "S", 1)
assert r.rename_episodes([]) == []
def test_rename_with_tvdb(self, tmp_path, mock_episodes, mock_tvdb_episodes):
for ep in mock_episodes:
f = tmp_path / ep["name"]
f.write_bytes(b"\x00" * ep["size_bytes"])
ep["path"] = f
r = EpisodeRenamer(str(tmp_path), "TestShow", 1)
renamed = r.rename_episodes(mock_episodes, mock_tvdb_episodes)
assert len(renamed) > 0
for item in renamed:
assert "original_name" in item
assert "new_name" in item
assert "episode_number" in item
def test_rename_skip_existing(self, tmp_path, mock_episodes, capsys):
for ep in mock_episodes[:2]:
f = tmp_path / ep["name"]
f.write_bytes(b"\x00" * ep["size_bytes"])
ep["path"] = f
(tmp_path / "TestShow s01e01.mkv").write_bytes(b"\x00")
r = EpisodeRenamer(str(tmp_path), "TestShow", 1)
renamed = r.rename_episodes(mock_episodes[:2], None)
out = capsys.readouterr().out
assert "already exists" in out or len(renamed) < 2
class TestEstimateEpisodesPerDisc:
def test_estimate_basic(self, tmp_path, mock_episodes):
for ep in mock_episodes:
f = tmp_path / ep["name"]
f.write_bytes(b"\x00" * ep["size_bytes"])
ep["path"] = f
r = EpisodeRenamer(str(tmp_path), "S", 1)
episodes_info = []
for ep in mock_episodes:
disc_info = r._extract_disc_info(ep["name"])
episodes_info.append({
"file_info": ep,
"disc_number": disc_info["disc_number"],
"filename": ep["name"],
})
result = r._estimate_episodes_per_disc(episodes_info, 6)
assert isinstance(result, dict)
def test_estimate_no_disc_info(self):
r = EpisodeRenamer("/tmp/t", "S", 1)
result = r._estimate_episodes_per_disc([], 10)
assert result == {}
def test_estimate_no_disc_numbers(self):
r = EpisodeRenamer("/tmp/t", "S", 1)
info = [{"disc_number": None, "filename": "a.mkv", "file_info": {"size_gb": 5.0}}]
result = r._estimate_episodes_per_disc(info, 10)
assert result == {}
class TestAnalyzeDiscCapacity:
def test_analyze_basic(self, tmp_path, mock_episodes):
for ep in mock_episodes:
f = tmp_path / ep["name"]
f.write_bytes(b"\x00" * ep["size_bytes"])
ep["path"] = f
r = EpisodeRenamer(str(tmp_path), "S", 1)
info = []
for ep in mock_episodes:
disc_info = r._extract_disc_info(ep["name"])
info.append({
"file_info": ep,
"disc_number": disc_info["disc_number"],
"filename": ep["name"],
})
result = r._analyze_disc_capacity(info)
assert isinstance(result, dict)
def test_analyze_no_discs(self):
r = EpisodeRenamer("/tmp/t", "S", 1)
result = r._analyze_disc_capacity([{"disc_number": None, "file_info": {"size_gb": 5}}])
assert result == {}
class TestDurationMatching:
def test_match_by_duration_and_order(self, mock_episodes, mock_tvdb_episodes):
r = EpisodeRenamer("/tmp/t", "Show", 1)
info = []
for ep in mock_episodes:
disc_info = r._extract_disc_info(ep["name"])
info.append({
"file_info": ep,
"disc_number": disc_info["disc_number"],
"track_number": disc_info["track_number"],
"filename": ep["name"],
"duration": ep["duration_minutes"],
})
result = r._match_by_duration_and_order(info, mock_tvdb_episodes)
assert len(result) > 0
ep_nums = [m["episode_number"] for m in result]
assert len(set(ep_nums)) == len(ep_nums)
def test_flexible_duration_match(self, mock_episodes, mock_tvdb_episodes):
r = EpisodeRenamer("/tmp/t", "Show", 1)
info = []
for ep in mock_episodes:
disc_info = r._extract_disc_info(ep["name"])
info.append({
"file_info": ep,
"disc_number": disc_info["disc_number"],
"filename": ep["name"],
"duration": ep["duration_minutes"],
})
result = r._flexible_duration_match(info, mock_tvdb_episodes)
assert isinstance(result, list)
def test_sequential_assignment_with_constraints(self, mock_episodes, mock_tvdb_episodes):
r = EpisodeRenamer("/tmp/t", "Show", 1)
info = []
for ep in mock_episodes:
disc_info = r._extract_disc_info(ep["name"])
info.append({
"file_info": ep,
"disc_number": disc_info["disc_number"],
"filename": ep["name"],
"duration": ep["duration_minutes"],
})
disc_capacity = {1: 3, 2: 3}
result = r._sequential_assignment_with_constraints(info, mock_tvdb_episodes, disc_capacity)
assert isinstance(result, list)
def test_validate_sequential_assignment_valid(self):
r = EpisodeRenamer("/tmp/t", "S", 1)
file_info = {"duration": 55.0, "disc_number": 1}
tvdb_ep = {"runtime": 55, "episode_number": 1}
assert r._validate_sequential_assignment(file_info, tvdb_ep, {}, {}) is True
def test_validate_sequential_assignment_duration_mismatch(self):
r = EpisodeRenamer("/tmp/t", "S", 1)
file_info = {"duration": 30.0, "disc_number": 1}
tvdb_ep = {"runtime": 60, "episode_number": 1}
result = r._validate_sequential_assignment(file_info, tvdb_ep, {}, {})
assert result is False
def test_validate_sequential_assignment_disc_full(self):
r = EpisodeRenamer("/tmp/t", "S", 1)
file_info = {"duration": 55.0, "disc_number": 1}
tvdb_ep = {"runtime": 55, "episode_number": 1}
disc_assignments = {1: 3}
disc_capacity = {1: 3}
result = r._validate_sequential_assignment(file_info, tvdb_ep, disc_assignments, disc_capacity)
assert result is False
def test_assignment_satisfies_constraints(self):
r = EpisodeRenamer("/tmp/t", "S", 1)
fake_path = type("P", (), {"name": "Disc 1_t01.mkv"})()
file_info = {
"duration": 55.0,
"disc_number": 1,
"file_info": {"path": fake_path},
}
tvdb_ep = {"runtime": 55, "episode_number": 1}
result = r._assignment_satisfies_constraints(file_info, tvdb_ep, [], {})
assert result is True
def test_assignment_satisfies_duration_exceeded(self):
r = EpisodeRenamer("/tmp/t", "S", 1)
fake_path = type("P", (), {"name": "a.mkv"})()
file_info = {
"duration": 30.0,
"disc_number": None,
"file_info": {"path": fake_path},
}
tvdb_ep = {"runtime": 60, "episode_number": 1}
result = r._assignment_satisfies_constraints(file_info, tvdb_ep, [], {})
assert result is False
class TestMatchByDurationAndDisc:
def test_match_disc(self, mock_episodes, mock_tvdb_episodes):
r = EpisodeRenamer("/tmp/t", "Show", 1)
info = []
for ep in mock_episodes:
disc_info = r._extract_disc_info(ep["name"])
info.append({
"file_info": ep,
"disc_number": disc_info["disc_number"],
"filename": ep["name"],
"duration": ep["duration_minutes"],
})
episodes_per_disc = {1: [1, 2, 3], 2: [4, 5, 6]}
result = r._match_by_duration_and_disc(info, mock_tvdb_episodes, episodes_per_disc)
assert len(result) > 0
for m in result:
assert m["episode_number"] in [1, 2, 3, 4, 5, 6]
class TestFindOptimalAssignment:
def test_find_optimal_assignment(self, mock_episodes, mock_tvdb_episodes):
r = EpisodeRenamer("/tmp/t", "Show", 1)
info = []
for ep in mock_episodes:
disc_info = r._extract_disc_info(ep["name"])
info.append({
"file_info": ep,
"disc_number": disc_info["disc_number"],
"filename": ep["name"],
"duration": ep["duration_minutes"],
})
result = r._find_optimal_assignment(info, mock_tvdb_episodes, {1: 3, 2: 3})
assert isinstance(result, list)
def test_find_optimal_empty(self, mock_tvdb_episodes):
r = EpisodeRenamer("/tmp/t", "Show", 1)
result = r._find_optimal_assignment([], mock_tvdb_episodes, {})
assert result == []
class TestIsValidAssignment:
def test_valid_assignment(self):
r = EpisodeRenamer("/tmp/t", "S", 1)
file_info = {"duration": 55.0, "disc_number": None}
tvdb_ep = {"runtime": 55, "episode_number": 1}
result = r._is_valid_assignment(file_info, tvdb_ep, {}, 0, [])
assert result is True
def test_invalid_duration(self):
r = EpisodeRenamer("/tmp/t", "S", 1)
file_info = {"duration": 30.0, "disc_number": None}
tvdb_ep = {"runtime": 60, "episode_number": 1}
result = r._is_valid_assignment(file_info, tvdb_ep, {}, 0, [])
assert result is False