diff --git a/.forgejo/workflows/tests.yml b/.forgejo/workflows/tests.yml new file mode 100644 index 0000000..2f39cd9 --- /dev/null +++ b/.forgejo/workflows/tests.yml @@ -0,0 +1,28 @@ +name: tests + +on: + push: + branches: [master] + pull_request: + +jobs: + pytest: + runs-on: docker + container: + image: debian:bookworm + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: | + apt-get update + # PyGObject + GTK introspection are needed because the modules under + # test import gi.repository at import time; the tests themselves run + # headless (no display, no encoding). + apt-get install -y --no-install-recommends \ + python3 python3-pip python3-gi gir1.2-gtk-3.0 \ + python3-cairo python3-gi-cairo + pip3 install --break-system-packages pytest + + - name: Run test suite + run: python3 -m pytest diff --git a/.gitignore b/.gitignore index ab7d506..683cc51 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ __pycache__/ build/ dist/ *.egg-info/ +.pytest_cache/ # Compiled translations (generated from po/ at build time) src/devedeng/data/locale/ diff --git a/CLAUDE.md b/CLAUDE.md index f9a58be..1ea5c02 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,21 +26,17 @@ Runtime external tools (must be on PATH): `ffmpeg`/`ffprobe` (or `avconv`/`avpro ## Testing -There is **no test framework or test suite.** Verify changes two ways: +There is a **pytest suite in `tests/`** (run `python3 -m pytest`; it needs `pytest` and PyGObject/`gi`). Tests are headless — no display, no actual encoding — and import the in-tree package via `tests/conftest.py` (which prepends `src/` to the path and installs the gettext `_` builtin). CI runs them on Codeberg via `.forgejo/workflows/tests.yml`. Coverage focuses on the two things that are cheap to test and easy to break: -1. **Pure logic** (no GTK/display) — e.g. the split planner. Import and exercise directly: - ```bash - cd src && python3 -c "from devedeng.project import plan_disc_split; ..." - ``` - `plan_disc_split`, `_movie_chapter_seconds`, and `_nearest_chapter_before` in `project.py` are deliberately module-level pure functions so they can be unit-tested without instantiating GTK. +1. **Pure logic** (no GTK/display) — the split planner and bitrate math: `plan_disc_split`, `_movie_chapter_seconds`, `_nearest_chapter_before`, and `compute_high_profile_bitrate` in `project.py` are deliberately module-level pure functions so they can be unit-tested without instantiating GTK. See `tests/test_split_planner.py`, `tests/test_high_profile_bitrate.py`. -2. **Generated ffmpeg commands** — build a converter and inspect `command_var` without running anything. The config singleton works headless: +2. **Generated commands** — build a converter and assert on `command_var` without running anything (`tests/test_ffmpeg_command.py`); detection is checked by feeding `ffprobe.process_json` canned JSON (`tests/test_ffprobe_detection.py`). The `FakeFileProject`/`FakeMovie` factories and the `ffmpeg_converter`/`arg_after` helpers live in `conftest.py`. The config singleton works headless: ```python cfg = devedeng.configuration_data.configuration.get_config(); cfg.disc_type = "dvd" conv = devedeng.ffmpeg.ffmpeg(); conv.convert_file(mock_file_movie, "/tmp/out.mpg", 0) print(conv.command_var) # the exact ffmpeg argv ``` - `convert_file` takes the `file_movie` object as `file_project` and reads `hardsub_index`/`hardsub_kind`, the encode profile from config, etc. For a full integration check, run a real short encode through the executor's GLib loop and watch a fake progress bar (GTK is installed but there is no Xvfb here, so the *windowed* UI can only be verified by actually launching the app on a display). + `convert_file` reads `hardsub_index`/`hardsub_kind` off the `file_project` and the encode profile from config. For a full integration check, run a real short encode through the executor's GLib loop and watch a fake progress bar; the *windowed* UI can only be verified by launching the app on a real display. `contrib/dvd-safe-converter.sh` is the known-good baseline the **Compatibility** profile is modelled on — use it as a regression reference for the no-subtitle DVD encode (mpeg2video / 720x480 / 29.97 / AC3 192k / 48k). diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..d8f1faf --- /dev/null +++ b/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +testpaths = tests +python_files = test_*.py +addopts = -q +filterwarnings = + ignore::DeprecationWarning + ignore::PendingDeprecationWarning diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ffa4df4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,119 @@ +"""Shared test fixtures/helpers for the devedeng-extended test suite. + +These tests exercise pure logic and command generation without launching the +GTK UI. They import the package from ``src/`` and stub out the gettext ``_`` +builtin so modules that call ``_(...)`` at runtime work headless. +""" + +import os +import sys +import gettext + +# make the in-tree package importable without installing +_SRC = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +# ensure the gettext _() builtin exists (the app normally installs it at start) +gettext.install("devedeng") + +import pytest + + +class FakeMovie: + """Minimal stand-in for file_movie for the pure split/bitrate planners. + + Only the attributes those functions read are provided. + """ + + element_type = "file_movie" + + def __init__(self, rate_kBps=586.5, length=3600, chapters_step=5, + divide=True, manual=None, audio_streams=1, subtitles=0): + self._rate = rate_kBps + self.original_length = length + self.chapter_size = chapters_step + self.divide_in_chapters = divide + self.chapter_list_entry = manual + self.audio_streams = audio_streams + self.subtitles_list = [None] * subtitles + + def get_kbytes_per_second(self): + return self._rate + + +@pytest.fixture +def make_movie(): + """Factory for FakeMovie instances used by the planner tests.""" + def _make(**kwargs): + return FakeMovie(**kwargs) + return _make + + +class FakeFileProject: + """Stand-in for a file_movie passed to ffmpeg/avconv.convert_file. + + Defaults describe a typical 1080p 16:9 source targeting NTSC DVD with no + hardsub. Override fields per test. + """ + + def __init__(self, **overrides): + self.file_name = "/movies/input.mkv" + self.title_name = "input" + self.two_pass_encoding = False + self.volume = 100 + self.audio_delay = 0.0 + self.copy_sound = False + self.no_reencode_audio_video = False + self.is_mpeg_ps = False + self.video_list = [0] + self.audio_list = [1] + self.deinterlace = "deinterlace_none" + self.prerotation = 0 + self.rotation = "rotation_0" + self.mirror_vertical = False + self.mirror_horizontal = False + self.original_width = 1920 + self.original_height = 1080 + self.width_midle = 1920 + self.height_midle = 1080 + self.width_final = 720 + self.height_final = 480 + self.original_fps = 24 + self.format_pal = False + self.sound5_1 = False + self.gop12 = True + self.video_rate_final = 4500 + self.audio_rate_final = 192 + self.aspect_ratio_final = 1.7777777777777777 + self.original_length = 7200 + self.hardsub_index = -1 + self.hardsub_kind = "text" + self.embedded_subtitles = [] + for k, v in overrides.items(): + setattr(self, k, v) + + +@pytest.fixture +def make_file_project(): + def _make(**overrides): + return FakeFileProject(**overrides) + return _make + + +def ffmpeg_converter(disc_type="dvd", profile="compatibility"): + """Build an ffmpeg converter with the config singleton set for a test.""" + import devedeng.configuration_data + import devedeng.ffmpeg + cfg = devedeng.configuration_data.configuration.get_config() + cfg.disc_type = disc_type + cfg.encode_profile = profile + return devedeng.ffmpeg.ffmpeg() + + +def arg_after(cmd, flag): + """Return the argument following ``flag`` in an argv list, or None.""" + for i, a in enumerate(cmd): + if a == flag and i + 1 < len(cmd): + return cmd[i + 1] + return None diff --git a/tests/test_ffmpeg_command.py b/tests/test_ffmpeg_command.py new file mode 100644 index 0000000..9f53a8e --- /dev/null +++ b/tests/test_ffmpeg_command.py @@ -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 diff --git a/tests/test_ffprobe_detection.py b/tests/test_ffprobe_detection.py new file mode 100644 index 0000000..fc2adac --- /dev/null +++ b/tests/test_ffprobe_detection.py @@ -0,0 +1,82 @@ +"""Tests for embedded subtitle detection in ffprobe.process_json.""" + +import json +import devedeng.ffprobe + + +def _probe(streams): + p = devedeng.ffprobe.ffprobe() + data = json.dumps({"streams": streams}) + err = p.process_json("/movies/input.mkv", data) + return p, err + + +def _video(index=0, duration="120.0"): + return {"index": index, "codec_type": "video", "codec_name": "h264", + "width": 1920, "height": 1080, "avg_frame_rate": "24/1", + "duration": duration} + + +def _audio(index=1): + return {"index": index, "codec_type": "audio", "codec_name": "aac", + "sample_rate": "48000"} + + +def _sub(index, codec, language="eng", title="", default=0, forced=0): + return {"index": index, "codec_type": "subtitle", "codec_name": codec, + "tags": {"language": language, "title": title}, + "disposition": {"default": default, "forced": forced}} + + +def test_no_subtitles_empty_list(): + p, err = _probe([_video(), _audio()]) + assert err is False + assert p.subtitle_tracks == [] + + +def test_text_subtitle_classified_text(): + p, _ = _probe([_video(), _audio(), _sub(2, "subrip", "eng", default=1)]) + assert len(p.subtitle_tracks) == 1 + t = p.subtitle_tracks[0] + assert t["index"] == 2 + assert t["codec"] == "subrip" + assert t["language"] == "eng" + assert t["kind"] == "text" + assert t["default"] is True + assert t["forced"] is False + + +def test_image_subtitles_classified_image(): + for codec in ("hdmv_pgs_subtitle", "dvd_subtitle", "dvdsub", "pgssub", "xsub"): + p, _ = _probe([_video(), _audio(), _sub(2, codec)]) + assert p.subtitle_tracks[0]["kind"] == "image", codec + + +def test_various_text_codecs_classified_text(): + for codec in ("subrip", "ass", "ssa", "mov_text", "webvtt"): + p, _ = _probe([_video(), _audio(), _sub(2, codec)]) + assert p.subtitle_tracks[0]["kind"] == "text", codec + + +def test_multiple_subtitle_tracks_in_order(): + p, _ = _probe([ + _video(), _audio(), + _sub(2, "subrip", "eng", default=1), + _sub(3, "hdmv_pgs_subtitle", "fre", forced=1), + _sub(4, "ass", "jpn", title="Signs"), + ]) + assert [t["index"] for t in p.subtitle_tracks] == [2, 3, 4] + assert [t["kind"] for t in p.subtitle_tracks] == ["text", "image", "text"] + assert p.subtitle_tracks[1]["forced"] is True + assert p.subtitle_tracks[2]["title"] == "Signs" + + +def test_missing_tags_and_disposition_defaults(): + # a subtitle stream with no tags/disposition must not crash + p, _ = _probe([_video(), _audio(), + {"index": 2, "codec_type": "subtitle", + "codec_name": "subrip"}]) + t = p.subtitle_tracks[0] + assert t["language"] == "" + assert t["default"] is False + assert t["forced"] is False diff --git a/tests/test_high_profile_bitrate.py b/tests/test_high_profile_bitrate.py new file mode 100644 index 0000000..b207e17 --- /dev/null +++ b/tests/test_high_profile_bitrate.py @@ -0,0 +1,67 @@ +"""Tests for the High-quality profile fill-the-disc bitrate math.""" + +from devedeng.project import compute_high_profile_bitrate as calc + +CAP_KB = 3_780_000.0 # 4.2 GB * 0.90 margin, in kBytes +FLOOR = 3000 +CAP = 9000 + + +def audio_kbits(rate_kbps, runtime_s, streams=1): + return rate_kbps * streams * runtime_s + + +def test_short_movie_one_disc_at_cap(): + # 30 min: tiny, so bitrate is pinned at the 9000k cap, one disc + n, br = calc(1800, audio_kbits(448, 1800), CAP_KB) + assert n == 1 + assert br == CAP + + +def test_two_hours_fills_one_disc(): + n, br = calc(7200, audio_kbits(448, 7200), CAP_KB) + assert n == 1 + assert FLOOR <= br <= CAP + # should be comfortably above the floor (filling the disc) + assert br > 3500 + + +def test_four_hours_two_discs(): + n, br = calc(14400, audio_kbits(448, 14400), CAP_KB) + assert n == 2 + assert FLOOR <= br <= CAP + + +def test_eight_hours_multiple_discs(): + n, br = calc(28800, audio_kbits(448, 28800), CAP_KB) + assert n >= 3 + assert br >= FLOOR + + +def test_zero_runtime_guard(): + assert calc(0, 0, CAP_KB) == (1, CAP) + + +def test_bitrate_always_within_bounds(): + for rt in (300, 1800, 3600, 7200, 14400, 30000, 60000): + n, br = calc(rt, audio_kbits(448, rt), CAP_KB) + assert FLOOR <= br <= CAP, (rt, br) + assert n >= 1 + + +def test_filled_content_does_not_exceed_disc_capacity(): + # the chosen bitrate must not overflow the n discs it claims to fill + for rt in (1800, 7200, 14400, 28800): + ak = audio_kbits(448, rt) + n, br = calc(rt, ak, CAP_KB) + used_kbits = br * rt + ak + assert used_kbits <= n * CAP_KB * 8 + 1 # +1 for rounding slack + + +def test_menu_reservation_reduces_video_budget(): + rt = 7200 + ak = audio_kbits(448, rt) + n0, br0 = calc(rt, ak, CAP_KB, menu_kbytes=0) + n1, br1 = calc(rt, ak, CAP_KB, menu_kbytes=200_000) + # with a menu eating space, the fill bitrate can only be <= the no-menu one + assert br1 <= br0 diff --git a/tests/test_split_planner.py b/tests/test_split_planner.py new file mode 100644 index 0000000..ee81e28 --- /dev/null +++ b/tests/test_split_planner.py @@ -0,0 +1,115 @@ +"""Tests for the pure disc-split planner in project.py.""" + +from devedeng.project import ( + plan_disc_split, + _movie_chapter_seconds, + _nearest_chapter_before, +) + +# ~4.2 GB usable disc in kBytes (matches get_dvd_size()[0]*1000 with margin) +CAP = 4_200_000.0 +# 4500k video + 192k audio = 4692 kbit/s -> /8 = 586.5 kByte/s +RATE = (4500 + 192) / 8.0 +# seconds that fill one disc at RATE +SECS_PER_DISC = CAP / RATE + + +def names(plan): + return [[seg[0] for seg in disc] for disc in plan] + + +def test_single_short_movie_one_disc(make_movie): + m = make_movie(rate_kBps=RATE, length=3600) + plan = plan_disc_split([m], CAP) + assert len(plan) == 1 + assert plan[0] == [(m, 0, 0)] + + +def test_two_movies_fit_one_disc(make_movie): + a = make_movie(rate_kBps=RATE, length=3000) + b = make_movie(rate_kBps=RATE, length=3000) + plan = plan_disc_split([a, b], CAP) + assert len(plan) == 1 + assert names(plan) == [[a, b]] + + +def test_two_movies_overflow_to_two_discs(make_movie): + a = make_movie(rate_kBps=RATE, length=5000) + b = make_movie(rate_kBps=RATE, length=5000) + plan = plan_disc_split([a, b], CAP) + assert len(plan) == 2 + assert plan[0] == [(a, 0, 0)] + assert plan[1] == [(b, 0, 0)] + + +def test_single_long_movie_splits_at_chapter(make_movie): + # 200 min movie, chapters every 5 min (300 s) + m = make_movie(rate_kBps=RATE, length=12000, chapters_step=5) + plan = plan_disc_split([m], CAP) + assert len(plan) >= 2 + first = plan[0][0] + assert first[0] is m and first[1] == 0 + cut = first[2] + # cut lands on a 300-s chapter boundary, just under one disc's capacity + assert cut % 300 == 0 + assert SECS_PER_DISC - 300 < cut <= SECS_PER_DISC + # last segment runs to the end (duration 0) + assert plan[-1][0][2] == 0 + + +def test_single_long_movie_no_chapters_even_cut(make_movie): + m = make_movie(rate_kBps=RATE, length=12000, divide=False) + plan = plan_disc_split([m], CAP) + assert len(plan) >= 2 + # first cut is an exact time cut at capacity + assert abs(plan[0][0][2] - SECS_PER_DISC) < 1.0 + + +def test_segments_cover_whole_movie(make_movie): + m = make_movie(rate_kBps=RATE, length=12000, divide=False) + plan = plan_disc_split([m], CAP) + # reconstruct covered span from (start, duration) segments + covered = 0.0 + for disc in plan: + movie, start, duration = disc[0] + end = movie.original_length if duration == 0 else start + duration + assert start == covered # contiguous, no gaps/overlaps + covered = end + assert covered == m.original_length + + +def test_menu_reservation_on_first_disc(make_movie): + m = make_movie(rate_kBps=RATE, length=3000) + # reserve a chunk on disc 1; small movie still fits + plan = plan_disc_split([m], CAP, first_disc_reserved_kbytes=500_000) + assert len(plan) == 1 + + +def test_empty_project_returns_one_empty_disc(): + plan = plan_disc_split([], CAP) + assert plan == [[]] + + +def test_zero_rate_does_not_crash(make_movie): + m = make_movie(rate_kBps=0, length=3600) + plan = plan_disc_split([m], CAP) + assert len(plan) >= 1 + + +# --- chapter helpers --- + +def test_chapter_seconds_auto_and_manual(make_movie): + m = make_movie(length=1000, chapters_step=5, manual="2:30,9:00") + # auto: 300, 600, 900 (< length-4); manual: 150 (2:30), 540 (9:00) + assert _movie_chapter_seconds(m) == [150, 300, 540, 600, 900] + + +def test_chapter_seconds_disabled(make_movie): + m = make_movie(length=1000, divide=False) + assert _movie_chapter_seconds(m) == [] + + +def test_nearest_chapter_before(): + assert _nearest_chapter_before([300, 600, 900], 0, 700) == 600 + assert _nearest_chapter_before([300, 600, 900], 0, 250) is None + assert _nearest_chapter_before([300, 600, 900], 600, 1000) == 900