"""Tests for recently refactored helper functions.
Covers: _index_elements (writer), _iter_spine_clips (writer),
_find_spine_clip_at_seconds (writer), _resolve_clip_duration (writer),
_make_asset_clip (writer), _format_batch_result (server),
and serialize_xml edge cases (safe_xml).
These helpers were extracted in v0.6.30–0.6.52 to eliminate duplication.
Only tested indirectly through callers — these tests validate edge cases
the callers never exercise.
"""
import os
import sys
import tempfile
import types
import xml.etree.ElementTree as ET
from unittest.mock import MagicMock
import pytest
from fcpxml.safe_xml import serialize_xml
from fcpxml.writer import FCPXMLModifier
# ---------------------------------------------------------------------------
# Shim the `mcp` package tree before importing server.py (same as test_server)
# ---------------------------------------------------------------------------
_NEEDS_SHIM = "mcp" not in sys.modules or "mcp.server" not in sys.modules
def _install_mcp_shim():
mcp = types.ModuleType("mcp")
mcp_server = types.ModuleType("mcp.server")
mcp_server_stdio = types.ModuleType("mcp.server.stdio")
mcp_types = types.ModuleType("mcp.types")
class _FakeServer:
def __init__(self, *a, **kw): pass
def call_tool(self): return lambda fn: fn
def list_tools(self): return lambda fn: fn
def list_resources(self): return lambda fn: fn
def read_resource(self): return lambda fn: fn
def list_prompts(self): return lambda fn: fn
def get_prompt(self): return lambda fn: fn
mcp_server.Server = _FakeServer
class _FakeCtx:
def __init__(self, *a): pass
async def __aenter__(self): return (MagicMock(), MagicMock())
async def __aexit__(self, *a): pass
mcp_server_stdio.stdio_server = _FakeCtx
class TextContent:
def __init__(self, *, type: str, text: str):
self.type = type
self.text = text
for name in ("GetPromptResult", "Prompt", "PromptArgument",
"PromptMessage", "Resource", "Tool"):
setattr(mcp_types, name, MagicMock)
mcp_types.TextContent = TextContent
sys.modules.setdefault("mcp", mcp)
sys.modules.setdefault("mcp.server", mcp_server)
sys.modules.setdefault("mcp.server.stdio", mcp_server_stdio)
sys.modules.setdefault("mcp.types", mcp_types)
if _NEEDS_SHIM:
_install_mcp_shim()
from server import _format_batch_result, _markdown_table # noqa: E402, I001
# ---------------------------------------------------------------------------
# Minimal FCPXML fixtures
# ---------------------------------------------------------------------------
SPINE_3_CLIPS = """\
"""
EMPTY_SPINE = """\
"""
GAPS_ONLY_SPINE = """\
"""
DUPLICATE_NAMES = """\
"""
def _make_modifier(xml_str: str) -> FCPXMLModifier:
"""Create an FCPXMLModifier from an XML string via temp file."""
with tempfile.NamedTemporaryFile(mode='w', suffix='.fcpxml', delete=False) as f:
f.write(xml_str)
path = f.name
try:
return FCPXMLModifier(path)
finally:
os.unlink(path)
# ===================================================================
# _index_elements
# ===================================================================
class TestIndexElements:
"""Tests for FCPXMLModifier._index_elements."""
def test_indexes_clips_by_name(self):
mod = _make_modifier(SPINE_3_CLIPS)
assert "A" in mod.clips
assert "B" in mod.clips
def test_indexes_asset_clips(self):
mod = _make_modifier(SPINE_3_CLIPS)
assert "C" in mod.clips
def test_duplicate_names_last_wins(self):
"""When multiple clips share a name, the last one indexed wins."""
mod = _make_modifier(DUPLICATE_NAMES)
# Both are named "Interview" — only one key exists
clip = mod.clips["Interview"]
# The last one has offset="2400/2400s"
assert clip.get("offset") == "2400/2400s"
def test_fallback_prefix_when_no_id_or_name(self):
"""Elements without id or name get a generated key."""
xml = """\
"""
mod = _make_modifier(xml)
# No name/id → fallback key "clip_0"
assert "clip_0" in mod.clips
def test_id_preferred_over_name(self):
"""When both id and name exist, id takes priority."""
xml = """\
"""
mod = _make_modifier(xml)
assert "r42" in mod.clips
# ===================================================================
# _iter_spine_clips
# ===================================================================
class TestIterSpineClips:
"""Tests for FCPXMLModifier._iter_spine_clips."""
def test_returns_only_clip_elements(self):
mod = _make_modifier(SPINE_3_CLIPS)
clips = mod._iter_spine_clips()
tags = [elem.tag for _, elem in clips]
assert "gap" not in tags
assert tags == ["clip", "clip", "asset-clip"]
def test_preserves_spine_index(self):
"""Indices should be positions among ALL spine children, not just clips."""
mod = _make_modifier(SPINE_3_CLIPS)
clips = mod._iter_spine_clips()
indices = [i for i, _ in clips]
# spine has: clip(0), gap(1), clip(2), asset-clip(3)
assert indices == [0, 2, 3]
def test_empty_spine_returns_empty(self):
mod = _make_modifier(EMPTY_SPINE)
assert mod._iter_spine_clips() == []
def test_gaps_only_spine_returns_empty(self):
mod = _make_modifier(GAPS_ONLY_SPINE)
assert mod._iter_spine_clips() == []
# ===================================================================
# _find_spine_clip_at_seconds
# ===================================================================
class TestFindSpineClipAtSeconds:
"""Tests for FCPXMLModifier._find_spine_clip_at_seconds."""
def test_finds_first_clip(self):
mod = _make_modifier(SPINE_3_CLIPS)
clip, rel = mod._find_spine_clip_at_seconds(0.5)
assert clip.get("name") == "A"
assert abs(rel - 0.5) < 0.001
def test_finds_second_clip(self):
mod = _make_modifier(SPINE_3_CLIPS)
# B starts at 3600/2400 = 1.5s
clip, rel = mod._find_spine_clip_at_seconds(2.0)
assert clip.get("name") == "B"
assert abs(rel - 0.5) < 0.001
def test_exact_clip_boundary(self):
"""Requesting exactly at clip start should find that clip."""
mod = _make_modifier(SPINE_3_CLIPS)
clip, rel = mod._find_spine_clip_at_seconds(0.0)
assert clip.get("name") == "A"
assert abs(rel) < 0.001
def test_raises_past_end(self):
mod = _make_modifier(SPINE_3_CLIPS)
with pytest.raises(ValueError, match="No spine clip"):
mod._find_spine_clip_at_seconds(999.0)
def test_raises_in_gap(self):
"""Position inside a gap (not a clip) should raise."""
mod = _make_modifier(SPINE_3_CLIPS)
# gap is at 2400/2400s=1.0s, dur 1200/2400s=0.5s → gap covers 1.0–1.5s
with pytest.raises(ValueError, match="No spine clip"):
mod._find_spine_clip_at_seconds(1.25)
def test_raises_on_empty_spine(self):
mod = _make_modifier(EMPTY_SPINE)
with pytest.raises(ValueError, match="No spine clip"):
mod._find_spine_clip_at_seconds(0.0)
# ===================================================================
# _resolve_clip_duration & _make_asset_clip (v0.6.52)
# ===================================================================
ASSET_SPINE = """\
"""
from fcpxml.models import TimeValue # noqa: E402
class TestResolveClipDuration:
"""Tests for FCPXMLModifier._resolve_clip_duration."""
def test_uses_in_out_points(self):
mod = _make_modifier(ASSET_SPINE)
asset = mod.resources['r2']
dur, start = mod._resolve_clip_duration(
asset, in_point="1200/2400s", out_point="3600/2400s",
)
assert dur == TimeValue(2400, 2400)
assert start == TimeValue(1200, 2400)
def test_explicit_duration_overrides_asset(self):
mod = _make_modifier(ASSET_SPINE)
asset = mod.resources['r2']
dur, start = mod._resolve_clip_duration(asset, duration="1200/2400s")
assert dur == TimeValue(1200, 2400)
assert start == TimeValue.zero()
def test_falls_back_to_asset_duration(self):
mod = _make_modifier(ASSET_SPINE)
asset = mod.resources['r2']
dur, start = mod._resolve_clip_duration(asset)
assert dur == TimeValue(4800, 2400)
assert start == TimeValue.zero()
def test_in_out_takes_priority_over_duration(self):
"""When both in/out and duration are given, in/out wins."""
mod = _make_modifier(ASSET_SPINE)
asset = mod.resources['r2']
dur, start = mod._resolve_clip_duration(
asset, duration="600/2400s",
in_point="0s", out_point="2400/2400s",
)
# in/out range = 2400/2400s, not the explicit 600/2400s
assert dur == TimeValue(2400, 2400)
class TestMakeAssetClip:
"""Tests for FCPXMLModifier._make_asset_clip."""
def test_creates_detached_element(self):
mod = _make_modifier(ASSET_SPINE)
dur = TimeValue(2400, 2400)
elem = mod._make_asset_clip(
'r2', 'TestClip',
TimeValue(0, 2400), TimeValue(0, 2400), dur,
)
assert elem.tag == 'asset-clip'
assert elem.get('ref') == 'r2'
assert elem.get('name') == 'TestClip'
assert elem.get('duration') == dur.to_fcpxml()
def test_creates_subelement_when_parent_given(self):
mod = _make_modifier(ASSET_SPINE)
parent = ET.Element('clip')
dur = TimeValue(1200, 2400)
elem = mod._make_asset_clip(
'r2', 'Child',
TimeValue(0, 2400), TimeValue(0, 2400), dur,
parent=parent,
)
assert elem in list(parent)
assert elem.get('name') == 'Child'
assert elem.get('duration') == dur.to_fcpxml()
def test_extra_attrs_applied(self):
mod = _make_modifier(ASSET_SPINE)
elem = mod._make_asset_clip(
'r2', 'Audio',
TimeValue(0, 2400), TimeValue(0, 2400), TimeValue(2400, 2400),
lane='-1', audioRole='music',
)
assert elem.get('lane') == '-1'
assert elem.get('audioRole') == 'music'
def test_format_attr_passthrough(self):
mod = _make_modifier(ASSET_SPINE)
elem = mod._make_asset_clip(
'r2', 'Formatted',
TimeValue(0, 2400), TimeValue(0, 2400), TimeValue(2400, 2400),
format='r1',
)
assert elem.get('format') == 'r1'
# ===================================================================
# _format_batch_result (server.py)
# ===================================================================
class TestFormatBatchResult:
"""Tests for server._format_batch_result."""
def test_basic_structure(self):
result = _format_batch_result(
title="Flash Frames Fixed",
summary={"Fixed": "3", "Skipped": "1"},
headers=["Clip", "Action"],
rows=[["A", "Removed"], ["B", "Trimmed"]],
output_path="/tmp/out.fcpxml",
)
assert "# Flash Frames Fixed" in result
assert "## Summary" in result
assert "- **Fixed**: 3" in result
assert "## Details" in result
assert "| Clip | Action |" in result
assert "Saved to: `/tmp/out.fcpxml`" in result
def test_empty_rows(self):
result = _format_batch_result(
title="Nothing Done",
summary={"Total": "0"},
headers=["Name"],
rows=[],
output_path="/tmp/empty.fcpxml",
)
assert "# Nothing Done" in result
assert "| Name |" in result
# No data rows but header and separator still present
assert "Saved to:" in result
def test_markdown_table_alignment(self):
table = _markdown_table(["A", "B"], [["1", "2"], ["3", "4"]])
lines = table.strip().split("\n")
assert len(lines) == 4 # header + sep + 2 data rows
assert "---" in lines[1]
# ===================================================================
# serialize_xml edge cases (safe_xml.py)
# ===================================================================
class TestSerializeXml:
"""Tests for safe_xml.serialize_xml edge cases."""
def test_no_doctype(self):
root = ET.Element("root")
ET.SubElement(root, "child").text = "hello"
with tempfile.NamedTemporaryFile(suffix='.xml', delete=False) as f:
path = f.name
try:
serialize_xml(root, path)
with open(path) as f:
content = f.read()
assert '' in content
assert "hello" in content
finally:
os.unlink(path)
def test_with_doctype(self):
root = ET.Element("fcpxml")
with tempfile.NamedTemporaryFile(suffix='.fcpxml', delete=False) as f:
path = f.name
try:
serialize_xml(root, path, doctype="")
with open(path) as f:
content = f.read()
assert "" in content
assert content.index(" content.index("