Files
Curator/tests/test_hardlink_manager.py
T

930 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import pytest
import os
from src.core.hardlink_manager import HardlinkManager, create_hardlink_structure
from src.core.file import File
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_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.csfd_cache = {"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.csfd_cache = {"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í" / "90100 %" / "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.csfd_cache = {"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.csfd_cache = {"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)