"""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)