Initial commit: Video Library Manager
- Add core VLM modules (scanner, parser, planner, executor, analysis) - Add CLI with quarantine, reports, rollback, and state management - Add comprehensive test suite - Add project configuration and documentation - Add .gitignore for Python project
This commit is contained in:
@@ -0,0 +1,546 @@
|
||||
"""Unit tests for the analysis engine.
|
||||
|
||||
Tests series completeness analysis, duplicate detection, and quality comparison.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from vlm.models import SeriesIdentity, SeasonCompleteness, MovieIdentity, VideoFile, DuplicateGroup
|
||||
from vlm.analysis import analyze_series_completeness, detect_duplicates, compare_quality
|
||||
|
||||
|
||||
class TestSeriesCompletenessAnalysis:
|
||||
"""Test series completeness analysis functionality."""
|
||||
|
||||
def test_detect_single_gap(self):
|
||||
"""Test detection of a single missing episode."""
|
||||
episodes = [
|
||||
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [2], 0.9, False, "Show.Name.S01E02.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [4], 0.9, False, "Show.Name.S01E04.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [5], 0.9, False, "Show.Name.S01E05.mkv"),
|
||||
]
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].series_title == "Show Name"
|
||||
assert result[0].season == 1
|
||||
assert result[0].episodes_found == [1, 2, 4, 5]
|
||||
assert result[0].episodes_missing == [3]
|
||||
|
||||
def test_detect_multiple_gaps(self):
|
||||
"""Test detection of multiple missing episodes."""
|
||||
episodes = [
|
||||
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [3], 0.9, False, "Show.Name.S01E03.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [5], 0.9, False, "Show.Name.S01E05.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [7], 0.9, False, "Show.Name.S01E07.mkv"),
|
||||
]
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].episodes_found == [1, 3, 5, 7]
|
||||
assert result[0].episodes_missing == [2, 4, 6]
|
||||
|
||||
def test_no_gaps_returns_empty(self):
|
||||
"""Test that complete seasons are not included in results."""
|
||||
episodes = [
|
||||
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [2], 0.9, False, "Show.Name.S01E02.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [3], 0.9, False, "Show.Name.S01E03.mkv"),
|
||||
]
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
def test_multi_season_independence(self):
|
||||
"""Test that gap detection for one season doesn't affect others."""
|
||||
episodes = [
|
||||
# Season 1 - has gap at episode 2
|
||||
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [3], 0.9, False, "Show.Name.S01E03.mkv"),
|
||||
# Season 2 - complete
|
||||
SeriesIdentity("Show Name", 2, [1], 0.9, False, "Show.Name.S02E01.mkv"),
|
||||
SeriesIdentity("Show Name", 2, [2], 0.9, False, "Show.Name.S02E02.mkv"),
|
||||
# Season 3 - has gap at episode 5
|
||||
SeriesIdentity("Show Name", 3, [4], 0.9, False, "Show.Name.S03E04.mkv"),
|
||||
SeriesIdentity("Show Name", 3, [6], 0.9, False, "Show.Name.S03E06.mkv"),
|
||||
]
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
|
||||
# Should have 2 results (seasons 1 and 3 with gaps)
|
||||
assert len(result) == 2
|
||||
|
||||
# Find season 1 result
|
||||
season1 = next(r for r in result if r.season == 1)
|
||||
assert season1.episodes_found == [1, 3]
|
||||
assert season1.episodes_missing == [2]
|
||||
|
||||
# Find season 3 result
|
||||
season3 = next(r for r in result if r.season == 3)
|
||||
assert season3.episodes_found == [4, 6]
|
||||
assert season3.episodes_missing == [5]
|
||||
|
||||
def test_multi_episode_files(self):
|
||||
"""Test handling of multi-episode files."""
|
||||
episodes = [
|
||||
SeriesIdentity("Show Name", 1, [1, 2], 0.9, False, "Show.Name.S01E01-E02.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [4], 0.9, False, "Show.Name.S01E04.mkv"),
|
||||
]
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].episodes_found == [1, 2, 4]
|
||||
assert result[0].episodes_missing == [3]
|
||||
|
||||
def test_different_series_separate_analysis(self):
|
||||
"""Test that different series are analyzed separately."""
|
||||
episodes = [
|
||||
# Series A - has gap
|
||||
SeriesIdentity("Series A", 1, [1], 0.9, False, "Series.A.S01E01.mkv"),
|
||||
SeriesIdentity("Series A", 1, [3], 0.9, False, "Series.A.S01E03.mkv"),
|
||||
# Series B - complete
|
||||
SeriesIdentity("Series B", 1, [1], 0.9, False, "Series.B.S01E01.mkv"),
|
||||
SeriesIdentity("Series B", 1, [2], 0.9, False, "Series.B.S01E02.mkv"),
|
||||
]
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
|
||||
# Only Series A should be in results
|
||||
assert len(result) == 1
|
||||
assert result[0].series_title == "Series A"
|
||||
assert result[0].episodes_missing == [2]
|
||||
|
||||
def test_skip_episodes_without_season(self):
|
||||
"""Test that episodes with season=None are excluded from analysis."""
|
||||
episodes = [
|
||||
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [2], 0.9, False, "Show.Name.S01E02.mkv"),
|
||||
SeriesIdentity("Show Name", None, [1], 0.3, True, "Show.Name.Episode.1.mkv"),
|
||||
]
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
|
||||
# Should only analyze season 1, which is complete
|
||||
assert len(result) == 0
|
||||
|
||||
def test_skip_episodes_with_empty_episode_list(self):
|
||||
"""Test that episodes with empty episode list are excluded from analysis."""
|
||||
episodes = [
|
||||
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [3], 0.9, False, "Show.Name.S01E03.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [], 0.3, True, "Show.Name.S01.mkv"),
|
||||
]
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
|
||||
# Should detect gap at episode 2
|
||||
assert len(result) == 1
|
||||
assert result[0].episodes_missing == [2]
|
||||
|
||||
def test_non_sequential_start(self):
|
||||
"""Test gap detection when episodes don't start at 1."""
|
||||
episodes = [
|
||||
SeriesIdentity("Show Name", 1, [5], 0.9, False, "Show.Name.S01E05.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [6], 0.9, False, "Show.Name.S01E06.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [8], 0.9, False, "Show.Name.S01E08.mkv"),
|
||||
]
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
|
||||
# Should detect gap at episode 7 in range [5, 8]
|
||||
assert len(result) == 1
|
||||
assert result[0].episodes_found == [5, 6, 8]
|
||||
assert result[0].episodes_missing == [7]
|
||||
|
||||
def test_empty_input(self):
|
||||
"""Test handling of empty episode list."""
|
||||
result = analyze_series_completeness([])
|
||||
assert len(result) == 0
|
||||
|
||||
def test_single_episode_no_gap(self):
|
||||
"""Test that a single episode has no gaps."""
|
||||
episodes = [
|
||||
SeriesIdentity("Show Name", 1, [1], 0.9, False, "Show.Name.S01E01.mkv"),
|
||||
]
|
||||
|
||||
result = analyze_series_completeness(episodes)
|
||||
|
||||
# Single episode has no gaps
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
|
||||
class TestDuplicateDetection:
|
||||
"""Test duplicate detection functionality."""
|
||||
|
||||
def test_detect_movie_duplicates(self):
|
||||
"""Test detection of duplicate movies with identical title and year."""
|
||||
identities = [
|
||||
MovieIdentity("The Matrix", 1999, 0.9, False, "The.Matrix.1999.1080p.mkv"),
|
||||
MovieIdentity("The Matrix", 1999, 0.9, False, "The.Matrix.1999.720p.mkv"),
|
||||
MovieIdentity("Inception", 2010, 0.9, False, "Inception.2010.mkv"),
|
||||
]
|
||||
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/movies/The.Matrix.1999.1080p.mkv"),
|
||||
"The.Matrix.1999.1080p.mkv",
|
||||
2000000000,
|
||||
datetime.now(),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264"
|
||||
),
|
||||
VideoFile(
|
||||
Path("/movies/The.Matrix.1999.720p.mkv"),
|
||||
"The.Matrix.1999.720p.mkv",
|
||||
1000000000,
|
||||
datetime.now(),
|
||||
"movie",
|
||||
resolution="1280x720",
|
||||
codec="h264"
|
||||
),
|
||||
VideoFile(
|
||||
Path("/movies/Inception.2010.mkv"),
|
||||
"Inception.2010.mkv",
|
||||
1500000000,
|
||||
datetime.now(),
|
||||
"movie"
|
||||
),
|
||||
]
|
||||
|
||||
result = detect_duplicates(identities, files)
|
||||
|
||||
# Should find one duplicate group (The Matrix)
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0].identity, MovieIdentity)
|
||||
assert result[0].identity.title == "The Matrix"
|
||||
assert result[0].identity.year == 1999
|
||||
assert len(result[0].files) == 2
|
||||
assert len(result[0].quality_comparison) == 2
|
||||
|
||||
def test_detect_series_duplicates(self):
|
||||
"""Test detection of duplicate series episodes."""
|
||||
identities = [
|
||||
SeriesIdentity("Breaking Bad", 1, [1], 0.9, False, "Breaking.Bad.S01E01.1080p.mkv"),
|
||||
SeriesIdentity("Breaking Bad", 1, [1], 0.9, False, "Breaking.Bad.S01E01.720p.mkv"),
|
||||
SeriesIdentity("Breaking Bad", 1, [2], 0.9, False, "Breaking.Bad.S01E02.mkv"),
|
||||
]
|
||||
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/series/Breaking.Bad.S01E01.1080p.mkv"),
|
||||
"Breaking.Bad.S01E01.1080p.mkv",
|
||||
1500000000,
|
||||
datetime.now(),
|
||||
"series",
|
||||
resolution="1920x1080"
|
||||
),
|
||||
VideoFile(
|
||||
Path("/series/Breaking.Bad.S01E01.720p.mkv"),
|
||||
"Breaking.Bad.S01E01.720p.mkv",
|
||||
800000000,
|
||||
datetime.now(),
|
||||
"series",
|
||||
resolution="1280x720"
|
||||
),
|
||||
VideoFile(
|
||||
Path("/series/Breaking.Bad.S01E02.mkv"),
|
||||
"Breaking.Bad.S01E02.mkv",
|
||||
1200000000,
|
||||
datetime.now(),
|
||||
"series"
|
||||
),
|
||||
]
|
||||
|
||||
result = detect_duplicates(identities, files)
|
||||
|
||||
# Should find one duplicate group (S01E01)
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0].identity, SeriesIdentity)
|
||||
assert result[0].identity.title == "Breaking Bad"
|
||||
assert result[0].identity.season == 1
|
||||
assert 1 in result[0].identity.episodes
|
||||
assert len(result[0].files) == 2
|
||||
|
||||
def test_no_duplicates(self):
|
||||
"""Test that unique files are not flagged as duplicates."""
|
||||
identities = [
|
||||
MovieIdentity("Movie A", 2020, 0.9, False, "Movie.A.2020.mkv"),
|
||||
MovieIdentity("Movie B", 2021, 0.9, False, "Movie.B.2021.mkv"),
|
||||
]
|
||||
|
||||
files = [
|
||||
VideoFile(Path("/movies/Movie.A.2020.mkv"), "Movie.A.2020.mkv", 1000000000, datetime.now(), "movie"),
|
||||
VideoFile(Path("/movies/Movie.B.2021.mkv"), "Movie.B.2021.mkv", 1000000000, datetime.now(), "movie"),
|
||||
]
|
||||
|
||||
result = detect_duplicates(identities, files)
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
def test_skip_movies_without_year(self):
|
||||
"""Test that movies without year are excluded from duplicate detection."""
|
||||
identities = [
|
||||
MovieIdentity("Unknown Movie", None, 0.3, True, "Unknown.Movie.mkv"),
|
||||
MovieIdentity("Unknown Movie", None, 0.3, True, "Unknown.Movie.2.mkv"),
|
||||
]
|
||||
|
||||
files = [
|
||||
VideoFile(Path("/movies/Unknown.Movie.mkv"), "Unknown.Movie.mkv", 1000000000, datetime.now(), "movie"),
|
||||
VideoFile(Path("/movies/Unknown.Movie.2.mkv"), "Unknown.Movie.2.mkv", 1000000000, datetime.now(), "movie"),
|
||||
]
|
||||
|
||||
result = detect_duplicates(identities, files)
|
||||
|
||||
# Should not detect duplicates for files needing review
|
||||
assert len(result) == 0
|
||||
|
||||
def test_skip_series_without_season(self):
|
||||
"""Test that series without season are excluded from duplicate detection."""
|
||||
identities = [
|
||||
SeriesIdentity("Unknown Show", None, [1], 0.3, True, "Unknown.Show.E01.mkv"),
|
||||
SeriesIdentity("Unknown Show", None, [1], 0.3, True, "Unknown.Show.Episode.1.mkv"),
|
||||
]
|
||||
|
||||
files = [
|
||||
VideoFile(Path("/series/Unknown.Show.E01.mkv"), "Unknown.Show.E01.mkv", 1000000000, datetime.now(), "series"),
|
||||
VideoFile(Path("/series/Unknown.Show.Episode.1.mkv"), "Unknown.Show.Episode.1.mkv", 1000000000, datetime.now(), "series"),
|
||||
]
|
||||
|
||||
result = detect_duplicates(identities, files)
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
def test_skip_series_with_empty_episodes(self):
|
||||
"""Test that series with empty episode list are excluded."""
|
||||
identities = [
|
||||
SeriesIdentity("Show Name", 1, [], 0.3, True, "Show.Name.S01.mkv"),
|
||||
SeriesIdentity("Show Name", 1, [], 0.3, True, "Show.Name.Season.1.mkv"),
|
||||
]
|
||||
|
||||
files = [
|
||||
VideoFile(Path("/series/Show.Name.S01.mkv"), "Show.Name.S01.mkv", 1000000000, datetime.now(), "series"),
|
||||
VideoFile(Path("/series/Show.Name.Season.1.mkv"), "Show.Name.Season.1.mkv", 1000000000, datetime.now(), "series"),
|
||||
]
|
||||
|
||||
result = detect_duplicates(identities, files)
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
def test_quality_comparison_includes_all_metadata(self):
|
||||
"""Test that quality comparison includes all available metadata."""
|
||||
identities = [
|
||||
MovieIdentity("Test Movie", 2020, 0.9, False, "Test.Movie.2020.1080p.mkv"),
|
||||
MovieIdentity("Test Movie", 2020, 0.9, False, "Test.Movie.2020.720p.mkv"),
|
||||
]
|
||||
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/movies/Test.Movie.2020.1080p.mkv"),
|
||||
"Test.Movie.2020.1080p.mkv",
|
||||
2000000000,
|
||||
datetime.now(),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264",
|
||||
duration_seconds=7200.0,
|
||||
bitrate_kbps=5000
|
||||
),
|
||||
VideoFile(
|
||||
Path("/movies/Test.Movie.2020.720p.mkv"),
|
||||
"Test.Movie.2020.720p.mkv",
|
||||
1000000000,
|
||||
datetime.now(),
|
||||
"movie",
|
||||
resolution="1280x720",
|
||||
codec="h264",
|
||||
duration_seconds=7200.0,
|
||||
bitrate_kbps=2500
|
||||
),
|
||||
]
|
||||
|
||||
result = detect_duplicates(identities, files)
|
||||
|
||||
assert len(result) == 1
|
||||
comparison = result[0].quality_comparison
|
||||
assert len(comparison) == 2
|
||||
|
||||
# Check first file comparison data
|
||||
assert comparison[0]['filename'] == "Test.Movie.2020.1080p.mkv"
|
||||
assert comparison[0]['size_bytes'] == 2000000000
|
||||
assert comparison[0]['resolution'] == "1920x1080"
|
||||
assert comparison[0]['codec'] == "h264"
|
||||
assert comparison[0]['duration_seconds'] == 7200.0
|
||||
assert comparison[0]['bitrate_kbps'] == 5000
|
||||
|
||||
# Check second file comparison data
|
||||
assert comparison[1]['filename'] == "Test.Movie.2020.720p.mkv"
|
||||
assert comparison[1]['size_bytes'] == 1000000000
|
||||
assert comparison[1]['resolution'] == "1280x720"
|
||||
|
||||
def test_multi_episode_file_duplicates(self):
|
||||
"""Test duplicate detection for multi-episode files."""
|
||||
identities = [
|
||||
SeriesIdentity("Show", 1, [1, 2], 0.9, False, "Show.S01E01-E02.mkv"),
|
||||
SeriesIdentity("Show", 1, [1], 0.9, False, "Show.S01E01.mkv"),
|
||||
SeriesIdentity("Show", 1, [2], 0.9, False, "Show.S01E02.mkv"),
|
||||
]
|
||||
|
||||
files = [
|
||||
VideoFile(Path("/series/Show.S01E01-E02.mkv"), "Show.S01E01-E02.mkv", 2000000000, datetime.now(), "series"),
|
||||
VideoFile(Path("/series/Show.S01E01.mkv"), "Show.S01E01.mkv", 1000000000, datetime.now(), "series"),
|
||||
VideoFile(Path("/series/Show.S01E02.mkv"), "Show.S01E02.mkv", 1000000000, datetime.now(), "series"),
|
||||
]
|
||||
|
||||
result = detect_duplicates(identities, files)
|
||||
|
||||
# Should find duplicates for both E01 and E02
|
||||
assert len(result) == 2
|
||||
|
||||
def test_different_years_not_duplicates(self):
|
||||
"""Test that same title with different years are not duplicates."""
|
||||
identities = [
|
||||
MovieIdentity("The Thing", 1982, 0.9, False, "The.Thing.1982.mkv"),
|
||||
MovieIdentity("The Thing", 2011, 0.9, False, "The.Thing.2011.mkv"),
|
||||
]
|
||||
|
||||
files = [
|
||||
VideoFile(Path("/movies/The.Thing.1982.mkv"), "The.Thing.1982.mkv", 1000000000, datetime.now(), "movie"),
|
||||
VideoFile(Path("/movies/The.Thing.2011.mkv"), "The.Thing.2011.mkv", 1000000000, datetime.now(), "movie"),
|
||||
]
|
||||
|
||||
result = detect_duplicates(identities, files)
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
def test_different_seasons_not_duplicates(self):
|
||||
"""Test that same series/episode in different seasons are not duplicates."""
|
||||
identities = [
|
||||
SeriesIdentity("Show", 1, [1], 0.9, False, "Show.S01E01.mkv"),
|
||||
SeriesIdentity("Show", 2, [1], 0.9, False, "Show.S02E01.mkv"),
|
||||
]
|
||||
|
||||
files = [
|
||||
VideoFile(Path("/series/Show.S01E01.mkv"), "Show.S01E01.mkv", 1000000000, datetime.now(), "series"),
|
||||
VideoFile(Path("/series/Show.S02E01.mkv"), "Show.S02E01.mkv", 1000000000, datetime.now(), "series"),
|
||||
]
|
||||
|
||||
result = detect_duplicates(identities, files)
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
class TestQualityComparison:
|
||||
"""Test quality comparison functionality."""
|
||||
|
||||
def test_compare_quality_with_all_metadata(self):
|
||||
"""Test quality comparison with all metadata available."""
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/test/file1.mkv"),
|
||||
"file1.mkv",
|
||||
2000000000,
|
||||
datetime.now(),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264",
|
||||
duration_seconds=7200.0,
|
||||
bitrate_kbps=5000
|
||||
),
|
||||
VideoFile(
|
||||
Path("/test/file2.mkv"),
|
||||
"file2.mkv",
|
||||
1000000000,
|
||||
datetime.now(),
|
||||
"movie",
|
||||
resolution="1280x720",
|
||||
codec="h265",
|
||||
duration_seconds=7200.0,
|
||||
bitrate_kbps=2500
|
||||
),
|
||||
]
|
||||
|
||||
result = compare_quality(files)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]['filename'] == "file1.mkv"
|
||||
assert result[0]['size_bytes'] == 2000000000
|
||||
assert result[0]['resolution'] == "1920x1080"
|
||||
assert result[0]['codec'] == "h264"
|
||||
assert result[0]['duration_seconds'] == 7200.0
|
||||
assert result[0]['bitrate_kbps'] == 5000
|
||||
|
||||
assert result[1]['filename'] == "file2.mkv"
|
||||
assert result[1]['size_bytes'] == 1000000000
|
||||
assert result[1]['resolution'] == "1280x720"
|
||||
assert result[1]['codec'] == "h265"
|
||||
|
||||
def test_compare_quality_with_partial_metadata(self):
|
||||
"""Test quality comparison when some metadata is missing."""
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/test/file1.mkv"),
|
||||
"file1.mkv",
|
||||
2000000000,
|
||||
datetime.now(),
|
||||
"movie",
|
||||
resolution="1920x1080"
|
||||
# codec, duration, bitrate not available
|
||||
),
|
||||
VideoFile(
|
||||
Path("/test/file2.mkv"),
|
||||
"file2.mkv",
|
||||
1000000000,
|
||||
datetime.now(),
|
||||
"movie"
|
||||
# No optional metadata
|
||||
),
|
||||
]
|
||||
|
||||
result = compare_quality(files)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]['filename'] == "file1.mkv"
|
||||
assert result[0]['size_bytes'] == 2000000000
|
||||
assert result[0]['resolution'] == "1920x1080"
|
||||
assert 'codec' not in result[0]
|
||||
assert 'duration_seconds' not in result[0]
|
||||
assert 'bitrate_kbps' not in result[0]
|
||||
|
||||
assert result[1]['filename'] == "file2.mkv"
|
||||
assert result[1]['size_bytes'] == 1000000000
|
||||
assert 'resolution' not in result[1]
|
||||
assert 'codec' not in result[1]
|
||||
|
||||
def test_compare_quality_empty_list(self):
|
||||
"""Test quality comparison with empty file list."""
|
||||
result = compare_quality([])
|
||||
assert len(result) == 0
|
||||
|
||||
def test_compare_quality_single_file(self):
|
||||
"""Test quality comparison with single file."""
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/test/file.mkv"),
|
||||
"file.mkv",
|
||||
1500000000,
|
||||
datetime.now(),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264"
|
||||
),
|
||||
]
|
||||
|
||||
result = compare_quality(files)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]['filename'] == "file.mkv"
|
||||
assert result[0]['size_bytes'] == 1500000000
|
||||
@@ -0,0 +1,515 @@
|
||||
"""Property-based tests for the analysis engine.
|
||||
|
||||
Tests universal correctness properties using Hypothesis with minimum 100 iterations.
|
||||
Each test validates a specific property from the design document.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from hypothesis import given, strategies as st, settings
|
||||
from vlm.models import (
|
||||
SeriesIdentity, SeasonCompleteness, MovieIdentity, VideoFile, DuplicateGroup
|
||||
)
|
||||
from vlm.analysis import analyze_series_completeness, detect_duplicates, compare_quality
|
||||
from vlm.reports import (
|
||||
generate_completeness_report, generate_duplicate_report, generate_summary_report
|
||||
)
|
||||
|
||||
|
||||
# Custom strategies for generating test data
|
||||
|
||||
@st.composite
|
||||
def series_identity_strategy(draw, title=None, season=None):
|
||||
"""Generate a SeriesIdentity with optional fixed title and season."""
|
||||
if title is None:
|
||||
title = draw(st.text(min_size=1, max_size=50, alphabet=st.characters(
|
||||
whitelist_categories=('Lu', 'Ll', 'Nd'), whitelist_characters=' '
|
||||
)))
|
||||
|
||||
if season is None:
|
||||
season = draw(st.integers(min_value=1, max_value=20))
|
||||
|
||||
# Generate 1-3 episode numbers
|
||||
episode_count = draw(st.integers(min_value=1, max_value=3))
|
||||
episodes = draw(st.lists(
|
||||
st.integers(min_value=1, max_value=50),
|
||||
min_size=episode_count,
|
||||
max_size=episode_count,
|
||||
unique=True
|
||||
))
|
||||
|
||||
confidence = draw(st.floats(min_value=0.5, max_value=1.0))
|
||||
needs_review = False
|
||||
original_filename = f"{title.replace(' ', '.')}.S{season:02d}E{episodes[0]:02d}.mkv"
|
||||
|
||||
return SeriesIdentity(title, season, sorted(episodes), confidence, needs_review, original_filename)
|
||||
|
||||
|
||||
@st.composite
|
||||
def movie_identity_strategy(draw, title=None, year=None):
|
||||
"""Generate a MovieIdentity with optional fixed title and year."""
|
||||
if title is None:
|
||||
title = draw(st.text(min_size=1, max_size=50, alphabet=st.characters(
|
||||
whitelist_categories=('Lu', 'Ll', 'Nd'), whitelist_characters=' '
|
||||
)))
|
||||
|
||||
if year is None:
|
||||
year = draw(st.integers(min_value=1900, max_value=2030))
|
||||
|
||||
confidence = draw(st.floats(min_value=0.5, max_value=1.0))
|
||||
needs_review = False
|
||||
original_filename = f"{title.replace(' ', '.')}.{year}.mkv"
|
||||
|
||||
return MovieIdentity(title, year, confidence, needs_review, original_filename)
|
||||
|
||||
|
||||
@st.composite
|
||||
def video_file_strategy(draw, filename=None, category="movie"):
|
||||
"""Generate a VideoFile with optional fixed filename."""
|
||||
if filename is None:
|
||||
filename = draw(st.text(min_size=5, max_size=50, alphabet=st.characters(
|
||||
whitelist_categories=('Lu', 'Ll', 'Nd'), whitelist_characters='.-_'
|
||||
))) + ".mkv"
|
||||
|
||||
path = Path(f"/{category}/{filename}")
|
||||
size_bytes = draw(st.integers(min_value=1000000, max_value=10000000000))
|
||||
modified_timestamp = datetime.now()
|
||||
|
||||
# Optional metadata
|
||||
has_metadata = draw(st.booleans())
|
||||
if has_metadata:
|
||||
resolution = draw(st.sampled_from(["1920x1080", "1280x720", "3840x2160", "720x480"]))
|
||||
codec = draw(st.sampled_from(["h264", "h265", "vp9", "av1"]))
|
||||
duration_seconds = draw(st.floats(min_value=300, max_value=10800))
|
||||
bitrate_kbps = draw(st.integers(min_value=500, max_value=20000))
|
||||
return VideoFile(path, filename, size_bytes, modified_timestamp, category,
|
||||
resolution, codec, duration_seconds, bitrate_kbps)
|
||||
else:
|
||||
return VideoFile(path, filename, size_bytes, modified_timestamp, category)
|
||||
|
||||
|
||||
# Property 10: Gap detection
|
||||
# Feature: video-library-manager, Property 10: Gap detection
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
title=st.text(min_size=1, max_size=30, alphabet=st.characters(
|
||||
whitelist_categories=('Lu', 'Ll'), whitelist_characters=' '
|
||||
)),
|
||||
season=st.integers(min_value=1, max_value=10),
|
||||
# Generate a list of episode numbers with guaranteed gaps
|
||||
episodes_data=st.lists(
|
||||
st.integers(min_value=1, max_value=30),
|
||||
min_size=3,
|
||||
max_size=15,
|
||||
unique=True
|
||||
)
|
||||
)
|
||||
def test_property_10_gap_detection(title, season, episodes_data):
|
||||
"""Property 10: For any set of episodes within season, analysis SHALL detect
|
||||
missing episode numbers in range [min, max].
|
||||
|
||||
Validates: Requirements 4.1, 4.2
|
||||
"""
|
||||
# Sort episodes and ensure there's at least one gap
|
||||
sorted_episodes = sorted(episodes_data)
|
||||
|
||||
# Create episodes, intentionally removing one to create a gap
|
||||
if len(sorted_episodes) >= 3:
|
||||
# Remove a middle episode to guarantee a gap
|
||||
gap_index = len(sorted_episodes) // 2
|
||||
removed_episode = sorted_episodes[gap_index]
|
||||
episodes_with_gap = sorted_episodes[:gap_index] + sorted_episodes[gap_index + 1:]
|
||||
|
||||
# Create SeriesIdentity objects
|
||||
episode_identities = [
|
||||
SeriesIdentity(title, season, [ep], 0.9, False, f"{title}.S{season:02d}E{ep:02d}.mkv")
|
||||
for ep in episodes_with_gap
|
||||
]
|
||||
|
||||
# Analyze completeness
|
||||
result = analyze_series_completeness(episode_identities)
|
||||
|
||||
# Should detect the gap
|
||||
if len(result) > 0:
|
||||
assert result[0].series_title == title
|
||||
assert result[0].season == season
|
||||
|
||||
# The missing episode should be in the detected gaps
|
||||
min_ep = min(episodes_with_gap)
|
||||
max_ep = max(episodes_with_gap)
|
||||
expected_missing = set(range(min_ep, max_ep + 1)) - set(episodes_with_gap)
|
||||
|
||||
assert set(result[0].episodes_missing) == expected_missing
|
||||
assert removed_episode in result[0].episodes_missing
|
||||
|
||||
|
||||
# Property 11: Multi-season independence
|
||||
# Feature: video-library-manager, Property 11: Multi-season independence
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
title=st.text(min_size=1, max_size=30, alphabet=st.characters(
|
||||
whitelist_categories=('Lu', 'Ll'), whitelist_characters=' '
|
||||
)),
|
||||
season1_episodes=st.lists(st.integers(min_value=1, max_value=20), min_size=2, max_size=10, unique=True),
|
||||
season2_episodes=st.lists(st.integers(min_value=1, max_value=20), min_size=2, max_size=10, unique=True),
|
||||
)
|
||||
def test_property_11_multi_season_independence(title, season1_episodes, season2_episodes):
|
||||
"""Property 11: For any series with multiple seasons, gap detection of one
|
||||
season SHALL not affect others.
|
||||
|
||||
Validates: Requirements 4.4
|
||||
"""
|
||||
# Create episodes for season 1 with a gap
|
||||
s1_sorted = sorted(season1_episodes)
|
||||
if len(s1_sorted) >= 3:
|
||||
gap_index = len(s1_sorted) // 2
|
||||
s1_with_gap = s1_sorted[:gap_index] + s1_sorted[gap_index + 1:]
|
||||
s1_missing = s1_sorted[gap_index]
|
||||
else:
|
||||
s1_with_gap = s1_sorted
|
||||
s1_missing = None
|
||||
|
||||
# Create episodes for season 2 (complete, no gaps)
|
||||
s2_sorted = sorted(season2_episodes)
|
||||
s2_complete = list(range(min(s2_sorted), max(s2_sorted) + 1))
|
||||
|
||||
# Create SeriesIdentity objects
|
||||
episode_identities = []
|
||||
for ep in s1_with_gap:
|
||||
episode_identities.append(
|
||||
SeriesIdentity(title, 1, [ep], 0.9, False, f"{title}.S01E{ep:02d}.mkv")
|
||||
)
|
||||
for ep in s2_complete:
|
||||
episode_identities.append(
|
||||
SeriesIdentity(title, 2, [ep], 0.9, False, f"{title}.S02E{ep:02d}.mkv")
|
||||
)
|
||||
|
||||
# Analyze completeness
|
||||
result = analyze_series_completeness(episode_identities)
|
||||
|
||||
# Season 2 should not appear in results (it's complete)
|
||||
season2_results = [r for r in result if r.season == 2]
|
||||
assert len(season2_results) == 0
|
||||
|
||||
# Season 1 should appear if there's a gap
|
||||
if s1_missing is not None:
|
||||
season1_results = [r for r in result if r.season == 1]
|
||||
if len(season1_results) > 0:
|
||||
assert s1_missing in season1_results[0].episodes_missing
|
||||
|
||||
|
||||
# Property 12: Duplicate detection for movies
|
||||
# Feature: video-library-manager, Property 12: Duplicate detection for movies
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
title=st.text(min_size=1, max_size=30, alphabet=st.characters(
|
||||
whitelist_categories=('Lu', 'Ll'), whitelist_characters=' '
|
||||
)),
|
||||
year=st.integers(min_value=1900, max_value=2030),
|
||||
duplicate_count=st.integers(min_value=2, max_value=5)
|
||||
)
|
||||
def test_property_12_duplicate_detection_movies(title, year, duplicate_count):
|
||||
"""Property 12: For any set of movies with identical normalized titles and years,
|
||||
all SHALL be grouped as duplicates.
|
||||
|
||||
Validates: Requirements 5.1
|
||||
"""
|
||||
# Create multiple movie identities with same title and year
|
||||
identities = []
|
||||
files = []
|
||||
|
||||
for i in range(duplicate_count):
|
||||
filename = f"{title.replace(' ', '.')}.{year}.{i}.mkv"
|
||||
identities.append(MovieIdentity(title, year, 0.9, False, filename))
|
||||
files.append(VideoFile(
|
||||
Path(f"/movies/{filename}"),
|
||||
filename,
|
||||
1000000000 + i * 100000000,
|
||||
datetime.now(),
|
||||
"movie"
|
||||
))
|
||||
|
||||
# Detect duplicates
|
||||
result = detect_duplicates(identities, files)
|
||||
|
||||
# Should find exactly one duplicate group
|
||||
assert len(result) == 1
|
||||
|
||||
# The group should contain all files
|
||||
assert len(result[0].files) == duplicate_count
|
||||
|
||||
# Identity should match
|
||||
assert result[0].identity.title == title
|
||||
assert result[0].identity.year == year
|
||||
|
||||
|
||||
# Property 13: Duplicate detection for series
|
||||
# Feature: video-library-manager, Property 13: Duplicate detection for series
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
title=st.text(min_size=1, max_size=30, alphabet=st.characters(
|
||||
whitelist_categories=('Lu', 'Ll'), whitelist_characters=' '
|
||||
)),
|
||||
season=st.integers(min_value=1, max_value=10),
|
||||
episode=st.integers(min_value=1, max_value=30),
|
||||
duplicate_count=st.integers(min_value=2, max_value=5)
|
||||
)
|
||||
def test_property_13_duplicate_detection_series(title, season, episode, duplicate_count):
|
||||
"""Property 13: For any set of series files with identical normalized titles,
|
||||
seasons, and episodes, all SHALL be grouped as duplicates.
|
||||
|
||||
Validates: Requirements 5.2
|
||||
"""
|
||||
# Create multiple series identities with same title, season, and episode
|
||||
identities = []
|
||||
files = []
|
||||
|
||||
for i in range(duplicate_count):
|
||||
filename = f"{title.replace(' ', '.')}.S{season:02d}E{episode:02d}.{i}.mkv"
|
||||
identities.append(SeriesIdentity(title, season, [episode], 0.9, False, filename))
|
||||
files.append(VideoFile(
|
||||
Path(f"/series/{filename}"),
|
||||
filename,
|
||||
1000000000 + i * 100000000,
|
||||
datetime.now(),
|
||||
"series"
|
||||
))
|
||||
|
||||
# Detect duplicates
|
||||
result = detect_duplicates(identities, files)
|
||||
|
||||
# Should find exactly one duplicate group
|
||||
assert len(result) == 1
|
||||
|
||||
# The group should contain all files
|
||||
assert len(result[0].files) == duplicate_count
|
||||
|
||||
# Identity should match
|
||||
assert result[0].identity.title == title
|
||||
assert result[0].identity.season == season
|
||||
assert episode in result[0].identity.episodes
|
||||
|
||||
|
||||
# Property 14: Duplicate quality comparison
|
||||
# Feature: video-library-manager, Property 14: Duplicate quality comparison
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
title=st.text(min_size=1, max_size=30, alphabet=st.characters(
|
||||
whitelist_categories=('Lu', 'Ll'), whitelist_characters=' '
|
||||
)),
|
||||
year=st.integers(min_value=1900, max_value=2030),
|
||||
file_count=st.integers(min_value=2, max_value=4)
|
||||
)
|
||||
def test_property_14_duplicate_quality_comparison(title, year, file_count):
|
||||
"""Property 14: For any duplicate group, comparison data SHALL include
|
||||
available metadata for each file.
|
||||
|
||||
Validates: Requirements 5.3
|
||||
"""
|
||||
# Create movie identities and files with varying metadata
|
||||
identities = []
|
||||
files = []
|
||||
|
||||
for i in range(file_count):
|
||||
filename = f"{title.replace(' ', '.')}.{year}.{i}.mkv"
|
||||
identities.append(MovieIdentity(title, year, 0.9, False, filename))
|
||||
|
||||
# Some files have full metadata, some don't
|
||||
if i % 2 == 0:
|
||||
files.append(VideoFile(
|
||||
Path(f"/movies/{filename}"),
|
||||
filename,
|
||||
1000000000 + i * 100000000,
|
||||
datetime.now(),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264",
|
||||
duration_seconds=7200.0,
|
||||
bitrate_kbps=5000
|
||||
))
|
||||
else:
|
||||
files.append(VideoFile(
|
||||
Path(f"/movies/{filename}"),
|
||||
filename,
|
||||
1000000000 + i * 100000000,
|
||||
datetime.now(),
|
||||
"movie"
|
||||
))
|
||||
|
||||
# Detect duplicates
|
||||
result = detect_duplicates(identities, files)
|
||||
|
||||
# Should have quality comparison data
|
||||
assert len(result) == 1
|
||||
assert len(result[0].quality_comparison) == file_count
|
||||
|
||||
# Each comparison entry should have at least filename and size
|
||||
for comparison in result[0].quality_comparison:
|
||||
assert 'filename' in comparison
|
||||
assert 'size_bytes' in comparison
|
||||
assert 'path' in comparison
|
||||
|
||||
# Files with metadata should have those fields
|
||||
if comparison['filename'].endswith('.0.mkv') or comparison['filename'].endswith('.2.mkv'):
|
||||
assert 'resolution' in comparison
|
||||
assert 'codec' in comparison
|
||||
assert 'duration_seconds' in comparison
|
||||
assert 'bitrate_kbps' in comparison
|
||||
|
||||
|
||||
# Property 42: Completeness report
|
||||
# Feature: video-library-manager, Property 42: Completeness report
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
series_count=st.integers(min_value=1, max_value=5),
|
||||
format=st.sampled_from(["text", "json"])
|
||||
)
|
||||
def test_property_42_completeness_report(series_count, format):
|
||||
"""Property 42: For any set of analyzed series, completeness report SHALL
|
||||
include all series with detected gaps.
|
||||
|
||||
Validates: Requirements 11.2
|
||||
"""
|
||||
# Create series with gaps
|
||||
analysis_results = []
|
||||
|
||||
for i in range(series_count):
|
||||
title = f"Series {i}"
|
||||
season = 1
|
||||
episodes_found = [1, 2, 4, 5] # Gap at episode 3
|
||||
episodes_missing = [3]
|
||||
|
||||
analysis_results.append(SeasonCompleteness(
|
||||
series_title=title,
|
||||
season=season,
|
||||
episodes_found=episodes_found,
|
||||
episodes_missing=episodes_missing
|
||||
))
|
||||
|
||||
# Generate report
|
||||
library_root = Path("/test/library")
|
||||
report = generate_completeness_report(analysis_results, format, library_root)
|
||||
|
||||
# Report should include all series
|
||||
for i in range(series_count):
|
||||
assert f"Series {i}" in report
|
||||
|
||||
# Report should include metadata
|
||||
assert str(library_root) in report
|
||||
|
||||
|
||||
# Property 43: Duplicate report grouping
|
||||
# Feature: video-library-manager, Property 43: Duplicate report grouping
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
duplicate_count=st.integers(min_value=1, max_value=5),
|
||||
format=st.sampled_from(["text", "json"])
|
||||
)
|
||||
def test_property_43_duplicate_report_grouping(duplicate_count, format):
|
||||
"""Property 43: For any set of detected duplicates, duplicate report SHALL
|
||||
group files by identity with comparison data.
|
||||
|
||||
Validates: Requirements 11.3
|
||||
"""
|
||||
# Create duplicate groups
|
||||
duplicate_groups = []
|
||||
|
||||
for i in range(duplicate_count):
|
||||
title = f"Movie {i}"
|
||||
year = 2020 + i
|
||||
|
||||
# Create 2 files for each duplicate group
|
||||
files = []
|
||||
quality_comparison = []
|
||||
|
||||
for j in range(2):
|
||||
filename = f"{title.replace(' ', '.')}.{year}.{j}.mkv"
|
||||
file = VideoFile(
|
||||
Path(f"/movies/{filename}"),
|
||||
filename,
|
||||
1000000000 + j * 500000000,
|
||||
datetime.now(),
|
||||
"movie",
|
||||
resolution="1920x1080" if j == 0 else "1280x720",
|
||||
codec="h264"
|
||||
)
|
||||
files.append(file)
|
||||
quality_comparison.append({
|
||||
'filename': filename,
|
||||
'path': str(file.path),
|
||||
'size_bytes': file.size_bytes,
|
||||
'resolution': file.resolution,
|
||||
'codec': file.codec
|
||||
})
|
||||
|
||||
identity = MovieIdentity(title, year, 0.9, False, files[0].filename)
|
||||
duplicate_groups.append(DuplicateGroup(identity, files, quality_comparison))
|
||||
|
||||
# Generate report
|
||||
library_root = Path("/test/library")
|
||||
report = generate_duplicate_report(duplicate_groups, format, library_root)
|
||||
|
||||
# Report should include all duplicate groups
|
||||
for i in range(duplicate_count):
|
||||
assert f"Movie {i}" in report
|
||||
|
||||
# Report should include comparison data (file sizes, resolutions)
|
||||
assert "1920x1080" in report or "resolution" in report.lower()
|
||||
|
||||
# Report should include metadata
|
||||
assert str(library_root) in report
|
||||
|
||||
|
||||
# Property 44: Summary report accuracy
|
||||
# Feature: video-library-manager, Property 44: Summary report accuracy
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
file_count=st.integers(min_value=1, max_value=20),
|
||||
categories=st.lists(
|
||||
st.sampled_from(["movie", "series", "anime", "other"]),
|
||||
min_size=1,
|
||||
max_size=4
|
||||
)
|
||||
)
|
||||
def test_property_44_summary_report_accuracy(file_count, categories):
|
||||
"""Property 44: For any scanned library, summary report SHALL contain
|
||||
accurate counts and sizes.
|
||||
|
||||
Validates: Requirements 11.4
|
||||
"""
|
||||
# Create video files
|
||||
files = []
|
||||
total_size = 0
|
||||
category_counts = {}
|
||||
|
||||
for i in range(file_count):
|
||||
category = categories[i % len(categories)]
|
||||
size = 1000000000 + i * 100000000
|
||||
filename = f"file_{i}.mkv"
|
||||
|
||||
files.append(VideoFile(
|
||||
Path(f"/{category}/{filename}"),
|
||||
filename,
|
||||
size,
|
||||
datetime.now(),
|
||||
category
|
||||
))
|
||||
|
||||
total_size += size
|
||||
category_counts[category] = category_counts.get(category, 0) + 1
|
||||
|
||||
# Generate summary report
|
||||
library_root = Path("/test/library")
|
||||
report = generate_summary_report(files, library_root)
|
||||
|
||||
# Report should include total file count
|
||||
assert f"Total Files: {file_count}" in report
|
||||
|
||||
# Report should include category breakdown
|
||||
for category, count in category_counts.items():
|
||||
assert category.capitalize() in report
|
||||
assert f"Files: {count}" in report
|
||||
|
||||
# Report should include metadata
|
||||
assert str(library_root) in report
|
||||
@@ -0,0 +1,379 @@
|
||||
"""Tests for CLI quarantine commands.
|
||||
|
||||
This module tests the CLI interface for quarantine operations.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from click.testing import CliRunner
|
||||
import pytest
|
||||
|
||||
from vlm.cli import main
|
||||
from vlm.config import Config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_library(tmp_path):
|
||||
"""Create a temporary library structure for testing."""
|
||||
library_root = tmp_path / "library"
|
||||
|
||||
# Create category directories
|
||||
(library_root / "movie").mkdir(parents=True)
|
||||
(library_root / "series").mkdir(parents=True)
|
||||
(library_root / "anime").mkdir(parents=True)
|
||||
(library_root / "other").mkdir(parents=True)
|
||||
|
||||
return library_root
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_file(tmp_path, temp_library):
|
||||
"""Create a temporary config file."""
|
||||
config_path = tmp_path / "config.yaml"
|
||||
|
||||
config_content = f"""
|
||||
library_root: "{temp_library}"
|
||||
video_extensions:
|
||||
- .mp4
|
||||
- .mkv
|
||||
- .avi
|
||||
|
||||
templates:
|
||||
movie_dir: "movie/{{title}} ({{year}})/"
|
||||
series_dir: "series/{{title}}/Season {{season:02d}}/"
|
||||
movie_filename: "{{title}} ({{year}}){{ext}}"
|
||||
series_filename: "S{{season:02d}}E{{episode:02d}}{{ext}}"
|
||||
|
||||
quarantine_dir: ".quarantine"
|
||||
log_level: "INFO"
|
||||
"""
|
||||
|
||||
config_path.write_text(config_content)
|
||||
return config_path
|
||||
|
||||
|
||||
class TestQuarantineListCommand:
|
||||
"""Tests for 'vlm quarantine list' command."""
|
||||
|
||||
def test_list_empty_quarantine(self, config_file):
|
||||
"""Test listing when no files are quarantined."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ['--config', str(config_file), 'quarantine', 'list'])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "No quarantined files found" in result.output
|
||||
|
||||
def test_list_with_quarantined_files(self, config_file, temp_library):
|
||||
"""Test listing quarantined files."""
|
||||
runner = CliRunner()
|
||||
|
||||
# Create a test movie file
|
||||
movie_file = temp_library / "movie" / "Test Movie (2020).mkv"
|
||||
movie_file.write_text("test content")
|
||||
|
||||
# Quarantine the file first
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(config_file),
|
||||
'quarantine', 'add',
|
||||
str(movie_file)
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Now list quarantined files
|
||||
result = runner.invoke(main, ['--config', str(config_file), 'quarantine', 'list'])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Test Movie (2020).mkv" in result.output
|
||||
assert "Category: movie" in result.output
|
||||
|
||||
def test_list_filter_by_category(self, config_file, temp_library):
|
||||
"""Test listing with category filter."""
|
||||
runner = CliRunner()
|
||||
|
||||
# Create and quarantine a movie file
|
||||
movie_file = temp_library / "movie" / "Movie.mkv"
|
||||
movie_file.write_text("movie content")
|
||||
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(config_file),
|
||||
'quarantine', 'add',
|
||||
str(movie_file)
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# List only movie category
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(config_file),
|
||||
'quarantine', 'list',
|
||||
'--category', 'movie'
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Movie.mkv" in result.output
|
||||
assert "category 'movie'" in result.output
|
||||
|
||||
|
||||
class TestQuarantineAddCommand:
|
||||
"""Tests for 'vlm quarantine add' command."""
|
||||
|
||||
def test_add_movie_file(self, config_file, temp_library):
|
||||
"""Test adding a movie file to quarantine."""
|
||||
runner = CliRunner()
|
||||
|
||||
# Create a test movie file
|
||||
movie_file = temp_library / "movie" / "Test Movie (2020).mkv"
|
||||
movie_file.write_text("test content")
|
||||
|
||||
# Quarantine the file
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(config_file),
|
||||
'quarantine', 'add',
|
||||
str(movie_file)
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "successfully quarantined" in result.output
|
||||
assert not movie_file.exists() # Original file should be moved
|
||||
|
||||
# Check quarantine location
|
||||
quarantine_path = temp_library / "movie" / ".quarantine" / "Test Movie (2020).mkv"
|
||||
assert quarantine_path.exists()
|
||||
assert quarantine_path.read_text() == "test content"
|
||||
|
||||
def test_add_series_file(self, config_file, temp_library):
|
||||
"""Test adding a series file to quarantine."""
|
||||
runner = CliRunner()
|
||||
|
||||
# Create a test series file
|
||||
series_file = temp_library / "series" / "Show Name" / "S01E01.mkv"
|
||||
series_file.parent.mkdir(parents=True)
|
||||
series_file.write_text("series content")
|
||||
|
||||
# Quarantine the file
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(config_file),
|
||||
'quarantine', 'add',
|
||||
str(series_file)
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "successfully quarantined" in result.output
|
||||
assert not series_file.exists()
|
||||
|
||||
def test_add_with_reason(self, config_file, temp_library):
|
||||
"""Test adding file with a reason."""
|
||||
runner = CliRunner()
|
||||
|
||||
# Create a test movie file
|
||||
movie_file = temp_library / "movie" / "Duplicate.mkv"
|
||||
movie_file.write_text("test content")
|
||||
|
||||
# Quarantine with reason
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(config_file),
|
||||
'quarantine', 'add',
|
||||
str(movie_file),
|
||||
'--reason', 'duplicate file'
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Reason: duplicate file" in result.output
|
||||
assert "successfully quarantined" in result.output
|
||||
|
||||
def test_add_anime_file_rejected(self, config_file, temp_library):
|
||||
"""Test that anime files are rejected."""
|
||||
runner = CliRunner()
|
||||
|
||||
# Create a test anime file
|
||||
anime_file = temp_library / "anime" / "Anime Show.mkv"
|
||||
anime_file.write_text("anime content")
|
||||
|
||||
# Try to quarantine (should fail)
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(config_file),
|
||||
'quarantine', 'add',
|
||||
str(anime_file)
|
||||
])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "not supported" in result.output.lower()
|
||||
assert anime_file.exists() # File should still exist
|
||||
|
||||
def test_add_nonexistent_file(self, config_file, temp_library):
|
||||
"""Test adding a file that doesn't exist."""
|
||||
runner = CliRunner()
|
||||
|
||||
# Try to quarantine non-existent file
|
||||
nonexistent = temp_library / "movie" / "DoesNotExist.mkv"
|
||||
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(config_file),
|
||||
'quarantine', 'add',
|
||||
str(nonexistent)
|
||||
])
|
||||
|
||||
# Click should catch this before our code runs
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
class TestQuarantineRestoreCommand:
|
||||
"""Tests for 'vlm quarantine restore' command."""
|
||||
|
||||
def test_restore_movie_file(self, config_file, temp_library):
|
||||
"""Test restoring a movie file from quarantine."""
|
||||
runner = CliRunner()
|
||||
|
||||
# Create and quarantine a movie file
|
||||
movie_file = temp_library / "movie" / "Test Movie.mkv"
|
||||
movie_file.write_text("test content")
|
||||
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(config_file),
|
||||
'quarantine', 'add',
|
||||
str(movie_file)
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Get quarantine path
|
||||
quarantine_path = temp_library / "movie" / ".quarantine" / "Test Movie.mkv"
|
||||
assert quarantine_path.exists()
|
||||
|
||||
# Restore the file
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(config_file),
|
||||
'quarantine', 'restore',
|
||||
str(quarantine_path)
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "successfully restored" in result.output
|
||||
assert movie_file.exists() # Original location should have file
|
||||
assert not quarantine_path.exists() # Quarantine should be empty
|
||||
assert movie_file.read_text() == "test content"
|
||||
|
||||
def test_restore_series_file(self, config_file, temp_library):
|
||||
"""Test restoring a series file from quarantine."""
|
||||
runner = CliRunner()
|
||||
|
||||
# Create and quarantine a series file
|
||||
series_file = temp_library / "series" / "Show" / "S01E01.mkv"
|
||||
series_file.parent.mkdir(parents=True)
|
||||
series_file.write_text("series content")
|
||||
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(config_file),
|
||||
'quarantine', 'add',
|
||||
str(series_file)
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Get quarantine path
|
||||
quarantine_path = temp_library / "series" / ".quarantine" / "Show" / "S01E01.mkv"
|
||||
assert quarantine_path.exists()
|
||||
|
||||
# Restore the file
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(config_file),
|
||||
'quarantine', 'restore',
|
||||
str(quarantine_path)
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "successfully restored" in result.output
|
||||
assert series_file.exists()
|
||||
|
||||
def test_restore_conflict(self, config_file, temp_library):
|
||||
"""Test restoring when original location is occupied."""
|
||||
runner = CliRunner()
|
||||
|
||||
# Create and quarantine a movie file
|
||||
movie_file = temp_library / "movie" / "Movie.mkv"
|
||||
movie_file.write_text("original content")
|
||||
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(config_file),
|
||||
'quarantine', 'add',
|
||||
str(movie_file)
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Create a new file at the original location
|
||||
movie_file.write_text("new content")
|
||||
|
||||
# Try to restore (should fail due to conflict)
|
||||
quarantine_path = temp_library / "movie" / ".quarantine" / "Movie.mkv"
|
||||
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(config_file),
|
||||
'quarantine', 'restore',
|
||||
str(quarantine_path)
|
||||
])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Cannot restore" in result.output or "already exists" in result.output
|
||||
assert movie_file.read_text() == "new content" # Original location unchanged
|
||||
assert quarantine_path.exists() # File still in quarantine
|
||||
|
||||
|
||||
class TestQuarantineIntegration:
|
||||
"""Integration tests for quarantine workflow."""
|
||||
|
||||
def test_full_quarantine_workflow(self, config_file, temp_library):
|
||||
"""Test complete workflow: add -> list -> restore."""
|
||||
runner = CliRunner()
|
||||
|
||||
# Create test files
|
||||
movie1 = temp_library / "movie" / "Movie1.mkv"
|
||||
movie2 = temp_library / "movie" / "Movie2.mkv"
|
||||
movie1.write_text("content1")
|
||||
movie2.write_text("content2")
|
||||
|
||||
# Add both to quarantine
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(config_file),
|
||||
'quarantine', 'add',
|
||||
str(movie1),
|
||||
'--reason', 'duplicate'
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(config_file),
|
||||
'quarantine', 'add',
|
||||
str(movie2),
|
||||
'--reason', 'low quality'
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# List quarantined files
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(config_file),
|
||||
'quarantine', 'list'
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
assert "Movie1.mkv" in result.output
|
||||
assert "Movie2.mkv" in result.output
|
||||
assert "Total: 2 quarantined file(s)" in result.output
|
||||
|
||||
# Restore one file
|
||||
quarantine_path1 = temp_library / "movie" / ".quarantine" / "Movie1.mkv"
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(config_file),
|
||||
'quarantine', 'restore',
|
||||
str(quarantine_path1)
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# List again (should show only 1 file now)
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(config_file),
|
||||
'quarantine', 'list'
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
assert "Movie1.mkv" not in result.output
|
||||
assert "Movie2.mkv" in result.output
|
||||
assert "Total: 1 quarantined file(s)" in result.output
|
||||
|
||||
# Verify restored file
|
||||
assert movie1.exists()
|
||||
assert movie1.read_text() == "content1"
|
||||
@@ -0,0 +1,343 @@
|
||||
"""Integration tests for CLI report commands.
|
||||
|
||||
Tests the report commands to ensure they properly wire to the Report Generator.
|
||||
"""
|
||||
|
||||
import json
|
||||
import csv
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from click.testing import CliRunner
|
||||
from vlm.cli import main
|
||||
|
||||
|
||||
class TestCLIReports:
|
||||
"""Test CLI report commands."""
|
||||
|
||||
def test_report_inventory_csv(self, tmp_path):
|
||||
"""Test inventory report generation in CSV format."""
|
||||
# Create test inventory CSV
|
||||
inventory_file = tmp_path / "inventory.csv"
|
||||
with open(inventory_file, 'w', encoding='utf-8') as f:
|
||||
f.write("# Generated: 2024-01-01T00:00:00\n")
|
||||
f.write("# Library Root: /test/library\n")
|
||||
f.write("path,filename,size_bytes,modified_timestamp,category,resolution,codec,duration_seconds,bitrate_kbps\n")
|
||||
f.write("/test/library/movie/Movie1.mkv,Movie1.mkv,1000000,2024-01-01T00:00:00,movie,1920x1080,h264,7200,5000\n")
|
||||
f.write("/test/library/series/Show.S01E01.mkv,Show.S01E01.mkv,800000,2024-01-01T00:00:00,series,1280x720,h264,2700,3000\n")
|
||||
|
||||
# Run command
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ['report', 'inventory', '--input', str(inventory_file), '--format', 'csv'])
|
||||
|
||||
# Verify success
|
||||
assert result.exit_code == 0
|
||||
assert "Loaded 2 files" in result.output
|
||||
assert "Movie1.mkv" in result.output
|
||||
assert "Show.S01E01.mkv" in result.output
|
||||
|
||||
def test_report_inventory_json(self, tmp_path):
|
||||
"""Test inventory report generation in JSON format."""
|
||||
# Create test inventory CSV
|
||||
inventory_file = tmp_path / "inventory.csv"
|
||||
with open(inventory_file, 'w', encoding='utf-8') as f:
|
||||
f.write("# Generated: 2024-01-01T00:00:00\n")
|
||||
f.write("# Library Root: /test/library\n")
|
||||
f.write("path,filename,size_bytes,modified_timestamp,category,resolution,codec,duration_seconds,bitrate_kbps\n")
|
||||
f.write("/test/library/movie/Movie1.mkv,Movie1.mkv,1000000,2024-01-01T00:00:00,movie,,,\n")
|
||||
|
||||
# Run command with output file
|
||||
output_file = tmp_path / "inventory_report.json"
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [
|
||||
'report', 'inventory',
|
||||
'--input', str(inventory_file),
|
||||
'--format', 'json',
|
||||
'--output', str(output_file)
|
||||
])
|
||||
|
||||
# Verify success
|
||||
assert result.exit_code == 0
|
||||
assert output_file.exists()
|
||||
|
||||
# Verify JSON content
|
||||
with open(output_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
assert data['metadata']['file_count'] == 1
|
||||
assert len(data['files']) == 1
|
||||
assert data['files'][0]['filename'] == 'Movie1.mkv'
|
||||
|
||||
def test_report_completeness_text(self, tmp_path):
|
||||
"""Test completeness report generation in text format."""
|
||||
# Create test analysis JSON
|
||||
analysis_file = tmp_path / "analysis.json"
|
||||
analysis_data = {
|
||||
"metadata": {
|
||||
"generated": "2024-01-01T00:00:00",
|
||||
"source_identities": "identities.json",
|
||||
"total_movies": 0,
|
||||
"total_series": 3
|
||||
},
|
||||
"completeness": [
|
||||
{
|
||||
"series_title": "Breaking Bad",
|
||||
"season": 1,
|
||||
"episodes_found": [1, 2, 4],
|
||||
"episodes_missing": [3]
|
||||
}
|
||||
],
|
||||
"duplicates": []
|
||||
}
|
||||
|
||||
with open(analysis_file, 'w') as f:
|
||||
json.dump(analysis_data, f)
|
||||
|
||||
# Run command
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [
|
||||
'report', 'completeness',
|
||||
'--input', str(analysis_file),
|
||||
'--format', 'text'
|
||||
])
|
||||
|
||||
# Verify success
|
||||
assert result.exit_code == 0
|
||||
assert "Loaded 1 series with gaps" in result.output
|
||||
assert "Breaking Bad" in result.output
|
||||
assert "Episodes missing:" in result.output
|
||||
|
||||
def test_report_completeness_json(self, tmp_path):
|
||||
"""Test completeness report generation in JSON format."""
|
||||
# Create test analysis JSON
|
||||
analysis_file = tmp_path / "analysis.json"
|
||||
analysis_data = {
|
||||
"metadata": {},
|
||||
"completeness": [
|
||||
{
|
||||
"series_title": "The Wire",
|
||||
"season": 1,
|
||||
"episodes_found": [1, 3],
|
||||
"episodes_missing": [2]
|
||||
}
|
||||
],
|
||||
"duplicates": []
|
||||
}
|
||||
|
||||
with open(analysis_file, 'w') as f:
|
||||
json.dump(analysis_data, f)
|
||||
|
||||
# Run command with output file
|
||||
output_file = tmp_path / "completeness_report.json"
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [
|
||||
'report', 'completeness',
|
||||
'--input', str(analysis_file),
|
||||
'--format', 'json',
|
||||
'--output', str(output_file)
|
||||
])
|
||||
|
||||
# Verify success
|
||||
assert result.exit_code == 0
|
||||
assert output_file.exists()
|
||||
|
||||
# Verify JSON content
|
||||
with open(output_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
assert data['metadata']['series_count'] == 1
|
||||
assert len(data['series']) == 1
|
||||
assert data['series'][0]['title'] == 'The Wire'
|
||||
|
||||
def test_report_duplicates_text(self, tmp_path):
|
||||
"""Test duplicate report generation in text format."""
|
||||
# Create test analysis JSON
|
||||
analysis_file = tmp_path / "analysis.json"
|
||||
analysis_data = {
|
||||
"metadata": {},
|
||||
"completeness": [],
|
||||
"duplicates": [
|
||||
{
|
||||
"identity": {
|
||||
"type": "movie",
|
||||
"title": "The Matrix",
|
||||
"year": 1999
|
||||
},
|
||||
"files": [
|
||||
"/movies/The.Matrix.1999.1080p.mkv",
|
||||
"/movies/The.Matrix.1999.720p.mkv"
|
||||
],
|
||||
"quality_comparison": [
|
||||
{
|
||||
"filename": "The.Matrix.1999.1080p.mkv",
|
||||
"path": "/movies/The.Matrix.1999.1080p.mkv",
|
||||
"size_bytes": 2000000000,
|
||||
"resolution": "1920x1080"
|
||||
},
|
||||
{
|
||||
"filename": "The.Matrix.1999.720p.mkv",
|
||||
"path": "/movies/The.Matrix.1999.720p.mkv",
|
||||
"size_bytes": 1000000000,
|
||||
"resolution": "1280x720"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with open(analysis_file, 'w') as f:
|
||||
json.dump(analysis_data, f)
|
||||
|
||||
# Run command
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [
|
||||
'report', 'duplicates',
|
||||
'--input', str(analysis_file),
|
||||
'--format', 'text'
|
||||
])
|
||||
|
||||
# Verify success
|
||||
assert result.exit_code == 0
|
||||
assert "Loaded 1 duplicate groups" in result.output
|
||||
assert "The Matrix (1999)" in result.output
|
||||
assert "1920x1080" in result.output
|
||||
|
||||
def test_report_duplicates_json(self, tmp_path):
|
||||
"""Test duplicate report generation in JSON format."""
|
||||
# Create test analysis JSON
|
||||
analysis_file = tmp_path / "analysis.json"
|
||||
analysis_data = {
|
||||
"metadata": {},
|
||||
"completeness": [],
|
||||
"duplicates": [
|
||||
{
|
||||
"identity": {
|
||||
"type": "series",
|
||||
"title": "Breaking Bad",
|
||||
"season": 1,
|
||||
"episodes": [1]
|
||||
},
|
||||
"files": [
|
||||
"/series/Breaking.Bad.S01E01.1080p.mkv",
|
||||
"/series/Breaking.Bad.S01E01.720p.mkv"
|
||||
],
|
||||
"quality_comparison": [
|
||||
{
|
||||
"filename": "Breaking.Bad.S01E01.1080p.mkv",
|
||||
"path": "/series/Breaking.Bad.S01E01.1080p.mkv",
|
||||
"size_bytes": 1500000000
|
||||
},
|
||||
{
|
||||
"filename": "Breaking.Bad.S01E01.720p.mkv",
|
||||
"path": "/series/Breaking.Bad.S01E01.720p.mkv",
|
||||
"size_bytes": 800000000
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with open(analysis_file, 'w') as f:
|
||||
json.dump(analysis_data, f)
|
||||
|
||||
# Run command with output file
|
||||
output_file = tmp_path / "duplicates_report.json"
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [
|
||||
'report', 'duplicates',
|
||||
'--input', str(analysis_file),
|
||||
'--format', 'json',
|
||||
'--output', str(output_file)
|
||||
])
|
||||
|
||||
# Verify success
|
||||
assert result.exit_code == 0
|
||||
assert output_file.exists()
|
||||
|
||||
# Verify JSON content
|
||||
with open(output_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
assert data['metadata']['duplicate_groups'] == 1
|
||||
assert len(data['duplicates']) == 1
|
||||
|
||||
def test_report_summary(self, tmp_path):
|
||||
"""Test summary report generation."""
|
||||
# Create test inventory CSV
|
||||
inventory_file = tmp_path / "inventory.csv"
|
||||
with open(inventory_file, 'w', encoding='utf-8') as f:
|
||||
f.write("# Generated: 2024-01-01T00:00:00\n")
|
||||
f.write("# Library Root: /test/library\n")
|
||||
f.write("path,filename,size_bytes,modified_timestamp,category,resolution,codec,duration_seconds,bitrate_kbps\n")
|
||||
f.write("/test/library/movie/Movie1.mkv,Movie1.mkv,2000000000,2024-01-01T00:00:00,movie,,,\n")
|
||||
f.write("/test/library/movie/Movie2.mkv,Movie2.mkv,1500000000,2024-01-01T00:00:00,movie,,,\n")
|
||||
f.write("/test/library/series/Show.S01E01.mkv,Show.S01E01.mkv,1000000000,2024-01-01T00:00:00,series,,,\n")
|
||||
f.write("/test/library/anime/Anime1.mkv,Anime1.mkv,800000000,2024-01-01T00:00:00,anime,,,\n")
|
||||
|
||||
# Run command
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [
|
||||
'report', 'summary',
|
||||
'--input', str(inventory_file)
|
||||
])
|
||||
|
||||
# Verify success
|
||||
assert result.exit_code == 0
|
||||
assert "Loaded 4 files" in result.output
|
||||
assert "Total Files: 4" in result.output
|
||||
assert "Movie:" in result.output
|
||||
assert "Series:" in result.output
|
||||
assert "Anime:" in result.output
|
||||
|
||||
def test_report_summary_with_output_file(self, tmp_path):
|
||||
"""Test summary report generation with output file."""
|
||||
# Create test inventory CSV
|
||||
inventory_file = tmp_path / "inventory.csv"
|
||||
with open(inventory_file, 'w', encoding='utf-8') as f:
|
||||
f.write("# Generated: 2024-01-01T00:00:00\n")
|
||||
f.write("# Library Root: /test/library\n")
|
||||
f.write("path,filename,size_bytes,modified_timestamp,category,resolution,codec,duration_seconds,bitrate_kbps\n")
|
||||
f.write("/test/library/movie/Movie1.mkv,Movie1.mkv,1000000,2024-01-01T00:00:00,movie,,,\n")
|
||||
|
||||
# Run command with output file
|
||||
output_file = tmp_path / "summary_report.txt"
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [
|
||||
'report', 'summary',
|
||||
'--input', str(inventory_file),
|
||||
'--output', str(output_file)
|
||||
])
|
||||
|
||||
# Verify success
|
||||
assert result.exit_code == 0
|
||||
assert output_file.exists()
|
||||
|
||||
# Verify file content
|
||||
with open(output_file, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
assert "Total Files: 1" in content
|
||||
assert "Movie:" in content
|
||||
|
||||
def test_report_inventory_missing_file(self, tmp_path):
|
||||
"""Test inventory report with missing input file."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [
|
||||
'report', 'inventory',
|
||||
'--input', str(tmp_path / "nonexistent.csv")
|
||||
])
|
||||
|
||||
# Verify error (Click validates file existence before our code runs)
|
||||
assert result.exit_code != 0
|
||||
assert "does not exist" in result.output
|
||||
|
||||
def test_report_completeness_missing_file(self, tmp_path):
|
||||
"""Test completeness report with missing input file."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [
|
||||
'report', 'completeness',
|
||||
'--input', str(tmp_path / "nonexistent.json")
|
||||
])
|
||||
|
||||
# Verify error (Click validates file existence before our code runs)
|
||||
assert result.exit_code != 0
|
||||
assert "does not exist" in result.output
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Tests for CLI rollback command."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from vlm.cli import main
|
||||
from vlm.models import FileOperation, OperationResult, RollbackLog
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cli_runner():
|
||||
"""Create a Click CLI test runner."""
|
||||
return CliRunner()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_rollback_log(tmp_path):
|
||||
"""Create a sample rollback log file for testing."""
|
||||
# Create test files
|
||||
source1 = tmp_path / "source1.txt"
|
||||
source2 = tmp_path / "source2.txt"
|
||||
dest1 = tmp_path / "dest1.txt"
|
||||
dest2 = tmp_path / "dest2.txt"
|
||||
|
||||
source1.write_text("content1")
|
||||
source2.write_text("content2")
|
||||
|
||||
# Move files to simulate execution
|
||||
source1.rename(dest1)
|
||||
source2.rename(dest2)
|
||||
|
||||
# Create rollback log
|
||||
operations = [
|
||||
OperationResult(
|
||||
operation=FileOperation(
|
||||
operation_type="move",
|
||||
source_path=source1,
|
||||
destination_path=dest1,
|
||||
reason="test move 1",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
),
|
||||
success=True,
|
||||
error_message=None,
|
||||
executed_at=datetime.now()
|
||||
),
|
||||
OperationResult(
|
||||
operation=FileOperation(
|
||||
operation_type="move",
|
||||
source_path=source2,
|
||||
destination_path=dest2,
|
||||
reason="test move 2",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
),
|
||||
success=True,
|
||||
error_message=None,
|
||||
executed_at=datetime.now()
|
||||
)
|
||||
]
|
||||
|
||||
rollback_log = RollbackLog(
|
||||
log_id="test-log-id",
|
||||
execution_plan_id="test-plan-id",
|
||||
executed_at=datetime.now(),
|
||||
operations=operations
|
||||
)
|
||||
|
||||
# Save rollback log to file
|
||||
log_path = tmp_path / "rollback_test.json"
|
||||
log_data = {
|
||||
"log_id": rollback_log.log_id,
|
||||
"execution_plan_id": rollback_log.execution_plan_id,
|
||||
"executed_at": rollback_log.executed_at.isoformat(),
|
||||
"operations": [
|
||||
{
|
||||
"operation_type": op.operation.operation_type,
|
||||
"source_path": str(op.operation.source_path),
|
||||
"destination_path": str(op.operation.destination_path),
|
||||
"reason": op.operation.reason,
|
||||
"success": op.success,
|
||||
"error_message": op.error_message,
|
||||
"executed_at": op.executed_at.isoformat()
|
||||
}
|
||||
for op in rollback_log.operations
|
||||
]
|
||||
}
|
||||
|
||||
with open(log_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(log_data, f, indent=2)
|
||||
|
||||
return {
|
||||
"log_path": log_path,
|
||||
"source1": source1,
|
||||
"source2": source2,
|
||||
"dest1": dest1,
|
||||
"dest2": dest2
|
||||
}
|
||||
|
||||
|
||||
class TestRollbackCommand:
|
||||
"""Tests for the rollback CLI command."""
|
||||
|
||||
def test_rollback_help(self, cli_runner):
|
||||
"""Test that rollback command shows help text."""
|
||||
result = cli_runner.invoke(main, ['rollback', '--help'])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Rollback previous execution" in result.output
|
||||
assert "--log" in result.output
|
||||
assert "best-effort" in result.output
|
||||
|
||||
def test_rollback_with_log_file(self, cli_runner, sample_rollback_log):
|
||||
"""Test rollback command with explicit log file."""
|
||||
log_path = sample_rollback_log["log_path"]
|
||||
dest1 = sample_rollback_log["dest1"]
|
||||
dest2 = sample_rollback_log["dest2"]
|
||||
source1 = sample_rollback_log["source1"]
|
||||
source2 = sample_rollback_log["source2"]
|
||||
|
||||
# Verify files are at destination before rollback
|
||||
assert dest1.exists()
|
||||
assert dest2.exists()
|
||||
assert not source1.exists()
|
||||
assert not source2.exists()
|
||||
|
||||
# Run rollback command with auto-confirmation
|
||||
result = cli_runner.invoke(
|
||||
main,
|
||||
['rollback', '--log', str(log_path)],
|
||||
input='y\n' # Confirm rollback
|
||||
)
|
||||
|
||||
# Check command succeeded
|
||||
assert result.exit_code == 0
|
||||
assert "Rollback log loaded" in result.output
|
||||
assert "Rolling back" in result.output
|
||||
assert "Rollback Summary" in result.output
|
||||
|
||||
# Verify files were moved back to source
|
||||
assert source1.exists()
|
||||
assert source2.exists()
|
||||
assert not dest1.exists()
|
||||
assert not dest2.exists()
|
||||
|
||||
def test_rollback_cancel_confirmation(self, cli_runner, sample_rollback_log):
|
||||
"""Test that rollback can be cancelled at confirmation prompt."""
|
||||
log_path = sample_rollback_log["log_path"]
|
||||
dest1 = sample_rollback_log["dest1"]
|
||||
dest2 = sample_rollback_log["dest2"]
|
||||
|
||||
# Run rollback command and cancel
|
||||
result = cli_runner.invoke(
|
||||
main,
|
||||
['rollback', '--log', str(log_path)],
|
||||
input='n\n' # Cancel rollback
|
||||
)
|
||||
|
||||
# Check command was cancelled
|
||||
assert result.exit_code == 0
|
||||
assert "Rollback cancelled" in result.output
|
||||
|
||||
# Verify files were NOT moved (still at destination)
|
||||
assert dest1.exists()
|
||||
assert dest2.exists()
|
||||
|
||||
def test_rollback_missing_log_file(self, cli_runner, tmp_path):
|
||||
"""Test rollback command with missing log file."""
|
||||
missing_log = tmp_path / "nonexistent.json"
|
||||
|
||||
result = cli_runner.invoke(
|
||||
main,
|
||||
['rollback', '--log', str(missing_log)]
|
||||
)
|
||||
|
||||
# Check command failed with appropriate error
|
||||
# Click returns exit code 2 for file validation errors
|
||||
assert result.exit_code == 2
|
||||
assert "Error" in result.output or "does not exist" in result.output
|
||||
|
||||
def test_rollback_no_log_specified_no_logs_exist(self, cli_runner, tmp_path, monkeypatch):
|
||||
"""Test rollback command without log file when no logs exist."""
|
||||
# Mock home directory to use tmp_path
|
||||
fake_home = tmp_path / "fake_home"
|
||||
fake_home.mkdir()
|
||||
monkeypatch.setattr(Path, 'home', lambda: fake_home)
|
||||
|
||||
result = cli_runner.invoke(main, ['rollback'])
|
||||
|
||||
# Check command failed with appropriate error
|
||||
assert result.exit_code == 1
|
||||
assert "No rollback logs found" in result.output
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Tests for CLI state commands."""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from vlm.cli import main
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner():
|
||||
"""Create a Click CLI test runner."""
|
||||
return CliRunner()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_state_file(tmp_path, monkeypatch):
|
||||
"""Create a temporary state file and set up environment."""
|
||||
state_dir = tmp_path / ".vlm"
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
state_file = state_dir / "state.json"
|
||||
|
||||
# Mock the home directory to use tmp_path
|
||||
monkeypatch.setattr(Path, 'home', lambda: tmp_path)
|
||||
|
||||
return state_file
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_config(tmp_path):
|
||||
"""Create a temporary config file."""
|
||||
config_dir = tmp_path / ".vlm"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
config_file = config_dir / "config.yaml"
|
||||
|
||||
# Create a minimal config
|
||||
config_content = f"""
|
||||
library_root: {tmp_path / "videos"}
|
||||
video_extensions:
|
||||
- .mp4
|
||||
- .mkv
|
||||
templates:
|
||||
movie_dir: "movie/{{title}} ({{year}})/"
|
||||
series_dir: "series/{{title}}/Season {{season:02d}}/"
|
||||
movie_filename: "{{title}} ({{year}}){{ext}}"
|
||||
series_filename: "S{{season:02d}}E{{episode:02d}}{{ext}}"
|
||||
quarantine_dir: ".quarantine"
|
||||
log_level: "INFO"
|
||||
"""
|
||||
config_file.write_text(config_content)
|
||||
|
||||
return config_file
|
||||
|
||||
|
||||
def test_state_set_and_show(runner, temp_state_file, temp_config):
|
||||
"""Test setting and showing file state."""
|
||||
test_file = Path("/test/movie.mkv")
|
||||
|
||||
# Set state
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(temp_config),
|
||||
'state', 'set', str(test_file),
|
||||
'--status', 'reviewed',
|
||||
'--reason', 'checked manually'
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "State updated" in result.output
|
||||
assert "reviewed" in result.output
|
||||
|
||||
# Verify state file was created
|
||||
assert temp_state_file.exists()
|
||||
|
||||
# Show state
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(temp_config),
|
||||
'state', 'show', str(test_file)
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "reviewed" in result.output
|
||||
assert "checked manually" in result.output
|
||||
|
||||
|
||||
def test_state_query(runner, temp_state_file, temp_config):
|
||||
"""Test querying files by status."""
|
||||
test_files = [
|
||||
Path("/test/movie1.mkv"),
|
||||
Path("/test/movie2.mkv"),
|
||||
Path("/test/movie3.mkv")
|
||||
]
|
||||
|
||||
# Set states for multiple files
|
||||
for i, test_file in enumerate(test_files):
|
||||
status = 'ignored' if i < 2 else 'reviewed'
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(temp_config),
|
||||
'state', 'set', str(test_file),
|
||||
'--status', status
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Query for ignored files
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(temp_config),
|
||||
'state', 'query',
|
||||
'--status', 'ignored'
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "movie1.mkv" in result.output
|
||||
assert "movie2.mkv" in result.output
|
||||
assert "movie3.mkv" not in result.output
|
||||
assert "Total: 2 file(s)" in result.output
|
||||
|
||||
|
||||
def test_state_clear(runner, temp_state_file, temp_config):
|
||||
"""Test clearing file state."""
|
||||
test_file = Path("/test/movie.mkv")
|
||||
|
||||
# Set state
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(temp_config),
|
||||
'state', 'set', str(test_file),
|
||||
'--status', 'reviewed'
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Clear state
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(temp_config),
|
||||
'state', 'clear', str(test_file)
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "State cleared" in result.output
|
||||
|
||||
# Verify state is cleared
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(temp_config),
|
||||
'state', 'show', str(test_file)
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "No state found" in result.output
|
||||
|
||||
|
||||
def test_state_set_invalid_status(runner, temp_state_file, temp_config):
|
||||
"""Test setting state with invalid status."""
|
||||
test_file = Path("/test/movie.mkv")
|
||||
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(temp_config),
|
||||
'state', 'set', str(test_file),
|
||||
'--status', 'invalid_status'
|
||||
])
|
||||
|
||||
# Should fail due to invalid choice
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
def test_state_show_nonexistent(runner, temp_state_file, temp_config):
|
||||
"""Test showing state for file that doesn't have state."""
|
||||
test_file = Path("/test/nonexistent.mkv")
|
||||
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(temp_config),
|
||||
'state', 'show', str(test_file)
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "No state found" in result.output
|
||||
|
||||
|
||||
def test_state_clear_nonexistent(runner, temp_state_file, temp_config):
|
||||
"""Test clearing state for file that doesn't have state."""
|
||||
test_file = Path("/test/nonexistent.mkv")
|
||||
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(temp_config),
|
||||
'state', 'clear', str(test_file)
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "No state found" in result.output
|
||||
assert "Nothing to clear" in result.output
|
||||
|
||||
|
||||
def test_state_query_empty(runner, temp_state_file, temp_config):
|
||||
"""Test querying when no files have the status."""
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(temp_config),
|
||||
'state', 'query',
|
||||
'--status', 'quarantined'
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "No files found" in result.output
|
||||
|
||||
|
||||
def test_state_set_idempotent(runner, temp_state_file, temp_config):
|
||||
"""Test that setting state multiple times is idempotent."""
|
||||
test_file = Path("/test/movie.mkv")
|
||||
|
||||
# Set state first time
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(temp_config),
|
||||
'state', 'set', str(test_file),
|
||||
'--status', 'reviewed',
|
||||
'--reason', 'first check'
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Set state second time with different reason
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(temp_config),
|
||||
'state', 'set', str(test_file),
|
||||
'--status', 'reviewed',
|
||||
'--reason', 'second check'
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Verify the reason was updated
|
||||
result = runner.invoke(main, [
|
||||
'--config', str(temp_config),
|
||||
'state', 'show', str(test_file)
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "second check" in result.output
|
||||
assert "first check" not in result.output
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Unit tests for Configuration Manager."""
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from vlm.config import Config, load_config, create_default_config, validate_config
|
||||
|
||||
|
||||
class TestConfig:
|
||||
"""Test Config dataclass."""
|
||||
|
||||
def test_config_creation_with_defaults(self):
|
||||
"""Test creating Config with default values."""
|
||||
config = Config(library_root=Path("/mnt/nas/videos"))
|
||||
|
||||
assert config.library_root == Path("/mnt/nas/videos")
|
||||
assert ".mp4" in config.video_extensions
|
||||
assert ".mkv" in config.video_extensions
|
||||
assert config.movie_template == "movie/{title} ({year})/"
|
||||
assert config.series_template == "series/{title}/Season {season:02d}/"
|
||||
assert config.log_level == "INFO"
|
||||
assert config.quarantine_dir == ".quarantine"
|
||||
|
||||
def test_config_creation_with_custom_values(self):
|
||||
"""Test creating Config with custom values."""
|
||||
config = Config(
|
||||
library_root=Path("/custom/path"),
|
||||
video_extensions=[".mp4", ".avi"],
|
||||
movie_template="movies/{title}-{year}/",
|
||||
log_level="DEBUG"
|
||||
)
|
||||
|
||||
assert config.library_root == Path("/custom/path")
|
||||
assert config.video_extensions == [".mp4", ".avi"]
|
||||
assert config.movie_template == "movies/{title}-{year}/"
|
||||
assert config.log_level == "DEBUG"
|
||||
|
||||
|
||||
class TestLoadConfig:
|
||||
"""Test load_config function."""
|
||||
|
||||
def test_load_config_success(self, tmp_path):
|
||||
"""Test loading valid configuration file."""
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_data = {
|
||||
'library_root': '/mnt/nas/videos',
|
||||
'video_extensions': ['.mp4', '.mkv', '.avi'],
|
||||
'templates': {
|
||||
'movie_dir': 'movie/{title} ({year})/',
|
||||
'series_dir': 'series/{title}/Season {season:02d}/',
|
||||
'movie_filename': '{title} ({year}){ext}',
|
||||
'series_filename': 'S{season:02d}E{episode:02d}{ext}'
|
||||
},
|
||||
'quarantine_dir': '.quarantine',
|
||||
'log_level': 'DEBUG'
|
||||
}
|
||||
|
||||
with open(config_file, 'w') as f:
|
||||
yaml.dump(config_data, f)
|
||||
|
||||
config = load_config(config_file)
|
||||
|
||||
assert config.library_root == Path('/mnt/nas/videos')
|
||||
assert config.video_extensions == ['.mp4', '.mkv', '.avi']
|
||||
assert config.movie_template == 'movie/{title} ({year})/'
|
||||
assert config.series_template == 'series/{title}/Season {season:02d}/'
|
||||
assert config.log_level == 'DEBUG'
|
||||
assert config.quarantine_dir == '.quarantine'
|
||||
|
||||
def test_load_config_with_home_directory(self, tmp_path):
|
||||
"""Test loading config with ~ in library_root."""
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_data = {
|
||||
'library_root': '~/Videos',
|
||||
'video_extensions': ['.mp4']
|
||||
}
|
||||
|
||||
with open(config_file, 'w') as f:
|
||||
yaml.dump(config_data, f)
|
||||
|
||||
config = load_config(config_file)
|
||||
|
||||
# Should expand ~ to home directory
|
||||
assert config.library_root == Path.home() / "Videos"
|
||||
|
||||
def test_load_config_missing_file(self, tmp_path):
|
||||
"""Test loading non-existent configuration file."""
|
||||
config_file = tmp_path / "nonexistent.yaml"
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_config(config_file)
|
||||
|
||||
def test_load_config_invalid_yaml(self, tmp_path):
|
||||
"""Test loading configuration with invalid YAML syntax."""
|
||||
config_file = tmp_path / "config.yaml"
|
||||
|
||||
with open(config_file, 'w') as f:
|
||||
f.write("invalid: yaml: syntax: [unclosed")
|
||||
|
||||
with pytest.raises(yaml.YAMLError):
|
||||
load_config(config_file)
|
||||
|
||||
def test_load_config_missing_library_root(self, tmp_path):
|
||||
"""Test loading configuration without library_root."""
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_data = {
|
||||
'video_extensions': ['.mp4']
|
||||
}
|
||||
|
||||
with open(config_file, 'w') as f:
|
||||
yaml.dump(config_data, f)
|
||||
|
||||
with pytest.raises(ValueError, match="library_root"):
|
||||
load_config(config_file)
|
||||
|
||||
def test_load_config_empty_file(self, tmp_path):
|
||||
"""Test loading empty configuration file."""
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text("")
|
||||
|
||||
with pytest.raises(ValueError, match="library_root"):
|
||||
load_config(config_file)
|
||||
|
||||
def test_load_config_with_defaults(self, tmp_path):
|
||||
"""Test loading config with minimal settings uses defaults."""
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_data = {
|
||||
'library_root': '/mnt/nas/videos'
|
||||
}
|
||||
|
||||
with open(config_file, 'w') as f:
|
||||
yaml.dump(config_data, f)
|
||||
|
||||
config = load_config(config_file)
|
||||
|
||||
# Should use default values for missing fields
|
||||
assert config.library_root == Path('/mnt/nas/videos')
|
||||
assert ".mp4" in config.video_extensions
|
||||
assert config.movie_template == "movie/{title} ({year})/"
|
||||
assert config.log_level == "INFO"
|
||||
|
||||
|
||||
class TestCreateDefaultConfig:
|
||||
"""Test create_default_config function."""
|
||||
|
||||
def test_create_default_config(self, tmp_path):
|
||||
"""Test creating default configuration file."""
|
||||
config_file = tmp_path / "config.yaml"
|
||||
|
||||
config = create_default_config(config_file)
|
||||
|
||||
# Check returned config object
|
||||
assert config.library_root == Path.home() / "Videos"
|
||||
assert ".mp4" in config.video_extensions
|
||||
assert ".mkv" in config.video_extensions
|
||||
assert config.movie_template == "movie/{title} ({year})/"
|
||||
assert config.series_template == "series/{title}/Season {season:02d}/"
|
||||
assert config.log_level == "INFO"
|
||||
assert config.quarantine_dir == ".quarantine"
|
||||
|
||||
# Check file was created
|
||||
assert config_file.exists()
|
||||
|
||||
# Check file content
|
||||
with open(config_file, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
assert 'library_root' in data
|
||||
assert 'video_extensions' in data
|
||||
assert 'templates' in data
|
||||
assert 'log_level' in data
|
||||
assert 'quarantine_dir' in data
|
||||
|
||||
def test_create_default_config_creates_parent_dirs(self, tmp_path):
|
||||
"""Test that create_default_config creates parent directories."""
|
||||
config_file = tmp_path / "subdir" / "config.yaml"
|
||||
|
||||
config = create_default_config(config_file)
|
||||
|
||||
assert config_file.exists()
|
||||
assert config_file.parent.exists()
|
||||
|
||||
def test_create_default_config_is_loadable(self, tmp_path):
|
||||
"""Test that created default config can be loaded."""
|
||||
config_file = tmp_path / "config.yaml"
|
||||
|
||||
created_config = create_default_config(config_file)
|
||||
loaded_config = load_config(config_file)
|
||||
|
||||
# Configs should be equivalent
|
||||
assert loaded_config.library_root == created_config.library_root
|
||||
assert loaded_config.video_extensions == created_config.video_extensions
|
||||
assert loaded_config.movie_template == created_config.movie_template
|
||||
assert loaded_config.log_level == created_config.log_level
|
||||
|
||||
|
||||
class TestValidateConfig:
|
||||
"""Test validate_config function."""
|
||||
|
||||
def test_validate_valid_config(self):
|
||||
"""Test validating a valid configuration."""
|
||||
config = Config(library_root=Path("/mnt/nas/videos"))
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
assert errors == []
|
||||
|
||||
def test_validate_empty_library_root(self):
|
||||
"""Test validating config with empty library_root."""
|
||||
config = Config(library_root=Path(""))
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
assert len(errors) > 0
|
||||
assert any("library_root" in err for err in errors)
|
||||
|
||||
def test_validate_empty_video_extensions(self):
|
||||
"""Test validating config with empty video_extensions."""
|
||||
config = Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
video_extensions=[]
|
||||
)
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
assert len(errors) > 0
|
||||
assert any("video_extensions" in err for err in errors)
|
||||
|
||||
def test_validate_invalid_video_extension_format(self):
|
||||
"""Test validating config with invalid video extension format."""
|
||||
config = Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
video_extensions=["mp4", ".mkv"] # Missing dot on first one
|
||||
)
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
assert len(errors) > 0
|
||||
assert any("must start with '.'" in err for err in errors)
|
||||
|
||||
def test_validate_empty_templates(self):
|
||||
"""Test validating config with empty templates."""
|
||||
config = Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
movie_template="",
|
||||
series_template=""
|
||||
)
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
assert len(errors) >= 2
|
||||
assert any("movie_template" in err for err in errors)
|
||||
assert any("series_template" in err for err in errors)
|
||||
|
||||
def test_validate_invalid_log_level(self):
|
||||
"""Test validating config with invalid log level."""
|
||||
config = Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
log_level="INVALID"
|
||||
)
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
assert len(errors) > 0
|
||||
assert any("log_level" in err for err in errors)
|
||||
|
||||
def test_validate_valid_log_levels(self):
|
||||
"""Test validating config with all valid log levels."""
|
||||
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||
|
||||
for level in valid_levels:
|
||||
config = Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
log_level=level
|
||||
)
|
||||
errors = validate_config(config)
|
||||
assert errors == [], f"Log level {level} should be valid"
|
||||
|
||||
def test_validate_absolute_quarantine_dir(self):
|
||||
"""Test validating config with absolute quarantine_dir."""
|
||||
config = Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
quarantine_dir="/absolute/path"
|
||||
)
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
assert len(errors) > 0
|
||||
assert any("must be relative" in err for err in errors)
|
||||
|
||||
def test_validate_empty_quarantine_dir(self):
|
||||
"""Test validating config with empty quarantine_dir."""
|
||||
config = Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
quarantine_dir=""
|
||||
)
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
assert len(errors) > 0
|
||||
assert any("quarantine_dir" in err for err in errors)
|
||||
|
||||
def test_validate_multiple_errors(self):
|
||||
"""Test validating config with multiple errors."""
|
||||
config = Config(
|
||||
library_root=Path(""),
|
||||
video_extensions=[],
|
||||
movie_template="",
|
||||
log_level="INVALID"
|
||||
)
|
||||
|
||||
errors = validate_config(config)
|
||||
|
||||
# Should have multiple errors
|
||||
assert len(errors) >= 4
|
||||
|
||||
|
||||
class TestConfigIntegration:
|
||||
"""Integration tests for configuration workflow."""
|
||||
|
||||
def test_missing_config_workflow(self, tmp_path):
|
||||
"""Test workflow: missing config -> create default -> load."""
|
||||
config_file = tmp_path / "config.yaml"
|
||||
|
||||
# Config doesn't exist
|
||||
assert not config_file.exists()
|
||||
|
||||
# Try to load, should raise FileNotFoundError
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_config(config_file)
|
||||
|
||||
# Create default config
|
||||
default_config = create_default_config(config_file)
|
||||
|
||||
# Now file exists
|
||||
assert config_file.exists()
|
||||
|
||||
# Load the created config
|
||||
loaded_config = load_config(config_file)
|
||||
|
||||
# Should match default
|
||||
assert loaded_config.library_root == default_config.library_root
|
||||
assert loaded_config.video_extensions == default_config.video_extensions
|
||||
|
||||
def test_invalid_yaml_workflow(self, tmp_path):
|
||||
"""Test workflow: invalid YAML -> report error -> use defaults."""
|
||||
config_file = tmp_path / "config.yaml"
|
||||
|
||||
# Create invalid YAML
|
||||
with open(config_file, 'w') as f:
|
||||
f.write("invalid: yaml: [unclosed")
|
||||
|
||||
# Try to load, should raise YAMLError
|
||||
with pytest.raises(yaml.YAMLError):
|
||||
load_config(config_file)
|
||||
|
||||
# In real usage, caller would catch this and create default
|
||||
default_config = create_default_config(config_file)
|
||||
|
||||
# Now should be loadable
|
||||
loaded_config = load_config(config_file)
|
||||
assert loaded_config.library_root == default_config.library_root
|
||||
|
||||
def test_validation_workflow(self, tmp_path):
|
||||
"""Test workflow: load config -> validate -> report errors."""
|
||||
config_file = tmp_path / "config.yaml"
|
||||
|
||||
# Create config with some invalid values
|
||||
config_data = {
|
||||
'library_root': '/mnt/nas/videos',
|
||||
'video_extensions': ['mp4', '.mkv'], # First one missing dot
|
||||
'log_level': 'INVALID'
|
||||
}
|
||||
|
||||
with open(config_file, 'w') as f:
|
||||
yaml.dump(config_data, f)
|
||||
|
||||
# Load config
|
||||
config = load_config(config_file)
|
||||
|
||||
# Validate
|
||||
errors = validate_config(config)
|
||||
|
||||
# Should have errors
|
||||
assert len(errors) > 0
|
||||
assert any("must start with '.'" in err for err in errors)
|
||||
assert any("log_level" in err for err in errors)
|
||||
@@ -0,0 +1,906 @@
|
||||
"""Unit tests for execution engine.
|
||||
|
||||
Tests execution mode handling, dry-run simulation, and actual file operations.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from src.vlm.executor import ExecutionEngine
|
||||
from src.vlm.models import ExecutionPlan, FileOperation
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_test_dir(tmp_path):
|
||||
"""Create a temporary test directory structure."""
|
||||
# Create source directory with test files
|
||||
source_dir = tmp_path / "source"
|
||||
source_dir.mkdir()
|
||||
|
||||
# Create test files
|
||||
test_file1 = source_dir / "test1.mp4"
|
||||
test_file1.write_text("test content 1")
|
||||
|
||||
test_file2 = source_dir / "test2.mkv"
|
||||
test_file2.write_text("test content 2")
|
||||
|
||||
# Create destination directory
|
||||
dest_dir = tmp_path / "dest"
|
||||
dest_dir.mkdir()
|
||||
|
||||
return {
|
||||
"source_dir": source_dir,
|
||||
"dest_dir": dest_dir,
|
||||
"test_file1": test_file1,
|
||||
"test_file2": test_file2,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def execution_engine():
|
||||
"""Create an execution engine instance with test logger."""
|
||||
logger = logging.getLogger("test_executor")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
return ExecutionEngine(logger=logger)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_plan(temp_test_dir):
|
||||
"""Create a sample execution plan."""
|
||||
operations = [
|
||||
FileOperation(
|
||||
operation_type="move",
|
||||
source_path=temp_test_dir["test_file1"],
|
||||
destination_path=temp_test_dir["dest_dir"] / "moved1.mp4",
|
||||
reason="Organize movie",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
),
|
||||
FileOperation(
|
||||
operation_type="rename",
|
||||
source_path=temp_test_dir["test_file2"],
|
||||
destination_path=temp_test_dir["dest_dir"] / "renamed2.mkv",
|
||||
reason="Rename series episode",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
),
|
||||
]
|
||||
|
||||
return ExecutionPlan(
|
||||
plan_id=str(uuid4()),
|
||||
created_at=datetime.now(),
|
||||
operations=operations,
|
||||
summary={"move": 1, "rename": 1}
|
||||
)
|
||||
|
||||
|
||||
class TestExecutionModeHandling:
|
||||
"""Tests for execution mode parameter handling."""
|
||||
|
||||
def test_dry_run_mode_default(self, execution_engine, sample_plan, temp_test_dir):
|
||||
"""Test that dry-run is the default mode."""
|
||||
results, summary, rollback_log = execution_engine.execute_plan(sample_plan)
|
||||
|
||||
# All operations should succeed in dry-run
|
||||
assert all(r.success for r in results)
|
||||
assert len(results) == 2
|
||||
|
||||
# Check execution summary
|
||||
assert summary["successful"] == 2
|
||||
assert summary["failed"] == 0
|
||||
assert summary["skipped"] == 0
|
||||
assert summary["total"] == 2
|
||||
|
||||
# No rollback log in dry-run mode
|
||||
assert rollback_log is None
|
||||
|
||||
# Files should not be moved (dry-run doesn't modify files)
|
||||
assert temp_test_dir["test_file1"].exists()
|
||||
assert temp_test_dir["test_file2"].exists()
|
||||
assert not (temp_test_dir["dest_dir"] / "moved1.mp4").exists()
|
||||
assert not (temp_test_dir["dest_dir"] / "renamed2.mkv").exists()
|
||||
|
||||
def test_dry_run_mode_explicit(self, execution_engine, sample_plan, temp_test_dir):
|
||||
"""Test explicit dry-run mode parameter."""
|
||||
results, summary, rollback_log = execution_engine.execute_plan(
|
||||
sample_plan,
|
||||
mode="dry-run"
|
||||
)
|
||||
|
||||
# All operations should succeed in dry-run
|
||||
assert all(r.success for r in results)
|
||||
|
||||
# Check execution summary
|
||||
assert summary["successful"] == 2
|
||||
assert summary["total"] == 2
|
||||
|
||||
# No rollback log in dry-run mode
|
||||
assert rollback_log is None
|
||||
|
||||
# Files should not be moved
|
||||
assert temp_test_dir["test_file1"].exists()
|
||||
assert not (temp_test_dir["dest_dir"] / "moved1.mp4").exists()
|
||||
|
||||
def test_execute_mode_requires_confirmation(self, execution_engine, sample_plan):
|
||||
"""Test that execute mode requires explicit confirmation."""
|
||||
with pytest.raises(ValueError, match="requires explicit confirmation"):
|
||||
execution_engine.execute_plan(sample_plan, mode="execute")
|
||||
|
||||
def test_execute_mode_with_confirmation(self, execution_engine, sample_plan, temp_test_dir):
|
||||
"""Test execute mode with explicit confirmation."""
|
||||
results, summary, rollback_log = execution_engine.execute_plan(
|
||||
sample_plan,
|
||||
mode="execute",
|
||||
confirmed=True
|
||||
)
|
||||
|
||||
# All operations should succeed
|
||||
assert all(r.success for r in results)
|
||||
assert len(results) == 2
|
||||
|
||||
# Check execution summary
|
||||
assert summary["successful"] == 2
|
||||
assert summary["failed"] == 0
|
||||
assert summary["total"] == 2
|
||||
|
||||
# Rollback log should be created in execute mode
|
||||
assert rollback_log is not None
|
||||
assert rollback_log.execution_plan_id == sample_plan.plan_id
|
||||
assert len(rollback_log.operations) == 2
|
||||
|
||||
# Files should be moved
|
||||
assert not temp_test_dir["test_file1"].exists()
|
||||
assert not temp_test_dir["test_file2"].exists()
|
||||
assert (temp_test_dir["dest_dir"] / "moved1.mp4").exists()
|
||||
assert (temp_test_dir["dest_dir"] / "renamed2.mkv").exists()
|
||||
|
||||
def test_invalid_mode_raises_error(self, execution_engine, sample_plan):
|
||||
"""Test that invalid mode parameter raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Invalid mode"):
|
||||
execution_engine.execute_plan(sample_plan, mode="invalid")
|
||||
|
||||
|
||||
class TestDryRunSimulation:
|
||||
"""Tests for dry-run mode simulation."""
|
||||
|
||||
def test_dry_run_logs_operations(self, execution_engine, sample_plan, caplog):
|
||||
"""Test that dry-run mode logs what would happen."""
|
||||
caplog.set_level(logging.INFO)
|
||||
|
||||
execution_engine.execute_plan(sample_plan, mode="dry-run")
|
||||
|
||||
# Check that dry-run operations are logged
|
||||
assert "[DRY-RUN]" in caplog.text
|
||||
assert "Would move" in caplog.text or "Would rename" in caplog.text
|
||||
|
||||
def test_dry_run_never_modifies_files(self, execution_engine, sample_plan, temp_test_dir):
|
||||
"""Test that dry-run mode never modifies the file system."""
|
||||
# Record initial state
|
||||
initial_files = list(temp_test_dir["source_dir"].iterdir())
|
||||
|
||||
# Execute in dry-run mode
|
||||
results, summary, rollback_log = execution_engine.execute_plan(sample_plan, mode="dry-run")
|
||||
|
||||
# Verify no files were moved or modified
|
||||
final_files = list(temp_test_dir["source_dir"].iterdir())
|
||||
assert set(initial_files) == set(final_files)
|
||||
|
||||
# Verify destination directory is still empty
|
||||
dest_files = list(temp_test_dir["dest_dir"].iterdir())
|
||||
assert len(dest_files) == 0
|
||||
|
||||
def test_dry_run_handles_no_op_operations(self, execution_engine, temp_test_dir):
|
||||
"""Test that dry-run mode handles no-op operations correctly."""
|
||||
no_op_operation = FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=temp_test_dir["test_file1"],
|
||||
destination_path=None,
|
||||
reason="Anime file - not organized in v1",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
plan = ExecutionPlan(
|
||||
plan_id=str(uuid4()),
|
||||
created_at=datetime.now(),
|
||||
operations=[no_op_operation],
|
||||
summary={"no-op": 1}
|
||||
)
|
||||
|
||||
results, summary, _ = execution_engine.execute_plan(plan, mode="dry-run")
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].success
|
||||
assert results[0].operation.operation_type == "no-op"
|
||||
assert summary["skipped"] == 1
|
||||
|
||||
def test_dry_run_handles_conflicts(self, execution_engine, temp_test_dir):
|
||||
"""Test that dry-run mode handles conflicted operations."""
|
||||
conflicted_operation = FileOperation(
|
||||
operation_type="move",
|
||||
source_path=temp_test_dir["test_file1"],
|
||||
destination_path=temp_test_dir["dest_dir"] / "conflict.mp4",
|
||||
reason="Organize movie",
|
||||
has_conflict=True,
|
||||
conflict_reason="Destination file already exists"
|
||||
)
|
||||
|
||||
plan = ExecutionPlan(
|
||||
plan_id=str(uuid4()),
|
||||
created_at=datetime.now(),
|
||||
operations=[conflicted_operation],
|
||||
summary={"move": 1}
|
||||
)
|
||||
|
||||
results, summary, _ = execution_engine.execute_plan(plan, mode="dry-run")
|
||||
|
||||
assert len(results) == 1
|
||||
assert not results[0].success
|
||||
assert "Conflict" in results[0].error_message
|
||||
assert summary["failed"] == 1
|
||||
assert summary["skipped"] == 1 # Conflicts are also counted as skipped
|
||||
|
||||
|
||||
class TestExecuteMode:
|
||||
"""Tests for execute mode with actual file operations."""
|
||||
|
||||
def test_execute_mode_moves_files(self, execution_engine, sample_plan, temp_test_dir):
|
||||
"""Test that execute mode actually moves files."""
|
||||
results, summary, rollback_log = execution_engine.execute_plan(
|
||||
sample_plan,
|
||||
mode="execute",
|
||||
confirmed=True
|
||||
)
|
||||
|
||||
# Verify operations succeeded
|
||||
assert all(r.success for r in results)
|
||||
assert summary["successful"] == 2
|
||||
|
||||
# Verify files were moved
|
||||
assert not temp_test_dir["test_file1"].exists()
|
||||
assert (temp_test_dir["dest_dir"] / "moved1.mp4").exists()
|
||||
|
||||
# Verify file content is preserved
|
||||
content = (temp_test_dir["dest_dir"] / "moved1.mp4").read_text()
|
||||
assert content == "test content 1"
|
||||
|
||||
def test_execute_mode_creates_directories(self, execution_engine, temp_test_dir):
|
||||
"""Test that execute mode creates destination directories."""
|
||||
nested_dest = temp_test_dir["dest_dir"] / "subdir1" / "subdir2" / "file.mp4"
|
||||
|
||||
operation = FileOperation(
|
||||
operation_type="move",
|
||||
source_path=temp_test_dir["test_file1"],
|
||||
destination_path=nested_dest,
|
||||
reason="Organize with nested structure",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
plan = ExecutionPlan(
|
||||
plan_id=str(uuid4()),
|
||||
created_at=datetime.now(),
|
||||
operations=[operation],
|
||||
summary={"move": 1}
|
||||
)
|
||||
|
||||
results, summary, _ = execution_engine.execute_plan(
|
||||
plan,
|
||||
mode="execute",
|
||||
confirmed=True
|
||||
)
|
||||
|
||||
# Verify operation succeeded
|
||||
assert results[0].success
|
||||
assert summary["successful"] == 1
|
||||
|
||||
# Verify nested directories were created
|
||||
assert nested_dest.exists()
|
||||
assert nested_dest.parent.exists()
|
||||
|
||||
def test_execute_mode_handles_missing_source(self, execution_engine, temp_test_dir):
|
||||
"""Test that execute mode handles missing source files gracefully."""
|
||||
missing_file = temp_test_dir["source_dir"] / "nonexistent.mp4"
|
||||
|
||||
operation = FileOperation(
|
||||
operation_type="move",
|
||||
source_path=missing_file,
|
||||
destination_path=temp_test_dir["dest_dir"] / "dest.mp4",
|
||||
reason="Move nonexistent file",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
plan = ExecutionPlan(
|
||||
plan_id=str(uuid4()),
|
||||
created_at=datetime.now(),
|
||||
operations=[operation],
|
||||
summary={"move": 1}
|
||||
)
|
||||
|
||||
results, summary, _ = execution_engine.execute_plan(
|
||||
plan,
|
||||
mode="execute",
|
||||
confirmed=True
|
||||
)
|
||||
|
||||
# Operation should fail gracefully
|
||||
assert not results[0].success
|
||||
assert "does not exist" in results[0].error_message
|
||||
assert summary["failed"] == 1
|
||||
|
||||
def test_execute_mode_skips_conflicts(self, execution_engine, temp_test_dir):
|
||||
"""Test that execute mode skips conflicted operations."""
|
||||
# Create a file at the destination
|
||||
dest_file = temp_test_dir["dest_dir"] / "existing.mp4"
|
||||
dest_file.write_text("existing content")
|
||||
|
||||
conflicted_operation = FileOperation(
|
||||
operation_type="move",
|
||||
source_path=temp_test_dir["test_file1"],
|
||||
destination_path=dest_file,
|
||||
reason="Move to existing location",
|
||||
has_conflict=True,
|
||||
conflict_reason="Destination file already exists"
|
||||
)
|
||||
|
||||
plan = ExecutionPlan(
|
||||
plan_id=str(uuid4()),
|
||||
created_at=datetime.now(),
|
||||
operations=[conflicted_operation],
|
||||
summary={"move": 1}
|
||||
)
|
||||
|
||||
results, summary, _ = execution_engine.execute_plan(
|
||||
plan,
|
||||
mode="execute",
|
||||
confirmed=True
|
||||
)
|
||||
|
||||
# Operation should be skipped
|
||||
assert not results[0].success
|
||||
assert "Conflict" in results[0].error_message
|
||||
assert summary["failed"] == 1
|
||||
assert summary["skipped"] == 1
|
||||
|
||||
# Source file should still exist
|
||||
assert temp_test_dir["test_file1"].exists()
|
||||
|
||||
# Destination file should be unchanged
|
||||
assert dest_file.read_text() == "existing content"
|
||||
|
||||
|
||||
class TestRollbackLog:
|
||||
"""Tests for rollback log creation."""
|
||||
|
||||
def test_rollback_log_created_in_execute_mode(self, execution_engine, sample_plan):
|
||||
"""Test that rollback log is created in execute mode."""
|
||||
results, summary, rollback_log = execution_engine.execute_plan(
|
||||
sample_plan,
|
||||
mode="execute",
|
||||
confirmed=True
|
||||
)
|
||||
|
||||
assert rollback_log is not None
|
||||
assert rollback_log.log_id is not None
|
||||
assert rollback_log.execution_plan_id == sample_plan.plan_id
|
||||
assert len(rollback_log.operations) == 2
|
||||
|
||||
def test_rollback_log_not_created_in_dry_run(self, execution_engine, sample_plan):
|
||||
"""Test that rollback log is not created in dry-run mode."""
|
||||
results, summary, rollback_log = execution_engine.execute_plan(
|
||||
sample_plan,
|
||||
mode="dry-run"
|
||||
)
|
||||
|
||||
assert rollback_log is None
|
||||
|
||||
def test_rollback_log_only_includes_successful_operations(
|
||||
self, execution_engine, temp_test_dir
|
||||
):
|
||||
"""Test that rollback log only includes successful operations."""
|
||||
# Create a plan with one successful and one failed operation
|
||||
operations = [
|
||||
FileOperation(
|
||||
operation_type="move",
|
||||
source_path=temp_test_dir["test_file1"],
|
||||
destination_path=temp_test_dir["dest_dir"] / "success.mp4",
|
||||
reason="This will succeed",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
),
|
||||
FileOperation(
|
||||
operation_type="move",
|
||||
source_path=temp_test_dir["source_dir"] / "nonexistent.mp4",
|
||||
destination_path=temp_test_dir["dest_dir"] / "fail.mp4",
|
||||
reason="This will fail",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
),
|
||||
]
|
||||
|
||||
plan = ExecutionPlan(
|
||||
plan_id=str(uuid4()),
|
||||
created_at=datetime.now(),
|
||||
operations=operations,
|
||||
summary={"move": 2}
|
||||
)
|
||||
|
||||
results, summary, rollback_log = execution_engine.execute_plan(
|
||||
plan,
|
||||
mode="execute",
|
||||
confirmed=True
|
||||
)
|
||||
|
||||
# Rollback log should only include the successful operation
|
||||
assert rollback_log is not None
|
||||
assert len(rollback_log.operations) == 1
|
||||
assert rollback_log.operations[0].success
|
||||
|
||||
|
||||
class TestExecutionSummary:
|
||||
"""Tests for execution summary logging."""
|
||||
|
||||
def test_execution_summary_counts(self, execution_engine, temp_test_dir, caplog):
|
||||
"""Test that execution summary includes correct counts."""
|
||||
caplog.set_level(logging.INFO)
|
||||
|
||||
operations = [
|
||||
FileOperation(
|
||||
operation_type="move",
|
||||
source_path=temp_test_dir["test_file1"],
|
||||
destination_path=temp_test_dir["dest_dir"] / "success.mp4",
|
||||
reason="Successful move",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
),
|
||||
FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=temp_test_dir["test_file2"],
|
||||
destination_path=None,
|
||||
reason="Anime file",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
),
|
||||
FileOperation(
|
||||
operation_type="move",
|
||||
source_path=temp_test_dir["source_dir"] / "missing.mp4",
|
||||
destination_path=temp_test_dir["dest_dir"] / "fail.mp4",
|
||||
reason="This will fail",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
),
|
||||
]
|
||||
|
||||
plan = ExecutionPlan(
|
||||
plan_id=str(uuid4()),
|
||||
created_at=datetime.now(),
|
||||
operations=operations,
|
||||
summary={"move": 2, "no-op": 1}
|
||||
)
|
||||
|
||||
results, summary, rollback_log = execution_engine.execute_plan(plan, mode="execute", confirmed=True)
|
||||
|
||||
# Check summary structure
|
||||
assert summary["successful"] == 2 # 1 successful move + 1 no-op
|
||||
assert summary["failed"] == 1 # 1 failed move
|
||||
assert summary["skipped"] == 1 # 1 no-op
|
||||
assert summary["total"] == 3
|
||||
|
||||
# Check summary in logs
|
||||
assert "Execution summary" in caplog.text
|
||||
assert "successful" in caplog.text
|
||||
assert "failed" in caplog.text
|
||||
assert "skipped" in caplog.text
|
||||
|
||||
|
||||
class TestRollbackLogSaving:
|
||||
"""Tests for saving rollback logs to disk."""
|
||||
|
||||
def test_save_rollback_log_creates_file(self, execution_engine, sample_plan, tmp_path):
|
||||
"""Test that save_rollback_log creates a JSON file."""
|
||||
# Execute plan to get rollback log
|
||||
results, summary, rollback_log = execution_engine.execute_plan(
|
||||
sample_plan,
|
||||
mode="execute",
|
||||
confirmed=True
|
||||
)
|
||||
|
||||
# Save rollback log
|
||||
output_path = tmp_path / "rollback" / "test_rollback.json"
|
||||
execution_engine.save_rollback_log(rollback_log, output_path)
|
||||
|
||||
# Verify file was created
|
||||
assert output_path.exists()
|
||||
assert output_path.is_file()
|
||||
|
||||
def test_save_rollback_log_json_structure(self, execution_engine, sample_plan, tmp_path):
|
||||
"""Test that saved rollback log has correct JSON structure."""
|
||||
# Execute plan to get rollback log
|
||||
results, summary, rollback_log = execution_engine.execute_plan(
|
||||
sample_plan,
|
||||
mode="execute",
|
||||
confirmed=True
|
||||
)
|
||||
|
||||
# Save rollback log
|
||||
output_path = tmp_path / "rollback" / "test_rollback.json"
|
||||
execution_engine.save_rollback_log(rollback_log, output_path)
|
||||
|
||||
# Load and verify JSON structure
|
||||
import json
|
||||
with open(output_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Check required fields
|
||||
assert "log_id" in data
|
||||
assert "execution_plan_id" in data
|
||||
assert "executed_at" in data
|
||||
assert "operations" in data
|
||||
|
||||
# Check operations structure
|
||||
assert len(data["operations"]) == 2
|
||||
for op in data["operations"]:
|
||||
assert "operation_type" in op
|
||||
assert "source_path" in op
|
||||
assert "destination_path" in op
|
||||
assert "reason" in op
|
||||
assert "success" in op
|
||||
assert "executed_at" in op
|
||||
|
||||
def test_save_rollback_log_includes_timestamps(self, execution_engine, sample_plan, tmp_path):
|
||||
"""Test that rollback log includes ISO format timestamps."""
|
||||
# Execute plan to get rollback log
|
||||
results, summary, rollback_log = execution_engine.execute_plan(
|
||||
sample_plan,
|
||||
mode="execute",
|
||||
confirmed=True
|
||||
)
|
||||
|
||||
# Save rollback log
|
||||
output_path = tmp_path / "rollback" / "test_rollback.json"
|
||||
execution_engine.save_rollback_log(rollback_log, output_path)
|
||||
|
||||
# Load and verify timestamps
|
||||
import json
|
||||
with open(output_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Verify timestamps are in ISO format
|
||||
from datetime import datetime
|
||||
executed_at = datetime.fromisoformat(data["executed_at"])
|
||||
assert executed_at is not None
|
||||
|
||||
for op in data["operations"]:
|
||||
op_executed_at = datetime.fromisoformat(op["executed_at"])
|
||||
assert op_executed_at is not None
|
||||
|
||||
|
||||
|
||||
class TestRollbackExecution:
|
||||
"""Tests for rollback execution functionality."""
|
||||
|
||||
def test_load_rollback_log_from_file(self, execution_engine, sample_plan, tmp_path):
|
||||
"""Test loading a rollback log from a JSON file."""
|
||||
# Execute plan and save rollback log
|
||||
results, summary, rollback_log = execution_engine.execute_plan(
|
||||
sample_plan,
|
||||
mode="execute",
|
||||
confirmed=True
|
||||
)
|
||||
|
||||
log_path = tmp_path / "rollback.json"
|
||||
execution_engine.save_rollback_log(rollback_log, log_path)
|
||||
|
||||
# Load the rollback log
|
||||
loaded_log = execution_engine.load_rollback_log(log_path)
|
||||
|
||||
# Verify loaded log matches original
|
||||
assert loaded_log.log_id == rollback_log.log_id
|
||||
assert loaded_log.execution_plan_id == rollback_log.execution_plan_id
|
||||
assert len(loaded_log.operations) == len(rollback_log.operations)
|
||||
|
||||
def test_load_rollback_log_missing_file(self, execution_engine, tmp_path):
|
||||
"""Test that loading a missing rollback log raises FileNotFoundError."""
|
||||
missing_path = tmp_path / "nonexistent.json"
|
||||
|
||||
with pytest.raises(FileNotFoundError, match="Rollback log not found"):
|
||||
execution_engine.load_rollback_log(missing_path)
|
||||
|
||||
def test_load_rollback_log_invalid_json(self, execution_engine, tmp_path):
|
||||
"""Test that loading invalid JSON raises ValueError."""
|
||||
invalid_path = tmp_path / "invalid.json"
|
||||
invalid_path.write_text("not valid json {")
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid rollback log format"):
|
||||
execution_engine.load_rollback_log(invalid_path)
|
||||
|
||||
def test_rollback_reverses_operations(self, execution_engine, sample_plan, temp_test_dir):
|
||||
"""Test that rollback reverses file operations."""
|
||||
# Execute plan to move files
|
||||
results, summary, rollback_log = execution_engine.execute_plan(
|
||||
sample_plan,
|
||||
mode="execute",
|
||||
confirmed=True
|
||||
)
|
||||
|
||||
# Verify files were moved
|
||||
assert not temp_test_dir["test_file1"].exists()
|
||||
assert (temp_test_dir["dest_dir"] / "moved1.mp4").exists()
|
||||
|
||||
# Perform rollback
|
||||
rollback_results, rollback_summary = execution_engine.rollback(rollback_log)
|
||||
|
||||
# Verify files were moved back
|
||||
assert temp_test_dir["test_file1"].exists()
|
||||
assert not (temp_test_dir["dest_dir"] / "moved1.mp4").exists()
|
||||
|
||||
# Verify rollback summary
|
||||
assert rollback_summary["successful"] == 2
|
||||
assert rollback_summary["failed"] == 0
|
||||
assert rollback_summary["total"] == 2
|
||||
|
||||
def test_rollback_lifo_order(self, execution_engine, temp_test_dir):
|
||||
"""Test that rollback processes operations in LIFO order."""
|
||||
# Create a plan with multiple operations
|
||||
operations = [
|
||||
FileOperation(
|
||||
operation_type="move",
|
||||
source_path=temp_test_dir["test_file1"],
|
||||
destination_path=temp_test_dir["dest_dir"] / "first.mp4",
|
||||
reason="First operation",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
),
|
||||
FileOperation(
|
||||
operation_type="move",
|
||||
source_path=temp_test_dir["test_file2"],
|
||||
destination_path=temp_test_dir["dest_dir"] / "second.mkv",
|
||||
reason="Second operation",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
),
|
||||
]
|
||||
|
||||
plan = ExecutionPlan(
|
||||
plan_id=str(uuid4()),
|
||||
created_at=datetime.now(),
|
||||
operations=operations,
|
||||
summary={"move": 2}
|
||||
)
|
||||
|
||||
# Execute and rollback
|
||||
results, summary, rollback_log = execution_engine.execute_plan(
|
||||
plan,
|
||||
mode="execute",
|
||||
confirmed=True
|
||||
)
|
||||
|
||||
rollback_results, rollback_summary = execution_engine.rollback(rollback_log)
|
||||
|
||||
# Verify LIFO order: second operation should be rolled back first
|
||||
# Both should succeed regardless of order
|
||||
assert all(r.success for r in rollback_results)
|
||||
assert len(rollback_results) == 2
|
||||
|
||||
def test_rollback_handles_missing_destination(self, execution_engine, sample_plan, temp_test_dir):
|
||||
"""Test that rollback handles missing destination files gracefully."""
|
||||
# Execute plan
|
||||
results, summary, rollback_log = execution_engine.execute_plan(
|
||||
sample_plan,
|
||||
mode="execute",
|
||||
confirmed=True
|
||||
)
|
||||
|
||||
# Manually delete one of the destination files
|
||||
(temp_test_dir["dest_dir"] / "moved1.mp4").unlink()
|
||||
|
||||
# Perform rollback
|
||||
rollback_results, rollback_summary = execution_engine.rollback(rollback_log)
|
||||
|
||||
# One rollback should fail, one should succeed
|
||||
assert rollback_summary["successful"] == 1
|
||||
assert rollback_summary["failed"] == 1
|
||||
assert rollback_summary["total"] == 2
|
||||
|
||||
# The file that wasn't deleted should be rolled back
|
||||
assert temp_test_dir["test_file2"].exists()
|
||||
|
||||
def test_rollback_continues_after_failure(self, execution_engine, sample_plan, temp_test_dir):
|
||||
"""Test that rollback continues processing after encountering failures."""
|
||||
# Execute plan
|
||||
results, summary, rollback_log = execution_engine.execute_plan(
|
||||
sample_plan,
|
||||
mode="execute",
|
||||
confirmed=True
|
||||
)
|
||||
|
||||
# Delete one destination file to cause a rollback failure
|
||||
(temp_test_dir["dest_dir"] / "moved1.mp4").unlink()
|
||||
|
||||
# Perform rollback
|
||||
rollback_results, rollback_summary = execution_engine.rollback(rollback_log)
|
||||
|
||||
# Verify all operations were attempted (not halted by failure)
|
||||
assert len(rollback_results) == 2
|
||||
|
||||
# One should fail, one should succeed
|
||||
failed_count = sum(1 for r in rollback_results if not r.success)
|
||||
success_count = sum(1 for r in rollback_results if r.success)
|
||||
assert failed_count == 1
|
||||
assert success_count == 1
|
||||
|
||||
def test_rollback_skips_no_op_operations(self, execution_engine, temp_test_dir):
|
||||
"""Test that rollback skips no-op operations."""
|
||||
operations = [
|
||||
FileOperation(
|
||||
operation_type="move",
|
||||
source_path=temp_test_dir["test_file1"],
|
||||
destination_path=temp_test_dir["dest_dir"] / "moved.mp4",
|
||||
reason="Move file",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
),
|
||||
FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=temp_test_dir["test_file2"],
|
||||
destination_path=None,
|
||||
reason="Anime file",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
),
|
||||
]
|
||||
|
||||
plan = ExecutionPlan(
|
||||
plan_id=str(uuid4()),
|
||||
created_at=datetime.now(),
|
||||
operations=operations,
|
||||
summary={"move": 1, "no-op": 1}
|
||||
)
|
||||
|
||||
# Execute and rollback
|
||||
results, summary, rollback_log = execution_engine.execute_plan(
|
||||
plan,
|
||||
mode="execute",
|
||||
confirmed=True
|
||||
)
|
||||
|
||||
rollback_results, rollback_summary = execution_engine.rollback(rollback_log)
|
||||
|
||||
# Both operations are in rollback log, but no-op is skipped during rollback
|
||||
assert len(rollback_results) == 2
|
||||
assert rollback_summary["skipped"] == 1 # no-op is skipped during rollback
|
||||
assert rollback_summary["successful"] == 2 # Both succeed (no-op succeeds trivially)
|
||||
|
||||
def test_rollback_preserves_file_content(self, execution_engine, sample_plan, temp_test_dir):
|
||||
"""Test that rollback preserves file content."""
|
||||
original_content = temp_test_dir["test_file1"].read_text()
|
||||
|
||||
# Execute plan
|
||||
results, summary, rollback_log = execution_engine.execute_plan(
|
||||
sample_plan,
|
||||
mode="execute",
|
||||
confirmed=True
|
||||
)
|
||||
|
||||
# Perform rollback
|
||||
rollback_results, rollback_summary = execution_engine.rollback(rollback_log)
|
||||
|
||||
# Verify file content is preserved
|
||||
restored_content = temp_test_dir["test_file1"].read_text()
|
||||
assert restored_content == original_content
|
||||
|
||||
def test_rollback_idempotence(self, execution_engine, sample_plan, temp_test_dir):
|
||||
"""Test that running rollback multiple times produces the same result."""
|
||||
# Execute plan
|
||||
results, summary, rollback_log = execution_engine.execute_plan(
|
||||
sample_plan,
|
||||
mode="execute",
|
||||
confirmed=True
|
||||
)
|
||||
|
||||
# First rollback
|
||||
rollback_results1, rollback_summary1 = execution_engine.rollback(rollback_log)
|
||||
|
||||
# Verify files are back
|
||||
assert temp_test_dir["test_file1"].exists()
|
||||
assert temp_test_dir["test_file2"].exists()
|
||||
|
||||
# Execute plan again
|
||||
results2, summary2, rollback_log2 = execution_engine.execute_plan(
|
||||
sample_plan,
|
||||
mode="execute",
|
||||
confirmed=True
|
||||
)
|
||||
|
||||
# Second rollback
|
||||
rollback_results2, rollback_summary2 = execution_engine.rollback(rollback_log2)
|
||||
|
||||
# Both rollbacks should have same results
|
||||
assert rollback_summary1["successful"] == rollback_summary2["successful"]
|
||||
assert rollback_summary1["failed"] == rollback_summary2["failed"]
|
||||
|
||||
# Files should be in same state
|
||||
assert temp_test_dir["test_file1"].exists()
|
||||
assert temp_test_dir["test_file2"].exists()
|
||||
|
||||
def test_rollback_logs_operations(self, execution_engine, sample_plan, caplog):
|
||||
"""Test that rollback logs all operations."""
|
||||
caplog.set_level(logging.INFO)
|
||||
|
||||
# Execute plan
|
||||
results, summary, rollback_log = execution_engine.execute_plan(
|
||||
sample_plan,
|
||||
mode="execute",
|
||||
confirmed=True
|
||||
)
|
||||
|
||||
# Clear logs
|
||||
caplog.clear()
|
||||
|
||||
# Perform rollback
|
||||
rollback_results, rollback_summary = execution_engine.rollback(rollback_log)
|
||||
|
||||
# Verify rollback operations are logged
|
||||
assert "Starting rollback" in caplog.text
|
||||
assert "LIFO order" in caplog.text
|
||||
assert "Successfully rolled back" in caplog.text
|
||||
assert "Rollback summary" in caplog.text
|
||||
|
||||
def test_rollback_summary_accuracy(self, execution_engine, temp_test_dir):
|
||||
"""Test that rollback summary contains accurate counts."""
|
||||
# Create a plan with operations that will have mixed results
|
||||
operations = [
|
||||
FileOperation(
|
||||
operation_type="move",
|
||||
source_path=temp_test_dir["test_file1"],
|
||||
destination_path=temp_test_dir["dest_dir"] / "file1.mp4",
|
||||
reason="Move file 1",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
),
|
||||
FileOperation(
|
||||
operation_type="move",
|
||||
source_path=temp_test_dir["test_file2"],
|
||||
destination_path=temp_test_dir["dest_dir"] / "file2.mkv",
|
||||
reason="Move file 2",
|
||||
has_conflict=False,
|
||||
conflict_reason=None
|
||||
),
|
||||
]
|
||||
|
||||
plan = ExecutionPlan(
|
||||
plan_id=str(uuid4()),
|
||||
created_at=datetime.now(),
|
||||
operations=operations,
|
||||
summary={"move": 2}
|
||||
)
|
||||
|
||||
# Execute plan
|
||||
results, summary, rollback_log = execution_engine.execute_plan(
|
||||
plan,
|
||||
mode="execute",
|
||||
confirmed=True
|
||||
)
|
||||
|
||||
# Delete one file to cause partial rollback failure
|
||||
(temp_test_dir["dest_dir"] / "file1.mp4").unlink()
|
||||
|
||||
# Perform rollback
|
||||
rollback_results, rollback_summary = execution_engine.rollback(rollback_log)
|
||||
|
||||
# Verify summary accuracy
|
||||
assert rollback_summary["total"] == 2
|
||||
assert rollback_summary["successful"] == 1
|
||||
assert rollback_summary["failed"] == 1
|
||||
assert rollback_summary["skipped"] == 0
|
||||
|
||||
# Verify counts match actual results
|
||||
actual_success = sum(1 for r in rollback_results if r.success)
|
||||
actual_failed = sum(1 for r in rollback_results if not r.success)
|
||||
assert rollback_summary["successful"] == actual_success
|
||||
assert rollback_summary["failed"] == actual_failed
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Unit tests for logging configuration."""
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from vlm.logging_config import (
|
||||
setup_logging,
|
||||
get_logger,
|
||||
log_operation,
|
||||
MAX_LOG_SIZE,
|
||||
)
|
||||
|
||||
|
||||
class TestLoggingSetup:
|
||||
"""Test logging configuration setup."""
|
||||
|
||||
def test_setup_logging_creates_logger(self, tmp_path):
|
||||
"""Test that setup_logging creates a configured logger."""
|
||||
logger = setup_logging(log_level="INFO", log_dir=tmp_path)
|
||||
|
||||
assert logger is not None
|
||||
assert logger.name == "vlm"
|
||||
assert logger.level == logging.DEBUG
|
||||
|
||||
def test_setup_logging_creates_log_directory(self, tmp_path):
|
||||
"""Test that setup_logging creates the log directory."""
|
||||
log_dir = tmp_path / "logs"
|
||||
assert not log_dir.exists()
|
||||
|
||||
setup_logging(log_level="INFO", log_dir=log_dir)
|
||||
|
||||
assert log_dir.exists()
|
||||
assert log_dir.is_dir()
|
||||
|
||||
def test_setup_logging_creates_log_file(self, tmp_path):
|
||||
"""Test that setup_logging creates the log file."""
|
||||
logger = setup_logging(log_level="INFO", log_dir=tmp_path)
|
||||
|
||||
# Log a message to ensure file is created
|
||||
logger.info("Test message")
|
||||
|
||||
log_file = tmp_path / "vlm.log"
|
||||
assert log_file.exists()
|
||||
|
||||
def test_setup_logging_with_custom_log_file(self, tmp_path):
|
||||
"""Test that setup_logging accepts custom log file name."""
|
||||
logger = setup_logging(
|
||||
log_level="INFO",
|
||||
log_dir=tmp_path,
|
||||
log_file="custom.log"
|
||||
)
|
||||
|
||||
logger.info("Test message")
|
||||
|
||||
log_file = tmp_path / "custom.log"
|
||||
assert log_file.exists()
|
||||
|
||||
def test_setup_logging_invalid_level_raises_error(self, tmp_path):
|
||||
"""Test that invalid log level raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Invalid log level"):
|
||||
setup_logging(log_level="INVALID", log_dir=tmp_path)
|
||||
|
||||
def test_setup_logging_accepts_valid_levels(self, tmp_path):
|
||||
"""Test that all valid log levels are accepted."""
|
||||
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||
|
||||
for level in valid_levels:
|
||||
logger = setup_logging(log_level=level, log_dir=tmp_path)
|
||||
assert logger is not None
|
||||
|
||||
|
||||
class TestDualOutput:
|
||||
"""Test dual output to console and file."""
|
||||
|
||||
def test_console_handler_respects_log_level(self, tmp_path):
|
||||
"""Test that console handler only logs INFO+ messages."""
|
||||
logger = setup_logging(log_level="INFO", log_dir=tmp_path)
|
||||
|
||||
logger.debug("Debug message")
|
||||
logger.info("Info message")
|
||||
logger.warning("Warning message")
|
||||
|
||||
# Check file contains all messages (DEBUG+)
|
||||
log_file = tmp_path / "vlm.log"
|
||||
log_content = log_file.read_text()
|
||||
|
||||
assert "Debug message" in log_content
|
||||
assert "Info message" in log_content
|
||||
assert "Warning message" in log_content
|
||||
|
||||
# Verify console handler has INFO level
|
||||
console_handler = [h for h in logger.handlers if isinstance(h, logging.StreamHandler) and not isinstance(h, logging.handlers.RotatingFileHandler)][0]
|
||||
assert console_handler.level == logging.INFO
|
||||
|
||||
def test_file_handler_logs_all_levels(self, tmp_path):
|
||||
"""Test that file handler logs DEBUG+ messages."""
|
||||
logger = setup_logging(log_level="INFO", log_dir=tmp_path)
|
||||
|
||||
logger.debug("Debug message")
|
||||
logger.info("Info message")
|
||||
logger.warning("Warning message")
|
||||
|
||||
log_file = tmp_path / "vlm.log"
|
||||
log_content = log_file.read_text()
|
||||
|
||||
# File should contain all levels
|
||||
assert "Debug message" in log_content
|
||||
assert "Info message" in log_content
|
||||
assert "Warning message" in log_content
|
||||
|
||||
|
||||
class TestLogFormat:
|
||||
"""Test log message formatting."""
|
||||
|
||||
def test_log_includes_timestamp(self, tmp_path):
|
||||
"""Test that log entries include timestamps."""
|
||||
logger = setup_logging(log_level="INFO", log_dir=tmp_path)
|
||||
logger.info("Test message")
|
||||
|
||||
log_file = tmp_path / "vlm.log"
|
||||
log_content = log_file.read_text()
|
||||
|
||||
# Check for timestamp format (YYYY-MM-DD HH:MM:SS)
|
||||
import re
|
||||
timestamp_pattern = r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}'
|
||||
assert re.search(timestamp_pattern, log_content)
|
||||
|
||||
def test_log_includes_level(self, tmp_path):
|
||||
"""Test that log entries include log level."""
|
||||
logger = setup_logging(log_level="INFO", log_dir=tmp_path)
|
||||
logger.info("Test message")
|
||||
logger.warning("Warning message")
|
||||
|
||||
log_file = tmp_path / "vlm.log"
|
||||
log_content = log_file.read_text()
|
||||
|
||||
assert "INFO" in log_content
|
||||
assert "WARNING" in log_content
|
||||
|
||||
def test_log_includes_operation_type(self, tmp_path):
|
||||
"""Test that log entries include operation type."""
|
||||
logger = setup_logging(log_level="INFO", log_dir=tmp_path)
|
||||
log_operation(logger, logging.INFO, "Test message", operation_type="scan")
|
||||
|
||||
log_file = tmp_path / "vlm.log"
|
||||
log_content = log_file.read_text()
|
||||
|
||||
assert "[scan]" in log_content
|
||||
|
||||
def test_log_includes_file_path(self, tmp_path):
|
||||
"""Test that log entries include file paths when provided."""
|
||||
logger = setup_logging(log_level="INFO", log_dir=tmp_path)
|
||||
test_path = Path("/test/path/file.mp4")
|
||||
log_operation(
|
||||
logger,
|
||||
logging.INFO,
|
||||
"Processing file",
|
||||
operation_type="parse",
|
||||
file_path=test_path
|
||||
)
|
||||
|
||||
log_file = tmp_path / "vlm.log"
|
||||
log_content = log_file.read_text()
|
||||
|
||||
assert str(test_path) in log_content
|
||||
|
||||
def test_log_without_file_path(self, tmp_path):
|
||||
"""Test that log entries work without file path."""
|
||||
logger = setup_logging(log_level="INFO", log_dir=tmp_path)
|
||||
log_operation(logger, logging.INFO, "Test message", operation_type="general")
|
||||
|
||||
log_file = tmp_path / "vlm.log"
|
||||
log_content = log_file.read_text()
|
||||
|
||||
assert "Test message" in log_content
|
||||
assert "[general]" in log_content
|
||||
|
||||
|
||||
class TestLogRotation:
|
||||
"""Test log rotation at 10MB threshold."""
|
||||
|
||||
def test_log_rotation_creates_backup(self, tmp_path):
|
||||
"""Test that log rotation creates backup files."""
|
||||
logger = setup_logging(log_level="INFO", log_dir=tmp_path)
|
||||
|
||||
# Write enough data to trigger rotation (slightly over 10MB)
|
||||
large_message = "x" * 1024 # 1KB message
|
||||
num_messages = (MAX_LOG_SIZE // 1024) + 100 # Exceed 10MB
|
||||
|
||||
for i in range(num_messages):
|
||||
logger.info(f"{large_message} - {i}")
|
||||
|
||||
# Check that backup file was created
|
||||
log_file = tmp_path / "vlm.log"
|
||||
backup_file = tmp_path / "vlm.log.1"
|
||||
|
||||
assert log_file.exists()
|
||||
assert backup_file.exists()
|
||||
|
||||
def test_log_file_size_stays_under_limit(self, tmp_path):
|
||||
"""Test that log file size stays under 10MB after rotation."""
|
||||
logger = setup_logging(log_level="INFO", log_dir=tmp_path)
|
||||
|
||||
# Write enough data to trigger rotation
|
||||
large_message = "x" * 1024 # 1KB message
|
||||
num_messages = (MAX_LOG_SIZE // 1024) + 100 # Exceed 10MB
|
||||
|
||||
for i in range(num_messages):
|
||||
logger.info(f"{large_message} - {i}")
|
||||
|
||||
log_file = tmp_path / "vlm.log"
|
||||
|
||||
# Current log file should be smaller than MAX_LOG_SIZE
|
||||
assert log_file.stat().st_size < MAX_LOG_SIZE
|
||||
|
||||
|
||||
class TestGetLogger:
|
||||
"""Test get_logger function."""
|
||||
|
||||
def test_get_logger_returns_logger(self):
|
||||
"""Test that get_logger returns a logger instance."""
|
||||
logger = get_logger()
|
||||
|
||||
assert logger is not None
|
||||
assert logger.name == "vlm"
|
||||
|
||||
def test_get_logger_creates_default_config(self):
|
||||
"""Test that get_logger creates default configuration if needed."""
|
||||
# Clear any existing handlers
|
||||
logger = logging.getLogger("vlm")
|
||||
logger.handlers.clear()
|
||||
|
||||
# Get logger should set up default configuration
|
||||
logger = get_logger()
|
||||
|
||||
assert len(logger.handlers) > 0
|
||||
|
||||
|
||||
class TestLogOperation:
|
||||
"""Test log_operation helper function."""
|
||||
|
||||
def test_log_operation_with_all_parameters(self, tmp_path):
|
||||
"""Test log_operation with all parameters."""
|
||||
logger = setup_logging(log_level="INFO", log_dir=tmp_path)
|
||||
test_path = Path("/test/file.mp4")
|
||||
|
||||
log_operation(
|
||||
logger,
|
||||
logging.INFO,
|
||||
"Processing file",
|
||||
operation_type="execute",
|
||||
file_path=test_path
|
||||
)
|
||||
|
||||
log_file = tmp_path / "vlm.log"
|
||||
log_content = log_file.read_text()
|
||||
|
||||
assert "Processing file" in log_content
|
||||
assert "[execute]" in log_content
|
||||
assert str(test_path) in log_content
|
||||
|
||||
def test_log_operation_with_minimal_parameters(self, tmp_path):
|
||||
"""Test log_operation with minimal parameters."""
|
||||
logger = setup_logging(log_level="INFO", log_dir=tmp_path)
|
||||
|
||||
log_operation(logger, logging.INFO, "Simple message")
|
||||
|
||||
log_file = tmp_path / "vlm.log"
|
||||
log_content = log_file.read_text()
|
||||
|
||||
assert "Simple message" in log_content
|
||||
assert "[general]" in log_content
|
||||
|
||||
def test_log_operation_different_levels(self, tmp_path):
|
||||
"""Test log_operation with different log levels."""
|
||||
logger = setup_logging(log_level="DEBUG", log_dir=tmp_path)
|
||||
|
||||
log_operation(logger, logging.DEBUG, "Debug message", operation_type="scan")
|
||||
log_operation(logger, logging.INFO, "Info message", operation_type="parse")
|
||||
log_operation(logger, logging.WARNING, "Warning message", operation_type="execute")
|
||||
log_operation(logger, logging.ERROR, "Error message", operation_type="rollback")
|
||||
|
||||
log_file = tmp_path / "vlm.log"
|
||||
log_content = log_file.read_text()
|
||||
|
||||
assert "DEBUG" in log_content
|
||||
assert "INFO" in log_content
|
||||
assert "WARNING" in log_content
|
||||
assert "ERROR" in log_content
|
||||
@@ -0,0 +1,841 @@
|
||||
"""Tests for the identity parser module."""
|
||||
|
||||
import pytest
|
||||
from vlm.parser import (
|
||||
parse_movie,
|
||||
parse_series,
|
||||
normalize_title,
|
||||
remove_quality_tags,
|
||||
remove_release_groups,
|
||||
)
|
||||
|
||||
|
||||
class TestMovieParser:
|
||||
"""Tests for movie identity parsing."""
|
||||
|
||||
def test_parse_movie_with_parentheses_year(self):
|
||||
"""Test parsing movie with year in parentheses."""
|
||||
result = parse_movie("The Matrix (1999).mkv")
|
||||
assert result.title == "The Matrix"
|
||||
assert result.year == 1999
|
||||
assert result.confidence == 0.9
|
||||
assert result.needs_review is False
|
||||
assert result.original_filename == "The Matrix (1999).mkv"
|
||||
|
||||
def test_parse_movie_with_dot_year(self):
|
||||
"""Test parsing movie with dot-separated year."""
|
||||
result = parse_movie("Inception.2010.1080p.BluRay.mkv")
|
||||
assert result.title == "Inception"
|
||||
assert result.year == 2010
|
||||
assert result.confidence == 0.9
|
||||
assert result.needs_review is False
|
||||
|
||||
def test_parse_movie_with_dash_year(self):
|
||||
"""Test parsing movie with dash-separated year."""
|
||||
result = parse_movie("The Godfather - 1972.mp4")
|
||||
assert result.title == "The Godfather"
|
||||
assert result.year == 1972
|
||||
assert result.confidence == 0.7
|
||||
assert result.needs_review is False
|
||||
|
||||
def test_parse_movie_with_space_year(self):
|
||||
"""Test parsing movie with space-separated year."""
|
||||
result = parse_movie("Pulp Fiction 1994.avi")
|
||||
assert result.title == "Pulp Fiction"
|
||||
assert result.year == 1994
|
||||
assert result.confidence == 0.7
|
||||
assert result.needs_review is False
|
||||
|
||||
def test_parse_movie_with_quality_tags(self):
|
||||
"""Test parsing movie with quality tags removed."""
|
||||
result = parse_movie("Interstellar.2014.1080p.BluRay.x264.mkv")
|
||||
assert result.title == "Interstellar"
|
||||
assert result.year == 2014
|
||||
assert "1080p" not in result.title
|
||||
assert "BluRay" not in result.title
|
||||
assert "x264" not in result.title
|
||||
|
||||
def test_parse_movie_with_release_group(self):
|
||||
"""Test parsing movie with release group tags removed."""
|
||||
result = parse_movie("The Shawshank Redemption (1994) [RARBG].mkv")
|
||||
assert result.title == "The Shawshank Redemption"
|
||||
assert result.year == 1994
|
||||
assert "RARBG" not in result.title
|
||||
|
||||
def test_parse_movie_with_yts_release_group(self):
|
||||
"""Test parsing movie with YTS release group."""
|
||||
result = parse_movie("Fight Club (1999) (YTS).mp4")
|
||||
assert result.title == "Fight Club"
|
||||
assert result.year == 1999
|
||||
# YTS should be removed
|
||||
|
||||
def test_parse_movie_without_year(self):
|
||||
"""Test parsing movie without extractable year."""
|
||||
result = parse_movie("Some Random Movie.mkv")
|
||||
assert result.title == "Some Random Movie"
|
||||
assert result.year is None
|
||||
assert result.needs_review is True
|
||||
assert result.confidence == 0.3
|
||||
|
||||
def test_parse_movie_with_multiple_quality_tags(self):
|
||||
"""Test parsing movie with multiple quality indicators."""
|
||||
result = parse_movie("Avatar.2009.2160p.4K.UHD.BluRay.x265.10bit.mkv")
|
||||
assert result.title == "Avatar"
|
||||
assert result.year == 2009
|
||||
assert "2160p" not in result.title
|
||||
assert "4K" not in result.title
|
||||
assert "UHD" not in result.title
|
||||
|
||||
def test_parse_movie_with_web_dl(self):
|
||||
"""Test parsing movie with WEB-DL tag."""
|
||||
result = parse_movie("The Dark Knight (2008) WEB-DL 1080p.mkv")
|
||||
assert result.title == "The Dark Knight"
|
||||
assert result.year == 2008
|
||||
assert "WEB-DL" not in result.title
|
||||
|
||||
def test_parse_movie_with_hdtv(self):
|
||||
"""Test parsing movie with HDTV tag."""
|
||||
result = parse_movie("Movie Name 2015 HDTV 720p.avi")
|
||||
assert result.title == "Movie Name"
|
||||
assert result.year == 2015
|
||||
assert "HDTV" not in result.title
|
||||
|
||||
def test_parse_movie_with_dots_in_title(self):
|
||||
"""Test parsing movie with dots in title."""
|
||||
result = parse_movie("The.Lord.of.the.Rings.2001.mkv")
|
||||
assert result.title == "The Lord Of The Rings"
|
||||
assert result.year == 2001
|
||||
|
||||
def test_parse_movie_with_underscores(self):
|
||||
"""Test parsing movie with underscores in title."""
|
||||
result = parse_movie("Star_Wars_Episode_IV (1977).mp4")
|
||||
assert result.title == "Star Wars Episode Iv"
|
||||
assert result.year == 1977
|
||||
|
||||
def test_parse_movie_preserves_original_filename(self):
|
||||
"""Test that original filename is preserved."""
|
||||
original = "Complex.Movie.Name.2020.1080p.BluRay.x264.[RARBG].mkv"
|
||||
result = parse_movie(original)
|
||||
assert result.original_filename == original
|
||||
|
||||
|
||||
class TestNormalizeTitle:
|
||||
"""Tests for title normalization."""
|
||||
|
||||
def test_normalize_removes_dots(self):
|
||||
"""Test that dots are replaced with spaces."""
|
||||
assert normalize_title("The.Matrix") == "The Matrix"
|
||||
|
||||
def test_normalize_removes_underscores(self):
|
||||
"""Test that underscores are replaced with spaces."""
|
||||
assert normalize_title("Star_Wars") == "Star Wars"
|
||||
|
||||
def test_normalize_removes_extra_whitespace(self):
|
||||
"""Test that extra whitespace is removed."""
|
||||
assert normalize_title("The Matrix Reloaded") == "The Matrix Reloaded"
|
||||
|
||||
def test_normalize_applies_title_case(self):
|
||||
"""Test that title case is applied."""
|
||||
assert normalize_title("the matrix") == "The Matrix"
|
||||
assert normalize_title("THE MATRIX") == "The Matrix"
|
||||
|
||||
def test_normalize_idempotence(self):
|
||||
"""Test that normalizing multiple times produces same result."""
|
||||
title = "The.Matrix.Reloaded"
|
||||
normalized_once = normalize_title(title)
|
||||
normalized_twice = normalize_title(normalized_once)
|
||||
assert normalized_once == normalized_twice
|
||||
|
||||
|
||||
class TestRemoveQualityTags:
|
||||
"""Tests for quality tag removal."""
|
||||
|
||||
def test_remove_resolution_tags(self):
|
||||
"""Test removal of resolution tags."""
|
||||
assert "1080p" not in remove_quality_tags("Movie 1080p")
|
||||
assert "720p" not in remove_quality_tags("Movie 720p")
|
||||
assert "4K" not in remove_quality_tags("Movie 4K")
|
||||
|
||||
def test_remove_source_tags(self):
|
||||
"""Test removal of source tags."""
|
||||
assert "BluRay" not in remove_quality_tags("Movie BluRay")
|
||||
assert "WEB-DL" not in remove_quality_tags("Movie WEB-DL")
|
||||
assert "HDTV" not in remove_quality_tags("Movie HDTV")
|
||||
|
||||
def test_remove_codec_tags(self):
|
||||
"""Test removal of codec tags."""
|
||||
assert "x264" not in remove_quality_tags("Movie x264")
|
||||
assert "x265" not in remove_quality_tags("Movie x265")
|
||||
assert "HEVC" not in remove_quality_tags("Movie HEVC")
|
||||
|
||||
def test_case_insensitive_removal(self):
|
||||
"""Test that removal is case-insensitive."""
|
||||
assert "bluray" not in remove_quality_tags("Movie bluray").lower()
|
||||
assert "BLURAY" not in remove_quality_tags("Movie BLURAY").upper()
|
||||
|
||||
|
||||
class TestRemoveReleaseGroups:
|
||||
"""Tests for release group removal."""
|
||||
|
||||
def test_remove_bracketed_groups(self):
|
||||
"""Test removal of bracketed release groups."""
|
||||
assert "[RARBG]" not in remove_release_groups("Movie [RARBG]")
|
||||
assert "[YTS]" not in remove_release_groups("Movie [YTS]")
|
||||
|
||||
def test_preserve_year_in_parentheses(self):
|
||||
"""Test that years in parentheses are preserved."""
|
||||
result = remove_release_groups("Movie (2020)")
|
||||
# Years in parentheses should be preserved - they're handled by the parser
|
||||
assert "(2020)" in result
|
||||
|
||||
|
||||
|
||||
class TestSeriesParser:
|
||||
"""Tests for series identity parsing."""
|
||||
|
||||
def test_parse_series_sxxeyy_format(self):
|
||||
"""Test parsing series with SXXEYY format."""
|
||||
result = parse_series("Breaking Bad S01E01.mkv")
|
||||
assert result.title == "Breaking Bad"
|
||||
assert result.season == 1
|
||||
assert result.episodes == [1]
|
||||
assert result.confidence == 0.9
|
||||
assert result.needs_review is False
|
||||
assert result.original_filename == "Breaking Bad S01E01.mkv"
|
||||
|
||||
def test_parse_series_sxxeyy_lowercase(self):
|
||||
"""Test parsing series with lowercase sxxeyy format."""
|
||||
result = parse_series("Game of Thrones s02e05.mkv")
|
||||
assert result.title == "Game Of Thrones"
|
||||
assert result.season == 2
|
||||
assert result.episodes == [5]
|
||||
assert result.confidence == 0.9
|
||||
assert result.needs_review is False
|
||||
|
||||
def test_parse_series_xxxyy_format(self):
|
||||
"""Test parsing series with XXxYY format."""
|
||||
result = parse_series("The Office 1x01.mp4")
|
||||
assert result.title == "The Office"
|
||||
assert result.season == 1
|
||||
assert result.episodes == [1]
|
||||
assert result.confidence == 0.9
|
||||
assert result.needs_review is False
|
||||
|
||||
def test_parse_series_season_episode_format(self):
|
||||
"""Test parsing series with Season X Episode Y format."""
|
||||
result = parse_series("Friends Season 1 Episode 1.avi")
|
||||
assert result.title == "Friends"
|
||||
assert result.season == 1
|
||||
assert result.episodes == [1]
|
||||
assert result.confidence == 0.7
|
||||
assert result.needs_review is False
|
||||
|
||||
def test_parse_series_multi_episode_dash(self):
|
||||
"""Test parsing multi-episode file with dash separator."""
|
||||
result = parse_series("The Wire S01E01-E02.mkv")
|
||||
assert result.title == "The Wire"
|
||||
assert result.season == 1
|
||||
assert result.episodes == [1, 2]
|
||||
assert result.confidence == 0.9
|
||||
assert result.needs_review is False
|
||||
|
||||
def test_parse_series_multi_episode_no_dash(self):
|
||||
"""Test parsing multi-episode file without dash."""
|
||||
result = parse_series("Stranger Things S01E01E02.mkv")
|
||||
assert result.title == "Stranger Things"
|
||||
assert result.season == 1
|
||||
assert result.episodes == [1, 2]
|
||||
assert result.confidence == 0.9
|
||||
assert result.needs_review is False
|
||||
|
||||
def test_parse_series_multi_episode_three_episodes(self):
|
||||
"""Test parsing file with three episodes."""
|
||||
result = parse_series("Show Name S02E01E02E03.mkv")
|
||||
assert result.title == "Show Name"
|
||||
assert result.season == 2
|
||||
assert result.episodes == [1, 2, 3]
|
||||
assert result.confidence == 0.9
|
||||
assert result.needs_review is False
|
||||
|
||||
def test_parse_series_with_quality_tags(self):
|
||||
"""Test parsing series with quality tags removed."""
|
||||
result = parse_series("The Mandalorian S01E01 1080p WEB-DL x264.mkv")
|
||||
assert result.title == "The Mandalorian"
|
||||
assert result.season == 1
|
||||
assert result.episodes == [1]
|
||||
assert "1080p" not in result.title
|
||||
assert "WEB-DL" not in result.title
|
||||
assert "x264" not in result.title
|
||||
|
||||
def test_parse_series_with_release_group(self):
|
||||
"""Test parsing series with release group removed."""
|
||||
result = parse_series("Westworld S01E01 [RARBG].mkv")
|
||||
assert result.title == "Westworld"
|
||||
assert result.season == 1
|
||||
assert result.episodes == [1]
|
||||
assert "RARBG" not in result.title
|
||||
|
||||
def test_parse_series_with_dots_in_title(self):
|
||||
"""Test parsing series with dots in title."""
|
||||
result = parse_series("The.Walking.Dead.S05E10.mkv")
|
||||
assert result.title == "The Walking Dead"
|
||||
assert result.season == 5
|
||||
assert result.episodes == [10]
|
||||
|
||||
def test_parse_series_with_underscores(self):
|
||||
"""Test parsing series with underscores in title."""
|
||||
result = parse_series("Better_Call_Saul_S02E03.mp4")
|
||||
assert result.title == "Better Call Saul"
|
||||
assert result.season == 2
|
||||
assert result.episodes == [3]
|
||||
|
||||
def test_parse_series_without_season(self):
|
||||
"""Test parsing series without extractable season."""
|
||||
result = parse_series("Random Show Episode.mkv")
|
||||
assert result.title == "Random Show Episode"
|
||||
assert result.season is None
|
||||
assert result.episodes == []
|
||||
assert result.needs_review is True
|
||||
assert result.confidence == 0.3
|
||||
|
||||
def test_parse_series_without_episode(self):
|
||||
"""Test parsing series without extractable episode."""
|
||||
result = parse_series("Some Series Name.mkv")
|
||||
assert result.title == "Some Series Name"
|
||||
assert result.season is None
|
||||
assert result.episodes == []
|
||||
assert result.needs_review is True
|
||||
|
||||
def test_parse_series_double_digit_season_episode(self):
|
||||
"""Test parsing series with double-digit season and episode."""
|
||||
result = parse_series("Doctor Who S12E10.mkv")
|
||||
assert result.title == "Doctor Who"
|
||||
assert result.season == 12
|
||||
assert result.episodes == [10]
|
||||
assert result.needs_review is False
|
||||
|
||||
def test_parse_series_complex_filename(self):
|
||||
"""Test parsing series with complex filename."""
|
||||
result = parse_series("The.Expanse.S03E05.1080p.BluRay.x264.[YTS].mkv")
|
||||
assert result.title == "The Expanse"
|
||||
assert result.season == 3
|
||||
assert result.episodes == [5]
|
||||
assert "1080p" not in result.title
|
||||
assert "BluRay" not in result.title
|
||||
assert "YTS" not in result.title
|
||||
|
||||
def test_parse_series_preserves_original_filename(self):
|
||||
"""Test that original filename is preserved."""
|
||||
original = "Complex.Series.Name.S01E01.1080p.WEB-DL.[RARBG].mkv"
|
||||
result = parse_series(original)
|
||||
assert result.original_filename == original
|
||||
|
||||
def test_parse_series_with_multiple_quality_tags(self):
|
||||
"""Test parsing series with multiple quality indicators."""
|
||||
result = parse_series("Series.Name.S01E01.2160p.4K.UHD.WEB-DL.x265.10bit.mkv")
|
||||
assert result.title == "Series Name"
|
||||
assert result.season == 1
|
||||
assert result.episodes == [1]
|
||||
assert "2160p" not in result.title
|
||||
assert "4K" not in result.title
|
||||
assert "UHD" not in result.title
|
||||
|
||||
def test_parse_series_xxxyy_double_digits(self):
|
||||
"""Test parsing series with XXxYY format and double digits."""
|
||||
result = parse_series("Show Name 10x15.mp4")
|
||||
assert result.title == "Show Name"
|
||||
assert result.season == 10
|
||||
assert result.episodes == [15]
|
||||
assert result.confidence == 0.9
|
||||
|
||||
|
||||
class TestEpisodeGrouping:
|
||||
"""Tests for episode grouping functionality."""
|
||||
|
||||
def test_group_episodes_by_title_and_season(self):
|
||||
"""Test grouping episodes by normalized title and season."""
|
||||
from vlm.parser import group_episodes
|
||||
|
||||
episodes = [
|
||||
parse_series("Breaking Bad S01E01.mkv"),
|
||||
parse_series("Breaking Bad S01E02.mkv"),
|
||||
parse_series("Breaking Bad S02E01.mkv"),
|
||||
parse_series("Game of Thrones S01E01.mkv"),
|
||||
]
|
||||
|
||||
groups = group_episodes(episodes)
|
||||
|
||||
# Should have 3 groups: Breaking Bad S01, Breaking Bad S02, Game of Thrones S01
|
||||
assert len(groups) == 3
|
||||
assert ("Breaking Bad", 1) in groups
|
||||
assert ("Breaking Bad", 2) in groups
|
||||
assert ("Game Of Thrones", 1) in groups
|
||||
|
||||
# Breaking Bad S01 should have 2 episodes
|
||||
assert len(groups[("Breaking Bad", 1)]) == 2
|
||||
# Breaking Bad S02 should have 1 episode
|
||||
assert len(groups[("Breaking Bad", 2)]) == 1
|
||||
# Game of Thrones S01 should have 1 episode
|
||||
assert len(groups[("Game Of Thrones", 1)]) == 1
|
||||
|
||||
def test_group_episodes_excludes_none_season(self):
|
||||
"""Test that episodes with season=None are excluded from grouping."""
|
||||
from vlm.parser import group_episodes
|
||||
|
||||
episodes = [
|
||||
parse_series("Breaking Bad S01E01.mkv"),
|
||||
parse_series("Random Show Episode.mkv"), # No season
|
||||
parse_series("Breaking Bad S01E02.mkv"),
|
||||
]
|
||||
|
||||
groups = group_episodes(episodes)
|
||||
|
||||
# Should only have 1 group (Breaking Bad S01)
|
||||
assert len(groups) == 1
|
||||
assert ("Breaking Bad", 1) in groups
|
||||
assert len(groups[("Breaking Bad", 1)]) == 2
|
||||
|
||||
def test_group_episodes_empty_list(self):
|
||||
"""Test grouping with empty episode list."""
|
||||
from vlm.parser import group_episodes
|
||||
|
||||
groups = group_episodes([])
|
||||
|
||||
assert len(groups) == 0
|
||||
assert groups == {}
|
||||
|
||||
def test_group_episodes_single_episode(self):
|
||||
"""Test grouping with single episode."""
|
||||
from vlm.parser import group_episodes
|
||||
|
||||
episodes = [parse_series("The Office S01E01.mkv")]
|
||||
|
||||
groups = group_episodes(episodes)
|
||||
|
||||
assert len(groups) == 1
|
||||
assert ("The Office", 1) in groups
|
||||
assert len(groups[("The Office", 1)]) == 1
|
||||
|
||||
def test_group_episodes_same_title_different_seasons(self):
|
||||
"""Test grouping episodes from same series but different seasons."""
|
||||
from vlm.parser import group_episodes
|
||||
|
||||
episodes = [
|
||||
parse_series("Friends S01E01.mkv"),
|
||||
parse_series("Friends S01E02.mkv"),
|
||||
parse_series("Friends S02E01.mkv"),
|
||||
parse_series("Friends S02E02.mkv"),
|
||||
parse_series("Friends S03E01.mkv"),
|
||||
]
|
||||
|
||||
groups = group_episodes(episodes)
|
||||
|
||||
# Should have 3 groups (one per season)
|
||||
assert len(groups) == 3
|
||||
assert ("Friends", 1) in groups
|
||||
assert ("Friends", 2) in groups
|
||||
assert ("Friends", 3) in groups
|
||||
|
||||
# Check episode counts per season
|
||||
assert len(groups[("Friends", 1)]) == 2
|
||||
assert len(groups[("Friends", 2)]) == 2
|
||||
assert len(groups[("Friends", 3)]) == 1
|
||||
|
||||
def test_group_episodes_normalized_titles(self):
|
||||
"""Test that grouping uses normalized titles."""
|
||||
from vlm.parser import group_episodes
|
||||
|
||||
episodes = [
|
||||
parse_series("The.Walking.Dead.S01E01.mkv"), # Dots
|
||||
parse_series("The_Walking_Dead_S01E02.mkv"), # Underscores
|
||||
parse_series("The Walking Dead S01E03.mkv"), # Spaces
|
||||
]
|
||||
|
||||
groups = group_episodes(episodes)
|
||||
|
||||
# All should be grouped together under normalized title
|
||||
assert len(groups) == 1
|
||||
assert ("The Walking Dead", 1) in groups
|
||||
assert len(groups[("The Walking Dead", 1)]) == 3
|
||||
|
||||
def test_group_episodes_multi_episode_files(self):
|
||||
"""Test grouping with multi-episode files."""
|
||||
from vlm.parser import group_episodes
|
||||
|
||||
episodes = [
|
||||
parse_series("Show Name S01E01.mkv"),
|
||||
parse_series("Show Name S01E02E03.mkv"), # Multi-episode
|
||||
parse_series("Show Name S01E04.mkv"),
|
||||
]
|
||||
|
||||
groups = group_episodes(episodes)
|
||||
|
||||
# All should be in same group
|
||||
assert len(groups) == 1
|
||||
assert ("Show Name", 1) in groups
|
||||
assert len(groups[("Show Name", 1)]) == 3
|
||||
|
||||
def test_group_episodes_preserves_original_objects(self):
|
||||
"""Test that grouping preserves original SeriesIdentity objects."""
|
||||
from vlm.parser import group_episodes
|
||||
|
||||
episodes = [
|
||||
parse_series("Breaking Bad S01E01.mkv"),
|
||||
parse_series("Breaking Bad S01E02.mkv"),
|
||||
]
|
||||
|
||||
groups = group_episodes(episodes)
|
||||
|
||||
# Check that original objects are preserved
|
||||
grouped_episodes = groups[("Breaking Bad", 1)]
|
||||
assert grouped_episodes[0].original_filename == "Breaking Bad S01E01.mkv"
|
||||
assert grouped_episodes[1].original_filename == "Breaking Bad S01E02.mkv"
|
||||
assert grouped_episodes[0].episodes == [1]
|
||||
assert grouped_episodes[1].episodes == [2]
|
||||
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Property-Based Tests
|
||||
# ============================================================================
|
||||
|
||||
from hypothesis import given, strategies as st, assume, settings
|
||||
from vlm.parser import group_episodes
|
||||
import logging
|
||||
|
||||
|
||||
# Custom strategies for generating test data
|
||||
@st.composite
|
||||
def movie_filename_strategy(draw):
|
||||
"""Generate movie filenames matching common patterns."""
|
||||
# Generate a title (1-5 words)
|
||||
title_words = draw(st.lists(
|
||||
st.text(
|
||||
alphabet=st.characters(whitelist_categories=('Lu', 'Ll'), min_codepoint=65, max_codepoint=122),
|
||||
min_size=3,
|
||||
max_size=10
|
||||
),
|
||||
min_size=1,
|
||||
max_size=5
|
||||
))
|
||||
title = ' '.join(title_words)
|
||||
|
||||
# Generate a year (1900-2030)
|
||||
year = draw(st.integers(min_value=1900, max_value=2030))
|
||||
|
||||
# Choose a pattern
|
||||
pattern = draw(st.sampled_from([
|
||||
'parentheses', # Title (Year)
|
||||
'dot', # Title.Year
|
||||
'dash', # Title - Year
|
||||
'space' # Title Year
|
||||
]))
|
||||
|
||||
# Choose optional quality tags
|
||||
quality_tags = draw(st.lists(
|
||||
st.sampled_from(['1080p', '720p', '4K', 'BluRay', 'WEB-DL', 'HDTV', 'x264', 'x265']),
|
||||
max_size=3
|
||||
))
|
||||
|
||||
# Choose optional release group
|
||||
release_group = draw(st.one_of(
|
||||
st.none(),
|
||||
st.sampled_from(['[RARBG]', '[YTS]', '[YIFY]'])
|
||||
))
|
||||
|
||||
# Choose extension
|
||||
ext = draw(st.sampled_from(['.mp4', '.mkv', '.avi', '.mov']))
|
||||
|
||||
# Build filename based on pattern
|
||||
if pattern == 'parentheses':
|
||||
filename = f"{title} ({year})"
|
||||
elif pattern == 'dot':
|
||||
filename = title.replace(' ', '.') + f".{year}"
|
||||
elif pattern == 'dash':
|
||||
filename = f"{title} - {year}"
|
||||
else: # space
|
||||
filename = f"{title} {year}"
|
||||
|
||||
# Add quality tags
|
||||
if quality_tags:
|
||||
filename += ' ' + ' '.join(quality_tags)
|
||||
|
||||
# Add release group
|
||||
if release_group:
|
||||
filename += ' ' + release_group
|
||||
|
||||
# Add extension
|
||||
filename += ext
|
||||
|
||||
return filename, title, year
|
||||
|
||||
|
||||
@st.composite
|
||||
def series_filename_strategy(draw):
|
||||
"""Generate series filenames matching common patterns."""
|
||||
# Generate a title (1-5 words)
|
||||
title_words = draw(st.lists(
|
||||
st.text(
|
||||
alphabet=st.characters(whitelist_categories=('Lu', 'Ll'), min_codepoint=65, max_codepoint=122),
|
||||
min_size=3,
|
||||
max_size=10
|
||||
),
|
||||
min_size=1,
|
||||
max_size=5
|
||||
))
|
||||
title = ' '.join(title_words)
|
||||
|
||||
# Generate season and episode
|
||||
season = draw(st.integers(min_value=1, max_value=20))
|
||||
episode = draw(st.integers(min_value=1, max_value=30))
|
||||
|
||||
# Choose a pattern
|
||||
pattern = draw(st.sampled_from([
|
||||
'SXXEYY', # S01E01
|
||||
'sxxeyy', # s01e01
|
||||
'XXxYY', # 1x01
|
||||
'season_episode' # Season 1 Episode 1
|
||||
]))
|
||||
|
||||
# Choose optional quality tags
|
||||
quality_tags = draw(st.lists(
|
||||
st.sampled_from(['1080p', '720p', '4K', 'WEB-DL', 'BluRay', 'x264']),
|
||||
max_size=2
|
||||
))
|
||||
|
||||
# Choose extension
|
||||
ext = draw(st.sampled_from(['.mp4', '.mkv', '.avi']))
|
||||
|
||||
# Build filename based on pattern
|
||||
if pattern == 'SXXEYY':
|
||||
episode_part = f"S{season:02d}E{episode:02d}"
|
||||
elif pattern == 'sxxeyy':
|
||||
episode_part = f"s{season:02d}e{episode:02d}"
|
||||
elif pattern == 'XXxYY':
|
||||
episode_part = f"{season}x{episode:02d}"
|
||||
else: # season_episode
|
||||
episode_part = f"Season {season} Episode {episode}"
|
||||
|
||||
# Build filename
|
||||
filename = f"{title} {episode_part}"
|
||||
|
||||
# Add quality tags
|
||||
if quality_tags:
|
||||
filename += ' ' + ' '.join(quality_tags)
|
||||
|
||||
# Add extension
|
||||
filename += ext
|
||||
|
||||
return filename, title, season, episode
|
||||
|
||||
|
||||
@st.composite
|
||||
def ambiguous_filename_strategy(draw):
|
||||
"""Generate filenames without clear season/episode patterns."""
|
||||
# Generate random text without season/episode patterns
|
||||
words = draw(st.lists(
|
||||
st.text(
|
||||
alphabet=st.characters(whitelist_categories=('Lu', 'Ll'), min_codepoint=65, max_codepoint=122),
|
||||
min_size=3,
|
||||
max_size=10
|
||||
),
|
||||
min_size=1,
|
||||
max_size=5
|
||||
))
|
||||
filename = ' '.join(words)
|
||||
|
||||
# Add extension
|
||||
ext = draw(st.sampled_from(['.mp4', '.mkv', '.avi']))
|
||||
filename += ext
|
||||
|
||||
return filename
|
||||
|
||||
|
||||
class TestParserProperties:
|
||||
"""Property-based tests for the Identity Parser."""
|
||||
|
||||
# Feature: video-library-manager, Property 5: Movie parsing
|
||||
@settings(max_examples=100)
|
||||
@given(movie_filename_strategy())
|
||||
def test_property_5_movie_parsing(self, movie_data):
|
||||
"""For any filename matching common movie patterns, parser SHALL extract title and year.
|
||||
|
||||
Validates: Requirements 2.1, 2.2
|
||||
"""
|
||||
filename, expected_title, expected_year = movie_data
|
||||
|
||||
result = parse_movie(filename)
|
||||
|
||||
# Parser should extract a title
|
||||
assert result.title is not None
|
||||
assert len(result.title) > 0
|
||||
|
||||
# Parser should extract the year
|
||||
assert result.year == expected_year
|
||||
|
||||
# Title should be normalized (no dots, underscores, proper case)
|
||||
assert '.' not in result.title
|
||||
assert '_' not in result.title
|
||||
|
||||
# Quality tags should be removed from title
|
||||
quality_indicators = ['1080p', '720p', '4K', 'BluRay', 'WEB-DL', 'HDTV', 'x264', 'x265']
|
||||
for tag in quality_indicators:
|
||||
assert tag not in result.title
|
||||
|
||||
# Release groups should be removed from title
|
||||
assert '[RARBG]' not in result.title
|
||||
assert '[YTS]' not in result.title
|
||||
assert '[YIFY]' not in result.title
|
||||
|
||||
# Original filename should be preserved
|
||||
assert result.original_filename == filename
|
||||
|
||||
# Feature: video-library-manager, Property 6: Series parsing
|
||||
@settings(max_examples=100)
|
||||
@given(series_filename_strategy())
|
||||
def test_property_6_series_parsing(self, series_data):
|
||||
"""For any filename matching common series patterns, parser SHALL extract series title, season, and episodes.
|
||||
|
||||
Validates: Requirements 3.1, 3.2
|
||||
"""
|
||||
filename, expected_title, expected_season, expected_episode = series_data
|
||||
|
||||
result = parse_series(filename)
|
||||
|
||||
# Parser should extract a title
|
||||
assert result.title is not None
|
||||
assert len(result.title) > 0
|
||||
|
||||
# Parser should extract season and episode
|
||||
assert result.season == expected_season
|
||||
assert expected_episode in result.episodes
|
||||
|
||||
# Title should be normalized
|
||||
assert '.' not in result.title
|
||||
assert '_' not in result.title
|
||||
|
||||
# Quality tags should be removed from title
|
||||
quality_indicators = ['1080p', '720p', '4K', 'WEB-DL', 'BluRay', 'x264']
|
||||
for tag in quality_indicators:
|
||||
assert tag not in result.title
|
||||
|
||||
# Original filename should be preserved
|
||||
assert result.original_filename == filename
|
||||
|
||||
# Feature: video-library-manager, Property 7: Title normalization idempotence
|
||||
@settings(max_examples=100)
|
||||
@given(st.text(
|
||||
alphabet=st.characters(whitelist_categories=('Lu', 'Ll', 'Nd', 'Zs'), min_codepoint=32, max_codepoint=126),
|
||||
min_size=1,
|
||||
max_size=100
|
||||
))
|
||||
def test_property_7_title_normalization_idempotence(self, title):
|
||||
"""For any title string, normalizing multiple times SHALL produce same result.
|
||||
|
||||
Validates: Requirements 2.6, 3.6
|
||||
"""
|
||||
# Filter out empty strings after normalization
|
||||
assume(len(title.strip()) > 0)
|
||||
|
||||
normalized_once = normalize_title(title)
|
||||
normalized_twice = normalize_title(normalized_once)
|
||||
normalized_thrice = normalize_title(normalized_twice)
|
||||
|
||||
# All normalizations should produce the same result
|
||||
assert normalized_once == normalized_twice
|
||||
assert normalized_twice == normalized_thrice
|
||||
|
||||
# Feature: video-library-manager, Property 8: Ambiguous filename flagging
|
||||
@settings(max_examples=100)
|
||||
@given(ambiguous_filename_strategy())
|
||||
def test_property_8_ambiguous_filename_flagging(self, filename):
|
||||
"""For any filename where season/episode cannot be extracted, parser SHALL mark as needs_review.
|
||||
|
||||
Validates: Requirements 2.5, 3.5
|
||||
"""
|
||||
# Ensure the filename doesn't accidentally match a pattern
|
||||
# by checking it doesn't contain common episode markers
|
||||
assume('S' not in filename.upper() or 'E' not in filename.upper())
|
||||
assume('x' not in filename.lower())
|
||||
assume('season' not in filename.lower())
|
||||
assume('episode' not in filename.lower())
|
||||
|
||||
result = parse_series(filename)
|
||||
|
||||
# If season or episodes cannot be extracted, should be marked for review
|
||||
if result.season is None or len(result.episodes) == 0:
|
||||
assert result.needs_review is True
|
||||
assert result.confidence <= 0.5
|
||||
|
||||
# Feature: video-library-manager, Property 9: Episode grouping
|
||||
@settings(max_examples=100)
|
||||
@given(st.lists(series_filename_strategy(), min_size=1, max_size=20))
|
||||
def test_property_9_episode_grouping(self, series_data_list):
|
||||
"""For any set of parsed episodes, grouping SHALL place episodes with identical normalized titles and seasons in same group.
|
||||
|
||||
Validates: Requirements 3.7
|
||||
"""
|
||||
# Parse all episodes
|
||||
episodes = [parse_series(filename) for filename, _, _, _ in series_data_list]
|
||||
|
||||
# Group episodes
|
||||
groups = group_episodes(episodes)
|
||||
|
||||
# Verify grouping correctness
|
||||
for (title, season), group_episodes_list in groups.items():
|
||||
# All episodes in a group should have the same normalized title and season
|
||||
for episode in group_episodes_list:
|
||||
assert episode.title == title
|
||||
assert episode.season == season
|
||||
|
||||
# Verify no episode is in multiple groups
|
||||
all_grouped_episodes = []
|
||||
for group_episodes_list in groups.values():
|
||||
all_grouped_episodes.extend(group_episodes_list)
|
||||
|
||||
# Count episodes with valid season (should match grouped count)
|
||||
valid_episodes = [ep for ep in episodes if ep.season is not None]
|
||||
assert len(all_grouped_episodes) == len(valid_episodes)
|
||||
|
||||
# Feature: video-library-manager, Property 51: Parsing error handling
|
||||
@settings(max_examples=100)
|
||||
@given(st.text(
|
||||
alphabet=st.characters(blacklist_categories=('Cc', 'Cs'), min_codepoint=32, max_codepoint=126),
|
||||
min_size=1,
|
||||
max_size=200
|
||||
))
|
||||
def test_property_51_parsing_error_handling(self, filename):
|
||||
"""For any filename that cannot be parsed, system SHALL log error and mark file for review.
|
||||
|
||||
Validates: Requirements 13.4
|
||||
|
||||
Note: This test verifies that the parser handles unparseable filenames gracefully
|
||||
by marking them for review, rather than crashing or producing invalid results.
|
||||
"""
|
||||
# Ensure filename has an extension
|
||||
if not any(filename.endswith(ext) for ext in ['.mp4', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.webm', '.m4v']):
|
||||
filename += '.mkv'
|
||||
|
||||
# Try parsing as movie
|
||||
movie_result = parse_movie(filename)
|
||||
|
||||
# Parser should always return a valid MovieIdentity object
|
||||
assert movie_result is not None
|
||||
assert movie_result.title is not None
|
||||
assert movie_result.original_filename == filename
|
||||
|
||||
# If year cannot be extracted, should be marked for review
|
||||
if movie_result.year is None:
|
||||
assert movie_result.needs_review is True
|
||||
|
||||
# Try parsing as series
|
||||
series_result = parse_series(filename)
|
||||
|
||||
# Parser should always return a valid SeriesIdentity object
|
||||
assert series_result is not None
|
||||
assert series_result.title is not None
|
||||
assert series_result.original_filename == filename
|
||||
|
||||
# If season/episode cannot be extracted, should be marked for review
|
||||
if series_result.season is None or len(series_result.episodes) == 0:
|
||||
assert series_result.needs_review is True
|
||||
@@ -0,0 +1,923 @@
|
||||
"""Unit tests for plan generator."""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from vlm.config import Config
|
||||
from vlm.models import (
|
||||
ExecutionPlan,
|
||||
FileOperation,
|
||||
MovieIdentity,
|
||||
SeriesIdentity,
|
||||
VideoFile,
|
||||
)
|
||||
from vlm.planner import generate_plan
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
"""Create a test configuration."""
|
||||
return Config(
|
||||
library_root=Path("/mnt/nas/videos"),
|
||||
video_extensions=[".mp4", ".mkv", ".avi"],
|
||||
movie_template="movie/{title} ({year})/",
|
||||
series_template="series/{title}/Season {season:02d}/",
|
||||
movie_filename_template="{title} ({year}){ext}",
|
||||
series_filename_template="S{season:02d}E{episode:02d}{ext}",
|
||||
log_level="INFO",
|
||||
quarantine_dir=".quarantine"
|
||||
)
|
||||
|
||||
|
||||
def test_generate_plan_for_movie_with_year(config):
|
||||
"""Test plan generation for a movie with year."""
|
||||
video_file = VideoFile(
|
||||
path=Path("/mnt/nas/videos/movie/Some.Movie.2020.1080p.mkv"),
|
||||
filename="Some.Movie.2020.1080p.mkv",
|
||||
size_bytes=1000000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="movie"
|
||||
)
|
||||
|
||||
identity = MovieIdentity(
|
||||
title="Some Movie",
|
||||
year=2020,
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename="Some.Movie.2020.1080p.mkv"
|
||||
)
|
||||
|
||||
plan = generate_plan([(video_file, identity)], config)
|
||||
|
||||
assert isinstance(plan, ExecutionPlan)
|
||||
assert len(plan.operations) == 1
|
||||
|
||||
operation = plan.operations[0]
|
||||
assert operation.operation_type == "move"
|
||||
assert operation.source_path == video_file.path
|
||||
assert operation.destination_path == Path("/mnt/nas/videos/movie/Some Movie (2020)/Some Movie (2020).mkv")
|
||||
assert not operation.has_conflict
|
||||
assert "Some Movie (2020)" in operation.reason
|
||||
|
||||
|
||||
def test_generate_plan_for_movie_without_year(config):
|
||||
"""Test plan generation for a movie without year (needs review)."""
|
||||
video_file = VideoFile(
|
||||
path=Path("/mnt/nas/videos/movie/random_movie.mkv"),
|
||||
filename="random_movie.mkv",
|
||||
size_bytes=1000000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="movie"
|
||||
)
|
||||
|
||||
identity = MovieIdentity(
|
||||
title="Random Movie",
|
||||
year=None,
|
||||
confidence=0.3,
|
||||
needs_review=True,
|
||||
original_filename="random_movie.mkv"
|
||||
)
|
||||
|
||||
plan = generate_plan([(video_file, identity)], config)
|
||||
|
||||
assert len(plan.operations) == 1
|
||||
operation = plan.operations[0]
|
||||
assert operation.operation_type == "no-op"
|
||||
assert operation.destination_path is None
|
||||
assert "manual review" in operation.reason.lower()
|
||||
|
||||
|
||||
def test_generate_plan_for_series_with_season_and_episode(config):
|
||||
"""Test plan generation for a series with season and episode."""
|
||||
video_file = VideoFile(
|
||||
path=Path("/mnt/nas/videos/series/Show.Name.S01E05.mkv"),
|
||||
filename="Show.Name.S01E05.mkv",
|
||||
size_bytes=500000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="series"
|
||||
)
|
||||
|
||||
identity = SeriesIdentity(
|
||||
title="Show Name",
|
||||
season=1,
|
||||
episodes=[5],
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename="Show.Name.S01E05.mkv"
|
||||
)
|
||||
|
||||
plan = generate_plan([(video_file, identity)], config)
|
||||
|
||||
assert len(plan.operations) == 1
|
||||
operation = plan.operations[0]
|
||||
assert operation.operation_type == "move"
|
||||
assert operation.source_path == video_file.path
|
||||
assert operation.destination_path == Path("/mnt/nas/videos/series/Show Name/Season 01/S01E05.mkv")
|
||||
assert not operation.has_conflict
|
||||
|
||||
|
||||
def test_generate_plan_for_series_without_season(config):
|
||||
"""Test plan generation for a series without season (needs review)."""
|
||||
video_file = VideoFile(
|
||||
path=Path("/mnt/nas/videos/series/ambiguous_show.mkv"),
|
||||
filename="ambiguous_show.mkv",
|
||||
size_bytes=500000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="series"
|
||||
)
|
||||
|
||||
identity = SeriesIdentity(
|
||||
title="Ambiguous Show",
|
||||
season=None,
|
||||
episodes=[],
|
||||
confidence=0.3,
|
||||
needs_review=True,
|
||||
original_filename="ambiguous_show.mkv"
|
||||
)
|
||||
|
||||
plan = generate_plan([(video_file, identity)], config)
|
||||
|
||||
assert len(plan.operations) == 1
|
||||
operation = plan.operations[0]
|
||||
assert operation.operation_type == "no-op"
|
||||
assert operation.destination_path is None
|
||||
assert "manual review" in operation.reason.lower()
|
||||
|
||||
|
||||
def test_generate_plan_for_anime_category(config):
|
||||
"""Test plan generation for anime files (no-op in v1)."""
|
||||
video_file = VideoFile(
|
||||
path=Path("/mnt/nas/videos/anime/Some.Anime.01.mkv"),
|
||||
filename="Some.Anime.01.mkv",
|
||||
size_bytes=500000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="anime"
|
||||
)
|
||||
|
||||
# Anime files don't get parsed in v1, so identity is None
|
||||
plan = generate_plan([(video_file, None)], config)
|
||||
|
||||
assert len(plan.operations) == 1
|
||||
operation = plan.operations[0]
|
||||
assert operation.operation_type == "no-op"
|
||||
assert operation.destination_path is None
|
||||
assert "anime" in operation.reason.lower() or "not organized" in operation.reason.lower()
|
||||
|
||||
|
||||
def test_generate_plan_for_other_category(config):
|
||||
"""Test plan generation for other category files (no-op in v1)."""
|
||||
video_file = VideoFile(
|
||||
path=Path("/mnt/nas/videos/other/random.mkv"),
|
||||
filename="random.mkv",
|
||||
size_bytes=500000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="other"
|
||||
)
|
||||
|
||||
plan = generate_plan([(video_file, None)], config)
|
||||
|
||||
assert len(plan.operations) == 1
|
||||
operation = plan.operations[0]
|
||||
assert operation.operation_type == "no-op"
|
||||
assert operation.destination_path is None
|
||||
assert "other" in operation.reason.lower() or "not organized" in operation.reason.lower()
|
||||
|
||||
|
||||
def test_generate_plan_preserves_category_boundaries(config):
|
||||
"""Test that plan generation preserves category boundaries."""
|
||||
movie_file = VideoFile(
|
||||
path=Path("/mnt/nas/videos/movie/Movie.2020.mkv"),
|
||||
filename="Movie.2020.mkv",
|
||||
size_bytes=1000000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="movie"
|
||||
)
|
||||
|
||||
movie_identity = MovieIdentity(
|
||||
title="Movie",
|
||||
year=2020,
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename="Movie.2020.mkv"
|
||||
)
|
||||
|
||||
plan = generate_plan([(movie_file, movie_identity)], config)
|
||||
|
||||
operation = plan.operations[0]
|
||||
# Destination should still be in movie category
|
||||
assert str(operation.destination_path).startswith(str(config.library_root / "movie"))
|
||||
|
||||
|
||||
def test_generate_plan_for_multi_episode_file(config):
|
||||
"""Test plan generation for multi-episode files."""
|
||||
video_file = VideoFile(
|
||||
path=Path("/mnt/nas/videos/series/Show.S01E01E02.mkv"),
|
||||
filename="Show.S01E01E02.mkv",
|
||||
size_bytes=1000000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="series"
|
||||
)
|
||||
|
||||
identity = SeriesIdentity(
|
||||
title="Show",
|
||||
season=1,
|
||||
episodes=[1, 2], # Multi-episode
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename="Show.S01E01E02.mkv"
|
||||
)
|
||||
|
||||
plan = generate_plan([(video_file, identity)], config)
|
||||
|
||||
operation = plan.operations[0]
|
||||
# Should use first episode number for filename
|
||||
assert operation.destination_path == Path("/mnt/nas/videos/series/Show/Season 01/S01E01.mkv")
|
||||
|
||||
|
||||
def test_generate_plan_file_already_at_target(config):
|
||||
"""Test plan generation when file is already at target location."""
|
||||
# File already in correct location
|
||||
video_file = VideoFile(
|
||||
path=Path("/mnt/nas/videos/movie/Some Movie (2020)/Some Movie (2020).mkv"),
|
||||
filename="Some Movie (2020).mkv",
|
||||
size_bytes=1000000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="movie"
|
||||
)
|
||||
|
||||
identity = MovieIdentity(
|
||||
title="Some Movie",
|
||||
year=2020,
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename="Some Movie (2020).mkv"
|
||||
)
|
||||
|
||||
plan = generate_plan([(video_file, identity)], config)
|
||||
|
||||
operation = plan.operations[0]
|
||||
assert operation.operation_type == "no-op"
|
||||
assert "already at target" in operation.reason.lower()
|
||||
|
||||
|
||||
def test_generate_plan_rename_vs_move(config):
|
||||
"""Test that plan distinguishes between rename and move operations."""
|
||||
# File in correct directory but wrong name (rename)
|
||||
video_file_rename = VideoFile(
|
||||
path=Path("/mnt/nas/videos/movie/Some Movie (2020)/old_name.mkv"),
|
||||
filename="old_name.mkv",
|
||||
size_bytes=1000000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="movie"
|
||||
)
|
||||
|
||||
identity = MovieIdentity(
|
||||
title="Some Movie",
|
||||
year=2020,
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename="old_name.mkv"
|
||||
)
|
||||
|
||||
plan = generate_plan([(video_file_rename, identity)], config)
|
||||
operation = plan.operations[0]
|
||||
assert operation.operation_type == "rename"
|
||||
|
||||
# File in wrong directory (move)
|
||||
video_file_move = VideoFile(
|
||||
path=Path("/mnt/nas/videos/movie/wrong_dir/Some Movie (2020).mkv"),
|
||||
filename="Some Movie (2020).mkv",
|
||||
size_bytes=1000000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="movie"
|
||||
)
|
||||
|
||||
plan = generate_plan([(video_file_move, identity)], config)
|
||||
operation = plan.operations[0]
|
||||
assert operation.operation_type == "move"
|
||||
|
||||
|
||||
def test_generate_plan_summary(config):
|
||||
"""Test that plan summary contains accurate counts."""
|
||||
files_and_identities = [
|
||||
# Movie with year (move)
|
||||
(
|
||||
VideoFile(
|
||||
path=Path("/mnt/nas/videos/movie/Movie1.2020.mkv"),
|
||||
filename="Movie1.2020.mkv",
|
||||
size_bytes=1000000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="movie"
|
||||
),
|
||||
MovieIdentity(
|
||||
title="Movie1",
|
||||
year=2020,
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename="Movie1.2020.mkv"
|
||||
)
|
||||
),
|
||||
# Movie without year (no-op)
|
||||
(
|
||||
VideoFile(
|
||||
path=Path("/mnt/nas/videos/movie/Movie2.mkv"),
|
||||
filename="Movie2.mkv",
|
||||
size_bytes=1000000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="movie"
|
||||
),
|
||||
MovieIdentity(
|
||||
title="Movie2",
|
||||
year=None,
|
||||
confidence=0.3,
|
||||
needs_review=True,
|
||||
original_filename="Movie2.mkv"
|
||||
)
|
||||
),
|
||||
# Anime (no-op)
|
||||
(
|
||||
VideoFile(
|
||||
path=Path("/mnt/nas/videos/anime/Anime.01.mkv"),
|
||||
filename="Anime.01.mkv",
|
||||
size_bytes=500000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="anime"
|
||||
),
|
||||
None
|
||||
),
|
||||
# Series with season/episode (move)
|
||||
(
|
||||
VideoFile(
|
||||
path=Path("/mnt/nas/videos/series/Show.S01E01.mkv"),
|
||||
filename="Show.S01E01.mkv",
|
||||
size_bytes=500000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="series"
|
||||
),
|
||||
SeriesIdentity(
|
||||
title="Show",
|
||||
season=1,
|
||||
episodes=[1],
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename="Show.S01E01.mkv"
|
||||
)
|
||||
),
|
||||
]
|
||||
|
||||
plan = generate_plan(files_and_identities, config)
|
||||
|
||||
assert plan.summary["total"] == 4
|
||||
assert plan.summary["move"] == 2
|
||||
assert plan.summary["no-op"] == 2
|
||||
assert plan.summary["rename"] == 0
|
||||
assert plan.summary["quarantine"] == 0
|
||||
|
||||
|
||||
def test_generate_plan_with_different_extensions(config):
|
||||
"""Test plan generation preserves file extensions."""
|
||||
extensions = [".mp4", ".mkv", ".avi"]
|
||||
|
||||
for ext in extensions:
|
||||
video_file = VideoFile(
|
||||
path=Path(f"/mnt/nas/videos/movie/Movie.2020{ext}"),
|
||||
filename=f"Movie.2020{ext}",
|
||||
size_bytes=1000000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="movie"
|
||||
)
|
||||
|
||||
identity = MovieIdentity(
|
||||
title="Movie",
|
||||
year=2020,
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename=f"Movie.2020{ext}"
|
||||
)
|
||||
|
||||
plan = generate_plan([(video_file, identity)], config)
|
||||
operation = plan.operations[0]
|
||||
|
||||
# Check that extension is preserved
|
||||
assert operation.destination_path.suffix == ext
|
||||
|
||||
|
||||
def test_conflict_detection_for_movie(config, tmp_path):
|
||||
"""Test conflict detection when destination movie file already exists."""
|
||||
# Set up config with tmp_path as library root
|
||||
config.library_root = tmp_path
|
||||
|
||||
# Create destination directory and file
|
||||
dest_dir = tmp_path / "movie" / "Some Movie (2020)"
|
||||
dest_dir.mkdir(parents=True)
|
||||
dest_file = dest_dir / "Some Movie (2020).mkv"
|
||||
dest_file.touch() # Create the file
|
||||
|
||||
# Source file in different location
|
||||
video_file = VideoFile(
|
||||
path=tmp_path / "movie" / "Some.Movie.2020.1080p.mkv",
|
||||
filename="Some.Movie.2020.1080p.mkv",
|
||||
size_bytes=1000000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="movie"
|
||||
)
|
||||
|
||||
identity = MovieIdentity(
|
||||
title="Some Movie",
|
||||
year=2020,
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename="Some.Movie.2020.1080p.mkv"
|
||||
)
|
||||
|
||||
plan = generate_plan([(video_file, identity)], config)
|
||||
|
||||
operation = plan.operations[0]
|
||||
assert operation.has_conflict is True
|
||||
assert operation.conflict_reason is not None
|
||||
assert "already exists" in operation.conflict_reason.lower()
|
||||
assert str(dest_file) in operation.conflict_reason
|
||||
|
||||
|
||||
def test_conflict_detection_for_series(config, tmp_path):
|
||||
"""Test conflict detection when destination series file already exists."""
|
||||
# Set up config with tmp_path as library root
|
||||
config.library_root = tmp_path
|
||||
|
||||
# Create destination directory and file
|
||||
dest_dir = tmp_path / "series" / "Show Name" / "Season 01"
|
||||
dest_dir.mkdir(parents=True)
|
||||
dest_file = dest_dir / "S01E05.mkv"
|
||||
dest_file.touch() # Create the file
|
||||
|
||||
# Source file in different location
|
||||
video_file = VideoFile(
|
||||
path=tmp_path / "series" / "Show.Name.S01E05.1080p.mkv",
|
||||
filename="Show.Name.S01E05.1080p.mkv",
|
||||
size_bytes=500000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="series"
|
||||
)
|
||||
|
||||
identity = SeriesIdentity(
|
||||
title="Show Name",
|
||||
season=1,
|
||||
episodes=[5],
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename="Show.Name.S01E05.1080p.mkv"
|
||||
)
|
||||
|
||||
plan = generate_plan([(video_file, identity)], config)
|
||||
|
||||
operation = plan.operations[0]
|
||||
assert operation.has_conflict is True
|
||||
assert operation.conflict_reason is not None
|
||||
assert "already exists" in operation.conflict_reason.lower()
|
||||
assert str(dest_file) in operation.conflict_reason
|
||||
|
||||
|
||||
def test_no_conflict_when_destination_does_not_exist(config, tmp_path):
|
||||
"""Test that no conflict is detected when destination file does not exist."""
|
||||
# Set up config with tmp_path as library root
|
||||
config.library_root = tmp_path
|
||||
|
||||
# Create source directory but NOT destination
|
||||
source_dir = tmp_path / "movie"
|
||||
source_dir.mkdir(parents=True)
|
||||
|
||||
video_file = VideoFile(
|
||||
path=source_dir / "Some.Movie.2020.mkv",
|
||||
filename="Some.Movie.2020.mkv",
|
||||
size_bytes=1000000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="movie"
|
||||
)
|
||||
|
||||
identity = MovieIdentity(
|
||||
title="Some Movie",
|
||||
year=2020,
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename="Some.Movie.2020.mkv"
|
||||
)
|
||||
|
||||
plan = generate_plan([(video_file, identity)], config)
|
||||
|
||||
operation = plan.operations[0]
|
||||
assert operation.has_conflict is False
|
||||
assert operation.conflict_reason is None
|
||||
|
||||
|
||||
def test_conflict_detection_with_multiple_files(config, tmp_path):
|
||||
"""Test conflict detection with multiple files, some with conflicts."""
|
||||
# Set up config with tmp_path as library root
|
||||
config.library_root = tmp_path
|
||||
|
||||
# Create destination for first movie (conflict)
|
||||
dest_dir1 = tmp_path / "movie" / "Movie1 (2020)"
|
||||
dest_dir1.mkdir(parents=True)
|
||||
(dest_dir1 / "Movie1 (2020).mkv").touch()
|
||||
|
||||
# Don't create destination for second movie (no conflict)
|
||||
|
||||
files_and_identities = [
|
||||
# Movie 1 - has conflict
|
||||
(
|
||||
VideoFile(
|
||||
path=tmp_path / "movie" / "Movie1.2020.mkv",
|
||||
filename="Movie1.2020.mkv",
|
||||
size_bytes=1000000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="movie"
|
||||
),
|
||||
MovieIdentity(
|
||||
title="Movie1",
|
||||
year=2020,
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename="Movie1.2020.mkv"
|
||||
)
|
||||
),
|
||||
# Movie 2 - no conflict
|
||||
(
|
||||
VideoFile(
|
||||
path=tmp_path / "movie" / "Movie2.2021.mkv",
|
||||
filename="Movie2.2021.mkv",
|
||||
size_bytes=1000000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="movie"
|
||||
),
|
||||
MovieIdentity(
|
||||
title="Movie2",
|
||||
year=2021,
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename="Movie2.2021.mkv"
|
||||
)
|
||||
),
|
||||
]
|
||||
|
||||
plan = generate_plan(files_and_identities, config)
|
||||
|
||||
# First operation should have conflict
|
||||
assert plan.operations[0].has_conflict is True
|
||||
assert plan.operations[0].conflict_reason is not None
|
||||
|
||||
# Second operation should not have conflict
|
||||
assert plan.operations[1].has_conflict is False
|
||||
assert plan.operations[1].conflict_reason is None
|
||||
|
||||
|
||||
def test_save_plan_to_json(config, tmp_path):
|
||||
"""Test saving execution plan to JSON file."""
|
||||
from vlm.planner import save_plan
|
||||
|
||||
# Create a simple plan
|
||||
video_file = VideoFile(
|
||||
path=Path("/mnt/nas/videos/movie/Movie.2020.mkv"),
|
||||
filename="Movie.2020.mkv",
|
||||
size_bytes=1000000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="movie"
|
||||
)
|
||||
|
||||
identity = MovieIdentity(
|
||||
title="Movie",
|
||||
year=2020,
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename="Movie.2020.mkv"
|
||||
)
|
||||
|
||||
plan = generate_plan([(video_file, identity)], config)
|
||||
|
||||
# Save to JSON
|
||||
output_path = tmp_path / "plan.json"
|
||||
save_plan(plan, output_path)
|
||||
|
||||
# Verify file was created
|
||||
assert output_path.exists()
|
||||
|
||||
# Verify JSON is valid and contains expected fields
|
||||
import json
|
||||
with open(output_path, 'r') as f:
|
||||
plan_dict = json.load(f)
|
||||
|
||||
assert "plan_id" in plan_dict
|
||||
assert "created_at" in plan_dict
|
||||
assert "operations" in plan_dict
|
||||
assert "summary" in plan_dict
|
||||
|
||||
assert plan_dict["plan_id"] == plan.plan_id
|
||||
assert len(plan_dict["operations"]) == 1
|
||||
assert plan_dict["summary"]["total"] == 1
|
||||
|
||||
|
||||
def test_load_plan_from_json(config, tmp_path):
|
||||
"""Test loading execution plan from JSON file."""
|
||||
from vlm.planner import save_plan, load_plan
|
||||
|
||||
# Create and save a plan
|
||||
video_file = VideoFile(
|
||||
path=Path("/mnt/nas/videos/movie/Movie.2020.mkv"),
|
||||
filename="Movie.2020.mkv",
|
||||
size_bytes=1000000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="movie"
|
||||
)
|
||||
|
||||
identity = MovieIdentity(
|
||||
title="Movie",
|
||||
year=2020,
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename="Movie.2020.mkv"
|
||||
)
|
||||
|
||||
original_plan = generate_plan([(video_file, identity)], config)
|
||||
|
||||
output_path = tmp_path / "plan.json"
|
||||
save_plan(original_plan, output_path)
|
||||
|
||||
# Load the plan
|
||||
loaded_plan = load_plan(output_path)
|
||||
|
||||
# Verify loaded plan matches original
|
||||
assert loaded_plan.plan_id == original_plan.plan_id
|
||||
assert loaded_plan.created_at == original_plan.created_at
|
||||
assert len(loaded_plan.operations) == len(original_plan.operations)
|
||||
assert loaded_plan.summary == original_plan.summary
|
||||
|
||||
# Verify operation details
|
||||
loaded_op = loaded_plan.operations[0]
|
||||
original_op = original_plan.operations[0]
|
||||
|
||||
assert loaded_op.operation_type == original_op.operation_type
|
||||
assert loaded_op.source_path == original_op.source_path
|
||||
assert loaded_op.destination_path == original_op.destination_path
|
||||
assert loaded_op.reason == original_op.reason
|
||||
assert loaded_op.has_conflict == original_op.has_conflict
|
||||
assert loaded_op.conflict_reason == original_op.conflict_reason
|
||||
|
||||
|
||||
def test_save_and_load_plan_with_conflicts(config, tmp_path):
|
||||
"""Test saving and loading plan with conflict information."""
|
||||
from vlm.planner import save_plan, load_plan
|
||||
|
||||
# Set up config with tmp_path as library root
|
||||
config.library_root = tmp_path
|
||||
|
||||
# Create destination file to trigger conflict
|
||||
dest_dir = tmp_path / "movie" / "Movie (2020)"
|
||||
dest_dir.mkdir(parents=True)
|
||||
(dest_dir / "Movie (2020).mkv").touch()
|
||||
|
||||
video_file = VideoFile(
|
||||
path=tmp_path / "movie" / "Movie.2020.mkv",
|
||||
filename="Movie.2020.mkv",
|
||||
size_bytes=1000000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="movie"
|
||||
)
|
||||
|
||||
identity = MovieIdentity(
|
||||
title="Movie",
|
||||
year=2020,
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename="Movie.2020.mkv"
|
||||
)
|
||||
|
||||
original_plan = generate_plan([(video_file, identity)], config)
|
||||
|
||||
# Verify conflict was detected
|
||||
assert original_plan.operations[0].has_conflict is True
|
||||
|
||||
# Save and load
|
||||
output_path = tmp_path / "plan_with_conflict.json"
|
||||
save_plan(original_plan, output_path)
|
||||
loaded_plan = load_plan(output_path)
|
||||
|
||||
# Verify conflict information is preserved
|
||||
assert loaded_plan.operations[0].has_conflict is True
|
||||
assert loaded_plan.operations[0].conflict_reason is not None
|
||||
assert "already exists" in loaded_plan.operations[0].conflict_reason.lower()
|
||||
|
||||
|
||||
def test_save_and_load_plan_with_no_op_operations(config, tmp_path):
|
||||
"""Test saving and loading plan with no-op operations."""
|
||||
from vlm.planner import save_plan, load_plan
|
||||
|
||||
# Create files that will generate no-op operations
|
||||
files_and_identities = [
|
||||
# Anime (no-op)
|
||||
(
|
||||
VideoFile(
|
||||
path=Path("/mnt/nas/videos/anime/Anime.01.mkv"),
|
||||
filename="Anime.01.mkv",
|
||||
size_bytes=500000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="anime"
|
||||
),
|
||||
None
|
||||
),
|
||||
# Movie without year (no-op)
|
||||
(
|
||||
VideoFile(
|
||||
path=Path("/mnt/nas/videos/movie/Movie.mkv"),
|
||||
filename="Movie.mkv",
|
||||
size_bytes=1000000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="movie"
|
||||
),
|
||||
MovieIdentity(
|
||||
title="Movie",
|
||||
year=None,
|
||||
confidence=0.3,
|
||||
needs_review=True,
|
||||
original_filename="Movie.mkv"
|
||||
)
|
||||
),
|
||||
]
|
||||
|
||||
original_plan = generate_plan(files_and_identities, config)
|
||||
|
||||
# Save and load
|
||||
output_path = tmp_path / "plan_with_noops.json"
|
||||
save_plan(original_plan, output_path)
|
||||
loaded_plan = load_plan(output_path)
|
||||
|
||||
# Verify no-op operations are preserved
|
||||
assert len(loaded_plan.operations) == 2
|
||||
assert all(op.operation_type == "no-op" for op in loaded_plan.operations)
|
||||
assert all(op.destination_path is None for op in loaded_plan.operations)
|
||||
|
||||
|
||||
def test_save_plan_json_is_human_readable(config, tmp_path):
|
||||
"""Test that saved JSON is human-readable with proper formatting."""
|
||||
from vlm.planner import save_plan
|
||||
|
||||
video_file = VideoFile(
|
||||
path=Path("/mnt/nas/videos/movie/Movie.2020.mkv"),
|
||||
filename="Movie.2020.mkv",
|
||||
size_bytes=1000000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="movie"
|
||||
)
|
||||
|
||||
identity = MovieIdentity(
|
||||
title="Movie",
|
||||
year=2020,
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename="Movie.2020.mkv"
|
||||
)
|
||||
|
||||
plan = generate_plan([(video_file, identity)], config)
|
||||
|
||||
output_path = tmp_path / "plan.json"
|
||||
save_plan(plan, output_path)
|
||||
|
||||
# Read the raw JSON content
|
||||
with open(output_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Verify it's formatted with indentation (human-readable)
|
||||
assert "\n" in content # Has newlines
|
||||
assert " " in content # Has indentation
|
||||
|
||||
# Verify it's valid JSON
|
||||
import json
|
||||
json.loads(content)
|
||||
|
||||
|
||||
def test_save_plan_with_multiple_operations(config, tmp_path):
|
||||
"""Test saving plan with multiple operations of different types."""
|
||||
from vlm.planner import save_plan, load_plan
|
||||
|
||||
files_and_identities = [
|
||||
# Movie (move)
|
||||
(
|
||||
VideoFile(
|
||||
path=Path("/mnt/nas/videos/movie/Movie1.2020.mkv"),
|
||||
filename="Movie1.2020.mkv",
|
||||
size_bytes=1000000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="movie"
|
||||
),
|
||||
MovieIdentity(
|
||||
title="Movie1",
|
||||
year=2020,
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename="Movie1.2020.mkv"
|
||||
)
|
||||
),
|
||||
# Series (move)
|
||||
(
|
||||
VideoFile(
|
||||
path=Path("/mnt/nas/videos/series/Show.S01E01.mkv"),
|
||||
filename="Show.S01E01.mkv",
|
||||
size_bytes=500000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="series"
|
||||
),
|
||||
SeriesIdentity(
|
||||
title="Show",
|
||||
season=1,
|
||||
episodes=[1],
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename="Show.S01E01.mkv"
|
||||
)
|
||||
),
|
||||
# Anime (no-op)
|
||||
(
|
||||
VideoFile(
|
||||
path=Path("/mnt/nas/videos/anime/Anime.01.mkv"),
|
||||
filename="Anime.01.mkv",
|
||||
size_bytes=500000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="anime"
|
||||
),
|
||||
None
|
||||
),
|
||||
]
|
||||
|
||||
original_plan = generate_plan(files_and_identities, config)
|
||||
|
||||
output_path = tmp_path / "multi_op_plan.json"
|
||||
save_plan(original_plan, output_path)
|
||||
loaded_plan = load_plan(output_path)
|
||||
|
||||
# Verify all operations are preserved
|
||||
assert len(loaded_plan.operations) == 3
|
||||
assert loaded_plan.summary["total"] == 3
|
||||
assert loaded_plan.summary["move"] == 2
|
||||
assert loaded_plan.summary["no-op"] == 1
|
||||
|
||||
|
||||
def test_load_plan_file_not_found(tmp_path):
|
||||
"""Test loading plan from non-existent file raises FileNotFoundError."""
|
||||
from vlm.planner import load_plan
|
||||
|
||||
non_existent_path = tmp_path / "does_not_exist.json"
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_plan(non_existent_path)
|
||||
|
||||
|
||||
def test_load_plan_invalid_json(tmp_path):
|
||||
"""Test loading plan from invalid JSON raises JSONDecodeError."""
|
||||
from vlm.planner import load_plan
|
||||
import json
|
||||
|
||||
invalid_json_path = tmp_path / "invalid.json"
|
||||
with open(invalid_json_path, 'w') as f:
|
||||
f.write("{ this is not valid json }")
|
||||
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
load_plan(invalid_json_path)
|
||||
|
||||
|
||||
def test_plan_json_includes_all_required_fields(config, tmp_path):
|
||||
"""Test that saved JSON includes plan_id, created_at, operations, and summary."""
|
||||
from vlm.planner import save_plan
|
||||
import json
|
||||
|
||||
video_file = VideoFile(
|
||||
path=Path("/mnt/nas/videos/movie/Movie.2020.mkv"),
|
||||
filename="Movie.2020.mkv",
|
||||
size_bytes=1000000,
|
||||
modified_timestamp=datetime.now(),
|
||||
category="movie"
|
||||
)
|
||||
|
||||
identity = MovieIdentity(
|
||||
title="Movie",
|
||||
year=2020,
|
||||
confidence=0.9,
|
||||
needs_review=False,
|
||||
original_filename="Movie.2020.mkv"
|
||||
)
|
||||
|
||||
plan = generate_plan([(video_file, identity)], config)
|
||||
|
||||
output_path = tmp_path / "plan.json"
|
||||
save_plan(plan, output_path)
|
||||
|
||||
with open(output_path, 'r') as f:
|
||||
plan_dict = json.load(f)
|
||||
|
||||
# Verify all required fields are present
|
||||
required_fields = ["plan_id", "created_at", "operations", "summary"]
|
||||
for field in required_fields:
|
||||
assert field in plan_dict, f"Missing required field: {field}"
|
||||
|
||||
# Verify operations have required fields
|
||||
operation = plan_dict["operations"][0]
|
||||
required_op_fields = ["operation_type", "source_path", "destination_path", "reason", "has_conflict", "conflict_reason"]
|
||||
for field in required_op_fields:
|
||||
assert field in operation, f"Missing required operation field: {field}"
|
||||
@@ -0,0 +1,909 @@
|
||||
"""Tests for quarantine manager."""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from src.vlm.quarantine import QuarantineManager
|
||||
from src.vlm.config import Config
|
||||
from src.vlm.models import QuarantineEntry, QuarantineManifest
|
||||
|
||||
|
||||
class TestQuarantineManager:
|
||||
"""Test suite for QuarantineManager."""
|
||||
|
||||
@pytest.fixture
|
||||
def config(self, tmp_path):
|
||||
"""Create a test configuration."""
|
||||
library_root = tmp_path / "library"
|
||||
library_root.mkdir()
|
||||
|
||||
# Create category directories
|
||||
(library_root / "movie").mkdir()
|
||||
(library_root / "series").mkdir()
|
||||
(library_root / "anime").mkdir()
|
||||
(library_root / "other").mkdir()
|
||||
|
||||
return Config(
|
||||
library_root=library_root,
|
||||
quarantine_dir=".quarantine"
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def manager(self, config):
|
||||
"""Create a quarantine manager instance."""
|
||||
return QuarantineManager(config)
|
||||
|
||||
def test_quarantine_movie_file(self, manager, config):
|
||||
"""Test quarantining a movie file."""
|
||||
# Create a test movie file
|
||||
movie_file = config.library_root / "movie" / "Test Movie (2020).mkv"
|
||||
movie_file.write_text("test content")
|
||||
|
||||
# Quarantine the file
|
||||
result = manager.quarantine_file(movie_file, reason="duplicate")
|
||||
|
||||
# Verify operation succeeded
|
||||
assert result.success is True
|
||||
assert result.error_message is None
|
||||
|
||||
# Verify file was moved
|
||||
assert not movie_file.exists()
|
||||
|
||||
# Verify file is in quarantine
|
||||
expected_quarantine_path = config.library_root / "movie" / ".quarantine" / "Test Movie (2020).mkv"
|
||||
assert expected_quarantine_path.exists()
|
||||
assert expected_quarantine_path.read_text() == "test content"
|
||||
|
||||
def test_quarantine_series_file(self, manager, config):
|
||||
"""Test quarantining a series file."""
|
||||
# Create a test series file with subdirectory
|
||||
series_dir = config.library_root / "series" / "Test Show" / "Season 01"
|
||||
series_dir.mkdir(parents=True)
|
||||
series_file = series_dir / "S01E01.mkv"
|
||||
series_file.write_text("test content")
|
||||
|
||||
# Quarantine the file
|
||||
result = manager.quarantine_file(series_file, reason="low quality")
|
||||
|
||||
# Verify operation succeeded
|
||||
assert result.success is True
|
||||
assert result.error_message is None
|
||||
|
||||
# Verify file was moved
|
||||
assert not series_file.exists()
|
||||
|
||||
# Verify file is in quarantine with preserved structure
|
||||
expected_quarantine_path = (
|
||||
config.library_root / "series" / ".quarantine" / "Test Show" / "Season 01" / "S01E01.mkv"
|
||||
)
|
||||
assert expected_quarantine_path.exists()
|
||||
assert expected_quarantine_path.read_text() == "test content"
|
||||
|
||||
def test_quarantine_anime_file_rejected(self, manager, config):
|
||||
"""Test that quarantining anime files is rejected."""
|
||||
# Create a test anime file
|
||||
anime_file = config.library_root / "anime" / "Test Anime.mkv"
|
||||
anime_file.write_text("test content")
|
||||
|
||||
# Attempt to quarantine should raise ValueError
|
||||
with pytest.raises(ValueError, match="Quarantine not supported for category 'anime'"):
|
||||
manager.quarantine_file(anime_file)
|
||||
|
||||
# Verify file was not moved
|
||||
assert anime_file.exists()
|
||||
|
||||
def test_quarantine_other_file_rejected(self, manager, config):
|
||||
"""Test that quarantining other files is rejected."""
|
||||
# Create a test other file
|
||||
other_file = config.library_root / "other" / "Test File.mkv"
|
||||
other_file.write_text("test content")
|
||||
|
||||
# Attempt to quarantine should raise ValueError
|
||||
with pytest.raises(ValueError, match="Quarantine not supported for category 'other'"):
|
||||
manager.quarantine_file(other_file)
|
||||
|
||||
# Verify file was not moved
|
||||
assert other_file.exists()
|
||||
|
||||
def test_quarantine_conflict_handling(self, manager, config):
|
||||
"""Test that destination conflicts are handled with numeric suffixes."""
|
||||
# Create a test movie file
|
||||
movie_file = config.library_root / "movie" / "Test Movie (2020).mkv"
|
||||
movie_file.write_text("original content")
|
||||
|
||||
# Create a conflicting file in quarantine
|
||||
quarantine_dir = config.library_root / "movie" / ".quarantine"
|
||||
quarantine_dir.mkdir()
|
||||
existing_file = quarantine_dir / "Test Movie (2020).mkv"
|
||||
existing_file.write_text("existing content")
|
||||
|
||||
# Quarantine the file
|
||||
result = manager.quarantine_file(movie_file)
|
||||
|
||||
# Verify operation succeeded
|
||||
assert result.success is True
|
||||
|
||||
# Verify original file was moved
|
||||
assert not movie_file.exists()
|
||||
|
||||
# Verify existing file is unchanged
|
||||
assert existing_file.exists()
|
||||
assert existing_file.read_text() == "existing content"
|
||||
|
||||
# Verify new file has numeric suffix
|
||||
new_file = quarantine_dir / "Test Movie (2020)_1.mkv"
|
||||
assert new_file.exists()
|
||||
assert new_file.read_text() == "original content"
|
||||
|
||||
def test_quarantine_multiple_conflicts(self, manager, config):
|
||||
"""Test handling multiple conflicts with incrementing suffixes."""
|
||||
# Create quarantine directory with existing files
|
||||
quarantine_dir = config.library_root / "movie" / ".quarantine"
|
||||
quarantine_dir.mkdir()
|
||||
|
||||
# Create existing files
|
||||
(quarantine_dir / "Test Movie (2020).mkv").write_text("file 0")
|
||||
(quarantine_dir / "Test Movie (2020)_1.mkv").write_text("file 1")
|
||||
(quarantine_dir / "Test Movie (2020)_2.mkv").write_text("file 2")
|
||||
|
||||
# Create new file to quarantine
|
||||
movie_file = config.library_root / "movie" / "Test Movie (2020).mkv"
|
||||
movie_file.write_text("file 3")
|
||||
|
||||
# Quarantine the file
|
||||
result = manager.quarantine_file(movie_file)
|
||||
|
||||
# Verify operation succeeded
|
||||
assert result.success is True
|
||||
|
||||
# Verify new file has suffix _3
|
||||
new_file = quarantine_dir / "Test Movie (2020)_3.mkv"
|
||||
assert new_file.exists()
|
||||
assert new_file.read_text() == "file 3"
|
||||
|
||||
def test_quarantine_nonexistent_file(self, manager, config):
|
||||
"""Test quarantining a file that doesn't exist."""
|
||||
# Try to quarantine non-existent file
|
||||
nonexistent_file = config.library_root / "movie" / "Nonexistent.mkv"
|
||||
|
||||
result = manager.quarantine_file(nonexistent_file)
|
||||
|
||||
# Verify operation failed
|
||||
assert result.success is False
|
||||
assert "does not exist" in result.error_message
|
||||
|
||||
def test_quarantine_preserves_directory_structure(self, manager, config):
|
||||
"""Test that quarantine preserves relative directory structure."""
|
||||
# Create a deeply nested series file
|
||||
series_path = config.library_root / "series" / "Show" / "Season 02" / "Extras"
|
||||
series_path.mkdir(parents=True)
|
||||
series_file = series_path / "Behind the Scenes.mkv"
|
||||
series_file.write_text("test content")
|
||||
|
||||
# Quarantine the file
|
||||
result = manager.quarantine_file(series_file)
|
||||
|
||||
# Verify operation succeeded
|
||||
assert result.success is True
|
||||
|
||||
# Verify structure is preserved in quarantine
|
||||
expected_path = (
|
||||
config.library_root / "series" / ".quarantine" /
|
||||
"Show" / "Season 02" / "Extras" / "Behind the Scenes.mkv"
|
||||
)
|
||||
assert expected_path.exists()
|
||||
assert expected_path.read_text() == "test content"
|
||||
|
||||
def test_determine_category_movie(self, manager, config):
|
||||
"""Test category determination for movie files."""
|
||||
movie_file = config.library_root / "movie" / "Test.mkv"
|
||||
category = manager._determine_category(movie_file)
|
||||
assert category == "movie"
|
||||
|
||||
def test_determine_category_series(self, manager, config):
|
||||
"""Test category determination for series files."""
|
||||
series_file = config.library_root / "series" / "Show" / "S01E01.mkv"
|
||||
category = manager._determine_category(series_file)
|
||||
assert category == "series"
|
||||
|
||||
def test_determine_category_anime(self, manager, config):
|
||||
"""Test category determination for anime files."""
|
||||
anime_file = config.library_root / "anime" / "Test.mkv"
|
||||
category = manager._determine_category(anime_file)
|
||||
assert category == "anime"
|
||||
|
||||
def test_determine_category_other(self, manager, config):
|
||||
"""Test category determination for other files."""
|
||||
other_file = config.library_root / "other" / "Test.mkv"
|
||||
category = manager._determine_category(other_file)
|
||||
assert category == "other"
|
||||
|
||||
def test_determine_category_outside_library(self, manager, config):
|
||||
"""Test category determination for files outside library."""
|
||||
outside_file = Path("/tmp/Test.mkv")
|
||||
category = manager._determine_category(outside_file)
|
||||
assert category == "other"
|
||||
|
||||
def test_resolve_conflict_no_conflict(self, manager, config):
|
||||
"""Test conflict resolution when no conflict exists."""
|
||||
test_path = config.library_root / "movie" / ".quarantine" / "Test.mkv"
|
||||
resolved = manager._resolve_conflict(test_path)
|
||||
assert resolved == test_path
|
||||
|
||||
def test_resolve_conflict_with_conflict(self, manager, config):
|
||||
"""Test conflict resolution when conflict exists."""
|
||||
quarantine_dir = config.library_root / "movie" / ".quarantine"
|
||||
quarantine_dir.mkdir(parents=True)
|
||||
|
||||
# Create existing file
|
||||
existing = quarantine_dir / "Test.mkv"
|
||||
existing.write_text("existing")
|
||||
|
||||
# Resolve conflict
|
||||
resolved = manager._resolve_conflict(existing)
|
||||
|
||||
# Should return path with _1 suffix
|
||||
assert resolved == quarantine_dir / "Test_1.mkv"
|
||||
assert not resolved.exists()
|
||||
|
||||
|
||||
class TestQuarantineManifest:
|
||||
"""Test suite for quarantine manifest management."""
|
||||
|
||||
@pytest.fixture
|
||||
def config(self, tmp_path):
|
||||
"""Create a test configuration."""
|
||||
library_root = tmp_path / "library"
|
||||
library_root.mkdir()
|
||||
|
||||
# Create category directories
|
||||
(library_root / "movie").mkdir()
|
||||
(library_root / "series").mkdir()
|
||||
|
||||
return Config(
|
||||
library_root=library_root,
|
||||
quarantine_dir=".quarantine"
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def manager(self, config):
|
||||
"""Create a quarantine manager instance."""
|
||||
return QuarantineManager(config)
|
||||
|
||||
def test_manifest_created_on_first_quarantine(self, manager, config):
|
||||
"""Test that manifest is created when first file is quarantined."""
|
||||
# Create a test movie file
|
||||
movie_file = config.library_root / "movie" / "Test Movie (2020).mkv"
|
||||
movie_file.write_text("test content")
|
||||
|
||||
# Quarantine the file
|
||||
result = manager.quarantine_file(movie_file, reason="duplicate")
|
||||
|
||||
# Verify operation succeeded
|
||||
assert result.success is True
|
||||
|
||||
# Verify manifest was created
|
||||
manifest_path = config.library_root / "movie" / ".quarantine" / "manifest.json"
|
||||
assert manifest_path.exists()
|
||||
|
||||
# Load and verify manifest content
|
||||
with open(manifest_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
assert 'entries' in data
|
||||
assert len(data['entries']) == 1
|
||||
|
||||
entry = data['entries'][0]
|
||||
assert entry['original_path'] == str(movie_file)
|
||||
assert entry['category'] == "movie"
|
||||
assert entry['reason'] == "duplicate"
|
||||
assert entry['size_bytes'] == len("test content")
|
||||
assert 'quarantined_at' in entry
|
||||
assert 'quarantine_path' in entry
|
||||
|
||||
def test_manifest_updated_on_subsequent_quarantine(self, manager, config):
|
||||
"""Test that manifest is updated when additional files are quarantined."""
|
||||
# Create and quarantine first file
|
||||
movie_file1 = config.library_root / "movie" / "Movie1.mkv"
|
||||
movie_file1.write_text("content1")
|
||||
manager.quarantine_file(movie_file1, reason="duplicate")
|
||||
|
||||
# Create and quarantine second file
|
||||
movie_file2 = config.library_root / "movie" / "Movie2.mkv"
|
||||
movie_file2.write_text("content2")
|
||||
manager.quarantine_file(movie_file2, reason="low quality")
|
||||
|
||||
# Load manifest
|
||||
manifest_path = config.library_root / "movie" / ".quarantine" / "manifest.json"
|
||||
with open(manifest_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Verify both entries are in manifest
|
||||
assert len(data['entries']) == 2
|
||||
|
||||
# Verify first entry
|
||||
entry1 = data['entries'][0]
|
||||
assert entry1['original_path'] == str(movie_file1)
|
||||
assert entry1['reason'] == "duplicate"
|
||||
|
||||
# Verify second entry
|
||||
entry2 = data['entries'][1]
|
||||
assert entry2['original_path'] == str(movie_file2)
|
||||
assert entry2['reason'] == "low quality"
|
||||
|
||||
def test_manifest_includes_all_required_fields(self, manager, config):
|
||||
"""Test that manifest entries include all required fields."""
|
||||
# Create a test series file
|
||||
series_dir = config.library_root / "series" / "Show" / "Season 01"
|
||||
series_dir.mkdir(parents=True)
|
||||
series_file = series_dir / "S01E01.mkv"
|
||||
series_file.write_text("test content")
|
||||
|
||||
# Quarantine the file
|
||||
manager.quarantine_file(series_file, reason="test reason")
|
||||
|
||||
# Load manifest
|
||||
manifest_path = config.library_root / "series" / ".quarantine" / "manifest.json"
|
||||
with open(manifest_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
entry = data['entries'][0]
|
||||
|
||||
# Verify all required fields are present
|
||||
assert 'original_path' in entry
|
||||
assert 'quarantine_path' in entry
|
||||
assert 'quarantined_at' in entry
|
||||
assert 'reason' in entry
|
||||
assert 'size_bytes' in entry
|
||||
assert 'category' in entry
|
||||
|
||||
# Verify field values
|
||||
assert entry['original_path'] == str(series_file)
|
||||
assert entry['category'] == "series"
|
||||
assert entry['reason'] == "test reason"
|
||||
assert entry['size_bytes'] == len("test content")
|
||||
|
||||
# Verify timestamp is valid ISO format
|
||||
datetime.fromisoformat(entry['quarantined_at'])
|
||||
|
||||
def test_manifest_separate_per_category(self, manager, config):
|
||||
"""Test that each category has its own manifest."""
|
||||
# Create and quarantine movie file
|
||||
movie_file = config.library_root / "movie" / "Movie.mkv"
|
||||
movie_file.write_text("movie content")
|
||||
manager.quarantine_file(movie_file)
|
||||
|
||||
# Create and quarantine series file
|
||||
series_file = config.library_root / "series" / "S01E01.mkv"
|
||||
series_file.write_text("series content")
|
||||
manager.quarantine_file(series_file)
|
||||
|
||||
# Verify separate manifests exist
|
||||
movie_manifest_path = config.library_root / "movie" / ".quarantine" / "manifest.json"
|
||||
series_manifest_path = config.library_root / "series" / ".quarantine" / "manifest.json"
|
||||
|
||||
assert movie_manifest_path.exists()
|
||||
assert series_manifest_path.exists()
|
||||
|
||||
# Verify movie manifest contains only movie entry
|
||||
with open(movie_manifest_path, 'r') as f:
|
||||
movie_data = json.load(f)
|
||||
assert len(movie_data['entries']) == 1
|
||||
assert movie_data['entries'][0]['category'] == "movie"
|
||||
|
||||
# Verify series manifest contains only series entry
|
||||
with open(series_manifest_path, 'r') as f:
|
||||
series_data = json.load(f)
|
||||
assert len(series_data['entries']) == 1
|
||||
assert series_data['entries'][0]['category'] == "series"
|
||||
|
||||
def test_manifest_handles_none_reason(self, manager, config):
|
||||
"""Test that manifest handles files quarantined without a reason."""
|
||||
# Create and quarantine file without reason
|
||||
movie_file = config.library_root / "movie" / "Movie.mkv"
|
||||
movie_file.write_text("content")
|
||||
manager.quarantine_file(movie_file) # No reason provided
|
||||
|
||||
# Load manifest
|
||||
manifest_path = config.library_root / "movie" / ".quarantine" / "manifest.json"
|
||||
with open(manifest_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
entry = data['entries'][0]
|
||||
|
||||
# Verify reason is None/null
|
||||
assert entry['reason'] is None
|
||||
|
||||
def test_load_manifest_empty_when_not_exists(self, manager, config):
|
||||
"""Test that loading non-existent manifest returns empty manifest."""
|
||||
manifest = manager._load_manifest("movie")
|
||||
|
||||
assert isinstance(manifest, QuarantineManifest)
|
||||
assert len(manifest.entries) == 0
|
||||
|
||||
def test_load_manifest_parses_existing_manifest(self, manager, config):
|
||||
"""Test that loading existing manifest parses entries correctly."""
|
||||
# Create manifest manually
|
||||
manifest_path = config.library_root / "movie" / ".quarantine" / "manifest.json"
|
||||
manifest_path.parent.mkdir(parents=True)
|
||||
|
||||
test_data = {
|
||||
'entries': [
|
||||
{
|
||||
'original_path': '/path/to/original.mkv',
|
||||
'quarantine_path': '/path/to/quarantine.mkv',
|
||||
'quarantined_at': '2024-01-15T10:30:00',
|
||||
'reason': 'test reason',
|
||||
'size_bytes': 1024,
|
||||
'category': 'movie'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with open(manifest_path, 'w') as f:
|
||||
json.dump(test_data, f)
|
||||
|
||||
# Load manifest
|
||||
manifest = manager._load_manifest("movie")
|
||||
|
||||
# Verify parsed correctly
|
||||
assert len(manifest.entries) == 1
|
||||
entry = manifest.entries[0]
|
||||
assert entry.original_path == Path('/path/to/original.mkv')
|
||||
assert entry.quarantine_path == Path('/path/to/quarantine.mkv')
|
||||
assert entry.quarantined_at == datetime(2024, 1, 15, 10, 30, 0)
|
||||
assert entry.reason == 'test reason'
|
||||
assert entry.size_bytes == 1024
|
||||
assert entry.category == 'movie'
|
||||
|
||||
def test_save_manifest_creates_directory(self, manager, config):
|
||||
"""Test that saving manifest creates quarantine directory if needed."""
|
||||
# Create manifest
|
||||
manifest = QuarantineManifest(entries=[])
|
||||
|
||||
# Save manifest (directory doesn't exist yet)
|
||||
manager._save_manifest("movie", manifest)
|
||||
|
||||
# Verify directory and file were created
|
||||
manifest_path = config.library_root / "movie" / ".quarantine" / "manifest.json"
|
||||
assert manifest_path.exists()
|
||||
assert manifest_path.parent.is_dir()
|
||||
|
||||
def test_manifest_preserves_utf8_characters(self, manager, config):
|
||||
"""Test that manifest correctly handles UTF-8 characters in paths and reasons."""
|
||||
# Create file with UTF-8 characters
|
||||
movie_file = config.library_root / "movie" / "Café Müller (2020).mkv"
|
||||
movie_file.write_text("content")
|
||||
|
||||
# Quarantine with UTF-8 reason
|
||||
manager.quarantine_file(movie_file, reason="Qualité insuffisante")
|
||||
|
||||
# Load manifest
|
||||
manifest_path = config.library_root / "movie" / ".quarantine" / "manifest.json"
|
||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
entry = data['entries'][0]
|
||||
|
||||
# Verify UTF-8 characters are preserved
|
||||
assert "Café Müller" in entry['original_path']
|
||||
assert entry['reason'] == "Qualité insuffisante"
|
||||
|
||||
|
||||
class TestQuarantineListing:
|
||||
"""Test suite for quarantine listing functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def config(self, tmp_path):
|
||||
"""Create a test configuration."""
|
||||
library_root = tmp_path / "library"
|
||||
library_root.mkdir()
|
||||
|
||||
# Create category directories
|
||||
(library_root / "movie").mkdir()
|
||||
(library_root / "series").mkdir()
|
||||
|
||||
return Config(
|
||||
library_root=library_root,
|
||||
quarantine_dir=".quarantine"
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def manager(self, config):
|
||||
"""Create a quarantine manager instance."""
|
||||
return QuarantineManager(config)
|
||||
|
||||
def test_list_quarantined_empty(self, manager, config):
|
||||
"""Test listing quarantined files when none exist."""
|
||||
entries = manager.list_quarantined()
|
||||
assert entries == []
|
||||
|
||||
def test_list_quarantined_single_category(self, manager, config):
|
||||
"""Test listing quarantined files from a single category."""
|
||||
# Create and quarantine movie files
|
||||
movie1 = config.library_root / "movie" / "Movie1.mkv"
|
||||
movie1.write_text("content1")
|
||||
manager.quarantine_file(movie1, reason="duplicate")
|
||||
|
||||
movie2 = config.library_root / "movie" / "Movie2.mkv"
|
||||
movie2.write_text("content2")
|
||||
manager.quarantine_file(movie2, reason="low quality")
|
||||
|
||||
# List quarantined files from movie category
|
||||
entries = manager.list_quarantined(category="movie")
|
||||
|
||||
assert len(entries) == 2
|
||||
assert all(e.category == "movie" for e in entries)
|
||||
|
||||
# Verify entries contain expected data
|
||||
original_paths = [str(e.original_path) for e in entries]
|
||||
assert str(movie1) in original_paths
|
||||
assert str(movie2) in original_paths
|
||||
|
||||
def test_list_quarantined_all_categories(self, manager, config):
|
||||
"""Test listing quarantined files from all categories."""
|
||||
# Create and quarantine movie file
|
||||
movie = config.library_root / "movie" / "Movie.mkv"
|
||||
movie.write_text("movie content")
|
||||
manager.quarantine_file(movie, reason="duplicate")
|
||||
|
||||
# Create and quarantine series file
|
||||
series = config.library_root / "series" / "S01E01.mkv"
|
||||
series.write_text("series content")
|
||||
manager.quarantine_file(series, reason="low quality")
|
||||
|
||||
# List all quarantined files
|
||||
entries = manager.list_quarantined()
|
||||
|
||||
assert len(entries) == 2
|
||||
|
||||
# Verify both categories are represented
|
||||
categories = [e.category for e in entries]
|
||||
assert "movie" in categories
|
||||
assert "series" in categories
|
||||
|
||||
def test_list_quarantined_filter_by_category(self, manager, config):
|
||||
"""Test filtering quarantined files by category."""
|
||||
# Create and quarantine files in both categories
|
||||
movie = config.library_root / "movie" / "Movie.mkv"
|
||||
movie.write_text("movie content")
|
||||
manager.quarantine_file(movie)
|
||||
|
||||
series = config.library_root / "series" / "S01E01.mkv"
|
||||
series.write_text("series content")
|
||||
manager.quarantine_file(series)
|
||||
|
||||
# List only movie files
|
||||
movie_entries = manager.list_quarantined(category="movie")
|
||||
assert len(movie_entries) == 1
|
||||
assert movie_entries[0].category == "movie"
|
||||
|
||||
# List only series files
|
||||
series_entries = manager.list_quarantined(category="series")
|
||||
assert len(series_entries) == 1
|
||||
assert series_entries[0].category == "series"
|
||||
|
||||
def test_list_quarantined_invalid_category(self, manager, config):
|
||||
"""Test listing with invalid category returns empty list."""
|
||||
entries = manager.list_quarantined(category="anime")
|
||||
assert entries == []
|
||||
|
||||
entries = manager.list_quarantined(category="other")
|
||||
assert entries == []
|
||||
|
||||
entries = manager.list_quarantined(category="invalid")
|
||||
assert entries == []
|
||||
|
||||
def test_list_quarantined_includes_all_fields(self, manager, config):
|
||||
"""Test that listed entries include all required fields."""
|
||||
# Create and quarantine file
|
||||
movie = config.library_root / "movie" / "Movie.mkv"
|
||||
movie.write_text("test content")
|
||||
manager.quarantine_file(movie, reason="test reason")
|
||||
|
||||
# List quarantined files
|
||||
entries = manager.list_quarantined()
|
||||
|
||||
assert len(entries) == 1
|
||||
entry = entries[0]
|
||||
|
||||
# Verify all fields are present
|
||||
assert entry.original_path == movie
|
||||
assert entry.quarantine_path.exists()
|
||||
assert entry.quarantined_at is not None
|
||||
assert entry.reason == "test reason"
|
||||
assert entry.size_bytes == len("test content")
|
||||
assert entry.category == "movie"
|
||||
|
||||
def test_list_quarantined_preserves_directory_structure(self, manager, config):
|
||||
"""Test that listing shows preserved directory structure."""
|
||||
# Create nested series file
|
||||
series_path = config.library_root / "series" / "Show" / "Season 01"
|
||||
series_path.mkdir(parents=True)
|
||||
series_file = series_path / "S01E01.mkv"
|
||||
series_file.write_text("content")
|
||||
|
||||
# Quarantine the file
|
||||
manager.quarantine_file(series_file)
|
||||
|
||||
# List quarantined files
|
||||
entries = manager.list_quarantined(category="series")
|
||||
|
||||
assert len(entries) == 1
|
||||
entry = entries[0]
|
||||
|
||||
# Verify quarantine path preserves structure
|
||||
expected_quarantine = (
|
||||
config.library_root / "series" / ".quarantine" /
|
||||
"Show" / "Season 01" / "S01E01.mkv"
|
||||
)
|
||||
assert entry.quarantine_path == expected_quarantine
|
||||
|
||||
def test_list_quarantined_multiple_files_same_category(self, manager, config):
|
||||
"""Test listing multiple files from the same category."""
|
||||
# Create and quarantine multiple movie files
|
||||
for i in range(5):
|
||||
movie = config.library_root / "movie" / f"Movie{i}.mkv"
|
||||
movie.write_text(f"content{i}")
|
||||
manager.quarantine_file(movie, reason=f"reason{i}")
|
||||
|
||||
# List quarantined files
|
||||
entries = manager.list_quarantined(category="movie")
|
||||
|
||||
assert len(entries) == 5
|
||||
|
||||
# Verify all entries are unique
|
||||
original_paths = [e.original_path for e in entries]
|
||||
assert len(set(original_paths)) == 5
|
||||
|
||||
|
||||
class TestQuarantineRestoration:
|
||||
"""Test suite for quarantine restoration functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def config(self, tmp_path):
|
||||
"""Create a test configuration."""
|
||||
library_root = tmp_path / "library"
|
||||
library_root.mkdir()
|
||||
|
||||
# Create category directories
|
||||
(library_root / "movie").mkdir()
|
||||
(library_root / "series").mkdir()
|
||||
|
||||
return Config(
|
||||
library_root=library_root,
|
||||
quarantine_dir=".quarantine"
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def manager(self, config):
|
||||
"""Create a quarantine manager instance."""
|
||||
return QuarantineManager(config)
|
||||
|
||||
def test_restore_movie_file(self, manager, config):
|
||||
"""Test restoring a quarantined movie file."""
|
||||
# Create and quarantine movie file
|
||||
original_path = config.library_root / "movie" / "Movie.mkv"
|
||||
original_path.write_text("test content")
|
||||
|
||||
result = manager.quarantine_file(original_path, reason="duplicate")
|
||||
assert result.success is True
|
||||
|
||||
quarantine_path = result.operation.destination_path
|
||||
|
||||
# Verify file is in quarantine
|
||||
assert quarantine_path.exists()
|
||||
assert not original_path.exists()
|
||||
|
||||
# Restore the file
|
||||
restore_result = manager.restore_from_quarantine(quarantine_path)
|
||||
|
||||
# Verify restoration succeeded
|
||||
assert restore_result.success is True
|
||||
assert restore_result.error_message is None
|
||||
|
||||
# Verify file is back at original location
|
||||
assert original_path.exists()
|
||||
assert original_path.read_text() == "test content"
|
||||
|
||||
# Verify file is removed from quarantine
|
||||
assert not quarantine_path.exists()
|
||||
|
||||
def test_restore_series_file(self, manager, config):
|
||||
"""Test restoring a quarantined series file."""
|
||||
# Create nested series file
|
||||
series_path = config.library_root / "series" / "Show" / "Season 01"
|
||||
series_path.mkdir(parents=True)
|
||||
original_path = series_path / "S01E01.mkv"
|
||||
original_path.write_text("series content")
|
||||
|
||||
# Quarantine the file
|
||||
result = manager.quarantine_file(original_path)
|
||||
quarantine_path = result.operation.destination_path
|
||||
|
||||
# Restore the file
|
||||
restore_result = manager.restore_from_quarantine(quarantine_path)
|
||||
|
||||
# Verify restoration succeeded
|
||||
assert restore_result.success is True
|
||||
|
||||
# Verify file is back with preserved structure
|
||||
assert original_path.exists()
|
||||
assert original_path.read_text() == "series content"
|
||||
assert not quarantine_path.exists()
|
||||
|
||||
def test_restore_removes_manifest_entry(self, manager, config):
|
||||
"""Test that restoration removes entry from manifest."""
|
||||
# Create and quarantine file
|
||||
original_path = config.library_root / "movie" / "Movie.mkv"
|
||||
original_path.write_text("content")
|
||||
|
||||
result = manager.quarantine_file(original_path)
|
||||
quarantine_path = result.operation.destination_path
|
||||
|
||||
# Verify manifest has entry
|
||||
manifest = manager._load_manifest("movie")
|
||||
assert len(manifest.entries) == 1
|
||||
|
||||
# Restore the file
|
||||
manager.restore_from_quarantine(quarantine_path)
|
||||
|
||||
# Verify manifest entry is removed
|
||||
manifest = manager._load_manifest("movie")
|
||||
assert len(manifest.entries) == 0
|
||||
|
||||
def test_restore_nonexistent_file(self, manager, config):
|
||||
"""Test restoring a file that doesn't exist."""
|
||||
# Try to restore non-existent file
|
||||
nonexistent = config.library_root / "movie" / ".quarantine" / "Nonexistent.mkv"
|
||||
|
||||
result = manager.restore_from_quarantine(nonexistent)
|
||||
|
||||
# Verify operation failed
|
||||
assert result.success is False
|
||||
assert "does not exist" in result.error_message
|
||||
|
||||
def test_restore_conflict_destination_exists(self, manager, config):
|
||||
"""Test restoration when original location already has a file."""
|
||||
# Create and quarantine file
|
||||
original_path = config.library_root / "movie" / "Movie.mkv"
|
||||
original_path.write_text("original content")
|
||||
|
||||
result = manager.quarantine_file(original_path)
|
||||
quarantine_path = result.operation.destination_path
|
||||
|
||||
# Create a new file at original location
|
||||
original_path.write_text("new content")
|
||||
|
||||
# Try to restore
|
||||
restore_result = manager.restore_from_quarantine(quarantine_path)
|
||||
|
||||
# Verify restoration failed due to conflict
|
||||
assert restore_result.success is False
|
||||
assert "already exists" in restore_result.error_message
|
||||
assert restore_result.operation.has_conflict is True
|
||||
|
||||
# Verify original file is unchanged
|
||||
assert original_path.read_text() == "new content"
|
||||
|
||||
# Verify quarantine file still exists
|
||||
assert quarantine_path.exists()
|
||||
|
||||
def test_restore_creates_parent_directory(self, manager, config):
|
||||
"""Test that restoration creates parent directory if needed."""
|
||||
# Create and quarantine file
|
||||
series_path = config.library_root / "series" / "Show" / "Season 01"
|
||||
series_path.mkdir(parents=True)
|
||||
original_path = series_path / "S01E01.mkv"
|
||||
original_path.write_text("content")
|
||||
|
||||
result = manager.quarantine_file(original_path)
|
||||
quarantine_path = result.operation.destination_path
|
||||
|
||||
# Remove the parent directory
|
||||
import shutil
|
||||
shutil.rmtree(series_path)
|
||||
|
||||
# Restore the file
|
||||
restore_result = manager.restore_from_quarantine(quarantine_path)
|
||||
|
||||
# Verify restoration succeeded and directory was created
|
||||
assert restore_result.success is True
|
||||
assert original_path.exists()
|
||||
assert original_path.parent.is_dir()
|
||||
|
||||
def test_restore_no_manifest_entry(self, manager, config):
|
||||
"""Test restoring a file that has no manifest entry."""
|
||||
# Create quarantine file manually without manifest entry
|
||||
quarantine_dir = config.library_root / "movie" / ".quarantine"
|
||||
quarantine_dir.mkdir(parents=True)
|
||||
quarantine_path = quarantine_dir / "Movie.mkv"
|
||||
quarantine_path.write_text("content")
|
||||
|
||||
# Try to restore
|
||||
result = manager.restore_from_quarantine(quarantine_path)
|
||||
|
||||
# Verify operation failed
|
||||
assert result.success is False
|
||||
assert "No manifest entry" in result.error_message
|
||||
|
||||
def test_restore_invalid_quarantine_path(self, manager, config):
|
||||
"""Test restoring from an invalid quarantine path."""
|
||||
# Try to restore from non-quarantine location
|
||||
invalid_path = config.library_root / "movie" / "Movie.mkv"
|
||||
invalid_path.write_text("content")
|
||||
|
||||
result = manager.restore_from_quarantine(invalid_path)
|
||||
|
||||
# Verify operation failed
|
||||
assert result.success is False
|
||||
assert "Could not determine category" in result.error_message
|
||||
|
||||
def test_restore_multiple_files(self, manager, config):
|
||||
"""Test restoring multiple quarantined files."""
|
||||
# Create and quarantine multiple files
|
||||
files = []
|
||||
quarantine_paths = []
|
||||
|
||||
for i in range(3):
|
||||
file_path = config.library_root / "movie" / f"Movie{i}.mkv"
|
||||
file_path.write_text(f"content{i}")
|
||||
files.append(file_path)
|
||||
|
||||
result = manager.quarantine_file(file_path)
|
||||
quarantine_paths.append(result.operation.destination_path)
|
||||
|
||||
# Verify all files are quarantined
|
||||
for file_path in files:
|
||||
assert not file_path.exists()
|
||||
for qpath in quarantine_paths:
|
||||
assert qpath.exists()
|
||||
|
||||
# Restore all files
|
||||
for qpath in quarantine_paths:
|
||||
result = manager.restore_from_quarantine(qpath)
|
||||
assert result.success is True
|
||||
|
||||
# Verify all files are restored
|
||||
for i, file_path in enumerate(files):
|
||||
assert file_path.exists()
|
||||
assert file_path.read_text() == f"content{i}"
|
||||
|
||||
# Verify all quarantine files are removed
|
||||
for qpath in quarantine_paths:
|
||||
assert not qpath.exists()
|
||||
|
||||
# Verify manifest is empty
|
||||
manifest = manager._load_manifest("movie")
|
||||
assert len(manifest.entries) == 0
|
||||
|
||||
def test_determine_category_from_quarantine_movie(self, manager, config):
|
||||
"""Test determining category from movie quarantine path."""
|
||||
qpath = config.library_root / "movie" / ".quarantine" / "Movie.mkv"
|
||||
category = manager._determine_category_from_quarantine(qpath)
|
||||
assert category == "movie"
|
||||
|
||||
def test_determine_category_from_quarantine_series(self, manager, config):
|
||||
"""Test determining category from series quarantine path."""
|
||||
qpath = config.library_root / "series" / ".quarantine" / "Show" / "S01E01.mkv"
|
||||
category = manager._determine_category_from_quarantine(qpath)
|
||||
assert category == "series"
|
||||
|
||||
def test_determine_category_from_quarantine_invalid(self, manager, config):
|
||||
"""Test determining category from invalid quarantine path."""
|
||||
# Not in quarantine directory
|
||||
invalid = config.library_root / "movie" / "Movie.mkv"
|
||||
category = manager._determine_category_from_quarantine(invalid)
|
||||
assert category is None
|
||||
|
||||
# Outside library root
|
||||
outside = Path("/tmp/Movie.mkv")
|
||||
category = manager._determine_category_from_quarantine(outside)
|
||||
assert category is None
|
||||
|
||||
# Unsupported category
|
||||
anime = config.library_root / "anime" / ".quarantine" / "Anime.mkv"
|
||||
category = manager._determine_category_from_quarantine(anime)
|
||||
assert category is None
|
||||
@@ -0,0 +1,694 @@
|
||||
"""Unit tests for report generation.
|
||||
|
||||
Tests inventory reports, completeness reports, duplicate reports, and summary reports in various formats.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
import pytest
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from vlm.models import SeasonCompleteness, DuplicateGroup, VideoFile, MovieIdentity, SeriesIdentity
|
||||
from vlm.reports import (
|
||||
generate_inventory_report,
|
||||
generate_completeness_report,
|
||||
generate_duplicate_report,
|
||||
generate_summary_report,
|
||||
_format_episode_list,
|
||||
_format_size,
|
||||
_format_duration
|
||||
)
|
||||
|
||||
|
||||
class TestInventoryReport:
|
||||
"""Test inventory report generation."""
|
||||
|
||||
def test_generate_csv_report(self):
|
||||
"""Test generating CSV format inventory report."""
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/movies/Movie1.mkv"),
|
||||
"Movie1.mkv",
|
||||
2000000000,
|
||||
datetime(2023, 1, 15, 10, 30, 0),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264",
|
||||
duration_seconds=7200.0,
|
||||
bitrate_kbps=5000
|
||||
),
|
||||
VideoFile(
|
||||
Path("/series/Show.S01E01.mkv"),
|
||||
"Show.S01E01.mkv",
|
||||
1000000000,
|
||||
datetime(2023, 2, 20, 14, 45, 0),
|
||||
"series",
|
||||
resolution="1280x720",
|
||||
codec="h265"
|
||||
),
|
||||
]
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
report = generate_inventory_report(files, "csv", library_root)
|
||||
|
||||
# Check metadata comments
|
||||
assert "# Generated:" in report
|
||||
assert f"# Library Root: {library_root}" in report
|
||||
|
||||
# Parse CSV
|
||||
lines = report.strip().split('\n')
|
||||
# Skip comment lines
|
||||
csv_lines = [line for line in lines if not line.startswith('#')]
|
||||
csv_reader = csv.DictReader(csv_lines)
|
||||
rows = list(csv_reader)
|
||||
|
||||
# Check we have 2 data rows
|
||||
assert len(rows) == 2
|
||||
|
||||
# Check first file
|
||||
assert rows[0]['filename'] == 'Movie1.mkv'
|
||||
assert rows[0]['size_bytes'] == '2000000000'
|
||||
assert rows[0]['category'] == 'movie'
|
||||
assert rows[0]['resolution'] == '1920x1080'
|
||||
assert rows[0]['codec'] == 'h264'
|
||||
assert rows[0]['duration_seconds'] == '7200.0'
|
||||
assert rows[0]['bitrate_kbps'] == '5000'
|
||||
|
||||
# Check second file
|
||||
assert rows[1]['filename'] == 'Show.S01E01.mkv'
|
||||
assert rows[1]['category'] == 'series'
|
||||
assert rows[1]['resolution'] == '1280x720'
|
||||
assert rows[1]['codec'] == 'h265'
|
||||
# Optional fields not present should be empty
|
||||
assert rows[1]['duration_seconds'] == ''
|
||||
assert rows[1]['bitrate_kbps'] == ''
|
||||
|
||||
def test_generate_json_report(self):
|
||||
"""Test generating JSON format inventory report."""
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/movies/Movie1.mkv"),
|
||||
"Movie1.mkv",
|
||||
2000000000,
|
||||
datetime(2023, 1, 15, 10, 30, 0),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264"
|
||||
),
|
||||
VideoFile(
|
||||
Path("/anime/Anime1.mkv"),
|
||||
"Anime1.mkv",
|
||||
800000000,
|
||||
datetime(2023, 3, 10, 8, 15, 0),
|
||||
"anime"
|
||||
),
|
||||
]
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
report = generate_inventory_report(files, "json", library_root)
|
||||
|
||||
# Parse JSON
|
||||
data = json.loads(report)
|
||||
|
||||
# Check metadata
|
||||
assert "metadata" in data
|
||||
assert "generated" in data["metadata"]
|
||||
assert data["metadata"]["library_root"] == str(library_root)
|
||||
assert data["metadata"]["file_count"] == 2
|
||||
|
||||
# Check files
|
||||
assert "files" in data
|
||||
assert len(data["files"]) == 2
|
||||
|
||||
# Check first file
|
||||
file1 = data["files"][0]
|
||||
assert file1["filename"] == "Movie1.mkv"
|
||||
assert file1["size_bytes"] == 2000000000
|
||||
assert file1["category"] == "movie"
|
||||
assert file1["resolution"] == "1920x1080"
|
||||
assert file1["codec"] == "h264"
|
||||
|
||||
# Check second file
|
||||
file2 = data["files"][1]
|
||||
assert file2["filename"] == "Anime1.mkv"
|
||||
assert file2["category"] == "anime"
|
||||
# Optional fields should be null
|
||||
assert file2["resolution"] is None
|
||||
assert file2["codec"] is None
|
||||
|
||||
def test_generate_csv_report_empty(self):
|
||||
"""Test generating CSV report with no files."""
|
||||
files = []
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
report = generate_inventory_report(files, "csv", library_root)
|
||||
|
||||
# Should have metadata and header
|
||||
assert "# Generated:" in report
|
||||
assert "path,filename,size_bytes" in report
|
||||
|
||||
# Parse CSV
|
||||
lines = report.strip().split('\n')
|
||||
csv_lines = [line for line in lines if not line.startswith('#')]
|
||||
csv_reader = csv.DictReader(csv_lines)
|
||||
rows = list(csv_reader)
|
||||
|
||||
# No data rows
|
||||
assert len(rows) == 0
|
||||
|
||||
def test_generate_json_report_empty(self):
|
||||
"""Test generating JSON report with no files."""
|
||||
files = []
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
report = generate_inventory_report(files, "json", library_root)
|
||||
|
||||
data = json.loads(report)
|
||||
assert data["metadata"]["file_count"] == 0
|
||||
assert len(data["files"]) == 0
|
||||
|
||||
def test_invalid_format_raises_error(self):
|
||||
"""Test that invalid format raises ValueError."""
|
||||
files = []
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid format"):
|
||||
generate_inventory_report(files, "xml", library_root)
|
||||
|
||||
def test_csv_schema_columns(self):
|
||||
"""Test that CSV has all required columns in correct order."""
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/test.mkv"),
|
||||
"test.mkv",
|
||||
1000,
|
||||
datetime.now(),
|
||||
"movie"
|
||||
)
|
||||
]
|
||||
library_root = Path("/test")
|
||||
|
||||
report = generate_inventory_report(files, "csv", library_root)
|
||||
|
||||
# Parse CSV header
|
||||
lines = report.strip().split('\n')
|
||||
csv_lines = [line for line in lines if not line.startswith('#')]
|
||||
header = csv_lines[0].strip() # Strip to remove any line ending characters
|
||||
|
||||
# Check column order
|
||||
expected_columns = [
|
||||
'path', 'filename', 'size_bytes', 'modified_timestamp', 'category',
|
||||
'resolution', 'codec', 'duration_seconds', 'bitrate_kbps'
|
||||
]
|
||||
assert header == ','.join(expected_columns)
|
||||
|
||||
def test_timestamp_formatting(self):
|
||||
"""Test that timestamps are formatted as ISO 8601."""
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/test.mkv"),
|
||||
"test.mkv",
|
||||
1000,
|
||||
datetime(2023, 6, 15, 14, 30, 45),
|
||||
"movie"
|
||||
)
|
||||
]
|
||||
library_root = Path("/test")
|
||||
|
||||
report = generate_inventory_report(files, "csv", library_root)
|
||||
|
||||
# Check timestamp format
|
||||
assert "2023-06-15T14:30:45" in report
|
||||
|
||||
|
||||
class TestCompletenessReport:
|
||||
"""Test completeness report generation."""
|
||||
|
||||
def test_generate_text_report_with_gaps(self):
|
||||
"""Test generating text format completeness report with gaps."""
|
||||
analysis = [
|
||||
SeasonCompleteness("Breaking Bad", 1, [1, 2, 4, 5], [3]),
|
||||
SeasonCompleteness("Breaking Bad", 2, [1, 3, 5], [2, 4]),
|
||||
SeasonCompleteness("The Wire", 1, [1, 2, 4], [3]),
|
||||
]
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
report = generate_completeness_report(analysis, "text", library_root)
|
||||
|
||||
# Check report structure
|
||||
assert "SERIES COMPLETENESS REPORT" in report
|
||||
assert "Generated:" in report
|
||||
assert str(library_root) in report
|
||||
assert "Series with gaps: 3" in report
|
||||
|
||||
# Check series content
|
||||
assert "Breaking Bad" in report
|
||||
assert "The Wire" in report
|
||||
assert "Season 01:" in report
|
||||
assert "Season 02:" in report
|
||||
|
||||
# Check episode information
|
||||
assert "Episodes found:" in report
|
||||
assert "Episodes missing:" in report
|
||||
|
||||
def test_generate_json_report_with_gaps(self):
|
||||
"""Test generating JSON format completeness report with gaps."""
|
||||
analysis = [
|
||||
SeasonCompleteness("Breaking Bad", 1, [1, 2, 4, 5], [3]),
|
||||
SeasonCompleteness("The Wire", 1, [1, 2, 4], [3]),
|
||||
]
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
report = generate_completeness_report(analysis, "json", library_root)
|
||||
|
||||
# Parse JSON
|
||||
data = json.loads(report)
|
||||
|
||||
# Check metadata
|
||||
assert "metadata" in data
|
||||
assert "generated" in data["metadata"]
|
||||
assert data["metadata"]["library_root"] == str(library_root)
|
||||
assert data["metadata"]["series_count"] == 2
|
||||
|
||||
# Check series data
|
||||
assert "series" in data
|
||||
assert len(data["series"]) == 2
|
||||
|
||||
# Check Breaking Bad
|
||||
breaking_bad = next(s for s in data["series"] if s["title"] == "Breaking Bad")
|
||||
assert len(breaking_bad["seasons"]) == 1
|
||||
assert breaking_bad["seasons"][0]["season"] == 1
|
||||
assert breaking_bad["seasons"][0]["episodes_found"] == [1, 2, 4, 5]
|
||||
assert breaking_bad["seasons"][0]["episodes_missing"] == [3]
|
||||
|
||||
def test_generate_text_report_empty(self):
|
||||
"""Test generating text report with no gaps."""
|
||||
analysis = []
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
report = generate_completeness_report(analysis, "text", library_root)
|
||||
|
||||
assert "SERIES COMPLETENESS REPORT" in report
|
||||
assert "No series with episode gaps detected." in report
|
||||
|
||||
def test_generate_json_report_empty(self):
|
||||
"""Test generating JSON report with no gaps."""
|
||||
analysis = []
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
report = generate_completeness_report(analysis, "json", library_root)
|
||||
|
||||
data = json.loads(report)
|
||||
assert data["metadata"]["series_count"] == 0
|
||||
assert len(data["series"]) == 0
|
||||
|
||||
def test_invalid_format_raises_error(self):
|
||||
"""Test that invalid format raises ValueError."""
|
||||
analysis = []
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid format"):
|
||||
generate_completeness_report(analysis, "xml", library_root)
|
||||
|
||||
def test_multiple_seasons_same_series(self):
|
||||
"""Test report with multiple seasons of same series."""
|
||||
analysis = [
|
||||
SeasonCompleteness("Show Name", 1, [1, 3], [2]),
|
||||
SeasonCompleteness("Show Name", 2, [1, 2, 4], [3]),
|
||||
SeasonCompleteness("Show Name", 3, [5, 7], [6]),
|
||||
]
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
report = generate_completeness_report(analysis, "text", library_root)
|
||||
|
||||
# Should group all seasons under same series
|
||||
assert report.count("Show Name") == 1 # Series title appears once
|
||||
assert "Season 01:" in report
|
||||
assert "Season 02:" in report
|
||||
assert "Season 03:" in report
|
||||
|
||||
|
||||
class TestDuplicateReport:
|
||||
"""Test duplicate report generation."""
|
||||
|
||||
def test_generate_text_report_with_duplicates(self):
|
||||
"""Test generating text format duplicate report."""
|
||||
identities = [
|
||||
MovieIdentity("The Matrix", 1999, 0.9, False, "The.Matrix.1999.1080p.mkv"),
|
||||
]
|
||||
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/movies/The.Matrix.1999.1080p.mkv"),
|
||||
"The.Matrix.1999.1080p.mkv",
|
||||
2000000000,
|
||||
datetime.now(),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264",
|
||||
duration_seconds=7200.0,
|
||||
bitrate_kbps=5000
|
||||
),
|
||||
VideoFile(
|
||||
Path("/movies/The.Matrix.1999.720p.mkv"),
|
||||
"The.Matrix.1999.720p.mkv",
|
||||
1000000000,
|
||||
datetime.now(),
|
||||
"movie",
|
||||
resolution="1280x720",
|
||||
codec="h264"
|
||||
),
|
||||
]
|
||||
|
||||
quality_comparison = [
|
||||
{
|
||||
'filename': 'The.Matrix.1999.1080p.mkv',
|
||||
'path': '/movies/The.Matrix.1999.1080p.mkv',
|
||||
'size_bytes': 2000000000,
|
||||
'resolution': '1920x1080',
|
||||
'codec': 'h264',
|
||||
'duration_seconds': 7200.0,
|
||||
'bitrate_kbps': 5000
|
||||
},
|
||||
{
|
||||
'filename': 'The.Matrix.1999.720p.mkv',
|
||||
'path': '/movies/The.Matrix.1999.720p.mkv',
|
||||
'size_bytes': 1000000000,
|
||||
'resolution': '1280x720',
|
||||
'codec': 'h264'
|
||||
}
|
||||
]
|
||||
|
||||
duplicates = [
|
||||
DuplicateGroup(identities[0], files, quality_comparison)
|
||||
]
|
||||
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
report = generate_duplicate_report(duplicates, "text", library_root)
|
||||
|
||||
# Check report structure
|
||||
assert "DUPLICATE FILES REPORT" in report
|
||||
assert "Generated:" in report
|
||||
assert str(library_root) in report
|
||||
assert "Duplicate groups: 1" in report
|
||||
|
||||
# Check duplicate group content
|
||||
assert "The Matrix (1999)" in report
|
||||
assert "Files: 2" in report
|
||||
|
||||
# Check file details
|
||||
assert "The.Matrix.1999.1080p.mkv" in report
|
||||
assert "The.Matrix.1999.720p.mkv" in report
|
||||
assert "1920x1080" in report
|
||||
assert "1280x720" in report
|
||||
assert "h264" in report
|
||||
|
||||
def test_generate_json_report_with_duplicates(self):
|
||||
"""Test generating JSON format duplicate report."""
|
||||
identity = MovieIdentity("Inception", 2010, 0.9, False, "Inception.2010.1080p.mkv")
|
||||
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/movies/Inception.2010.1080p.mkv"),
|
||||
"Inception.2010.1080p.mkv",
|
||||
2000000000,
|
||||
datetime.now(),
|
||||
"movie"
|
||||
),
|
||||
VideoFile(
|
||||
Path("/movies/Inception.2010.720p.mkv"),
|
||||
"Inception.2010.720p.mkv",
|
||||
1000000000,
|
||||
datetime.now(),
|
||||
"movie"
|
||||
),
|
||||
]
|
||||
|
||||
quality_comparison = [
|
||||
{'filename': 'Inception.2010.1080p.mkv', 'path': '/movies/Inception.2010.1080p.mkv', 'size_bytes': 2000000000},
|
||||
{'filename': 'Inception.2010.720p.mkv', 'path': '/movies/Inception.2010.720p.mkv', 'size_bytes': 1000000000}
|
||||
]
|
||||
|
||||
duplicates = [
|
||||
DuplicateGroup(identity, files, quality_comparison)
|
||||
]
|
||||
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
report = generate_duplicate_report(duplicates, "json", library_root)
|
||||
|
||||
# Parse JSON
|
||||
data = json.loads(report)
|
||||
|
||||
# Check metadata
|
||||
assert "metadata" in data
|
||||
assert data["metadata"]["duplicate_groups"] == 1
|
||||
assert data["metadata"]["library_root"] == str(library_root)
|
||||
|
||||
# Check duplicates
|
||||
assert "duplicates" in data
|
||||
assert len(data["duplicates"]) == 1
|
||||
|
||||
dup = data["duplicates"][0]
|
||||
assert dup["identity"]["type"] == "movie"
|
||||
assert dup["identity"]["title"] == "Inception"
|
||||
assert dup["identity"]["year"] == 2010
|
||||
assert dup["file_count"] == 2
|
||||
assert len(dup["files"]) == 2
|
||||
|
||||
def test_generate_text_report_series_duplicates(self):
|
||||
"""Test generating text report with series duplicates."""
|
||||
identity = SeriesIdentity("Breaking Bad", 1, [1], 0.9, False, "Breaking.Bad.S01E01.mkv")
|
||||
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/series/Breaking.Bad.S01E01.1080p.mkv"),
|
||||
"Breaking.Bad.S01E01.1080p.mkv",
|
||||
1500000000,
|
||||
datetime.now(),
|
||||
"series"
|
||||
),
|
||||
VideoFile(
|
||||
Path("/series/Breaking.Bad.S01E01.720p.mkv"),
|
||||
"Breaking.Bad.S01E01.720p.mkv",
|
||||
800000000,
|
||||
datetime.now(),
|
||||
"series"
|
||||
),
|
||||
]
|
||||
|
||||
quality_comparison = [
|
||||
{'filename': 'Breaking.Bad.S01E01.1080p.mkv', 'path': '/series/Breaking.Bad.S01E01.1080p.mkv', 'size_bytes': 1500000000},
|
||||
{'filename': 'Breaking.Bad.S01E01.720p.mkv', 'path': '/series/Breaking.Bad.S01E01.720p.mkv', 'size_bytes': 800000000}
|
||||
]
|
||||
|
||||
duplicates = [
|
||||
DuplicateGroup(identity, files, quality_comparison)
|
||||
]
|
||||
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
report = generate_duplicate_report(duplicates, "text", library_root)
|
||||
|
||||
# Check series format
|
||||
assert "Breaking Bad - S01E1" in report
|
||||
assert "Files: 2" in report
|
||||
|
||||
def test_generate_text_report_empty(self):
|
||||
"""Test generating text report with no duplicates."""
|
||||
duplicates = []
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
report = generate_duplicate_report(duplicates, "text", library_root)
|
||||
|
||||
assert "DUPLICATE FILES REPORT" in report
|
||||
assert "No duplicate files detected." in report
|
||||
|
||||
def test_generate_json_report_empty(self):
|
||||
"""Test generating JSON report with no duplicates."""
|
||||
duplicates = []
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
report = generate_duplicate_report(duplicates, "json", library_root)
|
||||
|
||||
data = json.loads(report)
|
||||
assert data["metadata"]["duplicate_groups"] == 0
|
||||
assert len(data["duplicates"]) == 0
|
||||
|
||||
def test_invalid_format_raises_error(self):
|
||||
"""Test that invalid format raises ValueError."""
|
||||
duplicates = []
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid format"):
|
||||
generate_duplicate_report(duplicates, "csv", library_root)
|
||||
|
||||
def test_sorted_by_file_size(self):
|
||||
"""Test that duplicate groups are sorted by largest file size."""
|
||||
# Create two duplicate groups with different sizes
|
||||
identity1 = MovieIdentity("Small Movie", 2020, 0.9, False, "Small.Movie.mkv")
|
||||
files1 = [
|
||||
VideoFile(Path("/movies/Small.Movie.1.mkv"), "Small.Movie.1.mkv", 500000000, datetime.now(), "movie"),
|
||||
VideoFile(Path("/movies/Small.Movie.2.mkv"), "Small.Movie.2.mkv", 600000000, datetime.now(), "movie"),
|
||||
]
|
||||
quality1 = [
|
||||
{'filename': 'Small.Movie.1.mkv', 'path': '/movies/Small.Movie.1.mkv', 'size_bytes': 500000000},
|
||||
{'filename': 'Small.Movie.2.mkv', 'path': '/movies/Small.Movie.2.mkv', 'size_bytes': 600000000}
|
||||
]
|
||||
|
||||
identity2 = MovieIdentity("Large Movie", 2021, 0.9, False, "Large.Movie.mkv")
|
||||
files2 = [
|
||||
VideoFile(Path("/movies/Large.Movie.1.mkv"), "Large.Movie.1.mkv", 2000000000, datetime.now(), "movie"),
|
||||
VideoFile(Path("/movies/Large.Movie.2.mkv"), "Large.Movie.2.mkv", 1800000000, datetime.now(), "movie"),
|
||||
]
|
||||
quality2 = [
|
||||
{'filename': 'Large.Movie.1.mkv', 'path': '/movies/Large.Movie.1.mkv', 'size_bytes': 2000000000},
|
||||
{'filename': 'Large.Movie.2.mkv', 'path': '/movies/Large.Movie.2.mkv', 'size_bytes': 1800000000}
|
||||
]
|
||||
|
||||
duplicates = [
|
||||
DuplicateGroup(identity1, files1, quality1),
|
||||
DuplicateGroup(identity2, files2, quality2)
|
||||
]
|
||||
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
report = generate_duplicate_report(duplicates, "text", library_root)
|
||||
|
||||
# Large Movie should appear before Small Movie
|
||||
large_pos = report.find("Large Movie")
|
||||
small_pos = report.find("Small Movie")
|
||||
assert large_pos < small_pos
|
||||
|
||||
|
||||
class TestSummaryReport:
|
||||
"""Test summary report generation."""
|
||||
|
||||
def test_generate_summary_report(self):
|
||||
"""Test generating summary report with various files."""
|
||||
files = [
|
||||
VideoFile(Path("/movies/Movie1.mkv"), "Movie1.mkv", 2000000000, datetime.now(), "movie"),
|
||||
VideoFile(Path("/movies/Movie2.mkv"), "Movie2.mkv", 1500000000, datetime.now(), "movie"),
|
||||
VideoFile(Path("/series/Show.S01E01.mkv"), "Show.S01E01.mkv", 1000000000, datetime.now(), "series"),
|
||||
VideoFile(Path("/series/Show.S01E02.mkv"), "Show.S01E02.mkv", 1000000000, datetime.now(), "series"),
|
||||
VideoFile(Path("/anime/Anime1.mkv"), "Anime1.mkv", 800000000, datetime.now(), "anime"),
|
||||
VideoFile(Path("/other/Random.mkv"), "Random.mkv", 500000000, datetime.now(), "other"),
|
||||
]
|
||||
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
report = generate_summary_report(files, library_root)
|
||||
|
||||
# Check report structure
|
||||
assert "LIBRARY SUMMARY REPORT" in report
|
||||
assert "Generated:" in report
|
||||
assert str(library_root) in report
|
||||
|
||||
# Check totals
|
||||
assert "Total Files: 6" in report
|
||||
assert "Total Size:" in report
|
||||
|
||||
# Check category breakdown
|
||||
assert "Category Breakdown:" in report
|
||||
assert "Movie:" in report
|
||||
assert "Series:" in report
|
||||
assert "Anime:" in report
|
||||
assert "Other:" in report
|
||||
|
||||
# Check category counts
|
||||
assert "Files: 2" in report # Movies
|
||||
|
||||
def test_generate_summary_report_empty(self):
|
||||
"""Test generating summary report with no files."""
|
||||
files = []
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
report = generate_summary_report(files, library_root)
|
||||
|
||||
assert "LIBRARY SUMMARY REPORT" in report
|
||||
assert "Total Files: 0" in report
|
||||
assert "Total Size: 0.00 B" in report
|
||||
|
||||
def test_generate_summary_report_single_category(self):
|
||||
"""Test generating summary report with files in single category."""
|
||||
files = [
|
||||
VideoFile(Path("/movies/Movie1.mkv"), "Movie1.mkv", 1000000000, datetime.now(), "movie"),
|
||||
VideoFile(Path("/movies/Movie2.mkv"), "Movie2.mkv", 2000000000, datetime.now(), "movie"),
|
||||
]
|
||||
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
report = generate_summary_report(files, library_root)
|
||||
|
||||
assert "Total Files: 2" in report
|
||||
assert "Movie:" in report
|
||||
assert "Files: 2" in report
|
||||
|
||||
|
||||
class TestFormatHelpers:
|
||||
"""Test formatting helper functions."""
|
||||
|
||||
def test_format_episode_list_single(self):
|
||||
"""Test formatting single episode."""
|
||||
assert _format_episode_list([5]) == "5"
|
||||
|
||||
def test_format_episode_list_range(self):
|
||||
"""Test formatting consecutive episode range."""
|
||||
assert _format_episode_list([1, 2, 3, 4, 5]) == "1-5"
|
||||
|
||||
def test_format_episode_list_mixed(self):
|
||||
"""Test formatting mixed ranges and singles."""
|
||||
assert _format_episode_list([1, 2, 3, 5, 6, 8]) == "1-3, 5-6, 8"
|
||||
|
||||
def test_format_episode_list_non_sequential(self):
|
||||
"""Test formatting non-sequential episodes."""
|
||||
assert _format_episode_list([1, 3, 5, 7]) == "1, 3, 5, 7"
|
||||
|
||||
def test_format_episode_list_empty(self):
|
||||
"""Test formatting empty episode list."""
|
||||
assert _format_episode_list([]) == "none"
|
||||
|
||||
def test_format_episode_list_unsorted(self):
|
||||
"""Test formatting unsorted episode list."""
|
||||
assert _format_episode_list([5, 1, 3, 2, 4]) == "1-5"
|
||||
|
||||
def test_format_size_bytes(self):
|
||||
"""Test formatting bytes."""
|
||||
assert _format_size(512) == "512.00 B"
|
||||
|
||||
def test_format_size_kilobytes(self):
|
||||
"""Test formatting kilobytes."""
|
||||
assert _format_size(1024) == "1.00 KB"
|
||||
assert _format_size(2048) == "2.00 KB"
|
||||
|
||||
def test_format_size_megabytes(self):
|
||||
"""Test formatting megabytes."""
|
||||
assert _format_size(1048576) == "1.00 MB"
|
||||
assert _format_size(5242880) == "5.00 MB"
|
||||
|
||||
def test_format_size_gigabytes(self):
|
||||
"""Test formatting gigabytes."""
|
||||
assert _format_size(1073741824) == "1.00 GB"
|
||||
assert _format_size(2147483648) == "2.00 GB"
|
||||
|
||||
def test_format_size_terabytes(self):
|
||||
"""Test formatting terabytes."""
|
||||
assert _format_size(1099511627776) == "1.00 TB"
|
||||
|
||||
def test_format_duration_seconds(self):
|
||||
"""Test formatting seconds only."""
|
||||
assert _format_duration(30) == "30s"
|
||||
assert _format_duration(0) == "0s"
|
||||
|
||||
def test_format_duration_minutes(self):
|
||||
"""Test formatting minutes and seconds."""
|
||||
assert _format_duration(90) == "1m 30s"
|
||||
assert _format_duration(120) == "2m"
|
||||
|
||||
def test_format_duration_hours(self):
|
||||
"""Test formatting hours, minutes, and seconds."""
|
||||
assert _format_duration(3665) == "1h 1m 5s"
|
||||
assert _format_duration(7200) == "2h"
|
||||
assert _format_duration(7260) == "2h 1m"
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Integration tests for report generation with analysis engine.
|
||||
|
||||
Tests the complete workflow from analysis to report generation.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from vlm.models import SeriesIdentity, VideoFile, MovieIdentity
|
||||
from vlm.analysis import analyze_series_completeness, detect_duplicates
|
||||
from vlm.reports import generate_completeness_report, generate_duplicate_report, generate_summary_report
|
||||
|
||||
|
||||
class TestReportsIntegration:
|
||||
"""Test report generation integrated with analysis engine."""
|
||||
|
||||
def test_completeness_workflow(self):
|
||||
"""Test complete workflow from series analysis to completeness report."""
|
||||
# Create test episodes with gaps
|
||||
episodes = [
|
||||
SeriesIdentity("Breaking Bad", 1, [1], 0.9, False, "Breaking.Bad.S01E01.mkv"),
|
||||
SeriesIdentity("Breaking Bad", 1, [2], 0.9, False, "Breaking.Bad.S01E02.mkv"),
|
||||
SeriesIdentity("Breaking Bad", 1, [4], 0.9, False, "Breaking.Bad.S01E04.mkv"),
|
||||
SeriesIdentity("The Wire", 1, [1], 0.9, False, "The.Wire.S01E01.mkv"),
|
||||
SeriesIdentity("The Wire", 1, [3], 0.9, False, "The.Wire.S01E03.mkv"),
|
||||
]
|
||||
|
||||
# Analyze completeness
|
||||
analysis = analyze_series_completeness(episodes)
|
||||
|
||||
# Generate text report
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
text_report = generate_completeness_report(analysis, "text", library_root)
|
||||
|
||||
# Verify report contains expected information
|
||||
assert "Breaking Bad" in text_report
|
||||
assert "The Wire" in text_report
|
||||
assert "Episodes missing:" in text_report
|
||||
|
||||
# Generate JSON report
|
||||
json_report = generate_completeness_report(analysis, "json", library_root)
|
||||
data = json.loads(json_report)
|
||||
|
||||
# Verify JSON structure
|
||||
assert data["metadata"]["series_count"] == 2
|
||||
assert len(data["series"]) == 2
|
||||
|
||||
def test_duplicate_workflow(self):
|
||||
"""Test complete workflow from duplicate detection to duplicate report."""
|
||||
# Create test identities and files
|
||||
identities = [
|
||||
MovieIdentity("The Matrix", 1999, 0.9, False, "The.Matrix.1999.1080p.mkv"),
|
||||
MovieIdentity("The Matrix", 1999, 0.9, False, "The.Matrix.1999.720p.mkv"),
|
||||
MovieIdentity("Inception", 2010, 0.9, False, "Inception.2010.mkv"),
|
||||
]
|
||||
|
||||
files = [
|
||||
VideoFile(
|
||||
Path("/movies/The.Matrix.1999.1080p.mkv"),
|
||||
"The.Matrix.1999.1080p.mkv",
|
||||
2000000000,
|
||||
datetime.now(),
|
||||
"movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264"
|
||||
),
|
||||
VideoFile(
|
||||
Path("/movies/The.Matrix.1999.720p.mkv"),
|
||||
"The.Matrix.1999.720p.mkv",
|
||||
1000000000,
|
||||
datetime.now(),
|
||||
"movie",
|
||||
resolution="1280x720",
|
||||
codec="h264"
|
||||
),
|
||||
VideoFile(
|
||||
Path("/movies/Inception.2010.mkv"),
|
||||
"Inception.2010.mkv",
|
||||
1500000000,
|
||||
datetime.now(),
|
||||
"movie"
|
||||
),
|
||||
]
|
||||
|
||||
# Detect duplicates
|
||||
duplicates = detect_duplicates(identities, files)
|
||||
|
||||
# Generate text report
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
text_report = generate_duplicate_report(duplicates, "text", library_root)
|
||||
|
||||
# Verify report contains expected information
|
||||
assert "The Matrix (1999)" in text_report
|
||||
assert "1920x1080" in text_report
|
||||
assert "1280x720" in text_report
|
||||
|
||||
# Generate JSON report
|
||||
json_report = generate_duplicate_report(duplicates, "json", library_root)
|
||||
data = json.loads(json_report)
|
||||
|
||||
# Verify JSON structure
|
||||
assert data["metadata"]["duplicate_groups"] == 1
|
||||
assert len(data["duplicates"]) == 1
|
||||
assert data["duplicates"][0]["file_count"] == 2
|
||||
|
||||
def test_summary_workflow(self):
|
||||
"""Test summary report generation with mixed file types."""
|
||||
# Create test files
|
||||
files = [
|
||||
VideoFile(Path("/movies/Movie1.mkv"), "Movie1.mkv", 2000000000, datetime.now(), "movie"),
|
||||
VideoFile(Path("/movies/Movie2.mkv"), "Movie2.mkv", 1500000000, datetime.now(), "movie"),
|
||||
VideoFile(Path("/series/Show.S01E01.mkv"), "Show.S01E01.mkv", 1000000000, datetime.now(), "series"),
|
||||
VideoFile(Path("/series/Show.S01E02.mkv"), "Show.S01E02.mkv", 1000000000, datetime.now(), "series"),
|
||||
VideoFile(Path("/anime/Anime1.mkv"), "Anime1.mkv", 800000000, datetime.now(), "anime"),
|
||||
]
|
||||
|
||||
# Generate summary report
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
report = generate_summary_report(files, library_root)
|
||||
|
||||
# Verify report contains expected information
|
||||
assert "Total Files: 5" in report
|
||||
assert "Movie:" in report
|
||||
assert "Series:" in report
|
||||
assert "Anime:" in report
|
||||
assert "Category Breakdown:" in report
|
||||
|
||||
def test_all_reports_include_metadata(self):
|
||||
"""Test that all reports include generation timestamp and library root."""
|
||||
library_root = Path("/mnt/nas/videos")
|
||||
|
||||
# Test completeness report
|
||||
completeness_report = generate_completeness_report([], "text", library_root)
|
||||
assert "Generated:" in completeness_report
|
||||
assert str(library_root) in completeness_report
|
||||
|
||||
# Test duplicate report
|
||||
duplicate_report = generate_duplicate_report([], "text", library_root)
|
||||
assert "Generated:" in duplicate_report
|
||||
assert str(library_root) in duplicate_report
|
||||
|
||||
# Test summary report
|
||||
summary_report = generate_summary_report([], library_root)
|
||||
assert "Generated:" in summary_report
|
||||
assert str(library_root) in summary_report
|
||||
@@ -0,0 +1,869 @@
|
||||
"""Unit tests for the inventory scanner module."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
import subprocess
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from vlm.config import Config
|
||||
from vlm.models import VideoFile
|
||||
from vlm.scanner import (
|
||||
categorize_file,
|
||||
scan_library,
|
||||
extract_metadata,
|
||||
)
|
||||
|
||||
|
||||
class TestScanLibrary:
|
||||
"""Tests for the scan_library function."""
|
||||
|
||||
def test_scan_empty_directory(self, tmp_path):
|
||||
"""Test scanning an empty directory returns empty list."""
|
||||
config = Config(library_root=tmp_path)
|
||||
result = scan_library(tmp_path, config)
|
||||
assert result == []
|
||||
|
||||
def test_scan_nonexistent_directory(self, tmp_path):
|
||||
"""Test scanning a nonexistent directory returns empty list."""
|
||||
nonexistent = tmp_path / "nonexistent"
|
||||
config = Config(library_root=nonexistent)
|
||||
result = scan_library(nonexistent, config)
|
||||
assert result == []
|
||||
|
||||
def test_scan_discovers_video_files(self, tmp_path):
|
||||
"""Test scanning discovers video files with correct extensions."""
|
||||
# Create test structure
|
||||
movie_dir = tmp_path / "movie"
|
||||
movie_dir.mkdir()
|
||||
|
||||
# Create video files
|
||||
video1 = movie_dir / "test1.mp4"
|
||||
video2 = movie_dir / "test2.mkv"
|
||||
video1.touch()
|
||||
video2.touch()
|
||||
|
||||
# Create non-video file
|
||||
text_file = movie_dir / "readme.txt"
|
||||
text_file.touch()
|
||||
|
||||
config = Config(library_root=tmp_path)
|
||||
result = scan_library(tmp_path, config)
|
||||
|
||||
# Should find only video files
|
||||
assert len(result) == 2
|
||||
filenames = {vf.filename for vf in result}
|
||||
assert filenames == {"test1.mp4", "test2.mkv"}
|
||||
|
||||
def test_scan_recursive(self, tmp_path):
|
||||
"""Test scanning recursively discovers files in subdirectories."""
|
||||
# Create nested structure
|
||||
movie_dir = tmp_path / "movie"
|
||||
subdir = movie_dir / "subdir"
|
||||
subdir.mkdir(parents=True)
|
||||
|
||||
# Create files at different levels
|
||||
video1 = movie_dir / "movie1.mp4"
|
||||
video2 = subdir / "movie2.mkv"
|
||||
video1.touch()
|
||||
video2.touch()
|
||||
|
||||
config = Config(library_root=tmp_path)
|
||||
result = scan_library(tmp_path, config)
|
||||
|
||||
assert len(result) == 2
|
||||
filenames = {vf.filename for vf in result}
|
||||
assert filenames == {"movie1.mp4", "movie2.mkv"}
|
||||
|
||||
def test_scan_filters_by_extension(self, tmp_path):
|
||||
"""Test scanning filters files by configured extensions."""
|
||||
movie_dir = tmp_path / "movie"
|
||||
movie_dir.mkdir()
|
||||
|
||||
# Create files with various extensions
|
||||
mp4_file = movie_dir / "video.mp4"
|
||||
mkv_file = movie_dir / "video.mkv"
|
||||
avi_file = movie_dir / "video.avi"
|
||||
txt_file = movie_dir / "readme.txt"
|
||||
|
||||
mp4_file.touch()
|
||||
mkv_file.touch()
|
||||
avi_file.touch()
|
||||
txt_file.touch()
|
||||
|
||||
# Configure to only accept .mp4 and .mkv
|
||||
config = Config(
|
||||
library_root=tmp_path,
|
||||
video_extensions=[".mp4", ".mkv"]
|
||||
)
|
||||
result = scan_library(tmp_path, config)
|
||||
|
||||
assert len(result) == 2
|
||||
filenames = {vf.filename for vf in result}
|
||||
assert filenames == {"video.mp4", "video.mkv"}
|
||||
|
||||
def test_scan_records_metadata(self, tmp_path):
|
||||
"""Test scanning records file metadata correctly."""
|
||||
movie_dir = tmp_path / "movie"
|
||||
movie_dir.mkdir()
|
||||
|
||||
video_file = movie_dir / "test.mp4"
|
||||
video_file.write_text("test content")
|
||||
|
||||
config = Config(library_root=tmp_path)
|
||||
result = scan_library(tmp_path, config)
|
||||
|
||||
assert len(result) == 1
|
||||
vf = result[0]
|
||||
|
||||
# Check metadata
|
||||
assert vf.filename == "test.mp4"
|
||||
assert vf.path == video_file
|
||||
assert vf.size_bytes > 0
|
||||
assert isinstance(vf.modified_timestamp, datetime)
|
||||
assert vf.category == "movie"
|
||||
|
||||
def test_scan_categorizes_files(self, tmp_path):
|
||||
"""Test scanning categorizes files based on directory structure."""
|
||||
# Create category directories
|
||||
movie_dir = tmp_path / "movie"
|
||||
series_dir = tmp_path / "series"
|
||||
anime_dir = tmp_path / "anime"
|
||||
other_dir = tmp_path / "other"
|
||||
|
||||
movie_dir.mkdir()
|
||||
series_dir.mkdir()
|
||||
anime_dir.mkdir()
|
||||
other_dir.mkdir()
|
||||
|
||||
# Create files in each category
|
||||
(movie_dir / "movie.mp4").touch()
|
||||
(series_dir / "series.mkv").touch()
|
||||
(anime_dir / "anime.avi").touch()
|
||||
(other_dir / "other.mov").touch()
|
||||
|
||||
config = Config(library_root=tmp_path)
|
||||
result = scan_library(tmp_path, config)
|
||||
|
||||
assert len(result) == 4
|
||||
|
||||
# Check categories
|
||||
categories = {vf.filename: vf.category for vf in result}
|
||||
assert categories["movie.mp4"] == "movie"
|
||||
assert categories["series.mkv"] == "series"
|
||||
assert categories["anime.avi"] == "anime"
|
||||
assert categories["other.mov"] == "other"
|
||||
|
||||
def test_scan_skips_hidden_files(self, tmp_path):
|
||||
"""Test scanning skips hidden files and directories."""
|
||||
movie_dir = tmp_path / "movie"
|
||||
hidden_dir = tmp_path / ".hidden"
|
||||
movie_dir.mkdir()
|
||||
hidden_dir.mkdir()
|
||||
|
||||
# Create visible and hidden files
|
||||
visible = movie_dir / "visible.mp4"
|
||||
hidden_file = movie_dir / ".hidden.mp4"
|
||||
hidden_dir_file = hidden_dir / "file.mp4"
|
||||
|
||||
visible.touch()
|
||||
hidden_file.touch()
|
||||
hidden_dir_file.touch()
|
||||
|
||||
config = Config(library_root=tmp_path)
|
||||
result = scan_library(tmp_path, config)
|
||||
|
||||
# Should only find visible file
|
||||
assert len(result) == 1
|
||||
assert result[0].filename == "visible.mp4"
|
||||
|
||||
def test_scan_handles_inaccessible_files(self, tmp_path):
|
||||
"""Test scanning continues when encountering inaccessible files."""
|
||||
movie_dir = tmp_path / "movie"
|
||||
movie_dir.mkdir()
|
||||
|
||||
# Create accessible files
|
||||
video1 = movie_dir / "video1.mp4"
|
||||
video2 = movie_dir / "video2.mp4"
|
||||
video1.touch()
|
||||
video2.touch()
|
||||
|
||||
config = Config(library_root=tmp_path)
|
||||
result = scan_library(tmp_path, config)
|
||||
|
||||
# Should find both files (no permission errors in test environment)
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
class TestCategorizeFile:
|
||||
"""Tests for the categorize_file function."""
|
||||
|
||||
def test_categorize_movie(self, tmp_path):
|
||||
"""Test categorizing a file in movie directory."""
|
||||
movie_dir = tmp_path / "movie"
|
||||
movie_dir.mkdir()
|
||||
video_file = movie_dir / "test.mp4"
|
||||
video_file.touch()
|
||||
|
||||
category = categorize_file(video_file, tmp_path)
|
||||
assert category == "movie"
|
||||
|
||||
def test_categorize_series(self, tmp_path):
|
||||
"""Test categorizing a file in series directory."""
|
||||
series_dir = tmp_path / "series"
|
||||
series_dir.mkdir()
|
||||
video_file = series_dir / "test.mkv"
|
||||
video_file.touch()
|
||||
|
||||
category = categorize_file(video_file, tmp_path)
|
||||
assert category == "series"
|
||||
|
||||
def test_categorize_anime(self, tmp_path):
|
||||
"""Test categorizing a file in anime directory."""
|
||||
anime_dir = tmp_path / "anime"
|
||||
anime_dir.mkdir()
|
||||
video_file = anime_dir / "test.avi"
|
||||
video_file.touch()
|
||||
|
||||
category = categorize_file(video_file, tmp_path)
|
||||
assert category == "anime"
|
||||
|
||||
def test_categorize_other(self, tmp_path):
|
||||
"""Test categorizing a file in other directory."""
|
||||
other_dir = tmp_path / "other"
|
||||
other_dir.mkdir()
|
||||
video_file = other_dir / "test.mov"
|
||||
video_file.touch()
|
||||
|
||||
category = categorize_file(video_file, tmp_path)
|
||||
assert category == "other"
|
||||
|
||||
def test_categorize_nested_file(self, tmp_path):
|
||||
"""Test categorizing a file in nested subdirectory."""
|
||||
movie_dir = tmp_path / "movie" / "subdir" / "nested"
|
||||
movie_dir.mkdir(parents=True)
|
||||
video_file = movie_dir / "test.mp4"
|
||||
video_file.touch()
|
||||
|
||||
category = categorize_file(video_file, tmp_path)
|
||||
assert category == "movie"
|
||||
|
||||
def test_categorize_case_insensitive(self, tmp_path):
|
||||
"""Test categorization is case-insensitive."""
|
||||
movie_dir = tmp_path / "Movie"
|
||||
movie_dir.mkdir()
|
||||
video_file = movie_dir / "test.mp4"
|
||||
video_file.touch()
|
||||
|
||||
category = categorize_file(video_file, tmp_path)
|
||||
assert category == "movie"
|
||||
|
||||
def test_categorize_file_in_root(self, tmp_path):
|
||||
"""Test categorizing a file directly in library root."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.touch()
|
||||
|
||||
category = categorize_file(video_file, tmp_path)
|
||||
assert category == "other"
|
||||
|
||||
def test_categorize_unknown_directory(self, tmp_path):
|
||||
"""Test categorizing a file in unknown directory."""
|
||||
unknown_dir = tmp_path / "random"
|
||||
unknown_dir.mkdir()
|
||||
video_file = unknown_dir / "test.mp4"
|
||||
video_file.touch()
|
||||
|
||||
category = categorize_file(video_file, tmp_path)
|
||||
assert category == "other"
|
||||
|
||||
|
||||
|
||||
class TestExtractMetadata:
|
||||
"""Tests for the extract_metadata function."""
|
||||
|
||||
def test_extract_metadata_with_ffprobe_available(self, tmp_path):
|
||||
"""Test metadata extraction when ffprobe is available and returns valid data."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.touch()
|
||||
|
||||
# Mock ffprobe output
|
||||
mock_output = {
|
||||
"streams": [
|
||||
{
|
||||
"codec_type": "video",
|
||||
"codec_name": "h264",
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
}
|
||||
],
|
||||
"format": {
|
||||
"duration": "120.5",
|
||||
"bit_rate": "5000000"
|
||||
}
|
||||
}
|
||||
|
||||
with patch('subprocess.run') as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout=json.dumps(mock_output),
|
||||
stderr=""
|
||||
)
|
||||
|
||||
result = extract_metadata(video_file)
|
||||
|
||||
assert result['resolution'] == "1920x1080"
|
||||
assert result['codec'] == "h264"
|
||||
assert result['duration_seconds'] == 120.5
|
||||
assert result['bitrate_kbps'] == 5000
|
||||
|
||||
def test_extract_metadata_ffprobe_not_available(self, tmp_path):
|
||||
"""Test metadata extraction when ffprobe is not installed."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.touch()
|
||||
|
||||
with patch('subprocess.run', side_effect=FileNotFoundError):
|
||||
result = extract_metadata(video_file)
|
||||
assert result == {}
|
||||
|
||||
def test_extract_metadata_ffprobe_fails(self, tmp_path):
|
||||
"""Test metadata extraction when ffprobe fails."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.touch()
|
||||
|
||||
with patch('subprocess.run') as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=1,
|
||||
stdout="",
|
||||
stderr="Error processing file"
|
||||
)
|
||||
|
||||
result = extract_metadata(video_file)
|
||||
assert result == {}
|
||||
|
||||
def test_extract_metadata_ffprobe_timeout(self, tmp_path):
|
||||
"""Test metadata extraction when ffprobe times out."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.touch()
|
||||
|
||||
with patch('subprocess.run', side_effect=subprocess.TimeoutExpired('ffprobe', 10)):
|
||||
result = extract_metadata(video_file)
|
||||
assert result == {}
|
||||
|
||||
def test_extract_metadata_invalid_json(self, tmp_path):
|
||||
"""Test metadata extraction when ffprobe returns invalid JSON."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.touch()
|
||||
|
||||
with patch('subprocess.run') as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout="invalid json",
|
||||
stderr=""
|
||||
)
|
||||
|
||||
result = extract_metadata(video_file)
|
||||
assert result == {}
|
||||
|
||||
def test_extract_metadata_partial_data(self, tmp_path):
|
||||
"""Test metadata extraction with partial data available."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.touch()
|
||||
|
||||
# Mock ffprobe output with only some fields
|
||||
mock_output = {
|
||||
"streams": [
|
||||
{
|
||||
"codec_type": "video",
|
||||
"codec_name": "h264"
|
||||
# Missing width and height
|
||||
}
|
||||
],
|
||||
"format": {
|
||||
"duration": "120.5"
|
||||
# Missing bit_rate
|
||||
}
|
||||
}
|
||||
|
||||
with patch('subprocess.run') as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout=json.dumps(mock_output),
|
||||
stderr=""
|
||||
)
|
||||
|
||||
result = extract_metadata(video_file)
|
||||
|
||||
assert result['codec'] == "h264"
|
||||
assert result['duration_seconds'] == 120.5
|
||||
assert 'resolution' not in result
|
||||
assert 'bitrate_kbps' not in result
|
||||
|
||||
def test_extract_metadata_no_video_stream(self, tmp_path):
|
||||
"""Test metadata extraction when no video stream is found."""
|
||||
video_file = tmp_path / "test.mp4"
|
||||
video_file.touch()
|
||||
|
||||
# Mock ffprobe output with only audio stream
|
||||
mock_output = {
|
||||
"streams": [
|
||||
{
|
||||
"codec_type": "audio",
|
||||
"codec_name": "aac"
|
||||
}
|
||||
],
|
||||
"format": {
|
||||
"duration": "120.5",
|
||||
"bit_rate": "5000000"
|
||||
}
|
||||
}
|
||||
|
||||
with patch('subprocess.run') as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout=json.dumps(mock_output),
|
||||
stderr=""
|
||||
)
|
||||
|
||||
result = extract_metadata(video_file)
|
||||
|
||||
# Should still extract format-level metadata
|
||||
assert result['duration_seconds'] == 120.5
|
||||
assert result['bitrate_kbps'] == 5000
|
||||
assert 'resolution' not in result
|
||||
assert 'codec' not in result
|
||||
|
||||
def test_scan_library_with_metadata_extraction(self, tmp_path):
|
||||
"""Test that scan_library integrates metadata extraction."""
|
||||
movie_dir = tmp_path / "movie"
|
||||
movie_dir.mkdir()
|
||||
|
||||
video_file = movie_dir / "test.mp4"
|
||||
video_file.touch()
|
||||
|
||||
# Mock ffprobe output
|
||||
mock_output = {
|
||||
"streams": [
|
||||
{
|
||||
"codec_type": "video",
|
||||
"codec_name": "h264",
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
}
|
||||
],
|
||||
"format": {
|
||||
"duration": "120.5",
|
||||
"bit_rate": "5000000"
|
||||
}
|
||||
}
|
||||
|
||||
with patch('subprocess.run') as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout=json.dumps(mock_output),
|
||||
stderr=""
|
||||
)
|
||||
|
||||
config = Config(library_root=tmp_path)
|
||||
result = scan_library(tmp_path, config)
|
||||
|
||||
assert len(result) == 1
|
||||
vf = result[0]
|
||||
|
||||
# Check that metadata was extracted
|
||||
assert vf.resolution == "1920x1080"
|
||||
assert vf.codec == "h264"
|
||||
assert vf.duration_seconds == 120.5
|
||||
assert vf.bitrate_kbps == 5000
|
||||
|
||||
def test_scan_library_without_ffprobe(self, tmp_path):
|
||||
"""Test that scan_library works gracefully without ffprobe."""
|
||||
movie_dir = tmp_path / "movie"
|
||||
movie_dir.mkdir()
|
||||
|
||||
video_file = movie_dir / "test.mp4"
|
||||
video_file.touch()
|
||||
|
||||
with patch('subprocess.run', side_effect=FileNotFoundError):
|
||||
config = Config(library_root=tmp_path)
|
||||
result = scan_library(tmp_path, config)
|
||||
|
||||
assert len(result) == 1
|
||||
vf = result[0]
|
||||
|
||||
# Check that file was still scanned without metadata
|
||||
assert vf.filename == "test.mp4"
|
||||
assert vf.resolution is None
|
||||
assert vf.codec is None
|
||||
assert vf.duration_seconds is None
|
||||
assert vf.bitrate_kbps is None
|
||||
|
||||
|
||||
|
||||
class TestInventoryReports:
|
||||
"""Tests for inventory report generation functions."""
|
||||
|
||||
def test_save_inventory_csv_basic(self, tmp_path):
|
||||
"""Test saving inventory to CSV format with basic data."""
|
||||
# Create test video files
|
||||
video_files = [
|
||||
VideoFile(
|
||||
path=Path("/library/movie/test1.mp4"),
|
||||
filename="test1.mp4",
|
||||
size_bytes=1024000,
|
||||
modified_timestamp=datetime(2024, 1, 15, 10, 30, 0),
|
||||
category="movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264",
|
||||
duration_seconds=120.5,
|
||||
bitrate_kbps=5000
|
||||
),
|
||||
VideoFile(
|
||||
path=Path("/library/series/test2.mkv"),
|
||||
filename="test2.mkv",
|
||||
size_bytes=2048000,
|
||||
modified_timestamp=datetime(2024, 1, 16, 14, 45, 0),
|
||||
category="series",
|
||||
resolution="1280x720",
|
||||
codec="h265",
|
||||
duration_seconds=45.0,
|
||||
bitrate_kbps=3000
|
||||
)
|
||||
]
|
||||
|
||||
output_file = tmp_path / "inventory.csv"
|
||||
library_root = Path("/library")
|
||||
|
||||
from vlm.scanner import save_inventory_csv
|
||||
save_inventory_csv(video_files, output_file, library_root)
|
||||
|
||||
# Verify file was created
|
||||
assert output_file.exists()
|
||||
|
||||
# Read and verify content
|
||||
with open(output_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Check metadata comments
|
||||
assert "# Generated:" in content
|
||||
assert "# Library Root: /library" in content
|
||||
|
||||
# Check header
|
||||
assert "path,filename,size_bytes,modified_timestamp,category,resolution,codec,duration_seconds,bitrate_kbps" in content
|
||||
|
||||
# Check data rows
|
||||
assert "test1.mp4" in content
|
||||
assert "1024000" in content
|
||||
assert "movie" in content
|
||||
assert "1920x1080" in content
|
||||
assert "h264" in content
|
||||
assert "120.5" in content
|
||||
assert "5000" in content
|
||||
|
||||
assert "test2.mkv" in content
|
||||
assert "2048000" in content
|
||||
assert "series" in content
|
||||
assert "1280x720" in content
|
||||
assert "h265" in content
|
||||
assert "45.0" in content
|
||||
assert "3000" in content
|
||||
|
||||
def test_save_inventory_csv_with_missing_metadata(self, tmp_path):
|
||||
"""Test saving inventory to CSV with missing optional metadata."""
|
||||
# Create video file without optional metadata
|
||||
video_files = [
|
||||
VideoFile(
|
||||
path=Path("/library/movie/test.mp4"),
|
||||
filename="test.mp4",
|
||||
size_bytes=1024000,
|
||||
modified_timestamp=datetime(2024, 1, 15, 10, 30, 0),
|
||||
category="movie",
|
||||
resolution=None,
|
||||
codec=None,
|
||||
duration_seconds=None,
|
||||
bitrate_kbps=None
|
||||
)
|
||||
]
|
||||
|
||||
output_file = tmp_path / "inventory.csv"
|
||||
library_root = Path("/library")
|
||||
|
||||
from vlm.scanner import save_inventory_csv
|
||||
save_inventory_csv(video_files, output_file, library_root)
|
||||
|
||||
# Verify file was created
|
||||
assert output_file.exists()
|
||||
|
||||
# Read and verify content
|
||||
import csv
|
||||
with open(output_file, 'r', encoding='utf-8') as f:
|
||||
# Skip comment lines
|
||||
lines = [line for line in f if not line.startswith('#')]
|
||||
reader = csv.DictReader(lines)
|
||||
rows = list(reader)
|
||||
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
|
||||
# Check required fields
|
||||
assert row['filename'] == 'test.mp4'
|
||||
assert row['size_bytes'] == '1024000'
|
||||
assert row['category'] == 'movie'
|
||||
|
||||
# Check optional fields are empty strings
|
||||
assert row['resolution'] == ''
|
||||
assert row['codec'] == ''
|
||||
assert row['duration_seconds'] == ''
|
||||
assert row['bitrate_kbps'] == ''
|
||||
|
||||
def test_save_inventory_csv_empty_list(self, tmp_path):
|
||||
"""Test saving empty inventory to CSV."""
|
||||
video_files = []
|
||||
output_file = tmp_path / "inventory.csv"
|
||||
library_root = Path("/library")
|
||||
|
||||
from vlm.scanner import save_inventory_csv
|
||||
save_inventory_csv(video_files, output_file, library_root)
|
||||
|
||||
# Verify file was created
|
||||
assert output_file.exists()
|
||||
|
||||
# Read and verify content
|
||||
with open(output_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Should have metadata and header but no data rows
|
||||
assert "# Generated:" in content
|
||||
assert "# Library Root:" in content
|
||||
assert "path,filename,size_bytes" in content
|
||||
|
||||
def test_save_inventory_csv_creates_directory(self, tmp_path):
|
||||
"""Test that save_inventory_csv creates output directory if needed."""
|
||||
video_files = [
|
||||
VideoFile(
|
||||
path=Path("/library/movie/test.mp4"),
|
||||
filename="test.mp4",
|
||||
size_bytes=1024000,
|
||||
modified_timestamp=datetime(2024, 1, 15, 10, 30, 0),
|
||||
category="movie"
|
||||
)
|
||||
]
|
||||
|
||||
# Use nested directory that doesn't exist
|
||||
output_file = tmp_path / "reports" / "inventory.csv"
|
||||
library_root = Path("/library")
|
||||
|
||||
from vlm.scanner import save_inventory_csv
|
||||
save_inventory_csv(video_files, output_file, library_root)
|
||||
|
||||
# Verify file was created
|
||||
assert output_file.exists()
|
||||
|
||||
def test_save_inventory_json_basic(self, tmp_path):
|
||||
"""Test saving inventory to JSON format with basic data."""
|
||||
# Create test video files
|
||||
video_files = [
|
||||
VideoFile(
|
||||
path=Path("/library/movie/test1.mp4"),
|
||||
filename="test1.mp4",
|
||||
size_bytes=1024000,
|
||||
modified_timestamp=datetime(2024, 1, 15, 10, 30, 0),
|
||||
category="movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264",
|
||||
duration_seconds=120.5,
|
||||
bitrate_kbps=5000
|
||||
),
|
||||
VideoFile(
|
||||
path=Path("/library/series/test2.mkv"),
|
||||
filename="test2.mkv",
|
||||
size_bytes=2048000,
|
||||
modified_timestamp=datetime(2024, 1, 16, 14, 45, 0),
|
||||
category="series",
|
||||
resolution="1280x720",
|
||||
codec="h265",
|
||||
duration_seconds=45.0,
|
||||
bitrate_kbps=3000
|
||||
)
|
||||
]
|
||||
|
||||
output_file = tmp_path / "inventory.json"
|
||||
library_root = Path("/library")
|
||||
|
||||
from vlm.scanner import save_inventory_json
|
||||
save_inventory_json(video_files, output_file, library_root)
|
||||
|
||||
# Verify file was created
|
||||
assert output_file.exists()
|
||||
|
||||
# Read and verify content
|
||||
with open(output_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Check metadata
|
||||
assert 'metadata' in data
|
||||
assert 'generated' in data['metadata']
|
||||
assert data['metadata']['library_root'] == '/library'
|
||||
assert data['metadata']['file_count'] == 2
|
||||
|
||||
# Check files
|
||||
assert 'files' in data
|
||||
assert len(data['files']) == 2
|
||||
|
||||
# Check first file
|
||||
file1 = data['files'][0]
|
||||
assert file1['filename'] == 'test1.mp4'
|
||||
assert file1['size_bytes'] == 1024000
|
||||
assert file1['category'] == 'movie'
|
||||
assert file1['resolution'] == '1920x1080'
|
||||
assert file1['codec'] == 'h264'
|
||||
assert file1['duration_seconds'] == 120.5
|
||||
assert file1['bitrate_kbps'] == 5000
|
||||
|
||||
# Check second file
|
||||
file2 = data['files'][1]
|
||||
assert file2['filename'] == 'test2.mkv'
|
||||
assert file2['size_bytes'] == 2048000
|
||||
assert file2['category'] == 'series'
|
||||
assert file2['resolution'] == '1280x720'
|
||||
assert file2['codec'] == 'h265'
|
||||
assert file2['duration_seconds'] == 45.0
|
||||
assert file2['bitrate_kbps'] == 3000
|
||||
|
||||
def test_save_inventory_json_with_missing_metadata(self, tmp_path):
|
||||
"""Test saving inventory to JSON with missing optional metadata."""
|
||||
# Create video file without optional metadata
|
||||
video_files = [
|
||||
VideoFile(
|
||||
path=Path("/library/movie/test.mp4"),
|
||||
filename="test.mp4",
|
||||
size_bytes=1024000,
|
||||
modified_timestamp=datetime(2024, 1, 15, 10, 30, 0),
|
||||
category="movie",
|
||||
resolution=None,
|
||||
codec=None,
|
||||
duration_seconds=None,
|
||||
bitrate_kbps=None
|
||||
)
|
||||
]
|
||||
|
||||
output_file = tmp_path / "inventory.json"
|
||||
library_root = Path("/library")
|
||||
|
||||
from vlm.scanner import save_inventory_json
|
||||
save_inventory_json(video_files, output_file, library_root)
|
||||
|
||||
# Verify file was created
|
||||
assert output_file.exists()
|
||||
|
||||
# Read and verify content
|
||||
with open(output_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
assert len(data['files']) == 1
|
||||
file_data = data['files'][0]
|
||||
|
||||
# Check required fields
|
||||
assert file_data['filename'] == 'test.mp4'
|
||||
assert file_data['size_bytes'] == 1024000
|
||||
assert file_data['category'] == 'movie'
|
||||
|
||||
# Check optional fields are null
|
||||
assert file_data['resolution'] is None
|
||||
assert file_data['codec'] is None
|
||||
assert file_data['duration_seconds'] is None
|
||||
assert file_data['bitrate_kbps'] is None
|
||||
|
||||
def test_save_inventory_json_empty_list(self, tmp_path):
|
||||
"""Test saving empty inventory to JSON."""
|
||||
video_files = []
|
||||
output_file = tmp_path / "inventory.json"
|
||||
library_root = Path("/library")
|
||||
|
||||
from vlm.scanner import save_inventory_json
|
||||
save_inventory_json(video_files, output_file, library_root)
|
||||
|
||||
# Verify file was created
|
||||
assert output_file.exists()
|
||||
|
||||
# Read and verify content
|
||||
with open(output_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Should have metadata but no files
|
||||
assert data['metadata']['file_count'] == 0
|
||||
assert len(data['files']) == 0
|
||||
|
||||
def test_save_inventory_json_creates_directory(self, tmp_path):
|
||||
"""Test that save_inventory_json creates output directory if needed."""
|
||||
video_files = [
|
||||
VideoFile(
|
||||
path=Path("/library/movie/test.mp4"),
|
||||
filename="test.mp4",
|
||||
size_bytes=1024000,
|
||||
modified_timestamp=datetime(2024, 1, 15, 10, 30, 0),
|
||||
category="movie"
|
||||
)
|
||||
]
|
||||
|
||||
# Use nested directory that doesn't exist
|
||||
output_file = tmp_path / "reports" / "inventory.json"
|
||||
library_root = Path("/library")
|
||||
|
||||
from vlm.scanner import save_inventory_json
|
||||
save_inventory_json(video_files, output_file, library_root)
|
||||
|
||||
# Verify file was created
|
||||
assert output_file.exists()
|
||||
|
||||
def test_csv_and_json_consistency(self, tmp_path):
|
||||
"""Test that CSV and JSON exports contain the same data."""
|
||||
# Create test video files
|
||||
video_files = [
|
||||
VideoFile(
|
||||
path=Path("/library/movie/test.mp4"),
|
||||
filename="test.mp4",
|
||||
size_bytes=1024000,
|
||||
modified_timestamp=datetime(2024, 1, 15, 10, 30, 0),
|
||||
category="movie",
|
||||
resolution="1920x1080",
|
||||
codec="h264",
|
||||
duration_seconds=120.5,
|
||||
bitrate_kbps=5000
|
||||
)
|
||||
]
|
||||
|
||||
csv_file = tmp_path / "inventory.csv"
|
||||
json_file = tmp_path / "inventory.json"
|
||||
library_root = Path("/library")
|
||||
|
||||
from vlm.scanner import save_inventory_csv, save_inventory_json
|
||||
save_inventory_csv(video_files, csv_file, library_root)
|
||||
save_inventory_json(video_files, json_file, library_root)
|
||||
|
||||
# Read CSV data
|
||||
import csv
|
||||
with open(csv_file, 'r', encoding='utf-8') as f:
|
||||
lines = [line for line in f if not line.startswith('#')]
|
||||
reader = csv.DictReader(lines)
|
||||
csv_rows = list(reader)
|
||||
|
||||
# Read JSON data
|
||||
with open(json_file, 'r', encoding='utf-8') as f:
|
||||
json_data = json.load(f)
|
||||
|
||||
# Compare data
|
||||
assert len(csv_rows) == len(json_data['files'])
|
||||
|
||||
csv_row = csv_rows[0]
|
||||
json_file_data = json_data['files'][0]
|
||||
|
||||
# Compare key fields
|
||||
assert csv_row['filename'] == json_file_data['filename']
|
||||
assert csv_row['size_bytes'] == str(json_file_data['size_bytes'])
|
||||
assert csv_row['category'] == json_file_data['category']
|
||||
assert csv_row['resolution'] == json_file_data['resolution']
|
||||
assert csv_row['codec'] == json_file_data['codec']
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Unit tests for State Store operations."""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from vlm.state import (
|
||||
load_state,
|
||||
save_state,
|
||||
StateManager,
|
||||
VALID_STATUSES
|
||||
)
|
||||
from vlm.models import FileState, StateStore
|
||||
|
||||
|
||||
class TestLoadSaveState:
|
||||
"""Tests for load_state and save_state functions."""
|
||||
|
||||
def test_save_and_load_empty_state(self, tmp_path):
|
||||
"""Test saving and loading an empty state store."""
|
||||
state_path = tmp_path / "state.json"
|
||||
|
||||
# Create empty state store
|
||||
store = StateStore(
|
||||
states={},
|
||||
version='1.0',
|
||||
last_updated=datetime(2024, 1, 1, 12, 0, 0)
|
||||
)
|
||||
|
||||
# Save and load
|
||||
save_state(store, state_path)
|
||||
loaded = load_state(state_path)
|
||||
|
||||
assert loaded.states == {}
|
||||
assert loaded.version == '1.0'
|
||||
assert loaded.last_updated == datetime(2024, 1, 1, 12, 0, 0)
|
||||
|
||||
def test_save_and_load_with_states(self, tmp_path):
|
||||
"""Test saving and loading state store with file states."""
|
||||
state_path = tmp_path / "state.json"
|
||||
|
||||
# Create state store with some states
|
||||
file1 = Path("/videos/movie1.mp4")
|
||||
file2 = Path("/videos/series/episode.mkv")
|
||||
|
||||
store = StateStore(
|
||||
states={
|
||||
str(file1): FileState(
|
||||
file_path=file1,
|
||||
status="reviewed",
|
||||
reason="Checked manually",
|
||||
updated_at=datetime(2024, 1, 1, 12, 0, 0)
|
||||
),
|
||||
str(file2): FileState(
|
||||
file_path=file2,
|
||||
status="ignored",
|
||||
reason=None,
|
||||
updated_at=datetime(2024, 1, 2, 12, 0, 0)
|
||||
)
|
||||
},
|
||||
version='1.0',
|
||||
last_updated=datetime(2024, 1, 2, 12, 0, 0)
|
||||
)
|
||||
|
||||
# Save and load
|
||||
save_state(store, state_path)
|
||||
loaded = load_state(state_path)
|
||||
|
||||
assert len(loaded.states) == 2
|
||||
assert str(file1) in loaded.states
|
||||
assert str(file2) in loaded.states
|
||||
|
||||
state1 = loaded.states[str(file1)]
|
||||
assert state1.file_path == file1
|
||||
assert state1.status == "reviewed"
|
||||
assert state1.reason == "Checked manually"
|
||||
assert state1.updated_at == datetime(2024, 1, 1, 12, 0, 0)
|
||||
|
||||
state2 = loaded.states[str(file2)]
|
||||
assert state2.file_path == file2
|
||||
assert state2.status == "ignored"
|
||||
assert state2.reason is None
|
||||
assert state2.updated_at == datetime(2024, 1, 2, 12, 0, 0)
|
||||
|
||||
def test_save_creates_parent_directory(self, tmp_path):
|
||||
"""Test that save_state creates parent directories if needed."""
|
||||
state_path = tmp_path / "subdir" / "nested" / "state.json"
|
||||
|
||||
store = StateStore(
|
||||
states={},
|
||||
version='1.0',
|
||||
last_updated=datetime.now()
|
||||
)
|
||||
|
||||
save_state(store, state_path)
|
||||
|
||||
assert state_path.exists()
|
||||
assert state_path.parent.exists()
|
||||
|
||||
def test_load_nonexistent_file_raises_error(self, tmp_path):
|
||||
"""Test that loading a nonexistent file raises FileNotFoundError."""
|
||||
state_path = tmp_path / "nonexistent.json"
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_state(state_path)
|
||||
|
||||
def test_load_invalid_json_raises_error(self, tmp_path):
|
||||
"""Test that loading invalid JSON raises JSONDecodeError."""
|
||||
state_path = tmp_path / "invalid.json"
|
||||
state_path.write_text("not valid json {")
|
||||
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
load_state(state_path)
|
||||
|
||||
def test_saved_json_is_valid(self, tmp_path):
|
||||
"""Test that saved JSON is valid and human-readable."""
|
||||
state_path = tmp_path / "state.json"
|
||||
|
||||
file1 = Path("/videos/movie.mp4")
|
||||
store = StateStore(
|
||||
states={
|
||||
str(file1): FileState(
|
||||
file_path=file1,
|
||||
status="reviewed",
|
||||
reason="Test",
|
||||
updated_at=datetime(2024, 1, 1, 12, 0, 0)
|
||||
)
|
||||
},
|
||||
version='1.0',
|
||||
last_updated=datetime(2024, 1, 1, 12, 0, 0)
|
||||
)
|
||||
|
||||
save_state(store, state_path)
|
||||
|
||||
# Verify JSON is valid by loading it directly
|
||||
with open(state_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
assert 'states' in data
|
||||
assert 'version' in data
|
||||
assert 'last_updated' in data
|
||||
assert data['version'] == '1.0'
|
||||
|
||||
|
||||
class TestStateManager:
|
||||
"""Tests for StateManager class."""
|
||||
|
||||
def test_init_creates_new_state_if_not_exists(self, tmp_path):
|
||||
"""Test that StateManager creates a new state store if file doesn't exist."""
|
||||
state_path = tmp_path / "state.json"
|
||||
|
||||
manager = StateManager(state_path)
|
||||
|
||||
assert manager.store.states == {}
|
||||
assert manager.store.version == '1.0'
|
||||
assert isinstance(manager.store.last_updated, datetime)
|
||||
|
||||
def test_init_loads_existing_state(self, tmp_path):
|
||||
"""Test that StateManager loads existing state store."""
|
||||
state_path = tmp_path / "state.json"
|
||||
|
||||
# Create existing state
|
||||
file1 = Path("/videos/movie.mp4")
|
||||
store = StateStore(
|
||||
states={
|
||||
str(file1): FileState(
|
||||
file_path=file1,
|
||||
status="reviewed",
|
||||
reason="Test",
|
||||
updated_at=datetime(2024, 1, 1, 12, 0, 0)
|
||||
)
|
||||
},
|
||||
version='1.0',
|
||||
last_updated=datetime(2024, 1, 1, 12, 0, 0)
|
||||
)
|
||||
save_state(store, state_path)
|
||||
|
||||
# Load with manager
|
||||
manager = StateManager(state_path)
|
||||
|
||||
assert len(manager.store.states) == 1
|
||||
assert str(file1) in manager.store.states
|
||||
|
||||
def test_get_file_state_returns_state(self, tmp_path):
|
||||
"""Test getting state for a file."""
|
||||
state_path = tmp_path / "state.json"
|
||||
manager = StateManager(state_path)
|
||||
|
||||
file1 = Path("/videos/movie.mp4")
|
||||
manager.set_file_state(file1, "reviewed", "Test reason")
|
||||
|
||||
state = manager.get_file_state(file1)
|
||||
|
||||
assert state is not None
|
||||
assert state.file_path == file1
|
||||
assert state.status == "reviewed"
|
||||
assert state.reason == "Test reason"
|
||||
|
||||
def test_get_file_state_returns_none_if_not_found(self, tmp_path):
|
||||
"""Test that get_file_state returns None for unknown files."""
|
||||
state_path = tmp_path / "state.json"
|
||||
manager = StateManager(state_path)
|
||||
|
||||
file1 = Path("/videos/movie.mp4")
|
||||
state = manager.get_file_state(file1)
|
||||
|
||||
assert state is None
|
||||
|
||||
def test_set_file_state_creates_new_state(self, tmp_path):
|
||||
"""Test setting state for a new file."""
|
||||
state_path = tmp_path / "state.json"
|
||||
manager = StateManager(state_path)
|
||||
|
||||
file1 = Path("/videos/movie.mp4")
|
||||
manager.set_file_state(file1, "reviewed", "Checked")
|
||||
|
||||
state = manager.get_file_state(file1)
|
||||
assert state.status == "reviewed"
|
||||
assert state.reason == "Checked"
|
||||
assert isinstance(state.updated_at, datetime)
|
||||
|
||||
def test_set_file_state_updates_existing_state(self, tmp_path):
|
||||
"""Test that set_file_state is idempotent and updates timestamp."""
|
||||
state_path = tmp_path / "state.json"
|
||||
manager = StateManager(state_path)
|
||||
|
||||
file1 = Path("/videos/movie.mp4")
|
||||
|
||||
# Set initial state
|
||||
manager.set_file_state(file1, "reviewed", "First check")
|
||||
state1 = manager.get_file_state(file1)
|
||||
|
||||
# Update state
|
||||
manager.set_file_state(file1, "reviewed", "Second check")
|
||||
state2 = manager.get_file_state(file1)
|
||||
|
||||
assert state2.status == "reviewed"
|
||||
assert state2.reason == "Second check"
|
||||
assert state2.updated_at >= state1.updated_at
|
||||
|
||||
def test_set_file_state_validates_status(self, tmp_path):
|
||||
"""Test that set_file_state validates status values."""
|
||||
state_path = tmp_path / "state.json"
|
||||
manager = StateManager(state_path)
|
||||
|
||||
file1 = Path("/videos/movie.mp4")
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid status"):
|
||||
manager.set_file_state(file1, "invalid_status")
|
||||
|
||||
def test_set_file_state_accepts_all_valid_statuses(self, tmp_path):
|
||||
"""Test that all valid statuses are accepted."""
|
||||
state_path = tmp_path / "state.json"
|
||||
manager = StateManager(state_path)
|
||||
|
||||
file1 = Path("/videos/movie.mp4")
|
||||
|
||||
for status in VALID_STATUSES:
|
||||
manager.set_file_state(file1, status)
|
||||
state = manager.get_file_state(file1)
|
||||
assert state.status == status
|
||||
|
||||
def test_set_file_state_without_reason(self, tmp_path):
|
||||
"""Test setting state without a reason."""
|
||||
state_path = tmp_path / "state.json"
|
||||
manager = StateManager(state_path)
|
||||
|
||||
file1 = Path("/videos/movie.mp4")
|
||||
manager.set_file_state(file1, "ignored")
|
||||
|
||||
state = manager.get_file_state(file1)
|
||||
assert state.status == "ignored"
|
||||
assert state.reason is None
|
||||
|
||||
def test_query_by_status_returns_matching_files(self, tmp_path):
|
||||
"""Test querying files by status."""
|
||||
state_path = tmp_path / "state.json"
|
||||
manager = StateManager(state_path)
|
||||
|
||||
file1 = Path("/videos/movie1.mp4")
|
||||
file2 = Path("/videos/movie2.mp4")
|
||||
file3 = Path("/videos/movie3.mp4")
|
||||
|
||||
manager.set_file_state(file1, "reviewed")
|
||||
manager.set_file_state(file2, "ignored")
|
||||
manager.set_file_state(file3, "reviewed")
|
||||
|
||||
reviewed = manager.query_by_status("reviewed")
|
||||
|
||||
assert len(reviewed) == 2
|
||||
reviewed_paths = {state.file_path for state in reviewed}
|
||||
assert file1 in reviewed_paths
|
||||
assert file3 in reviewed_paths
|
||||
|
||||
def test_query_by_status_returns_empty_list_if_none_match(self, tmp_path):
|
||||
"""Test that query_by_status returns empty list if no matches."""
|
||||
state_path = tmp_path / "state.json"
|
||||
manager = StateManager(state_path)
|
||||
|
||||
file1 = Path("/videos/movie.mp4")
|
||||
manager.set_file_state(file1, "reviewed")
|
||||
|
||||
quarantined = manager.query_by_status("quarantined")
|
||||
|
||||
assert quarantined == []
|
||||
|
||||
def test_clear_state_removes_file_state(self, tmp_path):
|
||||
"""Test clearing state for a file."""
|
||||
state_path = tmp_path / "state.json"
|
||||
manager = StateManager(state_path)
|
||||
|
||||
file1 = Path("/videos/movie.mp4")
|
||||
manager.set_file_state(file1, "reviewed")
|
||||
|
||||
assert manager.get_file_state(file1) is not None
|
||||
|
||||
manager.clear_state(file1)
|
||||
|
||||
assert manager.get_file_state(file1) is None
|
||||
|
||||
def test_clear_state_on_nonexistent_file_does_nothing(self, tmp_path):
|
||||
"""Test that clearing state on nonexistent file doesn't raise error."""
|
||||
state_path = tmp_path / "state.json"
|
||||
manager = StateManager(state_path)
|
||||
|
||||
file1 = Path("/videos/movie.mp4")
|
||||
|
||||
# Should not raise error
|
||||
manager.clear_state(file1)
|
||||
|
||||
def test_save_persists_state_to_disk(self, tmp_path):
|
||||
"""Test that save() persists state to disk."""
|
||||
state_path = tmp_path / "state.json"
|
||||
manager = StateManager(state_path)
|
||||
|
||||
file1 = Path("/videos/movie.mp4")
|
||||
manager.set_file_state(file1, "reviewed", "Test")
|
||||
|
||||
# Save to disk
|
||||
manager.save()
|
||||
|
||||
# Load in new manager
|
||||
manager2 = StateManager(state_path)
|
||||
state = manager2.get_file_state(file1)
|
||||
|
||||
assert state is not None
|
||||
assert state.status == "reviewed"
|
||||
assert state.reason == "Test"
|
||||
|
||||
def test_state_updates_last_updated_timestamp(self, tmp_path):
|
||||
"""Test that state operations update last_updated timestamp."""
|
||||
state_path = tmp_path / "state.json"
|
||||
manager = StateManager(state_path)
|
||||
|
||||
initial_timestamp = manager.store.last_updated
|
||||
|
||||
file1 = Path("/videos/movie.mp4")
|
||||
manager.set_file_state(file1, "reviewed")
|
||||
|
||||
assert manager.store.last_updated >= initial_timestamp
|
||||
|
||||
def test_multiple_files_with_different_statuses(self, tmp_path):
|
||||
"""Test managing multiple files with different statuses."""
|
||||
state_path = tmp_path / "state.json"
|
||||
manager = StateManager(state_path)
|
||||
|
||||
files = [
|
||||
(Path("/videos/movie1.mp4"), "reviewed"),
|
||||
(Path("/videos/movie2.mp4"), "ignored"),
|
||||
(Path("/videos/movie3.mp4"), "planned"),
|
||||
(Path("/videos/movie4.mp4"), "executed"),
|
||||
(Path("/videos/movie5.mp4"), "quarantined"),
|
||||
]
|
||||
|
||||
for file_path, status in files:
|
||||
manager.set_file_state(file_path, status)
|
||||
|
||||
# Verify all statuses
|
||||
for file_path, expected_status in files:
|
||||
state = manager.get_file_state(file_path)
|
||||
assert state.status == expected_status
|
||||
|
||||
# Verify queries
|
||||
for status in VALID_STATUSES:
|
||||
results = manager.query_by_status(status)
|
||||
expected_count = sum(1 for _, s in files if s == status)
|
||||
assert len(results) == expected_count
|
||||
Reference in New Issue
Block a user