Add unit tests for MediaInfo frame class detection
- Created a JSON file containing various test cases for different video resolutions and their expected frame classes. - Implemented a pytest test script that loads the test cases and verifies the frame class detection functionality of the MediaInfoExtractor. - Utilized mocking to simulate the behavior of the MediaInfoExtractor and its video track attributes.
This commit is contained in:
BIN
dist/renamer-0.5.3-py3-none-any.whl
vendored
Normal file
BIN
dist/renamer-0.5.3-py3-none-any.whl
vendored
Normal file
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "renamer"
|
||||
version = "0.5.2"
|
||||
version = "0.5.3"
|
||||
description = "Terminal-based media file renamer and metadata viewer"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -15,7 +15,7 @@ def cached_method(ttl_seconds: int = 3600) -> Callable:
|
||||
"""Decorator to cache method results with TTL.
|
||||
|
||||
Caches the result of a method call using a global file-based cache.
|
||||
The cache key includes class name, method name, and parameters hash.
|
||||
The cache key includes class name, method name, instance identifier, and parameters hash.
|
||||
|
||||
Args:
|
||||
ttl_seconds: Time to live for cached results in seconds (default 1 hour)
|
||||
@@ -25,15 +25,18 @@ def cached_method(ttl_seconds: int = 3600) -> Callable:
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
def wrapper(self, *args, **kwargs) -> Any:
|
||||
# Generate cache key: class_name.method_name.param_hash
|
||||
# Generate cache key: class_name.method_name.instance_id.param_hash
|
||||
class_name = self.__class__.__name__
|
||||
method_name = func.__name__
|
||||
|
||||
# Create hash from args and kwargs
|
||||
# Use instance identifier (file_path for extractors)
|
||||
instance_id = getattr(self, 'file_path', str(id(self)))
|
||||
|
||||
# Create hash from args and kwargs (excluding self)
|
||||
param_str = json.dumps((args, kwargs), sort_keys=True, default=str)
|
||||
param_hash = hashlib.md5(param_str.encode('utf-8')).hexdigest()
|
||||
|
||||
cache_key = f"{class_name}.{method_name}.{param_hash}"
|
||||
cache_key = f"{class_name}.{method_name}.{instance_id}.{param_hash}"
|
||||
|
||||
# Try to get from cache
|
||||
cached_result = _cache.get_object(cache_key)
|
||||
|
||||
@@ -65,33 +65,60 @@ class MediaInfoExtractor:
|
||||
return getattr(track, 'duration', 0) / 1000 if getattr(track, 'duration', None) else None
|
||||
return None
|
||||
|
||||
@cached_method()
|
||||
def extract_frame_class(self) -> str | None:
|
||||
"""Extract frame class from media info (480p, 720p, 1080p, etc.)"""
|
||||
if not self.video_tracks:
|
||||
return None
|
||||
height = getattr(self.video_tracks[0], 'height', None)
|
||||
if not height:
|
||||
width = getattr(self.video_tracks[0], 'width', None)
|
||||
if not height or not width:
|
||||
return None
|
||||
|
||||
# Check if interlaced
|
||||
interlaced = getattr(self.video_tracks[0], 'interlaced', None)
|
||||
scan_type = 'i' if interlaced == 'Yes' else 'p'
|
||||
|
||||
# Find the closest frame class based on height
|
||||
# Calculate effective height for frame class determination
|
||||
aspect_ratio = 16 / 9
|
||||
if height > width:
|
||||
effective_height = height / aspect_ratio
|
||||
else:
|
||||
effective_height = height
|
||||
|
||||
# First, try to match width to typical widths
|
||||
width_matches = []
|
||||
for frame_class, info in FRAME_CLASSES.items():
|
||||
if width in info['typical_widths'] and frame_class.endswith(scan_type):
|
||||
diff = abs(height - info['nominal_height'])
|
||||
width_matches.append((frame_class, diff))
|
||||
|
||||
if width_matches:
|
||||
# Choose the frame class with the smallest height difference
|
||||
width_matches.sort(key=lambda x: x[1])
|
||||
return width_matches[0][0]
|
||||
|
||||
# If no width match, fall back to height-based matching
|
||||
# First try exact match with standard frame classes
|
||||
frame_class = f"{int(round(effective_height))}{scan_type}"
|
||||
if frame_class in FRAME_CLASSES:
|
||||
return frame_class
|
||||
|
||||
# Find closest standard height match
|
||||
closest_class = None
|
||||
min_diff = float('inf')
|
||||
for frame_class, info in FRAME_CLASSES.items():
|
||||
if frame_class.endswith(scan_type):
|
||||
diff = abs(height - info['nominal_height'])
|
||||
for fc, info in FRAME_CLASSES.items():
|
||||
if fc.endswith(scan_type):
|
||||
diff = abs(effective_height - info['nominal_height'])
|
||||
if diff < min_diff:
|
||||
min_diff = diff
|
||||
closest_class = frame_class
|
||||
closest_class = fc
|
||||
|
||||
# Return the closest match if within reasonable distance
|
||||
if closest_class and min_diff <= 100:
|
||||
# Return closest standard match if within reasonable distance (20 pixels)
|
||||
if closest_class and min_diff <= 20:
|
||||
return closest_class
|
||||
return None
|
||||
|
||||
# For non-standard resolutions, create a custom frame class
|
||||
return frame_class
|
||||
|
||||
@cached_method()
|
||||
def extract_resolution(self) -> tuple[int, int] | None:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from renamer.extractors.mediainfo_extractor import MediaInfoExtractor
|
||||
import json
|
||||
|
||||
|
||||
class TestMediaInfoExtractor:
|
||||
@@ -13,6 +14,13 @@ class TestMediaInfoExtractor:
|
||||
"""Use the filenames.txt file for testing"""
|
||||
return Path(__file__).parent / "filenames.txt"
|
||||
|
||||
@pytest.fixture
|
||||
def frame_class_cases(self):
|
||||
"""Load test cases for frame class extraction"""
|
||||
cases_file = Path(__file__).parent / "test_mediainfo_frame_class_cases.json"
|
||||
with open(cases_file, 'r') as f:
|
||||
return json.load(f)
|
||||
|
||||
def test_extract_resolution(self, extractor, test_file):
|
||||
"""Test extracting resolution from media info"""
|
||||
resolution = extractor.extract_resolution()
|
||||
@@ -48,3 +56,21 @@ class TestMediaInfoExtractor:
|
||||
is_3d = extractor.is_3d()
|
||||
# Text files don't have video tracks
|
||||
assert is_3d is False
|
||||
|
||||
@pytest.mark.parametrize("case", [
|
||||
pytest.param(case, id=case["testname"])
|
||||
for case in json.load(open(Path(__file__).parent / "test_mediainfo_frame_class_cases.json"))
|
||||
])
|
||||
def test_extract_frame_class(self, case):
|
||||
"""Test extracting frame class from various resolutions"""
|
||||
# Create a mock extractor with the test resolution
|
||||
extractor = MediaInfoExtractor.__new__(MediaInfoExtractor)
|
||||
extractor.video_tracks = [{
|
||||
'width': case["resolution"][0],
|
||||
'height': case["resolution"][1],
|
||||
'interlaced': 'Yes' if case["interlaced"] else None
|
||||
}]
|
||||
|
||||
result = extractor.extract_frame_class()
|
||||
print(f"Case: {case['testname']}, resolution: {case['resolution']}, expected: {case['expected_frame_class']}, got: {result}")
|
||||
assert result == case["expected_frame_class"], f"Failed for {case['testname']}: expected {case['expected_frame_class']}, got {result}"
|
||||
146
renamer/test/test_mediainfo_frame_class.json
Normal file
146
renamer/test/test_mediainfo_frame_class.json
Normal file
@@ -0,0 +1,146 @@
|
||||
[
|
||||
{
|
||||
"testname": "test-480p-sd",
|
||||
"resolution": [720, 480],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "480p"
|
||||
},
|
||||
{
|
||||
"testname": "test-576p-pal",
|
||||
"resolution": [720, 576],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "576p"
|
||||
},
|
||||
{
|
||||
"testname": "test-720p-hd",
|
||||
"resolution": [1280, 720],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "720p"
|
||||
},
|
||||
{
|
||||
"testname": "test-1080p-fullhd",
|
||||
"resolution": [1920, 1080],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "1080p"
|
||||
},
|
||||
{
|
||||
"testname": "test-1080i-broadcast",
|
||||
"resolution": [1920, 1080],
|
||||
"interlaced": true,
|
||||
"expected_frame_class": "1080i"
|
||||
},
|
||||
{
|
||||
"testname": "test-1440p-qhd",
|
||||
"resolution": [2560, 1440],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "1440p"
|
||||
},
|
||||
{
|
||||
"testname": "test-2160p-uhd",
|
||||
"resolution": [3840, 2160],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "2160p"
|
||||
},
|
||||
{
|
||||
"testname": "test-4320p-8k",
|
||||
"resolution": [7680, 4320],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "4320p"
|
||||
},
|
||||
{
|
||||
"testname": "test-1080p-cinema-240",
|
||||
"resolution": [1920, 804],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "1080p"
|
||||
},
|
||||
{
|
||||
"testname": "test-1080p-cinema-235",
|
||||
"resolution": [1920, 816],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "1080p"
|
||||
},
|
||||
{
|
||||
"testname": "test-720p-cinema",
|
||||
"resolution": [1280, 536],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "720p"
|
||||
},
|
||||
{
|
||||
"testname": "test-2160p-cinema",
|
||||
"resolution": [3840, 1608],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "2160p"
|
||||
},
|
||||
{
|
||||
"testname": "test-mobile-vertical-iphone",
|
||||
"resolution": [1170, 2532],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "1440p"
|
||||
},
|
||||
{
|
||||
"testname": "test-mobile-vertical-4k",
|
||||
"resolution": [2160, 3840],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "2160p"
|
||||
},
|
||||
{
|
||||
"testname": "test-square-video",
|
||||
"resolution": [1080, 1080],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "1080p"
|
||||
},
|
||||
{
|
||||
"testname": "test-vhs-capture",
|
||||
"resolution": [720, 404],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "480p"
|
||||
},
|
||||
{
|
||||
"testname": "test-miniDV-pal",
|
||||
"resolution": [720, 576],
|
||||
"interlaced": true,
|
||||
"expected_frame_class": "576i"
|
||||
},
|
||||
{
|
||||
"testname": "test-old-digital-camera-4by3",
|
||||
"resolution": [1024, 768],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "768p"
|
||||
},
|
||||
{
|
||||
"testname": "test-old-digital-camera-lowres",
|
||||
"resolution": [800, 600],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "600p"
|
||||
},
|
||||
{
|
||||
"testname": "test-webcam-legacy",
|
||||
"resolution": [640, 480],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "480p"
|
||||
},
|
||||
{
|
||||
"testname": "test-odd-nonstandard-wide",
|
||||
"resolution": [1600, 900],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "900p"
|
||||
},
|
||||
{
|
||||
"testname": "test-odd-nonstandard-small",
|
||||
"resolution": [854, 480],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "480p"
|
||||
},
|
||||
{
|
||||
"testname": "test-ultrawide-monitor-capture",
|
||||
"resolution": [3440, 1440],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "1440p"
|
||||
},
|
||||
{
|
||||
"testname": "test-strange-lowres",
|
||||
"resolution": [512, 288],
|
||||
"interlaced": false,
|
||||
"expected_frame_class": "288p"
|
||||
}
|
||||
]
|
||||
40
renamer/test/test_mediainfo_frame_class.py
Normal file
40
renamer/test/test_mediainfo_frame_class.py
Normal file
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test script for MediaInfo frame class detection by resolution"""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
from renamer.extractors.mediainfo_extractor import MediaInfoExtractor
|
||||
|
||||
test_cases = json.load(open('renamer/test/test_mediainfo_frame_class.json'))
|
||||
|
||||
@pytest.mark.parametrize("test_case", test_cases, ids=[tc['testname'] for tc in test_cases])
|
||||
def test_frame_class_detection(test_case):
|
||||
"""Test frame class detection for various resolutions"""
|
||||
|
||||
testname = test_case['testname']
|
||||
width, height = test_case['resolution']
|
||||
interlaced = test_case['interlaced']
|
||||
expected = test_case['expected_frame_class']
|
||||
|
||||
# Create a mock MediaInfoExtractor
|
||||
extractor = MagicMock(spec=MediaInfoExtractor)
|
||||
from pathlib import Path
|
||||
extractor.file_path = Path(f"test_{testname}") # Set a unique file_path for caching
|
||||
|
||||
# Mock the video_tracks
|
||||
mock_track = MagicMock()
|
||||
mock_track.height = height
|
||||
mock_track.width = width
|
||||
mock_track.interlaced = 'Yes' if interlaced else 'No'
|
||||
|
||||
extractor.video_tracks = [mock_track]
|
||||
|
||||
# Test the method
|
||||
actual = MediaInfoExtractor.extract_frame_class(extractor)
|
||||
|
||||
assert actual == expected, f"{testname}: expected {expected}, got {actual}"
|
||||
Reference in New Issue
Block a user