"""Unit tests for report generation. Tests inventory reports, completeness reports, duplicate reports, and summary reports in various formats. """ import csv import json import os import time import pytest from io import StringIO from pathlib import Path from datetime import datetime, timezone 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(timezone.utc), "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.""" naive_local = datetime(2023, 6, 15, 14, 30, 45) files = [ VideoFile( Path("/test.mkv"), "test.mkv", 1000, naive_local, "movie" ) ] library_root = Path("/test") report = generate_inventory_report(files, "csv", library_root) # Check timestamp format expected_utc = naive_local.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S") assert expected_utc in report @pytest.mark.skipif(not hasattr(time, "tzset"), reason="tzset not available on this platform") def test_naive_timestamp_is_converted_from_local_to_utc(self, monkeypatch): """Test report conversion for naive timestamps uses local timezone semantics.""" original_tz = os.environ.get("TZ") try: monkeypatch.setenv("TZ", "Etc/GMT-2") time.tzset() naive_local = datetime(2023, 6, 15, 14, 30, 45) expected_utc = naive_local.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S") files = [ VideoFile( Path("/test.mkv"), "test.mkv", 1000, naive_local, "movie" ) ] report = generate_inventory_report(files, "json", Path("/test")) data = json.loads(report) assert data["files"][0]["modified_timestamp"] == expected_utc finally: if original_tz is None: monkeypatch.delenv("TZ", raising=False) else: monkeypatch.setenv("TZ", original_tz) time.tzset() 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(timezone.utc), "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(timezone.utc), "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(timezone.utc), "movie" ), VideoFile( Path("/movies/Inception.2010.720p.mkv"), "Inception.2010.720p.mkv", 1000000000, datetime.now(timezone.utc), "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(timezone.utc), "series" ), VideoFile( Path("/series/Breaking.Bad.S01E01.720p.mkv"), "Breaking.Bad.S01E01.720p.mkv", 800000000, datetime.now(timezone.utc), "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(timezone.utc), "movie"), VideoFile(Path("/movies/Small.Movie.2.mkv"), "Small.Movie.2.mkv", 600000000, datetime.now(timezone.utc), "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(timezone.utc), "movie"), VideoFile(Path("/movies/Large.Movie.2.mkv"), "Large.Movie.2.mkv", 1800000000, datetime.now(timezone.utc), "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 def test_sorted_by_quality_size_when_file_sizes_missing(self): """Sort order should use quality_comparison sizes when VideoFile sizes are zero.""" identity1 = MovieIdentity("Tiny", 2020, 0.9, False, "Tiny.mkv") files1 = [ VideoFile(Path("/movies/Tiny.1.mkv"), "Tiny.1.mkv", 0, datetime.now(timezone.utc), "movie"), VideoFile(Path("/movies/Tiny.2.mkv"), "Tiny.2.mkv", 0, datetime.now(timezone.utc), "movie"), ] quality1 = [ {"filename": "Tiny.1.mkv", "path": "/movies/Tiny.1.mkv", "size_bytes": 600000000}, {"filename": "Tiny.2.mkv", "path": "/movies/Tiny.2.mkv", "size_bytes": 500000000}, ] identity2 = MovieIdentity("Huge", 2021, 0.9, False, "Huge.mkv") files2 = [ VideoFile(Path("/movies/Huge.1.mkv"), "Huge.1.mkv", 0, datetime.now(timezone.utc), "movie"), VideoFile(Path("/movies/Huge.2.mkv"), "Huge.2.mkv", 0, datetime.now(timezone.utc), "movie"), ] quality2 = [ {"filename": "Huge.1.mkv", "path": "/movies/Huge.1.mkv", "size_bytes": 3000000000}, {"filename": "Huge.2.mkv", "path": "/movies/Huge.2.mkv", "size_bytes": 2800000000}, ] duplicates = [ DuplicateGroup(identity1, files1, quality1), DuplicateGroup(identity2, files2, quality2), ] report = generate_duplicate_report(duplicates, "text", Path("/mnt/nas/videos")) assert report.find("Huge") < report.find("Tiny") 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(timezone.utc), "movie"), VideoFile(Path("/movies/Movie2.mkv"), "Movie2.mkv", 1500000000, datetime.now(timezone.utc), "movie"), VideoFile(Path("/series/Show.S01E01.mkv"), "Show.S01E01.mkv", 1000000000, datetime.now(timezone.utc), "series"), VideoFile(Path("/series/Show.S01E02.mkv"), "Show.S01E02.mkv", 1000000000, datetime.now(timezone.utc), "series"), VideoFile(Path("/anime/Anime1.mkv"), "Anime1.mkv", 800000000, datetime.now(timezone.utc), "anime"), VideoFile(Path("/other/Random.mkv"), "Random.mkv", 500000000, datetime.now(timezone.utc), "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(timezone.utc), "movie"), VideoFile(Path("/movies/Movie2.mkv"), "Movie2.mkv", 2000000000, datetime.now(timezone.utc), "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"