"""Unit tests for caption_generator module.""" from __future__ import annotations import re import tempfile from pathlib import Path import pytest from pipeline.caption_generator import ( DEFAULT_STYLE, _format_ass_time, generate_ass_captions, write_ass_file, ) # ── Fixtures ───────────────────────────────────────────────────────────────── @pytest.fixture def sample_word_timings() -> list[dict]: """Realistic word timings as produced by extract_word_timings.""" return [ {"word": "This", "start": 10.0, "end": 10.3}, {"word": "is", "start": 10.3, "end": 10.5}, {"word": "a", "start": 10.5, "end": 10.6}, {"word": "test", "start": 10.6, "end": 11.0}, {"word": "sentence", "start": 11.1, "end": 11.6}, ] # ── Time formatting ───────────────────────────────────────────────────────── class TestFormatAssTime: def test_zero(self): assert _format_ass_time(0.0) == "0:00:00.00" def test_sub_second(self): assert _format_ass_time(0.5) == "0:00:00.50" def test_minutes(self): assert _format_ass_time(65.5) == "0:01:05.50" def test_hours(self): assert _format_ass_time(3661.25) == "1:01:01.25" def test_negative_clamps_to_zero(self): assert _format_ass_time(-5.0) == "0:00:00.00" # ── ASS generation ────────────────────────────────────────────────────────── class TestGenerateAssCaptions: def test_empty_timings_returns_header_only(self): result = generate_ass_captions([], clip_start=0.0) assert "[Script Info]" in result assert "[Events]" in result # No Dialogue lines assert "Dialogue:" not in result def test_structure_has_required_sections(self, sample_word_timings): result = generate_ass_captions(sample_word_timings, clip_start=10.0) assert "[Script Info]" in result assert "[V4+ Styles]" in result assert "[Events]" in result assert "Dialogue:" in result def test_clip_offset_applied(self, sample_word_timings): """Word at t=10.5 with clip_start=10.0 should become t=0.5 in ASS.""" result = generate_ass_captions(sample_word_timings, clip_start=10.0) lines = result.strip().split("\n") dialogue_lines = [l for l in lines if l.startswith("Dialogue:")] # First word "This" starts at 10.0, clip_start=10.0 → relative 0.0 assert dialogue_lines[0].startswith("Dialogue: 0,0:00:00.00,") # Third word "a" starts at 10.5, clip_start=10.0 → relative 0.5 assert "0:00:00.50" in dialogue_lines[2] def test_karaoke_tags_present(self, sample_word_timings): result = generate_ass_captions(sample_word_timings, clip_start=10.0) lines = result.strip().split("\n") dialogue_lines = [l for l in lines if l.startswith("Dialogue:")] for line in dialogue_lines: # Each line should have a \kN tag assert re.search(r"\{\\k\d+\}", line), f"Missing karaoke tag in: {line}" def test_karaoke_duration_math(self, sample_word_timings): """Word "This" at [10.0, 10.3] → 0.3s → k30 (30 centiseconds).""" result = generate_ass_captions(sample_word_timings, clip_start=10.0) lines = result.strip().split("\n") dialogue_lines = [l for l in lines if l.startswith("Dialogue:")] # "This" duration: 10.3 - 10.0 = 0.3s = 30cs assert "{\\k30}This" in dialogue_lines[0] # "test" duration: 11.0 - 10.6 = 0.4s = 40cs assert "{\\k40}test" in dialogue_lines[3] def test_word_count_matches(self, sample_word_timings): result = generate_ass_captions(sample_word_timings, clip_start=10.0) lines = result.strip().split("\n") dialogue_lines = [l for l in lines if l.startswith("Dialogue:")] assert len(dialogue_lines) == 5 def test_empty_word_text_skipped(self): timings = [ {"word": "hello", "start": 0.0, "end": 0.5}, {"word": " ", "start": 0.5, "end": 0.7}, # whitespace-only {"word": "", "start": 0.7, "end": 0.8}, # empty {"word": "world", "start": 0.8, "end": 1.2}, ] result = generate_ass_captions(timings, clip_start=0.0) lines = result.strip().split("\n") dialogue_lines = [l for l in lines if l.startswith("Dialogue:")] assert len(dialogue_lines) == 2 # only "hello" and "world" def test_custom_style_overrides(self, sample_word_timings): result = generate_ass_captions( sample_word_timings, clip_start=10.0, style_config={"font_size": 72, "font_name": "Roboto"}, ) assert "Roboto" in result assert ",72," in result def test_negative_relative_time_clamped(self): """Words before clip_start should clamp to 0.""" timings = [{"word": "early", "start": 5.0, "end": 5.5}] result = generate_ass_captions(timings, clip_start=10.0) lines = [l for l in result.strip().split("\n") if l.startswith("Dialogue:")] # Both start and end clamped to 0 assert lines[0].startswith("Dialogue: 0,0:00:00.00,0:00:00.00,") # ── File writing ───────────────────────────────────────────────────────────── class TestWriteAssFile: def test_writes_content(self): content = "[Script Info]\ntest content\n" with tempfile.TemporaryDirectory() as td: out = write_ass_file(content, Path(td) / "sub.ass") assert out.exists() assert out.read_text() == content def test_creates_parent_dirs(self): content = "test" with tempfile.TemporaryDirectory() as td: out = write_ass_file(content, Path(td) / "nested" / "deep" / "sub.ass") assert out.exists() def test_returns_path(self): content = "test" with tempfile.TemporaryDirectory() as td: target = Path(td) / "sub.ass" result = write_ass_file(content, target) assert result == target