Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Tests with pytest

on:
push:
branches:
- master
pull_request:
branches:
- master

permissions:
contents: read

jobs:
pytest:
name: Run tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false

- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.11'

- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends ffmpeg mkvtoolnix

- name: Install requirements
run: pip install -r requirements.txt -r requirements.dev.txt

- name: Run tests
run: pytest tests/ -v
13 changes: 13 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,19 @@ tasks:
- docker run -it --gpus all -u $(id -u):$(id -g) -v ${PWD}/{{.INPUT_DIRECTORY}}:/app/input -v ${PWD}/{{.OUTPUT_DIRECTORY}}:/app/output -v ${PWD}/{{.FONTS_DIRECTORY}}:/app/fonts --rm {{.IMAGE}}:{{.TARGET}} {{.CLI_ARGS}}

# Development tools
test:
desc: Run tests
summary: |
Run the pytest test suite inside the dev container.

Usage:
- task test
- task test -- -v
- task test -- --cov=ffconv
- task test -- tests/test_helper.py
cmds:
- $DOCKER_COMPOSE_RUN dev python3 -m pytest tests/ {{.CLI_ARGS}}

ruff:
desc: Run ruff
cmds:
Expand Down
2 changes: 2 additions & 0 deletions requirements.dev.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
ruff==0.16.4
mypy==2.3.1
black==26.5.1
pytest==9.1.1
pytest-cov==7.1.0
Empty file added tests/__init__.py
Empty file.
200 changes: 200 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import shutil
import subprocess

import pytest

from ffconv.cli import mkvmerge_identify_streams


def _binaries_available():
return (
shutil.which("ffmpeg") is not None
and shutil.which("ffprobe") is not None
and shutil.which("mkvmerge") is not None
)


@pytest.fixture(scope="session")
def test_mkv(tmp_path_factory):
if not _binaries_available():
pytest.skip("ffmpeg and mkvmerge required")

tmp = tmp_path_factory.mktemp("media")

srt_path = tmp / "sub.srt"
srt_path.write_text("1\n00:00:00,000 --> 00:00:02,000\nTest subtitle\n\n")

mkv_path = tmp / "test.mkv"
subprocess.run(
[
"ffmpeg",
"-y",
"-f",
"lavfi",
"-i",
"color=black:size=64x64:rate=24:duration=2",
"-f",
"lavfi",
"-i",
"anullsrc=r=44100:cl=mono",
"-i",
str(srt_path),
"-t",
"2",
"-map",
"0:v",
"-map",
"1:a",
"-map",
"2:s",
"-c:v",
"libx264",
"-crf",
"51",
"-preset",
"ultrafast",
"-c:a",
"aac",
"-ab",
"32k",
"-c:s",
"srt",
str(mkv_path),
],
check=True,
capture_output=True,
)

return mkv_path


@pytest.fixture(scope="session")
def stream_mapping(test_mkv):
_, mapping = mkvmerge_identify_streams(
test_mkv, total_items=1, item_index=0, batch_index=1, batch_name="test"
)
return mapping


@pytest.fixture(scope="session")
def test_mkv_ass(tmp_path_factory):
"""MKV with ASS subtitles — the most common real-world subtitle format."""
if not _binaries_available():
pytest.skip("ffmpeg, ffprobe and mkvmerge required")

tmp = tmp_path_factory.mktemp("media_ass")

ass_path = tmp / "sub.ass"
ass_path.write_text(
"[Script Info]\n"
"ScriptType: v4.00+\n"
"PlayResX: 64\n"
"PlayResY: 64\n"
"\n"
"[V4+ Styles]\n"
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour,"
" Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline,"
" Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n"
"Style: Default,Arial,12,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,"
"0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1\n"
"\n"
"[Events]\n"
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
"Dialogue: 0,0:00:00.00,0:00:02.00,Default,,0,0,0,,Test subtitle\n"
)

mkv_path = tmp / "test_ass.mkv"
subprocess.run(
[
"ffmpeg",
"-y",
"-f",
"lavfi",
"-i",
"color=black:size=64x64:rate=24:duration=2",
"-f",
"lavfi",
"-i",
"anullsrc=r=44100:cl=mono",
"-i",
str(ass_path),
"-t",
"2",
"-map",
"0:v",
"-map",
"1:a",
"-map",
"2:s",
"-c:v",
"libx264",
"-crf",
"51",
"-preset",
"ultrafast",
"-c:a",
"aac",
"-ab",
"32k",
"-c:s",
"ass",
str(mkv_path),
],
check=True,
capture_output=True,
)

