"""Tests for FCPXML parser.""" import tempfile from pathlib import Path import pytest from fcpxml.models import MarkerType from fcpxml.parser import FCPXMLParser, parse_fcpxml SAMPLE = Path(__file__).parent.parent / "examples" / "sample.fcpxml" def _fcpxml(spine_content="", resources_extra="", project_name="Test", frame_dur="1/24s", seq_dur="240/24s"): """Build a minimal FCPXML string for testing.""" return ( '\n' f'' f'' f'{resources_extra}' f'' f'' f'' f'{spine_content}' f'' ) ASSET_R2 = '' CLIP_A = '' # ============================================================ # File-based Parsing (existing) # ============================================================ def test_parse_sample(): project = FCPXMLParser().parse_file(str(SAMPLE)) assert project.name == "Music Video Edit" assert len(project.timelines) == 1 def test_timeline_props(): tl = FCPXMLParser().parse_file(str(SAMPLE)).primary_timeline assert tl.frame_rate == 24.0 assert tl.width == 1920 def test_clips_parsed(): tl = FCPXMLParser().parse_file(str(SAMPLE)).primary_timeline assert len(tl.clips) > 0 assert tl.clips[0].name == "Interview_A" def test_markers_parsed(): tl = FCPXMLParser().parse_file(str(SAMPLE)).primary_timeline assert len([m for m in tl.markers if m.marker_type == MarkerType.CHAPTER]) == 4 def test_short_clip_detection(): assert len(FCPXMLParser().parse_file(str(SAMPLE)).primary_timeline .get_clips_shorter_than(0.5)) >= 1 # ============================================================ # Library Clips # ============================================================ def test_get_library_clips_returns_assets(): parser = FCPXMLParser() parser.parse_file(str(SAMPLE)) assert len(parser.get_library_clips()) == 3 def test_get_library_clips_includes_metadata(): parser = FCPXMLParser() parser.parse_file(str(SAMPLE)) interview = next(c for c in parser.get_library_clips() if c['name'] == 'Interview_A') assert interview['asset_id'] == 'r2' assert interview['duration_seconds'] == 300.0 assert interview['src'] is not None def test_get_library_clips_filter_by_keyword(): parser = FCPXMLParser() parser.parse_file(str(SAMPLE)) assert len(parser.get_library_clips(keywords=['NonExistent'])) == 0 # ============================================================ # parse_string # ============================================================ def test_parse_string_basic(): xml = _fcpxml(CLIP_A, ASSET_R2, project_name="MyProject") project = FCPXMLParser().parse_string(xml) assert project.name == "MyProject" assert len(project.timelines) == 1 def test_parse_string_clip_properties(): xml = _fcpxml(CLIP_A, ASSET_R2) clip = FCPXMLParser().parse_string(xml).primary_timeline.clips[0] assert clip.name == "A" assert clip.duration.seconds == pytest.approx(5.0, abs=0.01) assert clip.media_path == "file:///a.mov" def test_parse_string_matches_file(): from_file = FCPXMLParser().parse_file(str(SAMPLE)) from_string = FCPXMLParser().parse_string(SAMPLE.read_text()) assert from_file.name == from_string.name assert len(from_file.timelines[0].clips) == len(from_string.timelines[0].clips) def test_parse_string_30fps(): xml = _fcpxml( '', '', frame_dur="1/30s", seq_dur="300/30s", ) assert FCPXMLParser().parse_string(xml).primary_timeline.frame_rate == 30.0 def test_parse_string_2997fps(): xml = _fcpxml( '', '', frame_dur="1001/30000s", seq_dur="30000/1001s", ) assert FCPXMLParser().parse_string(xml).primary_timeline.frame_rate == pytest.approx(29.97, abs=0.01) # ============================================================ # Markers # ============================================================ def test_standard_marker(): clip_xml = ('' '') clip = FCPXMLParser().parse_string(_fcpxml(clip_xml, ASSET_R2)).primary_timeline.clips[0] assert clip.markers[0].marker_type == MarkerType.STANDARD assert clip.markers[0].name == "Mark1" def test_todo_marker(): clip_xml = ('' '') m = FCPXMLParser().parse_string(_fcpxml(clip_xml, ASSET_R2)).primary_timeline.clips[0].markers[0] assert m.marker_type == MarkerType.INCOMPLETE def test_completed_marker(): """Marker with completed='1' should be COMPLETED, not incomplete.""" clip_xml = ('' '') m = FCPXMLParser().parse_string(_fcpxml(clip_xml, ASSET_R2)).primary_timeline.clips[0].markers[0] assert m.marker_type == MarkerType.COMPLETED def test_multiple_marker_types_on_one_clip(): """All four marker types coexist on a single clip without cross-contamination.""" clip_xml = ( '' '' '' '' '' '' ) clip = FCPXMLParser().parse_string(_fcpxml(clip_xml, ASSET_R2)).primary_timeline.clips[0] by_name = {m.name: m.marker_type for m in clip.markers} assert by_name["Note"] == MarkerType.STANDARD assert by_name["Task"] == MarkerType.INCOMPLETE assert by_name["Done"] == MarkerType.COMPLETED assert by_name["Ch1"] == MarkerType.CHAPTER def test_marker_without_completed_is_standard(): """A plain with no completed attribute must parse as STANDARD, not incomplete.""" clip_xml = ('' '') m = FCPXMLParser().parse_string(_fcpxml(clip_xml, ASSET_R2)).primary_timeline.clips[0].markers[0] assert m.marker_type == MarkerType.STANDARD assert m.name == "Plain" # The strict exact-match tests below verify that only completed='0' and # completed='1' (no whitespace, no variants) produce non-STANDARD types. @pytest.mark.parametrize("completed_val,label", [ (" 0 ", "space-padded zero"), (" 1 ", "space-padded one"), ("", "empty string"), ("\n0\n", "newline-padded zero"), ("\n1\n", "newline-padded one"), ("\t0\t", "tab-padded zero"), ("\r\n1\r\n", "crlf-padded one"), ("00", "double-zero"), ("true", "boolean-string true"), ]) def test_non_exact_completed_values_are_standard(completed_val, label): """completed attribute must be exactly '0' or '1' — anything else is STANDARD.""" clip_xml = ( '' f'' '' ) m = FCPXMLParser().parse_string(_fcpxml(clip_xml, ASSET_R2)).primary_timeline.clips[0].markers[0] assert m.marker_type == MarkerType.STANDARD, ( f"completed='{completed_val}' ({label}) should be STANDARD, got {m.marker_type}" ) def test_chapter_marker_ignores_completed_attribute(): """A with completed='0' must still parse as CHAPTER, not as a to-do.""" clip_xml = ( '' '' '' ) m = FCPXMLParser().parse_string(_fcpxml(clip_xml, ASSET_R2)).primary_timeline.clips[0].markers[0] assert m.marker_type == MarkerType.CHAPTER def test_chapter_markers_on_sequence(): # Chapter markers are children of sequence (parsed via findall .//chapter-marker) tl = FCPXMLParser().parse_file(str(SAMPLE)).primary_timeline chapters = [m for m in tl.markers if m.marker_type == MarkerType.CHAPTER] assert len(chapters) == 4 assert chapters[0].name == "Intro" # ============================================================ # Keywords # ============================================================ def test_keyword_with_range(): clip_xml = ('' '') kw = FCPXMLParser().parse_string(_fcpxml(clip_xml, ASSET_R2, seq_dur="120/24s")).primary_timeline.clips[0].keywords[0] assert kw.start is not None assert kw.duration.seconds == pytest.approx(2.0, abs=0.01) def test_keyword_without_range(): clip_xml = ('' '') kw = FCPXMLParser().parse_string(_fcpxml(clip_xml, ASSET_R2, seq_dur="120/24s")).primary_timeline.clips[0].keywords[0] assert kw.start is None assert kw.duration is None # ============================================================ # Transitions & Gaps # ============================================================ def test_transition_parsed(): spine = (CLIP_A + '' + '') tl = FCPXMLParser().parse_string(_fcpxml(spine, ASSET_R2)).primary_timeline assert len(tl.transitions) == 1 assert tl.transitions[0].name == "Cross Dissolve" assert len(tl.clips) == 2 def test_gap_advances_offset(): spine = (CLIP_A + '' + '') tl = FCPXMLParser().parse_string(_fcpxml(spine, ASSET_R2, seq_dur="240/24s")).primary_timeline assert len(tl.clips) == 2 assert tl.clips[1].start.frames == 168 # 120 clip + 48 gap # ============================================================ # Error Handling # ============================================================ def test_parse_file_not_found(): with pytest.raises(FileNotFoundError): FCPXMLParser().parse_file("/nonexistent/path.fcpxml") def test_parse_string_invalid_xml(): with pytest.raises(Exception): FCPXMLParser().parse_string("' '' '' '') project = FCPXMLParser().parse_string(xml) assert project.name == "Empty" assert len(project.primary_timeline.clips) == 0 def test_parse_no_project(): xml = '' project = FCPXMLParser().parse_string(xml) assert project.name == "Untitled" assert len(project.timelines) == 0 def test_fcpxmld_missing_info(): with tempfile.TemporaryDirectory(suffix=".fcpxmld") as tmpdir: with pytest.raises(FileNotFoundError, match="Info.fcpxml not found"): FCPXMLParser().parse_file(tmpdir) # ============================================================ # Helpers & Convenience # ============================================================ def test_duration_to_seconds_rational(): assert FCPXMLParser()._parse_duration_to_seconds("150/30s") == pytest.approx(5.0) def test_duration_to_seconds_plain(): assert FCPXMLParser()._parse_duration_to_seconds("10s") == pytest.approx(10.0) def test_duration_to_seconds_bare_number(): assert FCPXMLParser()._parse_duration_to_seconds("5") == pytest.approx(5.0) def test_duration_to_seconds_zero_denominator(): """Zero-denominator rational returns 0.0 instead of crashing.""" assert FCPXMLParser()._parse_duration_to_seconds("10/0s") == 0.0 def test_duration_to_seconds_multiple_slashes(): """Malformed rational with extra slashes returns 0.0 (unparseable).""" # "10/20/30s" → int("20/30") raises ValueError inside TimeValue.from_timecode, # which _parse_duration_to_seconds catches and returns 0.0 assert FCPXMLParser()._parse_duration_to_seconds("10/20/30s") == 0.0 def test_parse_fcpxml_convenience(): project = parse_fcpxml(str(SAMPLE)) assert project.name == "Music Video Edit" assert len(project.timelines[0].clips) == 9 def test_version_preserved(): xml = _fcpxml(CLIP_A, ASSET_R2).replace('version="1.11"', 'version="1.10"') assert FCPXMLParser().parse_string(xml).fcpxml_version == "1.10" def test_default_version(): assert FCPXMLParser().parse_string('').fcpxml_version == "1.11" def test_asset_media_rep_fallback(): """Asset with no direct src should fall back to media-rep child src.""" asset_xml = ( '' '' '' ) xml = _fcpxml(CLIP_A.replace('ref="r2"', 'ref="r2"'), asset_xml) parser = FCPXMLParser() parser.parse_string(xml) assert parser.resources.get('r2', {}).get('src') == 'file:///video.mov' def test_asset_no_src_no_media_rep(): """Asset with neither src attr nor media-rep child should yield empty string, not crash.""" asset_xml = '' xml = _fcpxml(CLIP_A, asset_xml) parser = FCPXMLParser() parser.parse_string(xml) assert parser.resources.get('r2', {}).get('src') == '' def test_zero_denominator_frame_duration_raises(): """frameDuration with zero denominator must raise, not silently set fps=0.""" xml = _fcpxml(CLIP_A, ASSET_R2, frame_dur="1/0s") with pytest.raises(ValueError, match="denominator"): FCPXMLParser().parse_string(xml)