""" tests/test_export.py — Unit tests for timecode conversion and export writers Tests use synthetic EditTimeline objects (no real video files needed). """ from __future__ import annotations from pathlib import Path import pytest from src.export.timecode import ( seconds_to_fcpxml, seconds_to_smpte, fcpxml_frame_duration, fcpxml_format_name, seconds_to_frame_count, ) # --------------------------------------------------------------------------- # Timecode helpers # --------------------------------------------------------------------------- class TestSecondsToFcpxml: def test_zero(self) -> None: assert seconds_to_fcpxml(0.0, 24.0) == "0s" def test_one_second_at_24fps(self) -> None: # 1.0s @ 24fps → 24 frames → 24/24s = 1/1s result = seconds_to_fcpxml(1.0, 24.0) assert result == "1/1s" def test_one_second_at_23976(self) -> None: # 1s @ 23.976 → 24000/24000 * 1001/1001 = 1001/1000 ... let's just check it's rational result = seconds_to_fcpxml(1.0, 23.976) assert result.endswith("s") assert "/" in result def test_ten_seconds_at_25fps(self) -> None: # 10s @ 25fps → 250 frames → 250/25s = 10/1s result = seconds_to_fcpxml(10.0, 25.0) assert result == "10/1s" def test_rational_is_reduced(self) -> None: # Should never produce 24/24s result = seconds_to_fcpxml(1.0, 24.0) num, den = result.rstrip("s").split("/") from math import gcd assert gcd(int(num), int(den)) == 1 class TestSecondsToSmpte: def test_zero(self) -> None: assert seconds_to_smpte(0.0, 24.0) == "00:00:00:00" def test_one_minute(self) -> None: assert seconds_to_smpte(60.0, 25.0) == "00:01:00:00" def test_one_hour(self) -> None: assert seconds_to_smpte(3600.0, 24.0) == "01:00:00:00" def test_frames_overflow(self) -> None: # 25fps: 26 frames → 1s + 1 frame = 00:00:01:01 result = seconds_to_smpte(26 / 25, 25.0) assert result == "00:00:01:01" def test_format_length(self) -> None: result = seconds_to_smpte(123.456, 23.976) parts = result.split(":") assert len(parts) == 4 assert all(len(p) == 2 for p in parts) class TestFcpxmlHelpers: def test_frame_duration_24fps(self) -> None: assert fcpxml_frame_duration(24.0) == "1/24s" def test_frame_duration_23976(self) -> None: fd = fcpxml_frame_duration(23.976) # Should be "1001/24000s" assert fd == "1001/24000s" def test_format_name_1080p_2398(self) -> None: name = fcpxml_format_name(23.976, 1920, 1080) assert "1080" in name assert "2398" in name def test_frame_count_roundtrip(self) -> None: fps = 25.0 seconds = 10.0 frames = seconds_to_frame_count(seconds, fps) assert frames == 250 # --------------------------------------------------------------------------- # EDL writer (string output) # --------------------------------------------------------------------------- class TestEdlWriter: def _make_timeline(self) -> "src.core.models.EditTimeline": # type: ignore from src.core.models import ( BeatType, EditClip, EditTimeline, MatchResult, TrailerBeat, ) beat = TrailerBeat( beat_id=0, trailer_path=Path("trailer.mp4"), start_s=0.0, end_s=5.0, start_frame=0, end_frame=120, beat_type=BeatType.HOOK, ) match = MatchResult( beat_id=0, scene_id=3, source_path=Path("movie.mp4"), in_point_s=30.0, out_point_s=35.0, in_point_frame=720, match_score=0.88, ) clip = EditClip( clip_index=0, beat=beat, match=match, timeline_start_s=0.0, timeline_end_s=5.0, ) return EditTimeline( title="TestTrailer", frame_rate=25.0, clips=(clip,) ) def test_edl_contains_title(self, tmp_path: Path) -> None: from src.core.config import load_config from src.export.edl_writer import write_edl cfg = load_config() tl = self._make_timeline() out = write_edl(tl, cfg, output_path=tmp_path / "test.edl") text = out.read_text(encoding="utf-8") assert "TITLE: TestTrailer" in text def test_edl_has_event_line(self, tmp_path: Path) -> None: from src.core.config import load_config from src.export.edl_writer import write_edl cfg = load_config() tl = self._make_timeline() out = write_edl(tl, cfg, output_path=tmp_path / "test.edl") text = out.read_text(encoding="utf-8") assert "001" in text # event number assert "AX" in text # reel name # --------------------------------------------------------------------------- # FCPXML writer (XML structure) # --------------------------------------------------------------------------- class TestFcpxmlWriter: def _make_timeline(self) -> "src.core.models.EditTimeline": # type: ignore from src.core.models import ( BeatType, EditClip, EditTimeline, MatchResult, TrailerBeat, ) beat = TrailerBeat( beat_id=0, trailer_path=Path("trailer.mp4"), start_s=0.0, end_s=5.0, start_frame=0, end_frame=120, beat_type=BeatType.HOOK, ) match = MatchResult( beat_id=0, scene_id=3, source_path=Path("B:/Proxy/movie.mp4"), in_point_s=30.0, out_point_s=35.0, in_point_frame=720, match_score=0.88, ) clip = EditClip( clip_index=0, beat=beat, match=match, timeline_start_s=0.0, timeline_end_s=5.0, ) return EditTimeline( title="TestTrailer", frame_rate=25.0, clips=(clip,) ) def test_fcpxml_is_valid_xml(self, tmp_path: Path) -> None: from xml.etree import ElementTree as ET from src.core.config import load_config from src.export.fcpxml_writer import write_fcpxml cfg = load_config() tl = self._make_timeline() out = write_fcpxml(tl, cfg, output_path=tmp_path / "test.fcpxml") text = out.read_text(encoding="utf-8") text_no_doctype = "\n".join( line for line in text.splitlines() if not line.strip().startswith(" None: from xml.etree import ElementTree as ET from src.core.config import load_config from src.export.fcpxml_writer import write_fcpxml cfg = load_config() tl = self._make_timeline() out = write_fcpxml(tl, cfg, output_path=tmp_path / "test.fcpxml") text = out.read_text(encoding="utf-8") text_no_doctype = "\n".join( line for line in text.splitlines() if not line.strip().startswith("