Rewrite Curator in Rust with an egui GUI
This commit is contained in:
@@ -1 +0,0 @@
|
||||
# Tests package
|
||||
@@ -1,29 +0,0 @@
|
||||
"""
|
||||
Konfigurace pytest - sdílené fixtures a nastavení pro všechny testy
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def session_temp_dir():
|
||||
"""Session-wide dočasný adresář"""
|
||||
temp_dir = Path(tempfile.mkdtemp())
|
||||
yield temp_dir
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def cleanup_config_files():
|
||||
"""Automaticky vyčistí config.json soubory po každém testu"""
|
||||
yield
|
||||
# Cleanup po testu
|
||||
config_file = Path("config.json")
|
||||
if config_file.exists():
|
||||
with contextlib.suppress(Exception):
|
||||
config_file.unlink()
|
||||
@@ -1,245 +0,0 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.config import (
|
||||
DEFAULT_GLOBAL_CONFIG,
|
||||
load_config, # Legacy functions
|
||||
load_global_config,
|
||||
save_config,
|
||||
save_global_config,
|
||||
)
|
||||
|
||||
|
||||
class TestGlobalConfig:
|
||||
"""Testy pro globální config"""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_global_config(self, tmp_path, monkeypatch):
|
||||
"""Fixture pro dočasný globální config soubor"""
|
||||
config_path = tmp_path / "config.json"
|
||||
import src.core.config as config_module
|
||||
|
||||
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path)
|
||||
return config_path
|
||||
|
||||
def test_default_global_config_structure(self):
|
||||
"""Test struktury defaultní globální konfigurace (jen data aplikace)"""
|
||||
assert "window_geometry" in DEFAULT_GLOBAL_CONFIG
|
||||
assert "window_maximized" in DEFAULT_GLOBAL_CONFIG
|
||||
assert "last_folder" in DEFAULT_GLOBAL_CONFIG
|
||||
assert "sidebar_width" in DEFAULT_GLOBAL_CONFIG
|
||||
assert "recent_folders" in DEFAULT_GLOBAL_CONFIG
|
||||
assert "pool_dir" in DEFAULT_GLOBAL_CONFIG
|
||||
assert "filmoteka_dir" in DEFAULT_GLOBAL_CONFIG
|
||||
assert DEFAULT_GLOBAL_CONFIG["window_geometry"] == "1200x800"
|
||||
assert DEFAULT_GLOBAL_CONFIG["window_maximized"] is False
|
||||
assert DEFAULT_GLOBAL_CONFIG["last_folder"] is None
|
||||
|
||||
def test_global_config_holds_no_library_settings(self):
|
||||
"""Library settings (tag_schema / copyasis_folders) žijí v indexu, ne tady."""
|
||||
assert "tag_schema" not in DEFAULT_GLOBAL_CONFIG
|
||||
assert "copyasis_folders" not in DEFAULT_GLOBAL_CONFIG
|
||||
|
||||
def test_load_global_config_nonexistent_file(self, temp_global_config):
|
||||
"""Test načtení globální konfigurace když soubor neexistuje"""
|
||||
config = load_global_config()
|
||||
assert config == DEFAULT_GLOBAL_CONFIG
|
||||
|
||||
def test_save_global_config(self, temp_global_config):
|
||||
"""Test uložení globální konfigurace"""
|
||||
test_config = {
|
||||
"window_geometry": "800x600",
|
||||
"window_maximized": True,
|
||||
"last_folder": "/home/user/documents",
|
||||
"sidebar_width": 300,
|
||||
"recent_folders": ["/path1", "/path2"],
|
||||
}
|
||||
|
||||
save_global_config(test_config)
|
||||
|
||||
assert temp_global_config.exists()
|
||||
with open(temp_global_config, "r", encoding="utf-8") as f:
|
||||
saved_data = json.load(f)
|
||||
assert saved_data == test_config
|
||||
|
||||
def test_load_global_config_existing_file(self, temp_global_config):
|
||||
"""Test načtení existující globální konfigurace"""
|
||||
test_config = {
|
||||
"window_geometry": "1920x1080",
|
||||
"window_maximized": False,
|
||||
"last_folder": "/test/path",
|
||||
"sidebar_width": 250,
|
||||
"recent_folders": [],
|
||||
"pool_dir": None,
|
||||
"filmoteka_dir": None,
|
||||
}
|
||||
|
||||
save_global_config(test_config)
|
||||
loaded_config = load_global_config()
|
||||
|
||||
assert loaded_config == test_config
|
||||
|
||||
def test_load_global_config_merges_defaults(self, temp_global_config):
|
||||
"""Test že chybějící klíče jsou doplněny z defaultů"""
|
||||
partial_config = {"window_geometry": "800x600"}
|
||||
|
||||
with open(temp_global_config, "w", encoding="utf-8") as f:
|
||||
json.dump(partial_config, f)
|
||||
|
||||
loaded = load_global_config()
|
||||
assert loaded["window_geometry"] == "800x600"
|
||||
assert loaded["window_maximized"] == DEFAULT_GLOBAL_CONFIG["window_maximized"]
|
||||
assert loaded["sidebar_width"] == DEFAULT_GLOBAL_CONFIG["sidebar_width"]
|
||||
|
||||
def test_global_config_corrupted_file(self, temp_global_config):
|
||||
"""Test načtení poškozeného global config souboru"""
|
||||
with open(temp_global_config, "w") as f:
|
||||
f.write("{ invalid json }")
|
||||
|
||||
config = load_global_config()
|
||||
assert config == DEFAULT_GLOBAL_CONFIG
|
||||
|
||||
def test_global_config_utf8_encoding(self, temp_global_config):
|
||||
"""Test UTF-8 encoding s českými znaky"""
|
||||
test_config = {
|
||||
**DEFAULT_GLOBAL_CONFIG,
|
||||
"last_folder": "/cesta/s/českými/znaky",
|
||||
"recent_folders": ["/složka/čeština"],
|
||||
}
|
||||
|
||||
save_global_config(test_config)
|
||||
loaded_config = load_global_config()
|
||||
|
||||
assert loaded_config["last_folder"] == "/cesta/s/českými/znaky"
|
||||
assert loaded_config["recent_folders"] == ["/složka/čeština"]
|
||||
|
||||
def test_global_config_returns_new_dict(self, temp_global_config):
|
||||
"""Test že load_global_config vrací nový dictionary"""
|
||||
config1 = load_global_config()
|
||||
config2 = load_global_config()
|
||||
|
||||
assert config1 is not config2
|
||||
assert config1 == config2
|
||||
|
||||
def test_global_config_recent_folders(self, temp_global_config):
|
||||
"""Test ukládání recent_folders"""
|
||||
folders = ["/path/one", "/path/two", "/path/three"]
|
||||
test_config = {**DEFAULT_GLOBAL_CONFIG, "recent_folders": folders}
|
||||
|
||||
save_global_config(test_config)
|
||||
loaded = load_global_config()
|
||||
|
||||
assert loaded["recent_folders"] == folders
|
||||
assert len(loaded["recent_folders"]) == 3
|
||||
|
||||
|
||||
class TestLegacyFunctions:
|
||||
"""Testy pro zpětnou kompatibilitu"""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_global_config(self, tmp_path, monkeypatch):
|
||||
"""Fixture pro dočasný globální config soubor"""
|
||||
config_path = tmp_path / "config.json"
|
||||
import src.core.config as config_module
|
||||
|
||||
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path)
|
||||
return config_path
|
||||
|
||||
def test_load_config_legacy(self, temp_global_config):
|
||||
"""Test že load_config funguje jako alias pro load_global_config"""
|
||||
test_config = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/test"}
|
||||
|
||||
save_global_config(test_config)
|
||||
loaded = load_config()
|
||||
|
||||
assert loaded["last_folder"] == "/test"
|
||||
|
||||
def test_save_config_legacy(self, temp_global_config):
|
||||
"""Test že save_config funguje jako alias pro save_global_config"""
|
||||
test_config = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/legacy"}
|
||||
|
||||
save_config(test_config)
|
||||
loaded = load_global_config()
|
||||
|
||||
assert loaded["last_folder"] == "/legacy"
|
||||
|
||||
|
||||
class TestConfigEdgeCases:
|
||||
"""Testy pro edge cases"""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_global_config(self, tmp_path, monkeypatch):
|
||||
"""Fixture pro dočasný globální config soubor"""
|
||||
config_path = tmp_path / "config.json"
|
||||
import src.core.config as config_module
|
||||
|
||||
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path)
|
||||
return config_path
|
||||
|
||||
def test_config_path_with_spaces(self, temp_global_config):
|
||||
"""Test s cestou obsahující mezery"""
|
||||
test_config = {
|
||||
**DEFAULT_GLOBAL_CONFIG,
|
||||
"last_folder": "/path/with spaces/in name",
|
||||
}
|
||||
|
||||
save_global_config(test_config)
|
||||
loaded = load_global_config()
|
||||
|
||||
assert loaded["last_folder"] == "/path/with spaces/in name"
|
||||
|
||||
def test_config_long_path(self, temp_global_config):
|
||||
"""Test s dlouhou cestou"""
|
||||
long_path = "/very/long/path/" + "subdir/" * 50 + "final"
|
||||
test_config = {**DEFAULT_GLOBAL_CONFIG, "last_folder": long_path}
|
||||
|
||||
save_global_config(test_config)
|
||||
loaded = load_global_config()
|
||||
|
||||
assert loaded["last_folder"] == long_path
|
||||
|
||||
def test_config_many_recent_folders(self, temp_global_config):
|
||||
"""Test s velkým počtem recent folders"""
|
||||
folders = [f"/path/folder{i}" for i in range(100)]
|
||||
test_config = {**DEFAULT_GLOBAL_CONFIG, "recent_folders": folders}
|
||||
|
||||
save_global_config(test_config)
|
||||
loaded = load_global_config()
|
||||
|
||||
assert len(loaded["recent_folders"]) == 100
|
||||
|
||||
def test_config_json_formatting(self, temp_global_config):
|
||||
"""Test že config je uložen ve správném JSON formátu s indentací"""
|
||||
test_config = {**DEFAULT_GLOBAL_CONFIG}
|
||||
|
||||
save_global_config(test_config)
|
||||
|
||||
with open(temp_global_config, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Mělo by být naformátováno s indentací
|
||||
assert " " in content
|
||||
|
||||
def test_config_ensure_ascii_false(self, temp_global_config):
|
||||
"""Test že ensure_ascii=False funguje správně"""
|
||||
test_config = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/cesta/čeština"}
|
||||
|
||||
save_global_config(test_config)
|
||||
|
||||
with open(temp_global_config, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
assert "čeština" in content
|
||||
assert "\\u" not in content # Nemělo by být escapováno
|
||||
|
||||
def test_config_overwrite(self, temp_global_config):
|
||||
"""Test přepsání existující konfigurace"""
|
||||
config1 = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/path1"}
|
||||
config2 = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/path2"}
|
||||
|
||||
save_global_config(config1)
|
||||
save_global_config(config2)
|
||||
|
||||
loaded = load_global_config()
|
||||
assert loaded["last_folder"] == "/path2"
|
||||
@@ -1,145 +0,0 @@
|
||||
"""Tests for constants module."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.constants import (
|
||||
APP_NAME,
|
||||
APP_TITLE,
|
||||
APP_VERSION,
|
||||
ENV_DEBUG,
|
||||
get_debug_mode,
|
||||
get_version,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_version()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_version_returns_string() -> None:
|
||||
"""get_version() should return a string."""
|
||||
assert isinstance(get_version(), str)
|
||||
|
||||
|
||||
def test_get_version_semver_format() -> None:
|
||||
"""get_version() should return a semver-like string X.Y.Z."""
|
||||
version = get_version()
|
||||
assert re.match(r"^\d+\.\d+\.\d+", version), f"Not semver: {version!r}"
|
||||
|
||||
|
||||
def test_get_version_fallback_when_toml_missing(tmp_path: Path) -> None:
|
||||
"""get_version() returns '0.0.0-unknown' when pyproject.toml and _version.py are both missing."""
|
||||
missing = tmp_path / "nonexistent.toml"
|
||||
with patch("src.constants._PYPROJECT_PATH", missing):
|
||||
result = get_version()
|
||||
# Either fallback _version.py exists (from previous run) or returns unknown
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
def test_get_version_unknown_fallback(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""get_version() returns '0.0.0-unknown' when all sources are unavailable."""
|
||||
missing = tmp_path / "nonexistent.toml"
|
||||
monkeypatch.setattr("src.constants._PYPROJECT_PATH", missing)
|
||||
|
||||
# Patch _version import to also fail
|
||||
with (
|
||||
patch("src.constants.Path.write_text", side_effect=OSError),
|
||||
patch.dict("sys.modules", {"src._version": None}),
|
||||
):
|
||||
result = get_version()
|
||||
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_debug_mode()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_debug_mode_returns_bool() -> None:
|
||||
"""get_debug_mode() should always return a bool."""
|
||||
assert isinstance(get_debug_mode(), bool)
|
||||
|
||||
|
||||
def test_get_debug_mode_true(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""get_debug_mode() returns True when ENV_DEBUG=true."""
|
||||
monkeypatch.setenv("ENV_DEBUG", "true")
|
||||
assert get_debug_mode() is True
|
||||
|
||||
|
||||
def test_get_debug_mode_true_variants(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""get_debug_mode() accepts '1' and 'yes' as truthy values."""
|
||||
for value in ("1", "yes", "YES", "True", "TRUE"):
|
||||
monkeypatch.setenv("ENV_DEBUG", value)
|
||||
assert get_debug_mode() is True, f"Expected True for ENV_DEBUG={value!r}"
|
||||
|
||||
|
||||
def test_get_debug_mode_false(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""get_debug_mode() returns False when ENV_DEBUG=false."""
|
||||
monkeypatch.setenv("ENV_DEBUG", "false")
|
||||
assert get_debug_mode() is False
|
||||
|
||||
|
||||
def test_get_debug_mode_false_when_unset(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""get_debug_mode() returns False when ENV_DEBUG is not set."""
|
||||
monkeypatch.delenv("ENV_DEBUG", raising=False)
|
||||
assert get_debug_mode() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_env_debug_is_bool() -> None:
|
||||
"""ENV_DEBUG should be a bool."""
|
||||
assert isinstance(ENV_DEBUG, bool)
|
||||
|
||||
|
||||
def test_app_version_is_string() -> None:
|
||||
"""APP_VERSION should be a string."""
|
||||
assert isinstance(APP_VERSION, str)
|
||||
|
||||
|
||||
def test_app_version_semver_format() -> None:
|
||||
"""APP_VERSION should follow semver format X.Y.Z."""
|
||||
assert re.match(r"^\d+\.\d+\.\d+", APP_VERSION), f"Not semver: {APP_VERSION!r}"
|
||||
|
||||
|
||||
def test_app_name_value() -> None:
|
||||
"""APP_NAME should be 'Curator'."""
|
||||
assert APP_NAME == "Curator"
|
||||
|
||||
|
||||
def test_app_title_contains_name_and_version() -> None:
|
||||
"""APP_TITLE should contain APP_NAME and APP_VERSION."""
|
||||
assert APP_NAME in APP_TITLE
|
||||
assert APP_VERSION in APP_TITLE
|
||||
|
||||
|
||||
def test_app_title_dev_suffix_when_debug(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""APP_TITLE ends with '-DEV' when ENV_DEBUG is True."""
|
||||
import src.constants as consts
|
||||
|
||||
monkeypatch.setenv("ENV_DEBUG", "true")
|
||||
monkeypatch.setattr(consts, "ENV_DEBUG", True)
|
||||
title = f"{consts.APP_NAME} v{consts.APP_VERSION}" + ("-DEV" if True else "")
|
||||
assert title.endswith("-DEV")
|
||||
|
||||
|
||||
def test_app_title_no_dev_suffix_when_not_debug(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""APP_TITLE does not end with '-DEV' when ENV_DEBUG is False."""
|
||||
import src.constants as consts
|
||||
|
||||
monkeypatch.setattr(consts, "ENV_DEBUG", False)
|
||||
title = f"{consts.APP_NAME} v{consts.APP_VERSION}" + ("-DEV" if False else "")
|
||||
assert not title.endswith("-DEV")
|
||||
@@ -1,279 +0,0 @@
|
||||
"""Tests for the lean ČSFD title-bridge scraper (`src.core.csfd`)."""
|
||||
|
||||
import hashlib
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core import csfd
|
||||
from src.core.csfd import (
|
||||
CsfdError,
|
||||
CsfdFilm,
|
||||
_extract_csfd_id,
|
||||
_is_film_href,
|
||||
_parse_origin,
|
||||
_parse_original_title,
|
||||
_solve_anubis_pow,
|
||||
clean_filename_to_query,
|
||||
fetch_film,
|
||||
find_csfd_url,
|
||||
search_films,
|
||||
)
|
||||
|
||||
# A trimmed ČSFD film page: Czech title in JSON-LD, USA origin, and a
|
||||
# ``.film-names`` list whose USA-flagged entry is the original title.
|
||||
FILM_HTML = """
|
||||
<html><head>
|
||||
<script type="application/ld+json">
|
||||
{"@context":"https://schema.org/","@type":"Movie","name":"Vykoupení z věznice Shawshank",
|
||||
"dateCreated":"1994","duration":"PT142M"}
|
||||
</script>
|
||||
</head><body>
|
||||
<div class="film-header-name">
|
||||
<h1>Vykoupení z věznice Shawshank</h1>
|
||||
<ul class="film-names">
|
||||
<li><img src="//x/us.svg" class="flag" title="USA" alt="USA"/> The Shawshank Redemption
|
||||
<span class="normal more-name-link"><a class="more">více</a></span></li>
|
||||
<li class="more-names hidden"><img src="//x/sk.svg" class="flag" title="Slovensko"/>
|
||||
Vykúpenie z väznice Shawshank</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="origin">USA <span class="bullet"></span>
|
||||
<span>1994 <span class="bullet"></span> </span>142 min</div>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
# A Czech film: ČSFD omits the Czech title from ``.film-names`` (it equals the
|
||||
# <h1>) and lists only a foreign festival title — the original title must fall
|
||||
# back to the <h1>, not "Cosy Dens".
|
||||
CZECH_FILM_HTML = """
|
||||
<html><head>
|
||||
<script type="application/ld+json">
|
||||
{"@type":"Movie","name":"Pelíšky","datePublished":"1999-04-01"}
|
||||
</script>
|
||||
</head><body>
|
||||
<div class="film-header-name"><h1>Pelíšky</h1>
|
||||
<ul class="film-names">
|
||||
<li><img class="flag" title="angličtina"/> Cosy Dens</li>
|
||||
<li><img class="flag" title="Slovensko"/> Pelíšky</li>
|
||||
</ul></div>
|
||||
<div class="origin">Česko <span class="bullet"></span><span>1999</span></div>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
# The real search page groups hits into <section> blocks; a.film-title-name also
|
||||
# matches series episodes, so search_films must scope to "Filmy" and drop the
|
||||
# two-segment episode hrefs.
|
||||
SEARCH_HTML = """
|
||||
<html><body>
|
||||
<section><div class="box-header"><h2>Filmy</h2></div>
|
||||
<a href="/film/2294-vykoupeni-z-veznice-shawshank/prehled/" class="film-title-name">Vykoupení z věznice Shawshank</a>
|
||||
<a href="/film/248803-film-o-filmu/prehled/" class="film-title-name">Film o filmu</a>
|
||||
</section>
|
||||
<section><div class="box-header"><h2>Seriály</h2></div>
|
||||
<a href="/film/318290-arrow/620181-x/prehled/" class="film-title-name">Arrow - Vykoupení</a>
|
||||
</section>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
class TestCleanFilenameToQuery:
|
||||
def test_strips_release_tags_and_keeps_year(self):
|
||||
assert (
|
||||
clean_filename_to_query("Matrix.1999.1080p.BluRay.x264-GROUP.mkv")
|
||||
== "Matrix 1999"
|
||||
)
|
||||
|
||||
def test_handles_spaces_and_parens_year(self):
|
||||
assert (
|
||||
clean_filename_to_query("Forrest Gump (1994) 2160p HDR.mkv")
|
||||
== "Forrest Gump 1994"
|
||||
)
|
||||
|
||||
def test_no_year_no_markers(self):
|
||||
assert clean_filename_to_query("Amelie.mkv") == "Amelie"
|
||||
|
||||
def test_underscores_and_resolution(self):
|
||||
assert clean_filename_to_query("Sam_doma_720p.mkv") == "Sam doma"
|
||||
|
||||
def test_falls_back_to_stem_when_starting_with_marker(self):
|
||||
assert clean_filename_to_query("1080p.mkv") == "1080p"
|
||||
|
||||
|
||||
class TestExtractCsfdId:
|
||||
def test_extract_from_various_urls(self):
|
||||
assert (
|
||||
_extract_csfd_id("https://www.csfd.cz/film/2294-vykoupeni/prehled/") == 2294
|
||||
)
|
||||
assert _extract_csfd_id("/film/999/") == 999
|
||||
|
||||
def test_none_when_absent(self):
|
||||
assert _extract_csfd_id("https://www.csfd.cz/") is None
|
||||
|
||||
|
||||
class TestAnubisPoW:
|
||||
def test_solve_difficulty_one_gives_one_leading_zero_nibble(self):
|
||||
hash_hex, nonce, _ = _solve_anubis_pow("abc123", difficulty=1)
|
||||
assert hash_hex[0] == "0"
|
||||
assert hashlib.sha256(f"abc123{nonce}".encode()).hexdigest() == hash_hex
|
||||
|
||||
def test_solve_difficulty_two_gives_leading_zero_byte(self):
|
||||
hash_hex, _, _ = _solve_anubis_pow("seed", difficulty=2)
|
||||
assert hash_hex[:2] == "00"
|
||||
|
||||
|
||||
class TestParseOrigin:
|
||||
def test_country_and_year_from_bullet_format(self):
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
soup = BeautifulSoup(FILM_HTML, "html.parser")
|
||||
assert _parse_origin(soup) == ("USA", "1994")
|
||||
|
||||
def test_first_country_of_coproduction(self):
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
html = (
|
||||
'<div class="origin">Francie / Německo <span class="bullet"></span>'
|
||||
"<span>2001</span> 120 min</div>"
|
||||
)
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
assert _parse_origin(soup) == ("Francie", "2001")
|
||||
|
||||
|
||||
class TestParseOriginalTitle:
|
||||
def test_picks_name_matching_origin_country(self):
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
soup = BeautifulSoup(FILM_HTML, "html.parser")
|
||||
assert _parse_original_title(soup, "USA") == "The Shawshank Redemption"
|
||||
|
||||
def test_none_without_country_match_so_caller_uses_czech_title(self):
|
||||
# A Czech film lists only foreign/festival alt-titles here — none match
|
||||
# the origin country, so we must NOT return one of them.
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
html = (
|
||||
'<ul class="film-names">'
|
||||
'<li><img class="flag" title="angličtina"/> Loners</li>'
|
||||
'<li><img class="flag" title="Slovinsko"/> Samotarji</li></ul>'
|
||||
)
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
assert _parse_original_title(soup, "Česko") is None
|
||||
|
||||
def test_cesko_matches_ceskoslovensko_flag(self):
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
html = (
|
||||
'<ul class="film-names">'
|
||||
'<li><img class="flag" title="Československo"/> Ostře sledované vlaky</li>'
|
||||
"</ul>"
|
||||
)
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
assert _parse_original_title(soup, "Česko") == "Ostře sledované vlaky"
|
||||
|
||||
def test_none_when_no_names(self):
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
soup = BeautifulSoup("<div>x</div>", "html.parser")
|
||||
assert _parse_original_title(soup, "USA") is None
|
||||
|
||||
|
||||
class TestFetchFilm:
|
||||
def test_parses_czech_and_original_title_and_year(self):
|
||||
class _Resp:
|
||||
text = FILM_HTML
|
||||
|
||||
with patch("src.core.csfd._get_page", return_value=_Resp()):
|
||||
film = fetch_film("https://www.csfd.cz/film/2294-x/")
|
||||
|
||||
assert film.czech_title == "Vykoupení z věznice Shawshank"
|
||||
assert film.original_title == "The Shawshank Redemption"
|
||||
assert film.year == "1994"
|
||||
assert film.csfd_id == 2294
|
||||
assert film.tmdb_query == ("The Shawshank Redemption", "1994")
|
||||
|
||||
def test_czech_film_original_title_falls_back_to_czech(self):
|
||||
class _Resp:
|
||||
text = CZECH_FILM_HTML
|
||||
|
||||
with patch("src.core.csfd._get_page", return_value=_Resp()):
|
||||
film = fetch_film("https://www.csfd.cz/film/15387-pelisky/")
|
||||
|
||||
assert film.czech_title == "Pelíšky"
|
||||
assert film.original_title == "Pelíšky"
|
||||
assert film.year == "1999"
|
||||
assert film.tmdb_query == ("Pelíšky", "1999")
|
||||
|
||||
def test_wraps_network_error_in_csfd_error(self):
|
||||
import requests
|
||||
|
||||
with (
|
||||
patch(
|
||||
"src.core.csfd._get_page", side_effect=requests.RequestException("boom")
|
||||
),
|
||||
pytest.raises(CsfdError),
|
||||
):
|
||||
fetch_film("https://www.csfd.cz/film/1/")
|
||||
|
||||
|
||||
class TestIsFilmHref:
|
||||
def test_accepts_single_segment_film(self):
|
||||
assert _is_film_href("/film/2294-vykoupeni/prehled/") is True
|
||||
assert _is_film_href("/film/2294-vykoupeni/") is True
|
||||
|
||||
def test_rejects_series_episode(self):
|
||||
assert _is_film_href("/film/318290-arrow/620181-vykoupeni/prehled/") is False
|
||||
|
||||
def test_rejects_non_film(self):
|
||||
assert _is_film_href("/tvurce/123-nekdo/") is False
|
||||
|
||||
|
||||
class TestSearchFilms:
|
||||
def test_scopes_to_films_section_and_drops_episodes(self):
|
||||
class _Resp:
|
||||
text = SEARCH_HTML
|
||||
|
||||
with patch("src.core.csfd._get_page", return_value=_Resp()):
|
||||
films = search_films("shawshank", limit=5)
|
||||
|
||||
# the Seriály-section episode link is excluded
|
||||
assert [f.csfd_id for f in films] == [2294, 248803]
|
||||
assert films[0].url == (
|
||||
"https://www.csfd.cz/film/2294-vykoupeni-z-veznice-shawshank/prehled/"
|
||||
)
|
||||
|
||||
def test_empty_query_returns_empty(self):
|
||||
assert search_films(" ") == []
|
||||
|
||||
|
||||
class TestFindCsfdUrl:
|
||||
def test_returns_first_hit_url(self):
|
||||
hit = CsfdFilm(url="https://www.csfd.cz/film/2294-x/", csfd_id=2294)
|
||||
with patch("src.core.csfd.search_films", return_value=[hit]):
|
||||
assert find_csfd_url("shawshank 1994") == "https://www.csfd.cz/film/2294-x/"
|
||||
|
||||
def test_retries_without_trailing_year_when_first_query_misses(self):
|
||||
hit = CsfdFilm(
|
||||
url="https://www.csfd.cz/film/221006-vratne-lahve/", csfd_id=221006
|
||||
)
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_search(query, limit=1, session=None):
|
||||
calls.append(query)
|
||||
return [] if query.endswith("2006") else [hit]
|
||||
|
||||
with patch("src.core.csfd.search_films", side_effect=fake_search):
|
||||
assert find_csfd_url("Vratne lahve 2006") == hit.url
|
||||
assert calls == ["Vratne lahve 2006", "Vratne lahve"]
|
||||
|
||||
def test_none_for_blank_query(self):
|
||||
assert find_csfd_url(" ") is None
|
||||
|
||||
def test_none_when_no_results(self):
|
||||
with patch("src.core.csfd.search_films", return_value=[]):
|
||||
assert find_csfd_url("nic") is None
|
||||
|
||||
|
||||
class TestDependencies:
|
||||
def test_dependencies_available_in_test_env(self):
|
||||
assert csfd.dependencies_available() is True
|
||||
@@ -1,343 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from src.core.file import File
|
||||
from src.core.pool_index import PoolIndex
|
||||
from src.core.tag import Tag
|
||||
from src.core.tag_manager import TagManager
|
||||
|
||||
|
||||
class TestFile:
|
||||
"""Testy pro třídu File (metadata žijí v pool indexu)"""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
return TagManager()
|
||||
|
||||
@pytest.fixture
|
||||
def index(self, tmp_path):
|
||||
"""Pool index that backs the File's metadata."""
|
||||
return PoolIndex(tmp_path)
|
||||
|
||||
@pytest.fixture
|
||||
def test_file(self, tmp_path):
|
||||
f = tmp_path / "test.txt"
|
||||
f.write_text("test content")
|
||||
return f
|
||||
|
||||
def test_file_creation(self, test_file, tag_manager, index):
|
||||
file_obj = File(test_file, tag_manager, index=index)
|
||||
assert file_obj.file_path == test_file
|
||||
assert file_obj.filename == "test.txt"
|
||||
assert file_obj.new is True
|
||||
|
||||
def test_file_initial_tags(self, test_file, tag_manager, index):
|
||||
"""Nový soubor nemá žádné automatické tagy (Stav/Nové odstraněn)."""
|
||||
file_obj = File(test_file, tag_manager, index=index)
|
||||
assert file_obj.tags == []
|
||||
|
||||
def test_file_metadata_saved_to_index(self, test_file, tag_manager, index):
|
||||
"""Metadata jsou zapsána do indexu už při vytvoření."""
|
||||
file_obj = File(test_file, tag_manager, index=index)
|
||||
assert index.record_for(file_obj.file_path) is not None
|
||||
|
||||
def test_file_no_sidecar_written(self, test_file, tag_manager, index):
|
||||
"""Žádný .!tag sidecar už nevzniká."""
|
||||
File(test_file, tag_manager, index=index)
|
||||
assert not (test_file.parent / ".test.txt.!tag").exists()
|
||||
|
||||
def test_index_less_file_is_in_memory_only(self, test_file, tag_manager):
|
||||
"""Bez indexu je File jen v paměti — nic se nezapíše na disk."""
|
||||
file_obj = File(test_file, tag_manager)
|
||||
file_obj.add_tag("Video/HD")
|
||||
file_obj.save_metadata() # no-op
|
||||
assert not (test_file.parent / ".test.txt.!tag").exists()
|
||||
|
||||
def test_file_save_metadata(self, test_file, tag_manager, index):
|
||||
file_obj = File(test_file, tag_manager, index=index)
|
||||
file_obj.new = False
|
||||
file_obj.ignored = True
|
||||
file_obj.save_metadata()
|
||||
|
||||
data = index.record_for(file_obj.file_path)
|
||||
assert data["new"] is False
|
||||
assert data["ignored"] is True
|
||||
|
||||
def test_file_load_metadata(self, test_file, tag_manager, index):
|
||||
file_obj = File(test_file, tag_manager, index=index)
|
||||
tag = tag_manager.add_tag("Video", "HD")
|
||||
file_obj.tags.append(tag)
|
||||
file_obj.date = "2025-01-15"
|
||||
file_obj.save_metadata()
|
||||
|
||||
# A fresh File over the same index reloads the metadata
|
||||
file_obj2 = File(test_file, tag_manager, index=index)
|
||||
assert len(file_obj2.tags) == 1 # Video/HD
|
||||
assert file_obj2.date == "2025-01-15"
|
||||
assert "Video/HD" in {t.full_path for t in file_obj2.tags}
|
||||
|
||||
def test_file_set_date(self, test_file, tag_manager, index):
|
||||
file_obj = File(test_file, tag_manager, index=index)
|
||||
file_obj.set_date("2025-12-25")
|
||||
assert file_obj.date == "2025-12-25"
|
||||
assert index.record_for(file_obj.file_path)["date"] == "2025-12-25"
|
||||
|
||||
def test_file_set_date_to_none(self, test_file, tag_manager, index):
|
||||
file_obj = File(test_file, tag_manager, index=index)
|
||||
file_obj.set_date("2025-12-25")
|
||||
file_obj.set_date(None)
|
||||
assert file_obj.date is None
|
||||
|
||||
def test_file_set_date_empty_string(self, test_file, tag_manager, index):
|
||||
file_obj = File(test_file, tag_manager, index=index)
|
||||
file_obj.set_date("2025-12-25")
|
||||
file_obj.set_date("")
|
||||
assert file_obj.date is None
|
||||
|
||||
def test_file_add_tag_object(self, test_file, tag_manager, index):
|
||||
file_obj = File(test_file, tag_manager, index=index)
|
||||
tag = Tag("Video", "4K")
|
||||
file_obj.add_tag(tag)
|
||||
|
||||
assert tag in file_obj.tags
|
||||
assert len(file_obj.tags) == 1
|
||||
|
||||
def test_file_add_tag_string(self, test_file, tag_manager, index):
|
||||
file_obj = File(test_file, tag_manager, index=index)
|
||||
file_obj.add_tag("Audio/MP3")
|
||||
assert "Audio/MP3" in {t.full_path for t in file_obj.tags}
|
||||
|
||||
def test_file_add_tag_string_without_category(self, test_file, tag_manager, index):
|
||||
file_obj = File(test_file, tag_manager, index=index)
|
||||
file_obj.add_tag("SimpleTag")
|
||||
assert "default/SimpleTag" in {t.full_path for t in file_obj.tags}
|
||||
|
||||
def test_file_add_duplicate_tag(self, test_file, tag_manager, index):
|
||||
file_obj = File(test_file, tag_manager, index=index)
|
||||
tag = Tag("Video", "HD")
|
||||
file_obj.add_tag(tag)
|
||||
file_obj.add_tag(tag)
|
||||
assert sum(1 for t in file_obj.tags if t == tag) == 1
|
||||
|
||||
def test_file_remove_tag_object(self, test_file, tag_manager, index):
|
||||
file_obj = File(test_file, tag_manager, index=index)
|
||||
tag = Tag("Video", "HD")
|
||||
file_obj.add_tag(tag)
|
||||
file_obj.remove_tag(tag)
|
||||
assert tag not in file_obj.tags
|
||||
|
||||
def test_file_remove_tag_string(self, test_file, tag_manager, index):
|
||||
file_obj = File(test_file, tag_manager, index=index)
|
||||
file_obj.add_tag("Video/HD")
|
||||
file_obj.remove_tag("Video/HD")
|
||||
assert "Video/HD" not in {t.full_path for t in file_obj.tags}
|
||||
|
||||
def test_file_remove_tag_string_without_category(
|
||||
self, test_file, tag_manager, index
|
||||
):
|
||||
file_obj = File(test_file, tag_manager, index=index)
|
||||
file_obj.add_tag("SimpleTag")
|
||||
file_obj.remove_tag("SimpleTag")
|
||||
assert "default/SimpleTag" not in {t.full_path for t in file_obj.tags}
|
||||
|
||||
def test_file_remove_nonexistent_tag(self, test_file, tag_manager, index):
|
||||
file_obj = File(test_file, tag_manager, index=index)
|
||||
initial_count = len(file_obj.tags)
|
||||
file_obj.remove_tag("Nonexistent/Tag")
|
||||
assert len(file_obj.tags) == initial_count
|
||||
|
||||
def test_file_without_tagmanager(self, test_file):
|
||||
file_obj = File(test_file, tagmanager=None)
|
||||
assert file_obj.tagmanager is None
|
||||
assert len(file_obj.tags) == 0
|
||||
|
||||
def test_file_metadata_persistence(self, test_file, tag_manager, index):
|
||||
file_obj1 = File(test_file, tag_manager, index=index)
|
||||
file_obj1.add_tag("Video/HD")
|
||||
file_obj1.add_tag("Audio/Stereo")
|
||||
file_obj1.set_date("2025-01-01")
|
||||
file_obj1.new = False
|
||||
file_obj1.ignored = True
|
||||
file_obj1.save_metadata()
|
||||
|
||||
file_obj2 = File(test_file, tag_manager, index=index)
|
||||
assert file_obj2.new is False
|
||||
assert file_obj2.ignored is True
|
||||
assert file_obj2.date == "2025-01-01"
|
||||
tag_paths = {t.full_path for t in file_obj2.tags}
|
||||
assert "Video/HD" in tag_paths
|
||||
assert "Audio/Stereo" in tag_paths
|
||||
|
||||
def test_file_metadata_record_shape(self, test_file, tag_manager, index):
|
||||
file_obj = File(test_file, tag_manager, index=index)
|
||||
file_obj.add_tag("Test/Tag")
|
||||
file_obj.set_date("2025-06-15")
|
||||
|
||||
data = index.record_for(file_obj.file_path)
|
||||
assert "new" in data
|
||||
assert "ignored" in data
|
||||
assert "tags" in data
|
||||
assert "date" in data
|
||||
assert isinstance(data["tags"], list)
|
||||
|
||||
def test_file_unicode_handling(self, tmp_path, tag_manager, index):
|
||||
test_file = tmp_path / "český_soubor.txt"
|
||||
test_file.write_text("obsah")
|
||||
|
||||
file_obj = File(test_file, tag_manager, index=index)
|
||||
file_obj.add_tag("Kategorie/Český tag")
|
||||
file_obj.save_metadata()
|
||||
|
||||
file_obj2 = File(test_file, tag_manager, index=index)
|
||||
assert "Kategorie/Český tag" in {t.full_path for t in file_obj2.tags}
|
||||
|
||||
def test_file_complex_scenario(self, test_file, tag_manager, index):
|
||||
file_obj = File(test_file, tag_manager, index=index)
|
||||
file_obj.add_tag("Video/HD")
|
||||
file_obj.add_tag("Video/Stereo")
|
||||
file_obj.add_tag("Stav/Zkontrolováno")
|
||||
file_obj.set_date("2025-01-01")
|
||||
file_obj.remove_tag("Stav/Nové")
|
||||
|
||||
tag_paths = {t.full_path for t in file_obj.tags}
|
||||
assert "Video/HD" in tag_paths
|
||||
assert "Video/Stereo" in tag_paths
|
||||
assert "Stav/Zkontrolováno" in tag_paths
|
||||
assert "Stav/Nové" not in tag_paths
|
||||
assert file_obj.date == "2025-01-01"
|
||||
|
||||
file_obj2 = File(test_file, tag_manager, index=index)
|
||||
assert {t.full_path for t in file_obj2.tags} == tag_paths
|
||||
assert file_obj2.date == "2025-01-01"
|
||||
|
||||
|
||||
class TestApplyNfoTags:
|
||||
"""Tests for File.apply_nfo tag assignment (NFO parsed from disk)."""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
return TagManager()
|
||||
|
||||
@pytest.fixture
|
||||
def index(self, tmp_path):
|
||||
return PoolIndex(tmp_path)
|
||||
|
||||
@pytest.fixture
|
||||
def movie_file(self, tmp_path, tag_manager, index):
|
||||
path = tmp_path / "Matrix.mkv"
|
||||
path.write_text("x")
|
||||
return File(path, tag_manager, index=index)
|
||||
|
||||
def test_apply_nfo_assigns_expected_categories(self, movie_file):
|
||||
from src.core.nfo import NfoData
|
||||
|
||||
nfo = NfoData(
|
||||
title="Matrix",
|
||||
year="1999",
|
||||
genres=["Akční", "Sci-Fi"],
|
||||
countries=["USA"],
|
||||
rating=90,
|
||||
)
|
||||
result = movie_file.apply_nfo(nfo)
|
||||
|
||||
assert result["success"]
|
||||
paths = {t.full_path for t in movie_file.tags}
|
||||
assert "Žánr/Akční" in paths
|
||||
assert "Žánr/Sci-Fi" in paths
|
||||
assert "Rok/1999" in paths
|
||||
assert "Země původu/USA" in paths
|
||||
assert "Hodnocení/90" in paths # exact value; folder grouping happens later
|
||||
|
||||
def test_apply_nfo_none_is_noop(self, movie_file):
|
||||
result = movie_file.apply_nfo(None)
|
||||
assert not result["success"]
|
||||
assert movie_file.tags == []
|
||||
|
||||
def test_name_context_fields(self, movie_file):
|
||||
movie_file.title = "Dr. No"
|
||||
movie_file.meta = {"year": 1962, "rating": 75}
|
||||
ctx = movie_file.name_context()
|
||||
assert ctx["title"] == "Dr. No"
|
||||
assert ctx["year"] == "1962" # coerced to str for uniform template fields
|
||||
assert ctx["rating"] == 75
|
||||
assert ctx["ext"] == movie_file.file_path.suffix
|
||||
assert ctx["filename"] == movie_file.filename
|
||||
assert "{year} - {title}{ext}".format(**ctx) == f"1962 - Dr. No{ctx['ext']}"
|
||||
|
||||
def test_name_context_year_from_tag_when_no_cache(self, movie_file):
|
||||
movie_file.meta = None
|
||||
movie_file.add_tag("Rok/1999")
|
||||
assert movie_file.name_context()["year"] == "1999"
|
||||
|
||||
def test_added_timestamp_uses_stored_added(self, movie_file):
|
||||
movie_file.added = "2026-06-16T12:00:00"
|
||||
from datetime import datetime
|
||||
|
||||
assert (
|
||||
movie_file.added_timestamp()
|
||||
== datetime.fromisoformat("2026-06-16T12:00:00").timestamp()
|
||||
)
|
||||
|
||||
def test_added_timestamp_falls_back_to_mtime(self, movie_file):
|
||||
movie_file.added = None
|
||||
import os
|
||||
|
||||
expected = os.stat(movie_file.file_path).st_mtime
|
||||
assert movie_file.added_timestamp() == expected
|
||||
|
||||
def test_set_attribute_persists_and_in_context(self, movie_file):
|
||||
movie_file.set_attribute("collection_sort", "03")
|
||||
assert movie_file.attributes["collection_sort"] == "03"
|
||||
assert movie_file.name_context()["collection_sort"] == "03"
|
||||
# reload (from index) keeps it
|
||||
reloaded = File(
|
||||
movie_file.file_path, movie_file.tagmanager, index=movie_file.index
|
||||
)
|
||||
assert reloaded.attributes["collection_sort"] == "03"
|
||||
|
||||
def test_set_attribute_empty_removes_it(self, movie_file):
|
||||
movie_file.set_attribute("collection_sort", "03")
|
||||
movie_file.set_attribute("collection_sort", None)
|
||||
assert "collection_sort" not in movie_file.attributes
|
||||
|
||||
def test_attribute_usable_in_filename_template(self, movie_file):
|
||||
movie_file.title = "Dr. No"
|
||||
movie_file.set_attribute("collection_sort", "01")
|
||||
ctx = movie_file.name_context()
|
||||
assert (
|
||||
"{collection_sort} - {title}{ext}".format(**ctx)
|
||||
== f"01 - Dr. No{ctx['ext']}"
|
||||
)
|
||||
|
||||
def test_apply_nfo_honors_custom_schema(self, movie_file):
|
||||
"""A schema without the rating entry produces no Hodnocení tags."""
|
||||
from src.core.nfo import NfoData
|
||||
|
||||
schema = [
|
||||
{
|
||||
"category": "Žánr",
|
||||
"csfd_field": "genres",
|
||||
"transform": None,
|
||||
"filmoteka_root": "",
|
||||
}
|
||||
]
|
||||
movie_file.apply_nfo(NfoData(genres=["Drama"], rating=90), schema)
|
||||
|
||||
paths = {t.full_path for t in movie_file.tags}
|
||||
assert "Žánr/Drama" in paths
|
||||
assert not any(p.startswith("Hodnocení/") for p in paths)
|
||||
|
||||
def test_apply_nfo_preserves_user_tags_on_refresh(self, movie_file):
|
||||
"""Re-reading regenerates only auto (NFO) tags; user-added tags survive."""
|
||||
from src.core.nfo import NfoData
|
||||
|
||||
movie_file.add_tag("Sbírka/Oblíbené") # user tag
|
||||
movie_file.apply_nfo(NfoData(year="1999", genres=["Akční"]))
|
||||
movie_file.apply_nfo(NfoData(year="2009", genres=["Drama"]))
|
||||
|
||||
paths = {t.full_path for t in movie_file.tags}
|
||||
assert "Sbírka/Oblíbené" in paths # user tag kept
|
||||
assert "Žánr/Drama" in paths # new NFO tag
|
||||
assert "Rok/2009" in paths
|
||||
assert "Žánr/Akční" not in paths # old NFO tag regenerated away
|
||||
assert "Žánr/Akční" not in paths # stale ČSFD tag dropped
|
||||
assert "Rok/1999" not in paths
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,989 +0,0 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.file import File
|
||||
from src.core.hardlink_manager import HardlinkManager, create_hardlink_structure
|
||||
from src.core.tag import Tag
|
||||
from src.core.tag_manager import TagManager
|
||||
|
||||
|
||||
class TestHardlinkManager:
|
||||
"""Testy pro HardlinkManager"""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
"""Fixture pro TagManager"""
|
||||
tm = TagManager()
|
||||
# Remove default tags for cleaner tests
|
||||
for cat in list(tm.tags_by_category.keys()):
|
||||
tm.remove_category(cat)
|
||||
return tm
|
||||
|
||||
@pytest.fixture
|
||||
def temp_source_dir(self, tmp_path):
|
||||
"""Fixture pro zdrojovou složku s testovacími soubory"""
|
||||
source_dir = tmp_path / "source"
|
||||
source_dir.mkdir()
|
||||
(source_dir / "file1.txt").write_text("content1")
|
||||
(source_dir / "file2.txt").write_text("content2")
|
||||
(source_dir / "file3.txt").write_text("content3")
|
||||
return source_dir
|
||||
|
||||
@pytest.fixture
|
||||
def temp_output_dir(self, tmp_path):
|
||||
"""Fixture pro výstupní složku"""
|
||||
output_dir = tmp_path / "output"
|
||||
output_dir.mkdir()
|
||||
return output_dir
|
||||
|
||||
@pytest.fixture
|
||||
def files_with_tags(self, temp_source_dir, tag_manager):
|
||||
"""Fixture pro soubory s tagy"""
|
||||
files = []
|
||||
|
||||
# File 1 with multiple tags
|
||||
f1 = File(temp_source_dir / "file1.txt", tag_manager)
|
||||
f1.tags.clear() # ensure a clean tag set
|
||||
f1.add_tag(Tag("žánr", "Komedie"))
|
||||
f1.add_tag(Tag("žánr", "Akční"))
|
||||
f1.add_tag(Tag("rok", "1988"))
|
||||
files.append(f1)
|
||||
|
||||
# File 2 with one tag
|
||||
f2 = File(temp_source_dir / "file2.txt", tag_manager)
|
||||
f2.tags.clear() # ensure a clean tag set
|
||||
f2.add_tag(Tag("žánr", "Drama"))
|
||||
files.append(f2)
|
||||
|
||||
# File 3 with no tags
|
||||
f3 = File(temp_source_dir / "file3.txt", tag_manager)
|
||||
f3.tags.clear() # ensure a clean tag set
|
||||
files.append(f3)
|
||||
|
||||
return files
|
||||
|
||||
def test_hardlink_manager_creation(self, temp_output_dir):
|
||||
"""Test vytvoření HardlinkManager"""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
assert manager.output_dir == temp_output_dir
|
||||
assert manager.created_links == []
|
||||
assert manager.errors == []
|
||||
|
||||
def test_create_structure_basic(self, files_with_tags, temp_output_dir):
|
||||
"""Test základního vytvoření struktury"""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
success, fail = manager.create_structure_for_files(files_with_tags)
|
||||
|
||||
# File1 has 3 tags, File2 has 1 tag, File3 has 0 tags
|
||||
# Should create 4 hardlinks total
|
||||
assert success == 4
|
||||
assert fail == 0
|
||||
|
||||
# Check directory structure
|
||||
assert (temp_output_dir / "žánr" / "Komedie" / "file1.txt").exists()
|
||||
assert (temp_output_dir / "žánr" / "Akční" / "file1.txt").exists()
|
||||
assert (temp_output_dir / "rok" / "1988" / "file1.txt").exists()
|
||||
assert (temp_output_dir / "žánr" / "Drama" / "file2.txt").exists()
|
||||
|
||||
def test_hardlinks_are_same_inode(
|
||||
self, files_with_tags, temp_output_dir, temp_source_dir
|
||||
):
|
||||
"""Test že vytvořené soubory jsou opravdu hardlinky (stejný inode)"""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
manager.create_structure_for_files(files_with_tags)
|
||||
|
||||
original = temp_source_dir / "file1.txt"
|
||||
hardlink = temp_output_dir / "žánr" / "Komedie" / "file1.txt"
|
||||
|
||||
# Same inode = hardlink
|
||||
assert original.stat().st_ino == hardlink.stat().st_ino
|
||||
|
||||
def test_create_structure_with_category_filter(
|
||||
self, files_with_tags, temp_output_dir
|
||||
):
|
||||
"""Test vytvoření struktury jen pro vybrané kategorie"""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
success, fail = manager.create_structure_for_files(
|
||||
files_with_tags, categories=["žánr"]
|
||||
)
|
||||
|
||||
# Only "žánr" tags should be processed (3 links)
|
||||
assert success == 3
|
||||
assert fail == 0
|
||||
|
||||
assert (temp_output_dir / "žánr" / "Komedie" / "file1.txt").exists()
|
||||
assert not (temp_output_dir / "rok").exists()
|
||||
|
||||
def test_create_structure_with_category_roots(
|
||||
self, files_with_tags, temp_output_dir
|
||||
):
|
||||
"""category_roots: genres sit at the output root, rok under 'Dle roku'."""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
roots = {"žánr": "", "rok": "Dle roku"}
|
||||
manager.create_structure_for_files(files_with_tags, category_roots=roots)
|
||||
|
||||
# Genres directly at the output root (no "žánr" wrapper folder)
|
||||
assert (temp_output_dir / "Komedie" / "file1.txt").exists()
|
||||
assert (temp_output_dir / "Akční" / "file1.txt").exists()
|
||||
assert (temp_output_dir / "Drama" / "file2.txt").exists()
|
||||
assert not (temp_output_dir / "žánr").exists()
|
||||
|
||||
# Rok grouped under its own "Dle roku" folder
|
||||
assert (temp_output_dir / "Dle roku" / "1988" / "file1.txt").exists()
|
||||
|
||||
def test_sync_with_roots_leaves_unmanaged_mirror_untouched(
|
||||
self, files_with_tags, temp_source_dir, temp_output_dir
|
||||
):
|
||||
"""Cleanup must not delete links in a copy-as-is mirror (e.g. Seriály)."""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
roots = {"žánr": "", "rok": "Dle roku"}
|
||||
manager.create_structure_for_files(files_with_tags, category_roots=roots)
|
||||
|
||||
# Simulate a copy-as-is mirror holding a hardlink to a source file
|
||||
mirror = temp_output_dir / "Seriály"
|
||||
mirror.mkdir()
|
||||
mirror_link = mirror / "file1.txt"
|
||||
os.link(temp_source_dir / "file1.txt", mirror_link)
|
||||
|
||||
manager.sync_structure(
|
||||
files_with_tags, category_roots=roots, reserved_subfolders={"Seriály"}
|
||||
)
|
||||
|
||||
# The mirror (reserved folder) is left alone
|
||||
assert mirror_link.exists()
|
||||
|
||||
def test_sync_removes_orphan_link_from_replaced_movie(
|
||||
self, temp_source_dir, temp_output_dir, tag_manager
|
||||
):
|
||||
"""A stale link pointing at a no-longer-pooled inode is cleaned up."""
|
||||
f = File(temp_source_dir / "file1.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("žánr", "Drama")) # a root-level category enables the sweep
|
||||
f.add_tag(Tag("rok", "1968"))
|
||||
roots = {"žánr": "", "rok": "- Dle roku"}
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
manager.sync_structure([f], category_roots=roots)
|
||||
|
||||
# Simulate a leftover from an OLD layout: a different file (different
|
||||
# inode, not in the pool) hardlinked under an old unprefixed folder.
|
||||
orphan_src = temp_source_dir / "orphan.txt"
|
||||
orphan_src.write_text("old data")
|
||||
old_dir = temp_output_dir / "Dle roku" / "1968"
|
||||
old_dir.mkdir(parents=True)
|
||||
os.link(orphan_src, old_dir / "file1.txt")
|
||||
|
||||
manager.sync_structure([f], category_roots=roots)
|
||||
|
||||
# new prefixed folder kept, stale unprefixed one (orphan) removed
|
||||
assert (temp_output_dir / "- Dle roku" / "1968" / "file1.txt").exists()
|
||||
assert not (temp_output_dir / "Dle roku").exists()
|
||||
|
||||
def test_sync_replaces_orphan_squatting_the_expected_name(
|
||||
self, temp_source_dir, temp_output_dir, tag_manager
|
||||
):
|
||||
"""An orphan link with the *expected name* but a stale inode is replaced.
|
||||
|
||||
Regression: when a movie is re-imported (new inode), the old hardlink
|
||||
keeps the correct name but points at the dead inode. Name-only obsolete
|
||||
detection spared it and forced the real link to a ``_1`` suffix, which
|
||||
was then swept every run — an endless created==removed churn. Obsolete
|
||||
detection now matches the inode too, so the orphan is removed and the
|
||||
base name is restored, stably.
|
||||
"""
|
||||
f = File(temp_source_dir / "file1.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("žánr", "Drama"))
|
||||
roots = {"žánr": ""}
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
manager.sync_structure([f], category_roots=roots)
|
||||
link = temp_output_dir / "Drama" / "file1.txt"
|
||||
assert link.stat().st_ino == f.file_path.stat().st_ino
|
||||
|
||||
# Simulate a re-import: the expected name now points at a *different*
|
||||
# (orphan) inode, exactly as a replaced movie leaves behind.
|
||||
orphan_src = temp_source_dir / "orphan.txt"
|
||||
orphan_src.write_text("old content")
|
||||
link.unlink()
|
||||
os.link(orphan_src, link)
|
||||
assert link.stat().st_ino != f.file_path.stat().st_ino
|
||||
|
||||
created, _, removed, _ = manager.sync_structure([f], category_roots=roots)
|
||||
# The orphan was swept and the correct link recreated under the base name.
|
||||
assert removed >= 1 and created >= 1
|
||||
assert link.stat().st_ino == f.file_path.stat().st_ino
|
||||
assert not (temp_output_dir / "Drama" / "file1_1.txt").exists()
|
||||
|
||||
# And it is now stable: a second run touches nothing (no churn).
|
||||
created2, _, removed2, _ = manager.sync_structure([f], category_roots=roots)
|
||||
assert (created2, removed2) == (0, 0)
|
||||
|
||||
def test_link_name_strips_year_by_default(self, tmp_path, tag_manager):
|
||||
"""A pooled 'Title (YYYY).ext' hardlinks into the tag tree as 'Title.ext'."""
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
(source / "Solaris (1972).mkv").write_text("x")
|
||||
f = File(source / "Solaris (1972).mkv", tag_manager)
|
||||
f.tags.clear()
|
||||
f.title = "Solaris"
|
||||
f.add_tag(Tag("žánr", "Sci-Fi"))
|
||||
|
||||
output = tmp_path / "output"
|
||||
manager = HardlinkManager(output)
|
||||
manager.create_structure_for_files([f], category_roots={"žánr": ""})
|
||||
|
||||
assert (output / "Sci-Fi" / "Solaris.mkv").exists() # year stripped
|
||||
assert not (output / "Sci-Fi" / "Solaris (1972).mkv").exists()
|
||||
|
||||
def test_link_name_strips_year_and_tmdbid(self, tmp_path, tag_manager):
|
||||
"""A pooled 'Title (YYYY) [tmdbid-N].ext' links as clean 'Title.ext'."""
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
(source / "Matrix (1999) [tmdbid-603].mkv").write_text("x")
|
||||
f = File(source / "Matrix (1999) [tmdbid-603].mkv", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("žánr", "Sci-Fi"))
|
||||
|
||||
output = tmp_path / "output"
|
||||
manager = HardlinkManager(output)
|
||||
manager.create_structure_for_files([f], category_roots={"žánr": ""})
|
||||
|
||||
assert (output / "Sci-Fi" / "Matrix.mkv").exists() # year + id stripped
|
||||
|
||||
def test_link_name_keeps_year_on_collision(self, tmp_path, tag_manager):
|
||||
"""Two same-title films in one folder keep the year to disambiguate."""
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
(source / "Solaris (1972).mkv").write_text("a")
|
||||
(source / "Solaris (2002).mkv").write_text("b")
|
||||
f1 = File(source / "Solaris (1972).mkv", tag_manager)
|
||||
f1.tags.clear()
|
||||
f1.title = "Solaris"
|
||||
f1.meta = {"year": 1972}
|
||||
f1.add_tag(Tag("žánr", "Sci-Fi"))
|
||||
f1.add_tag(Tag("rok", "1972"))
|
||||
f2 = File(source / "Solaris (2002).mkv", tag_manager)
|
||||
f2.tags.clear()
|
||||
f2.title = "Solaris"
|
||||
f2.meta = {"year": 2002}
|
||||
f2.add_tag(Tag("žánr", "Sci-Fi"))
|
||||
f2.add_tag(Tag("rok", "2002"))
|
||||
|
||||
output = tmp_path / "output"
|
||||
manager = HardlinkManager(output)
|
||||
roots = {"žánr": "", "rok": "Dle roku"}
|
||||
manager.create_structure_for_files([f1, f2], category_roots=roots)
|
||||
|
||||
# Same genre folder → clash → both keep the year
|
||||
assert (output / "Sci-Fi" / "Solaris (1972).mkv").exists()
|
||||
assert (output / "Sci-Fi" / "Solaris (2002).mkv").exists()
|
||||
assert not (output / "Sci-Fi" / "Solaris.mkv").exists()
|
||||
# Separate year folders → no clash → clean name
|
||||
assert (output / "Dle roku" / "1972" / "Solaris.mkv").exists()
|
||||
assert (output / "Dle roku" / "2002" / "Solaris.mkv").exists()
|
||||
|
||||
def test_clean_naming_cleanup_is_consistent(self, tmp_path, tag_manager):
|
||||
"""Clean-named links are stable: a second sync removes/creates nothing."""
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
(source / "Solaris (1972).mkv").write_text("x")
|
||||
f = File(source / "Solaris (1972).mkv", tag_manager)
|
||||
f.tags.clear()
|
||||
f.title = "Solaris"
|
||||
f.add_tag(Tag("žánr", "Sci-Fi"))
|
||||
roots = {"žánr": ""}
|
||||
|
||||
output = tmp_path / "output"
|
||||
manager = HardlinkManager(output)
|
||||
manager.sync_structure([f], category_roots=roots)
|
||||
created, _, removed, _ = manager.sync_structure([f], category_roots=roots)
|
||||
assert (created, removed) == (0, 0)
|
||||
|
||||
def test_sync_removes_root_genre_folder_when_last_movie_drops_tag(
|
||||
self, temp_source_dir, temp_output_dir, tag_manager
|
||||
):
|
||||
"""A genre folder at the output root is cleaned when no movie has it."""
|
||||
f = File(temp_source_dir / "file1.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("žánr", "Akční"))
|
||||
roots = {"žánr": ""}
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
manager.sync_structure([f], category_roots=roots)
|
||||
assert (temp_output_dir / "Akční").is_dir()
|
||||
|
||||
# last movie with the tag loses it → folder must go
|
||||
f.tags.clear()
|
||||
manager.sync_structure([f], category_roots=roots)
|
||||
assert not (temp_output_dir / "Akční").exists()
|
||||
|
||||
def test_category_transform_groups_folder_by_band(
|
||||
self, temp_source_dir, temp_output_dir, tag_manager
|
||||
):
|
||||
"""Exact tag value, but the folder name goes through the transform."""
|
||||
f = File(temp_source_dir / "file1.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("Hodnocení", "90")) # exact rating tag
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
manager.create_structure_for_files(
|
||||
[f],
|
||||
category_roots={"Hodnocení": "Dle hodnocení"},
|
||||
category_transforms={"Hodnocení": "decade_band"},
|
||||
)
|
||||
|
||||
# folder is the band; the file lands inside it
|
||||
assert (temp_output_dir / "Dle hodnocení" / "90–100 %" / "file1.txt").exists()
|
||||
# not a per-exact-value folder
|
||||
assert not (temp_output_dir / "Dle hodnocení" / "90").exists()
|
||||
|
||||
def test_filename_template_applies_only_in_that_category(
|
||||
self, temp_source_dir, temp_output_dir, tag_manager
|
||||
):
|
||||
"""A per-category template renames the hardlink only inside its folder."""
|
||||
f = File(temp_source_dir / "file1.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.title = "Dr. No"
|
||||
f.meta = {"year": 1962}
|
||||
f.add_tag(Tag("Kolekce", "James Bond"))
|
||||
f.add_tag(Tag("žánr", "Akční"))
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
manager.create_structure_for_files(
|
||||
[f],
|
||||
category_roots={"Kolekce": "Dle kolekce", "žánr": ""},
|
||||
category_filename_templates={"Kolekce": "{year} - {title}{ext}"},
|
||||
)
|
||||
|
||||
# Templated name inside the collection folder
|
||||
assert (
|
||||
temp_output_dir / "Dle kolekce" / "James Bond" / "1962 - Dr. No.txt"
|
||||
).exists()
|
||||
# Other categories use the default clean title (no year, not the pool name)
|
||||
assert (temp_output_dir / "Akční" / "Dr. No.txt").exists()
|
||||
|
||||
def test_filename_template_cleanup_is_consistent(
|
||||
self, temp_source_dir, temp_output_dir, tag_manager
|
||||
):
|
||||
"""sync twice with a template leaves no stale/duplicate templated link."""
|
||||
f = File(temp_source_dir / "file1.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.title = "Dr. No"
|
||||
f.meta = {"year": 1962}
|
||||
f.add_tag(Tag("Kolekce", "James Bond"))
|
||||
roots = {"Kolekce": "Dle kolekce"}
|
||||
templates = {"Kolekce": "{year} - {title}{ext}"}
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
manager.sync_structure(
|
||||
[f], category_roots=roots, category_filename_templates=templates
|
||||
)
|
||||
_created, _, removed, _ = manager.sync_structure(
|
||||
[f], category_roots=roots, category_filename_templates=templates
|
||||
)
|
||||
|
||||
folder = temp_output_dir / "Dle kolekce" / "James Bond"
|
||||
assert [p.name for p in folder.iterdir()] == ["1962 - Dr. No.txt"]
|
||||
assert removed == 0 # nothing treated as obsolete on the second run
|
||||
|
||||
def test_generate_random_tips_creates_hardlinks(
|
||||
self, files_with_tags, temp_output_dir
|
||||
):
|
||||
"""Tips folder gets `count` random pool files as hardlinks."""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
created, fail = manager.generate_random_tips(files_with_tags, count=2)
|
||||
|
||||
tips = temp_output_dir / "Tipy dne"
|
||||
assert created == 2
|
||||
assert fail == 0
|
||||
links = sorted(p.name for p in tips.iterdir())
|
||||
assert len(links) == 2
|
||||
# each is a hardlink to one of the source files
|
||||
srcs = {f.filename for f in files_with_tags}
|
||||
assert set(links) <= srcs
|
||||
|
||||
def test_generate_random_tips_caps_at_available(
|
||||
self, files_with_tags, temp_output_dir
|
||||
):
|
||||
"""Asking for more than available yields all of them, no error."""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
created, _ = manager.generate_random_tips(files_with_tags, count=99)
|
||||
assert created == len(files_with_tags)
|
||||
|
||||
def test_generate_random_tips_refreshes_each_run(
|
||||
self, files_with_tags, temp_output_dir
|
||||
):
|
||||
"""A second run replaces the previous selection (folder is emptied)."""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
manager.generate_random_tips(files_with_tags, count=1)
|
||||
manager.generate_random_tips(files_with_tags, count=1)
|
||||
tips = temp_output_dir / "Tipy dne"
|
||||
assert len(list(tips.iterdir())) == 1 # not accumulated to 2
|
||||
|
||||
def test_generate_recently_added_orders_by_added(
|
||||
self, files_with_tags, temp_output_dir
|
||||
):
|
||||
"""The newest-added movies (by added_timestamp) fill the folder."""
|
||||
# explicit added timestamps: file2 newest, file1 middle, file3 oldest
|
||||
files_with_tags[0].added = "2026-01-02T00:00:00"
|
||||
files_with_tags[1].added = "2026-01-03T00:00:00"
|
||||
files_with_tags[2].added = "2026-01-01T00:00:00"
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
created, fail = manager.generate_recently_added(
|
||||
files_with_tags, count=2, subfolder="- Nově přidané"
|
||||
)
|
||||
|
||||
recent = temp_output_dir / "- Nově přidané"
|
||||
assert created == 2 and fail == 0
|
||||
names = {p.name for p in recent.iterdir()}
|
||||
assert names == {"file2.txt", "file1.txt"} # newest two, file3 excluded
|
||||
|
||||
def test_dry_run(self, files_with_tags, temp_output_dir):
|
||||
"""Test dry run (bez skutečného vytváření)"""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
success, fail = manager.create_structure_for_files(
|
||||
files_with_tags, dry_run=True
|
||||
)
|
||||
|
||||
assert success == 4
|
||||
assert fail == 0
|
||||
|
||||
# No actual files should be created
|
||||
assert not (temp_output_dir / "žánr").exists()
|
||||
|
||||
def test_get_preview(self, files_with_tags, temp_output_dir):
|
||||
"""Test náhledu co bude vytvořeno"""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
preview = manager.get_preview(files_with_tags)
|
||||
|
||||
assert len(preview) == 4
|
||||
|
||||
# Check that preview contains expected paths
|
||||
targets = [p[1] for p in preview]
|
||||
assert temp_output_dir / "žánr" / "Komedie" / "file1.txt" in targets
|
||||
assert temp_output_dir / "žánr" / "Drama" / "file2.txt" in targets
|
||||
|
||||
def test_get_preview_with_category_filter(self, files_with_tags, temp_output_dir):
|
||||
"""Test náhledu s filtrem kategorií"""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
preview = manager.get_preview(files_with_tags, categories=["rok"])
|
||||
|
||||
assert len(preview) == 1
|
||||
assert preview[0][1] == temp_output_dir / "rok" / "1988" / "file1.txt"
|
||||
|
||||
def test_remove_created_links(self, files_with_tags, temp_output_dir):
|
||||
"""Test odstranění vytvořených hardlinků"""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
manager.create_structure_for_files(files_with_tags)
|
||||
|
||||
# Verify links exist
|
||||
assert (temp_output_dir / "žánr" / "Komedie" / "file1.txt").exists()
|
||||
|
||||
# Remove links
|
||||
removed = manager.remove_created_links()
|
||||
assert removed == 4
|
||||
|
||||
# Links should be gone
|
||||
assert not (temp_output_dir / "žánr" / "Komedie" / "file1.txt").exists()
|
||||
|
||||
# Empty directories should also be removed
|
||||
assert not (temp_output_dir / "žánr" / "Komedie").exists()
|
||||
|
||||
def test_empty_files_list(self, temp_output_dir):
|
||||
"""Test s prázdným seznamem souborů"""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
success, fail = manager.create_structure_for_files([])
|
||||
|
||||
assert success == 0
|
||||
assert fail == 0
|
||||
|
||||
def test_files_without_tags(self, temp_source_dir, temp_output_dir, tag_manager):
|
||||
"""Test se soubory bez tagů"""
|
||||
f1 = File(temp_source_dir / "file1.txt", tag_manager)
|
||||
f1.tags.clear() # Remove default tags
|
||||
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
success, fail = manager.create_structure_for_files([f1])
|
||||
|
||||
assert success == 0
|
||||
assert fail == 0
|
||||
|
||||
def test_duplicate_link_same_file(self, files_with_tags, temp_output_dir):
|
||||
"""Test že existující hardlink na stejný soubor je přeskočen"""
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
|
||||
# Create first time
|
||||
_success1, _ = manager.create_structure_for_files(files_with_tags)
|
||||
|
||||
# Create second time - should skip existing
|
||||
manager2 = HardlinkManager(temp_output_dir)
|
||||
success2, fail2 = manager2.create_structure_for_files(files_with_tags)
|
||||
|
||||
# All should be skipped (same inode)
|
||||
assert success2 == 0
|
||||
assert fail2 == 0
|
||||
|
||||
def test_unique_name_on_conflict(
|
||||
self, temp_source_dir, temp_output_dir, tag_manager
|
||||
):
|
||||
"""Two different movies with the same clean name (no year to tell them
|
||||
apart) in one folder get distinct links via a numeric suffix."""
|
||||
f1 = File(temp_source_dir / "file1.txt", tag_manager)
|
||||
f1.tags.clear()
|
||||
f1.add_tag(Tag("test", "tag"))
|
||||
|
||||
source2 = temp_source_dir / "subdir"
|
||||
source2.mkdir()
|
||||
(source2 / "file1.txt").write_text("different content")
|
||||
f2 = File(source2 / "file1.txt", tag_manager)
|
||||
f2.tags.clear()
|
||||
f2.add_tag(Tag("test", "tag"))
|
||||
|
||||
# One generation over both files → the plan disambiguates the clash.
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
success, fail = manager.create_structure_for_files([f1, f2])
|
||||
|
||||
assert success == 2 and fail == 0
|
||||
folder = temp_output_dir / "test" / "tag"
|
||||
assert (folder / "file1.txt").exists()
|
||||
assert (folder / "file1_2.txt").exists()
|
||||
|
||||
def test_czech_characters_in_tags(
|
||||
self, temp_source_dir, temp_output_dir, tag_manager
|
||||
):
|
||||
"""Test českých znaků v názvech tagů"""
|
||||
f1 = File(temp_source_dir / "file1.txt", tag_manager)
|
||||
f1.tags.clear()
|
||||
f1.add_tag(Tag("Žánr", "Česká komedie"))
|
||||
f1.add_tag(Tag("Štítky", "Příběh"))
|
||||
|
||||
manager = HardlinkManager(temp_output_dir)
|
||||
success, fail = manager.create_structure_for_files([f1])
|
||||
|
||||
assert success == 2
|
||||
assert fail == 0
|
||||
assert (temp_output_dir / "Žánr" / "Česká komedie" / "file1.txt").exists()
|
||||
assert (temp_output_dir / "Štítky" / "Příběh" / "file1.txt").exists()
|
||||
|
||||
|
||||
class TestConvenienceFunction:
|
||||
"""Testy pro convenience funkci create_hardlink_structure"""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
tm = TagManager()
|
||||
for cat in list(tm.tags_by_category.keys()):
|
||||
tm.remove_category(cat)
|
||||
return tm
|
||||
|
||||
@pytest.fixture
|
||||
def temp_files(self, tmp_path, tag_manager):
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag"))
|
||||
return [f]
|
||||
|
||||
def test_create_hardlink_structure_function(self, temp_files, tmp_path):
|
||||
"""Test convenience funkce"""
|
||||
output = tmp_path / "output"
|
||||
output.mkdir()
|
||||
|
||||
success, fail, errors = create_hardlink_structure(temp_files, output)
|
||||
|
||||
assert success == 1
|
||||
assert fail == 0
|
||||
assert len(errors) == 0
|
||||
assert (output / "cat" / "tag" / "file.txt").exists()
|
||||
|
||||
def test_create_hardlink_structure_with_categories(self, tmp_path, tag_manager):
|
||||
"""Test convenience funkce s filtrem kategorií"""
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("include", "yes"))
|
||||
f.add_tag(Tag("exclude", "no"))
|
||||
|
||||
output = tmp_path / "output"
|
||||
output.mkdir()
|
||||
|
||||
success, _fail, _errors = create_hardlink_structure(
|
||||
[f], output, categories=["include"]
|
||||
)
|
||||
|
||||
assert success == 1
|
||||
assert (output / "include" / "yes" / "file.txt").exists()
|
||||
assert not (output / "exclude").exists()
|
||||
|
||||
|
||||
class TestSyncStructure:
|
||||
"""Testy pro synchronizaci hardlink struktury"""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
tm = TagManager()
|
||||
for cat in list(tm.tags_by_category.keys()):
|
||||
tm.remove_category(cat)
|
||||
return tm
|
||||
|
||||
@pytest.fixture
|
||||
def setup_dirs(self, tmp_path):
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
output = tmp_path / "output"
|
||||
output.mkdir()
|
||||
return source, output
|
||||
|
||||
def test_find_obsolete_links_empty_output(self, setup_dirs, tag_manager):
|
||||
"""Test find_obsolete_links s prázdným výstupem"""
|
||||
source, output = setup_dirs
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag"))
|
||||
|
||||
manager = HardlinkManager(output)
|
||||
obsolete = manager.find_obsolete_links([f])
|
||||
|
||||
assert obsolete == []
|
||||
|
||||
def test_find_obsolete_links_detects_removed_tag(self, setup_dirs, tag_manager):
|
||||
"""Test že find_obsolete_links najde hardlink pro odebraný tag"""
|
||||
source, output = setup_dirs
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag1"))
|
||||
f.add_tag(Tag("cat", "tag2"))
|
||||
|
||||
# Create structure with both tags
|
||||
manager = HardlinkManager(output)
|
||||
manager.create_structure_for_files([f])
|
||||
|
||||
assert (output / "cat" / "tag1" / "file.txt").exists()
|
||||
assert (output / "cat" / "tag2" / "file.txt").exists()
|
||||
|
||||
# Remove one tag from file
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag1")) # Only tag1 remains
|
||||
|
||||
# Find obsolete
|
||||
obsolete = manager.find_obsolete_links([f])
|
||||
|
||||
assert len(obsolete) == 1
|
||||
assert obsolete[0][0] == output / "cat" / "tag2" / "file.txt"
|
||||
|
||||
def test_remove_obsolete_links(self, setup_dirs, tag_manager):
|
||||
"""Test odstranění zastaralých hardlinků"""
|
||||
source, output = setup_dirs
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag1"))
|
||||
f.add_tag(Tag("cat", "tag2"))
|
||||
|
||||
manager = HardlinkManager(output)
|
||||
manager.create_structure_for_files([f])
|
||||
|
||||
# Remove tag2
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag1"))
|
||||
|
||||
# Remove obsolete links
|
||||
removed, _paths = manager.remove_obsolete_links([f])
|
||||
|
||||
assert removed == 1
|
||||
assert not (output / "cat" / "tag2" / "file.txt").exists()
|
||||
assert (output / "cat" / "tag1" / "file.txt").exists()
|
||||
|
||||
def test_remove_obsolete_links_dry_run(self, setup_dirs, tag_manager):
|
||||
"""Test dry run pro remove_obsolete_links"""
|
||||
source, output = setup_dirs
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag1"))
|
||||
f.add_tag(Tag("cat", "tag2"))
|
||||
|
||||
manager = HardlinkManager(output)
|
||||
manager.create_structure_for_files([f])
|
||||
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag1"))
|
||||
|
||||
removed, _paths = manager.remove_obsolete_links([f], dry_run=True)
|
||||
|
||||
assert removed == 1
|
||||
# File should still exist (dry run)
|
||||
assert (output / "cat" / "tag2" / "file.txt").exists()
|
||||
|
||||
def test_sync_structure_creates_and_removes(self, setup_dirs, tag_manager):
|
||||
"""Test sync_structure vytvoří nové a odstraní staré hardlinky"""
|
||||
source, output = setup_dirs
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "old_tag"))
|
||||
|
||||
# Create initial structure
|
||||
manager = HardlinkManager(output)
|
||||
manager.create_structure_for_files([f])
|
||||
|
||||
assert (output / "cat" / "old_tag" / "file.txt").exists()
|
||||
|
||||
# Change tags
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "new_tag"))
|
||||
|
||||
# Sync
|
||||
created, c_fail, removed, r_fail = manager.sync_structure([f])
|
||||
|
||||
assert created == 1
|
||||
assert removed == 1
|
||||
assert c_fail == 0
|
||||
assert r_fail == 0
|
||||
assert not (output / "cat" / "old_tag").exists()
|
||||
assert (output / "cat" / "new_tag" / "file.txt").exists()
|
||||
|
||||
def test_sync_structure_no_changes_needed(self, setup_dirs, tag_manager):
|
||||
"""Test sync_structure když není potřeba žádná změna"""
|
||||
source, output = setup_dirs
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag"))
|
||||
|
||||
manager = HardlinkManager(output)
|
||||
manager.create_structure_for_files([f])
|
||||
|
||||
# Sync again without changes
|
||||
_created, _c_fail, removed, _r_fail = manager.sync_structure([f])
|
||||
|
||||
# Nothing should change (existing links are skipped)
|
||||
assert removed == 0
|
||||
assert (output / "cat" / "tag" / "file.txt").exists()
|
||||
|
||||
def test_find_obsolete_with_category_filter(self, setup_dirs, tag_manager):
|
||||
"""Test find_obsolete_links s filtrem kategorií"""
|
||||
source, output = setup_dirs
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat1", "tag"))
|
||||
f.add_tag(Tag("cat2", "tag"))
|
||||
|
||||
manager = HardlinkManager(output)
|
||||
manager.create_structure_for_files([f])
|
||||
|
||||
# Remove both tags
|
||||
f.tags.clear()
|
||||
|
||||
# Find obsolete only in cat1
|
||||
obsolete = manager.find_obsolete_links([f], categories=["cat1"])
|
||||
|
||||
assert len(obsolete) == 1
|
||||
assert obsolete[0][0] == output / "cat1" / "tag" / "file.txt"
|
||||
|
||||
def test_removes_empty_directories(self, setup_dirs, tag_manager):
|
||||
"""Test že prázdné adresáře jsou odstraněny po sync"""
|
||||
source, output = setup_dirs
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("category", "tag"))
|
||||
|
||||
manager = HardlinkManager(output)
|
||||
manager.create_structure_for_files([f])
|
||||
|
||||
# Remove all tags
|
||||
f.tags.clear()
|
||||
|
||||
manager.remove_obsolete_links([f])
|
||||
|
||||
# Directory should be gone
|
||||
assert not (output / "category" / "tag").exists()
|
||||
assert not (output / "category").exists()
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Testy pro okrajové případy"""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
tm = TagManager()
|
||||
for cat in list(tm.tags_by_category.keys()):
|
||||
tm.remove_category(cat)
|
||||
return tm
|
||||
|
||||
def test_nonexistent_output_dir_created(self, tmp_path, tag_manager):
|
||||
"""Test že výstupní složka je vytvořena pokud neexistuje"""
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag"))
|
||||
|
||||
output = tmp_path / "output" / "nested" / "deep"
|
||||
# output doesn't exist
|
||||
|
||||
manager = HardlinkManager(output)
|
||||
success, _fail = manager.create_structure_for_files([f])
|
||||
|
||||
assert success == 1
|
||||
assert (output / "cat" / "tag" / "file.txt").exists()
|
||||
|
||||
def test_special_characters_in_filename(self, tmp_path, tag_manager):
|
||||
"""Test souboru se speciálními znaky v názvu"""
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
# Parentheses that are NOT a four-digit year must survive verbatim
|
||||
# (a trailing "(YYYY)" would be stripped by the naming rule).
|
||||
(source / "file with spaces (part 2).txt").write_text("content")
|
||||
|
||||
f = File(source / "file with spaces (part 2).txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("test", "tag"))
|
||||
|
||||
output = tmp_path / "output"
|
||||
output.mkdir()
|
||||
|
||||
manager = HardlinkManager(output)
|
||||
success, _fail = manager.create_structure_for_files([f])
|
||||
|
||||
assert success == 1
|
||||
assert (output / "test" / "tag" / "file with spaces (part 2).txt").exists()
|
||||
|
||||
def test_empty_category_filter(self, tmp_path, tag_manager):
|
||||
"""Test s prázdným seznamem kategorií"""
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
(source / "file.txt").write_text("content")
|
||||
|
||||
f = File(source / "file.txt", tag_manager)
|
||||
f.tags.clear()
|
||||
f.add_tag(Tag("cat", "tag"))
|
||||
|
||||
output = tmp_path / "output"
|
||||
output.mkdir()
|
||||
|
||||
manager = HardlinkManager(output)
|
||||
# Empty list = no categories = no links
|
||||
success, _fail = manager.create_structure_for_files([f], categories=[])
|
||||
|
||||
assert success == 0
|
||||
|
||||
def test_is_same_file_method(self, tmp_path):
|
||||
"""Test metody _is_same_file"""
|
||||
file1 = tmp_path / "file1.txt"
|
||||
file1.write_text("content")
|
||||
|
||||
link = tmp_path / "link.txt"
|
||||
os.link(file1, link)
|
||||
|
||||
file2 = tmp_path / "file2.txt"
|
||||
file2.write_text("different")
|
||||
|
||||
manager = HardlinkManager(tmp_path)
|
||||
|
||||
# Same inode
|
||||
assert manager._is_same_file(file1, link) is True
|
||||
|
||||
# Different inode
|
||||
assert manager._is_same_file(file1, file2) is False
|
||||
|
||||
# Non-existent file
|
||||
assert manager._is_same_file(file1, tmp_path / "nonexistent") is False
|
||||
|
||||
def test_get_unique_name_method(self, tmp_path):
|
||||
"""Test metody _get_unique_name"""
|
||||
(tmp_path / "file.txt").write_text("1")
|
||||
(tmp_path / "file_1.txt").write_text("2")
|
||||
(tmp_path / "file_2.txt").write_text("3")
|
||||
|
||||
manager = HardlinkManager(tmp_path)
|
||||
unique = manager._get_unique_name(tmp_path / "file.txt")
|
||||
|
||||
assert unique == tmp_path / "file_3.txt"
|
||||
|
||||
|
||||
class TestMirrorAsIs:
|
||||
"""Testy pro copy-as-is zrcadlení (Seriály)"""
|
||||
|
||||
def test_mirror_clones_hierarchy_with_hardlinks(self, tmp_path):
|
||||
"""Adresářová struktura se zrcadlí 1:1 a soubory jsou hardlinky"""
|
||||
source = tmp_path / "Seriály"
|
||||
(source / "Show" / "S01").mkdir(parents=True)
|
||||
ep1 = source / "Show" / "S01" / "ep1.mkv"
|
||||
ep2 = source / "Show" / "S01" / "ep2.mkv"
|
||||
ep1.write_text("a")
|
||||
ep2.write_text("b")
|
||||
|
||||
output = tmp_path / "out"
|
||||
manager = HardlinkManager(output)
|
||||
created, failed = manager.mirror_as_is(source, "Seriály")
|
||||
|
||||
assert failed == 0
|
||||
assert created == 2
|
||||
linked = output / "Seriály" / "Show" / "S01" / "ep1.mkv"
|
||||
assert linked.exists()
|
||||
assert linked.stat().st_ino == ep1.stat().st_ino
|
||||
|
||||
def test_mirror_skips_curator_metadata(self, tmp_path):
|
||||
"""Metadata soubory (.!tag, .!index) se nezrcadlí"""
|
||||
source = tmp_path / "Seriály"
|
||||
source.mkdir()
|
||||
(source / "ep1.mkv").write_text("a")
|
||||
(source / ".ep1.mkv.!tag").write_text("{}")
|
||||
(source / ".Curator.!index").write_text("{}")
|
||||
|
||||
output = tmp_path / "out"
|
||||
manager = HardlinkManager(output)
|
||||
created, failed = manager.mirror_as_is(source, "Seriály")
|
||||
|
||||
assert created == 1
|
||||
assert failed == 0
|
||||
assert not (output / "Seriály" / ".ep1.mkv.!tag").exists()
|
||||
|
||||
def test_mirror_nonexistent_source_is_noop(self, tmp_path):
|
||||
"""Neexistující zdroj nic neudělá"""
|
||||
manager = HardlinkManager(tmp_path / "out")
|
||||
assert manager.mirror_as_is(tmp_path / "missing", "Seriály") == (0, 0)
|
||||
|
||||
|
||||
class TestRemoveMirror:
|
||||
"""Removing a whole mirrored library (or one bucket entry) from the output."""
|
||||
|
||||
def test_removes_a_whole_mirrored_folder(self, tmp_path):
|
||||
output = tmp_path / "out"
|
||||
(output / "Dokumenty" / "sub").mkdir(parents=True)
|
||||
(output / "Dokumenty" / "sub" / "clip.mp4").write_bytes(b"x")
|
||||
manager = HardlinkManager(output)
|
||||
|
||||
assert manager.remove_mirror("Dokumenty") is True
|
||||
assert not (output / "Dokumenty").exists()
|
||||
|
||||
def test_removes_one_bucket_entry_by_compound_name(self, tmp_path):
|
||||
output = tmp_path / "out"
|
||||
(output / "Seriály" / "Show A").mkdir(parents=True)
|
||||
(output / "Seriály" / "Show B").mkdir(parents=True)
|
||||
manager = HardlinkManager(output)
|
||||
|
||||
assert manager.remove_mirror("Seriály/Show A") is True
|
||||
assert not (output / "Seriály" / "Show A").exists()
|
||||
assert (output / "Seriály" / "Show B").exists() # sibling untouched
|
||||
|
||||
def test_missing_target_returns_false(self, tmp_path):
|
||||
manager = HardlinkManager(tmp_path / "out")
|
||||
assert manager.remove_mirror("Nope") is False
|
||||
@@ -1,164 +0,0 @@
|
||||
"""Tests for video integrity checking (src.core.integrity).
|
||||
|
||||
The FFmpeg subprocess call is monkeypatched so the tests don't need ffmpeg
|
||||
installed and don't touch real video data.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core import integrity
|
||||
from src.core.integrity import IntegrityResult, check_video_integrity
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def video_file(tmp_path) -> Path:
|
||||
f = tmp_path / "movie.mkv"
|
||||
f.write_bytes(b"not really a video, but a real file")
|
||||
return f
|
||||
|
||||
|
||||
def _fake_run(returncode=0, stdout="", stderr=""):
|
||||
def run(cmd, **kwargs):
|
||||
return SimpleNamespace(returncode=returncode, stdout=stdout, stderr=stderr)
|
||||
|
||||
return run
|
||||
|
||||
|
||||
def test_deep_clean_file_is_ok(monkeypatch, video_file):
|
||||
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
|
||||
monkeypatch.setattr(integrity.subprocess, "run", _fake_run(0, "", ""))
|
||||
result = check_video_integrity(video_file, deep=True)
|
||||
assert result.ok and result.checked and result.error == ""
|
||||
|
||||
|
||||
def test_deep_reports_decode_errors(monkeypatch, video_file):
|
||||
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
|
||||
monkeypatch.setattr(
|
||||
integrity.subprocess,
|
||||
"run",
|
||||
_fake_run(1, "", "[matroska @ 0x..] Read error\nmore noise"),
|
||||
)
|
||||
result = check_video_integrity(video_file, deep=True)
|
||||
assert not result.ok and result.checked
|
||||
assert result.error == "[matroska @ 0x..] Read error" # first line only
|
||||
|
||||
|
||||
def test_deep_stderr_without_nonzero_rc_still_fails(monkeypatch, video_file):
|
||||
"""ffmpeg can print decode errors while returning rc 0 — must still fail."""
|
||||
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
|
||||
monkeypatch.setattr(integrity.subprocess, "run", _fake_run(0, "", "corrupt packet"))
|
||||
result = check_video_integrity(video_file, deep=True)
|
||||
assert not result.ok
|
||||
|
||||
|
||||
def test_quick_ok_when_video_stream_present(monkeypatch, video_file):
|
||||
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffprobe")
|
||||
monkeypatch.setattr(integrity.subprocess, "run", _fake_run(0, "video\n", ""))
|
||||
result = check_video_integrity(video_file, deep=False)
|
||||
assert result.ok
|
||||
|
||||
|
||||
def test_quick_fails_without_video_stream(monkeypatch, video_file):
|
||||
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffprobe")
|
||||
monkeypatch.setattr(integrity.subprocess, "run", _fake_run(0, "", ""))
|
||||
result = check_video_integrity(video_file, deep=False)
|
||||
assert not result.ok and "video stream" in result.error.lower()
|
||||
|
||||
|
||||
def test_missing_file_is_unchecked_not_corrupt(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
|
||||
result = check_video_integrity(tmp_path / "nope.mkv", deep=True)
|
||||
assert not result.ok and not result.checked and "neexistuje" in result.error
|
||||
|
||||
|
||||
def test_missing_tool_is_unchecked(monkeypatch, video_file):
|
||||
monkeypatch.setattr(integrity.shutil, "which", lambda _: None)
|
||||
result = check_video_integrity(video_file, deep=True)
|
||||
assert not result.ok and not result.checked
|
||||
assert "ffmpeg" in result.error
|
||||
|
||||
|
||||
def test_timeout_reports_failure(monkeypatch, video_file):
|
||||
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
|
||||
|
||||
def raise_timeout(cmd, **kwargs):
|
||||
raise subprocess.TimeoutExpired(cmd, 1)
|
||||
|
||||
monkeypatch.setattr(integrity.subprocess, "run", raise_timeout)
|
||||
result = check_video_integrity(video_file, deep=True, timeout=1)
|
||||
assert not result.ok and result.checked and "timeout" in result.error.lower()
|
||||
|
||||
|
||||
def test_ffmpeg_available_reflects_which(monkeypatch):
|
||||
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
|
||||
assert integrity.ffmpeg_available(deep=True)
|
||||
monkeypatch.setattr(integrity.shutil, "which", lambda _: None)
|
||||
assert not integrity.ffmpeg_available(deep=True)
|
||||
|
||||
|
||||
def test_scan_collects_problems_and_progress(monkeypatch, tmp_path):
|
||||
"""FileManager.scan_video_integrity aggregates per-file results."""
|
||||
from src.core.file import File
|
||||
from src.core.file_manager import FileManager
|
||||
from src.core.tag_manager import TagManager
|
||||
|
||||
good = tmp_path / "good.mkv"
|
||||
bad = tmp_path / "bad.mkv"
|
||||
good.write_bytes(b"x")
|
||||
bad.write_bytes(b"y")
|
||||
|
||||
tm = TagManager()
|
||||
fm = FileManager(tm)
|
||||
files = [File(good, tm), File(bad, tm)]
|
||||
|
||||
def fake_check(path, deep=True):
|
||||
ok = Path(path).name == "good.mkv"
|
||||
return IntegrityResult(Path(path), ok=ok, error="" if ok else "broken")
|
||||
|
||||
monkeypatch.setattr("src.core.file_manager.ffmpeg_available", lambda deep: True)
|
||||
monkeypatch.setattr("src.core.file_manager.check_video_integrity", fake_check)
|
||||
|
||||
seen: list[int] = []
|
||||
result = fm.scan_video_integrity(
|
||||
files, deep=True, on_progress=lambda d, t, f: seen.append(d)
|
||||
)
|
||||
|
||||
assert result["ok"] and result["checked"] == 2
|
||||
assert [r.path.name for r in result["problems"]] == ["bad.mkv"]
|
||||
assert seen == [1, 2]
|
||||
|
||||
|
||||
def test_scan_stops_on_cancel(monkeypatch, tmp_path):
|
||||
from src.core.file import File
|
||||
from src.core.file_manager import FileManager
|
||||
from src.core.tag_manager import TagManager
|
||||
|
||||
files = []
|
||||
for i in range(3):
|
||||
p = tmp_path / f"m{i}.mkv"
|
||||
p.write_bytes(b"x")
|
||||
files.append(File(p, TagManager()))
|
||||
|
||||
monkeypatch.setattr("src.core.file_manager.ffmpeg_available", lambda deep: True)
|
||||
monkeypatch.setattr(
|
||||
"src.core.file_manager.check_video_integrity",
|
||||
lambda path, deep=True: IntegrityResult(Path(path), ok=True),
|
||||
)
|
||||
|
||||
fm = FileManager(TagManager())
|
||||
result = fm.scan_video_integrity(files, should_cancel=lambda: True)
|
||||
assert result["cancelled"] and result["checked"] == 0
|
||||
|
||||
|
||||
def test_scan_reports_missing_ffmpeg(monkeypatch, tmp_path):
|
||||
from src.core.file_manager import FileManager
|
||||
from src.core.tag_manager import TagManager
|
||||
|
||||
monkeypatch.setattr("src.core.file_manager.ffmpeg_available", lambda deep: False)
|
||||
fm = FileManager(TagManager())
|
||||
result = fm.scan_video_integrity([], deep=True)
|
||||
assert not result["ok"] and "ffmpeg" in result["error"]
|
||||
@@ -1,123 +0,0 @@
|
||||
"""Tests for the Jellyfin client + pool verification. Network is monkeypatched."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core import jellyfin
|
||||
from src.core.jellyfin import JellyfinError, JellyfinMovie
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _configured(monkeypatch):
|
||||
monkeypatch.setenv("JELLYFIN_URL", "http://jf:8096/")
|
||||
monkeypatch.setenv("JELLYFIN_API_KEY", "key")
|
||||
|
||||
|
||||
def _resp(status=200, payload=None):
|
||||
return SimpleNamespace(
|
||||
status_code=status, ok=(200 <= status < 300), json=lambda: payload or {}
|
||||
)
|
||||
|
||||
|
||||
def test_base_url_strips_trailing_slash():
|
||||
assert jellyfin.base_url() == "http://jf:8096"
|
||||
|
||||
|
||||
def test_api_available(monkeypatch):
|
||||
assert jellyfin.api_available()
|
||||
monkeypatch.delenv("JELLYFIN_API_KEY", raising=False)
|
||||
assert not jellyfin.api_available()
|
||||
|
||||
|
||||
def test_server_version(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
jellyfin.requests, "request", lambda *a, **k: _resp(200, {"Version": "10.9.11"})
|
||||
)
|
||||
assert jellyfin.server_version() == "10.9.11"
|
||||
|
||||
|
||||
def test_scan_all_posts_refresh(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def fake(method, url, **k):
|
||||
seen["method"], seen["url"] = method, url
|
||||
return _resp(200, {})
|
||||
|
||||
monkeypatch.setattr(jellyfin.requests, "request", fake)
|
||||
jellyfin.scan_all()
|
||||
assert seen["method"] == "POST" and seen["url"].endswith("/Library/Refresh")
|
||||
|
||||
|
||||
def test_fetch_movies_maps_tmdb_id(monkeypatch):
|
||||
payload = {
|
||||
"Items": [
|
||||
{
|
||||
"Id": "a1",
|
||||
"Name": "Matrix",
|
||||
"ProductionYear": 1999,
|
||||
"ProviderIds": {"Tmdb": "603"},
|
||||
"Path": "/m/Matrix (1999) [tmdbid-603].mkv",
|
||||
},
|
||||
{"Id": "a2", "Name": "Bez ID", "ProductionYear": 2000, "ProviderIds": {}},
|
||||
]
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
jellyfin.requests, "request", lambda *a, **k: _resp(200, payload)
|
||||
)
|
||||
movies = jellyfin.fetch_movies()
|
||||
assert movies[0] == JellyfinMovie(
|
||||
"a1", "Matrix", 1999, 603, "/m/Matrix (1999) [tmdbid-603].mkv"
|
||||
)
|
||||
assert movies[1].tmdb_id is None
|
||||
|
||||
|
||||
def test_missing_config_raises(monkeypatch):
|
||||
monkeypatch.delenv("JELLYFIN_URL", raising=False)
|
||||
with pytest.raises(JellyfinError):
|
||||
jellyfin.server_version()
|
||||
|
||||
|
||||
def test_bad_auth_raises(monkeypatch):
|
||||
monkeypatch.setattr(jellyfin.requests, "request", lambda *a, **k: _resp(401))
|
||||
with pytest.raises(JellyfinError):
|
||||
jellyfin.scan_all()
|
||||
|
||||
|
||||
class TestVerifyAgainstJellyfin:
|
||||
@pytest.fixture
|
||||
def file_manager(self, tmp_path, monkeypatch):
|
||||
import src.core.config as config_module
|
||||
|
||||
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", tmp_path / ".gtag")
|
||||
from src.core.file_manager import FileManager
|
||||
from src.core.tag_manager import TagManager
|
||||
|
||||
fm = FileManager(TagManager())
|
||||
fm.set_pool_dir(tmp_path / "pool")
|
||||
return fm
|
||||
|
||||
def test_match_by_tmdb_id_and_report_gaps(self, file_manager, tmp_path):
|
||||
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||
(tmp_path / "b.mkv").write_bytes(b"y")
|
||||
file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year=1999, tmdb_id=603)
|
||||
file_manager.import_movie(tmp_path / "b.mkv", "Neznámý", year=2020) # no id
|
||||
|
||||
jf = [
|
||||
JellyfinMovie("j1", "Matrix", 1999, 603, None), # matches a by tmdb
|
||||
JellyfinMovie("j2", "Cizí film", 2011, 999, None), # jellyfin-only
|
||||
]
|
||||
result = file_manager.verify_against_jellyfin(jf)
|
||||
|
||||
assert result["matched"] == 1
|
||||
assert result["pool_only"] == ["Neznámý (2020).mkv"]
|
||||
assert result["jellyfin_only"] == ["Cizí film"]
|
||||
assert result["pool_total"] == 2 and result["jellyfin_total"] == 2
|
||||
|
||||
def test_match_by_title_year_fallback(self, file_manager, tmp_path):
|
||||
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||
file_manager.import_movie(tmp_path / "a.mkv", "Kolja", year=1996) # no id
|
||||
|
||||
jf = [JellyfinMovie("j1", "Kolja", 1996, None, None)]
|
||||
result = file_manager.verify_against_jellyfin(jf)
|
||||
assert result["matched"] == 1 and result["pool_only"] == []
|
||||
@@ -1,142 +0,0 @@
|
||||
"""Tests for recovering ČSFD link / title / year from pre-index layouts."""
|
||||
|
||||
import json
|
||||
|
||||
from src.core.legacy_meta import LegacyMeta, read_legacy_meta
|
||||
|
||||
|
||||
def _video(tmp_path, name="Matrix (1999).mkv"):
|
||||
v = tmp_path / name
|
||||
v.write_bytes(b"x")
|
||||
return v
|
||||
|
||||
|
||||
def test_reads_tagger_era_sidecar(tmp_path):
|
||||
video = _video(tmp_path)
|
||||
(tmp_path / f".{video.name}.!tag").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"title": "Matrix",
|
||||
"csfd_link": "https://www.csfd.cz/film/9499-matrix/",
|
||||
"csfd_cache": {"year": 1999, "rating": 90},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
meta = read_legacy_meta(video)
|
||||
assert meta == LegacyMeta(
|
||||
csfd_url="https://www.csfd.cz/film/9499-matrix/",
|
||||
title="Matrix",
|
||||
year="1999",
|
||||
source="sidecar",
|
||||
)
|
||||
|
||||
|
||||
def test_sidecar_new_key_and_meta_block(tmp_path):
|
||||
video = _video(tmp_path)
|
||||
(tmp_path / f".{video.name}.!tag").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"title": "Matrix",
|
||||
"csfd_url": "https://www.csfd.cz/film/9499/",
|
||||
"meta": {"year": "1999"},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
meta = read_legacy_meta(video)
|
||||
assert meta.csfd_url == "https://www.csfd.cz/film/9499/"
|
||||
assert meta.year == "1999"
|
||||
|
||||
|
||||
def test_reads_old_pool_index_by_relative_key(tmp_path):
|
||||
pool = tmp_path / "old-pool"
|
||||
movies_dir = pool / "Filmy"
|
||||
movies_dir.mkdir(parents=True)
|
||||
video = _video(movies_dir)
|
||||
(pool / ".Curator.!index").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"settings": {},
|
||||
"movies": {
|
||||
"Filmy/Matrix (1999).mkv": {
|
||||
"title": "Matrix",
|
||||
"csfd_link": "https://www.csfd.cz/film/9499-matrix/",
|
||||
"csfd_cache": {"year": 1999},
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
meta = read_legacy_meta(video)
|
||||
assert meta.csfd_url == "https://www.csfd.cz/film/9499-matrix/"
|
||||
assert meta.title == "Matrix"
|
||||
assert meta.year == "1999"
|
||||
assert meta.source == "index"
|
||||
|
||||
|
||||
def test_index_fallback_to_unique_basename_match(tmp_path):
|
||||
pool = tmp_path / "pool"
|
||||
pool.mkdir()
|
||||
elsewhere = tmp_path / "loose"
|
||||
elsewhere.mkdir()
|
||||
video = _video(elsewhere)
|
||||
(pool.parent / ".Curator.!index").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"movies": {
|
||||
"whatever/Matrix (1999).mkv": {
|
||||
"csfd_url": "https://www.csfd.cz/film/9499/"
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
meta = read_legacy_meta(video)
|
||||
assert meta is not None
|
||||
assert meta.csfd_url == "https://www.csfd.cz/film/9499/"
|
||||
|
||||
|
||||
def test_sidecar_wins_over_index(tmp_path):
|
||||
video = _video(tmp_path)
|
||||
(tmp_path / f".{video.name}.!tag").write_text(
|
||||
json.dumps({"csfd_link": "https://www.csfd.cz/film/SIDECAR/"}), encoding="utf-8"
|
||||
)
|
||||
(tmp_path / ".Curator.!index").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"movies": {
|
||||
"Matrix (1999).mkv": {
|
||||
"csfd_link": "https://www.csfd.cz/film/INDEX/"
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert read_legacy_meta(video).csfd_url == "https://www.csfd.cz/film/SIDECAR/"
|
||||
|
||||
|
||||
def test_none_when_no_metadata(tmp_path):
|
||||
assert read_legacy_meta(_video(tmp_path)) is None
|
||||
|
||||
|
||||
def test_none_on_broken_sidecar(tmp_path):
|
||||
video = _video(tmp_path)
|
||||
(tmp_path / f".{video.name}.!tag").write_text("{not json", encoding="utf-8")
|
||||
assert read_legacy_meta(video) is None
|
||||
|
||||
|
||||
def test_record_without_useful_fields_is_none(tmp_path):
|
||||
video = _video(tmp_path)
|
||||
(tmp_path / f".{video.name}.!tag").write_text(
|
||||
json.dumps({"new": True, "ignored": False, "tags": []}), encoding="utf-8"
|
||||
)
|
||||
assert read_legacy_meta(video) is None
|
||||
@@ -1,102 +0,0 @@
|
||||
"""Tests for pool → TMDb migration (src.core.migrate + FileManager)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.migrate import classify, normalize
|
||||
from src.core.tmdb import TmdbResult
|
||||
|
||||
|
||||
def test_normalize_folds_diacritics_and_punct():
|
||||
assert normalize("Šíleně smutná princezna!") == "silene smutna princezna"
|
||||
assert normalize("Amélie z Montmartru") == "amelie z montmartru"
|
||||
|
||||
|
||||
def test_classify_auto_on_exact_unique():
|
||||
cands = [TmdbResult(603, "Matrix", "1999", "movie")]
|
||||
status, chosen = classify("Matrix", "1999", cands)
|
||||
assert status == "auto" and chosen.tmdb_id == 603
|
||||
|
||||
|
||||
def test_classify_auto_disambiguates_by_year():
|
||||
cands = [
|
||||
TmdbResult(1, "Solaris", "1972", "movie"),
|
||||
TmdbResult(2, "Solaris", "2002", "movie"),
|
||||
]
|
||||
# local year 1972 → unique exact-year match → auto picks the 1972 one
|
||||
assert classify("Solaris", "1972", cands) == ("auto", cands[0])
|
||||
|
||||
|
||||
def test_classify_ambiguous_when_year_matches_none():
|
||||
cands = [
|
||||
TmdbResult(1, "Solaris", "1972", "movie"),
|
||||
TmdbResult(2, "Solaris", "2002", "movie"),
|
||||
]
|
||||
status, chosen = classify("Solaris", "1990", cands)
|
||||
# year close to neither → no unique exact-year → ambiguous (top hit preselected)
|
||||
assert status == "ambiguous" and chosen.tmdb_id == 1
|
||||
|
||||
|
||||
def test_classify_ambiguous_without_year():
|
||||
cands = [TmdbResult(603, "Matrix", "1999", "movie")]
|
||||
status, _ = classify("Matrix", None, cands)
|
||||
assert status == "ambiguous" # no local year → user confirms
|
||||
|
||||
|
||||
def test_classify_no_match():
|
||||
assert classify("Nic", "2000", []) == ("no_match", None)
|
||||
|
||||
|
||||
class TestFileManagerMigrate:
|
||||
@pytest.fixture
|
||||
def file_manager(self, tmp_path, monkeypatch):
|
||||
import src.core.config as config_module
|
||||
|
||||
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", tmp_path / ".gtag")
|
||||
from src.core.file_manager import FileManager
|
||||
from src.core.tag_manager import TagManager
|
||||
|
||||
fm = FileManager(TagManager())
|
||||
fm.set_pool_dir(tmp_path / "pool")
|
||||
return fm
|
||||
|
||||
def test_match_and_assign(self, file_manager, tmp_path, monkeypatch):
|
||||
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||
movie = file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year=1999)
|
||||
assert "[tmdbid" not in movie.file_path.name # not migrated yet
|
||||
|
||||
from src.core import tmdb
|
||||
|
||||
monkeypatch.setattr(
|
||||
tmdb,
|
||||
"search_movie",
|
||||
lambda q, year=None, limit=5: [TmdbResult(603, "Matrix", "1999", "movie")],
|
||||
)
|
||||
|
||||
proposals = file_manager.match_pool_to_tmdb()
|
||||
assert len(proposals) == 1
|
||||
p = proposals[0]
|
||||
assert p.status == "auto" and p.chosen.tmdb_id == 603
|
||||
|
||||
file_manager.assign_tmdb_id(p.file, p.chosen.tmdb_id, p.chosen.year)
|
||||
assert movie.file_path.name == "Matrix (1999) [tmdbid-603].mkv"
|
||||
|
||||
def test_match_skips_already_migrated(self, file_manager, tmp_path, monkeypatch):
|
||||
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||
file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year=1999, tmdb_id=603)
|
||||
|
||||
from src.core import tmdb
|
||||
|
||||
called = []
|
||||
monkeypatch.setattr(
|
||||
tmdb, "search_movie", lambda q, year=None, limit=5: called.append(q) or []
|
||||
)
|
||||
proposals = file_manager.match_pool_to_tmdb()
|
||||
assert proposals == [] and called == [] # already has id → not searched
|
||||
|
||||
def test_assign_fills_missing_year_from_tmdb(self, file_manager, tmp_path):
|
||||
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||
movie = file_manager.import_movie(tmp_path / "a.mkv", "Matrix") # no year
|
||||
assert movie.file_path.name == "Matrix.mkv"
|
||||
|
||||
file_manager.assign_tmdb_id(movie, 603, "1999") # TMDb supplies the year
|
||||
assert movie.file_path.name == "Matrix (1999) [tmdbid-603].mkv"
|
||||
@@ -1,225 +0,0 @@
|
||||
"""Tests for the movie object and the pool layout it derives (src.core.movie)."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.movie import (
|
||||
DEFAULT_VERSION_LABEL,
|
||||
MediaFile,
|
||||
Movie,
|
||||
identity_key,
|
||||
media_filename,
|
||||
normalize_3d_label,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"label,expected",
|
||||
[
|
||||
("HSBS", "HSBS"),
|
||||
("hsbs", "HSBS"),
|
||||
("3D HSBS", "HSBS"),
|
||||
("3d.hsbs", "HSBS"),
|
||||
("3D_FTAB", "FTAB"),
|
||||
("3d-mvc", "MVC"),
|
||||
("4K", None),
|
||||
("Director's Cut", None),
|
||||
("", None),
|
||||
(None, None),
|
||||
],
|
||||
)
|
||||
def test_normalize_3d_label(label, expected):
|
||||
assert normalize_3d_label(label) == expected
|
||||
|
||||
|
||||
class TestMediaFilename:
|
||||
def test_no_label_is_the_bare_stem(self):
|
||||
assert (
|
||||
media_filename("Matrix (1999)", "", ".mkv", grouped=False)
|
||||
== "Matrix (1999).mkv"
|
||||
)
|
||||
|
||||
def test_3d_label_uses_jellyfins_documented_form(self):
|
||||
assert (
|
||||
media_filename("Avatar (2009)", "3D HSBS", ".mkv", grouped=False)
|
||||
== "Avatar (2009).3D.HSBS.mkv"
|
||||
)
|
||||
# same spelling grouped — "." is a valid separator after the folder name
|
||||
assert (
|
||||
media_filename("Avatar (2009)", "hsbs", ".mkv", grouped=True)
|
||||
== "Avatar (2009).3D.HSBS.mkv"
|
||||
)
|
||||
|
||||
def test_plain_label_uses_dash_form(self):
|
||||
assert (
|
||||
media_filename("Avatar (2009)", "4K", ".mkv", grouped=True)
|
||||
== "Avatar (2009) - 4K.mkv"
|
||||
)
|
||||
|
||||
def test_grouped_empty_label_gets_a_default(self):
|
||||
assert (
|
||||
media_filename("Avatar (2009)", "", ".mkv", grouped=True)
|
||||
== f"Avatar (2009) - {DEFAULT_VERSION_LABEL}.mkv"
|
||||
)
|
||||
|
||||
|
||||
class TestIdentityKey:
|
||||
def test_tmdb_id_wins(self):
|
||||
assert identity_key(603, "Matrix", "1999") == "tmdb-603"
|
||||
assert identity_key("603", "cokoli", None) == "tmdb-603"
|
||||
|
||||
def test_slug_fallback_without_id(self):
|
||||
assert identity_key(None, "Matrix", "1999") == "name-matrix-1999"
|
||||
|
||||
def test_slug_folds_diacritics(self):
|
||||
assert identity_key(None, "Pelíšky", "1999") == "name-pelisky-1999"
|
||||
|
||||
def test_slug_without_year(self):
|
||||
assert identity_key(None, "Nějaký film", None) == "name-nejaky-film"
|
||||
|
||||
|
||||
def _movie(**kwargs: Any) -> Movie:
|
||||
base: dict[str, Any] = {
|
||||
"key": "tmdb-19995",
|
||||
"title": "Avatar",
|
||||
"year": "2009",
|
||||
"tmdb_id": 19995,
|
||||
"library": "Filmy",
|
||||
}
|
||||
base.update(kwargs)
|
||||
return Movie(**base)
|
||||
|
||||
|
||||
class TestMovieShape:
|
||||
def test_stem_is_the_canonical_form(self):
|
||||
assert _movie().stem == "Avatar (2009) [tmdbid-19995]"
|
||||
|
||||
def test_stem_falls_back_to_title_without_year(self):
|
||||
assert _movie(year=None, tmdb_id=None).stem == "Avatar"
|
||||
|
||||
def test_single_video_is_not_grouped(self):
|
||||
m = _movie(media=[MediaFile(Path("/pool/Filmy/x.mkv"))])
|
||||
assert m.is_grouped is False
|
||||
|
||||
def test_two_videos_are_grouped(self):
|
||||
m = _movie(
|
||||
media=[
|
||||
MediaFile(Path("/a.mkv"), "3D HSBS"),
|
||||
MediaFile(Path("/b.mkv"), "Výchozí"),
|
||||
]
|
||||
)
|
||||
assert m.is_grouped is True
|
||||
|
||||
def test_primary_prefers_the_plain_version(self):
|
||||
m = _movie(
|
||||
media=[MediaFile(Path("/a.mkv"), "3D HSBS"), MediaFile(Path("/b.mkv"), "")]
|
||||
)
|
||||
assert m.primary.label == ""
|
||||
|
||||
|
||||
class TestDesiredLayout:
|
||||
def test_one_video_stays_flat_in_the_library(self, tmp_path):
|
||||
m = _movie(media=[MediaFile(tmp_path / "kdekoli.mkv", "")])
|
||||
((_media, target),) = m.desired_layout(tmp_path)
|
||||
assert target == tmp_path / "Filmy" / "Avatar (2009) [tmdbid-19995].mkv"
|
||||
|
||||
def test_one_3d_video_keeps_the_3d_tag_flat(self, tmp_path):
|
||||
m = _movie(media=[MediaFile(tmp_path / "x.mkv", "3D HSBS")])
|
||||
((_m, target),) = m.desired_layout(tmp_path)
|
||||
assert target.name == "Avatar (2009) [tmdbid-19995].3D.HSBS.mkv"
|
||||
assert target.parent == tmp_path / "Filmy"
|
||||
|
||||
def test_two_videos_move_into_the_movie_folder(self, tmp_path):
|
||||
m = _movie(
|
||||
media=[
|
||||
MediaFile(tmp_path / "a.mkv", ""),
|
||||
MediaFile(tmp_path / "b.mkv", "3D HSBS"),
|
||||
]
|
||||
)
|
||||
targets = {mf.label: path for mf, path in m.desired_layout(tmp_path)}
|
||||
folder = tmp_path / "Filmy" / "Avatar (2009) [tmdbid-19995]"
|
||||
assert (
|
||||
targets[""]
|
||||
== folder / f"Avatar (2009) [tmdbid-19995] - {DEFAULT_VERSION_LABEL}.mkv"
|
||||
)
|
||||
assert targets["3D HSBS"] == folder / "Avatar (2009) [tmdbid-19995].3D.HSBS.mkv"
|
||||
|
||||
def test_layout_follows_a_retitle(self, tmp_path):
|
||||
"""Renaming the movie is just a different desired layout — the thing
|
||||
that used to be impossible for a grouped movie."""
|
||||
m = _movie(
|
||||
title="Avatar: Nová verze",
|
||||
media=[
|
||||
MediaFile(tmp_path / "a.mkv", "A"),
|
||||
MediaFile(tmp_path / "b.mkv", "B"),
|
||||
],
|
||||
)
|
||||
folders = {path.parent for _mf, path in m.desired_layout(tmp_path)}
|
||||
assert folders == {
|
||||
tmp_path / "Filmy" / "Avatar: Nová verze (2009) [tmdbid-19995]"
|
||||
}
|
||||
|
||||
def test_layout_follows_a_library_move(self, tmp_path):
|
||||
m = _movie(library="Kids", media=[MediaFile(tmp_path / "a.mkv", "")])
|
||||
((_m, target),) = m.desired_layout(tmp_path)
|
||||
assert target.parent == tmp_path / "Kids"
|
||||
|
||||
|
||||
class TestRecordRoundTrip:
|
||||
def test_to_and_from_record(self, tmp_path):
|
||||
m = _movie(
|
||||
media=[MediaFile(tmp_path / "Filmy" / "a.mkv", "3D HSBS")],
|
||||
csfd_url="https://www.csfd.cz/film/1/",
|
||||
date="2026-01-01",
|
||||
attributes={"collection_sort": "01"},
|
||||
meta={"year": "2009"},
|
||||
)
|
||||
|
||||
record = m.to_record(tmp_path)
|
||||
assert record["media"] == [
|
||||
{"role": "video", "label": "3D HSBS", "file": "Filmy/a.mkv"}
|
||||
]
|
||||
|
||||
back = Movie.from_record("tmdb-19995", record, tmp_path)
|
||||
assert back.title == "Avatar"
|
||||
assert back.year == "2009"
|
||||
assert back.tmdb_id == 19995
|
||||
assert back.library == "Filmy"
|
||||
assert back.csfd_url == "https://www.csfd.cz/film/1/"
|
||||
assert back.attributes == {"collection_sort": "01"}
|
||||
assert back.meta == {"year": "2009"}
|
||||
assert back.media[0].path == tmp_path / "Filmy" / "a.mkv"
|
||||
assert back.media[0].format_3d == "HSBS"
|
||||
|
||||
def test_from_record_builds_tags_via_tagmanager(self, tmp_path):
|
||||
from src.core.tag_manager import TagManager
|
||||
|
||||
record = {"title": "Avatar", "media": [], "tags": ["Žánr/Sci-Fi"]}
|
||||
|
||||
back = Movie.from_record("tmdb-19995", record, tmp_path, TagManager())
|
||||
|
||||
assert {t.full_path for t in back.tags} == {"Žánr/Sci-Fi"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"label,expected",
|
||||
[
|
||||
("part1", 1),
|
||||
("Part 2", 2),
|
||||
("pt3", 3),
|
||||
("CD1", 1),
|
||||
("disc-2", 2),
|
||||
("dvd_4", 4),
|
||||
("disk12", 12),
|
||||
("4K", None),
|
||||
("3D HSBS", None),
|
||||
("", None),
|
||||
("partition", None),
|
||||
],
|
||||
)
|
||||
def test_normalize_part_label(label, expected):
|
||||
from src.core.movie import normalize_part_label
|
||||
|
||||
assert normalize_part_label(label) == expected
|
||||
@@ -1,160 +0,0 @@
|
||||
"""Tests for the pool naming convention helpers (src.core.naming)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.naming import (
|
||||
THREE_D_FORMATS,
|
||||
canonical_pool_stem,
|
||||
is_canonical_pool_stem,
|
||||
is_valid_year,
|
||||
parse_3d_format,
|
||||
parse_pool_stem,
|
||||
parse_tmdb_id,
|
||||
three_d_format_from_filename,
|
||||
year_from_filename,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"year,ok",
|
||||
[
|
||||
("1968", True),
|
||||
(1968, True),
|
||||
("968", False),
|
||||
("19688", False),
|
||||
("abcd", False),
|
||||
("", False),
|
||||
(" 1999 ", True),
|
||||
],
|
||||
)
|
||||
def test_is_valid_year(year, ok):
|
||||
assert is_valid_year(year) is ok
|
||||
|
||||
|
||||
def test_canonical_pool_stem_builds_title_year():
|
||||
assert canonical_pool_stem("Matrix", 1999) == "Matrix (1999)"
|
||||
assert canonical_pool_stem(" Matrix ", "1999") == "Matrix (1999)"
|
||||
|
||||
|
||||
def test_canonical_pool_stem_rejects_bad_year():
|
||||
with pytest.raises(ValueError):
|
||||
canonical_pool_stem("Matrix", "99")
|
||||
|
||||
|
||||
def test_canonical_pool_stem_rejects_empty_title():
|
||||
with pytest.raises(ValueError):
|
||||
canonical_pool_stem(" ", "1999")
|
||||
|
||||
|
||||
def test_parse_pool_stem_roundtrip():
|
||||
assert parse_pool_stem("Matrix (1999)") == ("Matrix", 1999)
|
||||
# title keeps its own parentheses; only the trailing year is peeled off
|
||||
assert parse_pool_stem("Já, robot (2004)") == ("Já, robot", 2004)
|
||||
assert parse_pool_stem("Kurz (special) (2019)") == ("Kurz (special)", 2019)
|
||||
|
||||
|
||||
def test_parse_pool_stem_non_canonical():
|
||||
assert parse_pool_stem("Matrix") is None
|
||||
assert parse_pool_stem("Matrix (99)") is None
|
||||
assert parse_pool_stem("Matrix 1999") is None
|
||||
|
||||
|
||||
def test_canonical_pool_stem_with_tmdb_id():
|
||||
assert canonical_pool_stem("Matrix", 1999, 603) == "Matrix (1999) [tmdbid-603]"
|
||||
# empty/None id → plain stem, no brackets
|
||||
assert canonical_pool_stem("Matrix", 1999, None) == "Matrix (1999)"
|
||||
assert canonical_pool_stem("Matrix", 1999, "") == "Matrix (1999)"
|
||||
|
||||
|
||||
def test_canonical_pool_stem_rejects_bad_id():
|
||||
with pytest.raises(ValueError):
|
||||
canonical_pool_stem("Matrix", 1999, "tt0133093")
|
||||
|
||||
|
||||
def test_parse_pool_stem_strips_trailing_tmdb_id():
|
||||
# title stays clean; the id is dropped from parse_pool_stem
|
||||
assert parse_pool_stem("Matrix (1999) [tmdbid-603]") == ("Matrix", 1999)
|
||||
|
||||
|
||||
def test_parse_tmdb_id():
|
||||
assert parse_tmdb_id("Matrix (1999) [tmdbid-603]") == 603
|
||||
assert parse_tmdb_id("Matrix (1999)") is None
|
||||
assert parse_tmdb_id("Matrix") is None
|
||||
|
||||
|
||||
def test_is_canonical_accepts_tmdb_id_form():
|
||||
assert is_canonical_pool_stem("Matrix (1999) [tmdbid-603]")
|
||||
|
||||
|
||||
def test_is_canonical_pool_stem():
|
||||
assert is_canonical_pool_stem("Matrix (1999)")
|
||||
assert not is_canonical_pool_stem("Matrix")
|
||||
|
||||
|
||||
def test_canonical_pool_stem_with_3d_format():
|
||||
assert (
|
||||
canonical_pool_stem("Avatar", 2009, 19995, "hsbs")
|
||||
== "Avatar (2009) [tmdbid-19995].3D.HSBS"
|
||||
)
|
||||
# upper-cased regardless of input case
|
||||
assert canonical_pool_stem("Avatar", 2009, None, "fsbs") == "Avatar (2009).3D.FSBS"
|
||||
# empty/None → no tag at all
|
||||
assert canonical_pool_stem("Avatar", 2009, None, None) == "Avatar (2009)"
|
||||
assert canonical_pool_stem("Avatar", 2009, None, "") == "Avatar (2009)"
|
||||
|
||||
|
||||
def test_canonical_pool_stem_rejects_bad_3d_format():
|
||||
with pytest.raises(ValueError):
|
||||
canonical_pool_stem("Avatar", 2009, None, "bogus")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fmt", THREE_D_FORMATS)
|
||||
def test_parse_3d_format_roundtrip(fmt):
|
||||
stem = canonical_pool_stem("Avatar", 2009, 19995, fmt)
|
||||
assert parse_3d_format(stem) == fmt
|
||||
assert parse_pool_stem(stem) == ("Avatar", 2009)
|
||||
assert parse_tmdb_id(stem) == 19995
|
||||
|
||||
|
||||
def test_parse_3d_format_case_insensitive_on_read():
|
||||
assert parse_3d_format("Avatar (2009).3D.hsbs") == "HSBS"
|
||||
|
||||
|
||||
def test_parse_3d_format_absent_is_none():
|
||||
assert parse_3d_format("Avatar (2009) [tmdbid-19995]") is None
|
||||
assert parse_3d_format("Avatar (2009)") is None
|
||||
|
||||
|
||||
def test_canonical_pool_stem_3d_without_tmdb_id():
|
||||
assert canonical_pool_stem("Avatar", 2009, None, "MVC") == "Avatar (2009).3D.MVC"
|
||||
assert parse_3d_format("Avatar (2009).3D.MVC") == "MVC"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name,year",
|
||||
[
|
||||
("Matrix.1999.1080p.BluRay.mkv", "1999"),
|
||||
("Šíleně smutná princezna (1968).mkv", "1968"),
|
||||
("Movie 2001 2010 remux.mkv", "2010"), # last standalone year wins
|
||||
("No Year Here.mkv", None),
|
||||
("Film 12345.mkv", None), # not a bare 4-digit year
|
||||
],
|
||||
)
|
||||
def test_year_from_filename(name, year):
|
||||
assert year_from_filename(name) == year
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name,fmt",
|
||||
[
|
||||
("Avatar.2009.3D.HSBS.BluRay.mkv", "HSBS"),
|
||||
("Avatar.2009.3d.hsbs.mkv", "HSBS"),
|
||||
("Avatar (2009) 3D_FTAB 1080p.mkv", "FTAB"),
|
||||
("Avatar (2009) - 3D-MVC.mkv", "MVC"),
|
||||
("Avatar (2009).mkv", None),
|
||||
("Premium3D.HSBS.mkv", None), # "3D" glued to a word doesn't count
|
||||
("Avatar.2009.HSBS.mkv", None), # no "3D" token at all
|
||||
],
|
||||
)
|
||||
def test_three_d_format_from_filename(name, fmt):
|
||||
assert three_d_format_from_filename(name) == fmt
|
||||
@@ -1,148 +0,0 @@
|
||||
"""Tests for the NFO reader (src.core.nfo)."""
|
||||
|
||||
from src.core.nfo import NfoData, find_nfo, read_nfo
|
||||
|
||||
MOVIE_NFO = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<movie>
|
||||
<title>Matrix</title>
|
||||
<year>1999</year>
|
||||
<genre>Akční</genre>
|
||||
<genre>Sci-Fi</genre>
|
||||
<country>USA</country>
|
||||
<rating>8.7</rating>
|
||||
<uniqueid type="tmdb">603</uniqueid>
|
||||
</movie>
|
||||
"""
|
||||
|
||||
|
||||
def _movie(tmp_path):
|
||||
v = tmp_path / "Matrix (1999) [tmdbid-603].mkv"
|
||||
v.write_bytes(b"x")
|
||||
return v
|
||||
|
||||
|
||||
def test_read_nfo_sibling(tmp_path):
|
||||
video = _movie(tmp_path)
|
||||
video.with_suffix(".nfo").write_text(MOVIE_NFO, encoding="utf-8")
|
||||
|
||||
nfo = read_nfo(video)
|
||||
assert nfo == NfoData(
|
||||
title="Matrix",
|
||||
year="1999",
|
||||
genres=["Akční", "Sci-Fi"],
|
||||
countries=["USA"],
|
||||
rating=87,
|
||||
tmdb_id=603,
|
||||
)
|
||||
|
||||
|
||||
def test_rating_scaled_to_percent(tmp_path):
|
||||
video = _movie(tmp_path)
|
||||
video.with_suffix(".nfo").write_text(
|
||||
"<movie><rating>7.3</rating></movie>", encoding="utf-8"
|
||||
)
|
||||
assert read_nfo(video).rating == 73
|
||||
|
||||
|
||||
def test_rating_from_ratings_block(tmp_path):
|
||||
video = _movie(tmp_path)
|
||||
video.with_suffix(".nfo").write_text(
|
||||
"<movie><ratings><rating name='tmdb'><value>9.1</value></rating>"
|
||||
"</ratings></movie>",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert read_nfo(video).rating == 91
|
||||
|
||||
|
||||
def test_tmdbid_from_tmdbid_tag(tmp_path):
|
||||
video = _movie(tmp_path)
|
||||
video.with_suffix(".nfo").write_text(
|
||||
"<movie><tmdbid>603</tmdbid></movie>", encoding="utf-8"
|
||||
)
|
||||
assert read_nfo(video).tmdb_id == 603
|
||||
|
||||
|
||||
def test_csfd_url_from_uniqueid_bare_id(tmp_path):
|
||||
video = _movie(tmp_path)
|
||||
video.with_suffix(".nfo").write_text(
|
||||
'<movie><uniqueid type="csfd">9499</uniqueid></movie>', encoding="utf-8"
|
||||
)
|
||||
assert read_nfo(video).csfd_url == "https://www.csfd.cz/film/9499/"
|
||||
|
||||
|
||||
def test_csfd_url_from_uniqueid_full_url(tmp_path):
|
||||
video = _movie(tmp_path)
|
||||
video.with_suffix(".nfo").write_text(
|
||||
'<movie><uniqueid type="csfd">https://www.csfd.cz/film/9499-matrix/'
|
||||
"</uniqueid></movie>",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert read_nfo(video).csfd_url == "https://www.csfd.cz/film/9499-matrix/"
|
||||
|
||||
|
||||
def test_csfd_url_from_legacy_csfdid_tag(tmp_path):
|
||||
video = _movie(tmp_path)
|
||||
video.with_suffix(".nfo").write_text(
|
||||
"<movie><csfdid>12345</csfdid></movie>", encoding="utf-8"
|
||||
)
|
||||
assert read_nfo(video).csfd_url == "https://www.csfd.cz/film/12345/"
|
||||
|
||||
|
||||
def test_csfd_url_absent_is_none(tmp_path):
|
||||
video = _movie(tmp_path)
|
||||
video.with_suffix(".nfo").write_text(
|
||||
"<movie><title>Matrix</title></movie>", encoding="utf-8"
|
||||
)
|
||||
assert read_nfo(video).csfd_url is None
|
||||
|
||||
|
||||
def test_find_prefers_sibling_over_movie_nfo(tmp_path):
|
||||
folder = tmp_path / "Matrix (1999)"
|
||||
folder.mkdir()
|
||||
video = folder / "Matrix (1999).mkv"
|
||||
video.write_bytes(b"x")
|
||||
(folder / "movie.nfo").write_text(MOVIE_NFO, encoding="utf-8")
|
||||
# no sibling → movie.nfo is used
|
||||
assert find_nfo(video).name == "movie.nfo"
|
||||
# sibling present → it wins
|
||||
video.with_suffix(".nfo").write_text(MOVIE_NFO, encoding="utf-8")
|
||||
assert find_nfo(video).name == "Matrix (1999).nfo"
|
||||
|
||||
|
||||
def test_missing_nfo_returns_none(tmp_path):
|
||||
assert read_nfo(_movie(tmp_path)) is None
|
||||
|
||||
|
||||
def test_broken_nfo_returns_none(tmp_path):
|
||||
video = _movie(tmp_path)
|
||||
video.with_suffix(".nfo").write_text("<movie><genre>unclosed", encoding="utf-8")
|
||||
assert read_nfo(video) is None
|
||||
|
||||
|
||||
def test_field_values():
|
||||
nfo = NfoData(year="1999", genres=["A", "B"], countries=["USA"], rating=87)
|
||||
assert nfo.field_values("genres") == ["A", "B"]
|
||||
assert nfo.field_values("countries") == ["USA"]
|
||||
assert nfo.field_values("year") == ["1999"]
|
||||
assert nfo.field_values("rating") == ["87"]
|
||||
assert nfo.field_values("directors") == []
|
||||
|
||||
|
||||
def test_refresh_tags_from_nfo(tmp_path, monkeypatch):
|
||||
import src.core.config as config_module
|
||||
|
||||
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", tmp_path / ".gtag")
|
||||
from src.core.file_manager import FileManager
|
||||
from src.core.tag_manager import TagManager
|
||||
|
||||
fm = FileManager(TagManager())
|
||||
fm.set_pool_dir(tmp_path / "pool")
|
||||
(tmp_path / "raw.mkv").write_bytes(b"x")
|
||||
movie = fm.import_movie(tmp_path / "raw.mkv", "Matrix", year=1999, tmdb_id=603)
|
||||
movie.file_path.with_suffix(".nfo").write_text(MOVIE_NFO, encoding="utf-8")
|
||||
|
||||
result = fm.refresh_tags_from_nfo()
|
||||
assert result == {"updated": 1, "without_nfo": 0}
|
||||
paths = {t.full_path for t in movie.tags}
|
||||
assert {"Žánr/Akční", "Žánr/Sci-Fi", "Rok/1999", "Země původu/USA"} <= paths
|
||||
assert movie.meta == {"year": "1999", "rating": 87}
|
||||
@@ -1,249 +0,0 @@
|
||||
import json
|
||||
|
||||
from src.core.file import File
|
||||
from src.core.pool_index import INDEX_FILENAME, INDEX_VERSION, PoolIndex
|
||||
from src.core.tag_manager import TagManager
|
||||
|
||||
|
||||
class TestPoolIndex:
|
||||
"""Testy pro unifikovaný metadata index poolu (v2 — klíč = identita filmu)"""
|
||||
|
||||
def test_empty_index_when_no_file(self, tmp_path):
|
||||
index = PoolIndex(tmp_path)
|
||||
assert index.records == {}
|
||||
assert index.get_movie("tmdb-603") is None
|
||||
|
||||
def test_set_and_get_movie_by_identity(self, tmp_path):
|
||||
index = PoolIndex(tmp_path)
|
||||
index.set_movie("tmdb-603", {"title": "Matrix", "media": []})
|
||||
|
||||
assert index.get_movie("tmdb-603") == {"title": "Matrix", "media": []}
|
||||
assert "tmdb-603" in index.records
|
||||
|
||||
def test_set_persists_to_disk_with_version(self, tmp_path):
|
||||
index = PoolIndex(tmp_path)
|
||||
index.set_movie("tmdb-603", {"title": "Matrix", "media": []})
|
||||
|
||||
on_disk = json.loads((tmp_path / INDEX_FILENAME).read_text(encoding="utf-8"))
|
||||
assert on_disk["version"] == INDEX_VERSION
|
||||
assert on_disk["movies"]["tmdb-603"]["title"] == "Matrix"
|
||||
|
||||
def test_reload_reads_existing_index(self, tmp_path):
|
||||
PoolIndex(tmp_path).set_movie("tmdb-603", {"title": "Matrix", "media": []})
|
||||
|
||||
reloaded = PoolIndex(tmp_path)
|
||||
assert reloaded.get_movie("tmdb-603")["title"] == "Matrix"
|
||||
|
||||
def test_delete_movie_removes_record(self, tmp_path):
|
||||
index = PoolIndex(tmp_path)
|
||||
index.set_movie("tmdb-603", {"title": "Matrix", "media": []})
|
||||
index.delete_movie("tmdb-603")
|
||||
|
||||
assert index.get_movie("tmdb-603") is None
|
||||
assert PoolIndex(tmp_path).get_movie("tmdb-603") is None
|
||||
|
||||
def test_rekey_movie(self, tmp_path):
|
||||
index = PoolIndex(tmp_path)
|
||||
index.set_movie("name-matrix-1999", {"title": "Matrix", "media": []})
|
||||
|
||||
index.rekey_movie("name-matrix-1999", "tmdb-603")
|
||||
|
||||
assert index.get_movie("name-matrix-1999") is None
|
||||
assert index.get_movie("tmdb-603")["title"] == "Matrix"
|
||||
|
||||
def test_find_movie_by_media(self, tmp_path):
|
||||
index = PoolIndex(tmp_path)
|
||||
index.set_movie(
|
||||
"tmdb-603",
|
||||
{
|
||||
"title": "Matrix",
|
||||
"media": [{"role": "video", "label": "", "file": "Filmy/Matrix.mkv"}],
|
||||
},
|
||||
)
|
||||
|
||||
found = index.find_movie_by_media(tmp_path / "Filmy" / "Matrix.mkv")
|
||||
assert found is not None
|
||||
key, record = found
|
||||
assert key == "tmdb-603"
|
||||
assert record["title"] == "Matrix"
|
||||
assert index.find_movie_by_media(tmp_path / "Filmy" / "Jiny.mkv") is None
|
||||
|
||||
def test_corrupt_index_loads_empty(self, tmp_path):
|
||||
(tmp_path / INDEX_FILENAME).write_text("{ not json", encoding="utf-8")
|
||||
index = PoolIndex(tmp_path)
|
||||
assert index.records == {}
|
||||
|
||||
|
||||
class TestV1Migration:
|
||||
"""Starý path-klíčovaný index se převede na identity-klíčovaný (bez hýbání soubory)"""
|
||||
|
||||
def _write_v1(self, tmp_path, movies: dict) -> None:
|
||||
(tmp_path / INDEX_FILENAME).write_text(
|
||||
json.dumps({"settings": {"tag_schema": []}, "movies": movies}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def test_flat_movie_becomes_identity_keyed_record(self, tmp_path):
|
||||
self._write_v1(
|
||||
tmp_path,
|
||||
{
|
||||
"Filmy/Matrix (1999) [tmdbid-603].mkv": {
|
||||
"title": "Matrix",
|
||||
"tags": ["Žánr/Akční"],
|
||||
"csfd_url": "https://www.csfd.cz/film/9499/",
|
||||
"date": "1999-03-31",
|
||||
"added": "2026-01-01T00:00:00",
|
||||
"attributes": {"collection_sort": "01"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
index = PoolIndex(tmp_path)
|
||||
|
||||
record = index.get_movie("tmdb-603")
|
||||
assert record is not None
|
||||
assert record["title"] == "Matrix"
|
||||
assert record["year"] == "1999"
|
||||
assert record["tmdb_id"] == 603
|
||||
assert record["library"] == "Filmy"
|
||||
assert record["media"] == [
|
||||
{
|
||||
"role": "video",
|
||||
"label": "",
|
||||
"file": "Filmy/Matrix (1999) [tmdbid-603].mkv",
|
||||
}
|
||||
]
|
||||
assert record["tags"] == ["Žánr/Akční"]
|
||||
assert record["csfd_url"] == "https://www.csfd.cz/film/9499/"
|
||||
assert record["attributes"] == {"collection_sort": "01"}
|
||||
|
||||
def test_grouped_versions_merge_into_one_record(self, tmp_path):
|
||||
base = "Filmy/Avatar (2009) [tmdbid-19995]"
|
||||
self._write_v1(
|
||||
tmp_path,
|
||||
{
|
||||
f"{base}/Avatar (2009) [tmdbid-19995] - Výchozí.mkv": {
|
||||
"title": "Avatar"
|
||||
},
|
||||
f"{base}/Avatar (2009) [tmdbid-19995] - 3D HSBS.mkv": {
|
||||
"title": "Avatar"
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
index = PoolIndex(tmp_path)
|
||||
|
||||
assert len(index.all_movies()) == 1
|
||||
record = index.get_movie("tmdb-19995")
|
||||
assert {m["label"] for m in record["media"]} == {"Výchozí", "3D HSBS"}
|
||||
assert record["library"] == "Filmy"
|
||||
|
||||
def test_movie_without_tmdbid_gets_slug_key(self, tmp_path):
|
||||
self._write_v1(tmp_path, {"Filmy/Pelíšky (1999).mkv": {"title": "Pelíšky"}})
|
||||
|
||||
index = PoolIndex(tmp_path)
|
||||
|
||||
assert index.get_movie("name-pelisky-1999") is not None
|
||||
|
||||
def test_migration_backs_up_and_bumps_version(self, tmp_path):
|
||||
self._write_v1(tmp_path, {"Filmy/A (2000).mkv": {"title": "A"}})
|
||||
|
||||
PoolIndex(tmp_path)
|
||||
|
||||
backup = tmp_path / (INDEX_FILENAME + ".v1.bak")
|
||||
assert backup.exists()
|
||||
assert (
|
||||
"Filmy/A (2000).mkv"
|
||||
in json.loads(backup.read_text(encoding="utf-8"))["movies"]
|
||||
)
|
||||
on_disk = json.loads((tmp_path / INDEX_FILENAME).read_text(encoding="utf-8"))
|
||||
assert on_disk["version"] == INDEX_VERSION
|
||||
|
||||
def test_migration_keeps_settings(self, tmp_path):
|
||||
self._write_v1(tmp_path, {"Filmy/A (2000).mkv": {"title": "A"}})
|
||||
index = PoolIndex(tmp_path)
|
||||
assert index.get_setting("tag_schema") == []
|
||||
|
||||
def test_v2_index_is_not_migrated_again(self, tmp_path):
|
||||
(tmp_path / INDEX_FILENAME).write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": INDEX_VERSION,
|
||||
"settings": {},
|
||||
"movies": {"tmdb-1": {"title": "X", "media": []}},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
index = PoolIndex(tmp_path)
|
||||
|
||||
assert index.get_movie("tmdb-1")["title"] == "X"
|
||||
assert not (tmp_path / (INDEX_FILENAME + ".v1.bak")).exists()
|
||||
|
||||
|
||||
class TestFileWithIndex:
|
||||
"""File je pohled na jeden media záznam filmu v indexu"""
|
||||
|
||||
def test_index_backed_file_writes_no_sidecar(self, tmp_path):
|
||||
index = PoolIndex(tmp_path)
|
||||
movie = tmp_path / "Filmy" / "Matrix.mkv"
|
||||
movie.parent.mkdir(parents=True)
|
||||
movie.write_bytes(b"x")
|
||||
|
||||
f = File(movie, TagManager(), index=index)
|
||||
|
||||
assert not (movie.parent / ".Matrix.mkv.!tag").exists() # no sidecar
|
||||
assert index.find_movie_by_media(movie) is not None # record created
|
||||
assert f.tags == [] # no automatic tags
|
||||
|
||||
def test_index_backed_metadata_persists_across_reload(self, tmp_path):
|
||||
index = PoolIndex(tmp_path)
|
||||
movie = tmp_path / "Filmy" / "Matrix.mkv"
|
||||
movie.parent.mkdir(parents=True)
|
||||
movie.write_bytes(b"x")
|
||||
|
||||
tm = TagManager()
|
||||
f = File(movie, tm, index=index)
|
||||
f.title = "Matrix"
|
||||
f.meta = {"year": "1999", "rating": 87}
|
||||
f.add_tag("Žánr/Akční")
|
||||
f.set_date("1999-03-31")
|
||||
|
||||
# Fresh index + File read from disk
|
||||
index2 = PoolIndex(tmp_path)
|
||||
f2 = File(movie, TagManager(), index=index2)
|
||||
assert f2.title == "Matrix"
|
||||
assert f2.meta == {"year": "1999", "rating": 87}
|
||||
assert f2.date == "1999-03-31"
|
||||
assert "Žánr/Akční" in {t.full_path for t in f2.tags}
|
||||
|
||||
def test_delete_metadata_removes_index_record(self, tmp_path):
|
||||
index = PoolIndex(tmp_path)
|
||||
movie = tmp_path / "Filmy" / "Matrix.mkv"
|
||||
movie.parent.mkdir(parents=True)
|
||||
movie.write_bytes(b"x")
|
||||
|
||||
f = File(movie, TagManager(), index=index)
|
||||
f.delete_metadata()
|
||||
assert index.find_movie_by_media(movie) is None
|
||||
|
||||
def test_two_versions_share_one_movie_record(self, tmp_path):
|
||||
"""Obě verze čtou stejná metadata — to je smysl identity klíče."""
|
||||
index = PoolIndex(tmp_path)
|
||||
folder = tmp_path / "Filmy" / "Avatar (2009) [tmdbid-19995]"
|
||||
folder.mkdir(parents=True)
|
||||
a = folder / "Avatar (2009) [tmdbid-19995] - Výchozí.mkv"
|
||||
b = folder / "Avatar (2009) [tmdbid-19995] - 3D HSBS.mkv"
|
||||
a.write_bytes(b"x")
|
||||
b.write_bytes(b"y")
|
||||
|
||||
tm = TagManager()
|
||||
fa = File(a, tm, index=index)
|
||||
fa.title = "Avatar"
|
||||
fa.add_tag("Žánr/Sci-Fi")
|
||||
fb = File(b, tm, index=index)
|
||||
|
||||
assert len(index.all_movies()) == 1
|
||||
assert fb.title == "Avatar"
|
||||
assert "Žánr/Sci-Fi" in {t.full_path for t in fb.tags}
|
||||
@@ -1,525 +0,0 @@
|
||||
"""Tests for series naming/scanning (src.core.series) + FileManager episodes."""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.series import (
|
||||
Episode,
|
||||
collect_episode_files,
|
||||
episode_filename,
|
||||
is_canonical_episode,
|
||||
is_season_folder,
|
||||
parse_episode,
|
||||
parse_episode_code,
|
||||
parse_episode_part,
|
||||
sanitize_title,
|
||||
scan_series,
|
||||
season_folder_name,
|
||||
series_folder_stem,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name,expected",
|
||||
[
|
||||
("Breaking Bad S01E02.mkv", (1, 2)),
|
||||
("bb.s1e2.mkv", (1, 2)),
|
||||
("Show 01x02 x264.mkv", (1, 2)),
|
||||
("Series.S01E02E03.mkv", (1, 2)), # multi-ep → first
|
||||
("Series S12E145.mkv", (12, 145)),
|
||||
("No code here.mkv", None),
|
||||
],
|
||||
)
|
||||
def test_parse_episode(name, expected):
|
||||
assert parse_episode(name) == expected
|
||||
|
||||
|
||||
def test_season_folder_name():
|
||||
assert season_folder_name(0) == "Specials"
|
||||
assert season_folder_name(1) == "Season 01"
|
||||
assert season_folder_name(12) == "Season 12"
|
||||
|
||||
|
||||
def test_episode_filename_is_code_plus_series_title():
|
||||
assert episode_filename(1, 2, "Most!", ".mkv") == "S01E02 - Most!.mkv"
|
||||
assert episode_filename(0, 3, "Most!", ".mkv") == "S00E03 - Most!.mkv"
|
||||
# release string is NOT kept — only the code + series title
|
||||
assert (
|
||||
episode_filename(12, 145, "Breaking Bad", ".mkv")
|
||||
== "S12E145 - Breaking Bad.mkv"
|
||||
)
|
||||
# empty / unusable title → just the code
|
||||
assert episode_filename(3, 4, "", ".mkv") == "S03E04.mkv"
|
||||
assert episode_filename(3, 4, "/", ".mkv") == "S03E04.mkv"
|
||||
|
||||
|
||||
def test_sanitize_title():
|
||||
assert sanitize_title("Most!") == "Most!"
|
||||
assert sanitize_title("A: B / C D") == "A B C D"
|
||||
assert sanitize_title(" trailing. ") == "trailing"
|
||||
|
||||
|
||||
def test_is_season_folder():
|
||||
assert is_season_folder("Season 01")
|
||||
assert is_season_folder("Season 1")
|
||||
assert is_season_folder("Specials")
|
||||
assert not is_season_folder("Extras")
|
||||
assert not is_season_folder("Season")
|
||||
|
||||
|
||||
def test_series_folder_stem():
|
||||
assert (
|
||||
series_folder_stem("Breaking Bad", 2008, 1396)
|
||||
== "Breaking Bad (2008) [tmdbid-1396]"
|
||||
)
|
||||
assert series_folder_stem("Neznámý", None, 42) == "Neznámý [tmdbid-42]"
|
||||
assert series_folder_stem("Jen název", None, None) == "Jen název"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name,expected",
|
||||
[
|
||||
("Show S01E02.pt2.mkv", 2),
|
||||
("Show S01E01-part-1.mkv", 1),
|
||||
("Show S01E01 part 1.mkv", 1),
|
||||
("Show S01E01_cd1.mkv", 1),
|
||||
("Show S01E01.disc2.mkv", 2),
|
||||
("Show S01E01.mkv", None),
|
||||
("Show Abcd2.mkv", None), # "cd2" mid-word — not a stacking suffix
|
||||
("Show Part of Something.mkv", None), # no digit right after → not a match
|
||||
],
|
||||
)
|
||||
def test_parse_episode_part(name, expected):
|
||||
assert parse_episode_part(name) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text,expected",
|
||||
[
|
||||
("S01E02P3", (1, 2, 3)),
|
||||
("s1e2p3", (1, 2, 3)),
|
||||
("S01E02 pt3", (1, 2, 3)),
|
||||
("S01E02.part.3", (1, 2, 3)),
|
||||
("S01E02 cd2", (1, 2, 2)),
|
||||
("S01E02", (1, 2, None)),
|
||||
("1x02", (1, 2, None)),
|
||||
("no code here", None),
|
||||
],
|
||||
)
|
||||
def test_parse_episode_code(text, expected):
|
||||
assert parse_episode_code(text) == expected
|
||||
|
||||
|
||||
def test_episode_filename_with_part():
|
||||
assert episode_filename(1, 2, "Most!", ".mkv", part=3) == "S01E02 - Most!.pt3.mkv"
|
||||
assert episode_filename(1, 2, "", ".mkv", part=2) == "S01E02.pt2.mkv"
|
||||
# no part → unchanged from the plain form
|
||||
assert episode_filename(1, 2, "Most!", ".mkv", part=None) == "S01E02 - Most!.mkv"
|
||||
|
||||
|
||||
def test_episode_code_property_includes_part():
|
||||
assert Episode(None, 1, 2, part=3).code == "S01E02.pt3"
|
||||
assert Episode(None, 1, 2).code == "S01E02"
|
||||
assert Episode(None, None, None).code == "?"
|
||||
|
||||
|
||||
def test_is_canonical_episode(tmp_path):
|
||||
folder = tmp_path / "Breaking Bad (2008) [tmdbid-1396]" / "Season 01"
|
||||
folder.mkdir(parents=True)
|
||||
good = folder / "S01E02 - Breaking Bad.mkv"
|
||||
good.write_bytes(b"x")
|
||||
bad = folder / "bb.s01e02.mkv"
|
||||
bad.write_bytes(b"x")
|
||||
wrong_body = folder / "S01E02 - old release.mkv"
|
||||
wrong_body.write_bytes(b"x")
|
||||
assert is_canonical_episode(good, "Breaking Bad")
|
||||
assert not is_canonical_episode(bad, "Breaking Bad")
|
||||
assert not is_canonical_episode(wrong_body, "Breaking Bad")
|
||||
|
||||
|
||||
def test_is_canonical_episode_with_part(tmp_path):
|
||||
folder = tmp_path / "Show (2008) [tmdbid-1]" / "Season 01"
|
||||
folder.mkdir(parents=True)
|
||||
good = folder / "S01E02 - Show.pt1.mkv"
|
||||
good.write_bytes(b"x")
|
||||
old_style = folder / "S01E02-part-1.mkv" # recognised, but not our own form
|
||||
old_style.write_bytes(b"x")
|
||||
assert is_canonical_episode(good, "Show")
|
||||
assert not is_canonical_episode(old_style, "Show")
|
||||
|
||||
|
||||
def test_scan_series(tmp_path):
|
||||
lib = tmp_path / "Seriály"
|
||||
s1 = lib / "Breaking Bad (2008) [tmdbid-1396]" / "Season 01"
|
||||
s1.mkdir(parents=True)
|
||||
(s1 / "Breaking Bad S01E01.mkv").write_bytes(b"x")
|
||||
(s1 / "Breaking Bad S01E02.mkv").write_bytes(b"x")
|
||||
(s1 / "poster.jpg").write_bytes(b"img") # sidecar → skipped
|
||||
|
||||
found = scan_series(lib, "Seriály")
|
||||
assert len(found) == 1
|
||||
ser = found[0]
|
||||
assert ser.title == "Breaking Bad" and ser.year == "2008" and ser.tmdb_id == 1396
|
||||
assert ser.library == "Seriály"
|
||||
assert len(ser.episodes) == 2
|
||||
seasons = ser.seasons()
|
||||
assert list(seasons.keys()) == [1] and len(seasons[1]) == 2
|
||||
|
||||
|
||||
def test_scan_series_reads_episode_parts(tmp_path):
|
||||
lib = tmp_path / "Seriály"
|
||||
s1 = lib / "Show (2008) [tmdbid-1]" / "Season 01"
|
||||
s1.mkdir(parents=True)
|
||||
(s1 / "Show S01E02.pt1.mkv").write_bytes(b"x")
|
||||
(s1 / "Show S01E02.pt2.mkv").write_bytes(b"x")
|
||||
|
||||
ser = scan_series(lib, "Seriály")[0]
|
||||
parts = sorted(ep.part for ep in ser.episodes)
|
||||
assert parts == [1, 2]
|
||||
assert {ep.code for ep in ser.episodes} == {"S01E02.pt1", "S01E02.pt2"}
|
||||
|
||||
|
||||
def test_collect_episode_files_walks_subfolders(tmp_path):
|
||||
root = tmp_path / "Breaking.Bad.COMPLETE"
|
||||
(root / "Season 01").mkdir(parents=True)
|
||||
(root / "Season 02").mkdir(parents=True)
|
||||
(root / "Season 01" / "BB S01E01.mkv").write_bytes(b"x")
|
||||
(root / "Season 01" / "BB S01E02.mkv").write_bytes(b"x")
|
||||
(root / "Season 01" / "BB S01E02.srt").write_bytes(b"x") # sidecar → skipped
|
||||
(root / "Season 02" / "BB S02E01.avi").write_bytes(b"x")
|
||||
(root / "readme.nfo").write_bytes(b"x") # sidecar → skipped
|
||||
|
||||
files = collect_episode_files(root)
|
||||
|
||||
assert [f.name for f in files] == [
|
||||
"BB S01E01.mkv",
|
||||
"BB S01E02.mkv",
|
||||
"BB S02E01.avi",
|
||||
]
|
||||
|
||||
|
||||
def test_collect_episode_files_missing_folder_is_empty(tmp_path):
|
||||
assert collect_episode_files(tmp_path / "nope") == []
|
||||
|
||||
|
||||
class TestFileManagerSeries:
|
||||
@pytest.fixture
|
||||
def file_manager(self, tmp_path, monkeypatch):
|
||||
import src.core.config as config_module
|
||||
|
||||
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", tmp_path / ".gtag")
|
||||
from src.core.file_manager import FileManager
|
||||
from src.core.tag_manager import TagManager
|
||||
|
||||
fm = FileManager(TagManager())
|
||||
fm.set_pool_dir(tmp_path / "pool")
|
||||
return fm
|
||||
|
||||
def test_import_episodes_places_them(self, file_manager, tmp_path):
|
||||
for n in ("bb.S01E01.mkv", "bb.S01E02.mkv", "notes.txt_S00.dummy"):
|
||||
(tmp_path / n).write_bytes(b"x")
|
||||
result = file_manager.import_episodes(
|
||||
[tmp_path / "bb.S01E01.mkv", tmp_path / "bb.S01E02.mkv"],
|
||||
"Breaking Bad",
|
||||
year=2008,
|
||||
tmdb_id=1396,
|
||||
)
|
||||
|
||||
assert result["imported"] == 2
|
||||
base = (
|
||||
tmp_path
|
||||
/ "pool"
|
||||
/ "Seriály"
|
||||
/ "Breaking Bad (2008) [tmdbid-1396]"
|
||||
/ "Season 01"
|
||||
)
|
||||
assert (base / "S01E01 - Breaking Bad.mkv").exists()
|
||||
assert (base / "S01E02 - Breaking Bad.mkv").exists()
|
||||
|
||||
def test_import_episodes_into_mixed_library(self, file_manager, tmp_path):
|
||||
file_manager.set_libraries([{"name": "Hlavní", "kind": "mixed"}])
|
||||
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
|
||||
result = file_manager.import_episodes(
|
||||
[tmp_path / "bb.S01E01.mkv"],
|
||||
"Breaking Bad",
|
||||
year=2008,
|
||||
tmdb_id=1396,
|
||||
library="Hlavní",
|
||||
)
|
||||
|
||||
assert result["imported"] == 1
|
||||
assert (
|
||||
tmp_path
|
||||
/ "pool"
|
||||
/ "Hlavní"
|
||||
/ "Breaking Bad (2008) [tmdbid-1396]"
|
||||
/ "Season 01"
|
||||
/ "S01E01 - Breaking Bad.mkv"
|
||||
).exists()
|
||||
# a mixed library is scanned for series too
|
||||
found = file_manager.load_series()
|
||||
assert [s.title for s in found] == ["Breaking Bad"]
|
||||
|
||||
def test_import_episodes_skips_uncoded(self, file_manager, tmp_path):
|
||||
(tmp_path / "random.mkv").write_bytes(b"x")
|
||||
result = file_manager.import_episodes(
|
||||
[tmp_path / "random.mkv"], "Breaking Bad", year=2008
|
||||
)
|
||||
assert result["imported"] == 0
|
||||
assert result["skipped_no_code"] == ["random.mkv"]
|
||||
|
||||
def test_remove_series_deletes_the_folder(self, file_manager, tmp_path):
|
||||
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
|
||||
(tmp_path / "bb.S02E01.mkv").write_bytes(b"x")
|
||||
file_manager.import_episodes(
|
||||
[tmp_path / "bb.S01E01.mkv", tmp_path / "bb.S02E01.mkv"],
|
||||
"Breaking Bad",
|
||||
year=2008,
|
||||
tmdb_id=1396,
|
||||
)
|
||||
(ser,) = file_manager.load_series()
|
||||
assert ser.folder.is_dir()
|
||||
|
||||
file_manager.remove_series(ser)
|
||||
|
||||
assert not ser.folder.exists()
|
||||
assert file_manager.load_series() == []
|
||||
|
||||
def test_remove_series_refuses_folder_outside_pool(self, file_manager, tmp_path):
|
||||
from src.core.series import Series
|
||||
|
||||
outside = tmp_path / "not-the-pool" / "Show (2020)"
|
||||
outside.mkdir(parents=True)
|
||||
bogus = Series(outside, "Show", "2020", None, "Seriály")
|
||||
with pytest.raises(ValueError):
|
||||
file_manager.remove_series(bogus)
|
||||
assert outside.is_dir() # untouched
|
||||
|
||||
def test_remove_episodes_deletes_files_and_subtitles(self, file_manager, tmp_path):
|
||||
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
|
||||
(tmp_path / "bb.S01E02.mkv").write_bytes(b"x")
|
||||
file_manager.import_episodes(
|
||||
[tmp_path / "bb.S01E01.mkv", tmp_path / "bb.S01E02.mkv"],
|
||||
"Breaking Bad",
|
||||
year=2008,
|
||||
tmdb_id=1396,
|
||||
)
|
||||
(ser,) = file_manager.load_series()
|
||||
season = ser.seasons()[1]
|
||||
e1 = next(e for e in season if e.episode == 1)
|
||||
e2 = next(e for e in season if e.episode == 2)
|
||||
srt = e1.path.with_suffix(".srt")
|
||||
srt.write_bytes(b"x")
|
||||
|
||||
file_manager.remove_episodes([e1])
|
||||
|
||||
assert not e1.path.exists()
|
||||
assert not srt.exists()
|
||||
assert e2.path.exists() # untouched
|
||||
assert e1.path.parent.is_dir() # not empty — e2 is still there
|
||||
|
||||
def test_remove_episodes_cleans_up_the_now_empty_season_folder(
|
||||
self, file_manager, tmp_path
|
||||
):
|
||||
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
|
||||
file_manager.import_episodes(
|
||||
[tmp_path / "bb.S01E01.mkv"], "Breaking Bad", year=2008, tmdb_id=1396
|
||||
)
|
||||
(ser,) = file_manager.load_series()
|
||||
(ep,) = ser.episodes
|
||||
season_folder = ep.path.parent
|
||||
|
||||
file_manager.remove_episodes([ep])
|
||||
|
||||
assert not ep.path.exists()
|
||||
assert not season_folder.exists()
|
||||
|
||||
def test_remove_season_deletes_every_episode_in_it(self, file_manager, tmp_path):
|
||||
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
|
||||
(tmp_path / "bb.S01E02.mkv").write_bytes(b"x")
|
||||
(tmp_path / "bb.S02E01.mkv").write_bytes(b"x")
|
||||
file_manager.import_episodes(
|
||||
[
|
||||
tmp_path / "bb.S01E01.mkv",
|
||||
tmp_path / "bb.S01E02.mkv",
|
||||
tmp_path / "bb.S02E01.mkv",
|
||||
],
|
||||
"Breaking Bad",
|
||||
year=2008,
|
||||
tmdb_id=1396,
|
||||
)
|
||||
(ser,) = file_manager.load_series()
|
||||
season1_folder = ser.seasons()[1][0].path.parent
|
||||
|
||||
file_manager.remove_season(ser, 1)
|
||||
|
||||
assert not season1_folder.exists()
|
||||
(ser_after,) = file_manager.load_series()
|
||||
assert [ep.season for ep in ser_after.episodes] == [2]
|
||||
|
||||
def test_remove_season_raises_for_a_season_with_no_episodes(
|
||||
self, file_manager, tmp_path
|
||||
):
|
||||
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
|
||||
file_manager.import_episodes(
|
||||
[tmp_path / "bb.S01E01.mkv"], "Breaking Bad", year=2008, tmdb_id=1396
|
||||
)
|
||||
(ser,) = file_manager.load_series()
|
||||
with pytest.raises(ValueError):
|
||||
file_manager.remove_season(ser, 9)
|
||||
|
||||
def test_import_episodes_uses_explicit_codes_override(self, file_manager, tmp_path):
|
||||
uncoded = tmp_path / "pilot.mkv"
|
||||
uncoded.write_bytes(b"x")
|
||||
misparsed = tmp_path / "bb 2008 e07.mkv" # "2008" would parse as 20x08
|
||||
misparsed.write_bytes(b"x")
|
||||
result = file_manager.import_episodes(
|
||||
[uncoded, misparsed],
|
||||
"Breaking Bad",
|
||||
year=2008,
|
||||
tmdb_id=1396,
|
||||
codes={uncoded: (1, 1), misparsed: (1, 7)},
|
||||
)
|
||||
|
||||
assert result["imported"] == 2
|
||||
base = (
|
||||
tmp_path
|
||||
/ "pool"
|
||||
/ "Seriály"
|
||||
/ "Breaking Bad (2008) [tmdbid-1396]"
|
||||
/ "Season 01"
|
||||
)
|
||||
assert (base / "S01E01 - Breaking Bad.mkv").exists()
|
||||
assert (base / "S01E07 - Breaking Bad.mkv").exists()
|
||||
assert result["skipped_no_code"] == []
|
||||
|
||||
def test_import_episodes_uses_explicit_part_override(self, file_manager, tmp_path):
|
||||
tape1 = tmp_path / "tape1.mkv"
|
||||
tape1.write_bytes(b"x")
|
||||
tape2 = tmp_path / "tape2.mkv"
|
||||
tape2.write_bytes(b"x")
|
||||
result = file_manager.import_episodes(
|
||||
[tape1, tape2],
|
||||
"Breaking Bad",
|
||||
year=2008,
|
||||
tmdb_id=1396,
|
||||
codes={tape1: (1, 1, 1), tape2: (1, 1, 2)},
|
||||
)
|
||||
|
||||
assert result["imported"] == 2
|
||||
base = (
|
||||
tmp_path
|
||||
/ "pool"
|
||||
/ "Seriály"
|
||||
/ "Breaking Bad (2008) [tmdbid-1396]"
|
||||
/ "Season 01"
|
||||
)
|
||||
assert (base / "S01E01 - Breaking Bad.pt1.mkv").exists()
|
||||
assert (base / "S01E01 - Breaking Bad.pt2.mkv").exists()
|
||||
|
||||
def test_import_episodes_reads_part_from_filename(self, file_manager, tmp_path):
|
||||
tape = tmp_path / "Show.S01E01.pt2.mkv"
|
||||
tape.write_bytes(b"x")
|
||||
|
||||
result = file_manager.import_episodes([tape], "Show", year=2020, tmdb_id=5)
|
||||
|
||||
base = tmp_path / "pool" / "Seriály" / "Show (2020) [tmdbid-5]" / "Season 01"
|
||||
assert (base / "S01E01 - Show.pt2.mkv").exists()
|
||||
assert result["imported"] == 1
|
||||
|
||||
def test_rename_episodes_to_canonical(self, file_manager, tmp_path):
|
||||
# a mis-named episode sitting in the wrong place
|
||||
folder = tmp_path / "pool" / "Seriály" / "Breaking Bad (2008) [tmdbid-1396]"
|
||||
folder.mkdir(parents=True)
|
||||
(folder / "bb.1x03.HDTV.mkv").write_bytes(b"x")
|
||||
|
||||
series_list = file_manager.load_series()
|
||||
assert len(series_list) == 1
|
||||
result = file_manager.rename_episodes_to_canonical(series_list[0])
|
||||
|
||||
assert result["renamed"] == [("bb.1x03.HDTV.mkv", "S01E03 - Breaking Bad.mkv")]
|
||||
assert (folder / "Season 01" / "S01E03 - Breaking Bad.mkv").exists()
|
||||
|
||||
def test_rename_episodes_to_canonical_keeps_the_part_tag(
|
||||
self, file_manager, tmp_path
|
||||
):
|
||||
folder = tmp_path / "pool" / "Seriály" / "Breaking Bad (2008) [tmdbid-1396]"
|
||||
folder.mkdir(parents=True)
|
||||
(folder / "bb.1x03.HDTV-part2.mkv").write_bytes(b"x")
|
||||
|
||||
result = file_manager.rename_episodes_to_canonical(
|
||||
file_manager.load_series()[0]
|
||||
)
|
||||
|
||||
assert result["renamed"] == [
|
||||
("bb.1x03.HDTV-part2.mkv", "S01E03 - Breaking Bad.pt2.mkv")
|
||||
]
|
||||
assert (folder / "Season 01" / "S01E03 - Breaking Bad.pt2.mkv").exists()
|
||||
|
||||
def test_rename_episodes_to_canonical_carries_subtitle_along(
|
||||
self, file_manager, tmp_path
|
||||
):
|
||||
folder = tmp_path / "pool" / "Seriály" / "Breaking Bad (2008) [tmdbid-1396]"
|
||||
folder.mkdir(parents=True)
|
||||
(folder / "bb.1x03.HDTV.mkv").write_bytes(b"x")
|
||||
(folder / "bb.1x03.HDTV.cs.srt").write_bytes(b"x")
|
||||
|
||||
result = file_manager.rename_episodes_to_canonical(
|
||||
file_manager.load_series()[0]
|
||||
)
|
||||
|
||||
assert result["renamed"] == [("bb.1x03.HDTV.mkv", "S01E03 - Breaking Bad.mkv")]
|
||||
assert (folder / "Season 01" / "S01E03 - Breaking Bad.cs.srt").exists()
|
||||
assert not (folder / "bb.1x03.HDTV.cs.srt").exists()
|
||||
|
||||
def test_rename_all_series_to_canonical_bulk(self, file_manager, tmp_path):
|
||||
seri = tmp_path / "pool" / "Seriály"
|
||||
# series A: mis-named episodes in a non-padded season folder
|
||||
(seri / "Fargo (2014) [tmdbid-60622]" / "Season 1").mkdir(parents=True)
|
||||
(
|
||||
seri
|
||||
/ "Fargo (2014) [tmdbid-60622]"
|
||||
/ "Season 1"
|
||||
/ "fargo.1x01.1080p.WEB.mkv"
|
||||
).write_bytes(b"x")
|
||||
# series B: one good, one without a code
|
||||
(seri / "Most! (2019) [tmdbid-90000]" / "Season 01").mkdir(parents=True)
|
||||
(
|
||||
seri / "Most! (2019) [tmdbid-90000]" / "Season 01" / "S01E02 - Most!.mkv"
|
||||
).write_bytes(b"x")
|
||||
(
|
||||
seri / "Most! (2019) [tmdbid-90000]" / "Season 01" / "necoZvlastniho.mkv"
|
||||
).write_bytes(b"x")
|
||||
|
||||
result = file_manager.rename_all_series_to_canonical()
|
||||
|
||||
assert result["series"] == 2
|
||||
assert ("Fargo", "fargo.1x01.1080p.WEB.mkv", "S01E01 - Fargo.mkv") in result[
|
||||
"renamed"
|
||||
]
|
||||
assert (
|
||||
seri / "Fargo (2014) [tmdbid-60622]" / "Season 01" / "S01E01 - Fargo.mkv"
|
||||
).exists()
|
||||
# emptied non-padded season folder swept
|
||||
assert not (seri / "Fargo (2014) [tmdbid-60622]" / "Season 1").exists()
|
||||
# the already-canonical one is untouched, the code-less one is reported
|
||||
assert ("Most!", "necoZvlastniho.mkv") in result["skipped_no_code"]
|
||||
assert (
|
||||
seri / "Most! (2019) [tmdbid-90000]" / "Season 01" / "S01E02 - Most!.mkv"
|
||||
).exists()
|
||||
|
||||
def test_apply_series_edits_renames_folder_and_episodes(
|
||||
self, file_manager, tmp_path
|
||||
):
|
||||
folder = tmp_path / "pool" / "Seriály" / "Wrong Name"
|
||||
(folder / "Season 01").mkdir(parents=True)
|
||||
(folder / "Season 01" / "old S01E01.mkv").write_bytes(b"x")
|
||||
|
||||
ser = file_manager.load_series()[0]
|
||||
result = file_manager.apply_series_edits(
|
||||
ser, title="Breaking Bad", year=2008, tmdb_id=1396
|
||||
)
|
||||
|
||||
new_folder = tmp_path / "pool" / "Seriály" / "Breaking Bad (2008) [tmdbid-1396]"
|
||||
assert result["folder"] == new_folder
|
||||
assert (new_folder / "Season 01" / "S01E01 - Breaking Bad.mkv").exists()
|
||||
assert not folder.exists()
|
||||
@@ -1,170 +0,0 @@
|
||||
"""Tests for external subtitle sidecars (src.core.subtitles)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.subtitles import (
|
||||
SubtitleFile,
|
||||
attach_subtitle,
|
||||
find_subtitles,
|
||||
rename_subtitles_with,
|
||||
subtitle_filename,
|
||||
)
|
||||
|
||||
|
||||
def _video(tmp_path, name="Matrix (1999) [tmdbid-603].mkv"):
|
||||
v = tmp_path / name
|
||||
v.write_bytes(b"x")
|
||||
return v
|
||||
|
||||
|
||||
class TestSubtitleFilename:
|
||||
def test_bare_no_language(self):
|
||||
assert subtitle_filename("Matrix (1999)", ".srt") == "Matrix (1999).srt"
|
||||
|
||||
def test_with_language(self):
|
||||
assert (
|
||||
subtitle_filename("Matrix (1999)", ".srt", "cs") == "Matrix (1999).cs.srt"
|
||||
)
|
||||
|
||||
def test_with_language_and_flag(self):
|
||||
assert (
|
||||
subtitle_filename("Matrix (1999)", ".srt", "cs", "forced")
|
||||
== "Matrix (1999).cs.forced.srt"
|
||||
)
|
||||
|
||||
def test_uppercase_input_is_lowered(self):
|
||||
assert (
|
||||
subtitle_filename("Matrix (1999)", ".SRT", "CS", "FORCED")
|
||||
== "Matrix (1999).cs.forced.srt"
|
||||
)
|
||||
|
||||
|
||||
class TestFindSubtitles:
|
||||
def test_finds_bare_language_and_flagged(self, tmp_path):
|
||||
video = _video(tmp_path)
|
||||
(tmp_path / "Matrix (1999) [tmdbid-603].srt").write_bytes(b"x")
|
||||
(tmp_path / "Matrix (1999) [tmdbid-603].cs.srt").write_bytes(b"x")
|
||||
(tmp_path / "Matrix (1999) [tmdbid-603].en.forced.srt").write_bytes(b"x")
|
||||
|
||||
found = {s.path.name: s for s in find_subtitles(video)}
|
||||
assert set(found) == {
|
||||
"Matrix (1999) [tmdbid-603].srt",
|
||||
"Matrix (1999) [tmdbid-603].cs.srt",
|
||||
"Matrix (1999) [tmdbid-603].en.forced.srt",
|
||||
}
|
||||
assert found["Matrix (1999) [tmdbid-603].srt"].language is None
|
||||
assert found["Matrix (1999) [tmdbid-603].cs.srt"].language == "cs"
|
||||
assert found["Matrix (1999) [tmdbid-603].en.forced.srt"].language == "en"
|
||||
assert found["Matrix (1999) [tmdbid-603].en.forced.srt"].flag == "forced"
|
||||
|
||||
def test_ignores_unrelated_and_overlapping_stem(self, tmp_path):
|
||||
video = _video(tmp_path, "Matrix.mkv")
|
||||
(tmp_path / "Matrix Reloaded.srt").write_bytes(b"x") # overlapping prefix
|
||||
(tmp_path / "Matrix.nfo").write_bytes(b"x") # not a subtitle ext
|
||||
(tmp_path / "poster.jpg").write_bytes(b"x")
|
||||
assert find_subtitles(video) == []
|
||||
|
||||
def test_ignores_other_movies_subtitle(self, tmp_path):
|
||||
video = _video(tmp_path, "Matrix.mkv")
|
||||
(tmp_path / "Other Movie.cs.srt").write_bytes(b"x")
|
||||
assert find_subtitles(video) == []
|
||||
|
||||
def test_missing_folder_is_empty(self, tmp_path):
|
||||
assert find_subtitles(tmp_path / "nope" / "video.mkv") == []
|
||||
|
||||
def test_label_property(self):
|
||||
assert SubtitleFile(path=None, language="cs").label == "cs"
|
||||
assert (
|
||||
SubtitleFile(path=None, language="cs", flag="forced").label == "cs (forced)"
|
||||
)
|
||||
assert SubtitleFile(path=None).label == "?"
|
||||
|
||||
|
||||
class TestAttachSubtitle:
|
||||
def test_copies_and_names_to_match_video(self, tmp_path):
|
||||
video = _video(tmp_path)
|
||||
source = tmp_path / "downloaded.srt"
|
||||
source.write_bytes(b"subtitle content")
|
||||
|
||||
target = attach_subtitle(video, source, language="cs")
|
||||
|
||||
assert target == tmp_path / "Matrix (1999) [tmdbid-603].cs.srt"
|
||||
assert target.read_bytes() == b"subtitle content"
|
||||
assert source.exists() # copy, not move
|
||||
|
||||
def test_move_removes_source(self, tmp_path):
|
||||
video = _video(tmp_path)
|
||||
source = tmp_path / "downloaded.srt"
|
||||
source.write_bytes(b"x")
|
||||
|
||||
target = attach_subtitle(video, source, language="en", move=True)
|
||||
|
||||
assert target.exists()
|
||||
assert not source.exists()
|
||||
|
||||
def test_overwrites_same_language_slot(self, tmp_path):
|
||||
video = _video(tmp_path)
|
||||
old = tmp_path / "Matrix (1999) [tmdbid-603].cs.srt"
|
||||
old.write_bytes(b"old")
|
||||
source = tmp_path / "new.srt"
|
||||
source.write_bytes(b"new")
|
||||
|
||||
target = attach_subtitle(video, source, language="cs")
|
||||
|
||||
assert target == old
|
||||
assert target.read_bytes() == b"new"
|
||||
|
||||
def test_rejects_unsupported_extension(self, tmp_path):
|
||||
video = _video(tmp_path)
|
||||
source = tmp_path / "notes.txt"
|
||||
source.write_bytes(b"x")
|
||||
with pytest.raises(ValueError):
|
||||
attach_subtitle(video, source)
|
||||
|
||||
|
||||
class TestRenameSubtitlesWith:
|
||||
def test_moves_every_sidecar_preserving_lang_and_flag(self, tmp_path):
|
||||
old_video = _video(tmp_path, "Matrix.mkv")
|
||||
(tmp_path / "Matrix.srt").write_bytes(b"x")
|
||||
(tmp_path / "Matrix.cs.srt").write_bytes(b"x")
|
||||
(tmp_path / "Matrix.en.forced.srt").write_bytes(b"x")
|
||||
new_video = tmp_path / "Matrix (1999) [tmdbid-603].mkv"
|
||||
|
||||
moved = rename_subtitles_with(old_video, new_video)
|
||||
|
||||
names = {p.name for p in moved}
|
||||
assert names == {
|
||||
"Matrix (1999) [tmdbid-603].srt",
|
||||
"Matrix (1999) [tmdbid-603].cs.srt",
|
||||
"Matrix (1999) [tmdbid-603].en.forced.srt",
|
||||
}
|
||||
for name in names:
|
||||
assert (tmp_path / name).exists()
|
||||
assert not (tmp_path / "Matrix.srt").exists()
|
||||
|
||||
def test_moves_across_folders(self, tmp_path):
|
||||
old_dir = tmp_path / "Filmy"
|
||||
old_dir.mkdir()
|
||||
old_video = old_dir / "Matrix.mkv"
|
||||
old_video.write_bytes(b"x")
|
||||
(old_dir / "Matrix.cs.srt").write_bytes(b"x")
|
||||
new_dir = tmp_path / "Kids"
|
||||
new_dir.mkdir()
|
||||
new_video = new_dir / "Matrix (1999).mkv"
|
||||
|
||||
moved = rename_subtitles_with(old_video, new_video)
|
||||
|
||||
assert moved == [new_dir / "Matrix (1999).cs.srt"]
|
||||
assert (new_dir / "Matrix (1999).cs.srt").exists()
|
||||
assert not (old_dir / "Matrix.cs.srt").exists()
|
||||
|
||||
def test_noop_when_path_unchanged(self, tmp_path):
|
||||
video = _video(tmp_path, "Matrix.mkv")
|
||||
(tmp_path / "Matrix.cs.srt").write_bytes(b"x")
|
||||
assert rename_subtitles_with(video, video) == []
|
||||
assert (tmp_path / "Matrix.cs.srt").exists()
|
||||
|
||||
def test_no_subtitles_is_noop(self, tmp_path):
|
||||
old_video = _video(tmp_path, "Matrix.mkv")
|
||||
new_video = tmp_path / "Matrix (1999).mkv"
|
||||
assert rename_subtitles_with(old_video, new_video) == []
|
||||
@@ -1,105 +0,0 @@
|
||||
from src.core.tag import Tag
|
||||
|
||||
|
||||
class TestTag:
|
||||
"""Testy pro třídu Tag"""
|
||||
|
||||
def test_tag_creation(self):
|
||||
"""Test vytvoření tagu"""
|
||||
tag = Tag("Kategorie", "Název")
|
||||
assert tag.category == "Kategorie"
|
||||
assert tag.name == "Název"
|
||||
|
||||
def test_tag_full_path(self):
|
||||
"""Test full_path property"""
|
||||
tag = Tag("Video", "HD")
|
||||
assert tag.full_path == "Video/HD"
|
||||
|
||||
def test_tag_str_representation(self):
|
||||
"""Test string reprezentace"""
|
||||
tag = Tag("Foto", "Dovolená")
|
||||
assert str(tag) == "Foto/Dovolená"
|
||||
|
||||
def test_tag_repr(self):
|
||||
"""Test repr reprezentace"""
|
||||
tag = Tag("Audio", "Hudba")
|
||||
assert repr(tag) == "Tag(Audio/Hudba)"
|
||||
|
||||
def test_tag_equality_same_tags(self):
|
||||
"""Test rovnosti stejných tagů"""
|
||||
tag1 = Tag("Kategorie", "Název")
|
||||
tag2 = Tag("Kategorie", "Název")
|
||||
assert tag1 == tag2
|
||||
|
||||
def test_tag_equality_different_tags(self):
|
||||
"""Test nerovnosti různých tagů"""
|
||||
tag1 = Tag("Kategorie1", "Název")
|
||||
tag2 = Tag("Kategorie2", "Název")
|
||||
assert tag1 != tag2
|
||||
|
||||
tag3 = Tag("Kategorie", "Název1")
|
||||
tag4 = Tag("Kategorie", "Název2")
|
||||
assert tag3 != tag4
|
||||
|
||||
def test_tag_equality_with_non_tag(self):
|
||||
"""Test porovnání s ne-Tag objektem"""
|
||||
tag = Tag("Kategorie", "Název")
|
||||
assert tag != "Kategorie/Název"
|
||||
assert tag != 123
|
||||
assert tag != None
|
||||
|
||||
def test_tag_hash(self):
|
||||
"""Test hashování - důležité pro použití v set/dict"""
|
||||
tag1 = Tag("Kategorie", "Název")
|
||||
tag2 = Tag("Kategorie", "Název")
|
||||
tag3 = Tag("Jiná", "Název")
|
||||
|
||||
# Stejné tagy mají stejný hash
|
||||
assert hash(tag1) == hash(tag2)
|
||||
# Různé tagy mají různý hash (většinou)
|
||||
assert hash(tag1) != hash(tag3)
|
||||
|
||||
def test_tag_in_set(self):
|
||||
"""Test použití tagů v set"""
|
||||
tag1 = Tag("Kategorie", "Název")
|
||||
tag2 = Tag("Kategorie", "Název")
|
||||
tag3 = Tag("Jiná", "Název")
|
||||
|
||||
tag_set = {tag1, tag2, tag3}
|
||||
# tag1 a tag2 jsou stejné, takže set obsahuje pouze 2 prvky
|
||||
assert len(tag_set) == 2
|
||||
assert tag1 in tag_set
|
||||
assert tag3 in tag_set
|
||||
|
||||
def test_tag_in_dict(self):
|
||||
"""Test použití tagů jako klíčů v dict"""
|
||||
tag1 = Tag("Kategorie", "Název")
|
||||
tag2 = Tag("Kategorie", "Název")
|
||||
|
||||
tag_dict = {tag1: "hodnota1"}
|
||||
tag_dict[tag2] = "hodnota2"
|
||||
|
||||
# tag1 a tag2 jsou stejné, takže dict má 1 klíč
|
||||
assert len(tag_dict) == 1
|
||||
assert tag_dict[tag1] == "hodnota2"
|
||||
|
||||
def test_tag_with_special_characters(self):
|
||||
"""Test tagů se speciálními znaky"""
|
||||
tag = Tag("Kategorie/Složitá", "Název s mezerami")
|
||||
assert tag.category == "Kategorie/Složitá"
|
||||
assert tag.name == "Název s mezerami"
|
||||
assert tag.full_path == "Kategorie/Složitá/Název s mezerami"
|
||||
|
||||
def test_tag_with_empty_strings(self):
|
||||
"""Test tagů s prázdnými řetězci"""
|
||||
tag = Tag("", "")
|
||||
assert tag.category == ""
|
||||
assert tag.name == ""
|
||||
assert tag.full_path == "/"
|
||||
|
||||
def test_tag_unicode(self):
|
||||
"""Test tagů s unicode znaky"""
|
||||
tag = Tag("Kategorie", "Čeština")
|
||||
assert tag.category == "Kategorie"
|
||||
assert tag.name == "Čeština"
|
||||
assert tag.full_path == "Kategorie/Čeština"
|
||||
@@ -1,255 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from src.core.tag import Tag
|
||||
from src.core.tag_manager import DEFAULT_TAGS, TagManager
|
||||
|
||||
|
||||
class TestTagManager:
|
||||
"""Testy pro třídu TagManager"""
|
||||
|
||||
@pytest.fixture
|
||||
def tag_manager(self):
|
||||
"""Fixture pro vytvoření TagManager instance"""
|
||||
return TagManager()
|
||||
|
||||
@pytest.fixture
|
||||
def empty_tag_manager(self):
|
||||
"""Fixture pro prázdný TagManager (alias k tag_manager, žádné default tagy)"""
|
||||
return TagManager()
|
||||
|
||||
def test_tag_manager_creation_has_no_defaults(self, tag_manager):
|
||||
"""Test že nový TagManager nemá žádné předdefinované tagy"""
|
||||
assert tag_manager.tags_by_category == {}
|
||||
|
||||
def test_add_category(self, tag_manager):
|
||||
"""Test přidání kategorie"""
|
||||
tag_manager.add_category("Video")
|
||||
assert "Video" in tag_manager.tags_by_category
|
||||
assert tag_manager.tags_by_category["Video"] == set()
|
||||
|
||||
def test_add_category_duplicate(self, empty_tag_manager):
|
||||
"""Test přidání duplicitní kategorie"""
|
||||
empty_tag_manager.add_category("Video")
|
||||
empty_tag_manager.add_category("Video")
|
||||
assert len(empty_tag_manager.tags_by_category) == 1
|
||||
|
||||
def test_remove_category(self, tag_manager):
|
||||
"""Test odstranění kategorie"""
|
||||
tag_manager.add_category("Video")
|
||||
tag_manager.remove_category("Video")
|
||||
assert "Video" not in tag_manager.tags_by_category
|
||||
|
||||
def test_remove_nonexistent_category(self, tag_manager):
|
||||
"""Test odstranění neexistující kategorie"""
|
||||
# Nemělo by vyhodit výjimku
|
||||
tag_manager.remove_category("Neexistující")
|
||||
assert "Neexistující" not in tag_manager.tags_by_category
|
||||
|
||||
def test_add_tag(self, tag_manager):
|
||||
"""Test přidání tagu"""
|
||||
tag = tag_manager.add_tag("Video", "HD")
|
||||
assert isinstance(tag, Tag)
|
||||
assert tag.category == "Video"
|
||||
assert tag.name == "HD"
|
||||
assert "Video" in tag_manager.tags_by_category
|
||||
assert tag in tag_manager.tags_by_category["Video"]
|
||||
|
||||
def test_add_tag_creates_category(self, tag_manager):
|
||||
"""Test že add_tag vytvoří kategorii pokud neexistuje"""
|
||||
tag_manager.add_tag("NovaKategorie", "Tag")
|
||||
assert "NovaKategorie" in tag_manager.tags_by_category
|
||||
|
||||
def test_add_multiple_tags_same_category(self, tag_manager):
|
||||
"""Test přidání více tagů do stejné kategorie"""
|
||||
tag1 = tag_manager.add_tag("Video", "HD")
|
||||
tag2 = tag_manager.add_tag("Video", "4K")
|
||||
tag3 = tag_manager.add_tag("Video", "SD")
|
||||
|
||||
assert len(tag_manager.tags_by_category["Video"]) == 3
|
||||
assert tag1 in tag_manager.tags_by_category["Video"]
|
||||
assert tag2 in tag_manager.tags_by_category["Video"]
|
||||
assert tag3 in tag_manager.tags_by_category["Video"]
|
||||
|
||||
def test_add_duplicate_tag(self, tag_manager):
|
||||
"""Test přidání duplicitního tagu (set zabrání duplicitám)"""
|
||||
tag1 = tag_manager.add_tag("Video", "HD")
|
||||
tag2 = tag_manager.add_tag("Video", "HD")
|
||||
|
||||
assert len(tag_manager.tags_by_category["Video"]) == 1
|
||||
assert tag1 == tag2
|
||||
|
||||
def test_remove_tag(self, tag_manager):
|
||||
"""Test odstranění tagu - když je poslední, kategorie se smaže"""
|
||||
tag_manager.add_tag("Video", "HD")
|
||||
tag_manager.remove_tag("Video", "HD")
|
||||
|
||||
# Kategorie by měla být smazána (podle implementace v tag_manager.py)
|
||||
assert "Video" not in tag_manager.tags_by_category
|
||||
|
||||
def test_remove_tag_removes_empty_category(self, tag_manager):
|
||||
"""Test že odstranění posledního tagu odstraní i kategorii"""
|
||||
tag_manager.add_tag("Video", "HD")
|
||||
tag_manager.remove_tag("Video", "HD")
|
||||
|
||||
assert "Video" not in tag_manager.tags_by_category
|
||||
|
||||
def test_remove_tag_keeps_category_with_other_tags(self, tag_manager):
|
||||
"""Test že odstranění tagu neodstraní kategorii s dalšími tagy"""
|
||||
tag_manager.add_tag("Video", "HD")
|
||||
tag_manager.add_tag("Video", "4K")
|
||||
tag_manager.remove_tag("Video", "HD")
|
||||
|
||||
assert "Video" in tag_manager.tags_by_category
|
||||
assert len(tag_manager.tags_by_category["Video"]) == 1
|
||||
|
||||
def test_remove_nonexistent_tag(self, tag_manager):
|
||||
"""Test odstranění neexistujícího tagu"""
|
||||
tag_manager.add_category("Video")
|
||||
# Nemělo by vyhodit výjimku
|
||||
tag_manager.remove_tag("Video", "Neexistující")
|
||||
|
||||
def test_remove_tag_from_nonexistent_category(self, tag_manager):
|
||||
"""Test odstranění tagu z neexistující kategorie"""
|
||||
# Nemělo by vyhodit výjimku
|
||||
tag_manager.remove_tag("Neexistující", "Tag")
|
||||
|
||||
def test_get_all_tags_empty(self, empty_tag_manager):
|
||||
"""Test získání všech tagů (prázdný manager)"""
|
||||
tags = empty_tag_manager.get_all_tags()
|
||||
assert tags == []
|
||||
|
||||
def test_get_all_tags(self, empty_tag_manager):
|
||||
"""Test získání všech tagů"""
|
||||
empty_tag_manager.add_tag("Video", "HD")
|
||||
empty_tag_manager.add_tag("Video", "4K")
|
||||
empty_tag_manager.add_tag("Audio", "MP3")
|
||||
|
||||
tags = empty_tag_manager.get_all_tags()
|
||||
assert len(tags) == 3
|
||||
assert "Video/HD" in tags
|
||||
assert "Video/4K" in tags
|
||||
assert "Audio/MP3" in tags
|
||||
|
||||
def test_get_all_tags_empty_on_fresh_manager(self, tag_manager):
|
||||
"""Test že čerstvý TagManager nemá žádné tagy (bez defaultů)"""
|
||||
assert tag_manager.get_all_tags() == []
|
||||
|
||||
def test_get_categories_empty(self, empty_tag_manager):
|
||||
"""Test získání kategorií (prázdný manager)"""
|
||||
categories = empty_tag_manager.get_categories()
|
||||
assert categories == []
|
||||
|
||||
def test_get_categories(self, empty_tag_manager):
|
||||
"""Test získání kategorií"""
|
||||
empty_tag_manager.add_tag("Video", "HD")
|
||||
empty_tag_manager.add_tag("Audio", "MP3")
|
||||
empty_tag_manager.add_tag("Foto", "RAW")
|
||||
|
||||
categories = empty_tag_manager.get_categories()
|
||||
assert len(categories) == 3
|
||||
assert "Video" in categories
|
||||
assert "Audio" in categories
|
||||
assert "Foto" in categories
|
||||
|
||||
def test_get_categories_empty_on_fresh_manager(self, tag_manager):
|
||||
"""Test že čerstvý TagManager nemá žádné kategorie (bez defaultů)"""
|
||||
assert tag_manager.get_categories() == []
|
||||
|
||||
def test_get_tags_in_category_empty(self, tag_manager):
|
||||
"""Test získání tagů z prázdné kategorie"""
|
||||
tag_manager.add_category("Video")
|
||||
tags = tag_manager.get_tags_in_category("Video")
|
||||
assert tags == []
|
||||
|
||||
def test_get_tags_in_category(self, tag_manager):
|
||||
"""Test získání tagů z kategorie"""
|
||||
tag_manager.add_tag("Video", "HD")
|
||||
tag_manager.add_tag("Video", "4K")
|
||||
tag_manager.add_tag("Audio", "MP3")
|
||||
|
||||
video_tags = tag_manager.get_tags_in_category("Video")
|
||||
assert len(video_tags) == 2
|
||||
|
||||
# Kontrola že obsahují správné tagy (pořadí není garantováno)
|
||||
tag_names = {tag.name for tag in video_tags}
|
||||
assert "HD" in tag_names
|
||||
assert "4K" in tag_names
|
||||
|
||||
def test_get_tags_in_nonexistent_category(self, tag_manager):
|
||||
"""Test získání tagů z neexistující kategorie"""
|
||||
tags = tag_manager.get_tags_in_category("Neexistující")
|
||||
assert tags == []
|
||||
|
||||
def test_complex_scenario(self, empty_tag_manager):
|
||||
"""Test komplexního scénáře použití"""
|
||||
tm = empty_tag_manager
|
||||
|
||||
# Přidání několika kategorií a tagů
|
||||
tm.add_tag("Video", "HD")
|
||||
tm.add_tag("Video", "4K")
|
||||
tm.add_tag("Audio", "MP3")
|
||||
tm.add_tag("Audio", "FLAC")
|
||||
tm.add_tag("Foto", "RAW")
|
||||
|
||||
# Kontrola stavu
|
||||
assert len(tm.get_categories()) == 3
|
||||
assert len(tm.get_all_tags()) == 5
|
||||
|
||||
# Odstranění některých tagů
|
||||
tm.remove_tag("Video", "HD")
|
||||
assert len(tm.get_tags_in_category("Video")) == 1
|
||||
|
||||
# Odstranění celé kategorie
|
||||
tm.remove_category("Foto")
|
||||
assert "Foto" not in tm.get_categories()
|
||||
assert len(tm.get_all_tags()) == 3
|
||||
|
||||
def test_tag_uniqueness_in_set(self, tag_manager):
|
||||
"""Test že tagy jsou správně ukládány jako set (bez duplicit)"""
|
||||
tag_manager.add_tag("Video", "HD")
|
||||
tag_manager.add_tag("Video", "HD")
|
||||
tag_manager.add_tag("Video", "HD")
|
||||
|
||||
# I když přidáme 3x, v setu je jen 1
|
||||
assert len(tag_manager.tags_by_category["Video"]) == 1
|
||||
|
||||
|
||||
class TestDefaultTags:
|
||||
"""Testy pro defaultní tagy (legacy Tagger presety byly odstraněny)"""
|
||||
|
||||
def test_default_tags_constant_exists(self):
|
||||
"""Test že DEFAULT_TAGS konstanta existuje a je prázdná"""
|
||||
assert isinstance(DEFAULT_TAGS, dict)
|
||||
assert DEFAULT_TAGS == {}
|
||||
|
||||
def test_legacy_presets_removed(self):
|
||||
"""Test že staré předdefinované kategorie (Hodnocení, Barva) jsou pryč"""
|
||||
assert "Hodnocení" not in DEFAULT_TAGS
|
||||
assert "Barva" not in DEFAULT_TAGS
|
||||
|
||||
def test_tag_manager_starts_empty(self):
|
||||
"""Test že TagManager bez defaultů startuje prázdný"""
|
||||
tm = TagManager()
|
||||
assert tm.get_all_tags() == []
|
||||
assert tm.get_categories() == []
|
||||
|
||||
def test_can_add_custom_tags(self):
|
||||
"""Test že lze přidat vlastní tagy do prázdného manageru"""
|
||||
tm = TagManager()
|
||||
|
||||
tm.add_tag("Custom", "MyTag")
|
||||
|
||||
assert tm.get_all_tags() == ["Custom/MyTag"]
|
||||
assert "Custom" in tm.get_categories()
|
||||
|
||||
def test_custom_category_tags_sorted_alphabetically(self):
|
||||
"""Test že tagy v custom kategorii jsou seřazeny abecedně"""
|
||||
tm = TagManager()
|
||||
tm.add_tag("Video", "HD")
|
||||
tm.add_tag("Video", "4K")
|
||||
tm.add_tag("Video", "SD")
|
||||
|
||||
tags = tm.get_tags_in_category("Video")
|
||||
tag_names = [t.name for t in tags]
|
||||
|
||||
assert tag_names == ["4K", "HD", "SD"]
|
||||
@@ -1,166 +0,0 @@
|
||||
"""Tests for the TMDb client (src.core.tmdb). Network is monkeypatched."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core import tmdb
|
||||
from src.core.tmdb import TmdbError, TmdbResult
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _with_key(monkeypatch):
|
||||
monkeypatch.setenv("TMDB_API_KEY", "testkey")
|
||||
|
||||
|
||||
def _resp(status=200, payload=None):
|
||||
return SimpleNamespace(
|
||||
status_code=status, ok=(200 <= status < 300), json=lambda: payload or {}
|
||||
)
|
||||
|
||||
|
||||
def test_api_available_reflects_key(monkeypatch):
|
||||
assert tmdb.api_available()
|
||||
monkeypatch.delenv("TMDB_API_KEY", raising=False)
|
||||
assert not tmdb.api_available()
|
||||
|
||||
|
||||
def test_lookup_movie_returns_title_and_year(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
tmdb.requests,
|
||||
"get",
|
||||
lambda *a, **k: _resp(
|
||||
200, {"id": 603, "title": "Matrix", "release_date": "1999-03-31"}
|
||||
),
|
||||
)
|
||||
result = tmdb.lookup_movie(603)
|
||||
assert result == TmdbResult(603, "Matrix", "1999", "movie")
|
||||
|
||||
|
||||
def test_lookup_movie_falls_back_to_original_title(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
tmdb.requests,
|
||||
"get",
|
||||
lambda *a, **k: _resp(
|
||||
200, {"id": 1, "original_title": "Solyaris", "release_date": "1972-01-01"}
|
||||
),
|
||||
)
|
||||
assert tmdb.lookup_movie(1).title == "Solyaris"
|
||||
|
||||
|
||||
def test_lookup_movie_missing_date_gives_no_year(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
tmdb.requests,
|
||||
"get",
|
||||
lambda *a, **k: _resp(200, {"id": 2, "title": "Bez data", "release_date": ""}),
|
||||
)
|
||||
assert tmdb.lookup_movie(2).year is None
|
||||
|
||||
|
||||
def test_lookup_tv_uses_name_and_first_air(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
tmdb.requests,
|
||||
"get",
|
||||
lambda *a, **k: _resp(
|
||||
200, {"id": 1396, "name": "Breaking Bad", "first_air_date": "2008-01-20"}
|
||||
),
|
||||
)
|
||||
result = tmdb.lookup(1396, kind="tv")
|
||||
assert result == TmdbResult(1396, "Breaking Bad", "2008", "tv")
|
||||
|
||||
|
||||
def test_search_movie_maps_results(monkeypatch):
|
||||
payload = {
|
||||
"results": [
|
||||
{"id": 603, "title": "Matrix", "release_date": "1999-03-31"},
|
||||
{"id": 605, "title": "Matrix Revolutions", "release_date": "2003-11-05"},
|
||||
]
|
||||
}
|
||||
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(200, payload))
|
||||
results = tmdb.search_movie("Matrix", year="1999")
|
||||
assert [r.tmdb_id for r in results] == [603, 605]
|
||||
assert results[0].year == "1999"
|
||||
|
||||
|
||||
def test_search_movie_empty_query_skips_network(monkeypatch):
|
||||
def boom(*a, **k):
|
||||
raise AssertionError("should not hit network")
|
||||
|
||||
monkeypatch.setattr(tmdb.requests, "get", boom)
|
||||
assert tmdb.search_movie(" ") == []
|
||||
|
||||
|
||||
def test_movie_details_maps_fields(monkeypatch):
|
||||
payload = {
|
||||
"id": 603,
|
||||
"title": "Matrix",
|
||||
"release_date": "1999-03-31",
|
||||
"vote_average": 8.3,
|
||||
"genres": [
|
||||
{"id": 28, "name": "Akční"},
|
||||
{"id": 878, "name": "Vědeckofantastický"},
|
||||
],
|
||||
"production_countries": [
|
||||
{"iso_3166_1": "US", "name": "United States of America"}
|
||||
],
|
||||
}
|
||||
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(200, payload))
|
||||
d = tmdb.movie_details(603)
|
||||
assert d.genres == ["Akční", "Vědeckofantastický"]
|
||||
assert d.countries == ["United States of America"]
|
||||
assert d.rating == 83 and d.year == "1999"
|
||||
assert d.field_values("genres") == ["Akční", "Vědeckofantastický"]
|
||||
assert d.field_values("rating") == ["83"]
|
||||
|
||||
|
||||
def test_refresh_tags_from_tmdb(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("src.core.config.GLOBAL_CONFIG_FILE", tmp_path / ".gtag")
|
||||
from src.core.file_manager import FileManager
|
||||
from src.core.tag_manager import TagManager
|
||||
from src.core.tmdb import TmdbDetails
|
||||
|
||||
fm = FileManager(TagManager())
|
||||
fm.set_pool_dir(tmp_path / "pool")
|
||||
(tmp_path / "a.mkv").write_bytes(b"x")
|
||||
(tmp_path / "b.mkv").write_bytes(b"y")
|
||||
matched = fm.import_movie(tmp_path / "a.mkv", "Matrix", year=1999, tmdb_id=603)
|
||||
fm.import_movie(tmp_path / "b.mkv", "Bez ID", year=2000) # no tmdbid
|
||||
|
||||
import src.core.tmdb as tmdb_mod
|
||||
|
||||
monkeypatch.setattr(
|
||||
tmdb_mod,
|
||||
"movie_details",
|
||||
lambda i: TmdbDetails(
|
||||
int(i), "Matrix", "1999", ["Akční", "Sci-Fi"], ["USA"], 83
|
||||
),
|
||||
)
|
||||
|
||||
result = fm.refresh_tags_from_tmdb()
|
||||
assert result == {"ok": True, "updated": 1, "without_id": 1, "errors": []}
|
||||
paths = {t.full_path for t in matched.tags}
|
||||
assert {"Žánr/Akční", "Žánr/Sci-Fi", "Rok/1999", "Země původu/USA"} <= paths
|
||||
assert matched.meta == {"year": "1999", "rating": 83}
|
||||
|
||||
|
||||
def test_missing_key_raises(monkeypatch):
|
||||
monkeypatch.delenv("TMDB_API_KEY", raising=False)
|
||||
with pytest.raises(TmdbError):
|
||||
tmdb.lookup_movie(603)
|
||||
|
||||
|
||||
def test_bad_status_raises(monkeypatch):
|
||||
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(401, {}))
|
||||
with pytest.raises(TmdbError):
|
||||
tmdb.lookup_movie(603)
|
||||
|
||||
|
||||
def test_network_error_raises(monkeypatch):
|
||||
import requests
|
||||
|
||||
def boom(*a, **k):
|
||||
raise requests.ConnectionError("down")
|
||||
|
||||
monkeypatch.setattr(tmdb.requests, "get", boom)
|
||||
with pytest.raises(TmdbError):
|
||||
tmdb.lookup_movie(603)
|
||||
@@ -1,180 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.utils import list_files
|
||||
|
||||
|
||||
class TestUtils:
|
||||
"""Testy pro utils funkce"""
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir(self, tmp_path):
|
||||
"""Fixture pro dočasný adresář s testovací strukturou"""
|
||||
# Vytvoření souborů v root
|
||||
(tmp_path / "file1.txt").write_text("content1")
|
||||
(tmp_path / "file2.jpg").write_text("image")
|
||||
|
||||
# Podsložka
|
||||
subdir1 = tmp_path / "subdir1"
|
||||
subdir1.mkdir()
|
||||
(subdir1 / "file3.txt").write_text("content3")
|
||||
(subdir1 / "file4.png").write_text("image2")
|
||||
|
||||
# Vnořená podsložka
|
||||
subdir2 = subdir1 / "subdir2"
|
||||
subdir2.mkdir()
|
||||
(subdir2 / "file5.txt").write_text("content5")
|
||||
|
||||
# Prázdná složka
|
||||
empty_dir = tmp_path / "empty"
|
||||
empty_dir.mkdir()
|
||||
|
||||
return tmp_path
|
||||
|
||||
def test_list_files_basic(self, temp_dir):
|
||||
"""Test základního listování souborů"""
|
||||
files = list_files(temp_dir)
|
||||
assert isinstance(files, list)
|
||||
assert len(files) > 0
|
||||
assert all(isinstance(f, Path) for f in files)
|
||||
|
||||
def test_list_files_finds_all_files(self, temp_dir):
|
||||
"""Test že najde všechny soubory včetně vnořených"""
|
||||
files = list_files(temp_dir)
|
||||
filenames = {f.name for f in files}
|
||||
|
||||
assert "file1.txt" in filenames
|
||||
assert "file2.jpg" in filenames
|
||||
assert "file3.txt" in filenames
|
||||
assert "file4.png" in filenames
|
||||
assert "file5.txt" in filenames
|
||||
assert len(filenames) == 5
|
||||
|
||||
def test_list_files_recursive(self, temp_dir):
|
||||
"""Test rekurzivního procházení složek"""
|
||||
files = list_files(temp_dir)
|
||||
|
||||
# Kontrola cest - měly by obsahovat subdir1 a subdir2
|
||||
file_paths = [str(f) for f in files]
|
||||
assert any("subdir1" in path for path in file_paths)
|
||||
assert any("subdir2" in path for path in file_paths)
|
||||
|
||||
def test_list_files_only_files_no_directories(self, temp_dir):
|
||||
"""Test že vrací pouze soubory, ne složky"""
|
||||
files = list_files(temp_dir)
|
||||
|
||||
# Všechny výsledky by měly být soubory
|
||||
assert all(f.is_file() for f in files)
|
||||
|
||||
# Složky by neměly být ve výsledcích
|
||||
filenames = {f.name for f in files}
|
||||
assert "subdir1" not in filenames
|
||||
assert "subdir2" not in filenames
|
||||
assert "empty" not in filenames
|
||||
|
||||
def test_list_files_with_string_path(self, temp_dir):
|
||||
"""Test s cestou jako string"""
|
||||
files = list_files(str(temp_dir))
|
||||
assert len(files) == 5
|
||||
|
||||
def test_list_files_with_path_object(self, temp_dir):
|
||||
"""Test s cestou jako Path objekt"""
|
||||
files = list_files(temp_dir)
|
||||
assert len(files) == 5
|
||||
|
||||
def test_list_files_empty_directory(self, temp_dir):
|
||||
"""Test prázdné složky"""
|
||||
empty_dir = temp_dir / "empty"
|
||||
files = list_files(empty_dir)
|
||||
assert files == []
|
||||
|
||||
def test_list_files_nonexistent_directory(self):
|
||||
"""Test neexistující složky"""
|
||||
with pytest.raises(NotADirectoryError) as exc_info:
|
||||
list_files("/nonexistent/path")
|
||||
assert "není platná složka" in str(exc_info.value)
|
||||
|
||||
def test_list_files_file_not_directory(self, temp_dir):
|
||||
"""Test když je zadán soubor místo složky"""
|
||||
file_path = temp_dir / "file1.txt"
|
||||
with pytest.raises(NotADirectoryError) as exc_info:
|
||||
list_files(file_path)
|
||||
assert "není platná složka" in str(exc_info.value)
|
||||
|
||||
def test_list_files_returns_absolute_paths(self, temp_dir):
|
||||
"""Test že vrací absolutní cesty"""
|
||||
files = list_files(temp_dir)
|
||||
assert all(f.is_absolute() for f in files)
|
||||
|
||||
def test_list_files_different_extensions(self, temp_dir):
|
||||
"""Test s různými příponami"""
|
||||
files = list_files(temp_dir)
|
||||
extensions = {f.suffix for f in files}
|
||||
|
||||
assert ".txt" in extensions
|
||||
assert ".jpg" in extensions
|
||||
assert ".png" in extensions
|
||||
|
||||
def test_list_files_hidden_files(self, temp_dir):
|
||||
"""Test se skrytými soubory (začínající tečkou)"""
|
||||
# Vytvoření skrytého souboru
|
||||
(temp_dir / ".hidden").write_text("hidden content")
|
||||
|
||||
files = list_files(temp_dir)
|
||||
filenames = {f.name for f in files}
|
||||
|
||||
# Skryté soubory by měly být také nalezeny
|
||||
assert ".hidden" in filenames
|
||||
|
||||
def test_list_files_special_characters_in_names(self, temp_dir):
|
||||
"""Test se speciálními znaky v názvech"""
|
||||
# Vytvoření souborů se spec. znaky
|
||||
(temp_dir / "soubor s mezerami.txt").write_text("content")
|
||||
(temp_dir / "český_název.txt").write_text("content")
|
||||
|
||||
files = list_files(temp_dir)
|
||||
filenames = {f.name for f in files}
|
||||
|
||||
assert "soubor s mezerami.txt" in filenames
|
||||
assert "český_název.txt" in filenames
|
||||
|
||||
def test_list_files_symlinks(self, temp_dir):
|
||||
"""Test se symbolickými linky (pokud OS podporuje)"""
|
||||
try:
|
||||
# Vytvoření symlinku
|
||||
target = temp_dir / "file1.txt"
|
||||
link = temp_dir / "link_to_file1.txt"
|
||||
link.symlink_to(target)
|
||||
|
||||
files = list_files(temp_dir)
|
||||
# Symlink by měl být také nalezen a považován za soubor
|
||||
filenames = {f.name for f in files}
|
||||
assert "link_to_file1.txt" in filenames or "file1.txt" in filenames
|
||||
except OSError:
|
||||
# Pokud OS nepodporuje symlinky, přeskočíme
|
||||
pytest.skip("OS does not support symlinks")
|
||||
|
||||
def test_list_files_large_directory_structure(self, tmp_path):
|
||||
"""Test s větší strukturou složek"""
|
||||
# Vytvoření více vnořených úrovní
|
||||
for i in range(3):
|
||||
level_dir = tmp_path / f"level{i}"
|
||||
level_dir.mkdir()
|
||||
for j in range(5):
|
||||
(level_dir / f"file_{i}_{j}.txt").write_text(f"content {i} {j}")
|
||||
|
||||
files = list_files(tmp_path)
|
||||
# Měli bychom najít 3 * 5 = 15 souborů
|
||||
assert len(files) == 15
|
||||
|
||||
def test_list_files_preserves_path_structure(self, temp_dir):
|
||||
"""Test že zachovává strukturu cest"""
|
||||
files = list_files(temp_dir)
|
||||
|
||||
# Najdeme soubor v subdir2
|
||||
file5 = next(f for f in files if f.name == "file5.txt")
|
||||
|
||||
# Cesta by měla obsahovat obě složky
|
||||
assert "subdir1" in str(file5)
|
||||
assert "subdir2" in str(file5)
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
//! End-to-end UI tests: drive the egui window headlessly (egui_kittest) the way
|
||||
//! a user would — open a dialog, click buttons — and check the pool on disk.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use curator::core::file_manager::FileManager;
|
||||
use curator::ui::app::CuratorApp;
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
struct Env {
|
||||
dir: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl Env {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
dir: tempfile::tempdir().unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn pool(&self) -> PathBuf {
|
||||
self.dir.path().join("pool")
|
||||
}
|
||||
|
||||
fn manager(&self) -> FileManager {
|
||||
let mut fm = FileManager::with_config_path(self.dir.path().join("cfg.json"));
|
||||
fm.set_pool_dir(&self.pool()).unwrap();
|
||||
fm.load_pool_movies().unwrap();
|
||||
fm
|
||||
}
|
||||
|
||||
fn source(&self, name: &str) -> PathBuf {
|
||||
let p = self.dir.path().join("src").join(name);
|
||||
fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
fs::write(&p, name).unwrap();
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
fn harness(fm: FileManager) -> Harness<'static, CuratorApp> {
|
||||
Harness::builder()
|
||||
.with_size(egui::vec2(1300.0, 800.0))
|
||||
.allow_missing_glyphs()
|
||||
.with_accessibility_check(false)
|
||||
.build_eframe(|cc| CuratorApp::new(fm, cc.egui_ctx.clone()))
|
||||
}
|
||||
|
||||
/// Step until no background task runs (or time out).
|
||||
fn wait_idle(h: &mut Harness<'static, CuratorApp>) {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
h.step();
|
||||
if !h.state().busy() {
|
||||
h.run_steps(3);
|
||||
return;
|
||||
}
|
||||
assert!(
|
||||
start.elapsed() < Duration::from_secs(20),
|
||||
"task did not finish"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
fn exists(p: &Path) -> bool {
|
||||
p.exists()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_movie_through_the_dialog() {
|
||||
let env = Env::new();
|
||||
let src = env.source("Matrix.1999.1080p.mkv");
|
||||
let mut h = harness(env.manager());
|
||||
h.state_mut().open_import(vec![src.clone()]);
|
||||
h.run_steps(3);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(2);
|
||||
wait_idle(&mut h);
|
||||
let pooled = env.pool().join("Filmy/Matrix.1999.1080p (1999).mkv");
|
||||
assert!(
|
||||
exists(&pooled),
|
||||
"pool: {:?}",
|
||||
fs::read_dir(env.pool().join("Filmy"))
|
||||
.unwrap()
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert!(exists(&src), "copy keeps the source");
|
||||
// the summary message is shown and can be dismissed
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(2);
|
||||
let fm = h.state().file_manager().unwrap();
|
||||
assert_eq!(fm.filelist.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_requires_a_year() {
|
||||
let env = Env::new();
|
||||
let src = env.source("Bez roku.mkv");
|
||||
let mut h = harness(env.manager());
|
||||
h.state_mut().open_import(vec![src]);
|
||||
h.run_steps(3);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
// a warning appears and nothing is imported
|
||||
assert!(h.query_by_label_contains("čtyřmístný rok").is_some());
|
||||
assert!(
|
||||
fs::read_dir(env.pool().join("Filmy"))
|
||||
.unwrap()
|
||||
.next()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import_existing_film_as_a_version() {
|
||||
let env = Env::new();
|
||||
let mut fm = env.manager();
|
||||
let opts = curator::core::file_manager::ImportOptions {
|
||||
year: Some("2009".into()),
|
||||
tmdb_id: Some("19995".into()),
|
||||
..Default::default()
|
||||
};
|
||||
fm.import_movie(&env.source("a.mkv"), "Avatar", opts)
|
||||
.unwrap();
|
||||
// a second copy of the same film, named so title/year/id are prefilled
|
||||
let src = env.source("Avatar (2009).3D.HSBS.mkv");
|
||||
let nfo = src.with_extension("nfo");
|
||||
fs::write(
|
||||
&nfo,
|
||||
"<movie><title>Avatar</title><uniqueid type=\"tmdb\">19995</uniqueid></movie>",
|
||||
)
|
||||
.unwrap();
|
||||
let mut h = harness(fm);
|
||||
h.state_mut().open_import(vec![src]);
|
||||
h.run_steps(3);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
// conflict question → add as a version
|
||||
h.get_by_label("Přidat jako verzi").click();
|
||||
h.run_steps(3);
|
||||
// the popisek prompt is pre-filled from the 3D tag
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(2);
|
||||
wait_idle(&mut h);
|
||||
let folder = env.pool().join("Filmy/Avatar (2009) [tmdbid-19995]");
|
||||
assert!(folder.join("Avatar (2009) [tmdbid-19995].mkv").exists());
|
||||
assert!(
|
||||
folder
|
||||
.join("Avatar (2009) [tmdbid-19995] - 3D HSBS.mkv")
|
||||
.exists()
|
||||
);
|
||||
}
|
||||
|
||||
fn imported(env: &Env, fm: &mut FileManager, name: &str, title: &str) -> PathBuf {
|
||||
fm.import_movie(
|
||||
&env.source(name),
|
||||
title,
|
||||
curator::core::file_manager::ImportOptions::default(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edit_movie_renames_with_year_and_id() {
|
||||
let env = Env::new();
|
||||
let mut fm = env.manager();
|
||||
let p = imported(&env, &mut fm, "x.mkv", "Matrix");
|
||||
let mut h = harness(fm);
|
||||
h.state_mut().select_movies(&[p]);
|
||||
h.state_mut().debug_action("edit");
|
||||
h.run_steps(3);
|
||||
// the editor's year and TMDb fields (hint texts identify them)
|
||||
for (label, text) in [("Rok:", "1999"), ("TMDb ID:", "603")] {
|
||||
h.get_by_label(label).focus();
|
||||
h.run_steps(1);
|
||||
h.get_by_label(label).type_text(text);
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.run_steps(2);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
assert!(
|
||||
env.pool()
|
||||
.join("Filmy/Matrix (1999) [tmdbid-603].mkv")
|
||||
.exists()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_movie_after_confirmation() {
|
||||
let env = Env::new();
|
||||
let mut fm = env.manager();
|
||||
let p = imported(&env, &mut fm, "x.mkv", "Matrix");
|
||||
let mut h = harness(fm);
|
||||
h.state_mut().select_movies(std::slice::from_ref(&p));
|
||||
h.run_steps(2);
|
||||
h.key_press(egui::Key::Delete);
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Ano").click();
|
||||
h.run_steps(3);
|
||||
assert!(!p.exists());
|
||||
assert!(h.state().file_manager().unwrap().filelist.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assign_a_new_tag_through_the_dialog() {
|
||||
let env = Env::new();
|
||||
let mut fm = env.manager();
|
||||
let p = imported(&env, &mut fm, "x.mkv", "Matrix");
|
||||
let mut h = harness(fm);
|
||||
h.state_mut().select_movies(std::slice::from_ref(&p));
|
||||
h.state_mut().debug_action("tags");
|
||||
h.run_steps(3);
|
||||
h.get_by_label("➕ Nový štítek…").click();
|
||||
h.run_steps(3);
|
||||
// pick the "Žánr" category, then type the tag name
|
||||
h.get_all_by_label("Žánr").last().unwrap().click();
|
||||
h.run_steps(2);
|
||||
h.get_all_by_label("OK").last().unwrap().click();
|
||||
h.run_steps(3);
|
||||
h.get_all_by_role(egui::accesskit::Role::TextInput)
|
||||
.last()
|
||||
.unwrap()
|
||||
.type_text("Sci-Fi");
|
||||
h.run_steps(2);
|
||||
h.get_all_by_label("OK").last().unwrap().click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
let f = h.state().file_manager().unwrap().file(&p).unwrap().clone();
|
||||
assert!(f.tag_paths().contains("Žánr/Sci-Fi"), "{:?}", f.tags);
|
||||
}
|
||||
Reference in New Issue
Block a user