from __future__ import annotations import uuid from types import SimpleNamespace from unittest.mock import AsyncMock import pytest from cpv3.modules.project_workspaces.schemas import ( ActiveJobState, ProjectWorkspaceState, SetSourceFileAction, SilenceSettingsState, SilenceState, StartSilenceDetectAction, StartTranscriptionAction, TranscriptionRequestState, ) from cpv3.modules.project_workspaces.service import ( ProjectWorkspaceService, WorkspaceRevisionConflictError, ) @pytest.mark.asyncio async def test_get_workspace_returns_default_state_when_workspace_missing() -> None: project = SimpleNamespace(id=uuid.uuid4(), workspace_state=None) service = ProjectWorkspaceService(session=AsyncMock()) service._repo = SimpleNamespace( get_by_project_id=AsyncMock(return_value=None), create=AsyncMock( return_value=SimpleNamespace( project_id=project.id, revision=0, state=ProjectWorkspaceState().model_dump(mode="json"), ) ), ) workspace = await service.get_workspace(project=project) assert workspace.revision == 0 assert workspace.phase == "INGEST" assert workspace.current_screen == "upload" assert workspace.active_job is None assert workspace.source_file_id is None assert workspace.workspace_view.used_file_ids == [] assert workspace.workspace_view.selected_file_id is None @pytest.mark.asyncio async def test_apply_action_set_source_file_moves_workspace_to_verify() -> None: project = SimpleNamespace(id=uuid.uuid4(), workspace_state=None) requester = SimpleNamespace(id=uuid.uuid4(), is_staff=False) file_id = uuid.uuid4() workspace_row = SimpleNamespace( project_id=project.id, revision=0, state=ProjectWorkspaceState().model_dump(mode="json"), ) saved_state: dict[str, object] = {} async def update_state(*, project_id, expected_revision, state): saved_state.update(state) return SimpleNamespace( project_id=project_id, revision=expected_revision + 1, state=state, ) service = ProjectWorkspaceService(session=AsyncMock()) service._repo = SimpleNamespace( get_by_project_id=AsyncMock(return_value=workspace_row), create=AsyncMock(), update_state=AsyncMock(side_effect=update_state), ) service._file_repo = SimpleNamespace( get_by_id=AsyncMock( return_value=SimpleNamespace( id=file_id, owner_id=requester.id, project_id=project.id, path="users/test/source.mp4", ) ) ) workspace = await service.apply_action( requester=requester, project=project, action=SetSourceFileAction( type="SET_SOURCE_FILE", revision=0, file_id=file_id, ), ) assert workspace.revision == 1 assert workspace.phase == "VERIFY" assert workspace.current_screen == "verify" assert workspace.source_file_id == file_id assert saved_state["source_file_id"] == str(file_id) assert saved_state["workspace_view"] == { "used_file_ids": [str(file_id)], "selected_file_id": str(file_id), } @pytest.mark.asyncio async def test_apply_action_rejects_stale_revision() -> None: project = SimpleNamespace(id=uuid.uuid4(), workspace_state=None) workspace_row = SimpleNamespace( project_id=project.id, revision=2, state=ProjectWorkspaceState().model_dump(mode="json"), ) service = ProjectWorkspaceService(session=AsyncMock()) service._repo = SimpleNamespace( get_by_project_id=AsyncMock(return_value=workspace_row), create=AsyncMock(), update_state=AsyncMock(), ) with pytest.raises(WorkspaceRevisionConflictError): await service.apply_action( requester=SimpleNamespace(id=uuid.uuid4(), is_staff=False), project=project, action=SetSourceFileAction( type="SET_SOURCE_FILE", revision=1, file_id=uuid.uuid4(), ), ) @pytest.mark.asyncio async def test_start_silence_detect_submits_task_and_tracks_active_job() -> None: project = SimpleNamespace(id=uuid.uuid4(), workspace_state=None) requester = SimpleNamespace(id=uuid.uuid4(), is_staff=False) source_file_id = uuid.uuid4() workspace_state = ProjectWorkspaceState( phase="SILENCE", source_file_id=source_file_id, silence=SilenceState( status="CONFIGURED", settings=SilenceSettingsState( min_silence_duration_ms=250, silence_threshold_db=18, padding_ms=125, ), ), ) workspace_row = SimpleNamespace( project_id=project.id, revision=0, state=workspace_state.model_dump(mode="json"), ) submitted_response = SimpleNamespace(job_id=uuid.uuid4(), status="PENDING") async def update_state(*, project_id, expected_revision, state): return SimpleNamespace( project_id=project_id, revision=expected_revision + 1, state=state, ) task_service = SimpleNamespace( submit_silence_detect=AsyncMock(return_value=submitted_response), ) service = ProjectWorkspaceService(session=AsyncMock()) service._repo = SimpleNamespace( get_by_project_id=AsyncMock(return_value=workspace_row), create=AsyncMock(), update_state=AsyncMock(side_effect=update_state), ) service._file_repo = SimpleNamespace( get_by_id=AsyncMock( return_value=SimpleNamespace( id=source_file_id, owner_id=requester.id, project_id=project.id, path="projects/test/video.mp4", ) ) ) service._task_service_factory = lambda: task_service workspace = await service.apply_action( requester=requester, project=project, action=StartSilenceDetectAction( type="START_SILENCE_DETECT", revision=0, ), ) task_service.submit_silence_detect.assert_awaited_once() assert workspace.current_screen == "processing" assert workspace.active_job == ActiveJobState( job_id=submitted_response.job_id, job_type="SILENCE_DETECT", ) assert workspace.silence.detect_job_id == submitted_response.job_id @pytest.mark.asyncio async def test_start_transcription_persists_request_and_processing_job() -> None: project = SimpleNamespace(id=uuid.uuid4(), workspace_state=None) requester = SimpleNamespace(id=uuid.uuid4(), is_staff=False) source_file_id = uuid.uuid4() workspace_state = ProjectWorkspaceState( phase="TRANSCRIPTION", source_file_id=source_file_id, ) workspace_row = SimpleNamespace( project_id=project.id, revision=3, state=workspace_state.model_dump(mode="json"), ) submitted_response = SimpleNamespace(job_id=uuid.uuid4(), status="PENDING") async def update_state(*, project_id, expected_revision, state): return SimpleNamespace( project_id=project_id, revision=expected_revision + 1, state=state, ) task_service = SimpleNamespace( submit_transcription_generate=AsyncMock(return_value=submitted_response), ) service = ProjectWorkspaceService(session=AsyncMock()) service._repo = SimpleNamespace( get_by_project_id=AsyncMock(return_value=workspace_row), create=AsyncMock(), update_state=AsyncMock(side_effect=update_state), ) service._file_repo = SimpleNamespace( get_by_id=AsyncMock( return_value=SimpleNamespace( id=source_file_id, owner_id=requester.id, project_id=project.id, path="projects/test/video.mp4", ) ) ) service._task_service_factory = lambda: task_service request = TranscriptionRequestState(engine="whisper", language="ru", model="base") workspace = await service.apply_action( requester=requester, project=project, action=StartTranscriptionAction( type="START_TRANSCRIPTION", revision=3, request=request, ), ) assert workspace.current_screen == "transcription-processing" assert workspace.transcription.request == request assert workspace.active_job == ActiveJobState( job_id=submitted_response.job_id, job_type="TRANSCRIPTION_GENERATE", ) @pytest.mark.asyncio async def test_apply_job_update_moves_transcription_job_to_review() -> None: project = SimpleNamespace(id=uuid.uuid4(), workspace_state=None) job_id = uuid.uuid4() transcription_id = uuid.uuid4() artifact_id = uuid.uuid4() workspace_state = ProjectWorkspaceState( phase="TRANSCRIPTION", active_job=ActiveJobState(job_id=job_id, job_type="TRANSCRIPTION_GENERATE"), transcription={ "status": "PROCESSING", "job_id": job_id, "artifact_id": None, "transcription_id": None, "reviewed": False, }, ) workspace_row = SimpleNamespace( project_id=project.id, revision=4, state=workspace_state.model_dump(mode="json"), ) async def update_state(*, project_id, expected_revision, state): return SimpleNamespace( project_id=project_id, revision=expected_revision + 1, state=state, ) service = ProjectWorkspaceService(session=AsyncMock()) service._repo = SimpleNamespace( get_by_project_id=AsyncMock(return_value=workspace_row), create=AsyncMock(), update_state=AsyncMock(side_effect=update_state), ) workspace = await service.apply_job_update( project=project, job=SimpleNamespace( id=job_id, project_id=project.id, job_type="TRANSCRIPTION_GENERATE", status="DONE", output_data={ "transcription_id": str(transcription_id), "artifact_id": str(artifact_id), }, ), ) assert workspace is not None assert workspace.revision == 5 assert workspace.phase == "TRANSCRIPTION" assert workspace.current_screen == "subtitle-revision" assert workspace.active_job is None assert workspace.transcription.transcription_id == transcription_id assert workspace.transcription.artifact_id == artifact_id assert workspace.transcription.reviewed is False