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,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

1
.gitignore vendored
View file

@ -5,6 +5,7 @@ __pycache__/
build/
dist/
*.egg-info/
.pytest_cache/
# Compiled translations (generated from po/ at build time)
src/devedeng/data/locale/

View file

@ -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).

7
pytest.ini Normal file
View file

@ -0,0 +1,7 @@
[pytest]
testpaths = tests
python_files = test_*.py
addopts = -q
filterwarnings =
ignore::DeprecationWarning
ignore::PendingDeprecationWarning

119
tests/conftest.py Normal file
View file

@ -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

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

View file

@ -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

View file

@ -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

115
tests/test_split_planner.py Normal file
View file

@ -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