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:
sHa
2025-12-29 22:03:41 +00:00
parent e0637e9981
commit 6694567ab4
8 changed files with 259 additions and 17 deletions

BIN
dist/renamer-0.5.3-py3-none-any.whl vendored Normal file

Binary file not shown.

View File

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

View File

@@ -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)

View File

@@ -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:

View File

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

View 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"
}
]

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

2
uv.lock generated
View File

@@ -342,7 +342,7 @@ wheels = [
[[package]]
name = "renamer"
version = "0.5.2"
version = "0.5.3"
source = { editable = "." }
dependencies = [
{ name = "langcodes" },