"""Tests for the SwiftUI JSON bridge commands (admin/api/). Focused on the transcription flow: atomic save, speaker renaming, and the "use the selected model" default plus the model-availability guard. Patch targets follow one rule: replace a name **in the module that uses it**. `shared.emit` is the exception that proves it — the command modules call it as `shared.emit(...)` rather than binding the name locally, precisely so that one patch keeps capturing the output of all of them. """ import json import sys from pathlib import Path # `admin/` lives outside `code/`, which is pytest's rootdir — without the repo # root on the path this module is invisible and the whole file silently stops # being collected. It spent its life outside `testpaths` for exactly that # reason, so keep the insert next to the import that needs it. _REPO_ROOT = Path(__file__).resolve().parent.parent.parent if str(_REPO_ROOT) not in sys.path: sys.path.insert(0, str(_REPO_ROOT)) from admin.api import models, shared, subtitles, transcription # noqa: E402 def _capture(monkeypatch): captured: list[dict] = [] def _emit(obj): captured.append(obj) monkeypatch.setattr(shared, "emit", _emit) return captured def test_save_json_atomic(tmp_path): p = tmp_path / "t.json" shared._save_json_atomic(p, {"a": [1, 2], "text": "olá"}) assert p.exists() assert not (tmp_path / "t.json.tmp").exists() assert json.loads(p.read_text(encoding="utf-8"))["text"] == "olá" def test_rename_speakers(tmp_path, monkeypatch): captured = _capture(monkeypatch) p = tmp_path / "t.json" p.write_text( json.dumps( { "speakers": [ {"id": "SPEAKER_00", "name": "Speaker 1"}, {"id": "SPEAKER_01", "name": "Speaker 2"}, ] } ), encoding="utf-8", ) assert transcription.cmd_rename_speakers({"path": str(p), "speakers": {"SPEAKER_01": "Erika"}}) == 0 assert captured[0]["ok"] is True saved = json.loads(p.read_text(encoding="utf-8")) assert saved["speakers"][0]["name"] == "Speaker 1" assert saved["speakers"][1]["name"] == "Erika" def test_rename_speakers_missing_file(monkeypatch): captured = _capture(monkeypatch) assert transcription.cmd_rename_speakers({"path": "/nonexistent/x.json"}) == 1 assert captured[0]["type"] == "error" def test_transcribe_requires_output_dir(monkeypatch): captured = _capture(monkeypatch) monkeypatch.setattr(transcription, "load_selected_model", lambda: "small") monkeypatch.setattr(transcription, "is_model_downloaded", lambda m: True) assert transcription.cmd_transcribe({"path": "/some/project.fcpxml"}) == 1 assert captured[0]["type"] == "error" assert "pasta do projeto" in captured[0]["message"] def test_transcribe_requires_installed_model(monkeypatch, tmp_path): captured = _capture(monkeypatch) monkeypatch.setattr(transcription, "load_selected_model", lambda: "") monkeypatch.setattr(transcription, "is_model_downloaded", lambda m: False) assert transcription.cmd_transcribe({"path": "/some/project.fcpxml", "output_dir": str(tmp_path)}) == 1 assert captured[0]["type"] == "error" assert "instalado" in captured[0]["message"] def test_transcribe_defaults_to_selected_model(monkeypatch, tmp_path): captured = _capture(monkeypatch) monkeypatch.setattr(transcription, "load_selected_model", lambda: "small") monkeypatch.setattr(transcription, "is_model_downloaded", lambda m: m == "small") class FakeTL: clips = [] class FakeProject: primary_timeline = None timelines = [FakeTL()] monkeypatch.setattr(transcription, "parse_fcpxml", lambda p: FakeProject()) # No media accessible -> reaches the media-path check (past model validation). assert transcription.cmd_transcribe({"path": "/some/project.fcpxml", "output_dir": str(tmp_path)}) == 1 assert captured[0]["type"] == "error" assert "mídia" in captured[0]["message"] def test_set_language_persists(monkeypatch): captured = _capture(monkeypatch) assert models.cmd_set_language({"language": "pt"}) == 0 assert captured[0]["ok"] is True assert captured[0]["language"] == "pt" assert models.load_transcript_language() == "pt" def test_set_language_rejects_unknown(monkeypatch): captured = _capture(monkeypatch) assert models.cmd_set_language({"language": "xx"}) == 1 assert captured[0]["ok"] is False assert "language" in captured[0]["error"] def test_transcribe_defaults_language_to_persisted(monkeypatch, tmp_path): monkeypatch.setattr(transcription, "load_selected_model", lambda: "small") monkeypatch.setattr(transcription, "is_model_downloaded", lambda m: m == "small") monkeypatch.setattr(models, "load_transcript_language", lambda: "pt") media = tmp_path / "clip.mov" media.write_bytes(b"fake") class FakeClip: media_path = "" class FakeTL: clips = [FakeClip()] class FakeProject: primary_timeline = None timelines = [FakeTL()] monkeypatch.setattr(transcription, "parse_fcpxml", lambda p: FakeProject()) monkeypatch.setattr(transcription, "media_src_to_path", lambda mp: str(media)) called = {} monkeypatch.setattr( transcription, "transcribe", lambda mp, model_size, language, **kw: called.update(lang=language), ) assert transcription.cmd_transcribe({"path": "/some/project.fcpxml", "output_dir": str(tmp_path / "out")}) == 1 assert called["lang"] == "pt" def test_srt_stamp_format(): assert subtitles.srt_stamp(0.0) == "00:00:00,000" assert subtitles.srt_stamp(1.5) == "00:00:01,500" assert subtitles.srt_stamp(3661.234) == "01:01:01,234" _FCPXML_SAMPLE = """ """ def test_cmd_export_srt_maps_to_edited_timeline(tmp_path, monkeypatch): """Captions must reflect the EDITED timeline, not the whole source file.""" captured = _capture(monkeypatch) project = tmp_path / "proj.fcpxml" project.write_text(_FCPXML_SAMPLE, encoding="utf-8") media = tmp_path / "clip.mp4" media.write_bytes(b"fake") # Transcript covers 0..100s; the clip only USES source 10..20s -> timeline 0..10s. transcript = { "words": [], "segments": [ {"start": 5.0, "end": 6.0, "text": "antes do corte"}, {"start": 12.0, "end": 14.0, "text": "dentro do corte"}, {"start": 50.0, "end": 51.0, "text": "depois do corte"}, ] } tj = shared._transcript_json_path(media) tj.parent.mkdir(parents=True, exist_ok=True) shared._save_json_atomic(tj, transcript) monkeypatch.setattr(subtitles, "media_src_to_path", lambda src: str(media)) assert subtitles.cmd_export_srt({"path": str(project)}) == 0 assert captured[0]["ok"] is True srt = tmp_path / "clip_captions.srt" assert srt.exists() text = srt.read_text(encoding="utf-8") # Only the segment inside the used source window (12s) survives. assert "dentro do corte" in text assert "antes do corte" not in text assert "depois do corte" not in text # Mapped to timeline 0..10s -> the 12s source segment lands at 2s. assert "00:00:02,000 --> 00:00:04,000" in text def test_cmd_export_srt_no_transcript(tmp_path, monkeypatch): captured = _capture(monkeypatch) project = tmp_path / "proj.fcpxml" project.write_text(_FCPXML_SAMPLE, encoding="utf-8") media = tmp_path / "clip.mp4" media.write_bytes(b"fake") monkeypatch.setattr(subtitles, "media_src_to_path", lambda src: str(media)) assert subtitles.cmd_export_srt({"path": str(project)}) == 1 assert captured[0]["ok"] is False def test_cmd_export_srt_clamps_past_project_duration(tmp_path, monkeypatch): """A segment ending after the last clip must be clamped to the project end. Final Cut rejects an SRT whose final cue overruns the timeline ("subtitle extends beyond project duration"). """ captured = _capture(monkeypatch) project = tmp_path / "proj.fcpxml" project.write_text(_FCPXML_SAMPLE, encoding="utf-8") media = tmp_path / "clip.mp4" media.write_bytes(b"fake") # Clip uses source 10..20s -> timeline 0..10s. A segment 12..30s maps to # timeline 2..20s, but the project only lasts 10s: must clamp end to 10s. transcript = { "words": [], "segments": [ {"start": 12.0, "end": 30.0, "text": "longa fala"}, ] } tj = shared._transcript_json_path(media) tj.parent.mkdir(parents=True, exist_ok=True) shared._save_json_atomic(tj, transcript) monkeypatch.setattr(subtitles, "media_src_to_path", lambda src: str(media)) assert subtitles.cmd_export_srt({"path": str(project)}) == 0 assert captured[0]["ok"] is True srt = tmp_path / "clip_captions.srt" text = srt.read_text(encoding="utf-8") # Timeline is 10s; the cue must not end past it. assert "00:00:02,000 --> 00:00:10,000" in text assert "00:00:20,000" not in text