Files
dl-organizer/tests/test_analysis_properties.py
T

550 lines
19 KiB
Python
Raw Normal View History

2026-02-09 17:43:35 +08:00
"""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.
"""
from datetime import datetime, timezone
from pathlib import Path
2026-02-09 17:43:35 +08:00
from hypothesis import given, settings
from hypothesis import strategies as st
from vlm.analysis import analyze_series_completeness, detect_duplicates
from vlm.models import DuplicateGroup, MovieIdentity, SeasonCompleteness, SeriesIdentity, VideoFile
from vlm.reports import (
generate_completeness_report,
generate_duplicate_report,
generate_summary_report,
)
2026-02-09 17:43:35 +08:00
def _movie(title="Movie", year=2020, **kw):
return MovieIdentity(
title=title, year=year, confidence=kw.pop("confidence", 0.9),
needs_review=kw.pop("needs_review", False),
original_filename=kw.pop("original_filename", f"{title}.{year}.mkv"),
**kw,
)
def _series(title="Show", season=1, episodes=None, **kw):
if episodes is None:
episodes = [1]
return SeriesIdentity(
title=title, season=season, episodes=episodes,
confidence=kw.pop("confidence", 0.9),
needs_review=kw.pop("needs_review", False),
original_filename=kw.pop(
"original_filename",
f"{title.replace(' ', '.')}.S{season:02d}E01.mkv"
if season is not None
else f"{title.replace(' ', '.')}.E01.mkv",
),
**kw,
)
def _video(filename="file.mkv", size=1000, category="movie", **kw):
return VideoFile(
path=kw.pop("path", Path(f"/tmp/{filename}")),
filename=filename, size_bytes=size,
modified_timestamp=kw.pop("modified_timestamp", datetime(2023, 1, 1)),
category=category, **kw,
)
2026-02-09 17:43:35 +08:00
# 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=' '
)))
2026-02-09 17:43:35 +08:00
if season is None:
season = draw(st.integers(min_value=1, max_value=20))
2026-02-09 17:43:35 +08:00
# 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
))
2026-02-09 17:43:35 +08:00
confidence = draw(st.floats(min_value=0.5, max_value=1.0))
original_filename = f"{title.replace(' ', '.')}.S{season:02d}E{episodes[0]:02d}.mkv"
return _series(title, season, sorted(episodes),
confidence=confidence,
original_filename=original_filename)
2026-02-09 17:43:35 +08:00
@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=' '
)))
2026-02-09 17:43:35 +08:00
if year is None:
year = draw(st.integers(min_value=1900, max_value=2030))
2026-02-09 17:43:35 +08:00
confidence = draw(st.floats(min_value=0.5, max_value=1.0))
original_filename = f"{title.replace(' ', '.')}.{year}.mkv"
return _movie(title, year, confidence=confidence,
original_filename=original_filename)
2026-02-09 17:43:35 +08:00
@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"
2026-02-09 17:43:35 +08:00
size_bytes = draw(st.integers(min_value=1000000, max_value=10000000000))
now = datetime.now(timezone.utc)
2026-02-09 17:43:35 +08:00
# 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 _video(
filename, size_bytes, category,
modified_timestamp=now, path=Path(f"/{category}/{filename}"),
resolution=resolution, codec=codec,
duration_seconds=duration_seconds, bitrate_kbps=bitrate_kbps,
)
2026-02-09 17:43:35 +08:00
else:
return _video(
filename, size_bytes, category,
modified_timestamp=now, path=Path(f"/{category}/{filename}"),
)
2026-02-09 17:43:35 +08:00
# 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].
2026-02-09 17:43:35 +08:00
Validates: Requirements 4.1, 4.2
"""
# Sort episodes and ensure there's at least one gap
sorted_episodes = sorted(episodes_data)
2026-02-09 17:43:35 +08:00
# 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:]
2026-02-09 17:43:35 +08:00
# Create SeriesIdentity objects
episode_identities = [
_series(title, season, [ep],
original_filename=f"{title}.S{season:02d}E{ep:02d}.mkv")
2026-02-09 17:43:35 +08:00
for ep in episodes_with_gap
]
2026-02-09 17:43:35 +08:00
# Analyze completeness
result = analyze_series_completeness(episode_identities)
2026-02-09 17:43:35 +08:00
# Should detect the gap
if len(result) > 0:
assert result[0].series_title == title
assert result[0].season == season
2026-02-09 17:43:35 +08:00
# 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)
2026-02-09 17:43:35 +08:00
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.
2026-02-09 17:43:35 +08:00
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
2026-02-09 17:43:35 +08:00
# Create episodes for season 2 (complete, no gaps)
s2_sorted = sorted(season2_episodes)
s2_complete = list(range(min(s2_sorted), max(s2_sorted) + 1))
2026-02-09 17:43:35 +08:00
# Create SeriesIdentity objects
episode_identities = []
for ep in s1_with_gap:
episode_identities.append(
_series(title, 1, [ep],
original_filename=f"{title}.S01E{ep:02d}.mkv")
2026-02-09 17:43:35 +08:00
)
for ep in s2_complete:
episode_identities.append(
_series(title, 2, [ep],
original_filename=f"{title}.S02E{ep:02d}.mkv")
2026-02-09 17:43:35 +08:00
)
2026-02-09 17:43:35 +08:00
# Analyze completeness
result = analyze_series_completeness(episode_identities)
2026-02-09 17:43:35 +08:00
# 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
2026-02-09 17:43:35 +08:00
# 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.
2026-02-09 17:43:35 +08:00
Validates: Requirements 5.1
"""
# Create multiple movie identities with same title and year
identities = []
files = []
now = datetime.now(timezone.utc)
2026-02-09 17:43:35 +08:00
for i in range(duplicate_count):
filename = f"{title.replace(' ', '.')}.{year}.{i}.mkv"
identities.append(_movie(title, year, original_filename=filename))
files.append(_video(
filename, 1_000_000_000 + i * 100_000_000,
modified_timestamp=now, path=Path(f"/movies/{filename}"),
2026-02-09 17:43:35 +08:00
))
2026-02-09 17:43:35 +08:00
# Detect duplicates
result = detect_duplicates(list(zip(identities, files)))
2026-02-09 17:43:35 +08:00
# Should find exactly one duplicate group
assert len(result) == 1
2026-02-09 17:43:35 +08:00
# The group should contain all files
assert len(result[0].files) == duplicate_count
2026-02-09 17:43:35 +08:00
# 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.
2026-02-09 17:43:35 +08:00
Validates: Requirements 5.2
"""
# Create multiple series identities with same title, season, and episode
identities = []
files = []
now = datetime.now(timezone.utc)
2026-02-09 17:43:35 +08:00
for i in range(duplicate_count):
filename = f"{title.replace(' ', '.')}.S{season:02d}E{episode:02d}.{i}.mkv"
identities.append(
_series(title, season, [episode], original_filename=filename)
)
files.append(_video(
filename, 1_000_000_000 + i * 100_000_000, "series",
modified_timestamp=now, path=Path(f"/series/{filename}"),
2026-02-09 17:43:35 +08:00
))
2026-02-09 17:43:35 +08:00
# Detect duplicates
result = detect_duplicates(list(zip(identities, files)))
2026-02-09 17:43:35 +08:00
# Should find exactly one duplicate group
assert len(result) == 1
2026-02-09 17:43:35 +08:00
# The group should contain all files
assert len(result[0].files) == duplicate_count
2026-02-09 17:43:35 +08:00
# 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.
2026-02-09 17:43:35 +08:00
Validates: Requirements 5.3
"""
# Create movie identities and files with varying metadata
identities = []
files = []
now = datetime.now(timezone.utc)
2026-02-09 17:43:35 +08:00
for i in range(file_count):
filename = f"{title.replace(' ', '.')}.{year}.{i}.mkv"
identities.append(_movie(title, year, original_filename=filename))
2026-02-09 17:43:35 +08:00
# Some files have full metadata, some don't
if i % 2 == 0:
files.append(_video(
filename, 1_000_000_000 + i * 100_000_000,
modified_timestamp=now, path=Path(f"/movies/{filename}"),
resolution="1920x1080", codec="h264",
duration_seconds=7200.0, bitrate_kbps=5000,
2026-02-09 17:43:35 +08:00
))
else:
files.append(_video(
filename, 1_000_000_000 + i * 100_000_000,
modified_timestamp=now, path=Path(f"/movies/{filename}"),
2026-02-09 17:43:35 +08:00
))
2026-02-09 17:43:35 +08:00
# Detect duplicates
result = detect_duplicates(list(zip(identities, files)))
2026-02-09 17:43:35 +08:00
# Should have quality comparison data
assert len(result) == 1
assert len(result[0].quality_comparison) == file_count
2026-02-09 17:43:35 +08:00
# 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
2026-02-09 17:43:35 +08:00
# 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.
2026-02-09 17:43:35 +08:00
Validates: Requirements 11.2
"""
# Create series with gaps
analysis_results = []
2026-02-09 17:43:35 +08:00
for i in range(series_count):
title = f"Series {i}"
2026-02-09 17:43:35 +08:00
analysis_results.append(SeasonCompleteness(
series_title=title,
season=1,
episodes_found=[1, 2, 4, 5], # Gap at episode 3
episodes_missing=[3]
2026-02-09 17:43:35 +08:00
))
2026-02-09 17:43:35 +08:00
# Generate report
library_root = Path("/test/library")
report = generate_completeness_report(analysis_results, format, library_root)
2026-02-09 17:43:35 +08:00
# Report should include all series
for i in range(series_count):
assert f"Series {i}" in report
2026-02-09 17:43:35 +08:00
# 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.
2026-02-09 17:43:35 +08:00
Validates: Requirements 11.3
"""
# Create duplicate groups
duplicate_groups = []
now = datetime.now(timezone.utc)
2026-02-09 17:43:35 +08:00
for i in range(duplicate_count):
title = f"Movie {i}"
year = 2020 + i
2026-02-09 17:43:35 +08:00
# Create 2 files for each duplicate group
files = []
quality_comparison = []
2026-02-09 17:43:35 +08:00
for j in range(2):
filename = f"{title.replace(' ', '.')}.{year}.{j}.mkv"
file = _video(
filename, 1_000_000_000 + j * 500_000_000,
modified_timestamp=now, path=Path(f"/movies/{filename}"),
2026-02-09 17:43:35 +08:00
resolution="1920x1080" if j == 0 else "1280x720",
codec="h264",
2026-02-09 17:43:35 +08:00
)
files.append(file)
quality_comparison.append({
'filename': filename,
'path': str(file.path),
'size_bytes': file.size_bytes,
'resolution': file.resolution,
'codec': file.codec
})
identity = _movie(title, year, original_filename=files[0].filename)
duplicate_groups.append(DuplicateGroup(
identity=identity, files=files,
quality_comparison=quality_comparison,
))
2026-02-09 17:43:35 +08:00
# Generate report
library_root = Path("/test/library")
report = generate_duplicate_report(duplicate_groups, format, library_root)
2026-02-09 17:43:35 +08:00
# Report should include all duplicate groups
for i in range(duplicate_count):
assert f"Movie {i}" in report
2026-02-09 17:43:35 +08:00
# Report should include comparison data (file sizes, resolutions)
assert "1920x1080" in report or "resolution" in report.lower()
2026-02-09 17:43:35 +08:00
# 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.
2026-02-09 17:43:35 +08:00
Validates: Requirements 11.4
"""
# Create video files
files = []
total_size = 0
category_counts = {}
now = datetime.now(timezone.utc)
2026-02-09 17:43:35 +08:00
for i in range(file_count):
category = categories[i % len(categories)]
size = 1_000_000_000 + i * 100_000_000
2026-02-09 17:43:35 +08:00
filename = f"file_{i}.mkv"
files.append(_video(
filename, size, category,
modified_timestamp=now, path=Path(f"/{category}/{filename}"),
2026-02-09 17:43:35 +08:00
))
2026-02-09 17:43:35 +08:00
total_size += size
category_counts[category] = category_counts.get(category, 0) + 1
2026-02-09 17:43:35 +08:00
# Generate summary report
library_root = Path("/test/library")
report = generate_summary_report(files, library_root)
2026-02-09 17:43:35 +08:00
# Report should include total file count
assert f"Total Files: {file_count}" in report
2026-02-09 17:43:35 +08:00
# Report should include category breakdown
for category, count in category_counts.items():
assert category.capitalize() in report
assert f"Files: {count}" in report
2026-02-09 17:43:35 +08:00
# Report should include metadata
assert str(library_root) in report