Files
dl-organizer/tests/test_reports.py
T

758 lines
28 KiB
Python

"""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"