"""Tests for the refine_voice_timeline MCP tool. The tool exists because emphasis is *relative*: cut the loudest moment of a recording and every surviving score is still measured against something the viewer never sees. These tests pin the re-normalization actually happening, and the times staying in original source seconds so the result can be fed straight back to apply_voice_actions. """ import json import pytest from tests.test_voice_features_tool import _write_silent_wav from tests.test_voice_timeline_tool import _TRANSCRIPT, patched, wav # noqa: F401 _ = _write_silent_wav, _TRANSCRIPT # re-exported fixtures need the imports async def _build(wav_path): from server import handle_build_voice_timeline await handle_build_voice_timeline({"media_path": str(wav_path)}) @pytest.fixture def two_candidates(monkeypatch): """A transcript where BOTH lines carry a content word. The shared fixture's first line is "isso e" — two function words, which ``suggest_zoom_windows`` skips by design, so it can never produce more than one candidate to cap. """ import fcpxml.voice_timeline as vt import server_tools._shared.media as _shared_mod transcript = { "language": "pt", "duration": 4.0, "text": "cirurgia rapida seguranca total", "segments": [ {"text": "cirurgia rapida", "start": 0.0, "end": 1.0}, {"text": "seguranca total", "start": 2.0, "end": 4.0}, ], "words": [ {"word": "cirurgia", "start": 0.0, "end": 0.4, "confidence": 0.9}, {"word": "rapida", "start": 0.5, "end": 0.7, "confidence": 0.9}, {"word": "seguranca", "start": 2.0, "end": 2.9, "confidence": 0.9}, {"word": "total", "start": 3.0, "end": 3.5, "confidence": 0.9}, ], } monkeypatch.setattr(_shared_mod, "transcribe", lambda *a, **k: transcript) monkeypatch.setattr(vt, "extract_pitch", lambda *a, **k: [(2.4, 260.0), (0.2, 120.0)]) monkeypatch.setattr(vt, "extract_energy", lambda *a, **k: [(2.4, 0.95), (0.2, 0.10)]) class TestRefineVoiceTimelineHandler: async def test_requires_an_existing_timeline(self, wav): # noqa: F811 from server import handle_refine_voice_timeline result = await handle_refine_voice_timeline({"media_path": str(wav), "cuts": []}) assert "build_voice_timeline" in result[0].text async def test_rejects_disallowed_extension(self, tmp_path): from server import handle_refine_voice_timeline bad = tmp_path / "clip.txt" bad.write_text("not audio") with pytest.raises(ValueError): await handle_refine_voice_timeline({"media_path": str(bad), "cuts": []}) async def test_compares_raw_against_survivors(self, wav, patched): # noqa: F811 from server import handle_refine_voice_timeline await _build(wav) result = await handle_refine_voice_timeline( {"media_path": str(wav), "cuts": [{"start": 0.0, "end": 1.0}]} ) text = result[0].text assert "Survivors only" in text assert "Average emphasis" in text async def test_cut_words_are_excluded(self, wav, patched): # noqa: F811 from server import handle_refine_voice_timeline await _build(wav) result = await handle_refine_voice_timeline( {"media_path": str(wav), "cuts": [{"start": 0.0, "end": 1.0}], "save": True} ) assert "_voice_timeline_refined.json" in result[0].text data = json.loads( (wav.parent / "clip_voice_timeline_refined.json").read_text(encoding="utf-8") ) words = [w["text"] for s in data["segments"] for w in s["words"]] assert "isso" not in words assert "seguranca" in words async def test_times_stay_in_original_source_seconds(self, wav, patched): # noqa: F811 """A cut at the head must NOT slide the survivors back to zero.""" from server import handle_refine_voice_timeline await _build(wav) await handle_refine_voice_timeline( {"media_path": str(wav), "cuts": [{"start": 0.0, "end": 1.0}], "save": True} ) data = json.loads( (wav.parent / "clip_voice_timeline_refined.json").read_text(encoding="utf-8") ) first = data["segments"][0]["words"][0] assert first["start"] == pytest.approx(2.0) async def test_proposes_zoom_candidates(self, wav, patched): # noqa: F811 from server import handle_refine_voice_timeline await _build(wav) result = await handle_refine_voice_timeline({"media_path": str(wav), "cuts": []}) assert "Zoom Candidates" in result[0].text async def test_max_zooms_caps_the_list(self, wav, two_candidates): # noqa: F811 from server import handle_refine_voice_timeline await _build(wav) async def zoom_rows(**extra): result = await handle_refine_voice_timeline( {"media_path": str(wav), "cuts": [], "min_gap": 0.0, **extra} ) section = result[0].text.split("## Zoom Candidates", 1)[1] return [ ln for ln in section.splitlines() if ln.startswith("| ") and ln.rstrip().endswith("|") and "Start" not in ln ] assert len(await zoom_rows()) == 2 assert len(await zoom_rows(max_zooms=1)) == 1 async def test_malformed_cut_is_reported_not_raised(self, wav, patched): # noqa: F811 from server import handle_refine_voice_timeline await _build(wav) result = await handle_refine_voice_timeline( {"media_path": str(wav), "cuts": [{"start": 3.0, "end": 1.0}]} ) text = result[0].text assert "Rejected cuts" in text assert "must be after start" in text async def test_cutting_everything_says_so(self, wav, patched): # noqa: F811 from server import handle_refine_voice_timeline await _build(wav) result = await handle_refine_voice_timeline( {"media_path": str(wav), "cuts": [{"start": 0.0, "end": 60.0}]} ) assert "removed every word" in result[0].text class TestRefineVoiceTimelineRegistration: async def test_tool_is_listed(self): from server import list_tools assert "refine_voice_timeline" in {t.name for t in await list_tools()} async def test_tool_is_dispatched(self): from server import TOOL_HANDLERS, handle_refine_voice_timeline assert TOOL_HANDLERS["refine_voice_timeline"] is handle_refine_voice_timeline