Add pytest suite and CI (roadmap A1)

37 headless tests covering the logic that is cheap to break:
- split planner: plan_disc_split, chapter helpers (whole-fit, multi-disc
  partition, single-movie chapter/even-cut split, contiguous coverage)
- High-profile fill-disc bitrate math (disc count + bounds + capacity)
- ffmpeg command generation: profiles, text/image hardsub, 2-pass, nice,
  progress-to-file, even pad/crop + positive offsets, -ss seek, and the
  AC3 448k audio-clamp regression
- ffprobe subtitle detection: text/image classification, tags/disposition

tests/conftest.py makes the in-tree package importable and installs the
gettext _ builtin so modules run headless. Forgejo Actions workflow runs
the suite on push/PR. Update CLAUDE.md's testing section accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-06-04 22:26:22 -07:00
parent b5a6cfe25f
commit 691664095b
9 changed files with 559 additions and 8 deletions

View file

@ -0,0 +1,136 @@
"""Tests for ffmpeg.convert_file command generation (no encoding runs)."""
from conftest import ffmpeg_converter, arg_after
def _vf(cmd):
return arg_after(cmd, "-vf")
def test_compatibility_dvd_basics(make_file_project):
conv = ffmpeg_converter(profile="compatibility")
conv.convert_file(make_file_project(), "/tmp/out.mpg", 0)
cmd = conv.command_var
assert "mpeg2video" in cmd
assert "ac3" in cmd # fork uses AC3 for NTSC DVD
assert arg_after(cmd, "-ar") == "48000"
assert arg_after(cmd, "-b:v") == str(4500 * 1000)
assert arg_after(cmd, "-b:a") == str(192 * 1000)
assert _vf(cmd) == "scale=720:480"
assert "-sn" in cmd # no soft-sub passthrough
def test_runs_under_nice(make_file_project):
conv = ffmpeg_converter()
conv.convert_file(make_file_project(), "/tmp/out.mpg", 0)
assert conv.command_var[:4] == ["nice", "-n", "10", "ffmpeg"]
def test_progress_goes_to_file_not_pipe(make_file_project):
# the deadlock fix: -progress must target a file, never pipe:N
conv = ffmpeg_converter()
conv.convert_file(make_file_project(), "/tmp/out.mpg", 0)
prog = arg_after(conv.command_var, "-progress")
assert prog is not None
assert not prog.startswith("pipe:")
assert prog.endswith(".progress")
def test_text_hardsub_filter_after_scale(make_file_project):
fp = make_file_project(
hardsub_index=2, hardsub_kind="text",
embedded_subtitles=[{"index": 2, "codec": "subrip", "language": "eng",
"title": "", "default": True, "forced": False,
"kind": "text"}],
)
conv = ffmpeg_converter()
conv.convert_file(fp, "/tmp/out.mpg", 0)
vf = _vf(conv.command_var)
assert vf is not None
assert vf.startswith("scale=720:480")
assert "subtitles=" in vf
assert "si=0" in vf # first (and only) subtitle stream
def test_text_hardsub_escapes_path(make_file_project):
fp = make_file_project(
file_name="/movies/My Movie: 2024.mkv",
hardsub_index=2, hardsub_kind="text",
embedded_subtitles=[{"index": 2, "codec": "subrip", "language": "eng",
"title": "", "default": False, "forced": False,
"kind": "text"}],
)
conv = ffmpeg_converter()
conv.convert_file(fp, "/tmp/out.mpg", 0)
vf = _vf(conv.command_var)
assert r"\:" in vf # the ':' in the path is escaped
def test_image_hardsub_uses_filter_complex_overlay(make_file_project):
fp = make_file_project(
hardsub_index=3, hardsub_kind="image",
embedded_subtitles=[{"index": 3, "codec": "hdmv_pgs_subtitle",
"language": "eng", "title": "", "default": True,
"forced": False, "kind": "image"}],
)
conv = ffmpeg_converter()
conv.convert_file(fp, "/tmp/out.mpg", 0)
cmd = conv.command_var
fc = arg_after(cmd, "-filter_complex")
assert fc is not None
assert "overlay" in fc
# overlay must come before scale so bitmap subs stay aligned to the source
assert fc.index("overlay") < fc.index("scale=720:480")
assert fc.endswith("[vsub]")
assert arg_after(cmd, "-map") == "[vsub]" # video taken from the graph
assert "-vf" not in cmd # not both -vf and filter_complex
def test_high_profile_two_pass_and_448k_audio(make_file_project):
# regression for the AC3 448k -> 384k clamp bug
fp = make_file_project(two_pass_encoding=True,
video_rate_final=3752, audio_rate_final=448)
conv = ffmpeg_converter(profile="high")
conv.convert_file(fp, "/tmp/out.mpg", 0)
cmd = conv.command_var
assert "-pass" in cmd and "-passlogfile" in cmd
assert arg_after(cmd, "-b:a") == str(448 * 1000) # NOT clamped to 384k
assert arg_after(cmd, "-b:v") == str(3752 * 1000)
assert len(conv.childs) == 1 # spawns the pass-2 child
def test_audio_bitrate_table_allows_ac3_rates(make_file_project):
conv = ffmpeg_converter()
assert conv._adjust_audio_bitrate(448) == 448
assert conv._adjust_audio_bitrate(400) == 448 # rounds up to next valid
assert conv._adjust_audio_bitrate(192) == 192
assert conv._adjust_audio_bitrate(9999) == 640 # clamps to table max
def test_even_dimensions_and_positive_crop_offset(make_file_project):
# odd letterbox height (1079) must be rounded even, offset positive
fp = make_file_project(width_midle=1920, height_midle=1079)
conv = ffmpeg_converter()
conv.convert_file(fp, "/tmp/out.mpg", 0)
cmd = conv.command_var
blob = _vf(cmd) or arg_after(cmd, "-filter_complex") or ""
assert "1079" not in blob # no odd dimension
assert ":-" not in blob # no negative crop/pad offset
assert "1078" in blob # rounded down to even
def test_start_offset_emits_ss_before_inputs(make_file_project):
conv = ffmpeg_converter()
conv.convert_file(make_file_project(), "/tmp/out.mpg", 0, start_offset=120)
cmd = conv.command_var
# -ss appears before each -i (two inputs: audio-delay copy + video)
assert cmd.count("-ss") == 2
first_i = cmd.index("-i")
first_ss = cmd.index("-ss")
assert first_ss < first_i
def test_no_start_offset_has_no_ss(make_file_project):
conv = ffmpeg_converter()
conv.convert_file(make_file_project(), "/tmp/out.mpg", 0)
assert "-ss" not in conv.command_var