"""Tests for card_renderer: ffmpeg card generation and concat pipeline. Tests verify command construction, concat list file format, and template config parsing — no actual ffmpeg execution required. """ from __future__ import annotations import subprocess import textwrap from pathlib import Path from unittest.mock import MagicMock, patch import pytest from pipeline.card_renderer import ( DEFAULT_ACCENT_COLOR, DEFAULT_FONT_FAMILY, DEFAULT_INTRO_DURATION, DEFAULT_OUTRO_DURATION, build_concat_list, concat_segments, parse_template_config, render_card, render_card_to_file, ) # ── render_card tests ──────────────────────────────────────────────────────── class TestRenderCard: """Tests for render_card() ffmpeg command generation.""" def test_returns_list_of_strings(self): cmd = render_card("Hello", 2.0, 1080, 1920) assert isinstance(cmd, list) assert all(isinstance(s, str) for s in cmd) def test_contains_ffmpeg_and_lavfi(self): cmd = render_card("Test", 3.0, 1080, 1920) assert cmd[0] == "ffmpeg" assert "-f" in cmd lavfi_idx = cmd.index("-f") assert cmd[lavfi_idx + 1] == "lavfi" def test_contains_correct_dimensions_in_filtergraph(self): cmd = render_card("Test", 2.0, 1920, 1080) # The filtergraph is the arg after -i for lavfi filtergraph = None for i, arg in enumerate(cmd): if arg == "-i" and i > 0 and cmd[i - 1] == "lavfi": filtergraph = cmd[i + 1] break assert filtergraph is not None assert "s=1920x1080" in filtergraph def test_contains_duration_in_filtergraph(self): cmd = render_card("Test", 5.5, 1080, 1920) filtergraph = None for i, arg in enumerate(cmd): if arg == "-i" and i > 0 and cmd[i - 1] == "lavfi": filtergraph = cmd[i + 1] break assert "d=5.5" in filtergraph def test_contains_drawtext_with_text(self): cmd = render_card("My Creator", 2.0, 1080, 1920) filtergraph = None for i, arg in enumerate(cmd): if arg == "-i" and i > 0 and cmd[i - 1] == "lavfi": filtergraph = cmd[i + 1] break assert "drawtext=" in filtergraph assert "My Creator" in filtergraph def test_codec_settings(self): cmd = render_card("Test", 2.0, 1080, 1920) assert "-c:v" in cmd assert "libx264" in cmd assert "-c:a" in cmd assert "aac" in cmd def test_silent_audio_track(self): """Card includes anullsrc so concat with audio segments works.""" cmd = render_card("Test", 2.0, 1080, 1920) # Should have a second -f lavfi -i anullsrc input cmd_str = " ".join(cmd) assert "anullsrc" in cmd_str def test_rejects_zero_duration(self): with pytest.raises(ValueError, match="positive"): render_card("Test", 0, 1080, 1920) def test_rejects_negative_duration(self): with pytest.raises(ValueError, match="positive"): render_card("Test", -1.0, 1080, 1920) def test_rejects_zero_dimensions(self): with pytest.raises(ValueError, match="positive"): render_card("Test", 2.0, 0, 1920) def test_custom_accent_color(self): cmd = render_card("Test", 2.0, 1080, 1920, accent_color="#ff0000") filtergraph = None for i, arg in enumerate(cmd): if arg == "-i" and i > 0 and cmd[i - 1] == "lavfi": filtergraph = cmd[i + 1] break assert "#ff0000" in filtergraph def test_escapes_colons_in_text(self): cmd = render_card("Hello: World", 2.0, 1080, 1920) filtergraph = None for i, arg in enumerate(cmd): if arg == "-i" and i > 0 and cmd[i - 1] == "lavfi": filtergraph = cmd[i + 1] break # Colons should be escaped for ffmpeg assert "Hello\\: World" in filtergraph # ── render_card_to_file tests ──────────────────────────────────────────────── class TestRenderCardToFile: """Tests for render_card_to_file() — mocked ffmpeg execution.""" @patch("pipeline.card_renderer.subprocess.run") def test_calls_ffmpeg_and_returns_path(self, mock_run, tmp_path): mock_run.return_value = MagicMock(returncode=0) out = tmp_path / "card.mp4" out.write_bytes(b"fake") # stat().st_size needs the file result = render_card_to_file("Hi", 2.0, 1080, 1920, out) assert result == out mock_run.assert_called_once() # Output path should be the last arg call_args = mock_run.call_args[0][0] assert call_args[-1] == str(out) @patch("pipeline.card_renderer.subprocess.run") def test_raises_on_ffmpeg_failure(self, mock_run, tmp_path): mock_run.return_value = MagicMock( returncode=1, stderr=b"error: something failed", ) out = tmp_path / "card.mp4" with pytest.raises(subprocess.CalledProcessError): render_card_to_file("Hi", 2.0, 1080, 1920, out) # ── build_concat_list tests ───────────────────────────────────────────────── class TestBuildConcatList: """Tests for build_concat_list() file content.""" def test_writes_correct_format(self, tmp_path): seg1 = tmp_path / "intro.mp4" seg2 = tmp_path / "main.mp4" seg1.touch() seg2.touch() list_file = tmp_path / "concat.txt" result = build_concat_list([seg1, seg2], list_file) assert result == list_file content = list_file.read_text() lines = content.strip().split("\n") assert len(lines) == 2 assert lines[0] == f"file '{seg1.resolve()}'" assert lines[1] == f"file '{seg2.resolve()}'" def test_three_segments(self, tmp_path): segs = [tmp_path / f"seg{i}.mp4" for i in range(3)] for s in segs: s.touch() list_file = tmp_path / "list.txt" build_concat_list(segs, list_file) content = list_file.read_text() lines = content.strip().split("\n") assert len(lines) == 3 # ── concat_segments tests ──────────────────────────────────────────────────── class TestConcatSegments: """Tests for concat_segments() — mocked ffmpeg execution.""" @patch("pipeline.card_renderer.subprocess.run") def test_calls_ffmpeg_concat_demuxer(self, mock_run, tmp_path): mock_run.return_value = MagicMock(returncode=0) seg1 = tmp_path / "seg1.mp4" seg2 = tmp_path / "seg2.mp4" seg1.touch() seg2.touch() out = tmp_path / "output.mp4" out.write_bytes(b"fakemp4") result = concat_segments([seg1, seg2], out) assert result == out mock_run.assert_called_once() call_args = mock_run.call_args[0][0] assert "concat" in call_args assert "-safe" in call_args assert "0" in call_args assert "-c" in call_args assert "copy" in call_args def test_rejects_empty_segments(self): with pytest.raises(ValueError, match="empty"): concat_segments([], Path("/tmp/out.mp4")) @patch("pipeline.card_renderer.subprocess.run") def test_raises_on_ffmpeg_failure(self, mock_run, tmp_path): mock_run.return_value = MagicMock( returncode=1, stderr=b"concat error", ) seg1 = tmp_path / "s.mp4" seg1.touch() out = tmp_path / "out.mp4" with pytest.raises(subprocess.CalledProcessError): concat_segments([seg1], out) # ── parse_template_config tests ────────────────────────────────────────────── class TestParseTemplateConfig: """Tests for parse_template_config() defaults and overrides.""" def test_none_returns_all_defaults_disabled(self): cfg = parse_template_config(None) assert cfg["show_intro"] is False assert cfg["show_outro"] is False assert cfg["accent_color"] == DEFAULT_ACCENT_COLOR assert cfg["font_family"] == DEFAULT_FONT_FAMILY assert cfg["intro_duration"] == DEFAULT_INTRO_DURATION assert cfg["outro_duration"] == DEFAULT_OUTRO_DURATION def test_empty_dict_returns_defaults_disabled(self): cfg = parse_template_config({}) assert cfg["show_intro"] is False assert cfg["show_outro"] is False def test_full_config_preserves_values(self): raw = { "show_intro": True, "intro_text": "Welcome!", "intro_duration": 3.0, "show_outro": True, "outro_text": "Bye!", "outro_duration": 1.5, "accent_color": "#ff0000", "font_family": "Roboto", } cfg = parse_template_config(raw) assert cfg["show_intro"] is True assert cfg["intro_text"] == "Welcome!" assert cfg["intro_duration"] == 3.0 assert cfg["show_outro"] is True assert cfg["outro_text"] == "Bye!" assert cfg["outro_duration"] == 1.5 assert cfg["accent_color"] == "#ff0000" assert cfg["font_family"] == "Roboto" def test_partial_config_fills_defaults(self): raw = {"show_intro": True, "intro_text": "Hi"} cfg = parse_template_config(raw) assert cfg["show_intro"] is True assert cfg["intro_text"] == "Hi" assert cfg["intro_duration"] == DEFAULT_INTRO_DURATION assert cfg["show_outro"] is False assert cfg["outro_text"] == "" assert cfg["accent_color"] == DEFAULT_ACCENT_COLOR def test_truthy_coercion(self): """Non-bool truthy values should coerce to bool.""" cfg = parse_template_config({"show_intro": 1, "show_outro": 0}) assert cfg["show_intro"] is True assert cfg["show_outro"] is False def test_duration_coercion_from_int(self): cfg = parse_template_config({"intro_duration": 5}) assert cfg["intro_duration"] == 5.0 assert isinstance(cfg["intro_duration"], float) # ── extract_clip_with_template tests ───────────────────────────────────────── class TestExtractClipWithTemplate: """Tests for the shorts_generator.extract_clip_with_template function.""" @patch("pipeline.shorts_generator.extract_clip") def test_no_cards_delegates_to_extract_clip(self, mock_extract): from pipeline.shorts_generator import extract_clip_with_template extract_clip_with_template( input_path=Path("/fake/input.mp4"), output_path=Path("/fake/output.mp4"), start_secs=10.0, end_secs=20.0, vf_filter="scale=1080:-2", ) mock_extract.assert_called_once() @patch("pipeline.card_renderer.concat_segments") @patch("pipeline.shorts_generator.extract_clip") def test_with_intro_concats_two_segments(self, mock_extract, mock_concat, tmp_path): from pipeline.shorts_generator import extract_clip_with_template intro = tmp_path / "intro.mp4" intro.touch() out = tmp_path / "final.mp4" main_tmp = Path(str(out) + ".main.mp4") # Create the main clip temp file so cleanup doesn't error main_tmp.touch() mock_concat.return_value = out extract_clip_with_template( input_path=Path("/fake/input.mp4"), output_path=out, start_secs=10.0, end_secs=20.0, vf_filter="scale=1080:-2", intro_path=intro, ) mock_extract.assert_called_once() mock_concat.assert_called_once() # Segments should be [intro, main_clip] segments = mock_concat.call_args[1]["segments"] assert len(segments) == 2 assert segments[0] == intro @patch("pipeline.card_renderer.concat_segments") @patch("pipeline.shorts_generator.extract_clip") def test_with_intro_and_outro_concats_three_segments( self, mock_extract, mock_concat, tmp_path, ): from pipeline.shorts_generator import extract_clip_with_template intro = tmp_path / "intro.mp4" outro = tmp_path / "outro.mp4" intro.touch() outro.touch() out = tmp_path / "final.mp4" main_tmp = Path(str(out) + ".main.mp4") main_tmp.touch() mock_concat.return_value = out extract_clip_with_template( input_path=Path("/fake/input.mp4"), output_path=out, start_secs=10.0, end_secs=20.0, vf_filter="scale=1080:-2", intro_path=intro, outro_path=outro, ) segments = mock_concat.call_args[1]["segments"] assert len(segments) == 3 assert segments[0] == intro assert segments[2] == outro