import json from pathlib import Path import pytest from voice_transcriptor.services.job_manifest import JobManifestError, JobManifestRepository def chunks() -> list[dict]: return [ { "index": 0, "path": "chunks/chunk-00000.m4a", "source_start_seconds": "0", "source_end_seconds": "900.125", "duration_seconds": "900.125", }, { "index": 1, "path": "chunks/chunk-00001.m4a", "source_start_seconds": "885.125", "source_end_seconds": "1000.5", "duration_seconds": "115.375", }, ] def create_manifest(tmp_path: Path) -> tuple[JobManifestRepository, Path]: job = tmp_path / "job" repository = JobManifestRepository() manifest = repository.create( job, source={"path": str(tmp_path / "source.mp3"), "size_bytes": 10}, settings={"model": "gpt-transcribe", "language": "pt-BR"}, chunks=chunks(), ) return repository, Path(manifest["manifest_path"]) def test_create_persists_schema_timing_and_pending_statuses(tmp_path: Path) -> None: repository, path = create_manifest(tmp_path) manifest = repository.load(path) assert manifest["schema_version"] == 2 assert manifest["state"] == "preprocessing" assert manifest["completed_chunks"] == 0 assert manifest["total_chunks"] == 2 assert [item["status"] for item in manifest["chunks"]] == ["pending", "pending"] assert manifest["chunks"][1]["source_start_seconds"] == "885.125" assert not path.with_suffix(".tmp").exists() def test_create_rejects_chunk_path_outside_job(tmp_path: Path) -> None: invalid = chunks() invalid[0]["path"] = "../secret.m4a" with pytest.raises(JobManifestError, match="chunk path"): JobManifestRepository().create(tmp_path / "job", {}, {}, invalid) def test_completed_chunk_is_written_immediately_and_cannot_be_reprocessed(tmp_path: Path) -> None: repository, path = create_manifest(tmp_path) repository.mark_processing(path, 0) repository.mark_completed(path, 0, "Primeiro trecho") on_disk = json.loads(path.read_text(encoding="utf-8")) assert on_disk["chunks"][0]["status"] == "completed" assert on_disk["chunks"][0]["transcript"] == "Primeiro trecho" assert on_disk["completed_chunks"] == 1 with pytest.raises(JobManifestError, match="completed"): repository.mark_processing(path, 0) def test_resume_recovers_processing_and_failed_but_preserves_completed(tmp_path: Path) -> None: repository, path = create_manifest(tmp_path) repository.mark_processing(path, 0) repository.mark_completed(path, 0, "Feito") repository.mark_processing(path, 1) repository.mark_failed(path, 1, "temporary failure") manifest = repository.recover_for_resume(path) assert manifest["chunks"][0]["status"] == "completed" assert manifest["chunks"][0]["transcript"] == "Feito" assert manifest["chunks"][1]["status"] == "pending" assert manifest["chunks"][1]["last_error"] is None def test_assemble_transcript_uses_only_completed_chunks_in_index_order(tmp_path: Path) -> None: repository, path = create_manifest(tmp_path) repository.mark_processing(path, 1) repository.mark_completed(path, 1, "Segundo") repository.mark_processing(path, 0) repository.mark_completed(path, 0, "Primeiro") transcript_path = repository.assemble_transcript(path) assert transcript_path.read_text(encoding="utf-8") == "Primeiro\nSegundo\n" assert repository.load(path)["chunks"][1]["source_end_seconds"] == "1000.5" def test_explicit_reset_is_required_to_clear_completed_work(tmp_path: Path) -> None: repository, path = create_manifest(tmp_path) repository.mark_processing(path, 0) repository.mark_completed(path, 0, "Feito") repository.reset_completed(path, indexes=[0]) item = repository.load(path)["chunks"][0] assert item["status"] == "pending" assert item["transcript"] is None