"""Tests for layering subtitle generation by emphasis (etapa 5 review). Covers the pure helpers in server_tools/subtitles.py that decide which words also get the dynamic treatment on top of the always-complete plain track, and which clip-relative windows a plain title must be hidden (enabled="0") under. """ import json from pathlib import Path from server_tools.subtitles import ( _load_emphasis_spans, _overlaps_any_span, _phrase_actions_path, _segments_in_spans, _word_in_spans, _words_in_spans, ) SPANS = [ {"start": 2.0, "end": 10.7, "level": 1, "text": "abertura"}, {"start": 127.7, "end": 135.0, "level": 2, "text": "mastopexia"}, ] def test_phrase_actions_path_matches_media_stem(): assert _phrase_actions_path("/x/y/0E6A8290.mp4") == Path( "/x/y/0E6A8290_phrase_actions.json" ) class TestLoadEmphasisSpans: def test_missing_file_returns_empty(self, tmp_path): media = tmp_path / "clip.mp4" media.write_bytes(b"") assert _load_emphasis_spans(str(media)) == [] def test_reads_spans_from_sibling_json(self, tmp_path): media = tmp_path / "clip.mp4" media.write_bytes(b"") actions_path = tmp_path / "clip_phrase_actions.json" actions_path.write_text( json.dumps({"source": "clip.mp4", "actions": [], "emphasis_spans": SPANS}), encoding="utf-8", ) assert _load_emphasis_spans(str(media)) == SPANS def test_corrupt_json_returns_empty(self, tmp_path): media = tmp_path / "clip.mp4" media.write_bytes(b"") (tmp_path / "clip_phrase_actions.json").write_text("{not json", encoding="utf-8") assert _load_emphasis_spans(str(media)) == [] def test_missing_emphasis_spans_key_returns_empty(self, tmp_path): media = tmp_path / "clip.mp4" media.write_bytes(b"") (tmp_path / "clip_phrase_actions.json").write_text( json.dumps({"source": "clip.mp4", "actions": []}), encoding="utf-8" ) assert _load_emphasis_spans(str(media)) == [] class TestWordInSpans: def test_word_fully_inside_span(self): assert _word_in_spans(3.0, 3.4, SPANS) is True def test_word_fully_outside_every_span(self): assert _word_in_spans(50.0, 50.4, SPANS) is False def test_word_straddling_span_boundary_follows_its_midpoint(self): # midpoint 10.6 -> inside [2.0, 10.7) assert _word_in_spans(10.4, 10.8, SPANS) is True # midpoint 10.9 -> outside assert _word_in_spans(10.7, 11.1, SPANS) is False def test_span_end_is_exclusive(self): assert _word_in_spans(10.7, 10.7, SPANS) is False class TestWordsInSpans: WORDS = [ {"word": "Aquela", "start": 2.03, "end": 2.69}, {"word": "mama", "start": 2.69, "end": 2.89}, {"word": "fora", "start": 50.0, "end": 50.2}, {"word": "mastopexia", "start": 127.74, "end": 128.58}, ] def test_no_spans_returns_nothing(self): assert _words_in_spans(self.WORDS, []) == [] def test_keeps_only_words_inside_a_span(self): kept = _words_in_spans(self.WORDS, SPANS) assert [w["word"] for w in kept] == ["Aquela", "mama", "mastopexia"] def test_does_not_remove_words_from_the_source_list(self): """This is a filter for the dynamic half, not a partition — the plain half must still see every word, so this must never mutate `words`.""" before = list(self.WORDS) _words_in_spans(self.WORDS, SPANS) assert self.WORDS == before class TestOverlapsAnySpan: CLIP_SPANS = [(0.0, 8.64), (60.0, 67.22)] def test_window_inside_a_span_overlaps(self): assert _overlaps_any_span(1.0, 2.0, self.CLIP_SPANS) is True def test_window_outside_every_span_does_not_overlap(self): assert _overlaps_any_span(20.0, 21.0, self.CLIP_SPANS) is False def test_window_straddling_a_span_edge_overlaps(self): assert _overlaps_any_span(8.0, 9.0, self.CLIP_SPANS) is True def test_touching_but_not_overlapping_is_not_an_overlap(self): assert _overlaps_any_span(8.64, 9.0, self.CLIP_SPANS) is False def test_no_spans_never_overlaps(self): assert _overlaps_any_span(1.0, 2.0, []) is False class TestSegmentsInSpans: SEGMENTS = [ {"start": 2.0, "end": 10.67}, {"start": 40.0, "end": 43.0}, {"start": 127.74, "end": 134.96}, ] def test_no_spans_keeps_nothing(self): assert _segments_in_spans(self.SEGMENTS, []) == [] def test_keeps_only_segments_inside_a_span(self): kept = _segments_in_spans(self.SEGMENTS, SPANS) assert kept == [self.SEGMENTS[0], self.SEGMENTS[2]]