353 lines
15 KiB
Python
353 lines
15 KiB
Python
"""
|
|
v5 behavior tests — skip-invalid-files, early-abort, file-type diagnostics.
|
|
|
|
These tests verify the 3 root-cause fixes from v5:
|
|
- v5-01: Invalid files are SKIPPED, not "attempted anyway"
|
|
- v5-02: 3 consecutive failures auto-abort the queue
|
|
- v5-03: `file` command output in diagnostics reveals HTML/text/data
|
|
- v5-04: Pre-flight validation pass reports valid/invalid counts
|
|
"""
|
|
import importlib.util
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Module loading — same pattern as the other test files
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
V3_PATH = Path(__file__).resolve().parent.parent / "open-transcode.v3.py"
|
|
|
|
|
|
def _load_v3_module():
|
|
if not V3_PATH.exists():
|
|
pytest.skip(f"v3 source not found at {V3_PATH}")
|
|
_install_pyside6_stubs()
|
|
spec = importlib.util.spec_from_file_location("open_transcode_v3", str(V3_PATH))
|
|
mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
return mod
|
|
|
|
|
|
def _install_pyside6_stubs():
|
|
if any(name in sys.modules for name in
|
|
("PySide6", "PySide6.QtWidgets", "PySide6.QtCore", "PySide6.QtGui")):
|
|
return
|
|
import types
|
|
pyside6 = types.ModuleType("PySide6")
|
|
qt_widgets = types.ModuleType("PySide6.QtWidgets")
|
|
qt_core = types.ModuleType("PySide6.QtCore")
|
|
qt_gui = types.ModuleType("PySide6.QtGui")
|
|
|
|
class _QThread:
|
|
def __init__(self, *a, **kw): pass
|
|
def start(self): pass
|
|
def isRunning(self): return False
|
|
def wait(self, ms=None): pass
|
|
|
|
class _Signal:
|
|
def __init__(self, *a, **kw): pass
|
|
def connect(self, *a, **kw): pass
|
|
def emit(self, *a, **kw): pass
|
|
|
|
def _Slot(*a, **kw):
|
|
def deco(fn): return fn
|
|
return deco
|
|
|
|
qt_core.QThread = _QThread
|
|
qt_core.Signal = _Signal
|
|
qt_core.Slot = _Slot
|
|
qt_core.Qt = MagicMock()
|
|
qt_core.QPointF = MagicMock()
|
|
qt_core.QRectF = MagicMock()
|
|
qt_core.QTimer = MagicMock()
|
|
|
|
class _QWidget:
|
|
def __init__(self, *a, **kw): pass
|
|
class _QMainWindow(_QWidget): pass
|
|
qt_widgets.QWidget = _QWidget
|
|
qt_widgets.QMainWindow = _QMainWindow
|
|
for name in ("QApplication", "QVBoxLayout", "QHBoxLayout", "QLabel",
|
|
"QLineEdit", "QPushButton", "QComboBox", "QCheckBox",
|
|
"QTextEdit", "QFileDialog", "QGroupBox", "QStatusBar",
|
|
"QMessageBox", "QStyleFactory"):
|
|
setattr(qt_widgets, name, MagicMock())
|
|
|
|
qt_gui.QFont = MagicMock()
|
|
qt_gui.QPalette = MagicMock()
|
|
qt_gui.QColor = MagicMock()
|
|
qt_gui.QPainter = MagicMock()
|
|
qt_gui.QPen = MagicMock()
|
|
qt_gui.QBrush = MagicMock()
|
|
qt_gui.QRadialGradient = MagicMock()
|
|
qt_gui.QFontMetrics = MagicMock()
|
|
|
|
pyside6.QtWidgets = qt_widgets
|
|
pyside6.QtCore = qt_core
|
|
pyside6.QtGui = qt_gui
|
|
sys.modules["PySide6"] = pyside6
|
|
sys.modules["PySide6.QtWidgets"] = qt_widgets
|
|
sys.modules["PySide6.QtCore"] = qt_core
|
|
sys.modules["PySide6.QtGui"] = qt_gui
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def v3_module():
|
|
return _load_v3_module()
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Tests
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestIdentifyFileType:
|
|
"""v5-03: _identify_file_type() runs `file -b` and returns the type string."""
|
|
|
|
def test_identifies_text_file(self, v3_module, tmp_path):
|
|
"""A .txt file should be identified as 'ASCII text' or similar."""
|
|
f = tmp_path / "test.txt"
|
|
f.write_text("This is not a video file, just plain text.")
|
|
result = v3_module._identify_file_type(f)
|
|
# `file` should identify it as text
|
|
assert "text" in result.lower() or "ascii" in result.lower(), \
|
|
f"Expected text/ASCII in result, got: {result}"
|
|
|
|
def test_identifies_html_file(self, v3_module, tmp_path):
|
|
"""An HTML file should be identified as 'HTML document' — the classic
|
|
failed yt-dlp download scenario."""
|
|
f = tmp_path / "fake_video.mp4"
|
|
f.write_text("<!DOCTYPE html><html><body>Video unavailable</body></html>")
|
|
result = v3_module._identify_file_type(f)
|
|
assert "HTML" in result or "text" in result.lower(), \
|
|
f"Expected HTML/text in result, got: {result}"
|
|
|
|
def test_identifies_real_mp4(self, v3_module, tmp_path, real_ffmpeg=None):
|
|
"""A real MP4 should be identified as 'ISO Media' or 'MP4'."""
|
|
if not shutil.which("ffmpeg"):
|
|
pytest.skip("ffmpeg not available")
|
|
f = tmp_path / "real.mp4"
|
|
subprocess.run(
|
|
["ffmpeg", "-f", "lavfi", "-i", "testsrc=duration=0.1:size=32x32:rate=1",
|
|
"-c:v", "libx264", "-y", str(f)],
|
|
capture_output=True, timeout=10,
|
|
)
|
|
if not f.exists():
|
|
pytest.skip("Could not generate test MP4")
|
|
result = v3_module._identify_file_type(f)
|
|
assert "ISO Media" in result or "MP4" in result or "Media" in result, \
|
|
f"Expected ISO Media/MP4 in result, got: {result}"
|
|
|
|
def test_returns_empty_for_nonexistent_file(self, v3_module, tmp_path):
|
|
"""Nonexistent file should return empty string (not crash)."""
|
|
f = tmp_path / "does_not_exist.bin"
|
|
result = v3_module._identify_file_type(f)
|
|
# Should not crash; may return empty or an error string
|
|
assert isinstance(result, str)
|
|
|
|
|
|
class TestSkipInvalidFiles:
|
|
"""v5-01: Invalid files are SKIPPED, not 'attempted anyway'."""
|
|
|
|
def test_validate_file_skips_when_ffprobe_returns_none(
|
|
self, v3_module, tmp_path
|
|
):
|
|
"""When ffprobe can't read a file and force=False, _validate_file
|
|
should return skip=True."""
|
|
# Create a fake "video" file that's actually text
|
|
fake_video = tmp_path / "fake.mp4"
|
|
fake_video.write_text("<!DOCTYPE html><html>Not a video</html>")
|
|
|
|
# Build a minimal EncoderWorker via __new__ to bypass __init__
|
|
worker = v3_module.EncoderWorker.__new__(v3_module.EncoderWorker)
|
|
worker.force = False # v5-01: force=False (default)
|
|
worker.fail_count = 0
|
|
worker._file_res_map = {}
|
|
worker.env = MagicMock()
|
|
worker.env.ffprobe_path = shutil.which("ffprobe") or "/usr/bin/ffprobe"
|
|
logs = []
|
|
worker.log_msg = MagicMock()
|
|
worker.log_msg.emit = lambda msg: logs.append(msg)
|
|
|
|
skip, info, src_w, src_h = worker._validate_file(fake_video)
|
|
|
|
assert skip is True, "Should skip invalid file when force=False"
|
|
assert info is None
|
|
assert worker.fail_count == 1, "Should increment fail_count"
|
|
# Should mention SKIP in the log
|
|
assert any("SKIP" in l for l in logs), \
|
|
f"Expected SKIP in logs, got: {logs}"
|
|
# Should mention the file type (HTML/text)
|
|
assert any("HTML" in l or "text" in l.lower() for l in logs), \
|
|
f"Expected file type info in logs, got: {logs}"
|
|
|
|
def test_validate_file_proceeds_when_force_true(
|
|
self, v3_module, tmp_path
|
|
):
|
|
"""When force=True, _validate_file should NOT skip — it should
|
|
log a WARN and proceed (return skip=False)."""
|
|
fake_video = tmp_path / "fake.mp4"
|
|
fake_video.write_text("<!DOCTYPE html><html>Not a video</html>")
|
|
|
|
worker = v3_module.EncoderWorker.__new__(v3_module.EncoderWorker)
|
|
worker.force = True # v5-01: force=True overrides validation
|
|
worker.fail_count = 0
|
|
worker._file_res_map = {}
|
|
worker.env = MagicMock()
|
|
worker.env.ffprobe_path = shutil.which("ffprobe") or "/usr/bin/ffprobe"
|
|
logs = []
|
|
worker.log_msg = MagicMock()
|
|
worker.log_msg.emit = lambda msg: logs.append(msg)
|
|
|
|
skip, info, src_w, src_h = worker._validate_file(fake_video)
|
|
|
|
assert skip is False, "Should NOT skip when force=True"
|
|
assert worker.fail_count == 0, "Should NOT increment fail_count"
|
|
# Should log a WARN about attempting anyway
|
|
assert any("WARN" in l and "force" in l.lower() for l in logs), \
|
|
f"Expected WARN about force in logs, got: {logs}"
|
|
|
|
|
|
class TestConsecutiveFailureAbort:
|
|
"""v5-02: 3 consecutive failures auto-abort the queue."""
|
|
|
|
def test_aborts_after_three_consecutive_failures(self, v3_module):
|
|
"""After 3 consecutive failures, _check_consecutive_failures
|
|
should set self._stop = True."""
|
|
worker = v3_module.EncoderWorker.__new__(v3_module.EncoderWorker)
|
|
worker._stop = False
|
|
worker._consecutive_fail_count = 0
|
|
worker._last_fail_pattern = None
|
|
worker.log_msg = MagicMock()
|
|
worker.log_msg.emit = lambda msg: None
|
|
|
|
# 3 consecutive failures
|
|
worker._check_consecutive_failures(Path("f1.mp4"), accepted=False)
|
|
assert worker._consecutive_fail_count == 1
|
|
assert worker._stop is False
|
|
|
|
worker._check_consecutive_failures(Path("f2.mp4"), accepted=False)
|
|
assert worker._consecutive_fail_count == 2
|
|
assert worker._stop is False
|
|
|
|
worker._check_consecutive_failures(Path("f3.mp4"), accepted=False)
|
|
assert worker._consecutive_fail_count == 3
|
|
assert worker._stop is True, "Should auto-abort after 3 consecutive failures"
|
|
|
|
def test_success_resets_counter(self, v3_module):
|
|
"""A success should reset the consecutive failure counter."""
|
|
worker = v3_module.EncoderWorker.__new__(v3_module.EncoderWorker)
|
|
worker._stop = False
|
|
worker._consecutive_fail_count = 2 # already had 2 failures
|
|
worker._last_fail_pattern = None
|
|
worker.log_msg = MagicMock()
|
|
worker.log_msg.emit = lambda msg: None
|
|
|
|
# Success
|
|
worker._check_consecutive_failures(Path("ok.mp4"), accepted=True)
|
|
assert worker._consecutive_fail_count == 0, "Success should reset counter"
|
|
assert worker._stop is False
|
|
|
|
def test_does_not_double_abort(self, v3_module):
|
|
"""If already stopped (user clicked STOP), don't abort again."""
|
|
worker = v3_module.EncoderWorker.__new__(v3_module.EncoderWorker)
|
|
worker._stop = True # already stopped
|
|
worker._consecutive_fail_count = 0
|
|
worker._last_fail_pattern = None
|
|
worker.log_msg = MagicMock()
|
|
worker.log_msg.emit = lambda msg: None
|
|
|
|
worker._check_consecutive_failures(Path("f.mp4"), accepted=False)
|
|
# Should increment but NOT emit the ABORT message (already stopped)
|
|
assert worker._consecutive_fail_count == 1
|
|
|
|
|
|
class TestErrorPatternsIncludeStreams:
|
|
"""v5-03: 'missing field streams' and 'Invalid data found' are in the
|
|
error_patterns table."""
|
|
|
|
def test_error_patterns_table_has_streams_pattern(self, v3_module):
|
|
"""The error_patterns table in _encode_one should include the
|
|
'missing field streams' pattern. We verify by checking the source
|
|
code (the table is a local variable, not accessible from outside)."""
|
|
# Read the source and check for the pattern
|
|
src = Path(V3_PATH).read_text()
|
|
assert "missing field `streams`" in src, \
|
|
"error_patterns table should include 'missing field streams'"
|
|
assert "Invalid data found when processing input" in src, \
|
|
"error_patterns table should include 'Invalid data found'"
|
|
|
|
def test_identify_file_type_called_in_diagnostic(self, v3_module):
|
|
"""The diagnostic section should call _identify_file_type for
|
|
the streams/invalid-data patterns."""
|
|
src = Path(V3_PATH).read_text()
|
|
# The _identify_file_type call should be inside the diagnostic block
|
|
assert "_identify_file_type(file_path)" in src, \
|
|
"Diagnostic should call _identify_file_type"
|
|
|
|
|
|
class TestPreFlightValidation:
|
|
"""v5-04: Pre-flight validation pass reports valid/invalid counts."""
|
|
|
|
def test_run_aborts_when_all_files_invalid(self, v3_module, tmp_path):
|
|
"""When ALL files are invalid and force=False, run() should abort
|
|
immediately without entering the encode loop."""
|
|
# Create 3 fake "video" files (actually text)
|
|
for i in range(3):
|
|
(tmp_path / f"fake{i}.mp4").write_text(
|
|
f"<!DOCTYPE html><html>Not a video {i}</html>"
|
|
)
|
|
|
|
out_dir = tmp_path / "output"
|
|
out_dir.mkdir()
|
|
|
|
av1_codec = next(c for c in v3_module.VIDEO_CODECS if c.label == "AV1 (SVT-AV1)")
|
|
opus_audio = next(a for a in v3_module.AUDIO_PROFILES if a.label == "Opus (96k)")
|
|
mkv_container = next(c for c in v3_module.CONTAINER_PROFILES if c.ext == "mkv")
|
|
original_res = next(r for r in v3_module.RESOLUTION_PRESETS if r.category == "original")
|
|
|
|
env = v3_module.probe_environment()
|
|
|
|
worker = v3_module.EncoderWorker(
|
|
in_dir=tmp_path,
|
|
out_dir=out_dir,
|
|
video_codec=av1_codec,
|
|
audio_profile=opus_audio,
|
|
container=mkv_container,
|
|
crf=32,
|
|
preset_label="Fast (4)",
|
|
delete_source=False,
|
|
env=env,
|
|
extensions={".mp4"},
|
|
resolution=original_res,
|
|
use_ffmpeg_fallback=True,
|
|
force=False, # v5-01: default — should skip invalid files
|
|
)
|
|
|
|
logs = []
|
|
worker.log_msg.emit = lambda msg: logs.append(msg)
|
|
|
|
# Run the worker — should abort in pre-flight validation
|
|
worker.run()
|
|
|
|
# Should NOT have entered the encode loop (no "[1/3] Encoding" message)
|
|
assert not any("[1/3] Encoding" in l for l in logs), \
|
|
"Should not enter encode loop when all files are invalid"
|
|
|
|
# Should have the PRE-FLIGHT VALIDATION section
|
|
assert any("PRE-FLIGHT VALIDATION" in l for l in logs), \
|
|
f"Expected PRE-FLIGHT VALIDATION in logs"
|
|
|
|
# Should have the ABORT message
|
|
assert any("ABORT" in l and "invalid" in l.lower() for l in logs), \
|
|
f"Expected ABORT message about invalid files"
|
|
|
|
# Should report 0 valid, 3 invalid
|
|
assert any("Valid files: 0" in l for l in logs)
|
|
assert any("Invalid files: 3" in l for l in logs)
|