670 lines
21 KiB
Python
670 lines
21 KiB
Python
import contextlib
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
|
|
SCRIPT_PATH = (
|
|
Path(__file__).resolve().parents[1]
|
|
/ "scripts"
|
|
/ "detect_wcx_title.py"
|
|
)
|
|
SPEC = importlib.util.spec_from_file_location(
|
|
"detect_wcx_title",
|
|
SCRIPT_PATH,
|
|
)
|
|
if SPEC is None or SPEC.loader is None:
|
|
raise RuntimeError(f"Could not load script: {SCRIPT_PATH}")
|
|
|
|
detect_wcx_title = importlib.util.module_from_spec(SPEC)
|
|
SPEC.loader.exec_module(detect_wcx_title)
|
|
|
|
|
|
class PixelClassificationTests(unittest.TestCase):
|
|
def test_nearly_black_thresholds(self) -> None:
|
|
self.assertTrue(
|
|
detect_wcx_title.is_nearly_black(30, 30, 30)
|
|
)
|
|
self.assertFalse(
|
|
detect_wcx_title.is_nearly_black(31, 30, 30)
|
|
)
|
|
|
|
def test_red_thresholds(self) -> None:
|
|
self.assertTrue(
|
|
detect_wcx_title.is_red(150, 100, 100)
|
|
)
|
|
self.assertFalse(
|
|
detect_wcx_title.is_red(139, 0, 0)
|
|
)
|
|
self.assertFalse(
|
|
detect_wcx_title.is_red(150, 101, 100)
|
|
)
|
|
|
|
def test_bright_thresholds(self) -> None:
|
|
self.assertTrue(
|
|
detect_wcx_title.is_bright(180, 180, 180)
|
|
)
|
|
self.assertFalse(
|
|
detect_wcx_title.is_bright(180, 179, 180)
|
|
)
|
|
|
|
def test_layout_metrics_for_constructed_buffer(self) -> None:
|
|
width = 10
|
|
height = 20
|
|
pixels = [(100, 100, 100)] * (width * height)
|
|
|
|
for x_position in range(width):
|
|
pixels[x_position] = (0, 0, 0)
|
|
pixels[width + x_position] = (0, 0, 0)
|
|
|
|
for y_position in range(3):
|
|
pixels[y_position * width] = (150, 0, 0)
|
|
|
|
for y_position in range(height - 3, height):
|
|
pixels[y_position * width + 1] = (255, 255, 255)
|
|
|
|
rgb_data = bytes(
|
|
channel
|
|
for pixel in pixels
|
|
for channel in pixel
|
|
)
|
|
metrics = detect_wcx_title.calculate_layout_metrics(
|
|
rgb_data,
|
|
width=width,
|
|
height=height,
|
|
)
|
|
|
|
self.assertAlmostEqual(
|
|
metrics["black_ratio_top"],
|
|
18 / 20,
|
|
)
|
|
self.assertAlmostEqual(
|
|
metrics["red_ratio_top"],
|
|
3 / 30,
|
|
)
|
|
self.assertAlmostEqual(
|
|
metrics["bright_ratio_bottom"],
|
|
3 / 30,
|
|
)
|
|
|
|
def test_layout_metrics_reject_wrong_buffer_size(self) -> None:
|
|
with self.assertRaisesRegex(ValueError, "Expected 12 RGB bytes"):
|
|
detect_wcx_title.calculate_layout_metrics(
|
|
bytes(11),
|
|
width=2,
|
|
height=2,
|
|
)
|
|
|
|
def test_split_rgb24_frames(self) -> None:
|
|
frame_size = 2 * 2 * 3
|
|
frames = (
|
|
bytes([1]) * frame_size,
|
|
bytes([2]) * frame_size,
|
|
bytes([3]) * frame_size,
|
|
)
|
|
|
|
result = detect_wcx_title.split_rgb24_frames(
|
|
b"".join(frames),
|
|
width=2,
|
|
height=2,
|
|
)
|
|
|
|
self.assertEqual(result, frames)
|
|
|
|
def test_split_rgb24_frames_rejects_short_buffer(self) -> None:
|
|
with self.assertRaisesRegex(ValueError, "too short"):
|
|
detect_wcx_title.split_rgb24_frames(
|
|
bytes(35),
|
|
width=2,
|
|
height=2,
|
|
)
|
|
|
|
def test_split_rgb24_frames_rejects_long_buffer(self) -> None:
|
|
with self.assertRaisesRegex(ValueError, "too long"):
|
|
detect_wcx_title.split_rgb24_frames(
|
|
bytes(37),
|
|
width=2,
|
|
height=2,
|
|
)
|
|
|
|
def test_wcx_layout_score_all_conditions(self) -> None:
|
|
metrics = {
|
|
"black_ratio_total": 0.40,
|
|
"black_ratio_top": 0.60,
|
|
"black_ratio_bottom": 0.70,
|
|
"black_ratio_left": 0.90,
|
|
"black_ratio_right": 0.90,
|
|
"red_ratio_top": 0.10,
|
|
"bright_ratio_top": 0.05,
|
|
"bright_ratio_bottom": 0.08,
|
|
}
|
|
|
|
self.assertEqual(
|
|
detect_wcx_title.calculate_wcx_layout_score(metrics),
|
|
8,
|
|
)
|
|
|
|
def test_wcx_layout_score_no_conditions(self) -> None:
|
|
metrics = {
|
|
"black_ratio_total": 0.10,
|
|
"black_ratio_top": 0.10,
|
|
"black_ratio_bottom": 0.10,
|
|
"black_ratio_left": 0.10,
|
|
"black_ratio_right": 0.10,
|
|
"red_ratio_top": 0.01,
|
|
"bright_ratio_top": 0.01,
|
|
"bright_ratio_bottom": 0.01,
|
|
}
|
|
|
|
self.assertEqual(
|
|
detect_wcx_title.calculate_wcx_layout_score(metrics),
|
|
0,
|
|
)
|
|
|
|
def test_wcx_layout_score_includes_boundaries(self) -> None:
|
|
metrics = {
|
|
"black_ratio_total": 0.20,
|
|
"black_ratio_top": 0.45,
|
|
"black_ratio_bottom": 0.50,
|
|
"black_ratio_left": 0.75,
|
|
"black_ratio_right": 0.75,
|
|
"red_ratio_top": 0.05,
|
|
"bright_ratio_top": 0.02,
|
|
"bright_ratio_bottom": 0.03,
|
|
}
|
|
|
|
self.assertEqual(
|
|
detect_wcx_title.calculate_wcx_layout_score(metrics),
|
|
8,
|
|
)
|
|
|
|
metrics["black_ratio_total"] = 0.60
|
|
self.assertEqual(
|
|
detect_wcx_title.calculate_wcx_layout_score(metrics),
|
|
8,
|
|
)
|
|
|
|
def test_wcs_like_metrics_score_low(self) -> None:
|
|
metrics = {
|
|
"black_ratio_total": 0.88,
|
|
"black_ratio_top": 1.00,
|
|
"black_ratio_bottom": 1.00,
|
|
"black_ratio_left": 0.96,
|
|
"black_ratio_right": 0.96,
|
|
"red_ratio_top": 0.00,
|
|
"bright_ratio_top": 0.00,
|
|
"bright_ratio_bottom": 0.00,
|
|
}
|
|
|
|
self.assertEqual(
|
|
detect_wcx_title.calculate_wcx_layout_score(metrics),
|
|
4,
|
|
)
|
|
|
|
def test_wunf_like_metrics_score_low(self) -> None:
|
|
metrics = {
|
|
"black_ratio_total": 0.15,
|
|
"black_ratio_top": 0.20,
|
|
"black_ratio_bottom": 0.25,
|
|
"black_ratio_left": 0.10,
|
|
"black_ratio_right": 0.10,
|
|
"red_ratio_top": 0.20,
|
|
"bright_ratio_top": 0.01,
|
|
"bright_ratio_bottom": 0.01,
|
|
}
|
|
|
|
self.assertLessEqual(
|
|
detect_wcx_title.calculate_wcx_layout_score(metrics),
|
|
2,
|
|
)
|
|
|
|
def test_select_best_timestamp_uses_highest_score(self) -> None:
|
|
self.assertEqual(
|
|
detect_wcx_title.select_best_timestamp(
|
|
((8.0, 3), (10.0, 8), (12.0, 5))
|
|
),
|
|
(10.0, 8),
|
|
)
|
|
|
|
def test_select_best_timestamp_uses_earliest_tie(self) -> None:
|
|
self.assertEqual(
|
|
detect_wcx_title.select_best_timestamp(
|
|
((12.0, 8), (10.0, 8), (8.0, 7))
|
|
),
|
|
(10.0, 8),
|
|
)
|
|
|
|
def test_classify_wcx_score_eight(self) -> None:
|
|
self.assertEqual(
|
|
detect_wcx_title.classify_wcx_score(8),
|
|
"wcx",
|
|
)
|
|
|
|
def test_classify_wcx_score_seven(self) -> None:
|
|
self.assertEqual(
|
|
detect_wcx_title.classify_wcx_score(7),
|
|
"uncertain",
|
|
)
|
|
|
|
def test_classify_wcx_score_six(self) -> None:
|
|
self.assertEqual(
|
|
detect_wcx_title.classify_wcx_score(6),
|
|
"uncertain",
|
|
)
|
|
|
|
def test_classify_wcx_score_five(self) -> None:
|
|
self.assertEqual(
|
|
detect_wcx_title.classify_wcx_score(5),
|
|
"not_wcx",
|
|
)
|
|
|
|
def test_classify_wcx_score_zero(self) -> None:
|
|
self.assertEqual(
|
|
detect_wcx_title.classify_wcx_score(0),
|
|
"not_wcx",
|
|
)
|
|
|
|
def test_classify_wcx_score_rejects_negative(self) -> None:
|
|
with self.assertRaises(ValueError):
|
|
detect_wcx_title.classify_wcx_score(-1)
|
|
|
|
def test_classify_wcx_score_rejects_above_maximum(self) -> None:
|
|
with self.assertRaises(ValueError):
|
|
detect_wcx_title.classify_wcx_score(9)
|
|
|
|
|
|
class BatchModeTests(unittest.TestCase):
|
|
def test_parse_comma_separated_endings(self) -> None:
|
|
self.assertEqual(
|
|
detect_wcx_title.parse_endings(["mp4,avi"]),
|
|
{"mp4", "avi"},
|
|
)
|
|
|
|
def test_parse_repeated_endings(self) -> None:
|
|
self.assertEqual(
|
|
detect_wcx_title.parse_endings(["mp4", "avi"]),
|
|
{"mp4", "avi"},
|
|
)
|
|
|
|
def test_parse_endings_normalizes_dot_and_case(self) -> None:
|
|
self.assertEqual(
|
|
detect_wcx_title.parse_endings([".MP4"]),
|
|
{"mp4"},
|
|
)
|
|
|
|
def test_video_file_filtering_and_ignored_files(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
|
directory = Path(temporary_directory)
|
|
video = directory / "movie.MP4"
|
|
wrong_ending = directory / "movie.txt"
|
|
apple_double = directory / "._movie.mp4"
|
|
ds_store = directory / ".DS_Store"
|
|
thumbs = directory / "Thumbs.db"
|
|
desktop = directory / "desktop.ini"
|
|
|
|
for path in (
|
|
video,
|
|
wrong_ending,
|
|
apple_double,
|
|
ds_store,
|
|
thumbs,
|
|
desktop,
|
|
):
|
|
path.touch()
|
|
|
|
endings = {"mp4"}
|
|
self.assertTrue(
|
|
detect_wcx_title.is_video_file(video, endings)
|
|
)
|
|
for path in (apple_double, ds_store, thumbs, desktop):
|
|
self.assertTrue(
|
|
detect_wcx_title.is_ignored_file(path)
|
|
)
|
|
for path in (
|
|
wrong_ending,
|
|
apple_double,
|
|
ds_store,
|
|
thumbs,
|
|
desktop,
|
|
):
|
|
self.assertFalse(
|
|
detect_wcx_title.is_video_file(path, endings)
|
|
)
|
|
|
|
def test_non_recursive_file_search(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
|
directory = Path(temporary_directory)
|
|
direct = directory / "direct.mp4"
|
|
nested_directory = directory / "nested"
|
|
nested = nested_directory / "nested.mp4"
|
|
nested_directory.mkdir()
|
|
direct.touch()
|
|
nested.touch()
|
|
|
|
self.assertEqual(
|
|
detect_wcx_title.find_video_files(
|
|
directory,
|
|
endings={"mp4"},
|
|
recursive=False,
|
|
),
|
|
[direct.resolve()],
|
|
)
|
|
|
|
def test_recursive_file_search(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
|
directory = Path(temporary_directory)
|
|
direct = directory / "direct.mp4"
|
|
nested_directory = directory / "nested"
|
|
nested = nested_directory / "nested.mp4"
|
|
nested_directory.mkdir()
|
|
direct.touch()
|
|
nested.touch()
|
|
|
|
self.assertEqual(
|
|
detect_wcx_title.find_video_files(
|
|
directory,
|
|
endings={"mp4"},
|
|
recursive=True,
|
|
),
|
|
sorted(
|
|
[direct.resolve(), nested.resolve()],
|
|
key=lambda path: str(path),
|
|
),
|
|
)
|
|
|
|
def test_file_search_is_sorted_by_full_path(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
|
directory = Path(temporary_directory)
|
|
paths = [
|
|
directory / "z.mp4",
|
|
directory / "A.mp4",
|
|
directory / "m.mp4",
|
|
]
|
|
for path in paths:
|
|
path.touch()
|
|
|
|
result = detect_wcx_title.find_video_files(
|
|
directory,
|
|
endings={"mp4"},
|
|
recursive=False,
|
|
)
|
|
|
|
self.assertEqual(
|
|
result,
|
|
sorted(
|
|
(path.resolve() for path in paths),
|
|
key=lambda path: str(path),
|
|
),
|
|
)
|
|
|
|
def test_batch_counts_classifications(self) -> None:
|
|
video_files = (
|
|
Path("/videos/wcx.mp4"),
|
|
Path("/videos/uncertain.mp4"),
|
|
Path("/videos/not-wcx.mp4"),
|
|
)
|
|
results = {
|
|
video_files[0]: (12.0, 8, "wcx"),
|
|
video_files[1]: (10.0, 7, "uncertain"),
|
|
video_files[2]: (8.0, 4, "not_wcx"),
|
|
}
|
|
|
|
output = io.StringIO()
|
|
with contextlib.redirect_stdout(output):
|
|
files, summary = detect_wcx_title.process_batch(
|
|
video_files,
|
|
lambda path: results[path],
|
|
)
|
|
|
|
self.assertEqual(len(files), 3)
|
|
self.assertEqual(summary["errors"], 0)
|
|
self.assertIn("processed: 3", output.getvalue())
|
|
self.assertIn("wcx: 1", output.getvalue())
|
|
self.assertIn("uncertain: 1", output.getvalue())
|
|
self.assertIn("not_wcx: 1", output.getvalue())
|
|
self.assertIn("errors: 0", output.getvalue())
|
|
|
|
def test_batch_returns_one_when_file_errors(self) -> None:
|
|
video_files = (
|
|
Path("/videos/good.mp4"),
|
|
Path("/videos/broken.mp4"),
|
|
)
|
|
|
|
def analyze(path: Path) -> tuple[float, int, str]:
|
|
if path.name == "broken.mp4":
|
|
raise RuntimeError("mock analysis failure")
|
|
return 8.0, 8, "wcx"
|
|
|
|
output = io.StringIO()
|
|
with contextlib.redirect_stdout(output):
|
|
files, summary = detect_wcx_title.process_batch(
|
|
video_files,
|
|
analyze,
|
|
)
|
|
|
|
self.assertEqual(summary["errors"], 1)
|
|
self.assertEqual(files[1]["classification"], "error")
|
|
self.assertIn("error", output.getvalue())
|
|
self.assertIn("processed: 2", output.getvalue())
|
|
self.assertIn("errors: 1", output.getvalue())
|
|
|
|
|
|
class JsonReportTests(unittest.TestCase):
|
|
def test_report_contains_results_summary_and_sorted_endings(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
|
source_directory = Path(temporary_directory)
|
|
video_files = (
|
|
source_directory / "a.mp4",
|
|
source_directory / "b.avi",
|
|
source_directory / "c.mp4",
|
|
)
|
|
results = {
|
|
video_files[0]: (8.0, 8, "wcx"),
|
|
video_files[1]: (10.0, 4, "not_wcx"),
|
|
video_files[2]: (12.0, 7, "uncertain"),
|
|
}
|
|
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
files, summary = detect_wcx_title.process_batch(
|
|
video_files,
|
|
lambda path: results[path],
|
|
)
|
|
|
|
report = detect_wcx_title.build_json_report(
|
|
source_directory=source_directory,
|
|
recursive=False,
|
|
endings={"mp4", "avi"},
|
|
summary=summary,
|
|
files=files,
|
|
)
|
|
report_path = (
|
|
source_directory
|
|
/ "new-parent"
|
|
/ "report.json"
|
|
)
|
|
detect_wcx_title.write_json_report(
|
|
report_path,
|
|
report,
|
|
)
|
|
|
|
self.assertTrue(report_path.parent.is_dir())
|
|
self.assertTrue(
|
|
report_path.read_bytes().endswith(b"\n")
|
|
)
|
|
with report_path.open(encoding="utf-8") as report_file:
|
|
loaded = json.load(report_file)
|
|
|
|
self.assertEqual(loaded["endings"], ["avi", "mp4"])
|
|
self.assertEqual(
|
|
loaded["source_directory"],
|
|
str(source_directory.resolve()),
|
|
)
|
|
self.assertEqual(
|
|
[item["classification"] for item in loaded["files"]],
|
|
["wcx", "not_wcx", "uncertain"],
|
|
)
|
|
self.assertTrue(
|
|
all(
|
|
Path(item["path"]).is_absolute()
|
|
for item in loaded["files"]
|
|
)
|
|
)
|
|
self.assertEqual(
|
|
loaded["summary"],
|
|
{
|
|
"processed": 3,
|
|
"wcx": 1,
|
|
"uncertain": 1,
|
|
"not_wcx": 1,
|
|
"errors": 0,
|
|
},
|
|
)
|
|
|
|
def test_report_contains_error_result(self) -> None:
|
|
video_file = Path("/videos/broken.mp4")
|
|
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
files, summary = detect_wcx_title.process_batch(
|
|
(video_file,),
|
|
lambda _path: (_ for _ in ()).throw(
|
|
RuntimeError("mock ffmpeg failure")
|
|
),
|
|
)
|
|
|
|
report = detect_wcx_title.build_json_report(
|
|
source_directory=Path("/videos"),
|
|
recursive=False,
|
|
endings={"mp4"},
|
|
summary=summary,
|
|
files=files,
|
|
)
|
|
|
|
self.assertEqual(
|
|
report["files"],
|
|
[
|
|
{
|
|
"path": str(video_file.resolve()),
|
|
"classification": "error",
|
|
"score": None,
|
|
"best_timestamp": None,
|
|
"error": "mock ffmpeg failure",
|
|
}
|
|
],
|
|
)
|
|
self.assertEqual(report["summary"]["errors"], 1)
|
|
|
|
def test_empty_batch_report(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
files, summary = detect_wcx_title.process_batch(
|
|
(),
|
|
lambda _path: (8.0, 8, "wcx"),
|
|
)
|
|
|
|
report = detect_wcx_title.build_json_report(
|
|
source_directory=Path(temporary_directory),
|
|
recursive=True,
|
|
endings={"mp4"},
|
|
summary=summary,
|
|
files=files,
|
|
)
|
|
report_path = (
|
|
Path(temporary_directory)
|
|
/ "reports"
|
|
/ "empty.json"
|
|
)
|
|
detect_wcx_title.write_json_report(report_path, report)
|
|
|
|
with report_path.open(encoding="utf-8") as report_file:
|
|
loaded = json.load(report_file)
|
|
|
|
self.assertEqual(loaded["files"], [])
|
|
self.assertEqual(
|
|
loaded["summary"],
|
|
{
|
|
"processed": 0,
|
|
"wcx": 0,
|
|
"uncertain": 0,
|
|
"not_wcx": 0,
|
|
"errors": 0,
|
|
},
|
|
)
|
|
|
|
def test_existing_report_is_rejected(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
|
report_path = Path(temporary_directory) / "report.json"
|
|
report_path.write_text("existing", encoding="utf-8")
|
|
|
|
with self.assertRaises(FileExistsError):
|
|
detect_wcx_title.write_json_report(
|
|
report_path,
|
|
{"schema_version": 1},
|
|
)
|
|
|
|
self.assertEqual(
|
|
report_path.read_text(encoding="utf-8"),
|
|
"existing",
|
|
)
|
|
|
|
def test_report_write_failure_returns_one(self) -> None:
|
|
def failing_writer(
|
|
_path: Path,
|
|
_report: dict[str, object],
|
|
) -> None:
|
|
raise OSError("mock write failure")
|
|
|
|
with contextlib.redirect_stderr(io.StringIO()):
|
|
exit_code = detect_wcx_title.write_optional_json_report(
|
|
Path("/reports/report.json"),
|
|
{"schema_version": 1},
|
|
writer=failing_writer,
|
|
)
|
|
|
|
self.assertEqual(exit_code, 1)
|
|
|
|
def test_json_report_without_batch_is_rejected(self) -> None:
|
|
arguments = [
|
|
"detect_wcx_title.py",
|
|
"/videos/movie.mp4",
|
|
"--json-report",
|
|
"/reports/report.json",
|
|
]
|
|
with patch.object(sys, "argv", arguments):
|
|
with contextlib.redirect_stderr(io.StringIO()):
|
|
exit_code = detect_wcx_title.main()
|
|
|
|
self.assertEqual(exit_code, 2)
|
|
|
|
def test_existing_report_stops_before_batch_processing(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
|
directory = Path(temporary_directory)
|
|
report_path = directory / "report.json"
|
|
report_path.write_text("existing", encoding="utf-8")
|
|
arguments = [
|
|
"detect_wcx_title.py",
|
|
str(directory),
|
|
"--batch",
|
|
"--json-report",
|
|
str(report_path),
|
|
]
|
|
|
|
with patch.object(sys, "argv", arguments):
|
|
with patch.object(
|
|
detect_wcx_title,
|
|
"process_batch",
|
|
) as process_batch:
|
|
with contextlib.redirect_stderr(io.StringIO()):
|
|
exit_code = detect_wcx_title.main()
|
|
|
|
self.assertEqual(exit_code, 2)
|
|
process_batch.assert_not_called()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|