"""Tests for fcpxml/voice_actions.py — the decision contract. Pure functions over untrusted input (a model's decision list), so these cover the rejection paths as carefully as the happy path. """ import pytest from fcpxml.voice_actions import ( MAX_TEXT_LENGTH, VoiceAction, merge_cut_ranges, parse_actions, resolve_actions, shift_after_cuts, ) class TestParseActions: def test_accepts_bare_list(self): actions, errors = parse_actions([{"kind": "cut", "start": 1.0, "end": 2.0}]) assert len(actions) == 1 and errors == [] def test_accepts_actions_envelope(self): actions, errors = parse_actions({"actions": [{"kind": "cut", "start": 1.0, "end": 2.0}]}) assert len(actions) == 1 and errors == [] def test_rejects_non_list(self): actions, errors = parse_actions("cortar tudo") assert actions == [] and len(errors) == 1 def test_one_bad_row_does_not_discard_the_good_ones(self): actions, errors = parse_actions([ {"kind": "cut", "start": 1.0, "end": 2.0}, {"kind": "teleport", "start": 3.0, "end": 4.0}, {"kind": "zoom", "start": 5.0, "end": 6.0}, ]) assert len(actions) == 2 assert len(errors) == 1 and "teleport" in errors[0] def test_rejects_unknown_kind(self): _, errors = parse_actions([{"kind": "explode", "start": 0.0, "end": 1.0}]) assert "explode" in errors[0] def test_rejects_non_numeric_times(self): _, errors = parse_actions([{"kind": "cut", "start": "início", "end": 2.0}]) assert "numbers" in errors[0] def test_rejects_negative_start(self): _, errors = parse_actions([{"kind": "cut", "start": -1.0, "end": 2.0}]) assert "negative" in errors[0] def test_rejects_end_before_start(self): _, errors = parse_actions([{"kind": "cut", "start": 5.0, "end": 2.0}]) assert "must be after" in errors[0] def test_rejects_zero_length(self): _, errors = parse_actions([{"kind": "cut", "start": 2.0, "end": 2.0}]) assert errors def test_rejects_row_that_is_not_an_object(self): _, errors = parse_actions(["cortar aos 5s"]) assert "expected an object" in errors[0] def test_kind_is_case_insensitive(self): actions, _ = parse_actions([{"kind": "ZOOM", "start": 1.0, "end": 2.0}]) assert actions[0].kind == "zoom" def test_preserves_reason_and_speaker(self): actions, _ = parse_actions([ {"kind": "zoom", "start": 1.0, "end": 2.0, "reason": "argumento central", "speaker": "SPEAKER_01"} ]) assert actions[0].reason == "argumento central" assert actions[0].speaker == "SPEAKER_01" class TestZoomValidation: def test_default_scale_when_absent(self): actions, _ = parse_actions([{"kind": "zoom", "start": 1.0, "end": 2.0}]) assert actions[0].params["scale"] == 1.3 def test_rejects_scale_below_one(self): _, errors = parse_actions([ {"kind": "zoom", "start": 1.0, "end": 2.0, "params": {"scale": 0.5}} ]) assert "outside" in errors[0] def test_rejects_absurd_scale(self): _, errors = parse_actions([ {"kind": "zoom", "start": 1.0, "end": 2.0, "params": {"scale": 50}} ]) assert "outside" in errors[0] def test_rejects_non_numeric_scale(self): _, errors = parse_actions([ {"kind": "zoom", "start": 1.0, "end": 2.0, "params": {"scale": "muito"}} ]) assert "must be a number" in errors[0] class TestTextValidation: def test_requires_content(self): _, errors = parse_actions([{"kind": "text", "start": 1.0, "end": 2.0}]) assert "params.content" in errors[0] def test_rejects_blank_content(self): _, errors = parse_actions([ {"kind": "text", "start": 1.0, "end": 2.0, "params": {"content": " "}} ]) assert "params.content" in errors[0] def test_truncates_overlong_content(self): actions, _ = parse_actions([ {"kind": "text", "start": 1.0, "end": 2.0, "params": {"content": "A" * 500}} ]) assert len(actions[0].params["content"]) == MAX_TEXT_LENGTH class TestMergeCutRanges: def test_sorts_and_merges_overlaps(self): actions = [ VoiceAction("cut", 5.0, 7.0), VoiceAction("cut", 1.0, 3.0), VoiceAction("cut", 2.0, 4.0), ] assert merge_cut_ranges(actions) == [(1.0, 4.0), (5.0, 7.0)] def test_merges_touching_ranges(self): actions = [VoiceAction("cut", 1.0, 2.0), VoiceAction("cut", 2.0, 3.0)] assert merge_cut_ranges(actions) == [(1.0, 3.0)] def test_ignores_non_cut_actions(self): assert merge_cut_ranges([VoiceAction("zoom", 1.0, 2.0)]) == [] class TestShiftAfterCuts: def test_time_before_any_cut_is_unchanged(self): assert shift_after_cuts(0.5, [(2.0, 4.0)]) == 0.5 def test_time_after_a_cut_moves_earlier(self): assert shift_after_cuts(6.0, [(2.0, 4.0)]) == pytest.approx(4.0) def test_time_inside_a_cut_is_dropped(self): assert shift_after_cuts(3.0, [(2.0, 4.0)]) is None def test_multiple_cuts_accumulate(self): cuts = [(1.0, 2.0), (5.0, 7.0)] assert shift_after_cuts(10.0, cuts) == pytest.approx(7.0) def test_no_cuts_is_identity(self): assert shift_after_cuts(3.0, []) == 3.0 def test_boundary_start_of_cut_is_inside(self): assert shift_after_cuts(2.0, [(2.0, 4.0)]) is None def test_boundary_end_of_cut_survives(self): assert shift_after_cuts(4.0, [(2.0, 4.0)]) == pytest.approx(2.0) class TestResolveActions: def test_zoom_after_a_cut_is_moved_earlier(self): actions = [VoiceAction("cut", 2.0, 4.0), VoiceAction("zoom", 6.0, 7.0)] cuts, placed, dropped = resolve_actions(actions) assert cuts == [(2.0, 4.0)] assert dropped == [] assert placed[0].start == pytest.approx(4.0) assert placed[0].end == pytest.approx(5.0) def test_zoom_inside_a_cut_is_dropped_not_slid(self): actions = [VoiceAction("cut", 2.0, 8.0), VoiceAction("zoom", 3.0, 4.0)] _, placed, dropped = resolve_actions(actions) assert placed == [] assert len(dropped) == 1 def test_zoom_straddling_a_cut_edge_is_dropped(self): actions = [VoiceAction("cut", 4.0, 8.0), VoiceAction("zoom", 3.0, 5.0)] _, placed, dropped = resolve_actions(actions) assert placed == [] and len(dropped) == 1 def test_cuts_are_not_returned_as_placed(self): _, placed, _ = resolve_actions([VoiceAction("cut", 1.0, 2.0)]) assert placed == [] def test_without_cuts_everything_keeps_its_time(self): actions = [VoiceAction("zoom", 3.0, 4.0), VoiceAction("text", 5.0, 6.0)] cuts, placed, dropped = resolve_actions(actions) assert cuts == [] and dropped == [] assert [(a.start, a.end) for a in placed] == [(3.0, 4.0), (5.0, 6.0)] def test_params_survive_the_shift(self): actions = [ VoiceAction("cut", 1.0, 2.0), VoiceAction("text", 5.0, 6.0, params={"content": "SEGURANÇA"}), ] _, placed, _ = resolve_actions(actions) assert placed[0].params["content"] == "SEGURANÇA" class TestMarkersSurviveCutEdges: """A marker is a point in time, not a span. The markers worth keeping are precisely the ones flagging a join, which sit against a cut edge — so requiring their nominal end to survive would drop exactly those.""" def test_marker_at_a_cut_edge_survives(self): actions = [VoiceAction("cut", 21.9, 127.6), VoiceAction("marker", 21.85, 22.0)] _, placed, dropped = resolve_actions(actions) assert dropped == [] assert placed[0].kind == "marker" assert placed[0].start == pytest.approx(21.85) def test_marker_inside_removed_material_is_still_dropped(self): actions = [VoiceAction("cut", 20.0, 100.0), VoiceAction("marker", 50.0, 50.2)] _, placed, dropped = resolve_actions(actions) assert placed == [] and len(dropped) == 1 def test_marker_keeps_its_length_after_shifting(self): actions = [VoiceAction("cut", 0.0, 10.0), VoiceAction("marker", 20.0, 20.5)] _, placed, _ = resolve_actions(actions) assert placed[0].start == pytest.approx(10.0) assert placed[0].duration == pytest.approx(0.5) def test_zoom_straddling_an_edge_is_still_dropped(self): """Only markers get the point-action treatment — a span must fit.""" actions = [VoiceAction("cut", 21.9, 127.6), VoiceAction("zoom", 21.0, 22.5)] _, placed, dropped = resolve_actions(actions) assert placed == [] and len(dropped) == 1