Rewrite Curator in Rust with an egui GUI

This commit is contained in:
2026-10-17 11:28:17 +02:00
parent 364b515e3c
commit 65b5b74c9f
156 changed files with 24359 additions and 25795 deletions
-1
View File
@@ -1 +0,0 @@
# Tests package
-29
View File
@@ -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()
-245
View File
@@ -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"
-145
View File
@@ -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")
-279
View File
@@ -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
-343
View File
@@ -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
-989
View File
@@ -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
-164
View File
@@ -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"]
-123
View File
@@ -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"] == []
-142
View File
@@ -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
-102
View File
@@ -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"
-225
View File
@@ -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
-160
View File
@@ -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
-148
View File
@@ -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}
-249
View File
@@ -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}
-525
View File
@@ -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()
-170
View File
@@ -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) == []
-105
View File
@@ -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"
-255
View File
@@ -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"]
-166
View File
@@ -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)
-180
View File
@@ -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
View File
@@ -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);
}