"""Tests for fcpxml/export.py — DaVinci Resolve and XMEML export.
Covers: attribute stripping, compound clip flattening, audio track
generation, lane mapping, file path handling, no-timeline error,
doctype injection, NTSC detection.
"""
import os
import tempfile
import xml.etree.ElementTree as ET
import pytest
from fcpxml.export import DaVinciExporter
from fcpxml.safe_xml import serialize_xml
BASE_XML = """
"""
CONNECTED_XML = """
"""
COMPOUND_XML = """
"""
def _tmp(xml: str) -> str:
fd, path = tempfile.mkstemp(suffix='.fcpxml')
with os.fdopen(fd, 'w') as f:
f.write(xml)
return path
def _out(suffix='.fcpxml') -> str:
fd, path = tempfile.mkstemp(suffix=suffix)
os.close(fd)
return path
class TestAttributeStripping:
"""Simplified FCPXML should strip Resolve-incompatible attributes."""
def test_moddate_removed(self):
src = _tmp(BASE_XML)
out = _out()
try:
exp = DaVinciExporter(src)
exp.export_simplified_fcpxml(out)
tree = ET.parse(out)
for elem in tree.iter():
assert 'modDate' not in elem.attrib
finally:
os.unlink(src)
os.unlink(out)
def test_version_downgraded_to_1_9(self):
src = _tmp(BASE_XML)
out = _out()
try:
exp = DaVinciExporter(src)
exp.export_simplified_fcpxml(out)
tree = ET.parse(out)
assert tree.getroot().get('version') == '1.9'
finally:
os.unlink(src)
os.unlink(out)
def test_colorprocessing_stripped(self):
xml = BASE_XML.replace('', '')
src = _tmp(xml)
out = _out()
try:
exp = DaVinciExporter(src)
exp.export_simplified_fcpxml(out)
tree = ET.parse(out)
assert 'colorProcessing' not in tree.getroot().attrib
finally:
os.unlink(src)
os.unlink(out)
class TestCompoundClipFlattening:
"""ref-clip → asset-clip conversion when flatten_compounds=True."""
def test_ref_clip_becomes_asset_clip(self):
src = _tmp(COMPOUND_XML)
out = _out()
try:
exp = DaVinciExporter(src)
exp.export_simplified_fcpxml(out, flatten_compounds=True)
tree = ET.parse(out)
ref_clips = tree.findall('.//ref-clip')
asset_clips = tree.findall('.//asset-clip')
assert len(ref_clips) == 0, "ref-clip should be converted"
assert len(asset_clips) >= 1
finally:
os.unlink(src)
os.unlink(out)
def test_flatten_strips_compound_attrs(self):
"""srcEnable and other compound-specific attrs should be removed."""
src = _tmp(COMPOUND_XML)
out = _out()
try:
exp = DaVinciExporter(src)
exp.export_simplified_fcpxml(out, flatten_compounds=True)
tree = ET.parse(out)
for clip in tree.findall('.//asset-clip'):
assert 'srcEnable' not in clip.attrib
finally:
os.unlink(src)
os.unlink(out)
def test_no_flatten_preserves_ref_clip(self):
src = _tmp(COMPOUND_XML)
out = _out()
try:
exp = DaVinciExporter(src)
exp.export_simplified_fcpxml(out, flatten_compounds=False)
tree = ET.parse(out)
ref_clips = tree.findall('.//ref-clip')
assert len(ref_clips) >= 1, "ref-clip should be preserved"
finally:
os.unlink(src)
os.unlink(out)
class TestXmemlAudioTracks:
"""Audio tracks from connected clips with negative lanes."""
def test_audio_track_generated_from_negative_lane(self):
src = _tmp(CONNECTED_XML)
out = _out('.xml')
try:
exp = DaVinciExporter(src)
exp.export_xmeml(out)
tree = ET.parse(out)
audio = tree.find('.//audio')
assert audio is not None
audio_tracks = audio.findall('track')
assert len(audio_tracks) >= 1
finally:
os.unlink(src)
os.unlink(out)
def test_primary_storyline_audio_fallback(self):
"""When no connected audio clips, primary storyline creates audio track."""
src = _tmp(BASE_XML)
out = _out('.xml')
try:
exp = DaVinciExporter(src)
exp.export_xmeml(out)
tree = ET.parse(out)
audio = tree.find('.//audio')
tracks = audio.findall('track')
assert len(tracks) >= 1
clipitems = tracks[0].findall('clipitem')
assert len(clipitems) == 2 # Both primary clips have audio
finally:
os.unlink(src)
os.unlink(out)
class TestXmemlFilePathHandling:
"""Clipitem file/pathurl elements from media_path."""
def test_clipitem_has_file_pathurl(self):
src = _tmp(BASE_XML)
out = _out('.xml')
try:
exp = DaVinciExporter(src)
exp.export_xmeml(out)
tree = ET.parse(out)
clipitems = tree.findall('.//clipitem')
found_path = False
for ci in clipitems:
file_elem = ci.find('file')
if file_elem is not None:
pathurl = file_elem.find('pathurl')
if pathurl is not None and pathurl.text:
found_path = True
break
assert found_path, "At least one clipitem should have file/pathurl"
finally:
os.unlink(src)
os.unlink(out)
class TestNoTimelineError:
"""Export should fail cleanly when FCPXML has no timeline."""
NO_TL_XML = """
"""
def test_xmeml_raises_on_no_timeline(self):
src = _tmp(self.NO_TL_XML)
out = _out('.xml')
try:
exp = DaVinciExporter(src)
with pytest.raises(ValueError, match="No timeline"):
exp.export_xmeml(out)
finally:
os.unlink(src)
if os.path.exists(out):
os.unlink(out)
class TestDoctypeInjection:
"""serialize_xml should inject DOCTYPE correctly."""
def test_doctype_inserted(self):
root = ET.Element('fcpxml')
root.set('version', '1.9')
out = _out()
try:
serialize_xml(root, out, '')
with open(out) as f:
content = f.read()
assert '' in content
assert '' in content
finally:
os.unlink(out)
def test_no_doctype(self):
root = ET.Element('test')
out = _out()
try:
serialize_xml(root, out, '')
with open(out) as f:
content = f.read()
assert '