"""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 subprocess 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 _MODELS_API = _REPO_ROOT / "admin" / "models_api.py" class TestCodeDirResolution: """Pins the sys.path computation itself, independent of the environment. `code/` has an editable install (`__editable__.fcp_mcp_server*.pth`) that makes `fcpxml`/`server_tools` importable regardless of this calculation — which is exactly why TestSubprocessEntryPoint below can't be trusted alone to catch a regression here: it passes in an environment with the editable install even when the path math is wrong. The app's real subprocess call can fall back to a plain `python3` with no such install (see PythonBridge.swift), where this calculation is the only thing that puts `code/` on sys.path — so pin the math directly, not just its usual workaround. """ def test_importing_the_package_puts_a_real_code_dir_on_sys_path(self): """Checks the observable effect (what `admin.api`'s own `__init__.py` puts on sys.path), with the editable install that normally masks this stripped out of `sys.path` first — otherwise `server_tools` imports fine regardless of whether the path math is right, and the assertion proves nothing. A fresh subprocess is required too: importing `admin.api` in-process reuses whatever `sys.modules` already cached from an earlier test in this file, silently skipping `__init__.py` the second time around. """ script = ( "import sys\n" "sys.path = [p for p in sys.path if 'site-packages' not in p]\n" "import admin.api\n" "import server_tools\n" # only resolvable if __init__.py did its job "print('OK')\n" ) result = subprocess.run( [sys.executable, "-c", script], capture_output=True, text=True, timeout=15, cwd=str(_REPO_ROOT), ) assert result.returncode == 0 and "OK" in result.stdout, result.stderr class TestSubprocessEntryPoint: """Runs models_api.py as the real subprocess the app launches. Every other test in this file imports admin.api directly, which resolves `code/` onto sys.path a completely different way than a fresh Python process does. That gap let a real bug through: `admin/api/shared.py` sits one directory deeper than the old single-file `admin/models_api.py`, so its `Path(__file__).resolve().parent.parent / "code"` pointed at `admin/code` (nonexistent) instead of `code/` — `server_tools` (which lives in `code/`) was then unimportable the moment a handler tried `from server import ...`. Every in-process test still passed, because none of them launch a real subprocess with a real `__file__`. """ def _run(self, command: str, args: dict | None = None) -> dict: argv = [sys.executable, str(_MODELS_API), command] if args is not None: argv.append(json.dumps(args)) result = subprocess.run( argv, capture_output=True, text=True, timeout=30, cwd=str(_REPO_ROOT / "code") ) assert result.returncode in (0, 1), ( f"crashed (exit {result.returncode}):\n{result.stderr}" ) return json.loads(result.stdout.strip().splitlines()[-1]) def test_simple_command_runs_as_a_real_subprocess(self): assert self._run("acoustics_capability")["ok"] is True def test_command_that_imports_server_tools_runs_as_a_real_subprocess(self): """The exact failure mode: a handler reaching into `server` (and transitively `server_tools`) from a process where `code/` was never actually on sys.path.""" result = self._run("analyze_voice", {"path": "/nonexistent/project.fcpxml"}) # The path is bogus on purpose — the point is that it FAILS on a # missing project, not on `ModuleNotFoundError: server_tools`. assert result["ok"] is False assert "server_tools" not in result.get("error", "") assert "ModuleNotFoundError" not in result.get("error", "") 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