return mkv_path


@pytest.fixture(scope="session")
def test_mkv_vorbis(tmp_path_factory):
"""MKV with Vorbis audio — exercises the auto-preset default (re-encode) path."""
if not _binaries_available():
pytest.skip("ffmpeg, ffprobe and mkvmerge required")

tmp = tmp_path_factory.mktemp("media_vorbis")

srt_path = tmp / "sub.srt"
srt_path.write_text("1\n00:00:00,000 --> 00:00:02,000\nTest subtitle\n\n")

mkv_path = tmp / "test_vorbis.mkv"
subprocess.run(
[
"ffmpeg",
"-y",
"-f",
"lavfi",
"-i",
"color=black:size=64x64:rate=24:duration=2",
"-f",
"lavfi",
"-i",
"anullsrc=r=44100:cl=mono",
"-i",
str(srt_path),
"-t",
"2",
"-map",
"0:v",
"-map",
"1:a",
"-map",
"2:s",
"-c:v",
"libx264",
"-crf",
"51",
"-preset",
"ultrafast",
"-c:a",
"libvorbis",
"-c:s",
"srt",
str(mkv_path),
],
check=True,
capture_output=True,
)

return mkv_path
98 changes: 98 additions & 0 deletions tests/test_cli_validators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import pytest

from ffconv.cli import validate_stream_count, validate_stream_order
from ffconv.exception import StreamOrderError, StreamTypeMissingError

FILE_DETAILS = {"file_name": "test.mkv", "batch_name": "batch1"}


class TestValidateStreamOrder:
def test_correct_order_passes(self):
streams = {
"video": {"count": 1},
"audio": {"count": 1},
"subtitles": {"count": 1},
}
validate_stream_order(streams, FILE_DETAILS) # should not raise

def test_audio_first_raises(self):
streams = {
"audio": {"count": 1},
"video": {"count": 1},
"subtitles": {"count": 1},
}
with pytest.raises(StreamOrderError):
validate_stream_order(streams, FILE_DETAILS)

def test_subtitles_before_audio_raises(self):
streams = {
"video": {"count": 1},
"subtitles": {"count": 1},
"audio": {"count": 1},
}
with pytest.raises(StreamOrderError):
validate_stream_order(streams, FILE_DETAILS)

def test_error_message_mentions_expected_and_actual_types(self):
streams = {
"audio": {"count": 1},
"video": {"count": 1},
"subtitles": {"count": 1},
}
with pytest.raises(StreamOrderError) as exc_info:
validate_stream_order(streams, FILE_DETAILS)
assert "video" in str(exc_info.value)
assert "audio" in str(exc_info.value)


class TestValidateStreamCount:
def test_all_streams_present_passes(self):
streams = {
"video": {"count": 1},
"audio": {"count": 1},
"subtitles": {"count": 1},
}
validate_stream_count(streams, FILE_DETAILS) # should not raise

def test_missing_video_raises(self):
streams = {
"audio": {"count": 1},
"subtitles": {"count": 1},
}
with pytest.raises(StreamTypeMissingError):
validate_stream_count(streams, FILE_DETAILS)

def test_missing_audio_raises(self):
streams = {
"video": {"count": 1},
"subtitles": {"count": 1},
}
with pytest.raises(StreamTypeMissingError):
validate_stream_count(streams, FILE_DETAILS)

def test_missing_subtitles_raises(self):
streams = {
"video": {"count": 1},
"audio": {"count": 1},
}
with pytest.raises(StreamTypeMissingError):
validate_stream_count(streams, FILE_DETAILS)

def test_empty_streams_raises(self):
with pytest.raises(StreamTypeMissingError):
validate_stream_count({}, FILE_DETAILS)

def test_error_message_mentions_missing_type(self):
streams = {"video": {"count": 1}, "audio": {"count": 1}}
with pytest.raises(StreamTypeMissingError) as exc_info:
validate_stream_count(streams, FILE_DETAILS)
assert "subtitles" in str(exc_info.value)

def test_extra_stream_types_still_pass(self):
streams = {
"video": {"count": 1},
"audio": {"count": 2},
"subtitles": {"count": 3},
"attachments": {"count": 1},
}
validate_stream_count(streams, FILE_DETAILS) # should not raise
Loading