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]
|
[project]
|
||||||
name = "renamer"
|
name = "renamer"
|
||||||
version = "0.5.2"
|
version = "0.5.3"
|
||||||
description = "Terminal-based media file renamer and metadata viewer"
|
description = "Terminal-based media file renamer and metadata viewer"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ def cached_method(ttl_seconds: int = 3600) -> Callable:
|
|||||||
"""Decorator to cache method results with TTL.
|
"""Decorator to cache method results with TTL.
|
||||||
|
|
||||||
Caches the result of a method call using a global file-based cache.
|
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:
|
Args:
|
||||||
ttl_seconds: Time to live for cached results in seconds (default 1 hour)
|
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 decorator(func: Callable) -> Callable:
|
||||||
def wrapper(self, *args, **kwargs) -> Any:
|
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__
|
class_name = self.__class__.__name__
|
||||||
method_name = func.__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_str = json.dumps((args, kwargs), sort_keys=True, default=str)
|
||||||
param_hash = hashlib.md5(param_str.encode('utf-8')).hexdigest()
|
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
|
# Try to get from cache
|
||||||
cached_result = _cache.get_object(cache_key)
|
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 getattr(track, 'duration', 0) / 1000 if getattr(track, 'duration', None) else None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@cached_method()
|
|
||||||
def extract_frame_class(self) -> str | None:
|
def extract_frame_class(self) -> str | None:
|
||||||
"""Extract frame class from media info (480p, 720p, 1080p, etc.)"""
|
"""Extract frame class from media info (480p, 720p, 1080p, etc.)"""
|
||||||
if not self.video_tracks:
|
if not self.video_tracks:
|
||||||
return None
|
return None
|
||||||
height = getattr(self.video_tracks[0], 'height', 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
|
return None
|
||||||
|
|
||||||
# Check if interlaced
|
# Check if interlaced
|
||||||
interlaced = getattr(self.video_tracks[0], 'interlaced', None)
|
interlaced = getattr(self.video_tracks[0], 'interlaced', None)
|
||||||
scan_type = 'i' if interlaced == 'Yes' else 'p'
|
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
|
closest_class = None
|
||||||
min_diff = float('inf')
|
min_diff = float('inf')
|
||||||
for frame_class, info in FRAME_CLASSES.items():
|
for fc, info in FRAME_CLASSES.items():
|
||||||
if frame_class.endswith(scan_type):
|
if fc.endswith(scan_type):
|
||||||
diff = abs(height - info['nominal_height'])
|
diff = abs(effective_height - info['nominal_height'])
|
||||||
if diff < min_diff:
|
if diff < min_diff:
|
||||||
min_diff = diff
|
min_diff = diff
|
||||||
closest_class = frame_class
|
closest_class = fc
|
||||||
|
|
||||||
# Return the closest match if within reasonable distance
|
# Return closest standard match if within reasonable distance (20 pixels)
|
||||||
if closest_class and min_diff <= 100:
|
if closest_class and min_diff <= 20:
|
||||||
return closest_class
|
return closest_class
|
||||||
return None
|
|
||||||
|
# For non-standard resolutions, create a custom frame class
|
||||||
|
return frame_class
|
||||||
|
|
||||||
@cached_method()
|
@cached_method()
|
||||||
def extract_resolution(self) -> tuple[int, int] | None:
|
def extract_resolution(self) -> tuple[int, int] | None:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from renamer.extractors.mediainfo_extractor import MediaInfoExtractor
|
from renamer.extractors.mediainfo_extractor import MediaInfoExtractor
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
class TestMediaInfoExtractor:
|
class TestMediaInfoExtractor:
|
||||||
@@ -13,6 +14,13 @@ class TestMediaInfoExtractor:
|
|||||||
"""Use the filenames.txt file for testing"""
|
"""Use the filenames.txt file for testing"""
|
||||||
return Path(__file__).parent / "filenames.txt"
|
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):
|
def test_extract_resolution(self, extractor, test_file):
|
||||||
"""Test extracting resolution from media info"""
|
"""Test extracting resolution from media info"""
|
||||||
resolution = extractor.extract_resolution()
|
resolution = extractor.extract_resolution()
|
||||||
@@ -47,4 +55,22 @@ class TestMediaInfoExtractor:
|
|||||||
"""Test checking if video is 3D"""
|
"""Test checking if video is 3D"""
|
||||||
is_3d = extractor.is_3d()
|
is_3d = extractor.is_3d()
|
||||||
# Text files don't have video tracks
|
# Text files don't have video tracks
|
||||||
assert is_3d is False
|
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