228 lines
8.6 KiB
Python
228 lines
8.6 KiB
Python
"""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
|