"""Тесты чистой функции реконструкции фраз `build_phrases` (ТЗ §1.3). Синтетические таймлайны без БД/сети — алгоритм тестируется в изоляции (TDD). """ import uuid from core.plugins.transcriber import Segment from workers.transcription.phrases import ( INTERJECTION_THRESHOLD_S, PhraseDraft, build_phrases, ) ALICE = uuid.uuid4() BOB = uuid.uuid4() def test_single_speaker_multiple_segments_merge_into_one_phrase() -> None: """Несколько сегментов одного спикера → одна фраза с границами min start / max end.""" segments = {ALICE: [Segment(0.0, 2.0, "a"), Segment(3.0, 5.0, "b")]} offsets = {ALICE: 0.0} result = build_phrases(segments, offsets) assert result == [PhraseDraft(participant_id=ALICE, start=0.0, end=5.0, text="a b")] def test_alice_bob_alice_example_from_spec() -> None: """Пример A–B–A из §1.3: три фразы в порядке t_start.""" segments = { ALICE: [Segment(0.0, 3.0, "alice1"), Segment(6.0, 9.0, "alice2")], BOB: [Segment(3.0, 6.0, "bob1")], } offsets = {ALICE: 0.0, BOB: 0.0} result = build_phrases(segments, offsets) assert result == [ PhraseDraft(participant_id=ALICE, start=0.0, end=3.0, text="alice1"), PhraseDraft(participant_id=BOB, start=3.0, end=6.0, text="bob1"), PhraseDraft(participant_id=ALICE, start=6.0, end=9.0, text="alice2"), ] def test_long_overlap_keeps_both_phrases_with_intersecting_intervals() -> None: """Перекрытие речи ≥ порога → сохраняются обе фразы с пересекающимися интервалами.""" segments = { ALICE: [Segment(0.0, 10.0, "alice-long")], BOB: [Segment(3.0, 8.0, "bob-overlap")], } offsets = {ALICE: 0.0, BOB: 0.0} assert 8.0 - 3.0 >= INTERJECTION_THRESHOLD_S result = build_phrases(segments, offsets) assert result == [ PhraseDraft(participant_id=ALICE, start=0.0, end=10.0, text="alice-long"), PhraseDraft(participant_id=BOB, start=3.0, end=8.0, text="bob-overlap"), ] # интервалы действительно пересекаются alice_phrase, bob_phrase = result assert bob_phrase.start < alice_phrase.end assert bob_phrase.end > alice_phrase.start def test_short_interjection_does_not_break_monologue_but_is_kept_separately() -> None: """Короткое «угу» (<1.5с) внутри чужого монолога: монолог одной фразой, вставка — отдельной.""" segments = { ALICE: [Segment(0.0, 2.0, "one"), Segment(2.5, 5.0, "two")], BOB: [Segment(2.1, 2.3, "угу")], } offsets = {ALICE: 0.0, BOB: 0.0} assert 2.3 - 2.1 < INTERJECTION_THRESHOLD_S result = build_phrases(segments, offsets) assert result == [ PhraseDraft(participant_id=ALICE, start=0.0, end=5.0, text="one two"), PhraseDraft(participant_id=BOB, start=2.1, end=2.3, text="угу"), ] def test_silence_gap_does_not_break_single_speaker_phrase() -> None: """Тишина (большой зазор) между сегментами одного спикера без чужой речи фразу не рвёт.""" segments = {ALICE: [Segment(0.0, 2.0, "a"), Segment(20.0, 22.0, "b")]} offsets = {ALICE: 0.0} result = build_phrases(segments, offsets) assert result == [PhraseDraft(participant_id=ALICE, start=0.0, end=22.0, text="a b")] def test_empty_input_returns_empty_list() -> None: """Пустой вход → пустой список фраз.""" assert build_phrases({}, {}) == [] def test_participant_with_no_segments_contributes_nothing() -> None: """Участник без сегментов не создаёт фраз, но не ломает построение остальных.""" segments = {ALICE: [Segment(0.0, 2.0, "a")], BOB: []} offsets = {ALICE: 0.0, BOB: 0.0} result = build_phrases(segments, offsets) assert result == [PhraseDraft(participant_id=ALICE, start=0.0, end=2.0, text="a")] def test_track_offsets_shift_timeline() -> None: """Смещения треков (`track_offsets`) корректно сдвигают таймлайн сегментов.""" segments = { ALICE: [Segment(0.0, 2.0, "alice-early-on-track")], BOB: [Segment(0.0, 2.0, "bob-early-on-track")], } # трек Боба стартовал позже (offset больше) → на общем таймлайне сеанса # его сегмент оказывается ПОСЛЕ сегмента Алисы, хотя на своих треках # оба сегмента начинались с нуля. offsets = {ALICE: 10.0, BOB: 20.0} result = build_phrases(segments, offsets) assert result == [ PhraseDraft(participant_id=ALICE, start=10.0, end=12.0, text="alice-early-on-track"), PhraseDraft(participant_id=BOB, start=20.0, end=22.0, text="bob-early-on-track"), ]