564 lines
24 KiB
Python
564 lines
24 KiB
Python
"""
|
|
v7 behavior tests — chunk-method retry on y4m pipe break.
|
|
|
|
Verifies the v4.0.0 fix for the "works up until near the end, never saves
|
|
chunks into a full file" production bug. When av1an's Hybrid chunk method
|
|
fails on a phone-recorded MP4 with sparse keyframes, every chunk's
|
|
encoder dies with:
|
|
|
|
[h264 @ 0x...] error while decoding MB 35 25
|
|
Encoding Failed to read y4m frame delimiter. Read broken. EOF: 1
|
|
|
|
The fix retries the file with ``--chunk-method select`` (VapourSynth's
|
|
select() filter) before falling back to pure ffmpeg. This is faster than
|
|
the ffmpeg fallback (chunk-parallel still works) and produces identical-
|
|
quality output (same encoder, same params).
|
|
|
|
The production log that revealed this bug is at:
|
|
logs/av1an.log.2026-07-13
|
|
"""
|
|
import importlib.util
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
|
|
OPENTRANSCODE_PATH = Path(__file__).resolve().parent.parent / "open-transcode.py"
|
|
|
|
|
|
def _load_opentranscode_module():
|
|
if not OPENTRANSCODE_PATH.exists():
|
|
pytest.skip(f"open-transcode.py not found at {OPENTRANSCODE_PATH}")
|
|
_install_pyside6_stubs()
|
|
spec = importlib.util.spec_from_file_location("open_transcode", str(OPENTRANSCODE_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")):
|
|
# Check if it's a real PySide6 or our stub. If real, leave it alone.
|
|
if getattr(sys.modules.get("PySide6"), "_otc_stub", False):
|
|
return
|
|
# Real PySide6 — don't install stubs over it
|
|
try:
|
|
importlib.util.find_spec("PySide6.QtCore")
|
|
return
|
|
except (ImportError, ValueError):
|
|
pass
|
|
|
|
import types
|
|
pyside6 = types.ModuleType("PySide6")
|
|
pyside6._otc_stub = True
|
|
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())
|
|
|
|
for n in ("QFont", "QPalette", "QColor", "QPainter", "QPen", "QBrush",
|
|
"QRadialGradient", "QFontMetrics"):
|
|
setattr(qt_gui, n, 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 opentranscode_module():
|
|
return _load_opentranscode_module()
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Realistic stderr from a y4m-pipe-break failure (extracted from the
|
|
# production log at logs/av1an.log.2026-07-13). The key markers are:
|
|
# - "Failed to read y4m frame delimiter" (the broken y4m pipe)
|
|
# - "[h264 @ ...] error while decoding MB" (decoder failing mid-GOP)
|
|
# - "SUMMARY" + "Average Speed" (SVT-AV1's per-chunk summary block,
|
|
# printed because the encoder ran briefly on partial data before the
|
|
# pipe broke — this previously triggered the v6-03 "concat failure"
|
|
# misdiagnosis)
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
_Y4M_BREAK_STDERR = (
|
|
"INFO encode_file: av1an_core::context: Input: 1920x1080 @ 29.763 fps, YUVJ420P, SDR\n"
|
|
"INFO encode_file: av1an_core::scenes: scenecut: found 8 scene(s) "
|
|
"[with extra_splits (298 frames): 16 scene(s)]\n"
|
|
"DEBUG encode_file: av1an_core::context: Segmenting video\n"
|
|
"DEBUG encode_file: av1an_core::context: Segment done\n"
|
|
"INFO encode_file: av1an_core::context: \n"
|
|
" Encoding Failed to read y4m frame delimiter. Read broken. EOF: 1\n"
|
|
" [h264 @ 0x55da365b30c0] error while decoding MB 35 25\n"
|
|
"WARN encode_chunk{worker_id=5 total_chunks=16 chunk_index=\"00011\"}: "
|
|
"av1an_core::broker: Encoder failed (on chunk 11):\n"
|
|
" Encoding Failed to read y4m frame delimiter. Read broken. EOF: 1\n"
|
|
" SUMMARY -----------------------------------------------------------------\n"
|
|
" Average Speed:\t\t2.501 fps\n"
|
|
" [h264 @ 0x55da365b30c0] error while decoding MB 35 25\n"
|
|
"ERROR av1an_core::broker: [chunk 4] [chunk 4] encoder failed 3 times, "
|
|
"shutting down worker: encoder crashed: exit status: 0\n"
|
|
)
|
|
|
|
|
|
class TestY4mBreakPatternInErrorTable:
|
|
"""v4.0.0: 'Failed to read y4m frame delimiter' is in the error_patterns table."""
|
|
|
|
def test_y4m_break_pattern_in_source(self, opentranscode_module):
|
|
"""The error_patterns table should include the y4m break pattern."""
|
|
src = Path(OPENTRANSCODE_PATH).read_text()
|
|
assert "Failed to read y4m frame delimiter" in src, \
|
|
"error_patterns table should include 'Failed to read y4m frame delimiter'"
|
|
|
|
def test_v7_01_retry_logic_in_source(self, opentranscode_module):
|
|
"""The v4.0.0 retry-with-select logic should be present in _encode_one."""
|
|
src = Path(OPENTRANSCODE_PATH).read_text()
|
|
assert 'chunk_method="select"' in src, \
|
|
"_encode_one should have a retry path that passes chunk_method='select'"
|
|
assert 'chunk_method_override' in src, \
|
|
"_encode_one should cache the working chunk_method in chunk_method_override"
|
|
|
|
def test_v6_03_diagnostic_guarded_by_y4m_check(self, opentranscode_module):
|
|
"""v4.0.0: the v6-03 SUMMARY-block concat-failure diagnostic must NOT
|
|
fire when the y4m break marker is present (otherwise it misdiagnoses
|
|
chunk-extraction failures as concat failures)."""
|
|
src = Path(OPENTRANSCODE_PATH).read_text()
|
|
# Find the v6-03 SUMMARY block check and verify it's guarded by
|
|
# the y4m break exclusion.
|
|
assert '"Failed to read y4m frame delimiter" not in stderr_full' in src, \
|
|
"v6-03 SUMMARY block diagnostic must be guarded by y4m break exclusion"
|
|
|
|
def test_vs_plugin_probe_in_source(self, opentranscode_module):
|
|
"""v4.0.0: env_probe should include the VapourSynth source plugin probe."""
|
|
src = Path(OPENTRANSCODE_PATH).read_text()
|
|
assert "_probe_vs_source_plugins" in src, \
|
|
"env_probe should include _probe_vs_source_plugins helper"
|
|
assert "_VS_PLUGIN_PROBE_PATHS" in src, \
|
|
"env_probe should include the VS plugin path table"
|
|
|
|
|
|
class TestChunkMethodParameter:
|
|
"""v4.0.0: _encode_one accepts a chunk_method parameter for retries."""
|
|
|
|
def test_encode_one_accepts_chunk_method_kwarg(self, opentranscode_module, tmp_path):
|
|
"""_encode_one should accept chunk_method as a keyword argument."""
|
|
import inspect
|
|
sig = inspect.signature(opentranscode_module.EncoderWorker._encode_one)
|
|
params = list(sig.parameters.keys())
|
|
assert "chunk_method" in params, \
|
|
f"_encode_one should accept chunk_method parameter; got params: {params}"
|
|
# Default should be None (no override)
|
|
assert sig.parameters["chunk_method"].default is None, \
|
|
"chunk_method default should be None"
|
|
|
|
|
|
class TestY4mBreakRetry:
|
|
"""v4.0.0: When av1an fails with the y4m break pattern, retry with select."""
|
|
|
|
def test_y4m_break_triggers_select_retry(self, opentranscode_module, tmp_path):
|
|
"""When av1an fails with the y4m break pattern (and we're not already
|
|
using select), the code should retry with --chunk-method select."""
|
|
ffmpeg_bin = shutil.which("ffmpeg")
|
|
if not ffmpeg_bin:
|
|
pytest.skip("ffmpeg not available")
|
|
|
|
# Create a real test video
|
|
test_video = tmp_path / "input.mp4"
|
|
subprocess.run(
|
|
[ffmpeg_bin, "-f", "lavfi", "-i", "testsrc=duration=1:size=64x64:rate=24",
|
|
"-c:v", "libx264", "-y", str(test_video)],
|
|
capture_output=True, timeout=15,
|
|
)
|
|
if not test_video.exists():
|
|
pytest.skip("Could not generate test video")
|
|
|
|
av1_codec = next(c for c in opentranscode_module.VIDEO_CODECS if c.label == "AV1 (SVT-AV1)")
|
|
opus_audio = next(a for a in opentranscode_module.AUDIO_PROFILES if a.label == "Opus (96k)")
|
|
mkv_container = next(c for c in opentranscode_module.CONTAINER_PROFILES if c.ext == "mkv")
|
|
original_res = next(r for r in opentranscode_module.RESOLUTION_PRESETS if r.category == "original")
|
|
|
|
env = opentranscode_module.probe_environment()
|
|
env.av1an_path = "/usr/bin/av1an"
|
|
# Start with NO chunk_method_override — av1an auto-selects Hybrid
|
|
env.av1an_flags = {
|
|
"worker": "--workers",
|
|
"video_params": "--video-params",
|
|
"audio_params": "--audio-params",
|
|
"svt_name": "svt-av1",
|
|
"concat_method": "ffmpeg",
|
|
"has_chunk_method": True,
|
|
# NOTE: chunk_method_override intentionally absent — simulates
|
|
# the production scenario where env_probe didn't pre-set it
|
|
}
|
|
|
|
worker = opentranscode_module.EncoderWorker(
|
|
in_dir=tmp_path,
|
|
out_dir=tmp_path / "output",
|
|
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=False,
|
|
)
|
|
|
|
# Track which chunk_method each call used
|
|
call_chunk_methods: list[str | None] = []
|
|
|
|
def mock_run(cmd, **kw):
|
|
# Extract the chunk method from the command
|
|
chunk_m = None
|
|
if "--chunk-method" in cmd:
|
|
idx = cmd.index("--chunk-method")
|
|
chunk_m = cmd[idx + 1] if idx + 1 < len(cmd) else None
|
|
call_chunk_methods.append(chunk_m)
|
|
|
|
if chunk_m is None or chunk_m == "hybrid":
|
|
# First attempt (auto/Hybrid) — fail with y4m break
|
|
return ("ok", 1, "", _Y4M_BREAK_STDERR)
|
|
else:
|
|
# Retry with select — succeed by producing a fake output
|
|
# (the actual encode is mocked; we just create the file)
|
|
# Find the output path (last arg of -o)
|
|
if "-o" in cmd:
|
|
out_idx = cmd.index("-o")
|
|
out_path = Path(cmd[out_idx + 1])
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
out_path.write_bytes(b"\x00" * 8192) # 8KB fake AV1 output
|
|
return ("ok", 0, "", "encoding finished")
|
|
|
|
worker._run_with_stop_check = mock_run
|
|
|
|
# Mock _ffmpeg_fallback_encode — should NOT be called (select retry succeeds first)
|
|
worker._ffmpeg_fallback_encode = MagicMock(return_value=True)
|
|
|
|
logs: list[str] = []
|
|
worker.log_msg.emit = lambda msg: logs.append(msg)
|
|
|
|
# Setup required attributes
|
|
worker._current_temps = []
|
|
worker._file_res_map = {}
|
|
worker._stop = False
|
|
worker._consecutive_fail_count = 0
|
|
worker._last_fail_pattern = None
|
|
|
|
output_f = tmp_path / "output" / "input_archived.mkv"
|
|
result = worker._encode_one(test_video, test_video, output_f, 1)
|
|
|
|
# Should have succeeded via the select retry
|
|
assert result is True, f"Expected retry to succeed. Logs: {logs}"
|
|
|
|
# Should have been called twice: once with no chunk_method (auto),
|
|
# once with chunk_method="select"
|
|
assert len(call_chunk_methods) == 2, \
|
|
f"Expected 2 calls (Hybrid fail + select retry), got {len(call_chunk_methods)}: {call_chunk_methods}"
|
|
assert call_chunk_methods[0] is None, \
|
|
f"First call should have no chunk_method (auto/Hybrid), got: {call_chunk_methods[0]}"
|
|
assert call_chunk_methods[1] == "select", \
|
|
f"Second call should use --chunk-method select, got: {call_chunk_methods[1]}"
|
|
|
|
# Should have logged the RETRY message
|
|
assert any("RETRY" in l and "select" in l for l in logs), \
|
|
f"Expected RETRY message with 'select' in logs: {logs}"
|
|
|
|
# Should NOT have called ffmpeg fallback (select retry succeeded)
|
|
worker._ffmpeg_fallback_encode.assert_not_called()
|
|
|
|
# fail_count should NOT be incremented (retry succeeded)
|
|
assert worker.fail_count == 0, \
|
|
f"fail_count should be 0 after successful select retry, got {worker.fail_count}"
|
|
|
|
# v4.0.0: the working chunk_method should be cached for subsequent files
|
|
assert env.av1an_flags.get("chunk_method_override") == "select", \
|
|
f"chunk_method_override should be cached as 'select' for subsequent files"
|
|
|
|
def test_y4m_break_no_retry_when_already_select(self, opentranscode_module, tmp_path):
|
|
"""When av1an fails with y4m break AND we're already using select,
|
|
don't retry with select again (would infinite-loop). Fall through to
|
|
the v6-01 ffmpeg fallback instead."""
|
|
ffmpeg_bin = shutil.which("ffmpeg")
|
|
if not ffmpeg_bin:
|
|
pytest.skip("ffmpeg not available")
|
|
|
|
test_video = tmp_path / "input.mp4"
|
|
test_video.write_bytes(b"\x00" * 1024)
|
|
|
|
av1_codec = next(c for c in opentranscode_module.VIDEO_CODECS if c.label == "AV1 (SVT-AV1)")
|
|
opus_audio = next(a for a in opentranscode_module.AUDIO_PROFILES if a.label == "Opus (96k)")
|
|
mkv_container = next(c for c in opentranscode_module.CONTAINER_PROFILES if c.ext == "mkv")
|
|
original_res = next(r for r in opentranscode_module.RESOLUTION_PRESETS if r.category == "original")
|
|
|
|
env = opentranscode_module.probe_environment()
|
|
env.av1an_path = "/usr/bin/av1an"
|
|
# Already forcing select — simulates the case where the user
|
|
# passed --chunk-method select but it still failed (rare, but
|
|
# possible if VapourSynth itself is broken)
|
|
env.av1an_flags = {
|
|
"worker": "--workers",
|
|
"video_params": "--video-params",
|
|
"audio_params": "--audio-params",
|
|
"svt_name": "svt-av1",
|
|
"concat_method": "ffmpeg",
|
|
"has_chunk_method": True,
|
|
"chunk_method_override": "select",
|
|
}
|
|
|
|
worker = opentranscode_module.EncoderWorker(
|
|
in_dir=tmp_path,
|
|
out_dir=tmp_path / "output",
|
|
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=False,
|
|
)
|
|
|
|
call_count = [0]
|
|
def mock_run(cmd, **kw):
|
|
call_count[0] += 1
|
|
# Always fail with y4m break (even on select retry)
|
|
return ("ok", 1, "", _Y4M_BREAK_STDERR)
|
|
worker._run_with_stop_check = mock_run
|
|
|
|
# Mock ffmpeg fallback to succeed
|
|
def mock_ffmpeg_fallback(file_path, encode_input, output_f):
|
|
output_f.parent.mkdir(parents=True, exist_ok=True)
|
|
output_f.write_bytes(b"\x00" * 8192)
|
|
return True
|
|
worker._ffmpeg_fallback_encode = mock_ffmpeg_fallback
|
|
|
|
logs: list[str] = []
|
|
worker.log_msg.emit = lambda msg: logs.append(msg)
|
|
worker._current_temps = []
|
|
worker._stop = False
|
|
|
|
output_f = tmp_path / "output" / "input_archived.mkv"
|
|
result = worker._encode_one(test_video, test_video, output_f, 1)
|
|
|
|
# Should have succeeded via ffmpeg fallback (not select retry)
|
|
assert result is True, f"Expected ffmpeg fallback to succeed. Logs: {logs}"
|
|
|
|
# Should have been called only ONCE (no select retry since already select)
|
|
assert call_count[0] == 1, \
|
|
f"Expected 1 av1an call (no retry since already select), got {call_count[0]}"
|
|
|
|
# Should NOT have logged the select RETRY message
|
|
assert not any("RETRY" in l and "select" in l for l in logs), \
|
|
f"Should not log select RETRY when already using select: {logs}"
|
|
|
|
# Should have logged the ffmpeg fallback RETRY
|
|
assert any("RETRY" in l and "ffmpeg fallback" in l for l in logs), \
|
|
f"Expected ffmpeg fallback RETRY in logs: {logs}"
|
|
|
|
def test_y4m_break_diagnosis_not_misdiagnosed_as_concat(self, opentranscode_module, tmp_path):
|
|
"""v4.0.0: the v6-03 'concat failure' diagnostic must NOT fire when the
|
|
y4m break marker is present. The y4m break pattern's own diagnosis
|
|
should be emitted instead."""
|
|
ffmpeg_bin = shutil.which("ffmpeg")
|
|
if not ffmpeg_bin:
|
|
pytest.skip("ffmpeg not available")
|
|
|
|
test_video = tmp_path / "input.mp4"
|
|
test_video.write_bytes(b"\x00" * 1024)
|
|
|
|
av1_codec = next(c for c in opentranscode_module.VIDEO_CODECS if c.label == "AV1 (SVT-AV1)")
|
|
opus_audio = next(a for a in opentranscode_module.AUDIO_PROFILES if a.label == "Opus (96k)")
|
|
mkv_container = next(c for c in opentranscode_module.CONTAINER_PROFILES if c.ext == "mkv")
|
|
original_res = next(r for r in opentranscode_module.RESOLUTION_PRESETS if r.category == "original")
|
|
|
|
env = opentranscode_module.probe_environment()
|
|
env.av1an_path = "/usr/bin/av1an"
|
|
# Force select so the retry doesn't happen (we just want to test
|
|
# the diagnostic message)
|
|
env.av1an_flags = {
|
|
"worker": "--workers",
|
|
"video_params": "--video-params",
|
|
"audio_params": "--audio-params",
|
|
"svt_name": "svt-av1",
|
|
"concat_method": "ffmpeg",
|
|
"has_chunk_method": True,
|
|
"chunk_method_override": "select",
|
|
}
|
|
|
|
worker = opentranscode_module.EncoderWorker(
|
|
in_dir=tmp_path,
|
|
out_dir=tmp_path / "output",
|
|
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=False,
|
|
)
|
|
|
|
def mock_run(cmd, **kw):
|
|
# Fail with y4m break (which includes SUMMARY + Average Speed)
|
|
return ("ok", 1, "", _Y4M_BREAK_STDERR)
|
|
worker._run_with_stop_check = mock_run
|
|
|
|
# Mock ffmpeg fallback to succeed
|
|
worker._ffmpeg_fallback_encode = MagicMock(return_value=True)
|
|
|
|
logs: list[str] = []
|
|
worker.log_msg.emit = lambda msg: logs.append(msg)
|
|
worker._current_temps = []
|
|
worker._stop = False
|
|
|
|
output_f = tmp_path / "output" / "input_archived.mkv"
|
|
worker._encode_one(test_video, test_video, output_f, 1)
|
|
|
|
# Should have emitted the y4m break diagnosis
|
|
y4m_diagnosis = any(
|
|
"y4m" in l.lower() and "DIAGNOSIS" in l for l in logs
|
|
)
|
|
assert y4m_diagnosis, \
|
|
f"Should emit y4m break DIAGNOSIS. Logs: {logs}"
|
|
|
|
# Should NOT have emitted the v6-03 concat failure diagnosis.
|
|
# The v6-03 message starts with "SVT-AV1 encoder completed successfully
|
|
# (SUMMARY block found in stderr)" — check for that unique prefix
|
|
# to avoid matching the y4m break diagnosis which itself says "this
|
|
# is NOT a concat failure".
|
|
concat_diagnosis = any(
|
|
"SVT-AV1 encoder completed successfully" in l
|
|
or "post-encode merge step crashed" in l
|
|
for l in logs
|
|
)
|
|
assert not concat_diagnosis, \
|
|
f"Should NOT emit v6-03 concat failure diagnosis for y4m break. Logs: {logs}"
|
|
|
|
|
|
class TestVSPluginProbe:
|
|
"""v4.0.0: _probe_vs_source_plugins detects installed VS plugins."""
|
|
|
|
def test_probe_returns_empty_when_no_plugins(self, opentranscode_module, tmp_path, monkeypatch):
|
|
"""When no VS plugin .so files exist in any search dir, the probe
|
|
should return an empty list."""
|
|
# Point HOME at an empty tmp dir so the home-dir search paths
|
|
# don't accidentally find real plugins
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "share"))
|
|
|
|
result = opentranscode_module._probe_vs_source_plugins()
|
|
assert result == [], \
|
|
f"Expected empty list when no plugins installed, got: {result}"
|
|
|
|
def test_probe_detects_lsmash(self, opentranscode_module, tmp_path, monkeypatch):
|
|
"""When libvslsmashsource.so is in a search dir, the probe should
|
|
return ['lsmash']."""
|
|
# Create a fake VS plugin dir with a fake lsmash .so.
|
|
# The probe searches ~/.local/lib/vapoursynth/ so we create it there.
|
|
vs_dir = tmp_path / ".local" / "lib" / "vapoursynth"
|
|
vs_dir.mkdir(parents=True)
|
|
(vs_dir / "libvslsmashsource.so").write_bytes(b"\x00" * 16)
|
|
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "share"))
|
|
|
|
result = opentranscode_module._probe_vs_source_plugins()
|
|
assert "lsmash" in result, \
|
|
f"Expected 'lsmash' in probe result, got: {result}"
|
|
|
|
def test_probe_detects_multiple_plugins(self, opentranscode_module, tmp_path, monkeypatch):
|
|
"""When multiple plugins are installed, the probe should find them all."""
|
|
vs_dir = tmp_path / ".local" / "lib" / "vapoursynth"
|
|
vs_dir.mkdir(parents=True)
|
|
(vs_dir / "libvslsmashsource.so").write_bytes(b"\x00" * 16)
|
|
(vs_dir / "libffms2.so").write_bytes(b"\x00" * 16)
|
|
(vs_dir / "libbestsource.so").write_bytes(b"\x00" * 16)
|
|
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "share"))
|
|
|
|
result = opentranscode_module._probe_vs_source_plugins()
|
|
assert "lsmash" in result
|
|
assert "ffms2" in result
|
|
assert "bestsource" in result
|
|
assert len(result) == 3, f"Expected 3 plugins, got: {result}"
|
|
|
|
|
|
class TestCLIChunkMethodFlag:
|
|
"""v4.0.0: --chunk-method CLI flag is parsed and validated."""
|
|
|
|
def test_chunk_method_flag_accepted(self, opentranscode_module):
|
|
"""--chunk-method should be accepted by the CLI parser."""
|
|
parser = opentranscode_module.build_parser() if hasattr(opentranscode_module, "build_parser") else None
|
|
if parser is None:
|
|
# The v3 single file doesn't have build_parser; test the package's
|
|
# cli module instead
|
|
from opentranscode.cli import build_parser
|
|
parser = build_parser()
|
|
|
|
# Valid values
|
|
for method in ("auto", "select", "hybrid", "segment",
|
|
"ffms2", "lsmash", "bestsource", "dgdecnv"):
|
|
args = parser.parse_args(["--chunk-method", method])
|
|
assert args.chunk_method == method, \
|
|
f"--chunk-method {method} should parse to {method}"
|
|
|
|
def test_chunk_method_rejects_invalid_value(self, opentranscode_module):
|
|
"""Invalid --chunk-method values should be rejected by argparse."""
|
|
try:
|
|
from opentranscode.cli import build_parser
|
|
except ImportError:
|
|
pytest.skip("opentranscode.cli not importable (PySide6 missing)")
|
|
|
|
parser = build_parser()
|
|
with pytest.raises(SystemExit):
|
|
parser.parse_args(["--chunk-method", "invalid-method"])
|