diff --git a/.gitignore b/.gitignore index a452966..1790630 100644 --- a/.gitignore +++ b/.gitignore @@ -81,3 +81,4 @@ dmypy.json /.kilocode/ /data/whisper-models/ +/.qwen/ diff --git a/app/coding/domain/entities.py b/app/coding/domain/entities.py index 4ddc6ae..16a334a 100644 --- a/app/coding/domain/entities.py +++ b/app/coding/domain/entities.py @@ -357,7 +357,7 @@ def start_timer_for_task( def with_submit_test_summary( self, task_row_id: int, - summary: dict[str, Any], + summary: dict[str, Any] | None, *, source_code: str, ) -> CodingSection: diff --git a/app/coding/domain/value_objects.py b/app/coding/domain/value_objects.py index 0b306f7..a5267be 100644 --- a/app/coding/domain/value_objects.py +++ b/app/coding/domain/value_objects.py @@ -32,7 +32,7 @@ class PlannedCodingTask: @dataclass(frozen=True, slots=True) -class TestCaseRunResult: +class CaseRunResult: """Outcome of executing one public test case via Judge0. Attributes: @@ -77,5 +77,5 @@ class CodingRunResult: compile_output: str | None tests_passed: int tests_total: int - test_results: tuple[TestCaseRunResult, ...] + test_results: tuple[CaseRunResult, ...] duration_ms: int | None diff --git a/app/coding/services/evaluation_persistence.py b/app/coding/services/evaluation_persistence.py index 9183003..dad6790 100644 --- a/app/coding/services/evaluation_persistence.py +++ b/app/coding/services/evaluation_persistence.py @@ -121,11 +121,16 @@ def persist( raise CodingSectionNotFoundError(interview_id) section.ensure_active() + existing_task = section.find_task(task_id, round_num) updated = section.with_evaluation( task_id, round_num, evaluation.score, evaluation.feedback, + ).with_submit_test_summary( + existing_task.id, + submit_test_summary, + source_code=existing_task.submitted_code or submitted_source_code, ) follow_up_round: int | None = None if follow_up_needed: diff --git a/app/coding/services/run_execution.py b/app/coding/services/run_execution.py index 861f9da..5db035a 100644 --- a/app/coding/services/run_execution.py +++ b/app/coding/services/run_execution.py @@ -13,7 +13,7 @@ CodingRunLimitExceededError, CodingSectionNotFoundError, ) -from app.coding.domain.value_objects import CodingRunResult, TestCaseRunResult +from app.coding.domain.value_objects import CaseRunResult, CodingRunResult from app.coding.services.runner import CodingRunnerService from app.interview.domain.exceptions import InterviewNotFoundError from app.interview.repositories.uow import InterviewUnitOfWork @@ -50,7 +50,7 @@ def _ensure_interview_active(interview_id: str) -> None: aggregate.ensure_active() -def _serialize_test_result(result: TestCaseRunResult) -> dict[str, Any]: +def _serialize_test_result(result: CaseRunResult) -> dict[str, Any]: """Convert a domain test result into an API/persistence payload. Args: diff --git a/app/coding/services/runner.py b/app/coding/services/runner.py index 83a04d6..bed44ff 100644 --- a/app/coding/services/runner.py +++ b/app/coding/services/runner.py @@ -7,9 +7,9 @@ from typing import Any from app.coding.domain.value_objects import ( + CaseRunResult, CodingRunResult, RunOutcomeStatus, - TestCaseRunResult, ) from app.coding.services.harness import build_python_script from app.coding.services.judge0_client import ( @@ -109,7 +109,7 @@ async def run_public_tests( client=judge0, ) - results: list[TestCaseRunResult] = [] + results: list[CaseRunResult] = [] total_duration_ms = 0 last_stdout: str | None = None last_stderr: str | None = None @@ -218,7 +218,7 @@ def _case_result_from_submission( name: str, expected_stdout: str, submission: Judge0SubmissionResult, - ) -> TestCaseRunResult: + ) -> CaseRunResult: """Map one Judge0 submission to a public test case result. Args: @@ -234,7 +234,7 @@ def _case_result_from_submission( submission.status_id == JUDGE0_STATUS_ACCEPTED and actual_stdout == expected_stdout ) - return TestCaseRunResult( + return CaseRunResult( name=name, passed=passed, expected_stdout=expected_stdout, @@ -272,7 +272,7 @@ def _status_from_submission( return "tests_failed" @staticmethod - def _aggregate_status(results: list[TestCaseRunResult]) -> RunOutcomeStatus: + def _aggregate_status(results: list[CaseRunResult]) -> RunOutcomeStatus: """Derive the aggregate run status from per-test results. Args: diff --git a/app/interview/services/section_prefetch.py b/app/interview/services/section_prefetch.py index 1fab94d..83a701b 100644 --- a/app/interview/services/section_prefetch.py +++ b/app/interview/services/section_prefetch.py @@ -38,7 +38,7 @@ async def prefetch_section_feedback( try: provider = ConfigService.create_provider_from_config() - except ValueError: + except Exception: logger.warning( "Skipping %s section prefetch for %s: provider not configured", section_name, diff --git a/tests/ai/test_faster_whisper_transcriber.py b/tests/ai/test_faster_whisper_transcriber.py new file mode 100644 index 0000000..04d9f08 --- /dev/null +++ b/tests/ai/test_faster_whisper_transcriber.py @@ -0,0 +1,137 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for FasterWhisperTranscriber.""" + +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +from app.ai.faster_whisper_transcriber import FasterWhisperTranscriber + + +class FakeSegment: + """Fake segment matching faster-whisper segment interface.""" + + def __init__(self, text): + self.text = text + + +class TestFasterWhisperTranscriber: + """Tests for the faster-whisper adapter.""" + + @pytest.mark.asyncio + async def test_transcribe_calls_model_transcribe(self): + """Adapter delegates to WhisperModel.transcribe with correct arguments.""" + segment = FakeSegment(" hello world") + model = MagicMock() + model.transcribe.return_value = ([segment], None) + + transcriber = FasterWhisperTranscriber(model) + audio = np.zeros(16000, dtype=np.float32) + text = await transcriber.transcribe(audio, "en") + + assert text == "hello world" + model.transcribe.assert_called_once() + call_args, call_kwargs = model.transcribe.call_args + assert call_args[0] is audio + assert call_kwargs["language"] == "en" + assert call_kwargs["task"] == "transcribe" + + @pytest.mark.asyncio + async def test_uses_vad_filter_true(self): + """Transcription enables VAD filtering.""" + segment = FakeSegment(" test") + model = MagicMock() + model.transcribe.return_value = ([segment], None) + + transcriber = FasterWhisperTranscriber(model) + audio = np.zeros(8000, dtype=np.float32) + await transcriber.transcribe(audio, "ru") + + call_kwargs = model.transcribe.call_args.kwargs + assert call_kwargs["vad_filter"] is True + + @pytest.mark.asyncio + async def test_normalizes_locale(self): + """Locale is normalized before being passed to the model.""" + segment = FakeSegment(" result") + model = MagicMock() + model.transcribe.return_value = ([segment], None) + + transcriber = FasterWhisperTranscriber(model) + audio = np.zeros(8000, dtype=np.float32) + text = await transcriber.transcribe(audio, " RU ") + + call_kwargs = model.transcribe.call_args.kwargs + assert call_kwargs["language"] == "ru" + assert text == "result" + + @pytest.mark.asyncio + async def test_joins_multiple_segments(self): + """Multiple segments are concatenated and stripped.""" + segments = [FakeSegment(" Hello"), FakeSegment(" world")] + model = MagicMock() + model.transcribe.return_value = (segments, None) + + transcriber = FasterWhisperTranscriber(model) + audio = np.zeros(8000, dtype=np.float32) + text = await transcriber.transcribe(audio, "en") + + assert text == "Hello world" + + @pytest.mark.asyncio + async def test_returns_empty_string_when_no_segments(self): + """Empty segment list produces empty transcript.""" + model = MagicMock() + model.transcribe.return_value = ([], None) + + transcriber = FasterWhisperTranscriber(model) + audio = np.zeros(8000, dtype=np.float32) + text = await transcriber.transcribe(audio, "en") + + assert text == "" + + @pytest.mark.asyncio + async def test_runs_in_thread(self): + """Transcription runs the blocking model in a thread.""" + segment = FakeSegment(" async result") + model = MagicMock() + model.transcribe.return_value = ([segment], None) + + with patch( + "asyncio.to_thread", side_effect=lambda f, *a, **k: f(*a, **k) + ) as mock_to_thread: # noqa: E501 + transcriber = FasterWhisperTranscriber(model) + audio = np.zeros(8000, dtype=np.float32) + await transcriber.transcribe(audio, "en") + + mock_to_thread.assert_called_once() + + @pytest.mark.asyncio + async def test_strips_whitespace(self): + """Trailing and leading whitespace is stripped from result.""" + segments = [FakeSegment(" padded ")] + model = MagicMock() + model.transcribe.return_value = (segments, None) + + transcriber = FasterWhisperTranscriber(model) + audio = np.zeros(8000, dtype=np.float32) + text = await transcriber.transcribe(audio, "en") + + assert text == "padded" + + @pytest.mark.asyncio + async def test_ignores_info_return_value(self): + """Second return value from transcribe (info) is ignored.""" + segment = FakeSegment("text") + info = MagicMock() + info.language = "en" + model = MagicMock() + model.transcribe.return_value = ([segment], info) + + transcriber = FasterWhisperTranscriber(model) + audio = np.zeros(8000, dtype=np.float32) + text = await transcriber.transcribe(audio, "en") + + assert text == "text" diff --git a/tests/ai/test_llm_models.py b/tests/ai/test_llm_models.py new file mode 100644 index 0000000..c0dc2c1 --- /dev/null +++ b/tests/ai/test_llm_models.py @@ -0,0 +1,240 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for LLM model catalog types.""" + +import pytest + +from app.ai.llm_models import ( + CUSTOM_PRESET_ID, + LLMCatalog, + LLMModelEntry, + generate_model_id, + normalize_model_id, + slugify_model_id, +) + + +class TestLLMModelEntry: + """Tests for LLMModelEntry dataclass.""" + + def test_creation_with_required_fields(self): + """Entry can be created with all required fields.""" + entry = LLMModelEntry( + id="gpt-4", + display_name="GPT-4", + provider_type="openai-compatible", + model="gpt-4", + base_url="https://api.openai.com/v1", + api_key_required=True, + ) + assert entry.id == "gpt-4" + assert entry.display_name == "GPT-4" + assert entry.provider_type == "openai-compatible" + assert entry.model == "gpt-4" + assert entry.base_url == "https://api.openai.com/v1" + assert entry.api_key_required is True + assert entry.api_key is None + assert entry.accepts_audio_input is False + + def test_creation_with_optional_fields(self): + """Optional fields can be set explicitly.""" + entry = LLMModelEntry( + id="custom-model", + display_name="Custom", + provider_type="openai-compatible", + model="custom", + base_url="http://localhost:11434", + api_key_required=False, + api_key="secret", + accepts_audio_input=True, + ) + assert entry.api_key == "secret" + assert entry.accepts_audio_input is True + + def test_is_frozen(self): + """Entry is immutable after creation.""" + entry = LLMModelEntry( + id="x", + display_name="X", + provider_type="openai-compatible", + model="x", + base_url="http://localhost", + api_key_required=False, + ) + with pytest.raises(AttributeError): + entry.display_name = "Y" + + +class TestLLMCatalog: + """Tests for LLMCatalog dataclass.""" + + def test_creation_with_models(self): + """Catalog can be created with a full model map.""" + entry = LLMModelEntry( + id="gpt-4", + display_name="GPT-4", + provider_type="openai-compatible", + model="gpt-4", + base_url="https://api.openai.com/v1", + api_key_required=True, + ) + catalog = LLMCatalog( + selected_id="gpt-4", + models={"gpt-4": entry}, + ) + assert catalog.selected_id == "gpt-4" + assert "gpt-4" in catalog.models + + def test_creation_without_selection(self): + """Catalog can have None selected_id.""" + catalog = LLMCatalog( + selected_id=None, + models={}, + ) + assert catalog.selected_id is None + assert catalog.models == {} + + def test_is_frozen(self): + """Catalog is immutable after creation.""" + catalog = LLMCatalog(selected_id=None, models={}) + with pytest.raises(AttributeError): + catalog.selected_id = "x" + + +class TestNormalizeModelId: + """Tests for normalize_model_id.""" + + def test_returns_valid_id(self): + """Returns the id when it exists in catalog.""" + entry = LLMModelEntry( + id="gpt-4", + display_name="GPT-4", + provider_type="openai-compatible", + model="gpt-4", + base_url="https://api.openai.com/v1", + api_key_required=True, + ) + catalog = LLMCatalog( + selected_id="gpt-4", + models={"gpt-4": entry}, + ) + assert normalize_model_id("gpt-4", catalog) == "gpt-4" + + def test_strips_whitespace(self): + """Strips whitespace from the input id.""" + entry = LLMModelEntry( + id="gpt-4", + display_name="GPT-4", + provider_type="openai-compatible", + model="gpt-4", + base_url="https://api.openai.com/v1", + api_key_required=True, + ) + catalog = LLMCatalog( + selected_id="gpt-4", + models={"gpt-4": entry}, + ) + assert normalize_model_id(" gpt-4 ", catalog) == "gpt-4" + + def test_rejects_empty_string(self): + """Raises ValueError for empty string.""" + catalog = LLMCatalog(selected_id=None, models={}) + with pytest.raises(ValueError, match="Interview model is required"): + normalize_model_id("", catalog) + + def test_rejects_custom_preset_id(self): + """Raises ValueError when id matches CUSTOM_PRESET_ID.""" + catalog = LLMCatalog(selected_id=None, models={}) + with pytest.raises(ValueError, match="Interview model is required"): + normalize_model_id(CUSTOM_PRESET_ID, catalog) + + def test_rejects_unknown_id(self): + """Raises ValueError for unknown model ids.""" + catalog = LLMCatalog( + selected_id=None, + models={}, + ) + with pytest.raises(ValueError, match="Unsupported LLM model: unknown"): + normalize_model_id("unknown", catalog) + + def test_rejects_whitespace_only(self): + """Raises ValueError for whitespace-only input.""" + catalog = LLMCatalog(selected_id=None, models={}) + with pytest.raises(ValueError, match="Interview model is required"): + normalize_model_id(" ", catalog) + + +class TestSlugifyModelId: + """Tests for slugify_model_id.""" + + def test_lowercases_text(self): + """Result is all lowercase.""" + assert slugify_model_id("GPT-4 Turbo") == "gpt-4-turbo" + + def test_replaces_spaces_with_hyphens(self): + """Spaces become single hyphens.""" + assert slugify_model_id("my model name") == "my-model-name" + + def test_replaces_special_chars(self): + """Special characters become hyphens.""" + assert slugify_model_id("model@v1.5") == "model-v1-5" + + def test_collapses_multiple_special_chars(self): + """Runs of non-alphanumeric chars collapse to one hyphen.""" + assert slugify_model_id("a!!!b") == "a-b" + + def test_strips_leading_trailing_hyphens(self): + """Leading and trailing hyphens are removed.""" + assert slugify_model_id("!!!test!!!") == "test" + + def test_empty_result_for_no_usable_chars(self): + """Returns empty string when input has no alphanumeric chars.""" + assert slugify_model_id("!!!") == "" + + def test_strips_input_whitespace(self): + """Input whitespace is stripped before processing.""" + assert slugify_model_id(" Model Name ") == "model-name" + + +class TestGenerateModelId: + """Tests for generate_model_id.""" + + def test_generates_slug_from_display_name(self): + """Generates a slug from display name.""" + result = generate_model_id("My New Model", []) + assert result == "my-new-model" + + def test_returns_unique_id(self): + """Appends a suffix when base slug collides.""" + result = generate_model_id("My Model", ["my-model"]) + assert result == "my-model-2" + + def test_increments_suffix_for_multiple_collisions(self): + """Increments suffix until a unique id is found.""" + result = generate_model_id("My Model", ["my-model", "my-model-2", "my-model-3"]) + assert result == "my-model-4" + + def test_falls_back_to_model_for_empty_name(self): + """Uses 'model' fallback when name has no usable chars.""" + result = generate_model_id("!!!", []) + assert result == "model" + + def test_falls_back_when_fallback_collides(self): + """Appends suffix to fallback when 'model' is taken.""" + result = generate_model_id("!!!", ["model"]) + assert result == "model-2" + + def test_rewrites_custom_preset_id(self): + """Avoids collision with reserved CUSTOM_PRESET_ID.""" + result = generate_model_id("custom", []) + assert result == "custom-model" + + def test_rewrites_custom_preset_id_when_taken(self): + """Avoids collision when custom-model is already taken.""" + result = generate_model_id("custom", ["custom-model"]) + assert result == "custom-model-2" + + def test_does_not_append_suffix_when_no_collision(self): + """Returns base slug when it is already unique.""" + result = generate_model_id("Unique Model", ["other-model"]) + assert result == "unique-model" diff --git a/tests/ai/test_speech_transcriber.py b/tests/ai/test_speech_transcriber.py new file mode 100644 index 0000000..009bc1e --- /dev/null +++ b/tests/ai/test_speech_transcriber.py @@ -0,0 +1,50 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for SpeechTranscriber protocol.""" + +import inspect +from typing import Protocol, get_type_hints + +import numpy as np +import pytest + +from app.ai.speech_transcriber import SpeechTranscriber + + +class TestSpeechTranscriber: + """Tests for the SpeechTranscriber protocol structure.""" + + def test_is_protocol(self): + """SpeechTranscriber is a typing Protocol.""" + assert issubclass(SpeechTranscriber, Protocol) + + def test_has_transcribe_method(self): + """Protocol defines a transcribe method.""" + assert hasattr(SpeechTranscriber, "transcribe") + assert inspect.isfunction(SpeechTranscriber.transcribe) + + def test_transcribe_signature(self): + """transcribe accepts audio and locale parameters.""" + hints = get_type_hints(SpeechTranscriber.transcribe) + assert "audio" in hints + assert "locale" in hints + assert "return" in hints + assert hints["return"] is str + + def test_fake_transcriber_has_transcribe_method(self): + """Fake transcriber used in other tests has the required method.""" + from tests.helpers.transcription import FakeTranscriber + + fake = FakeTranscriber("test") + assert hasattr(fake, "transcribe") + assert inspect.iscoroutinefunction(fake.transcribe) + + @pytest.mark.asyncio + async def test_fake_transcriber_awaitable(self): + """Fake transcriber transcribe method is awaitable.""" + from tests.helpers.transcription import FakeTranscriber + + fake = FakeTranscriber("test result") + audio = np.zeros(1600, dtype=np.float32) + result = await fake.transcribe(audio, "en") + assert result == "test result" diff --git a/tests/coding/api/test_coding_full_flow.py b/tests/coding/api/test_coding_full_flow.py new file mode 100644 index 0000000..5d82b91 --- /dev/null +++ b/tests/coding/api/test_coding_full_flow.py @@ -0,0 +1,111 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Full flow tests for coding: run → submit (WS) → next task → finish.""" + +from unittest.mock import AsyncMock, patch + +from app.coding.domain.value_objects import CodingRunResult +from app.coding.services.evaluator.models import CodingAnswerEvaluation +from tests.helpers.coding_seed import seed_active_coding_interview + + +def _success_run_result() -> CodingRunResult: + return CodingRunResult( + status="success", + stdout=None, + stderr=None, + compile_output=None, + tests_passed=0, + tests_total=0, + test_results=(), + duration_ms=12, + ) + + +class TestCodingFullFlow: + """Run, submit, and navigate through a coding section.""" + + def test_run_then_submit_then_next_task( + self, client, isolated_db, mock_judge0, override_ws_ai_provider + ): + """Submit after Run; score and feedback returned; next task loaded.""" + interview_id, task_id = seed_active_coding_interview( + "coding-full-1", task_ids=["cod-001", "cod-002"] + ) + + mock_judge0() # default success + evaluation = CodingAnswerEvaluation( + score=4, + feedback="Nice work.", + follow_up_needed=False, + follow_up_question=None, + follow_up_mode=None, + ) + + # 1. Run (public tests / compile) + response = client.post( + f"/interview/{interview_id}/coding/run", + json={"task_id": task_id, "source_code": "def solve():\n return 42"}, + ) + assert response.status_code == 200 + run_result = response.json() + assert run_result["status"] == "success" + assert run_result["attempt_no"] == 1 + + # 2. Submit via WS + override_ws_ai_provider(client, []) + with ( + patch( + "app.coding.services.submission.CodingEvaluatorService.evaluate_submission", + new=AsyncMock(return_value=(evaluation, False, None, None)), + ), + client.websocket_connect(f"/interview/{interview_id}/coding/ws") as ws, + ): + ws.send_json( + { + "type": "submit", + "task_id": task_id, + "source_code": "def solve():\n return 42", + } + ) + assert ws.receive_json() == {"type": "saved"} + assert ws.receive_json() == {"type": "evaluating"} + fb = ws.receive_json() + assert fb["type"] == "feedback" + assert fb["task_id"] == task_id + assert fb["feedback"] == "Nice work." + + # 3. State should show submitted + next task + state = client.get(f"/interview/{interview_id}/coding/state").json() + assert state["completed_tasks"] == 1 + assert state["current_task"]["task_id"] == "cod-002" + + def test_run_compile_error_shows_error(self, client, isolated_db, mock_judge0): + """Run with compile error returns proper error status.""" + interview_id, task_id = seed_active_coding_interview("coding-compile-1") + mock_judge0(status="compile_error") + + response = client.post( + f"/interview/{interview_id}/coding/run", + json={"task_id": task_id, "source_code": "def solve(\n return"}, + ) + assert response.status_code == 200 + result = response.json() + assert result["status"] == "compile_error" + + def test_run_attempts_limit(self, client, isolated_db, mock_judge0, monkeypatch): + """Run returns 429 after exceeding max attempts.""" + monkeypatch.setenv("CODING_MAX_RUNS_PER_TASK", "1") + interview_id, task_id = seed_active_coding_interview("coding-limit-1") + mock_judge0() + + first = client.post( + f"/interview/{interview_id}/coding/run", + json={"task_id": task_id, "source_code": "pass"}, + ) + second = client.post( + f"/interview/{interview_id}/coding/run", + json={"task_id": task_id, "source_code": "pass"}, + ) + assert first.status_code == 200 + assert second.status_code == 429 diff --git a/tests/coding/api/test_ws_protocol.py b/tests/coding/api/test_ws_protocol.py new file mode 100644 index 0000000..bf9cf91 --- /dev/null +++ b/tests/coding/api/test_ws_protocol.py @@ -0,0 +1,84 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for coding WebSocket protocol mapping.""" + +import pytest + +from app.coding.api.ws_protocol import coding_event_to_message +from app.coding.services.events import CodingFeedbackEvent +from app.interview.services.events import AnswerSavedEvent, EvaluatingEvent + + +def test_coding_event_to_message_answer_saved_event() -> None: + """coding_event_to_message maps AnswerSavedEvent to saved message.""" + event = AnswerSavedEvent() + message = coding_event_to_message(event) + assert message == {"type": "saved"} + + +def test_coding_event_to_message_evaluating_event() -> None: + """coding_event_to_message maps EvaluatingEvent to evaluating message.""" + event = EvaluatingEvent() + message = coding_event_to_message(event) + assert message == {"type": "evaluating"} + + +def test_coding_event_to_message_coding_feedback_event_with_all_fields() -> None: + """coding_event_to_message maps CodingFeedbackEvent with all fields correctly.""" + event = CodingFeedbackEvent( + task_id="cod-001", + order=1, + round=0, + follow_up_needed=True, + follow_up_text="Add type hints.", + follow_up_mode="code", + next_task={"task_id": "cod-002", "prompt_text": "Next task."}, + feedback="Good effort.", + timer_remaining_seconds=45, + ) + message = coding_event_to_message(event) + assert message["type"] == "feedback" + assert message["task_id"] == "cod-001" + assert message["order"] == 1 + assert message["round"] == 0 + assert message["follow_up_question"] == "Add type hints." + assert message["follow_up_mode"] == "code" + assert message["next_task"] == {"task_id": "cod-002", "prompt_text": "Next task."} + assert message["feedback"] == "Good effort." + assert message["timer_remaining_seconds"] == 45 + + +def test_coding_event_to_message_coding_feedback_event_without_follow_up() -> None: + """coding_event_to_message maps CodingFeedbackEvent without follow-up correctly.""" + event = CodingFeedbackEvent( + task_id="cod-001", + order=1, + round=0, + follow_up_needed=False, + follow_up_text=None, + follow_up_mode=None, + next_task=None, + feedback="Excellent work.", + timer_remaining_seconds=None, + ) + message = coding_event_to_message(event) + assert message["type"] == "feedback" + assert message["task_id"] == "cod-001" + assert message["order"] == 1 + assert message["round"] == 0 + assert "follow_up_mode" not in message + assert "next_task" not in message + assert "timer_remaining_seconds" not in message + assert message["feedback"] == "Excellent work." + assert message["follow_up_question"] is None + + +def test_coding_event_to_message_raises_type_error_for_unknown_event() -> None: + """coding_event_to_message raises TypeError for unsupported event types.""" + + # Use a simple object that is not a known event type + class UnknownEvent: + pass + + with pytest.raises(TypeError, match="Unsupported coding event"): + coding_event_to_message(UnknownEvent()) # type: ignore[arg-type] diff --git a/tests/coding/api/test_ws_session.py b/tests/coding/api/test_ws_session.py new file mode 100644 index 0000000..865c615 --- /dev/null +++ b/tests/coding/api/test_ws_session.py @@ -0,0 +1,390 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for coding WebSocket session service.""" + +from unittest.mock import AsyncMock, MagicMock, Mock + +import pytest + +from app.coding.api.ws_session import CodingWebSocketService +from app.coding.domain.exceptions import CodingTaskNotCurrentError +from app.coding.services.events import CodingFeedbackEvent +from app.interview.services.events import AnswerSavedEvent, EvaluatingEvent +from tests.fakes import FakeProvider + + +def _make_submission_service( + *, + submit_events: tuple | None = None, + timeout_events: tuple | None = None, +) -> Mock: + """Build a mock submission service that yields predetermined events.""" + service = Mock() + + async def _stream_submit(**_kwargs): + for event in submit_events or (): + yield event + + async def _stream_timeout_submission(**_kwargs): + for event in timeout_events or (): + yield event + + service.stream_submit = _stream_submit + service.stream_timeout_submission = MagicMock(wraps=_stream_timeout_submission) + return service + + +class TestCodingWebSocketServiceIterResponses: + """Tests for ``CodingWebSocketService.iter_responses``.""" + + @pytest.mark.asyncio + async def test_iter_responses_dispatches_submit(self) -> None: + """iter_responses with type ``submit`` dispatches to _handle_submit.""" + service = _make_submission_service( + submit_events=( + AnswerSavedEvent(), + EvaluatingEvent(), + CodingFeedbackEvent( + task_id="cod-001", + order=1, + round=0, + follow_up_needed=False, + follow_up_text=None, + follow_up_mode=None, + next_task=None, + feedback="Nice.", + ), + ), + ) + provider = FakeProvider(replies=[]) + messages = [] + async for msg in CodingWebSocketService.iter_responses( + {"type": "submit", "task_id": "cod-001", "source_code": "pass"}, + interview_id="iv-001", + provider=provider, + submission_service=service, # type: ignore[arg-type] + ): + messages.append(msg) + + assert len(messages) == 3 + assert messages[0]["type"] == "saved" + assert messages[1]["type"] == "evaluating" + assert messages[2]["type"] == "feedback" + assert messages[2]["task_id"] == "cod-001" + assert messages[2]["feedback"] == "Nice." + + @pytest.mark.asyncio + async def test_iter_responses_dispatches_timeout(self) -> None: + """iter_responses with type ``timeout`` dispatches to _handle_timeout.""" + service = _make_submission_service( + timeout_events=( + CodingFeedbackEvent( + task_id="cod-001", + order=1, + round=0, + follow_up_needed=False, + follow_up_text=None, + follow_up_mode=None, + next_task=None, + feedback="Time expired.", + ), + ), + ) + messages = [] + async for msg in CodingWebSocketService.iter_responses( + {"type": "timeout", "task_id": "cod-001", "round": 0}, + interview_id="iv-001", + provider=FakeProvider(replies=[]), + submission_service=service, # type: ignore[arg-type] + ): + messages.append(msg) + + assert len(messages) == 1 + assert messages[0]["type"] == "feedback" + assert messages[0]["feedback"] == "Time expired." + + @pytest.mark.asyncio + async def test_iter_responses_returns_error_for_unknown_type(self) -> None: + """iter_responses returns an error for unknown message types.""" + messages = [] + async for msg in CodingWebSocketService.iter_responses( + {"type": "unknown"}, + interview_id="iv-001", + provider=FakeProvider(replies=[]), + submission_service=AsyncMock(), # type: ignore[arg-type] + ): + messages.append(msg) + + assert len(messages) == 1 + assert messages[0]["type"] == "error" + assert "Unknown message type" in messages[0]["message"] + + +class TestCodingWebSocketServiceHandleSubmit: + """Tests for ``CodingWebSocketService._handle_submit``.""" + + @pytest.mark.asyncio + async def test_handle_submit_validates_task_id_required(self) -> None: + """_handle_submit yields error when task_id is missing.""" + messages = [] + async for msg in CodingWebSocketService._handle_submit( + {"type": "submit", "source_code": "pass"}, + interview_id="iv-001", + provider=FakeProvider(replies=[]), + submission_service=AsyncMock(), # type: ignore[arg-type] + ): + messages.append(msg) + + assert len(messages) == 1 + assert messages[0]["type"] == "error" + assert "task_id and source_code are required" in messages[0]["message"] + + @pytest.mark.asyncio + async def test_handle_submit_validates_source_code_required(self) -> None: + """_handle_submit yields error when source_code is missing.""" + messages = [] + async for msg in CodingWebSocketService._handle_submit( + {"type": "submit", "task_id": "cod-001"}, + interview_id="iv-001", + provider=FakeProvider(replies=[]), + submission_service=AsyncMock(), # type: ignore[arg-type] + ): + messages.append(msg) + + assert len(messages) == 1 + assert messages[0]["type"] == "error" + assert "task_id and source_code are required" in messages[0]["message"] + + @pytest.mark.asyncio + async def test_handle_submit_validates_empty_task_id(self) -> None: + """_handle_submit yields error when task_id is empty after strip.""" + messages = [] + async for msg in CodingWebSocketService._handle_submit( + {"type": "submit", "task_id": " ", "source_code": "pass"}, + interview_id="iv-001", + provider=FakeProvider(replies=[]), + submission_service=AsyncMock(), # type: ignore[arg-type] + ): + messages.append(msg) + + assert len(messages) == 1 + assert messages[0]["type"] == "error" + + @pytest.mark.asyncio + async def test_handle_submit_yields_events_from_submission_service(self) -> None: + """_handle_submit yields events produced by the submission service.""" + service = _make_submission_service( + submit_events=( + AnswerSavedEvent(), + EvaluatingEvent(), + CodingFeedbackEvent( + task_id="cod-001", + order=1, + round=0, + follow_up_needed=False, + follow_up_text=None, + follow_up_mode=None, + next_task=None, + feedback="Great.", + ), + ), + ) + messages = [] + async for msg in CodingWebSocketService._handle_submit( + {"type": "submit", "task_id": "cod-001", "source_code": "pass"}, + interview_id="iv-001", + provider=FakeProvider(replies=[]), + submission_service=service, # type: ignore[arg-type] + ): + messages.append(msg) + + assert len(messages) == 3 + assert messages[0]["type"] == "saved" + assert messages[1]["type"] == "evaluating" + assert messages[2]["type"] == "feedback" + assert messages[2]["feedback"] == "Great." + + @pytest.mark.asyncio + async def test_handle_submit_handles_domain_errors(self) -> None: + """_handle_submit yields a domain error payload on CodingDomainError.""" + service = AsyncMock() + + async def _failing_stream(**_kwargs): + raise CodingTaskNotCurrentError("iv-001", "cod-001") + yield # type: ignore[unreachable] + + service.stream_submit = _failing_stream + messages = [] + async for msg in CodingWebSocketService._handle_submit( + {"type": "submit", "task_id": "cod-001", "source_code": "pass"}, + interview_id="iv-001", + provider=FakeProvider(replies=[]), + submission_service=service, # type: ignore[arg-type] + ): + messages.append(msg) + + assert len(messages) == 1 + assert messages[0]["type"] == "error" + assert "not the current coding task" in messages[0]["message"] + + @pytest.mark.asyncio + async def test_handle_submit_handles_general_exceptions(self) -> None: + """_handle_submit yields a generic error payload on unexpected exceptions.""" + service = AsyncMock() + + async def _failing_stream(**_kwargs): + raise RuntimeError("boom") + yield # type: ignore[unreachable] + + service.stream_submit = _failing_stream + messages = [] + async for msg in CodingWebSocketService._handle_submit( + {"type": "submit", "task_id": "cod-001", "source_code": "pass"}, + interview_id="iv-001", + provider=FakeProvider(replies=[]), + submission_service=service, # type: ignore[arg-type] + ): + messages.append(msg) + + assert len(messages) == 1 + assert messages[0]["type"] == "error" + + +class TestCodingWebSocketServiceHandleTimeout: + """Tests for ``CodingWebSocketService._handle_timeout``.""" + + @pytest.mark.asyncio + async def test_handle_timeout_validates_task_id_required(self) -> None: + """_handle_timeout yields error when task_id is missing.""" + messages = [] + async for msg in CodingWebSocketService._handle_timeout( + {"type": "timeout", "round": 0}, + interview_id="iv-001", + submission_service=AsyncMock(), # type: ignore[arg-type] + ): + messages.append(msg) + + assert len(messages) == 1 + assert messages[0]["type"] == "error" + assert "task_id and round are required" in messages[0]["message"] + + @pytest.mark.asyncio + async def test_handle_timeout_validates_round_required(self) -> None: + """_handle_timeout yields error when round is missing.""" + messages = [] + async for msg in CodingWebSocketService._handle_timeout( + {"type": "timeout", "task_id": "cod-001"}, + interview_id="iv-001", + submission_service=AsyncMock(), # type: ignore[arg-type] + ): + messages.append(msg) + + assert len(messages) == 1 + assert messages[0]["type"] == "error" + assert "task_id and round are required" in messages[0]["message"] + + @pytest.mark.asyncio + async def test_handle_timeout_uses_question_id_fallback(self) -> None: + """_handle_timeout falls back to ``question_id`` when ``task_id`` is absent.""" + service = _make_submission_service( + timeout_events=( + CodingFeedbackEvent( + task_id="cod-001", + order=1, + round=0, + follow_up_needed=False, + follow_up_text=None, + follow_up_mode=None, + next_task=None, + feedback="Timeout.", + ), + ), + ) + messages = [] + async for msg in CodingWebSocketService._handle_timeout( + {"type": "timeout", "question_id": "cod-001", "round": 0}, + interview_id="iv-001", + submission_service=service, # type: ignore[arg-type] + ): + messages.append(msg) + + assert len(messages) == 1 + assert messages[0]["type"] == "feedback" + assert messages[0]["feedback"] == "Timeout." + + @pytest.mark.asyncio + async def test_handle_timeout_yields_events_from_submission_service(self) -> None: + """_handle_timeout yields events produced by the submission service.""" + service = _make_submission_service( + timeout_events=( + CodingFeedbackEvent( + task_id="cod-001", + order=1, + round=0, + follow_up_needed=False, + follow_up_text=None, + follow_up_mode=None, + next_task=None, + feedback="Time expired.", + ), + ), + ) + messages = [] + async for msg in CodingWebSocketService._handle_timeout( + {"type": "timeout", "task_id": "cod-001", "round": 0}, + interview_id="iv-001", + submission_service=service, # type: ignore[arg-type] + ): + messages.append(msg) + + assert len(messages) == 1 + assert messages[0]["type"] == "feedback" + assert messages[0]["feedback"] == "Time expired." + service.stream_timeout_submission.assert_called_once_with( + interview_id="iv-001", + task_id="cod-001", + round_num=0, + ) + + @pytest.mark.asyncio + async def test_handle_timeout_handles_domain_errors(self) -> None: + """_handle_timeout yields a domain error payload on CodingDomainError.""" + service = AsyncMock() + + async def _failing_stream(**_kwargs): + raise CodingTaskNotCurrentError("iv-001", "cod-001") + yield # type: ignore[unreachable] + + service.stream_timeout_submission = _failing_stream + messages = [] + async for msg in CodingWebSocketService._handle_timeout( + {"type": "timeout", "task_id": "cod-001", "round": 0}, + interview_id="iv-001", + submission_service=service, # type: ignore[arg-type] + ): + messages.append(msg) + + assert len(messages) == 1 + assert messages[0]["type"] == "error" + assert "not the current coding task" in messages[0]["message"] + + @pytest.mark.asyncio + async def test_handle_timeout_handles_general_exceptions(self) -> None: + """_handle_timeout yields a generic error payload on unexpected exceptions.""" + service = AsyncMock() + + async def _failing_stream(**_kwargs): + raise RuntimeError("boom") + yield # type: ignore[unreachable] + + service.stream_timeout_submission = _failing_stream + messages = [] + async for msg in CodingWebSocketService._handle_timeout( + {"type": "timeout", "task_id": "cod-001", "round": 0}, + interview_id="iv-001", + submission_service=service, # type: ignore[arg-type] + ): + messages.append(msg) + + assert len(messages) == 1 + assert messages[0]["type"] == "error" diff --git a/tests/coding/domain/__init__.py b/tests/coding/domain/__init__.py new file mode 100644 index 0000000..ccd2ccd --- /dev/null +++ b/tests/coding/domain/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Coding domain unit tests.""" diff --git a/tests/coding/domain/test_entities.py b/tests/coding/domain/test_entities.py new file mode 100644 index 0000000..509ea6f --- /dev/null +++ b/tests/coding/domain/test_entities.py @@ -0,0 +1,727 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for coding domain entities.""" + +from datetime import UTC, datetime, timedelta + +import pytest + +from app.coding.domain.entities import CodeRunAttempt, CodingSection, CodingTask +from app.coding.domain.exceptions import ( + CodingSectionNotActiveError, + CodingTaskNotCurrentError, + CodingTaskNotFoundError, +) +from app.coding.domain.value_objects import PlannedCodingTask +from app.interview.domain.value_objects import InterviewSelection, TrackSelection + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +def _make_task( + *, + id: int = 1, + task_id: str = "cod-001", + order: int = 1, + round_num: int = 0, + submitted_code: str | None = None, + score: int | None = None, + feedback: str | None = None, + started_at: datetime | None = None, + task_spec: dict | None = None, +) -> CodingTask: + now = datetime.now(UTC) + return CodingTask( + id=id, + coding_section_id=1, + interview_id="iv-001", + task_id=task_id, + order=order, + round=round_num, + prompt_text="Solve it.", + task_spec=task_spec or {}, + submitted_code=submitted_code, + submit_test_summary=None, + score=score, + feedback=feedback, + started_at=started_at, + created_at=now, + ) + + +def _make_section( + *, + status: str = "active", + tasks: tuple[CodingTask, ...] = (), + task_time_limit_seconds: int | None = None, + section_score: int | None = None, + section_feedback: dict[str, object] | None = None, +) -> CodingSection: + return CodingSection( + id=1, + interview_id="iv-001", + locale="en", + selection=InterviewSelection( + sources=( + TrackSelection(track="python", level="junior", categories=("basics",)), + ) + ), + task_count=len(tasks), + task_ids=tuple(t.task_id for t in tasks), + task_time_limit_seconds=task_time_limit_seconds, + status=status, # type: ignore[arg-type] + section_score=section_score, + section_feedback=section_feedback, + tasks=tasks, + ) + + +# --------------------------------------------------------------------------- +# CodingTask +# --------------------------------------------------------------------------- +class TestCodingTask: + """Tests for the ``CodingTask`` entity.""" + + def test_timer_deadline_raises_when_started_at_is_none(self) -> None: + """timer_deadline raises ValueError when started_at is None.""" + task = _make_task() + with pytest.raises(ValueError, match="has no started_at"): + task.timer_deadline(limit_seconds=60) + + def test_timer_deadline_returns_deadline_when_started_at_set(self) -> None: + """timer_deadline returns correct deadline when started_at is set.""" + started_at = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC) + task = _make_task(started_at=started_at) + deadline = task.timer_deadline(limit_seconds=60) + assert deadline == started_at + timedelta(seconds=60) + + def test_is_timer_expired_returns_false_when_limit_is_none(self) -> None: + """is_timer_expired returns False when limit_seconds is None.""" + task = _make_task(started_at=datetime.now(UTC) - timedelta(hours=1)) + assert task.is_timer_expired(limit_seconds=None) is False + + def test_is_timer_expired_returns_false_when_started_at_is_none(self) -> None: + """is_timer_expired returns False when started_at is None.""" + task = _make_task(started_at=None) + assert task.is_timer_expired(limit_seconds=60) is False + + def test_is_timer_expired_returns_false_when_within_limit(self) -> None: + """is_timer_expired returns False when time is within limit.""" + started_at = datetime.now(UTC) + task = _make_task(started_at=started_at) + assert ( + task.is_timer_expired( + limit_seconds=60, now=started_at + timedelta(seconds=30) + ) + is False + ) + + def test_is_timer_expired_returns_true_when_past_limit(self) -> None: + """is_timer_expired returns True when past the limit.""" + started_at = datetime.now(UTC) + task = _make_task(started_at=started_at) + assert ( + task.is_timer_expired( + limit_seconds=60, now=started_at + timedelta(seconds=70) + ) + is True + ) + + def test_is_timer_expired_uses_grace_seconds(self) -> None: + """is_timer_expired respects grace seconds before marking expired.""" + started_at = datetime.now(UTC) + task = _make_task(started_at=started_at) + # Exactly at limit, with default grace of 2s should still be False + assert ( + task.is_timer_expired( + limit_seconds=60, now=started_at + timedelta(seconds=60) + ) + is False + ) + # Past limit but within grace + assert ( + task.is_timer_expired( + limit_seconds=60, now=started_at + timedelta(seconds=61) + ) + is False + ) + # Past limit + grace + assert ( + task.is_timer_expired( + limit_seconds=60, now=started_at + timedelta(seconds=63) + ) + is True + ) + + def test_is_timer_expired_with_zero_grace(self) -> None: + """is_timer_expired with grace_seconds=0 expires exactly at limit.""" + started_at = datetime.now(UTC) + task = _make_task(started_at=started_at) + assert ( + task.is_timer_expired( + limit_seconds=60, + now=started_at + timedelta(seconds=60), + grace_seconds=0, + ) + is True + ) + assert ( + task.is_timer_expired( + limit_seconds=60, + now=started_at + timedelta(seconds=59), + grace_seconds=0, + ) + is False + ) + + def test_remaining_seconds_returns_none_when_limit_is_none(self) -> None: + """remaining_seconds returns None when limit_seconds is None.""" + task = _make_task(started_at=datetime.now(UTC)) + assert task.remaining_seconds(limit_seconds=None) is None + + def test_remaining_seconds_returns_none_when_started_at_is_none(self) -> None: + """remaining_seconds returns None when started_at is None.""" + task = _make_task(started_at=None) + assert task.remaining_seconds(limit_seconds=60) is None + + def test_remaining_seconds_returns_correct_value(self) -> None: + """remaining_seconds returns correct remaining time.""" + started_at = datetime.now(UTC) + task = _make_task(started_at=started_at) + assert ( + task.remaining_seconds( + limit_seconds=60, now=started_at + timedelta(seconds=20) + ) + == 40 + ) + + def test_remaining_seconds_is_non_negative(self) -> None: + """remaining_seconds never returns negative values.""" + started_at = datetime.now(UTC) + task = _make_task(started_at=started_at) + assert ( + task.remaining_seconds( + limit_seconds=60, now=started_at + timedelta(seconds=90) + ) + == 0 + ) + + def test_client_timeout_due_returns_false_when_no_limit(self) -> None: + """client_timeout_due returns False when limit_seconds is None.""" + task = _make_task(started_at=datetime.now(UTC)) + assert task.client_timeout_due(limit_seconds=None) is False + + def test_client_timeout_due_returns_false_when_not_started(self) -> None: + """client_timeout_due returns False when started_at is None.""" + task = _make_task(started_at=None) + assert task.client_timeout_due(limit_seconds=60) is False + + def test_client_timeout_due_returns_true_when_expired(self) -> None: + """client_timeout_due returns True when timer is expired.""" + started_at = datetime.now(UTC) + task = _make_task(started_at=started_at) + assert ( + task.client_timeout_due( + limit_seconds=60, now=started_at + timedelta(seconds=65) + ) + is True + ) + + def test_client_timeout_due_returns_true_when_zero_remaining(self) -> None: + """client_timeout_due returns True when exactly zero seconds remain.""" + started_at = datetime.now(UTC) + task = _make_task(started_at=started_at) + assert ( + task.client_timeout_due( + limit_seconds=60, now=started_at + timedelta(seconds=60) + ) + is True + ) + + def test_client_timeout_due_returns_false_when_time_remains(self) -> None: + """client_timeout_due returns False when time still remains.""" + started_at = datetime.now(UTC) + task = _make_task(started_at=started_at) + assert ( + task.client_timeout_due( + limit_seconds=60, now=started_at + timedelta(seconds=30) + ) + is False + ) + + +# --------------------------------------------------------------------------- +# CodingSection +# --------------------------------------------------------------------------- +class TestCodingSection: + """Tests for the ``CodingSection`` aggregate.""" + + def test_start_raises_on_empty_planned_tasks(self) -> None: + """start() raises ValueError when planned_tasks is empty.""" + with pytest.raises(ValueError, match="No coding tasks found"): + CodingSection.start( + interview_id="iv-001", + selection=InterviewSelection(sources=()), + locale="en", + planned_tasks=(), + ) + + def test_start_creates_tasks_with_correct_order_and_round(self) -> None: + """start() creates tasks with 1-based order and round=0.""" + planned = ( + PlannedCodingTask( + id="cod-001", text="Task 1", task_spec={"language": "python"} + ), + PlannedCodingTask( + id="cod-002", text="Task 2", task_spec={"language": "python"} + ), + ) + section = CodingSection.start( + interview_id="iv-001", + selection=InterviewSelection(sources=()), + locale="en", + planned_tasks=planned, + ) + assert len(section.tasks) == 2 + assert section.tasks[0].order == 1 + assert section.tasks[0].round == 0 + assert section.tasks[0].task_id == "cod-001" + assert section.tasks[1].order == 2 + assert section.tasks[1].round == 0 + assert section.tasks[1].task_id == "cod-002" + + def test_start_starts_timer_when_active_and_limit_set(self) -> None: + """start() starts timer on first task when active and limit is set.""" + planned = (PlannedCodingTask(id="cod-001", text="Task 1", task_spec={}),) + section = CodingSection.start( + interview_id="iv-001", + selection=InterviewSelection(sources=()), + locale="en", + planned_tasks=planned, + task_time_limit_seconds=60, + status="active", + ) + assert section.tasks[0].started_at is not None + + def test_start_does_not_start_timer_when_pending(self) -> None: + """start() does not start timer when status is pending.""" + planned = (PlannedCodingTask(id="cod-001", text="Task 1", task_spec={}),) + section = CodingSection.start( + interview_id="iv-001", + selection=InterviewSelection(sources=()), + locale="en", + planned_tasks=planned, + task_time_limit_seconds=60, + status="pending", + ) + assert section.tasks[0].started_at is None + + def test_start_does_not_start_timer_when_no_limit(self) -> None: + """start() does not start timer when limit is None.""" + planned = (PlannedCodingTask(id="cod-001", text="Task 1", task_spec={}),) + section = CodingSection.start( + interview_id="iv-001", + selection=InterviewSelection(sources=()), + locale="en", + planned_tasks=planned, + task_time_limit_seconds=None, + status="active", + ) + assert section.tasks[0].started_at is None + + def test_with_activated_promotes_pending_to_active(self) -> None: + """with_activated changes pending status to active.""" + section = _make_section(status="pending") + activated = section.with_activated() + assert activated.status == "active" + + def test_with_activated_returns_self_when_already_active(self) -> None: + """with_activated returns self when status is already active.""" + section = _make_section(status="active") + activated = section.with_activated() + assert activated is section + + def test_with_activated_returns_self_when_completed(self) -> None: + """with_activated returns self when status is completed.""" + section = _make_section(status="completed") + activated = section.with_activated() + assert activated is section + + def test_ensure_active_raises_when_not_active(self) -> None: + """ensure_active raises CodingSectionNotActiveError when not active.""" + section = _make_section(status="completed") + with pytest.raises(CodingSectionNotActiveError): + section.ensure_active() + + def test_ensure_active_does_not_raise_when_active(self) -> None: + """ensure_active does not raise when status is active.""" + section = _make_section(status="active") + section.ensure_active() # should not raise + + def test_find_first_unsubmitted_returns_first_pending(self) -> None: + """find_first_unsubmitted returns the first task without submission.""" + tasks = ( + _make_task(submitted_code="code1"), + _make_task(task_id="cod-002", submitted_code=None), + _make_task(task_id="cod-003", submitted_code=None), + ) + section = _make_section(tasks=tasks) + first = section.find_first_unsubmitted() + assert first is not None + assert first.task_id == "cod-002" + + def test_find_first_unsubmitted_returns_none_when_all_submitted(self) -> None: + """find_first_unsubmitted returns None when all tasks are submitted.""" + tasks = ( + _make_task(submitted_code="code1"), + _make_task(task_id="cod-002", submitted_code="code2"), + ) + section = _make_section(tasks=tasks) + assert section.find_first_unsubmitted() is None + + def test_find_first_unsubmitted_returns_none_when_no_tasks(self) -> None: + """find_first_unsubmitted returns None when there are no tasks.""" + section = _make_section(tasks=()) + assert section.find_first_unsubmitted() is None + + def test_is_complete_returns_false_when_tasks_unsubmitted(self) -> None: + """is_complete returns False when tasks remain unsubmitted.""" + tasks = (_make_task(submitted_code=None),) + section = _make_section(tasks=tasks) + assert section.is_complete() is False + + def test_is_complete_returns_true_when_all_submitted(self) -> None: + """is_complete returns True when all tasks are submitted.""" + tasks = ( + _make_task(submitted_code="code1"), + _make_task(task_id="cod-002", submitted_code="code2"), + ) + section = _make_section(tasks=tasks) + assert section.is_complete() is True + + def test_is_complete_returns_false_when_no_tasks(self) -> None: + """is_complete returns False when there are no tasks.""" + section = _make_section(tasks=()) + assert section.is_complete() is False + + def test_total_score_sums_submitted_scores(self) -> None: + """total_score sums scores from all submitted tasks.""" + tasks = ( + _make_task(submitted_code="code1", score=3), + _make_task(task_id="cod-002", submitted_code="code2", score=5), + _make_task(task_id="cod-003", submitted_code=None, score=4), + ) + section = _make_section(tasks=tasks) + assert section.total_score() == 8 + + def test_total_score_returns_zero_when_no_submissions(self) -> None: + """total_score returns 0 when no tasks have been submitted.""" + tasks = (_make_task(submitted_code=None, score=3),) + section = _make_section(tasks=tasks) + assert section.total_score() == 0 + + def test_max_score_for_submitted_rounds(self) -> None: + """max_score computes max possible score for submitted rounds.""" + tasks = ( + _make_task(submitted_code="code1", score=3), + _make_task(task_id="cod-002", submitted_code="code2", score=5), + _make_task(task_id="cod-003", submitted_code=None, score=4), + ) + section = _make_section(tasks=tasks) + assert section.max_score() == CodingSection.MAX_SCORE_PER_ROUND * 2 + + def test_max_score_returns_zero_when_no_submissions(self) -> None: + """max_score returns 0 when no tasks have been submitted.""" + tasks = (_make_task(submitted_code=None),) + section = _make_section(tasks=tasks) + assert section.max_score() == 0 + + def test_with_cached_section_feedback_sets_when_not_cached(self) -> None: + """with_cached_section_feedback sets feedback when not already cached.""" + section = _make_section() + updated = section.with_cached_section_feedback( + feedback={"summary": "Good job"}, + section_score=4, + ) + assert updated.section_feedback == {"summary": "Good job"} + assert updated.section_score == 4 + + def test_with_cached_section_feedback_skips_when_already_cached(self) -> None: + """with_cached_section_feedback returns self when feedback already cached.""" + section = _make_section( + section_feedback={"summary": "Already cached"}, section_score=3 + ) + updated = section.with_cached_section_feedback( + feedback={"summary": "New feedback"}, + section_score=5, + ) + assert updated is section + assert updated.section_feedback == {"summary": "Already cached"} + + def test_start_timer_for_task_sets_started_at(self) -> None: + """start_timer_for_task sets started_at when limit is configured.""" + tasks = (_make_task(id=1),) + section = _make_section(tasks=tasks, task_time_limit_seconds=60) + when = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC) + updated = section.start_timer_for_task(task_row_id=1, when=when) + assert updated.tasks[0].started_at == when + + def test_start_timer_for_task_does_nothing_when_no_limit(self) -> None: + """start_timer_for_task returns self when no time limit is set.""" + tasks = (_make_task(id=1),) + section = _make_section(tasks=tasks, task_time_limit_seconds=None) + updated = section.start_timer_for_task(task_row_id=1) + assert updated is section + + def test_start_timer_for_task_does_not_overwrite_existing_started_at(self) -> None: + """start_timer_for_task does not overwrite an existing started_at.""" + existing = datetime(2026, 1, 1, 11, 0, 0, tzinfo=UTC) + tasks = (_make_task(id=1, started_at=existing),) + section = _make_section(tasks=tasks, task_time_limit_seconds=60) + when = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC) + updated = section.start_timer_for_task(task_row_id=1, when=when) + assert updated.tasks[0].started_at == existing + + def test_start_timer_for_task_ignores_non_matching_task(self) -> None: + """start_timer_for_task ignores tasks that don't match the row id.""" + tasks = (_make_task(id=1), _make_task(id=2, task_id="cod-002")) + section = _make_section(tasks=tasks, task_time_limit_seconds=60) + when = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC) + updated = section.start_timer_for_task(task_row_id=1, when=when) + assert updated.tasks[0].started_at == when + assert updated.tasks[1].started_at is None + + def test_with_submit_test_summary_updates_correct_task(self) -> None: + """with_submit_test_summary sets fields on the matching task only.""" + tasks = ( + _make_task(id=1, submitted_code=None), + _make_task(id=2, task_id="cod-002", submitted_code=None), + ) + section = _make_section(tasks=tasks) + updated = section.with_submit_test_summary( + task_row_id=2, + summary={"status": "success"}, + source_code="def solve(): pass", + ) + assert updated.tasks[0].submitted_code is None + assert updated.tasks[1].submitted_code == "def solve(): pass" + assert updated.tasks[1].submit_test_summary == {"status": "success"} + + def test_with_timed_out_round_marks_task_correctly(self) -> None: + """with_timed_out_round marks the task with TIME_EXPIRED_SOURCE_CODE.""" + tasks = (_make_task(id=1),) + section = _make_section(tasks=tasks) + updated = section.with_timed_out_round(task_row_id=1, feedback="Time ran out") + assert updated.tasks[0].submitted_code == CodingTask.TIME_EXPIRED_SOURCE_CODE + assert updated.tasks[0].submit_test_summary == {"status": "timeout"} + assert updated.tasks[0].score == 0 + assert updated.tasks[0].feedback == "Time ran out" + + def test_with_timed_out_round_ignores_non_matching_task(self) -> None: + """with_timed_out_round ignores tasks that don't match the row id.""" + tasks = ( + _make_task(id=1), + _make_task(id=2, task_id="cod-002"), + ) + section = _make_section(tasks=tasks) + updated = section.with_timed_out_round(task_row_id=2, feedback="Time ran out") + assert updated.tasks[0].submitted_code is None + assert updated.tasks[1].submitted_code == CodingTask.TIME_EXPIRED_SOURCE_CODE + + def test_with_evaluation_updates_correct_task(self) -> None: + """with_evaluation updates score and feedback on the matching task.""" + tasks = ( + _make_task(id=1, task_id="cod-001"), + _make_task(id=2, task_id="cod-002"), + ) + section = _make_section(tasks=tasks) + updated = section.with_evaluation( + task_id="cod-002", + round_num=0, + score=4, + feedback="Great work", + ) + assert updated.tasks[0].score is None + assert updated.tasks[1].score == 4 + assert updated.tasks[1].feedback == "Great work" + + def test_with_evaluation_finds_correct_round(self) -> None: + """with_evaluation finds the correct task round.""" + tasks = ( + _make_task(id=1, task_id="cod-001", round_num=0), + _make_task(id=2, task_id="cod-001", round_num=1), + ) + section = _make_section(tasks=tasks) + updated = section.with_evaluation( + task_id="cod-001", + round_num=1, + score=3, + feedback="Round 1 feedback", + ) + assert updated.tasks[0].score is None + assert updated.tasks[1].score == 3 + assert updated.tasks[1].feedback == "Round 1 feedback" + + def test_max_round_for_task_returns_highest_round(self) -> None: + """max_round_for_task returns the highest round for a task.""" + tasks = ( + _make_task(id=1, task_id="cod-001", round_num=0), + _make_task(id=2, task_id="cod-001", round_num=1), + _make_task(id=3, task_id="cod-001", round_num=2), + ) + section = _make_section(tasks=tasks) + assert section.max_round_for_task("cod-001") == 2 + + def test_max_round_for_task_returns_zero_when_no_match(self) -> None: + """max_round_for_task returns 0 when task does not exist.""" + tasks = (_make_task(task_id="cod-001"),) + section = _make_section(tasks=tasks) + assert section.max_round_for_task("cod-999") == 0 + + def test_with_follow_up_creates_new_round(self) -> None: + """with_follow_up creates a new follow-up task round.""" + tasks = ( + _make_task( + id=1, + task_id="cod-001", + order=1, + round_num=0, + task_spec={"language": "python"}, + ), + ) + section = _make_section(tasks=tasks) + updated, follow_up = section.with_follow_up( + task_id="cod-001", + prompt_text="Follow-up prompt", + starter_code="# starter", + ) + assert len(updated.tasks) == 2 + assert follow_up.round == 1 + assert follow_up.task_id == "cod-001" + assert follow_up.order == 1 + assert follow_up.prompt_text == "Follow-up prompt" + assert follow_up.task_spec["starter_code"] == "# starter" + assert follow_up.submitted_code is None + assert follow_up.score is None + + def test_with_follow_up_without_starter_code(self) -> None: + """with_follow_up works without starter_code.""" + tasks = ( + _make_task( + id=1, + task_id="cod-001", + order=1, + round_num=0, + task_spec={"language": "python"}, + ), + ) + section = _make_section(tasks=tasks) + updated, follow_up = section.with_follow_up( + task_id="cod-001", + prompt_text="Follow-up prompt", + starter_code=None, + ) + assert "starter_code" not in follow_up.task_spec + assert follow_up.round == 1 + + def test_find_next_unsubmitted_after_returns_next_pending(self) -> None: + """find_next_unsubmitted_after returns the next pending task.""" + tasks = ( + _make_task(id=1, submitted_code="code1"), + _make_task(id=2, task_id="cod-002", submitted_code=None), + _make_task(id=3, task_id="cod-003", submitted_code=None), + ) + section = _make_section(tasks=tasks) + next_task = section.find_next_unsubmitted_after(current_index=0) + assert next_task is not None + assert next_task.task_id == "cod-002" + + def test_find_next_unsubmitted_after_returns_none_when_no_more(self) -> None: + """find_next_unsubmitted_after returns None when no pending tasks remain.""" + tasks = ( + _make_task(id=1, submitted_code="code1"), + _make_task(id=2, task_id="cod-002", submitted_code="code2"), + ) + section = _make_section(tasks=tasks) + assert section.find_next_unsubmitted_after(current_index=0) is None + + def test_require_current_task_returns_matching_task(self) -> None: + """require_current_task returns the task when it is current.""" + tasks = ( + _make_task(id=1, task_id="cod-001", submitted_code=None), + _make_task(id=2, task_id="cod-002", submitted_code=None), + ) + section = _make_section(tasks=tasks) + current = section.require_current_task("cod-001") + assert current.task_id == "cod-001" + + def test_require_current_task_raises_when_not_current(self) -> None: + """require_current_task raises when task is not the current one.""" + tasks = ( + _make_task(id=1, task_id="cod-001", submitted_code=None), + _make_task(id=2, task_id="cod-002", submitted_code=None), + ) + section = _make_section(tasks=tasks) + with pytest.raises(CodingTaskNotCurrentError): + section.require_current_task("cod-002") + + def test_require_current_task_raises_when_all_submitted(self) -> None: + """require_current_task raises when all tasks are submitted.""" + tasks = (_make_task(id=1, task_id="cod-001", submitted_code="code1"),) + section = _make_section(tasks=tasks) + with pytest.raises(CodingTaskNotCurrentError): + section.require_current_task("cod-001") + + def test_find_task_returns_matching_task(self) -> None: + """find_task returns the task matching task_id and round.""" + tasks = ( + _make_task(id=1, task_id="cod-001", round_num=0), + _make_task(id=2, task_id="cod-001", round_num=1), + ) + section = _make_section(tasks=tasks) + found = section.find_task("cod-001", round_num=1) + assert found.id == 2 + + def test_find_task_raises_when_not_found(self) -> None: + """find_task raises CodingTaskNotFoundError when task is not found.""" + tasks = (_make_task(task_id="cod-001"),) + section = _make_section(tasks=tasks) + with pytest.raises(CodingTaskNotFoundError): + section.find_task("cod-999", round_num=0) + + +# --------------------------------------------------------------------------- +# CodeRunAttempt +# --------------------------------------------------------------------------- +class TestCodeRunAttempt: + """Tests for the ``CodeRunAttempt`` entity.""" + + def test_code_run_attempt_creation(self) -> None: + """CodeRunAttempt can be constructed with all fields.""" + now = datetime.now(UTC) + attempt = CodeRunAttempt( + id=1, + coding_task_id=2, + attempt_no=1, + source_code="def solve(): pass", + language="python", + status="success", # type: ignore[arg-type] + stdout="3\n", + stderr=None, + compile_output=None, + tests_passed=1, + tests_total=1, + test_results=(), + duration_ms=120, + created_at=now, + ) + assert attempt.id == 1 + assert attempt.coding_task_id == 2 + assert attempt.attempt_no == 1 + assert attempt.source_code == "def solve(): pass" + assert attempt.language == "python" + assert attempt.status == "success" + assert attempt.stdout == "3\n" + assert attempt.duration_ms == 120 + assert attempt.created_at == now diff --git a/tests/coding/domain/test_task_spec.py b/tests/coding/domain/test_task_spec.py new file mode 100644 index 0000000..9b6fba2 --- /dev/null +++ b/tests/coding/domain/test_task_spec.py @@ -0,0 +1,166 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for coding task spec builders.""" + +from app.coding.domain.task_spec import ( + client_task_spec_from_stored, + task_spec_from_bank_task, +) +from app.shared.coding import CodingSpec, CodingTask, CodingTestCase + + +def test_task_spec_from_bank_task_builds_correct_spec() -> None: + """task_spec_from_bank_task builds correct spec with tests and expected_points.""" + task = CodingTask( + id="algo-001", + difficulty=2, + tags=("sorting",), + text="Sort numbers", + coding=CodingSpec( + language="python", + evaluation_mode="tests", + starter_code="def solve():\n pass", + entrypoint="solve", + public_tests=( + CodingTestCase(name="normal", stdin="1\n2\n", expected_stdout="3\n"), + ), + hidden_tests=( + CodingTestCase(name="edge", stdin="0\n", expected_stdout="0\n"), + ), + time_limit_seconds=2, + memory_limit_kb=65536, + ), + expected_points=("Handle edge cases", "Use efficient algorithm"), + ) + + spec = task_spec_from_bank_task(task) + + assert spec["language"] == "python" + assert spec["evaluation_mode"] == "tests" + assert spec["starter_code"] == "def solve():\n pass" + assert spec["entrypoint"] == "solve" + assert spec["time_limit_seconds"] == 2 + assert spec["memory_limit_kb"] == 65536 + assert spec["public_tests"] == [ + {"name": "normal", "stdin": "1\n2\n", "expected_stdout": "3\n"}, + ] + assert spec["hidden_tests"] == [ + {"name": "edge", "stdin": "0\n", "expected_stdout": "0\n"}, + ] + assert spec["expected_points"] == ["Handle edge cases", "Use efficient algorithm"] + + +def test_task_spec_from_bank_task_with_empty_tests() -> None: + """task_spec_from_bank_task handles empty test lists.""" + task = CodingTask( + id="ai-001", + difficulty=1, + tags=("open-ended",), + text="Explain recursion", + coding=CodingSpec( + language="python", + evaluation_mode="ai", + starter_code="# Your answer here", + public_tests=(), + hidden_tests=(), + ), + expected_points=(), + ) + + spec = task_spec_from_bank_task(task) + + assert spec["public_tests"] == [] + assert spec["hidden_tests"] == [] + assert spec["expected_points"] == [] + assert "entrypoint" not in spec or spec.get("entrypoint") is None + + +def test_client_task_spec_from_stored_strips_hidden_tests() -> None: + """client_task_spec_from_stored removes hidden_tests entirely.""" + stored = { + "language": "python", + "evaluation_mode": "tests", + "starter_code": "pass", + "public_tests": [ + {"name": "normal", "stdin": "1\n", "expected_stdout": "1\n"}, + ], + "hidden_tests": [ + {"name": "secret", "stdin": "42\n", "expected_stdout": "42\n"}, + ], + "expected_points": ["Point 1"], + } + + client_spec = client_task_spec_from_stored(stored) + + assert "hidden_tests" not in client_spec + assert client_spec["public_tests"] == [{"name": "normal"}] + assert client_spec["language"] == "python" + assert client_spec["evaluation_mode"] == "tests" + assert client_spec["starter_code"] == "pass" + assert client_spec["expected_points"] == ["Point 1"] + + +def test_client_task_spec_from_stored_strips_public_test_details() -> None: + """client_task_spec_from_stored strips stdin and expected_stdout from public tests.""" + stored = { + "language": "python", + "public_tests": [ + {"name": "test1", "stdin": "in1", "expected_stdout": "out1"}, + {"name": "test2", "stdin": "in2", "expected_stdout": "out2"}, + ], + } + + client_spec = client_task_spec_from_stored(stored) + + for test in client_spec["public_tests"]: + assert set(test.keys()) == {"name"} + assert "stdin" not in test + assert "expected_stdout" not in test + + +def test_client_task_spec_from_stored_handles_empty_public_tests() -> None: + """client_task_spec_from_stored handles empty public_tests list.""" + stored = { + "language": "python", + "public_tests": [], + "hidden_tests": [], + } + + client_spec = client_task_spec_from_stored(stored) + + assert client_spec["public_tests"] == [] + assert "hidden_tests" not in client_spec + + +def test_client_task_spec_from_stored_handles_missing_public_tests() -> None: + """client_task_spec_from_stored handles missing public_tests key.""" + stored = { + "language": "python", + } + + client_spec = client_task_spec_from_stored(stored) + + assert "public_tests" not in client_spec + + +def test_client_task_spec_from_stored_preserves_other_fields() -> None: + """client_task_spec_from_stored preserves non-test-related fields.""" + stored = { + "language": "python", + "evaluation_mode": "ai", + "starter_code": "# code", + "entrypoint": None, + "time_limit_seconds": 5, + "memory_limit_kb": 1024, + "custom_field": "custom_value", + } + + client_spec = client_task_spec_from_stored(stored) + + assert client_spec["language"] == "python" + assert client_spec["evaluation_mode"] == "ai" + assert client_spec["starter_code"] == "# code" + assert client_spec["entrypoint"] is None + assert client_spec["time_limit_seconds"] == 5 + assert client_spec["memory_limit_kb"] == 1024 + assert client_spec["custom_field"] == "custom_value" diff --git a/tests/coding/domain/test_value_objects.py b/tests/coding/domain/test_value_objects.py new file mode 100644 index 0000000..6b0083c --- /dev/null +++ b/tests/coding/domain/test_value_objects.py @@ -0,0 +1,137 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for coding domain value objects.""" + +from dataclasses import FrozenInstanceError + +import pytest + +from app.coding.domain.value_objects import ( + CaseRunResult, + CodingRunResult, + PlannedCodingTask, +) + + +class TestPlannedCodingTask: + """Tests for the ``PlannedCodingTask`` frozen dataclass.""" + + def test_planned_coding_task_creation(self) -> None: + """PlannedCodingTask can be constructed and fields are accessible.""" + task = PlannedCodingTask( + id="cod-001", + text="Write a function.", + task_spec={"language": "python"}, + ) + assert task.id == "cod-001" + assert task.text == "Write a function." + assert task.task_spec == {"language": "python"} + + def test_planned_coding_task_is_frozen(self) -> None: + """PlannedCodingTask is immutable after creation.""" + task = PlannedCodingTask( + id="cod-001", + text="Write a function.", + task_spec={}, + ) + with pytest.raises(FrozenInstanceError): + task.id = "cod-002" # type: ignore[misc] + + +class TestCaseRunResult: + """Tests for the ``CaseRunResult`` frozen dataclass.""" + + def test_test_case_run_result_creation(self) -> None: + """CaseRunResult can be constructed with all fields.""" + result = CaseRunResult( + name="normal", + passed=True, + expected_stdout="1\n", + actual_stdout="1\n", + stderr=None, + compile_output=None, + judge0_status_id=3, + judge0_status_description="Accepted", + ) + assert result.name == "normal" + assert result.passed is True + assert result.expected_stdout == "1\n" + assert result.judge0_status_id == 3 + + def test_test_case_run_result_is_frozen(self) -> None: + """CaseRunResult is immutable after creation.""" + result = CaseRunResult( + name="normal", + passed=True, + expected_stdout="", + actual_stdout="", + stderr=None, + compile_output=None, + judge0_status_id=None, + judge0_status_description=None, + ) + with pytest.raises(FrozenInstanceError): + result.passed = False # type: ignore[misc] + + +class TestCodingRunResult: + """Tests for the ``CodingRunResult`` frozen dataclass.""" + + def test_coding_run_result_creation(self) -> None: + """CodingRunResult can be constructed with all fields.""" + test_result = CaseRunResult( + name="normal", + passed=True, + expected_stdout="1\n", + actual_stdout="1\n", + stderr=None, + compile_output=None, + judge0_status_id=3, + judge0_status_description="Accepted", + ) + result = CodingRunResult( + status="success", # type: ignore[arg-type] + stdout="1\n", + stderr=None, + compile_output=None, + tests_passed=1, + tests_total=1, + test_results=(test_result,), + duration_ms=120, + ) + assert result.status == "success" + assert result.tests_passed == 1 + assert result.tests_total == 1 + assert len(result.test_results) == 1 + assert result.test_results[0].name == "normal" + assert result.duration_ms == 120 + + def test_coding_run_result_is_frozen(self) -> None: + """CodingRunResult is immutable after creation.""" + result = CodingRunResult( + status="success", # type: ignore[arg-type] + stdout=None, + stderr=None, + compile_output=None, + tests_passed=0, + tests_total=0, + test_results=(), + duration_ms=None, + ) + with pytest.raises(FrozenInstanceError): + result.tests_passed = 1 # type: ignore[misc] + + def test_coding_run_result_with_empty_test_results(self) -> None: + """CodingRunResult works with empty test results.""" + result = CodingRunResult( + status="compile_error", # type: ignore[arg-type] + stdout=None, + stderr=None, + compile_output="SyntaxError", + tests_passed=0, + tests_total=0, + test_results=(), + duration_ms=None, + ) + assert result.status == "compile_error" + assert result.test_results == () diff --git a/tests/conftest.py b/tests/conftest.py index 26a78bb..36a841a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ """Pytest configuration and shared fixtures.""" from collections.abc import Callable +from typing import Any from unittest.mock import AsyncMock, patch from fastapi.testclient import TestClient @@ -11,6 +12,8 @@ from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool +from app.coding.domain.value_objects import CodingRunResult +from app.coding.services.judge0_client import Judge0Client, Judge0SubmissionResult from app.interview.repositories.uow import InterviewUnitOfWork from app.main import create_app from app.platform.services.config import AppConfig @@ -84,6 +87,110 @@ def _make(replies: list[str]) -> FakeProvider: return _make +def fake_judge0_client(return_result: CodingRunResult | None = None) -> Judge0Client: + """Build a Judge0Client whose ``submit`` always returns a predetermined result. + + Args: + return_result: Result returned by ``submit``; defaults to a fake success run. + + Returns: + Stubbed ``Judge0Client`` usable via ``CodingRunnerService.run_public_tests``. + """ + from tests.helpers.fake_judge0 import fake_coding_run_result + + result = return_result or fake_coding_run_result() + client = Judge0Client(base_url="http://fake-judge0", timeout_seconds=5.0) + + async def _stub_submit(**_kwargs: Any) -> Judge0SubmissionResult: + # Map CodingRunResult back to Judge0SubmissionResult + status_id = 3 if result.status == "success" else 6 + return Judge0SubmissionResult( + status_id=status_id, + status_description="Accepted" if result.status == "success" else "Error", + stdout=result.stdout, + stderr=result.stderr, + compile_output=result.compile_output, + time=str(result.duration_ms / 1000) if result.duration_ms else None, + memory=None, + ) + + client.submit = _stub_submit # type: ignore[method-assign] + return client + + +@pytest.fixture +def mock_judge0(monkeypatch): + """Globally patch ``CodingRunnerService`` to use a fake Judge0. + + The returned helper can be called per-test to set a custom result:: + + def test_run(mock_judge0): + mock_judge0(status="compile_error") + ... + + Yields: + Callable ``(status=...) -> None`` that reconfigures the global patch. + """ + from app.coding.services.runner import CodingRunnerService + from tests.helpers.fake_judge0 import ( + FakeRunConfig, + fake_coding_run_result, + fake_compile_error_result, + fake_tests_failed_result, + ) + + _current_result: CodingRunResult = fake_coding_run_result() + + async def _fake_run_public_tests( + *, + source_code: str, + task_spec: dict[str, Any], + client: Judge0Client | None = None, + ) -> CodingRunResult: + del source_code, task_spec, client + return _current_result + + async def _fake_run_hidden_tests( + *, + source_code: str, + task_spec: dict[str, Any], + client: Judge0Client | None = None, + ) -> CodingRunResult: + del source_code, task_spec, client + return _current_result + + monkeypatch.setattr( + CodingRunnerService, + "run_public_tests", + staticmethod(_fake_run_public_tests), # type: ignore[arg-type] + ) + monkeypatch.setattr( + CodingRunnerService, + "run_hidden_tests", + staticmethod(_fake_run_hidden_tests), # type: ignore[arg-type] + ) + + def _configure( + *, + status: str | None = None, + config: FakeRunConfig | None = None, + ) -> None: + nonlocal _current_result + if status == "compile_error": + _current_result = fake_compile_error_result() + elif status == "tests_failed": + _current_result = fake_tests_failed_result() + elif config is not None: + _current_result = fake_coding_run_result(config) + else: + _current_result = fake_coding_run_result() + + yield _configure + + # Reset to default after each test + _current_result = fake_coding_run_result() + + @pytest.fixture def override_ws_ai_provider() -> Callable: """Override the interview WebSocket AI provider dependency on a test client. @@ -117,4 +224,25 @@ def uow(isolated_db): yield work +@pytest.fixture +def minimal_config_saved(client): + """Save a minimal provider configuration so routes do not redirect to /config. + + Yields: + Response from the POST /config save call. + """ + response = client.post( + "/config", + data={ + "llm_preset_id": "preset-fake", + "api_key": "", + "timeout": "60", + "locale": "en", + "speech_model_size": "small", + "question_voice_enabled": "", + }, + ) + yield response + + pytest_plugins = ["tests.shared.test_questions"] diff --git a/tests/e2e/test_e2e_full_lifecycle.py b/tests/e2e/test_e2e_full_lifecycle.py new file mode 100644 index 0000000..b5ae654 --- /dev/null +++ b/tests/e2e/test_e2e_full_lifecycle.py @@ -0,0 +1,248 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""E2E test: full lifecycle — theory only, mark known, exclude known next session.""" + +from unittest.mock import patch + +from app.interview.domain.serialization import session_to_spec +from app.interview.domain.value_objects import ( + SectionBranchSpec, + SessionSelection, + TrackSelection, +) +from app.interview.repositories.uow import InterviewUnitOfWork +from app.interview.services.query import InterviewQuery +from app.platform.services.config import AppConfig +from tests.fakes import answer_evaluation_json + + +class TestE2EFullLifecycle: + """End-to-end: config → theory session → mark known → new session excludes known.""" + + def _config(self) -> AppConfig: + return AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + locale="en", + ) + + def test_theory_only_full_cycle(self, client, isolated_db, override_ws_ai_provider): + """E2E-1: full theory cycle.""" + with patch( + "app.platform.services.config.ConfigService.get_config", + return_value=self._config(), + ): + session = SessionSelection.theory_only( + sources=( + TrackSelection( + track="python", + level="junior", + categories=("basics",), + ), + ), + question_count=2, + ) + response = client.post( + "/setup", + data={ + "selection_json": session_to_spec(session), + "question_count": "2", + }, + follow_redirects=False, + ) + assert response.status_code == 303 + interview_id = response.headers["location"].rsplit("/", 1)[-1] + + # Получаем текущие question_id через query + from app.interview.repositories.uow import InterviewUnitOfWork + + with InterviewUnitOfWork() as uow: + interview = InterviewQuery(uow).get_interview(interview_id) + question_ids = [a.question_id for a in interview.answers] + assert len(question_ids) == 2 + + override_ws_ai_provider( + client, + [ + answer_evaluation_json(score=4, follow_up_needed=False), + answer_evaluation_json(score=5, follow_up_needed=False), + ], + ) + with client.websocket_connect(f"/interview/{interview_id}/theory/ws") as ws: + # Q1 + ws.send_json( + { + "type": "answer", + "question_id": question_ids[0], + "answer_text": "A1", + } + ) + assert ws.receive_json() == {"type": "saved"} + assert ws.receive_json() == {"type": "evaluating"} + fb1 = ws.receive_json() + assert fb1["type"] == "feedback" + # Q2 + ws.send_json( + { + "type": "answer", + "question_id": question_ids[1], + "answer_text": "A2", + } + ) + assert ws.receive_json() == {"type": "saved"} + assert ws.receive_json() == {"type": "evaluating"} + fb2 = ws.receive_json() + assert fb2["type"] == "feedback" + # Complete + ws.send_json({"type": "complete"}) + assert ws.receive_json() == {"type": "evaluating"} + completed = ws.receive_json() + assert completed["type"] == "interview_completed" + + # Results + review + assert client.get(f"/interview/{interview_id}/results").status_code == 200 + assert client.get(f"/interview/{interview_id}/theory").status_code == 200 + + def test_coding_only_full_cycle( + self, client, isolated_db, mock_judge0, override_ws_ai_provider + ): + """E2E-2: full coding cycle.""" + with ( + patch( + "app.platform.services.config.ConfigService.get_config", + return_value=self._config(), + ), + patch( + "app.interview.services.rules.selection.is_coding_available", + return_value=True, + ), + ): + session = SessionSelection( + session_mode="coding_only", + theory=SectionBranchSpec( + enabled=False, + question_count=0, + task_time_limit_seconds=None, + sources=(), + ), + coding=SectionBranchSpec( + enabled=True, + question_count=1, + task_time_limit_seconds=None, + sources=( + TrackSelection( + track="python", + level="junior", + categories=("basics",), + ), + ), + ), + ) + response = client.post( + "/setup", + data={ + "selection_json": session_to_spec(session), + "question_count": "0", + "coding_question_count": "1", + }, + follow_redirects=False, + ) + assert response.status_code == 303 + interview_id = response.headers["location"].rsplit("/", 1)[-1] + + # Получаем фактический task_id из coding state endpoint + state = client.get(f"/interview/{interview_id}/coding/state").json() + task_id = state["current_task"]["task_id"] + + mock_judge0() + evaluation = { + "score": 5, + "feedback": "Perfect.", + "follow_up_needed": False, + "follow_up_question": None, + } + + from unittest.mock import AsyncMock + + from app.coding.services.evaluator.models import CodingAnswerEvaluation + + eval_obj = CodingAnswerEvaluation(**evaluation) + + override_ws_ai_provider(client, []) + with ( + patch( + "app.coding.services.submission.CodingEvaluatorService.evaluate_submission", + new=AsyncMock(return_value=(eval_obj, False, None, None)), + ), + client.websocket_connect(f"/interview/{interview_id}/coding/ws") as ws, + ): + # Run + run_resp = client.post( + f"/interview/{interview_id}/coding/run", + json={"task_id": task_id, "source_code": "def solve(): return 42"}, + ) + assert run_resp.status_code == 200 + # Submit + ws.send_json( + { + "type": "submit", + "task_id": task_id, + "source_code": "def solve(): return 42", + } + ) + assert ws.receive_json() == {"type": "saved"} + assert ws.receive_json() == {"type": "evaluating"} + fb = ws.receive_json() + assert fb["type"] == "feedback" + + # Results + results = client.get(f"/interview/{interview_id}/results") + assert results.status_code == 200 + + def test_mark_known_then_exclude_known( + self, client, isolated_db, override_ws_ai_provider + ): + """E2E-4: mark known → create new session with exclude_known.""" + # Mark a question as known + response = client.post( + "/known-questions", + json={"branch": "theory", "item_id": "q-known-exclude"}, + ) + assert response.status_code == 200 + + # Create session + with patch( + "app.platform.services.config.ConfigService.get_config", + return_value=self._config(), + ): + session = SessionSelection.theory_only( + sources=( + TrackSelection( + track="python", + level="junior", + categories=("basics",), + ), + ), + question_count=3, + ) + import json + + spec = json.loads(session_to_spec(session)) + spec["exclude_known"] = True + response = client.post( + "/setup", + data={ + "selection_json": json.dumps(spec), + "question_count": "3", + }, + follow_redirects=False, + ) + assert response.status_code == 303 + interview_id = response.headers["location"].rsplit("/", 1)[-1] + + # Verify session created successfully + with InterviewUnitOfWork() as uow: + interview = uow.interviews.get_aggregate(interview_id) + assert interview is not None + assert interview.status == "active" diff --git a/tests/helpers/coding_seed.py b/tests/helpers/coding_seed.py index 6b62e57..ceacc80 100644 --- a/tests/helpers/coding_seed.py +++ b/tests/helpers/coding_seed.py @@ -23,6 +23,7 @@ def create_coding_section_for_interview( task_count: int = 2, task_time_limit_seconds: int | None = None, status: str = "active", + selection_spec: str | None = None, ) -> CodingSection: """Insert a coding section row matching an interview shell. @@ -32,13 +33,16 @@ def create_coding_section_for_interview( task_count: Number of coding tasks in the section. task_time_limit_seconds: Optional per-task timer in seconds. status: Section status to persist. + selection_spec: Optional coding-specific selection spec; defaults to interview.selection_spec. Returns: Persisted coding section with assigned primary key. """ section = CodingSection( interview_id=interview.id, - selection_spec=interview.selection_spec, + selection_spec=selection_spec + if selection_spec is not None + else interview.selection_spec, task_count=task_count, task_time_limit_seconds=task_time_limit_seconds, locale=interview.locale or "en", diff --git a/tests/helpers/fake_judge0.py b/tests/helpers/fake_judge0.py new file mode 100644 index 0000000..5795742 --- /dev/null +++ b/tests/helpers/fake_judge0.py @@ -0,0 +1,147 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Test doubles for Judge0 integration in coding tests.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from app.coding.domain.value_objects import CaseRunResult, CodingRunResult + + +@dataclass(frozen=True, slots=True) +class FakeRunConfig: + """Configuration for a fake Judge0 run result. + + Attributes: + status: High-level run outcome (success, compile_error, etc.). + tests_passed: How many public tests passed. + tests_total: How many public tests were executed. + stdout: Last captured stdout. + stderr: Last captured stderr. + compile_output: Compile diagnostics. + duration_ms: Execution duration. + """ + + status: str = "success" + tests_passed: int = 2 + tests_total: int = 2 + stdout: str | None = "42\n" + stderr: str | None = None + compile_output: str | None = None + duration_ms: int | None = 100 + + +def fake_coding_run_result(config: FakeRunConfig | None = None) -> CodingRunResult: + """Build a deterministic ``CodingRunResult`` for test doubles. + + Args: + config: Optional configuration overrides. + + Returns: + A ``CodingRunResult`` matching the fake Judge0 response shape. + """ + cfg = config or FakeRunConfig() + test_results: tuple[CaseRunResult, ...] = ( + CaseRunResult( + name="test_1", + passed=True, + expected_stdout="42", + actual_stdout="42", + stderr=None, + compile_output=None, + judge0_status_id=3, + judge0_status_description="Accepted", + ), + CaseRunResult( + name="test_2", + passed=True, + expected_stdout="42\n", + actual_stdout="42\n", + stderr=None, + compile_output=None, + judge0_status_id=3, + judge0_status_description="Accepted", + ), + ) + if cfg.tests_total == 0: + test_results = () + return CodingRunResult( + status=cfg.status, # type: ignore[arg-type] + stdout=cfg.stdout, + stderr=cfg.stderr, + compile_output=cfg.compile_output, + tests_passed=cfg.tests_passed, + tests_total=cfg.tests_total, + test_results=test_results, + duration_ms=cfg.duration_ms, + ) + + +def fake_compile_error_result( + compile_output: str = "SyntaxError: invalid syntax", +) -> CodingRunResult: + """Return a compile-error run result for negative tests. + + Args: + compile_output: Compiler diagnostic text. + + Returns: + CodingRunResult with ``compile_error`` status. + """ + return CodingRunResult( + status="compile_error", + stdout=None, + stderr=None, + compile_output=compile_output, + tests_passed=0, + tests_total=0, + test_results=(), + duration_ms=50, + ) + + +def fake_tests_failed_result( + expected: str = "42", + actual: str = "0", +) -> CodingRunResult: + """Return a tests-failed run result for negative tests. + + Args: + expected: Expected stdout for the failing test. + actual: Actual stdout from the run. + + Returns: + CodingRunResult with ``tests_failed`` status. + """ + return CodingRunResult( + status="tests_failed", + stdout=actual, + stderr=None, + compile_output=None, + tests_passed=0, + tests_total=2, + test_results=( + CaseRunResult( + name="test_1", + passed=False, + expected_stdout=expected, + actual_stdout=actual, + stderr=None, + compile_output=None, + judge0_status_id=3, + judge0_status_description="Accepted", + ), + CaseRunResult( + name="test_2", + passed=True, + expected_stdout="42", + actual_stdout="42", + stderr=None, + compile_output=None, + judge0_status_id=3, + judge0_status_description="Accepted", + ), + ), + duration_ms=120, + ) diff --git a/tests/helpers/known_questions_seed.py b/tests/helpers/known_questions_seed.py new file mode 100644 index 0000000..437229a --- /dev/null +++ b/tests/helpers/known_questions_seed.py @@ -0,0 +1,17 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Test helpers for seeding known questions directly via service.""" + +from app.interview.repositories.uow import InterviewUnitOfWork +from app.interview.services.known_questions import KnownQuestionsService + + +def seed_known_question(branch: str, item_id: str) -> None: + """Persist a known-question entry directly via the service. + + Args: + branch: Either ``theory`` or ``coding``. + item_id: YAML bank item identifier. + """ + with InterviewUnitOfWork(auto_commit=True) as uow: + KnownQuestionsService(uow).mark_known(branch, item_id) diff --git a/tests/interview/api/test_combined_phase_switch.py b/tests/interview/api/test_combined_phase_switch.py new file mode 100644 index 0000000..d0d3e32 --- /dev/null +++ b/tests/interview/api/test_combined_phase_switch.py @@ -0,0 +1,238 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Phase switching tests for combined modes (theory_then_coding, coding_then_theory).""" + +from unittest.mock import AsyncMock, patch + +from app.coding.domain.value_objects import CodingRunResult +from app.coding.services.evaluator.models import CodingAnswerEvaluation +from app.interview.domain.serialization import selection_to_spec +from app.interview.domain.value_objects import ( + SectionBranchSpec, + SessionSelection, + TrackSelection, +) +from app.interview.repositories.uow import InterviewUnitOfWork +from app.interview.services.query import InterviewQuery +from app.shared.infrastructure.models import Answer, Interview +from tests.fakes import answer_evaluation_json +from tests.helpers.coding_seed import ( + attach_coding_tasks, + create_coding_section_for_interview, +) +from tests.helpers.interview_seed import persist_interview_with_answers +from tests.helpers.selection import minimal_selection_spec + + +def _success_run_result() -> CodingRunResult: + return CodingRunResult( + status="success", + stdout=None, + stderr=None, + compile_output=None, + tests_passed=0, + tests_total=0, + test_results=(), + duration_ms=12, + ) + + +class TestCombinedPhaseSwitch: + """Tests for combined session modes.""" + + def test_theory_then_coding_switch_after_theory_finish( + self, client, isolated_db, override_ws_ai_provider + ): + """When theory completes, coding section activates.""" + interview_id = persist_interview_with_answers( + Interview( + id="combined-tc-1", + locale="en", + selection_spec=minimal_selection_spec(categories=["basics"]), + status="active", + session_mode="theory_then_coding", + ), + [ + Answer( + question_id="q1", + order=1, + round=0, + question_text="Q?", + ), + ], + question_count=1, + ) + # Attach coding section in pending state + coding_selection_spec = selection_to_spec( + SessionSelection( + session_mode="theory_then_coding", + theory=SectionBranchSpec( + enabled=True, + question_count=1, + task_time_limit_seconds=None, + sources=(), + ), + coding=SectionBranchSpec( + enabled=True, + question_count=1, + task_time_limit_seconds=None, + sources=( + TrackSelection( + track="python", level="junior", categories=("basics",) + ), + ), + ), + ).coding_selection + ) + with InterviewUnitOfWork(auto_commit=True) as uow: + from app.shared.infrastructure.models import Interview as InterviewModel + + db_interview = ( + uow.session.query(InterviewModel).filter_by(id=interview_id).one() + ) + section = create_coding_section_for_interview( + uow.session, + db_interview, + task_count=1, + status="pending", + selection_spec=coding_selection_spec, + ) + attach_coding_tasks(uow.session, section, task_ids=["cod-001"]) + + # Answer theory question (need two replies: one for answer, one for session completion) + override_ws_ai_provider( + client, + [ + answer_evaluation_json(score=5, follow_up_needed=False), + answer_evaluation_json(score=5, follow_up_needed=False), + ], + ) + with client.websocket_connect(f"/interview/{interview_id}/theory/ws") as ws: + ws.send_json( + { + "type": "answer", + "question_id": "q1", + "answer_text": "Done", + } + ) + assert ws.receive_json() == {"type": "saved"} + assert ws.receive_json() == {"type": "evaluating"} + fb = ws.receive_json() + assert fb["type"] == "feedback" + # Complete theory section + ws.send_json({"type": "complete"}) + assert ws.receive_json() == {"type": "evaluating"} + completed = ws.receive_json() + assert completed["type"] == "interview_completed" + + # Coding section should now be active + state = client.get(f"/interview/{interview_id}/coding/state") + assert state.status_code == 200 + assert state.json()["section_status"] == "active" + + def test_coding_then_theory_switch_after_coding_finish( + self, client, isolated_db, mock_judge0, override_ws_ai_provider + ): + """When coding completes, theory section activates.""" + interview_id = "combined-ct-1" + coding_selection_spec = selection_to_spec( + SessionSelection( + session_mode="coding_then_theory", + theory=SectionBranchSpec( + enabled=True, + question_count=1, + task_time_limit_seconds=None, + sources=(), + ), + coding=SectionBranchSpec( + enabled=True, + question_count=1, + task_time_limit_seconds=None, + sources=( + TrackSelection( + track="python", level="junior", categories=("basics",) + ), + ), + ), + ).coding_selection + ) + with InterviewUnitOfWork(auto_commit=True) as uow: + from app.shared.infrastructure.models import Answer as AnswerModel + from app.shared.infrastructure.models import Interview as InterviewModel + from app.shared.infrastructure.models import ( + TheorySection as TheorySectionModel, + ) + + db_interview = InterviewModel( + id=interview_id, + locale="en", + selection_spec=minimal_selection_spec(categories=["basics"]), + status="active", + session_mode="coding_then_theory", + ) + uow.session.add(db_interview) + uow.flush() + section = create_coding_section_for_interview( + uow.session, + db_interview, + task_count=1, + status="active", + selection_spec=coding_selection_spec, + ) + attach_coding_tasks(uow.session, section, task_ids=["cod-001"]) + # Add theory section manually (no add_from_selection method) + theory_section = TheorySectionModel( + interview_id=interview_id, + selection_spec=db_interview.selection_spec, + locale="en", + status="pending", + ) + uow.session.add(theory_section) + uow.session.flush() + # Add answer row + uow.session.add( + AnswerModel( + theory_section_id=theory_section.id, + question_id="q1", + order=1, + round=0, + question_text="Q?", + ) + ) + + mock_judge0() + evaluation = CodingAnswerEvaluation( + score=5, + feedback="Perfect.", + follow_up_needed=False, + follow_up_question=None, + follow_up_mode=None, + ) + override_ws_ai_provider(client, []) + with ( + patch( + "app.coding.services.submission.CodingEvaluatorService.evaluate_submission", + new=AsyncMock(return_value=(evaluation, False, None, None)), + ), + client.websocket_connect(f"/interview/{interview_id}/coding/ws") as ws, + ): + ws.send_json( + { + "type": "submit", + "task_id": "cod-001", + "source_code": "pass", + } + ) + assert ws.receive_json() == {"type": "saved"} + assert ws.receive_json() == {"type": "evaluating"} + fb = ws.receive_json() + assert fb["type"] == "feedback" + assert fb.get("next_task") is None + + # Interview page should now show theory + reloaded = InterviewQuery.load(interview_id) + assert reloaded is not None + # Theory section should be discoverable + with InterviewUnitOfWork() as uow: + theory = uow.theory_sections.get_aggregate(interview_id) + assert theory is not None diff --git a/tests/interview/api/test_dashboard.py b/tests/interview/api/test_dashboard.py new file mode 100644 index 0000000..93a82c9 --- /dev/null +++ b/tests/interview/api/test_dashboard.py @@ -0,0 +1,151 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for dashboard HTTP API (interview/api/dashboard.py).""" + +from datetime import UTC, datetime, timedelta +from unittest.mock import patch + +from app.platform.services.config import AppConfig +from app.shared.infrastructure.models import Interview +from tests.helpers.interview_seed import persist_interview_with_answers +from tests.helpers.selection import minimal_selection_spec + + +def _config_with_locale(locale: str = "en") -> AppConfig: + return AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + locale=locale, + ) + + +class TestDashboardPage: + """Tests for GET /.""" + + def test_empty_state(self, client, isolated_db): + """Dashboard shows welcome when no sessions exist.""" + with patch( + "app.platform.services.config.ConfigService.get_config", + return_value=_config_with_locale(), + ): + response = client.get("/") + assert response.status_code == 200 + assert ( + "Start your first interview" in response.text + or "interview" in response.text.lower() + ) + + def test_shows_recent_sessions(self, client, isolated_db): + """Dashboard lists up to 20 recent sessions.""" + # Seed 3 sessions + for i in range(3): + persist_interview_with_answers( + Interview( + id=f"dash-{i}", + locale="en", + selection_spec=minimal_selection_spec(categories=["basics"]), + status="active", + started_at=datetime.now(UTC) - timedelta(minutes=i), + ), + [], + question_count=5, + ) + + with patch( + "app.platform.services.config.ConfigService.get_config", + return_value=_config_with_locale(), + ): + response = client.get("/") + assert response.status_code == 200 + assert "dash-0" in response.text # most recent + assert "dash-1" in response.text + assert "dash-2" in response.text + + def test_sort_order_desc(self, client, isolated_db): + """Sessions are sorted by started_at DESC.""" + for i in range(3): + persist_interview_with_answers( + Interview( + id=f"dash-sort-{i}", + locale="en", + selection_spec=minimal_selection_spec(categories=["basics"]), + status="active", + started_at=datetime.now(UTC) - timedelta(hours=i), + ), + [], + question_count=5, + ) + + with patch( + "app.platform.services.config.ConfigService.get_config", + return_value=_config_with_locale(), + ): + response = client.get("/") + assert response.status_code == 200 + text = response.text + # newest should appear before older + assert text.index("dash-sort-0") < text.index("dash-sort-1") + assert text.index("dash-sort-1") < text.index("dash-sort-2") + + def test_completed_session_links_to_results(self, client, isolated_db): + """Completed sessions have 'View results' link pointing to /results.""" + from tests.helpers.completed_session_seed import seed_completed_theory_interview + + interview_id = seed_completed_theory_interview("dash-results-1") + + with patch( + "app.platform.services.config.ConfigService.get_config", + return_value=_config_with_locale(), + ): + response = client.get("/") + assert response.status_code == 200 + assert f"/interview/{interview_id}/results" in response.text + + def test_active_session_has_continue_link(self, client, isolated_db): + """Active sessions have 'Continue' link pointing to /interview/{id}.""" + interview_id = "dash-continue-1" + persist_interview_with_answers( + Interview( + id=interview_id, + locale="en", + selection_spec=minimal_selection_spec(categories=["basics"]), + status="active", + started_at=datetime.now(UTC), + ), + [], + question_count=5, + ) + + with patch( + "app.platform.services.config.ConfigService.get_config", + return_value=_config_with_locale(), + ): + response = client.get("/") + assert response.status_code == 200 + assert f"/interview/{interview_id}" in response.text + + def test_limit_20_sessions(self, client, isolated_db): + """Dashboard caps at 20 sessions.""" + for i in range(25): + persist_interview_with_answers( + Interview( + id=f"dash-limit-{i:02d}", + locale="en", + selection_spec=minimal_selection_spec(categories=["basics"]), + status="active", + started_at=datetime.now(UTC) - timedelta(minutes=i), + ), + [], + question_count=5, + ) + + with patch( + "app.platform.services.config.ConfigService.get_config", + return_value=_config_with_locale(), + ): + response = client.get("/") + assert response.status_code == 200 + # The 21st (index 20) should NOT appear + assert "dash-limit-20" not in response.text + assert "dash-limit-00" in response.text diff --git a/tests/interview/api/test_negative_scenarios.py b/tests/interview/api/test_negative_scenarios.py new file mode 100644 index 0000000..9c801fb --- /dev/null +++ b/tests/interview/api/test_negative_scenarios.py @@ -0,0 +1,96 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Negative scenarios: 404s, bad UUID, bad WS msg, invalid WAV, active→results 404.""" + +from app.interview.repositories.uow import InterviewUnitOfWork +from app.interview.services.query import InterviewQuery +from app.shared.infrastructure.models import Answer, Interview +from tests.fakes import answer_evaluation_json +from tests.helpers.interview_seed import persist_interview_with_answers +from tests.helpers.selection import minimal_selection_spec + + +class TestNegativeScenarios: + """Tests for error handling and edge cases.""" + + def test_invalid_interview_uuid(self, client, isolated_db): + """GET /interview/{invalid-uuid} redirects to home.""" + response = client.get("/interview/not-a-uuid", follow_redirects=False) + assert response.status_code == 303 + assert response.headers["location"] == "/" + + def test_missing_interview(self, client, isolated_db): + """GET /interview/{nonexistent} redirects to home.""" + response = client.get( + "/interview/00000000-0000-0000-0000-000000000000", follow_redirects=False + ) + assert response.status_code == 303 + assert response.headers["location"] == "/" + + def test_results_for_active_session_404(self, client, isolated_db): + """Results page redirects active sessions back to interview.""" + interview_id = persist_interview_with_answers( + Interview( + id="neg-active-1", + locale="en", + selection_spec=minimal_selection_spec(), + status="active", + ), + [Answer(question_id="q1", order=1, round=0, question_text="Q?")], + question_count=1, + ) + response = client.get( + f"/interview/{interview_id}/results", follow_redirects=False + ) + assert response.status_code == 303 + assert response.headers["location"] == f"/interview/{interview_id}" + + def test_answered_question_is_idempotent( + self, client, isolated_db, override_ws_ai_provider + ): + """S13.9: Answering a completed question returns no-op or error; session stays valid.""" + + interview_id = persist_interview_with_answers( + Interview( + id="neg-race-1", + locale="en", + selection_spec=minimal_selection_spec(), + status="active", + ), + [Answer(question_id="q1", order=1, round=0, question_text="Q?")], + question_count=1, + ) + + override_ws_ai_provider( + client, [answer_evaluation_json(score=5, follow_up_needed=False)] + ) + + # First answer + with client.websocket_connect(f"/interview/{interview_id}/theory/ws") as ws: + ws.send_json( + {"type": "answer", "question_id": "q1", "answer_text": "first"} + ) + for _ in range(5): + try: + msg = ws.receive_json(timeout=1.0) + if msg.get("type") == "feedback": + break + except Exception: + break + + # Second connection answering same (already-answered) question — should not crash + with client.websocket_connect(f"/interview/{interview_id}/theory/ws") as ws2: + ws2.send_json( + {"type": "answer", "question_id": "q1", "answer_text": "second"} + ) + for _ in range(2): + try: + ws2.receive_json(timeout=0.5) + except Exception: + break + + # Session still valid + with InterviewUnitOfWork() as uow: + interview = InterviewQuery(uow).get_interview(interview_id) + assert interview is not None + assert interview.status == "active" diff --git a/tests/interview/api/test_results_api.py b/tests/interview/api/test_results_api.py new file mode 100644 index 0000000..4b0bf03 --- /dev/null +++ b/tests/interview/api/test_results_api.py @@ -0,0 +1,74 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Results & review API tests.""" + +from app.shared.infrastructure.models import Answer, Interview +from tests.helpers.completed_session_seed import ( + seed_completed_coding_interview, + seed_completed_theory_interview, +) +from tests.helpers.interview_seed import persist_interview_with_answers +from tests.helpers.selection import minimal_selection_spec + + +class TestResultsApi: + """Tests for completed session results endpoints.""" + + def test_results_renders_for_completed_theory(self, client, isolated_db): + """GET /results shows evaluation for completed theory session.""" + interview_id = seed_completed_theory_interview("results-t-1") + response = client.get(f"/interview/{interview_id}/results") + assert response.status_code == 200 + assert "Overall Evaluation" in response.text + assert "Good theory performance." in response.text + + def test_results_renders_for_completed_coding(self, client, isolated_db): + """GET /results shows evaluation for completed coding session.""" + interview_id = seed_completed_coding_interview("results-c-1") + response = client.get(f"/interview/{interview_id}/results") + assert response.status_code == 200 + assert "Good coding performance." in response.text + + def test_theory_review_shows_chat_history(self, client, isolated_db): + """GET /theory shows full Q&A history for completed sessions.""" + interview_id = seed_completed_theory_interview("review-t-1") + response = client.get(f"/interview/{interview_id}/theory") + assert response.status_code == 200 + assert "What is Python?" in response.text + assert "A programming language" in response.text + + def test_coding_review_shows_tasks_and_code(self, client, isolated_db): + """GET /coding shows per-task accordion with code.""" + interview_id = seed_completed_coding_interview("review-c-1") + response = client.get(f"/interview/{interview_id}/coding") + assert response.status_code == 200 + assert "def solve()" in response.text + + def test_theory_review_active_session_redirects(self, client, isolated_db): + """Theory review for active sessions redirects to results.""" + interview_id = persist_interview_with_answers( + Interview( + id="review-active-1", + locale="en", + selection_spec=minimal_selection_spec(), + status="active", + ), + [Answer(question_id="q1", order=1, round=0, question_text="Q?")], + question_count=1, + ) + response = client.get( + f"/interview/{interview_id}/theory", follow_redirects=False + ) + assert response.status_code == 303 + assert response.headers["location"] == f"/interview/{interview_id}/results" + + def test_coding_review_active_session_redirects(self, client, isolated_db): + """Coding review for active sessions redirects to results.""" + from tests.helpers.coding_seed import seed_active_coding_interview + + interview_id, _ = seed_active_coding_interview("review-active-c") + response = client.get( + f"/interview/{interview_id}/coding", follow_redirects=False + ) + assert response.status_code == 303 + assert response.headers["location"] == f"/interview/{interview_id}/results" diff --git a/tests/interview/api/test_review_mark_known.py b/tests/interview/api/test_review_mark_known.py new file mode 100644 index 0000000..d8c409b --- /dev/null +++ b/tests/interview/api/test_review_mark_known.py @@ -0,0 +1,52 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Review mark-as-known integration tests.""" + +from app.interview.repositories.uow import InterviewUnitOfWork +from app.interview.services.known_questions import KnownQuestionsService +from tests.helpers.completed_session_seed import seed_completed_theory_interview + + +class TestReviewMarkKnown: + """Mark known from theory and coding review pages.""" + + def test_mark_known_from_theory_review(self, client, isolated_db): + """POST adds theory question to known list.""" + seed_completed_theory_interview("mark-theory-1") + response = client.post( + "/known-questions", + json={"branch": "theory", "item_id": "q-mark-1"}, + ) + assert response.status_code == 200 + known = client.get("/known-questions").json() + assert "q-mark-1" in known.get("theory", []) + + def test_mark_known_from_coding_review(self, client, isolated_db): + """POST adds coding task to known list.""" + response = client.post( + "/known-questions", + json={"branch": "coding", "item_id": "cod-mark-1"}, + ) + assert response.status_code == 200 + known = client.get("/known-questions").json() + assert "cod-mark-1" in known.get("coding", []) + + def test_remove_known_question(self, client, isolated_db): + """DELETE removes item from known list.""" + client.post("/known-questions", json={"branch": "theory", "item_id": "q-rm-1"}) + response = client.request( + "DELETE", + "/known-questions", + json={"branch": "theory", "item_id": "q-rm-1"}, + ) + assert response.status_code == 200 + known = client.get("/known-questions").json() + assert "q-rm-1" not in known.get("theory", []) + + def test_known_questions_manage_page(self, client, isolated_db): + """GET /manage renders HTML table with known questions.""" + with InterviewUnitOfWork(auto_commit=True) as uow: + KnownQuestionsService(uow).mark_known("theory", "q-manage-1") + response = client.get("/known-questions/manage") + assert response.status_code == 200 + assert "q-manage-1" in response.text diff --git a/tests/interview/api/test_setup_exclude_known.py b/tests/interview/api/test_setup_exclude_known.py new file mode 100644 index 0000000..5c343e5 --- /dev/null +++ b/tests/interview/api/test_setup_exclude_known.py @@ -0,0 +1,91 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for exclude_known in setup and session creation.""" + +from unittest.mock import patch + +from app.interview.domain.serialization import session_to_spec +from app.interview.domain.value_objects import ( + SessionSelection, + TrackSelection, +) +from app.interview.repositories.uow import InterviewUnitOfWork +from app.platform.services.config import AppConfig +from tests.helpers.known_questions_seed import seed_known_question + + +class TestSetupExcludeKnown: + """Tests that exclude_known=true excludes known questions from session plan.""" + + def _config(self) -> AppConfig: + return AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + locale="en", + ) + + def test_exclude_known_reads_from_json(self, client, isolated_db): + """selection_json with exclude_known=true is parsed correctly.""" + from app.interview.services.rules.selection import parse_session_json + + raw = ( + '{"version":2,"session_mode":"theory_only","exclude_known":true,' + '"theory":{"enabled":true,"question_count":5,' + '"task_time_limit_seconds":null,' + '"sources":[{"track":"python","level":"junior",' + '"categories":["basics"]}]},' + '"coding":{"enabled":false}}' + ) + session = parse_session_json(raw) + assert session.exclude_known is True + + def test_known_question_can_be_marked(self, client, isolated_db): + """POST /known-questions adds a theory item to known list.""" + response = client.post( + "/known-questions", + json={"branch": "theory", "item_id": "q-known-1"}, + follow_redirects=False, + ) + assert response.status_code == 200 + known = client.get("/known-questions").json() + assert "theory" in known + assert "q-known-1" in known["theory"] + + def test_exclude_known_prevents_known_in_new_session(self, client, isolated_db): + """Known questions are excluded from session plan when flag is set.""" + # Mark a question as known + seed_known_question("theory", "q-exclude-1") + + session = SessionSelection.theory_only( + sources=( + TrackSelection( + track="python", + level="junior", + categories=("basics",), + ), + ), + question_count=5, + ) + with patch( + "app.platform.services.config.ConfigService.get_config", + return_value=self._config(), + ): + response = client.post( + "/setup", + data={ + "selection_json": session_to_spec(session), + "question_count": "5", + }, + follow_redirects=False, + ) + + assert response.status_code == 303 + interview_id = response.headers["location"].rsplit("/", 1)[-1] + + # The plan should be generated; known question excluded would mean + # the session was still created (if enough questions remain). + with InterviewUnitOfWork() as uow: + interview = uow.interviews.get_aggregate(interview_id) + assert interview is not None + assert interview.status == "active" diff --git a/tests/interview/api/test_setup_modes.py b/tests/interview/api/test_setup_modes.py new file mode 100644 index 0000000..d9327ae --- /dev/null +++ b/tests/interview/api/test_setup_modes.py @@ -0,0 +1,250 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for creating interviews in all 4 session modes.""" + +from unittest.mock import patch + +from app.interview.domain.serialization import session_to_spec +from app.interview.domain.value_objects import ( + SectionBranchSpec, + SessionSelection, + TrackSelection, +) +from app.interview.repositories.uow import InterviewUnitOfWork +from app.platform.services.config import AppConfig + + +class TestSetupModes: + """End-to-end creation of interviews in each session mode.""" + + def _config(self) -> AppConfig: + return AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + locale="en", + ) + + def _post_setup(self, client, selection_json: str, **extra): + data = { + "selection_json": selection_json, + "question_count": "5", + "coding_question_count": "2", + } + data.update(extra) + return client.post("/setup", data=data, follow_redirects=False) + + def test_theory_only(self, client, isolated_db): + """Mode theory_only creates interview with theory section only.""" + session = SessionSelection.theory_only( + sources=( + TrackSelection( + track="python", + level="junior", + categories=("basics",), + ), + ), + question_count=3, + task_time_limit_seconds=180, + ) + with patch( + "app.platform.services.config.ConfigService.get_config", + return_value=self._config(), + ): + response = self._post_setup( + client, + session_to_spec(session), + question_count="3", + enable_question_timer="on", + question_time_minutes="3", + ) + + assert response.status_code == 303 + location = response.headers["location"] + assert "/interview/" in location + interview_id = location.rsplit("/", 1)[-1] + + with InterviewUnitOfWork() as uow: + interview = uow.interviews.get_aggregate(interview_id) + assert interview is not None + assert interview.session_mode == "theory_only" + section = uow.theory_sections.get_aggregate(interview_id) + assert section is not None + assert section.question_count == 3 + assert section.task_time_limit_seconds == 180 + + def test_coding_only(self, client, isolated_db): + """Mode coding_only creates interview with coding section only.""" + session = SessionSelection( + session_mode="coding_only", + theory=SectionBranchSpec( + enabled=False, + question_count=0, + task_time_limit_seconds=None, + sources=(), + ), + coding=SectionBranchSpec( + enabled=True, + question_count=2, + task_time_limit_seconds=600, + sources=( + TrackSelection( + track="python", + level="junior", + categories=("basics",), + ), + ), + ), + ) + with ( + patch( + "app.platform.services.config.ConfigService.get_config", + return_value=self._config(), + ), + patch( + "app.interview.services.rules.selection.is_coding_available", + return_value=True, + ), + ): + response = self._post_setup( + client, + session_to_spec(session), + coding_question_count="2", + enable_coding_timer="on", + coding_time_minutes="10", + ) + + assert response.status_code == 303 + interview_id = response.headers["location"].rsplit("/", 1)[-1] + + with InterviewUnitOfWork() as uow: + interview = uow.interviews.get_aggregate(interview_id) + assert interview is not None + assert interview.session_mode == "coding_only" + section = uow.coding_sections.get_aggregate(interview_id) + assert section is not None + assert section.task_count == 2 + assert section.task_time_limit_seconds == 600 + + def test_theory_then_coding(self, client, isolated_db): + """Mode theory_then_coding creates both sections.""" + session = SessionSelection( + session_mode="theory_then_coding", + theory=SectionBranchSpec( + enabled=True, + question_count=3, + task_time_limit_seconds=180, + sources=( + TrackSelection( + track="database", + level="middle", + categories=("sql-advanced",), + ), + ), + ), + coding=SectionBranchSpec( + enabled=True, + question_count=1, + task_time_limit_seconds=600, + sources=( + TrackSelection( + track="python", + level="junior", + categories=("basics",), + ), + ), + ), + ) + with ( + patch( + "app.platform.services.config.ConfigService.get_config", + return_value=self._config(), + ), + patch( + "app.interview.services.rules.selection.is_coding_available", + return_value=True, + ), + ): + response = self._post_setup( + client, + session_to_spec(session), + question_count="3", + coding_question_count="1", + enable_question_timer="on", + question_time_minutes="3", + enable_coding_timer="on", + coding_time_minutes="10", + ) + + assert response.status_code == 303 + interview_id = response.headers["location"].rsplit("/", 1)[-1] + + with InterviewUnitOfWork() as uow: + interview = uow.interviews.get_aggregate(interview_id) + assert interview.session_mode == "theory_then_coding" + theory = uow.theory_sections.get_aggregate(interview_id) + coding = uow.coding_sections.get_aggregate(interview_id) + assert theory is not None + assert coding is not None + assert theory.question_count == 3 + + def test_coding_then_theory(self, client, isolated_db): + """Mode coding_then_theory creates both sections.""" + session = SessionSelection( + session_mode="coding_then_theory", + theory=SectionBranchSpec( + enabled=True, + question_count=2, + task_time_limit_seconds=120, + sources=( + TrackSelection( + track="python", + level="junior", + categories=("basics",), + ), + ), + ), + coding=SectionBranchSpec( + enabled=True, + question_count=2, + task_time_limit_seconds=480, + sources=( + TrackSelection( + track="python", + level="junior", + categories=("basics",), + ), + ), + ), + ) + with ( + patch( + "app.platform.services.config.ConfigService.get_config", + return_value=self._config(), + ), + patch( + "app.interview.services.rules.selection.is_coding_available", + return_value=True, + ), + ): + response = self._post_setup( + client, + session_to_spec(session), + question_count="2", + coding_question_count="2", + enable_question_timer="on", + question_time_minutes="2", + enable_coding_timer="on", + coding_time_minutes="8", + ) + + assert response.status_code == 303 + interview_id = response.headers["location"].rsplit("/", 1)[-1] + + with InterviewUnitOfWork() as uow: + interview = uow.interviews.get_aggregate(interview_id) + assert interview.session_mode == "coding_then_theory" + theory = uow.theory_sections.get_aggregate(interview_id) + coding = uow.coding_sections.get_aggregate(interview_id) + assert theory is not None + assert coding is not None diff --git a/tests/interview/api/test_setup_negative.py b/tests/interview/api/test_setup_negative.py new file mode 100644 index 0000000..51d2b70 --- /dev/null +++ b/tests/interview/api/test_setup_negative.py @@ -0,0 +1,281 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Negative tests for interview setup POST (validation, clamping, errors).""" + +from unittest.mock import patch + +from app.interview.domain.serialization import session_to_spec +from app.interview.domain.value_objects import ( + SectionBranchSpec, + SessionSelection, + TrackSelection, +) +from app.platform.services.config import AppConfig +from app.shared.questions import list_categories + + +class TestSetupNegative: + """Tests for invalid setup submissions.""" + + def _config(self) -> AppConfig: + return AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + locale="en", + ) + + def _valid_theory_selection(self, **kwargs): + session = SessionSelection( + session_mode="theory_only", + exclude_known=False, + theory=SectionBranchSpec( + enabled=True, + question_count=5, + task_time_limit_seconds=None, + sources=( + TrackSelection( + track="python", + level="junior", + categories=("basics",), + ), + ), + ), + coding=SectionBranchSpec( + enabled=False, + question_count=0, + task_time_limit_seconds=None, + sources=(), + ), + ) + return session_to_spec(session) + + def test_question_count_clamped_to_min(self, client, isolated_db): + """question_count=0 is clamped to _MIN_QUESTIONS (1).""" + selection = self._valid_theory_selection() + with ( + patch( + "app.platform.services.config.ConfigService.get_config", + return_value=self._config(), + ), + ): + response = client.post( + "/setup", + data={ + "selection_json": selection, + "question_count": "0", + }, + follow_redirects=False, + ) + # Should still create session (clamped to 1) + assert response.status_code == 303 + assert "/interview/" in response.headers["location"] + + def test_question_count_clamped_to_max(self, client, isolated_db): + """question_count=50 is clamped to _MAX_QUESTIONS (20).""" + from app.interview.domain.serialization import session_to_spec + from app.interview.domain.value_objects import TrackSelection + + extensive = SessionSelection.theory_only( + sources=( + TrackSelection( + track="python", + level="junior", + categories=tuple( + list_categories("python", "junior"), + ), + ), + ), + question_count=50, + ) + with patch( + "app.platform.services.config.ConfigService.get_config", + return_value=self._config(), + ): + response = client.post( + "/setup", + data={ + "selection_json": session_to_spec(extensive), + "question_count": "50", + }, + follow_redirects=False, + ) + # Should still create session (clamped to 20) + assert response.status_code == 303 + assert "/interview/" in response.headers["location"] + + def test_malformed_json_shows_error(self, client, isolated_db): + """Malformed selection_json returns setup form with error.""" + with ( + patch( + "app.platform.services.config.ConfigService.get_config", + return_value=self._config(), + ), + patch( + "app.interview.api.setup.list_tracks", + return_value=["python"], + ), + patch( + "app.interview.api.setup.list_levels", + return_value=["junior"], + ), + patch( + "app.interview.api.setup.list_categories", + return_value=["basics"], + ), + ): + response = client.post( + "/setup", + data={ + "selection_json": "not valid json {{{", + "question_count": "5", + }, + ) + assert response.status_code == 200 + assert "error" in response.text.lower() or "setup" in response.text.lower() + + def test_unknown_category_rejected(self, client, isolated_db): + """Invalid category in selection returns error.""" + + selection = ( + '{"version":2,"session_mode":"theory_only",' + '"theory":{"enabled":true,"question_count":5,"sources":[' + '{"track":"python","level":"junior","categories":["unknown-category"]}' + ']},"coding":{"enabled":false}}' + ) + # The error happens during plan validation, not JSON parse + with ( + patch( + "app.platform.services.config.ConfigService.get_config", + return_value=self._config(), + ), + patch( + "app.interview.api.setup.list_tracks", + return_value=["python"], + ), + patch( + "app.interview.api.setup.list_levels", + return_value=["junior"], + ), + patch( + "app.interview.api.setup.list_categories", + return_value=["basics"], + ), + ): + response = client.post( + "/setup", + data={ + "selection_json": selection, + "question_count": "5", + }, + ) + assert response.status_code == 200 + + def test_coding_question_count_clamped(self, client, isolated_db): + """coding_question_count is also clamped to 1–20 range.""" + session = SessionSelection( + session_mode="coding_only", + theory=SectionBranchSpec( + enabled=False, + question_count=0, + task_time_limit_seconds=None, + sources=(), + ), + coding=SectionBranchSpec( + enabled=True, + question_count=5, + task_time_limit_seconds=None, + sources=( + TrackSelection( + track="python", + level="junior", + categories=("basics",), + ), + ), + ), + ) + # Coding available should be True for this test path + with ( + patch( + "app.platform.services.config.ConfigService.get_config", + return_value=self._config(), + ), + patch( + "app.interview.services.rules.selection.is_coding_available", + return_value=True, + ), + ): + response = client.post( + "/setup", + data={ + "selection_json": session_to_spec(session), + "question_count": "5", + "coding_question_count": "0", # Should clamp to 1 + }, + follow_redirects=False, + ) + assert response.status_code == 303 + assert "/interview/" in response.headers["location"] + + def test_too_few_questions_for_topics_shows_error(self, client, isolated_db): + """When question_count < topic_count and all are single-cat topics, error shown.""" + selection = ( + '{"version":2,"session_mode":"theory_only",' + '"theory":{"enabled":true,"question_count":1,"sources":[' + '{"track":"python","level":"junior","categories":["basics","oop"]}' + ']},"coding":{"enabled":false}}' + ) + with ( + patch( + "app.platform.services.config.ConfigService.get_config", + return_value=self._config(), + ), + patch( + "app.interview.api.setup.list_tracks", + return_value=["python"], + ), + patch( + "app.interview.api.setup.list_levels", + return_value=["junior"], + ), + patch( + "app.interview.api.setup.list_categories", + return_value=["basics", "oop"], + ), + ): + response = client.post( + "/setup", + data={ + "selection_json": selection, + "question_count": "1", + }, + ) + assert response.status_code == 200 + assert "at least" in response.text.lower() or "error" in response.text.lower() + + def test_setup_get_redirects_without_config(self, client): + """GET /setup redirects to /config when provider is not configured.""" + with patch( + "app.platform.services.config.ConfigService.get_config", + return_value=None, + ): + response = client.get("/setup", follow_redirects=False) + assert response.status_code == 303 + assert response.headers["location"] == "/config" + + def test_setup_post_redirects_without_config(self, client): + """POST /setup redirects to /config when provider is not configured.""" + with patch( + "app.platform.services.config.ConfigService.get_config", + return_value=None, + ): + response = client.post( + "/setup", + data={ + "selection_json": "{}", + "question_count": "5", + }, + follow_redirects=False, + ) + assert response.status_code == 303 + assert response.headers["location"] == "/config" diff --git a/tests/interview/api/test_timer.py b/tests/interview/api/test_timer.py new file mode 100644 index 0000000..0710c1b --- /dev/null +++ b/tests/interview/api/test_timer.py @@ -0,0 +1,192 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Timer expired tests for theory and coding sessions.""" + +from datetime import UTC, datetime, timedelta + +from app.interview.domain.serialization import selection_to_spec +from app.interview.domain.value_objects import ( + SectionBranchSpec, + SessionSelection, + TrackSelection, +) +from app.interview.repositories.uow import InterviewUnitOfWork +from app.interview.services.query import InterviewQuery +from app.shared.infrastructure.models import Answer, Interview +from app.theory.domain.entities import TheoryTask +from tests.helpers.interview_seed import persist_interview_with_answers +from tests.helpers.selection import minimal_selection_spec + + +class TestTheoryTimer: + """Theory timer expiry (S14).""" + + def test_timer_expired_scores_zero_and_advances( + self, client, isolated_db, override_ws_ai_provider + ): + """Timer expired → score=0, auto next question.""" + started = datetime.now(UTC) - timedelta(seconds=120) + interview_id = persist_interview_with_answers( + Interview( + id="timer-theory-1", + locale="en", + selection_spec=minimal_selection_spec(categories=["basics"]), + status="active", + ), + [ + Answer( + question_id="q1", + order=1, + round=0, + question_text="Q1?", + started_at=started, + ), + Answer( + question_id="q2", + order=2, + round=0, + question_text="Q2?", + ), + ], + question_count=2, + task_time_limit_seconds=60, + ) + + override_ws_ai_provider(client, []) + + with client.websocket_connect(f"/interview/{interview_id}/theory/ws") as ws: + ws.send_json({"type": "timeout", "question_id": "q1", "round": 0}) + feedback = ws.receive_json() + + assert feedback["type"] == "feedback" + assert feedback["timed_out"] is True + assert feedback["next_question"]["question_id"] == "q2" + + reloaded = InterviewQuery.load(interview_id) + assert reloaded is not None + q1 = next(a for a in reloaded.answers if a.question_id == "q1") + assert q1.answer_text == TheoryTask.TIME_EXPIRED_ANSWER_TEXT + assert q1.score == 0 + + def test_no_timer_does_not_timeout( + self, client, isolated_db, override_ws_ai_provider + ): + """Without timer enabled, timeout msg is rejected.""" + interview_id = persist_interview_with_answers( + Interview( + id="timer-theory-2", + locale="en", + selection_spec=minimal_selection_spec(), + status="active", + ), + [ + Answer(question_id="q1", order=1, round=0, question_text="Q?"), + ], + question_count=1, + task_time_limit_seconds=None, + ) + + override_ws_ai_provider(client, []) + + with client.websocket_connect(f"/interview/{interview_id}/theory/ws") as ws: + ws.send_json({"type": "timeout", "question_id": "q1", "round": 0}) + err = ws.receive_json() + + assert err["type"] == "error" + + +class TestCodingTimer: + """Coding timer expiry (S14).""" + + def test_timer_expired_scores_zero_and_advances( + self, client, isolated_db, override_ws_ai_provider + ): + """Timer expired coding task → score=0, next task.""" + from app.shared.infrastructure.models import CodingSection, CodingTask + + coding_selection_spec = selection_to_spec( + SessionSelection( + session_mode="coding_only", + theory=SectionBranchSpec( + enabled=False, + question_count=0, + task_time_limit_seconds=None, + sources=(), + ), + coding=SectionBranchSpec( + enabled=True, + question_count=2, + task_time_limit_seconds=60, + sources=( + TrackSelection( + track="python", level="junior", categories=("basics",) + ), + ), + ), + ).coding_selection + ) + with InterviewUnitOfWork(auto_commit=True) as uow: + from app.shared.infrastructure.models import Interview as InterviewModel + + db_interview = InterviewModel( + id="timer-coding-1", + locale="en", + selection_spec=minimal_selection_spec(), + status="active", + session_mode="coding_only", + ) + uow.session.add(db_interview) + uow.flush() + section = ( + uow.session.query(CodingSection) + .filter_by(interview_id="timer-coding-1") + .first() + ) + if not section: + section = CodingSection( + interview_id="timer-coding-1", + selection_spec=coding_selection_spec, + task_count=2, + task_time_limit_seconds=60, + locale="en", + status="active", + ) + uow.session.add(section) + uow.flush() + for i in range(2): + uow.session.add( + CodingTask( + coding_section_id=section.id, + task_id=f"cod-t{i}", + order=i + 1, + round=0, + prompt_text=f"Task {i}", + task_spec='{"language":"python"}', + ) + ) + uow.session.flush() + # Set started_at on first task + tasks = ( + uow.session.query(CodingTask) + .filter_by(coding_section_id=section.id) + .order_by(CodingTask.order) + .all() + ) + tasks[0].started_at = datetime.now(UTC) - timedelta(seconds=120) + tasks[1].started_at = None + + override_ws_ai_provider(client, []) + + with client.websocket_connect("/interview/timer-coding-1/coding/ws") as ws: + ws.send_json({"type": "timeout", "task_id": "cod-t0", "round": 0}) + feedback = ws.receive_json() + + assert feedback["type"] == "feedback" + assert feedback["task_id"] == "cod-t0" + # Next task should be set + assert feedback["next_task"] is not None + + with InterviewUnitOfWork() as uow: + task = uow.session.query(CodingTask).filter_by(task_id="cod-t0").one() + assert task.submitted_code == "[Time expired]" + assert task.score == 0 diff --git a/tests/interview/services/test_ai_errors.py b/tests/interview/services/test_ai_errors.py new file mode 100644 index 0000000..5d25792 --- /dev/null +++ b/tests/interview/services/test_ai_errors.py @@ -0,0 +1,84 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for AI error message formatting.""" + +from app.interview.services.ai_errors import ai_error_message_for_client + + +class TestModelNotFound: + """Tests for model-not-found error messages.""" + + def test_exact_match_lowercase(self) -> None: + exc = Exception("model 'gpt-4' not found on endpoint") + result = ai_error_message_for_client(exc) + assert "AI model is not available" in result + assert "/config" in result + + def test_mixed_case(self) -> None: + exc = Exception("Model Not Found") + result = ai_error_message_for_client(exc) + assert "AI model is not available" in result + + def test_model_before_not_found(self) -> None: + exc = Exception("the requested model was not found") + result = ai_error_message_for_client(exc) + assert "AI model is not available" in result + + +class TestTimeout: + """Tests for timeout error messages.""" + + def test_timed_out(self) -> None: + exc = Exception("request timed out after 30 seconds") + result = ai_error_message_for_client(exc) + assert "AI evaluation timed out" in result + assert "/config" in result + + def test_timeout_keyword(self) -> None: + exc = Exception("Connection timeout") + result = ai_error_message_for_client(exc) + assert "AI evaluation timed out" in result + + def test_timeout_mixed_case(self) -> None: + exc = Exception("Timeout Error") + result = ai_error_message_for_client(exc) + assert "AI evaluation timed out" in result + + +class TestGenericError: + """Tests for generic error messages.""" + + def test_returns_prefixed_message(self) -> None: + exc = Exception("Something went wrong") + result = ai_error_message_for_client(exc) + assert result == "AI evaluation failed: Something went wrong" + + def test_empty_message(self) -> None: + exc = Exception("") + result = ai_error_message_for_client(exc) + assert result == "AI evaluation failed: " + + def test_long_message(self) -> None: + msg = "A" * 500 + exc = Exception(msg) + result = ai_error_message_for_client(exc) + assert result == f"AI evaluation failed: {msg}" + + +class TestEdgeCases: + """Tests for edge cases in error matching.""" + + def test_model_found_but_not_other_word(self) -> None: + exc = Exception("model updated successfully") + result = ai_error_message_for_client(exc) + assert "AI evaluation failed" in result + + def test_not_found_without_model(self) -> None: + exc = Exception("page not found") + result = ai_error_message_for_client(exc) + assert "AI evaluation failed" in result + + def test_both_keywords_present_prefers_model(self) -> None: + exc = Exception("model 'xyz' not found and timed out") + result = ai_error_message_for_client(exc) + assert "AI model is not available" in result diff --git a/tests/interview/services/test_bank_text.py b/tests/interview/services/test_bank_text.py new file mode 100644 index 0000000..961ea1e --- /dev/null +++ b/tests/interview/services/test_bank_text.py @@ -0,0 +1,188 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for bank text resolution service.""" + +from unittest.mock import MagicMock + +import pytest + +from app.interview.services.bank_text import ( + KnownQuestionView, + _coding_text_index, + _theory_text_index, + _to_views, + resolve_known_views, +) + + +def _fake_question(id: str, text: str): + return MagicMock(id=id, text=text) + + +def _fake_task(id: str, text: str): + return MagicMock(id=id, text=text) + + +class TestKnownQuestionView: + """Tests for the KnownQuestionView dataclass.""" + + def test_dataclass_fields(self) -> None: + view = KnownQuestionView(id="q1", text="What is Python?") + assert view.id == "q1" + assert view.text == "What is Python?" + assert view == KnownQuestionView(id="q1", text="What is Python?") + + def test_frozen_and_slotted(self) -> None: + view = KnownQuestionView(id="q1", text="Q") + with pytest.raises(AttributeError): + view.id = "q2" # type: ignore[misc] + + +class TestTheoryTextIndex: + """Tests for _theory_text_index caching.""" + + def test_builds_index_and_caches(self, monkeypatch) -> None: + _theory_text_index.cache_clear() + + fake_question = _fake_question("q1", "What is a list?") + monkeypatch.setattr( + "app.interview.services.bank_text.theory_bank.list_tracks", + lambda: ("python",), + ) + monkeypatch.setattr( + "app.interview.services.bank_text.theory_bank.list_levels", + lambda track: ("junior",), + ) + monkeypatch.setattr( + "app.interview.services.bank_text.theory_bank.list_categories", + lambda track, level: ("basics",), + ) + monkeypatch.setattr( + "app.interview.services.bank_text.theory_bank.load_category", + lambda track, level, category: (fake_question,), + ) + + # First call should build and cache + result1 = _theory_text_index() + assert result1 == {"q1": "What is a list?"} + + # Modify the mock to prove caching + monkeypatch.setattr( + "app.interview.services.bank_text.theory_bank.load_category", + lambda track, level, category: (), + ) + + result2 = _theory_text_index() + assert result2 == {"q1": "What is a list?"} + + +class TestCodingTextIndex: + """Tests for _coding_text_index caching.""" + + def test_builds_index_and_caches(self, monkeypatch) -> None: + _coding_text_index.cache_clear() + + fake_task = _fake_task("cod-001", "Write a function.") + monkeypatch.setattr( + "app.interview.services.bank_text.coding_bank.list_tracks", + lambda: ("python",), + ) + monkeypatch.setattr( + "app.interview.services.bank_text.coding_bank.list_levels", + lambda track: ("junior",), + ) + monkeypatch.setattr( + "app.interview.services.bank_text.coding_bank.list_categories", + lambda track, level: ("basics",), + ) + monkeypatch.setattr( + "app.interview.services.bank_text.coding_bank.load_category", + lambda track, level, category: (fake_task,), + ) + + result1 = _coding_text_index() + assert result1 == {"cod-001": "Write a function."} + + monkeypatch.setattr( + "app.interview.services.bank_text.coding_bank.load_category", + lambda track, level, category: (), + ) + + result2 = _coding_text_index() + assert result2 == {"cod-001": "Write a function."} + + +class TestToViews: + """Tests for _to_views helper.""" + + def test_maps_ids_to_views(self) -> None: + index = {"q1": "Text one", "q2": "Text two"} + views = _to_views(["q1", "q2"], index) + assert len(views) == 2 + assert views[0] == KnownQuestionView(id="q1", text="Text one") + assert views[1] == KnownQuestionView(id="q2", text="Text two") + + def test_uses_id_as_text_when_missing(self) -> None: + index = {"q1": "Text one"} + views = _to_views(["q1", "missing"], index) + assert views[0] == KnownQuestionView(id="q1", text="Text one") + assert views[1] == KnownQuestionView(id="missing", text="missing") + + def test_empty_item_ids(self) -> None: + assert _to_views([], {"q1": "Text"}) == [] + + +class TestResolveKnownViews: + """Tests for resolve_known_views.""" + + def test_builds_views_correctly(self, monkeypatch) -> None: + monkeypatch.setattr( + "app.interview.services.bank_text._theory_text_index", + lambda: {"q1": "Theory Q1"}, + ) + monkeypatch.setattr( + "app.interview.services.bank_text._coding_text_index", + lambda: {"cod-001": "Coding T1"}, + ) + + grouped = { + "theory": ["q1", "missing-theory"], + "coding": ["cod-001"], + } + result = resolve_known_views(grouped) + + assert len(result["theory"]) == 2 + assert result["theory"][0] == KnownQuestionView(id="q1", text="Theory Q1") + assert result["theory"][1] == KnownQuestionView( + id="missing-theory", text="missing-theory" + ) + + assert len(result["coding"]) == 1 + assert result["coding"][0] == KnownQuestionView(id="cod-001", text="Coding T1") + + def test_handles_empty_groups(self, monkeypatch) -> None: + monkeypatch.setattr( + "app.interview.services.bank_text._theory_text_index", + lambda: {}, + ) + monkeypatch.setattr( + "app.interview.services.bank_text._coding_text_index", + lambda: {}, + ) + + result = resolve_known_views({"theory": [], "coding": []}) + assert result == {"theory": [], "coding": []} + + def test_handles_missing_branch_keys(self, monkeypatch) -> None: + monkeypatch.setattr( + "app.interview.services.bank_text._theory_text_index", + lambda: {}, + ) + monkeypatch.setattr( + "app.interview.services.bank_text._coding_text_index", + lambda: {}, + ) + + result = resolve_known_views({"theory": ["q1"]}) + assert result["theory"] == [KnownQuestionView(id="q1", text="q1")] + assert result["coding"] == [] diff --git a/tests/interview/services/test_query.py b/tests/interview/services/test_query.py new file mode 100644 index 0000000..b1a299b --- /dev/null +++ b/tests/interview/services/test_query.py @@ -0,0 +1,206 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for InterviewQuery read-only service.""" + +from datetime import UTC, datetime +from unittest.mock import MagicMock, patch + +import pytest + +from app.interview.domain.entities import Interview +from app.interview.domain.exceptions import ( + InterviewNotActiveError, + InterviewNotFoundError, +) +from app.interview.domain.value_objects import ( + SessionSelection, + TrackSelection, +) +from app.interview.schemas.interview import AnswerRead, InterviewRead +from app.interview.services.query import InterviewQuery + + +def _make_interview_read( + *, + status: str = "active", + answers: list[AnswerRead] | None = None, +) -> InterviewRead: + return InterviewRead( + id="iv-1", + status=status, + locale="en", + selection_spec="{}", + question_ids='["q1","q2"]', + question_count=2, + question_time_limit_seconds=None, + answers=answers + if answers is not None + else [ + AnswerRead( + id=1, + question_id="q1", + order=1, + round=0, + question_text="Q1", + question_code=None, + answer_text=None, + score=None, + started_at=None, + ), + AnswerRead( + id=2, + question_id="q2", + order=2, + round=0, + question_text="Q2", + question_code=None, + answer_text="answered", + score=4, + started_at=None, + ), + ], + ) + + +def _make_active_shell() -> Interview: + return Interview.start_shell( + "iv-1", + selection=SessionSelection.theory_only( + sources=( + TrackSelection( + track="python", + level="junior", + categories=("basics",), + ), + ) + ), + locale="en", + started_at=datetime(2026, 1, 1, tzinfo=UTC), + ) + + +class TestGetInterview: + """Tests for InterviewQuery.get_interview.""" + + def test_get_interview_loads_with_tasks(self) -> None: + uow = MagicMock() + read = _make_interview_read() + with patch( + "app.interview.services.query.load_interview_read", + return_value=read, + ) as mock_load: + query = InterviewQuery(uow) + result = query.get_interview("iv-1") + mock_load.assert_called_once_with(uow, "iv-1") + assert result is read + + def test_get_interview_returns_none_when_not_found(self) -> None: + uow = MagicMock() + with patch( + "app.interview.services.query.load_interview_read", + return_value=None, + ) as mock_load: + query = InterviewQuery(uow) + result = query.get_interview("missing") + mock_load.assert_called_once_with(uow, "missing") + assert result is None + + +class TestLoad: + """Tests for InterviewQuery.load static method.""" + + def test_load_returns_interview(self) -> None: + read = _make_interview_read() + with patch( + "app.interview.services.query.load_interview_read", + return_value=read, + ) as mock_load: + result = InterviewQuery.load("iv-1") + assert result is read + assert mock_load.call_args[0][1] == "iv-1" + + def test_load_returns_none_when_not_found(self) -> None: + with patch( + "app.interview.services.query.load_interview_read", + return_value=None, + ): + assert InterviewQuery.load("missing") is None + + +class TestGetActiveOrRaise: + """Tests for InterviewQuery.get_active_or_raise.""" + + def test_returns_active_interview(self) -> None: + uow = MagicMock() + shell = _make_active_shell() + uow.interviews.get_aggregate.return_value = shell + read = _make_interview_read() + with patch( + "app.interview.services.query.load_interview_read", + return_value=read, + ): + query = InterviewQuery(uow) + result = query.get_active_or_raise("iv-1") + assert result is read + uow.interviews.get_aggregate.assert_called_once_with("iv-1") + + def test_raises_when_interview_not_found(self) -> None: + uow = MagicMock() + uow.interviews.get_aggregate.return_value = None + query = InterviewQuery(uow) + with pytest.raises(InterviewNotFoundError): + query.get_active_or_raise("missing") + + def test_raises_when_interview_not_active(self) -> None: + uow = MagicMock() + shell = _make_active_shell().with_session_completed( + {"overall_feedback": "done"} + ) + uow.interviews.get_aggregate.return_value = shell + query = InterviewQuery(uow) + with pytest.raises(InterviewNotActiveError): + query.get_active_or_raise("iv-1") + + def test_raises_when_load_returns_none(self) -> None: + uow = MagicMock() + shell = _make_active_shell() + uow.interviews.get_aggregate.return_value = shell + with patch( + "app.interview.services.query.load_interview_read", + return_value=None, + ): + query = InterviewQuery(uow) + with pytest.raises(InterviewNotFoundError): + query.get_active_or_raise("iv-1") + + +class TestGetCurrentUnanswered: + """Tests for InterviewQuery.get_current_unanswered.""" + + def test_returns_first_unanswered_answer(self) -> None: + read = _make_interview_read() + result = InterviewQuery.get_current_unanswered(read) + assert result is not None + assert result.question_id == "q1" + + def test_returns_none_when_all_answered(self) -> None: + read = _make_interview_read( + answers=[ + AnswerRead( + id=1, + question_id="q1", + order=1, + round=0, + question_text="Q1", + question_code=None, + answer_text="done", + score=4, + started_at=None, + ), + ] + ) + assert InterviewQuery.get_current_unanswered(read) is None + + def test_returns_none_for_empty_answers(self) -> None: + read = _make_interview_read(answers=[]) + assert InterviewQuery.get_current_unanswered(read) is None diff --git a/tests/interview/services/test_read_model.py b/tests/interview/services/test_read_model.py new file mode 100644 index 0000000..8ec0e12 --- /dev/null +++ b/tests/interview/services/test_read_model.py @@ -0,0 +1,244 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for interview read model assembly and loading.""" + +from datetime import UTC, datetime +from unittest.mock import MagicMock, patch + +from app.interview.domain.entities import Interview +from app.interview.domain.value_objects import ( + InterviewSelection, + SessionSelection, + TrackSelection, +) +from app.interview.schemas.interview import InterviewRead +from app.interview.services.read_model import ( + assemble_interview_read, + load_interview_read, + load_recent_interview_reads, +) +from app.theory.domain.entities import TheorySection + + +def _shell( + *, + status: str = "active", + overall_feedback: dict | None = None, +) -> Interview: + base = Interview.start_shell( + "iv-1", + selection=SessionSelection.theory_only( + sources=( + TrackSelection( + track="python", + level="junior", + categories=("basics",), + ), + ) + ), + locale="en", + started_at=datetime(2026, 1, 1, tzinfo=UTC), + ) + if overall_feedback or status == "completed": + return base.with_session_completed(overall_feedback or {}) + return base + + +def _theory_section( + *, + status: str = "active", + section_score: int | None = None, + tasks: tuple | None = None, +) -> TheorySection: + _ = status, section_score, tasks + # Rebuild because start validates non-empty planned_questions + return None # type: ignore[return-value] + + +def test_assemble_interview_read_builds_correct_read_model() -> None: + """assemble_interview_read composes a read model from shell and theory.""" + shell = _shell() + theory = TheorySection( + id=1, + interview_id="iv-1", + locale="en", + selection=InterviewSelection(sources=()), + question_count=0, + question_ids=(), + task_time_limit_seconds=None, + status="active", + section_score=None, + section_feedback=None, + tasks=(), + ) + result = assemble_interview_read(shell, theory) + assert isinstance(result, InterviewRead) + assert result.id == "iv-1" + assert result.status == "active" + assert result.locale == "en" + + +def test_assemble_interview_read_sets_score_when_completed() -> None: + """Completed shells get a resolved display score.""" + shell = _shell( + status="completed", + overall_feedback={ + "score_breakdown": { + "theory": {"score": 8, "max": 10}, + } + }, + ) + theory = TheorySection( + id=1, + interview_id="iv-1", + locale="en", + selection=InterviewSelection(sources=()), + question_count=0, + question_ids=(), + task_time_limit_seconds=None, + status="completed", + section_score=None, + section_feedback=None, + tasks=(), + ) + with patch( + "app.interview.services.read_model.resolve_completed_read_score", + return_value=8, + ): + result = assemble_interview_read(shell, theory) + assert result.score == 8 + + +def test_assemble_interview_read_without_theory() -> None: + """assemble_interview_read works when theory section is None.""" + shell = _shell() + result = assemble_interview_read(shell, None) + assert isinstance(result, InterviewRead) + assert result.question_count == 0 + assert result.answers == [] + + +def test_load_interview_read_returns_none_when_missing() -> None: + """load_interview_read returns None for a missing interview.""" + uow = MagicMock() + uow.interviews.get_aggregate.return_value = None + assert load_interview_read(uow, "missing") is None + uow.interviews.get_aggregate.assert_called_once_with("missing") + + +def test_load_interview_read_returns_assembled_model() -> None: + """load_interview_read loads aggregates and assembles the read model.""" + shell = _shell() + theory = TheorySection( + id=1, + interview_id="iv-1", + locale="en", + selection=InterviewSelection(sources=()), + question_count=0, + question_ids=(), + task_time_limit_seconds=None, + status="active", + section_score=None, + section_feedback=None, + tasks=(), + ) + uow = MagicMock() + uow.interviews.get_aggregate.return_value = shell + uow.theory_sections.get_aggregate.return_value = theory + uow.coding_sections.get_aggregate.return_value = None + + result = load_interview_read(uow, "iv-1") + assert isinstance(result, InterviewRead) + assert result.id == "iv-1" + uow.interviews.get_aggregate.assert_called_once_with("iv-1") + uow.theory_sections.get_aggregate.assert_called_once_with("iv-1") + uow.coding_sections.get_aggregate.assert_called_once_with("iv-1") + + +def test_load_recent_interview_reads_empty_list() -> None: + """load_recent_interview_reads returns an empty list when there are no shells.""" + uow = MagicMock() + uow.interviews.list_recent_aggregates.return_value = [] + assert load_recent_interview_reads(uow, limit=20) == [] + uow.interviews.list_recent_aggregates.assert_called_once_with(limit=20) + + +def test_load_recent_interview_reads_returns_list() -> None: + """load_recent_interview_reads loads and assembles multiple reads.""" + shell1 = Interview.start_shell( + "iv-1", + selection=SessionSelection.theory_only( + sources=( + TrackSelection( + track="python", + level="junior", + categories=("basics",), + ), + ) + ), + locale="en", + started_at=datetime(2026, 1, 1, tzinfo=UTC), + ) + shell2 = Interview.start_shell( + "iv-2", + selection=SessionSelection.theory_only( + sources=( + TrackSelection( + track="go", + level="junior", + categories=("basics",), + ), + ) + ), + locale="en", + started_at=datetime(2026, 1, 2, tzinfo=UTC), + ) + uow = MagicMock() + uow.interviews.list_recent_aggregates.return_value = [shell1, shell2] + uow.theory_sections.get_aggregates_by_interview_ids.return_value = {} + uow.coding_sections.get_aggregates_by_interview_ids.return_value = {} + + results = load_recent_interview_reads(uow, limit=10) + assert len(results) == 2 + assert results[0].id == "iv-1" + assert results[1].id == "iv-2" + uow.interviews.list_recent_aggregates.assert_called_once_with(limit=10) + + +def test_load_recent_interview_reads_with_sections() -> None: + """Recent reads include theory and coding sections when present.""" + shell = Interview.start_shell( + "iv-1", + selection=SessionSelection.theory_only( + sources=( + TrackSelection( + track="python", + level="junior", + categories=("basics",), + ), + ) + ), + locale="en", + started_at=datetime(2026, 1, 1, tzinfo=UTC), + ) + theory = TheorySection( + id=1, + interview_id="iv-1", + locale="en", + selection=InterviewSelection(sources=()), + question_count=0, + question_ids=(), + task_time_limit_seconds=None, + status="active", + section_score=None, + section_feedback=None, + tasks=(), + ) + uow = MagicMock() + uow.interviews.list_recent_aggregates.return_value = [shell] + uow.theory_sections.get_aggregates_by_interview_ids.return_value = {"iv-1": theory} + uow.coding_sections.get_aggregates_by_interview_ids.return_value = {} + + results = load_recent_interview_reads(uow, limit=10) + assert len(results) == 1 + assert results[0].id == "iv-1" diff --git a/tests/interview/services/test_scoring.py b/tests/interview/services/test_scoring.py new file mode 100644 index 0000000..e6a51ac --- /dev/null +++ b/tests/interview/services/test_scoring.py @@ -0,0 +1,242 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for interview scoring helpers.""" + +from datetime import UTC, datetime +from unittest.mock import patch + +from app.coding.domain.entities import CodingSection, CodingTask +from app.interview.domain.entities import Interview +from app.interview.domain.value_objects import ( + InterviewSelection, + SessionSelection, + TrackSelection, +) +from app.interview.services.scoring import ( + _section_display_score, + completed_score_fallback, + resolve_completed_read_score, + score_from_overall_feedback, +) +from app.theory.domain.entities import TheorySection, TheoryTask + + +def _shell( + status: str = "active", + overall_feedback: dict | None = None, +) -> Interview: + base = Interview.start_shell( + "iv-1", + selection=SessionSelection.theory_only( + sources=( + TrackSelection( + track="python", + level="junior", + categories=("basics",), + ), + ) + ), + locale="en", + started_at=datetime(2026, 1, 1, tzinfo=UTC), + ) + if status == "completed": + return base.with_session_completed(overall_feedback or {}) + return base + + +def _theory_section( + *, + status: str = "active", + section_score: int | None = None, + tasks: tuple[TheoryTask, ...] | None = None, +) -> TheorySection: + base_tasks = tasks or ( + TheoryTask( + id=1, + theory_section_id=1, + interview_id="iv-1", + question_id="q1", + order=1, + round=0, + question_text="Q1", + question_code=None, + answer_text="answer", + score=4, + feedback=None, + started_at=None, + created_at=datetime.now(UTC), + ), + ) + return TheorySection( + id=1, + interview_id="iv-1", + locale="en", + selection=InterviewSelection(sources=()), + question_count=len(base_tasks), + question_ids=tuple(t.question_id for t in base_tasks), + task_time_limit_seconds=None, + status=status, # type: ignore[arg-type] + section_score=section_score, + section_feedback=None, + tasks=base_tasks, + ) + + +def _coding_section( + *, + status: str = "active", + section_score: int | None = None, + tasks: tuple[CodingTask, ...] | None = None, +) -> CodingSection: + base_tasks = tasks or ( + CodingTask( + id=1, + coding_section_id=1, + interview_id="iv-1", + task_id="cod-001", + order=1, + round=0, + prompt_text="Task 1", + task_spec={}, + submitted_code="def solve(): pass", + submit_test_summary=None, + score=3, + feedback=None, + started_at=None, + created_at=datetime.now(UTC), + ), + ) + return CodingSection( + id=1, + interview_id="iv-1", + locale="en", + selection=InterviewSelection(sources=()), + task_count=len(base_tasks), + task_ids=tuple(t.task_id for t in base_tasks), + task_time_limit_seconds=None, + status=status, # type: ignore[arg-type] + section_score=section_score, + section_feedback=None, + tasks=base_tasks, + ) + + +class TestScoreFromOverallFeedback: + """Tests for score_from_overall_feedback.""" + + def test_none_feedback_returns_none(self) -> None: + assert score_from_overall_feedback(None) is None + + def test_missing_breakdown_returns_none(self) -> None: + assert score_from_overall_feedback({"other": 1}) is None + + def test_empty_breakdown_returns_none(self) -> None: + assert score_from_overall_feedback({"score_breakdown": {}}) is None + + def test_extracts_total_score_from_breakdown(self) -> None: + feedback = {"score_breakdown": {"theory": {"score": 4}}} + with patch( + "app.interview.services.scoring.SessionEvaluationAggregator.total_score_from_breakdown", + return_value=9, + ): + assert score_from_overall_feedback(feedback) == 9 + + +class TestSectionDisplayScore: + """Tests for _section_display_score.""" + + def test_skipped_section_returns_zero(self) -> None: + section = _theory_section(status="skipped") + assert _section_display_score(section) == 0 + + def test_uses_section_score_when_set(self) -> None: + section = _theory_section(section_score=7) + assert _section_display_score(section) == 7 + + def test_falls_back_to_total_score(self) -> None: + section = _theory_section(section_score=None) + assert _section_display_score(section) == 4 + + +class TestCompletedScoreFallback: + """Tests for completed_score_fallback.""" + + def test_both_none_returns_none(self) -> None: + assert completed_score_fallback(_shell(), None, None) is None + + def test_theory_only_returns_theory_score(self) -> None: + section = _theory_section(section_score=8) + assert completed_score_fallback(_shell(), section, None) == 8 + + def test_coding_only_returns_coding_score(self) -> None: + section = _coding_section(section_score=6) + assert completed_score_fallback(_shell(), None, section) == 6 + + def test_both_sections_sums_scores(self) -> None: + theory = _theory_section(section_score=5) + coding = _coding_section(section_score=7) + assert completed_score_fallback(_shell(), theory, coding) == 12 + + def test_skipped_sections_count_as_zero(self) -> None: + theory = _theory_section(status="skipped") + coding = _coding_section(section_score=7) + assert completed_score_fallback(_shell(), theory, coding) == 7 + + +class TestResolveCompletedReadScore: + """Tests for resolve_completed_read_score.""" + + def test_active_session_returns_none(self) -> None: + shell = _shell(status="active") + assert resolve_completed_read_score(shell, _theory_section(), None) is None + + def test_uses_overall_feedback_score_when_available(self) -> None: + shell = _shell( + status="completed", + overall_feedback={ + "score_breakdown": { + "theory": {"score": 8, "max": 10}, + } + }, + ) + with patch( + "app.interview.services.scoring.SessionEvaluationAggregator.total_score_from_breakdown", + return_value=8, + ): + assert resolve_completed_read_score(shell, _theory_section(), None) == 8 + + def test_falls_back_to_section_totals(self) -> None: + shell = _shell( + status="completed", + overall_feedback={"not_breakdown": 1}, + ) + theory = _theory_section(section_score=None) + with patch( + "app.interview.services.scoring.SessionEvaluationAggregator.total_score_from_breakdown", + return_value=None, + ): + assert resolve_completed_read_score(shell, theory, None) == 4 + + def test_feedback_score_takes_precedence_over_fallback(self) -> None: + shell = _shell( + status="completed", + overall_feedback={ + "score_breakdown": { + "theory": {"score": 10, "max": 10}, + } + }, + ) + theory = _theory_section(section_score=3) + with patch( + "app.interview.services.scoring.SessionEvaluationAggregator.total_score_from_breakdown", + return_value=10, + ): + assert resolve_completed_read_score(shell, theory, None) == 10 + + def test_null_breakdown_uses_fallback(self) -> None: + shell = _shell( + status="completed", + overall_feedback=None, + ) + theory = _theory_section(section_score=6) + assert resolve_completed_read_score(shell, theory, None) == 6 diff --git a/tests/interview/services/test_section_prefetch.py b/tests/interview/services/test_section_prefetch.py new file mode 100644 index 0000000..bd54467 --- /dev/null +++ b/tests/interview/services/test_section_prefetch.py @@ -0,0 +1,141 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for section feedback background prefetch.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.interview.services.section_prefetch import prefetch_section_feedback + + +@pytest.mark.asyncio +async def test_prefetch_skips_when_should_prefetch_false() -> None: + """prefetch_section_feedback exits early when should_prefetch returns False.""" + evaluate = AsyncMock() + persist = MagicMock() + + await prefetch_section_feedback( + "iv-1", + section_name="theory", + should_prefetch=lambda: False, + evaluate=evaluate, + persist=persist, + ) + + evaluate.assert_not_awaited() + persist.assert_not_called() + + +@pytest.mark.asyncio +async def test_prefetch_skips_when_provider_not_configured() -> None: + """prefetch_section_feedback logs warning when provider creation fails.""" + evaluate = AsyncMock() + persist = MagicMock() + + with patch( + "app.interview.services.section_prefetch.ConfigService.create_provider_from_config", + side_effect=ValueError("No provider configured"), + ): + await prefetch_section_feedback( + "iv-1", + section_name="theory", + should_prefetch=lambda: True, + evaluate=evaluate, + persist=persist, + ) + + evaluate.assert_not_awaited() + persist.assert_not_called() + + +@pytest.mark.asyncio +async def test_prefetch_evaluates_and_persists() -> None: + """prefetch_section_feedback runs evaluation and saves the result.""" + provider = MagicMock() + payload = {"section_feedback": "Good work."} + evaluate = AsyncMock(return_value=(payload, 8)) + persist = MagicMock() + + with patch( + "app.interview.services.section_prefetch.ConfigService.create_provider_from_config", + return_value=provider, + ): + await prefetch_section_feedback( + "iv-1", + section_name="theory", + should_prefetch=lambda: True, + evaluate=evaluate, + persist=persist, + ) + + evaluate.assert_awaited_once_with(provider) + persist.assert_called_once_with(payload, 8) + + +@pytest.mark.asyncio +async def test_prefetch_skips_persist_when_result_none() -> None: + """prefetch_section_feedback skips persist when evaluation returns None.""" + provider = MagicMock() + evaluate = AsyncMock(return_value=None) + persist = MagicMock() + + with patch( + "app.interview.services.section_prefetch.ConfigService.create_provider_from_config", + return_value=provider, + ): + await prefetch_section_feedback( + "iv-1", + section_name="theory", + should_prefetch=lambda: True, + evaluate=evaluate, + persist=persist, + ) + + evaluate.assert_awaited_once_with(provider) + persist.assert_not_called() + + +@pytest.mark.asyncio +async def test_prefetch_handles_evaluation_error_gracefully() -> None: + """prefetch_section_failure logs but does not raise on evaluation failure.""" + provider = MagicMock() + evaluate = AsyncMock(side_effect=RuntimeError("LLM failure")) + persist = MagicMock() + + with patch( + "app.interview.services.section_prefetch.ConfigService.create_provider_from_config", + return_value=provider, + ): + await prefetch_section_feedback( + "iv-1", + section_name="theory", + should_prefetch=lambda: True, + evaluate=evaluate, + persist=persist, + ) + + evaluate.assert_awaited_once_with(provider) + persist.assert_not_called() + + +@pytest.mark.asyncio +async def test_prefetch_handles_provider_creation_error_gracefully() -> None: + """prefetch_section_feedback logs but does not raise when provider creation fails.""" + evaluate = AsyncMock() + persist = MagicMock() + + with patch( + "app.interview.services.section_prefetch.ConfigService.create_provider_from_config", + side_effect=Exception("unexpected"), + ): + await prefetch_section_feedback( + "iv-1", + section_name="coding", + should_prefetch=lambda: True, + evaluate=evaluate, + persist=persist, + ) + + evaluate.assert_not_awaited() + persist.assert_not_called() diff --git a/tests/interview/services/test_section_review_support.py b/tests/interview/services/test_section_review_support.py new file mode 100644 index 0000000..c8ce8c4 --- /dev/null +++ b/tests/interview/services/test_section_review_support.py @@ -0,0 +1,260 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for section review support helpers.""" + +from datetime import UTC, datetime +from unittest.mock import MagicMock, patch + +from app.interview.domain.serialization import session_to_spec +from app.interview.domain.value_objects import ( + SessionSelection, + TrackSelection, +) +from app.interview.schemas.interview import InterviewRead +from app.interview.services.section_review_support import ( + CompletedInterviewSnapshot, + item_id_key_for, + load_completed_interview, + resolved_section_feedback, + review_score_fields, + section_score_bounds, + shared_review_fields, +) +from app.interview.services.sections import SectionEvaluationSummary + + +def _completed_read( + *, + status: str = "completed", + selection_spec: str = "{}", +) -> InterviewRead: + return InterviewRead( + id="iv-1", + status=status, + locale="en", + selection_spec=selection_spec, + question_ids='["q1"]', + question_count=1, + question_time_limit_seconds=None, + answers=[], + started_at=datetime(2026, 1, 1, tzinfo=UTC), + completed_at=datetime(2026, 1, 1, tzinfo=UTC), + ) + + +def _snapshot() -> CompletedInterviewSnapshot: + selection = SessionSelection.theory_only( + sources=( + TrackSelection( + track="python", + level="junior", + categories=("basics",), + ), + ) + ) + interview = _completed_read(selection_spec=session_to_spec(selection)) + return CompletedInterviewSnapshot( + interview=interview, + session=selection, + ) + + +class TestLoadCompletedInterview: + """Tests for load_completed_interview.""" + + def test_returns_none_for_incomplete_interview(self) -> None: + uow = MagicMock() + active_read = _completed_read(status="active") + with patch( + "app.interview.services.section_review_support.load_interview_read", + return_value=active_read, + ): + result = load_completed_interview(uow, "iv-1") + assert result is None + + def test_returns_none_when_interview_missing(self) -> None: + uow = MagicMock() + with patch( + "app.interview.services.section_review_support.load_interview_read", + return_value=None, + ): + result = load_completed_interview(uow, "iv-1") + assert result is None + + def test_returns_snapshot_for_completed(self) -> None: + uow = MagicMock() + selection = SessionSelection.theory_only( + sources=( + TrackSelection( + track="python", + level="junior", + categories=("basics",), + ), + ) + ) + read = _completed_read( + selection_spec=session_to_spec(selection), + ) + with patch( + "app.interview.services.section_review_support.load_interview_read", + return_value=read, + ): + result = load_completed_interview(uow, "iv-1") + assert result is not None + assert isinstance(result, CompletedInterviewSnapshot) + assert result.interview.id == "iv-1" + assert result.interview.status == "completed" + + +class TestSectionScoreBounds: + """Tests for section_score_bounds.""" + + def test_skipped_zero_zero_returns_zeros(self) -> None: + assert section_score_bounds(skipped=True, total_score=0, max_score=0) == (0, 0) + + def test_skipped_with_score_returns_actual(self) -> None: + assert section_score_bounds(skipped=True, total_score=3, max_score=5) == (3, 5) + + def test_not_skipped_returns_actual(self) -> None: + assert section_score_bounds(skipped=False, total_score=7, max_score=10) == ( + 7, + 10, + ) + + +class TestSharedReviewFields: + """Tests for shared_review_fields.""" + + def test_builds_correct_fields(self) -> None: + snapshot = _snapshot() + fields = shared_review_fields("iv-1", snapshot) + assert fields["interview_id"] == "iv-1" + assert fields["results_url"] == "/interview/iv-1/results" + assert fields["locale_label"] == "English" + assert "interview_title" in fields + assert "selection_lines" in fields + + def test_locale_label_for_unknown_locale(self) -> None: + selection = SessionSelection.theory_only(sources=()) + interview = InterviewRead( + id="iv-2", + status="completed", + locale="zz", + selection_spec=session_to_spec(selection), + question_ids="[]", + question_count=0, + question_time_limit_seconds=None, + answers=[], + started_at=datetime(2026, 1, 1, tzinfo=UTC), + completed_at=datetime(2026, 1, 1, tzinfo=UTC), + ) + snapshot = CompletedInterviewSnapshot( + interview=interview, + session=selection, + ) + fields = shared_review_fields("iv-2", snapshot) + assert fields["locale_label"] == "zz" + + +class TestResolvedSectionFeedback: + """Tests for resolved_section_feedback.""" + + def test_uses_cached_payload(self) -> None: + cached = {"section_feedback": "Cached."} + summary = SectionEvaluationSummary( + section="theory", + score=5, + max_score=5, + items=(), + ) + with patch( + "app.interview.services.section_review_support.resolve_section_feedback", + return_value=cached, + ) as mock_resolve: + result = resolved_section_feedback( + summary, + item_id_key="question_id", + cached_payload=cached, + ) + mock_resolve.assert_called_once_with( + cached, + (), + item_id_key="question_id", + ) + assert result == cached + + def test_falls_back_to_summary_items(self) -> None: + summary = SectionEvaluationSummary( + section="theory", + score=5, + max_score=5, + items=( + { + "question_id": "q1", + "score": 4, + "feedback": "Good.", + }, + ), + ) + with patch( + "app.interview.services.section_review_support.resolve_section_feedback", + return_value={"section_feedback": "Good."}, + ) as mock_resolve: + result = resolved_section_feedback( + summary, + item_id_key="question_id", + cached_payload=None, + ) + mock_resolve.assert_called_once_with( + None, + summary.items, + item_id_key="question_id", + ) + assert result["section_feedback"] == "Good." + + +class TestReviewScoreFields: + """Tests for review_score_fields.""" + + def test_normalizes_skipped_zero_zero(self) -> None: + summary = SectionEvaluationSummary( + section="theory", + score=0, + max_score=0, + items=(), + skipped=True, + ) + result = review_score_fields(summary, total_score=0, max_score=0) + assert result == {"section_score": 0, "section_max_score": 0} + + def test_normalizes_active_scores(self) -> None: + summary = SectionEvaluationSummary( + section="theory", + score=7, + max_score=10, + items=(), + skipped=False, + ) + result = review_score_fields(summary, total_score=7, max_score=10) + assert result == {"section_score": 7, "section_max_score": 10} + + def test_uses_aggregate_scores_over_summary(self) -> None: + summary = SectionEvaluationSummary( + section="coding", + score=3, + max_score=5, + items=(), + skipped=False, + ) + result = review_score_fields(summary, total_score=8, max_score=10) + assert result == {"section_score": 8, "section_max_score": 10} + + +class TestItemIdKeyFor: + """Tests for item_id_key_for.""" + + def test_theory_returns_question_id(self) -> None: + assert item_id_key_for("theory") == "question_id" + + def test_coding_returns_task_id(self) -> None: + assert item_id_key_for("coding") == "task_id" diff --git a/tests/platform/api/test_config_edge_cases.py b/tests/platform/api/test_config_edge_cases.py new file mode 100644 index 0000000..ce7c5d1 --- /dev/null +++ b/tests/platform/api/test_config_edge_cases.py @@ -0,0 +1,248 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Config edge cases: locale change, delete config preserves sessions, invalid URL.""" + +from unittest.mock import patch + +from app.ai.llm_models import LLMModelEntry +from app.platform.services.config import AppConfig + + +class TestConfigEdgeCases: + """Tests for configuration edge cases.""" + + def _catalog_entry(self): + from app.ai.llm_models import LLMModelEntry + + return LLMModelEntry( + id="cloud", + display_name="Cloud", + provider_type="openai-compatible", + model="gpt-4", + base_url="https://api.openai.com", + api_key_required=True, + api_key="stored-secret", + ) + + def test_locale_change_saved(self, client, isolated_db): + """Locale change written to config and used in new sessions.""" + with ( + patch( + "app.platform.services.config_form.normalize_model_id", + return_value="cloud", + ), + patch( + "app.platform.api.config.LLMCatalogService.get_model", + return_value=self._catalog_entry(), + ), + patch( + "app.platform.services.config.LLMCatalogService.get_model", + return_value=self._catalog_entry(), + ), + patch( + "app.platform.services.config.ConfigService.test_connection", + return_value=(True, "OK"), + ), + patch( + "app.platform.services.config.ConfigService.save_config" + ) as mock_save, + ): + response = client.post( + "/config", + data={ + "llm_preset_id": "cloud", + "api_key": "test-key", + "timeout": "60", + "locale": "ru", + }, + ) + assert response.status_code == 200 + saved = mock_save.call_args[0][0] + assert saved.locale == "ru" + + def test_config_delete_keeps_sessions(self, client, isolated_db): + """Deleting config does not affect existing interviews.""" + from app.shared.infrastructure.models import Interview + from tests.helpers.interview_seed import persist_interview_with_answers + from tests.helpers.selection import minimal_selection_spec + + interview_id = persist_interview_with_answers( + Interview( + id="cfg-delete-1", + locale="en", + selection_spec=minimal_selection_spec(), + status="active", + ), + [], + question_count=5, + ) + + with patch( + "app.platform.services.config.ConfigService.delete_config" + ) as mock_delete: + client.delete("/config") + mock_delete.assert_called_once() + + # Existing session still reachable + from app.interview.repositories.uow import InterviewUnitOfWork + + with InterviewUnitOfWork() as uow: + interview = uow.interviews.get_aggregate(interview_id) + assert interview is not None + assert interview.status == "active" + + def test_add_model_rejects_invalid_url(self, client, isolated_db): + """Adding model with unreachable URL fails validation.""" + with ( + patch( + "app.platform.services.config.ConfigService.test_catalog_model", + return_value=(False, "Connection refused"), + ), + patch( + "app.platform.services.config.ConfigService.get_config", + return_value=None, + ), + ): + response = client.post( + "/config/llm-models", + data={ + "display_name": "Broken", + "base_url": "http://invalid-url-test", + "model": "none", + "api_key": "", + "api_key_required": "", + }, + ) + assert response.status_code == 200 + text = response.text + assert "error" in text.lower() or "refused" in text.lower() + + def test_add_model_with_accepts_audio_input_flag(self, client, isolated_db): + """S12.2: Adding model with accepts_audio_input=true stores the flag.""" + added_entry = LLMModelEntry( + id="audio-model-id", + display_name="Audio Model", + provider_type="openai-compatible", + model="gpt-4o-audio", + base_url="https://api.openai.com/v1", + api_key_required=True, + api_key="test-key", + accepts_audio_input=True, + ) + with ( + patch( + "app.platform.services.config.ConfigService.test_catalog_model", + return_value=(True, "OK"), + ), + patch( + "app.platform.services.config.ConfigService.get_config", + return_value=None, + ), + patch( + "app.platform.services.llm_catalog.LLMCatalogService.add_user_model", + return_value=added_entry, + ) as mock_add, + ): + response = client.post( + "/config/llm-models", + data={ + "display_name": "Audio Model", + "base_url": "https://api.openai.com/v1", + "model": "gpt-4o-audio", + "api_key": "test-key", + "api_key_required": "on", + "accepts_audio_input": "on", + }, + ) + assert response.status_code == 200 + mock_add.assert_called_once() + call_args = mock_add.call_args + payload = call_args[0][0] if call_args[0] else call_args[1] + assert payload.accepts_audio_input is True + + def test_config_save_without_api_key_when_not_required(self, client, isolated_db): + """S12.5: Save config with empty api_key when api_key_required=false works.""" + ollama_entry = LLMModelEntry( + id="local", + display_name="Ollama Local", + provider_type="openai-compatible", + model="llama3", + base_url="http://localhost:11434/v1", + api_key_required=False, + api_key=None, + ) + with ( + patch( + "app.platform.services.config_form.normalize_model_id", + return_value="local", + ), + patch( + "app.platform.api.config.LLMCatalogService.get_model", + return_value=ollama_entry, + ), + patch( + "app.platform.services.config.ConfigService.test_connection", + return_value=(True, "OK"), + ), + patch( + "app.platform.services.config.ConfigService.save_config" + ) as mock_save, + ): + response = client.post( + "/config", + data={ + "llm_preset_id": "local", + "api_key": "", # empty + "timeout": "60", + "locale": "en", + }, + ) + assert response.status_code == 200 + saved = mock_save.call_args[0][0] + assert saved.api_key is None or saved.api_key == "" + + def test_speech_model_size_change_stored(self, client, isolated_db): + """S12.4: Changing speech_model_size is persisted in config.""" + existing = AppConfig( + provider_type="openai-compatible", + base_url="https://api.openai.com", + model="gpt-4", + api_key="stored-secret", + llm_preset_id="cloud", + speech_model_size="small", + ) + with ( + patch( + "app.platform.services.config.ConfigService.get_config", + return_value=existing, + ), + patch( + "app.platform.services.config_form.normalize_model_id", + return_value="cloud", + ), + patch( + "app.platform.api.config.LLMCatalogService.get_model", + return_value=self._catalog_entry(), + ), + patch( + "app.platform.services.config.ConfigService.test_connection", + return_value=(True, "OK"), + ), + patch( + "app.platform.services.config.ConfigService.save_config" + ) as mock_save, + ): + response = client.post( + "/config", + data={ + "llm_preset_id": "cloud", + "api_key": "", + "timeout": "60", + "locale": "en", + "speech_model_size": "medium", + "question_voice_enabled": "", + }, + ) + assert response.status_code == 200 + saved = mock_save.call_args[0][0] + assert saved.speech_model_size == "medium" diff --git a/tests/platform/api/test_config_flow.py b/tests/platform/api/test_config_flow.py new file mode 100644 index 0000000..0cab28f --- /dev/null +++ b/tests/platform/api/test_config_flow.py @@ -0,0 +1,296 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for first-time configuration flow (S1 scenarios).""" + +from unittest.mock import patch + +from app.ai.llm_models import LLMModelEntry +from app.platform.services.config import AppConfig + + +class TestFirstTimeConfigFlow: + """S1: First-time Configuration Flow.""" + + def _catalog_entry(self): + return LLMModelEntry( + id="cloud", + display_name="Cloud", + provider_type="openai-compatible", + model="gpt-4", + base_url="https://api.openai.com", + api_key_required=True, + api_key="stored-secret", + ) + + def test_dashboard_renders_without_config(self, client): + """S1.1: GET / renders dashboard even without provider config.""" + with ( + patch( + "app.platform.services.config.ConfigService.get_config", + return_value=None, + ), + patch( + "app.interview.services.dashboard.DashboardBuilder.list_rows", + return_value=[], + ), + ): + response = client.get("/") + assert response.status_code == 200 + assert "text/html" in response.headers.get("content-type", "") + + def test_setup_redirects_without_config(self, client): + """S1.2: GET /setup redirects to /config when no provider config.""" + with patch( + "app.platform.services.config.ConfigService.get_config", + return_value=None, + ): + response = client.get("/setup", follow_redirects=False) + assert response.status_code == 303 + assert response.headers["location"] == "/config" + + def test_config_save_empty_fields_returns_422(self, client): + """S1.3: POST /config with empty required fields returns 422 validation error.""" + with patch( + "app.platform.services.config.ConfigService.get_config", + return_value=None, + ): + response = client.post( + "/config", + data={ + "llm_preset_id": "", + "api_key": "", + "timeout": "", + "locale": "en", + }, + ) + # FastAPI form validation returns 422 for empty required strings + assert response.status_code == 422 + + def test_config_save_fails_with_unreachable_ollama(self, client): + """S1.4: Save with unreachable Ollama shows connection error.""" + with ( + patch( + "app.platform.services.config_form.normalize_model_id", + return_value="local", + ), + patch( + "app.platform.api.config.LLMCatalogService.get_model", + return_value=None, + ), + patch( + "app.platform.services.config.ConfigService.test_connection", + return_value=(False, "Connection refused"), + ), + ): + response = client.post( + "/config", + data={ + "llm_preset_id": "local", + "base_url": "http://localhost:11434/v1", + "model": "llama3", + "api_key": "", + "timeout": "60", + "locale": "en", + }, + ) + assert response.status_code == 200 + assert ( + "connection refused" in response.text.lower() + or "error" in response.text.lower() + ) + + def test_config_test_connection_success(self, client): + """S1.5: POST /config/test returns success with valid provider.""" + with ( + patch( + "app.platform.services.config_form.normalize_model_id", + return_value="cloud", + ), + patch( + "app.platform.api.config.LLMCatalogService.get_model", + return_value=self._catalog_entry(), + ), + patch( + "app.platform.services.config.ConfigService.test_connection", + return_value=(True, "Connection successful"), + ), + ): + response = client.post( + "/config/test", + data={ + "llm_preset_id": "cloud", + "api_key": "test-key", + "timeout": "60", + "locale": "en", + }, + ) + assert response.status_code == 200 + assert "successful" in response.text.lower() + + def test_config_test_connection_failure(self, client): + """S1.5: POST /config/test returns error with invalid provider.""" + with ( + patch( + "app.platform.services.config_form.normalize_model_id", + return_value="cloud", + ), + patch( + "app.platform.api.config.LLMCatalogService.get_model", + return_value=self._catalog_entry(), + ), + patch( + "app.platform.services.config.ConfigService.test_connection", + return_value=(False, "Invalid API key"), + ), + ): + response = client.post( + "/config/test", + data={ + "llm_preset_id": "cloud", + "api_key": "bad-key", + "timeout": "60", + "locale": "en", + }, + ) + assert response.status_code == 200 + assert "error" in response.text.lower() or "invalid" in response.text.lower() + + def test_add_model_to_catalog_success(self, client): + """S1.6: Add a new model to catalog via POST /config/llm-models.""" + with ( + patch( + "app.platform.services.config.ConfigService.test_catalog_model", + return_value=(True, "OK"), + ), + patch( + "app.platform.services.config.ConfigService.get_config", + return_value=None, + ), + patch( + "app.platform.services.llm_catalog.LLMCatalogService.add_user_model", + return_value=self._catalog_entry(), + ) as mock_add, + ): + response = client.post( + "/config/llm-models", + data={ + "display_name": "GPT-4 Test", + "base_url": "https://api.openai.com/v1", + "model": "gpt-4", + "api_key": "test-key", + "api_key_required": "on", + }, + ) + assert response.status_code == 200 + mock_add.assert_called_once() + assert "added" in response.text.lower() or "gpt-4 test" in response.text.lower() + + def test_add_model_to_catalog_rejects_invalid_url(self, client): + """S1.6: Adding model with invalid URL fails validation.""" + with ( + patch( + "app.platform.services.config.ConfigService.test_catalog_model", + return_value=(False, "Connection refused"), + ), + patch( + "app.platform.services.config.ConfigService.get_config", + return_value=None, + ), + ): + response = client.post( + "/config/llm-models", + data={ + "display_name": "Broken", + "base_url": "http://invalid-url-test", + "model": "none", + "api_key": "", + "api_key_required": "", + }, + ) + assert response.status_code == 200 + assert "error" in response.text.lower() or "refused" in response.text.lower() + + def test_config_save_redirects_or_shows_success(self, client): + """S1.7: Save valid config and verify success response.""" + with ( + patch( + "app.platform.services.config_form.normalize_model_id", + return_value="cloud", + ), + patch( + "app.platform.api.config.LLMCatalogService.get_model", + return_value=self._catalog_entry(), + ), + patch( + "app.platform.services.config.ConfigService.test_connection", + return_value=(True, "OK"), + ), + patch( + "app.platform.services.config.ConfigService.save_config" + ) as mock_save, + patch( + "app.platform.services.speech_runtime.SpeechRuntimeCoordinator.reload_after_config_save" + ), + ): + response = client.post( + "/config", + data={ + "llm_preset_id": "cloud", + "api_key": "test-key", + "timeout": "60", + "locale": "en", + }, + ) + assert response.status_code == 200 + mock_save.assert_called_once() + assert "saved" in response.text.lower() or "success" in response.text.lower() + + def test_after_save_setup_no_longer_redirects(self, client): + """S1.8: After config saved, GET /setup returns setup page.""" + mock_config = AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + locale="en", + ) + with ( + patch( + "app.platform.services.config.ConfigService.get_config", + return_value=mock_config, + ), + patch( + "app.interview.api.setup.list_tracks", + return_value=["python"], + ), + patch( + "app.interview.api.setup.list_levels", + return_value=["junior"], + ), + patch( + "app.interview.api.setup.list_categories", + return_value=["basics"], + ), + ): + response = client.get("/setup") + assert response.status_code == 200 + assert "setup" in response.text.lower() + + def test_delete_config_then_setup_redirects(self, client): + """S1.9: DELETE /config then GET /setup redirects to /config.""" + with ( + patch( + "app.platform.services.config.ConfigService.delete_config" + ) as mock_delete, + ): + response = client.delete("/config") + assert response.status_code == 200 + mock_delete.assert_called_once() + + # After deletion, setup should redirect + with patch( + "app.platform.services.config.ConfigService.get_config", + return_value=None, + ): + response = client.get("/setup", follow_redirects=False) + assert response.status_code == 303 + assert response.headers["location"] == "/config" diff --git a/tests/platform/services/test_ai_context.py b/tests/platform/services/test_ai_context.py new file mode 100644 index 0000000..98b2821 --- /dev/null +++ b/tests/platform/services/test_ai_context.py @@ -0,0 +1,94 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for AI provider lifecycle helpers.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.platform.services.ai_context import ai_provider_from_config + + +class TestAiProviderFromConfig: + """Tests for ai_provider_from_config context manager.""" + + @pytest.mark.asyncio + async def test_yields_provider(self): + """Context manager yields a configured AI provider.""" + mock_provider = MagicMock() + mock_provider.close = AsyncMock() + + with patch( + "app.platform.services.ai_context.ConfigService.create_provider_from_config", + return_value=mock_provider, + ): + async with ai_provider_from_config() as provider: + assert provider == mock_provider + + @pytest.mark.asyncio + async def test_closes_provider_on_exit(self): + """Provider is closed when exiting the context manager.""" + mock_provider = MagicMock() + mock_provider.close = AsyncMock() + + with patch( + "app.platform.services.ai_context.ConfigService.create_provider_from_config", + return_value=mock_provider, + ): + async with ai_provider_from_config(): + pass + + mock_provider.close.assert_awaited_once() + + @pytest.mark.asyncio + async def test_closes_provider_on_exception(self): + """Provider is closed even when an exception occurs inside the context.""" + mock_provider = MagicMock() + mock_provider.close = AsyncMock() + + with ( + patch( + "app.platform.services.ai_context.ConfigService.create_provider_from_config", + return_value=mock_provider, + ), + pytest.raises(ValueError, match="boom"), + ): + async with ai_provider_from_config(): + raise ValueError("boom") + + mock_provider.close.assert_awaited_once() + + @pytest.mark.asyncio + async def test_logs_warning_when_close_fails(self): + """Close errors are logged but not re-raised.""" + mock_provider = MagicMock() + mock_provider.close = AsyncMock(side_effect=RuntimeError("close failed")) + + with ( + patch( + "app.platform.services.ai_context.ConfigService.create_provider_from_config", + return_value=mock_provider, + ), + patch("app.platform.services.ai_context.logger") as mock_logger, + ): + async with ai_provider_from_config(): + pass + + mock_provider.close.assert_awaited_once() + mock_logger.warning.assert_called_once() + args = mock_logger.warning.call_args[0] + assert "Failed to close AI provider" in args[0] + assert "close failed" in str(args[1]) + + @pytest.mark.asyncio + async def test_raises_when_no_config(self): + """ValueError propagates when no configuration exists.""" + with ( + patch( + "app.platform.services.ai_context.ConfigService.create_provider_from_config", + side_effect=ValueError("No configuration found"), + ), + pytest.raises(ValueError, match="No configuration found"), + ): + async with ai_provider_from_config(): + pass # pragma: no cover diff --git a/tests/platform/services/test_config_form.py b/tests/platform/services/test_config_form.py new file mode 100644 index 0000000..c8ee2d6 --- /dev/null +++ b/tests/platform/services/test_config_form.py @@ -0,0 +1,220 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for ConfigFormService parsing and connection testing.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.ai.llm_models import LLMModelEntry +from app.platform.services.config import AppConfig +from app.platform.services.config_form import ConfigFormService + + +class TestParseAndTest: + """Tests for ConfigFormService.parse_and_test.""" + + @pytest.fixture + def mock_catalog(self, monkeypatch): + """Patch LLMCatalogService with a minimal catalog.""" + entry = LLMModelEntry( + id="cloud", + display_name="Cloud", + provider_type="openai-compatible", + model="gpt-4", + base_url="https://api.example.com/v1", + api_key_required=True, + ) + catalog = MagicMock() + catalog.models = {"cloud": entry} + with ( + patch( + "app.platform.services.config_form.LLMCatalogService.get_model", + return_value=entry, + ) as mock_get, + patch( + "app.platform.services.config_form.LLMCatalogService.load_catalog", + return_value=catalog, + ), + ): + yield mock_get, entry + + @pytest.fixture + def mock_config_service(self): + """Build a mock ConfigService class.""" + mock_service = MagicMock() + mock_service.get_config.return_value = None + mock_service.test_interview_model = AsyncMock(return_value=(True, "OK")) + return mock_service + + @pytest.mark.asyncio + async def test_parse_and_test_success(self, mock_catalog, mock_config_service): + """Valid form data yields config, success=True, and message.""" + _, entry = mock_catalog + config, success, message = await ConfigFormService.parse_and_test( + config_service=mock_config_service, + llm_preset_id="cloud", + api_key="secret", + timeout=30.0, + locale="en", + speech_model_size="small", + question_voice_enabled=True, + ) + assert isinstance(config, AppConfig) + assert config.provider_type == "openai-compatible" + assert config.model == "gpt-4" + assert config.api_key == "secret" + assert config.locale == "en" + assert config.speech_model_size == "small" + assert config.question_voice_enabled is True + assert success is True + assert message == "OK" + mock_config_service.test_interview_model.assert_awaited_once() + + @pytest.mark.asyncio + async def test_parse_and_test_uses_existing_voice_when_locale_unchanged( + self, mock_catalog, mock_config_service + ): + """Existing tts_voice_id is preserved when locale matches.""" + existing = AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + locale="ru", + tts_voice_id="ru_RU-dmitri-medium", + ) + mock_config_service.get_config.return_value = existing + + config, success, message = await ConfigFormService.parse_and_test( + config_service=mock_config_service, + llm_preset_id="cloud", + api_key="secret", + timeout=30.0, + locale="ru", + speech_model_size="small", + question_voice_enabled=False, + ) + assert config.tts_voice_id == "ru_RU-dmitri-medium" + + @pytest.mark.asyncio + async def test_parse_and_test_selects_voice_by_locale_when_locale_changes( + self, mock_catalog, mock_config_service + ): + """New locale triggers default voice selection.""" + existing = AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + locale="en", + tts_voice_id="en_US-lessac-medium", + ) + mock_config_service.get_config.return_value = existing + + config, success, message = await ConfigFormService.parse_and_test( + config_service=mock_config_service, + llm_preset_id="cloud", + api_key="secret", + timeout=30.0, + locale="fr", + speech_model_size="small", + question_voice_enabled=False, + ) + assert config.tts_voice_id == "fr_FR-siwis-medium" + + @pytest.mark.asyncio + async def test_parse_and_test_rejects_invalid_preset(self, mock_config_service): + """Invalid llm_preset_id falls back to existing or default config.""" + with ( + patch( + "app.platform.services.config_form.LLMCatalogService.load_catalog", + return_value=MagicMock(), + ), + patch( + "app.platform.services.config_form.normalize_model_id", + side_effect=ValueError("Unsupported LLM model"), + ), + ): + config, success, message = await ConfigFormService.parse_and_test( + config_service=mock_config_service, + llm_preset_id="invalid", + api_key="", + timeout=60.0, + locale="en", + speech_model_size="small", + question_voice_enabled=False, + ) + assert success is False + assert "Unsupported LLM model" in message + assert config is not None + + @pytest.mark.asyncio + async def test_parse_and_test_rejects_missing_model_entry( + self, mock_config_service + ): + """Catalog entry not found returns failure with fallback config.""" + with ( + patch( + "app.platform.services.config_form.LLMCatalogService.load_catalog", + return_value=MagicMock(), + ), + patch( + "app.platform.services.config_form.LLMCatalogService.get_model", + return_value=None, + ), + patch( + "app.platform.services.config_form.normalize_model_id", + return_value="cloud", + ), + ): + config, success, message = await ConfigFormService.parse_and_test( + config_service=mock_config_service, + llm_preset_id="cloud", + api_key="", + timeout=60.0, + locale="en", + speech_model_size="small", + question_voice_enabled=False, + ) + assert success is False + assert "Interview model not found" in message + + @pytest.mark.asyncio + async def test_parse_and_test_connection_failure( + self, mock_catalog, mock_config_service + ): + """Connection test failure is returned with config.""" + mock_config_service.test_interview_model = AsyncMock( + return_value=(False, "Connection refused") + ) + + config, success, message = await ConfigFormService.parse_and_test( + config_service=mock_config_service, + llm_preset_id="cloud", + api_key="bad", + timeout=30.0, + locale="en", + speech_model_size="small", + question_voice_enabled=False, + ) + assert success is False + assert message == "Connection refused" + assert config.provider_type == "openai-compatible" + + @pytest.mark.asyncio + async def test_parse_and_test_normalizes_inputs( + self, mock_catalog, mock_config_service + ): + """Locale and speech_model_size are normalized.""" + _, entry = mock_catalog + config, success, message = await ConfigFormService.parse_and_test( + config_service=mock_config_service, + llm_preset_id="cloud", + api_key="", + timeout=60.0, + locale=" RU ", + speech_model_size=" SMALL ", + question_voice_enabled=True, + ) + assert config.locale == "ru" + assert config.speech_model_size == "small" + assert config.tts_voice_id == "ru_RU-dmitri-medium" diff --git a/tests/platform/services/test_speech_runtime.py b/tests/platform/services/test_speech_runtime.py new file mode 100644 index 0000000..ff4973a --- /dev/null +++ b/tests/platform/services/test_speech_runtime.py @@ -0,0 +1,432 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for SpeechRuntimeCoordinator.""" + +from unittest.mock import patch + +from fastapi import FastAPI +import pytest + +from app.platform.services.config import AppConfig +from app.platform.services.speech_runtime import SpeechRuntimeCoordinator + + +@pytest.fixture(autouse=True) +def reset_runtimes(): + """Reset runtime class state before each test.""" + from app.question_voice.services.piper_runtime import PiperRuntime + from app.speech.services.whisper_runtime import WhisperRuntime + + WhisperRuntime._app = None + WhisperRuntime._artifact = None + WhisperRuntime._loaded_key = None + PiperRuntime._artifact = None + PiperRuntime._loaded_key = None + PiperRuntime._load_error = None + yield + WhisperRuntime._app = None + WhisperRuntime._artifact = None + WhisperRuntime._loaded_key = None + PiperRuntime._artifact = None + PiperRuntime._loaded_key = None + PiperRuntime._load_error = None + + +class TestUnloadAll: + """Tests for SpeechRuntimeCoordinator.unload_all.""" + + def test_unloads_both_runtimes(self): + """unload_all calls unload on Whisper and Piper.""" + from app.question_voice.services.piper_runtime import PiperRuntime + from app.speech.services.whisper_runtime import WhisperRuntime + + with ( + patch.object(WhisperRuntime, "unload") as mock_whisper_unload, + patch.object(PiperRuntime, "unload") as mock_piper_unload, + ): + SpeechRuntimeCoordinator.unload_all() + + mock_whisper_unload.assert_called_once() + mock_piper_unload.assert_called_once() + + +class TestStartup: + """Tests for SpeechRuntimeCoordinator.startup.""" + + @pytest.mark.asyncio + async def test_startup_binds_app_and_loads_both(self): + """startup binds app, loads Whisper and Piper when installed.""" + from app.question_voice.services.piper_runtime import PiperRuntime + from app.speech.services.whisper_runtime import WhisperRuntime + + app = FastAPI() + config = AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + speech_model_size="small", + question_voice_enabled=True, + tts_voice_id="en_US-lessac-medium", + locale="en", + ) + + with ( + patch.object(WhisperRuntime, "bind_app") as mock_bind, + patch( + "app.platform.services.speech_runtime.ConfigService.get_config", + return_value=config, + ), + patch( + "app.platform.services.speech_runtime.is_installed", + return_value=True, + ), + patch( + "app.platform.services.speech_runtime.is_voice_installed", + return_value=True, + ), + patch.object( + WhisperRuntime, + "load_size", + return_value=True, + ) as mock_whisper_load, + patch.object( + PiperRuntime, + "load_voice", + return_value=True, + ) as mock_piper_load, + ): + await SpeechRuntimeCoordinator.startup(app) + + mock_bind.assert_called_once_with(app) + mock_whisper_load.assert_awaited_once_with("small") + mock_piper_load.assert_awaited_once_with("en_US-lessac-medium") + + @pytest.mark.asyncio + async def test_startup_skips_when_no_config(self): + """startup unloads both when no config exists.""" + from app.question_voice.services.piper_runtime import PiperRuntime + from app.speech.services.whisper_runtime import WhisperRuntime + + app = FastAPI() + + with ( + patch.object(WhisperRuntime, "bind_app") as mock_bind, + patch( + "app.platform.services.speech_runtime.ConfigService.get_config", + return_value=None, + ), + patch.object(WhisperRuntime, "unload") as mock_whisper_unload, + patch.object(PiperRuntime, "unload") as mock_piper_unload, + ): + await SpeechRuntimeCoordinator.startup(app) + + mock_bind.assert_called_once_with(app) + mock_whisper_unload.assert_called_once() + mock_piper_unload.assert_called_once() + + +class TestSyncWhisper: + """Tests for SpeechRuntimeCoordinator.sync_whisper.""" + + @pytest.mark.asyncio + async def test_loads_when_installed(self): + """sync_whisper loads when model is installed.""" + from app.speech.services.whisper_runtime import WhisperRuntime + + config = AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + speech_model_size="medium", + ) + + with ( + patch( + "app.platform.services.speech_runtime.is_installed", + return_value=True, + ), + patch.object( + WhisperRuntime, + "load_size", + return_value=True, + ) as mock_load, + ): + await SpeechRuntimeCoordinator.sync_whisper(config) + + mock_load.assert_awaited_once_with("medium") + + @pytest.mark.asyncio + async def test_unloads_when_not_installed(self): + """sync_whisper unloads when model is not installed.""" + from app.speech.services.whisper_runtime import WhisperRuntime + + config = AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + speech_model_size="large", + ) + + with ( + patch( + "app.platform.services.speech_runtime.is_installed", + return_value=False, + ), + patch.object(WhisperRuntime, "unload") as mock_unload, + ): + await SpeechRuntimeCoordinator.sync_whisper(config) + + mock_unload.assert_called_once() + + @pytest.mark.asyncio + async def test_unloads_when_no_config(self): + """sync_whisper unloads when config is None.""" + from app.speech.services.whisper_runtime import WhisperRuntime + + with patch.object(WhisperRuntime, "unload") as mock_unload: + await SpeechRuntimeCoordinator.sync_whisper(None) + + mock_unload.assert_called_once() + + +class TestSyncPiper: + """Tests for SpeechRuntimeCoordinator.sync_piper.""" + + @pytest.mark.asyncio + async def test_loads_when_enabled_and_installed(self): + """sync_piper loads voice when enabled and installed.""" + from app.question_voice.services.piper_runtime import PiperRuntime + + config = AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + question_voice_enabled=True, + tts_voice_id="en_US-lessac-medium", + ) + + with ( + patch( + "app.platform.services.speech_runtime.is_voice_installed", + return_value=True, + ), + patch.object( + PiperRuntime, + "load_voice", + return_value=True, + ) as mock_load, + ): + await SpeechRuntimeCoordinator.sync_piper(config) + + mock_load.assert_awaited_once_with("en_US-lessac-medium") + + @pytest.mark.asyncio + async def test_unloads_when_disabled(self): + """sync_piper unloads when question voice is disabled.""" + from app.question_voice.services.piper_runtime import PiperRuntime + + config = AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + question_voice_enabled=False, + ) + + with patch.object(PiperRuntime, "unload") as mock_unload: + await SpeechRuntimeCoordinator.sync_piper(config) + + mock_unload.assert_called_once() + + @pytest.mark.asyncio + async def test_unloads_when_not_installed(self): + """sync_piper unloads when voice is not on disk.""" + from app.question_voice.services.piper_runtime import PiperRuntime + + config = AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + question_voice_enabled=True, + tts_voice_id="en_US-lessac-medium", + ) + + with ( + patch( + "app.platform.services.speech_runtime.is_voice_installed", + return_value=False, + ), + patch.object(PiperRuntime, "unload") as mock_unload, + ): + await SpeechRuntimeCoordinator.sync_piper(config) + + mock_unload.assert_called_once() + + @pytest.mark.asyncio + async def test_unloads_when_no_config(self): + """sync_piper unloads when config is None.""" + from app.question_voice.services.piper_runtime import PiperRuntime + + with patch.object(PiperRuntime, "unload") as mock_unload: + await SpeechRuntimeCoordinator.sync_piper(None) + + mock_unload.assert_called_once() + + +class TestReloadAfterConfigSave: + """Tests for SpeechRuntimeCoordinator.reload_after_config_save.""" + + @pytest.mark.asyncio + async def test_reloads_whisper_and_piper(self): + """reload_after_config_save re-runs sync for both runtimes.""" + from app.question_voice.services.piper_runtime import PiperRuntime + from app.speech.services.whisper_runtime import WhisperRuntime + + config = AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + speech_model_size="small", + question_voice_enabled=True, + tts_voice_id="ru_RU-dmitri-medium", + ) + + with ( + patch( + "app.platform.services.speech_runtime.is_installed", + return_value=True, + ), + patch( + "app.platform.services.speech_runtime.is_voice_installed", + return_value=True, + ), + patch.object( + WhisperRuntime, + "load_size", + return_value=True, + ) as mock_whisper_load, + patch.object( + PiperRuntime, + "load_voice", + return_value=True, + ) as mock_piper_load, + ): + await SpeechRuntimeCoordinator.reload_after_config_save(config) + + mock_whisper_load.assert_awaited_once_with("small") + mock_piper_load.assert_awaited_once_with("ru_RU-dmitri-medium") + + +class TestPreloadWhisperForActiveInterview: + """Tests for SpeechRuntimeCoordinator.preload_whisper_for_active_interview.""" + + @pytest.mark.asyncio + async def test_loads_when_interview_active(self): + """Preloads Whisper when interview is active and model installed.""" + from app.speech.services.whisper_runtime import WhisperRuntime + + app = FastAPI() + config = AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + speech_model_size="small", + ) + + with ( + patch.object(WhisperRuntime, "bind_app") as mock_bind, + patch( + "app.platform.services.speech_runtime.is_installed", + return_value=True, + ), + patch.object( + WhisperRuntime, + "is_loaded", + return_value=False, + ), + patch.object( + WhisperRuntime, + "load_size", + return_value=True, + ) as mock_load, + ): + await SpeechRuntimeCoordinator.preload_whisper_for_active_interview( + app, config, interview_active=True + ) + + mock_bind.assert_called_once_with(app) + mock_load.assert_awaited_once_with("small") + + @pytest.mark.asyncio + async def test_skips_when_no_config(self): + """No loading when config is None.""" + from app.speech.services.whisper_runtime import WhisperRuntime + + app = FastAPI() + + with ( + patch.object(WhisperRuntime, "bind_app") as mock_bind, + patch.object(WhisperRuntime, "load_size") as mock_load, + ): + await SpeechRuntimeCoordinator.preload_whisper_for_active_interview( + app, None, interview_active=True + ) + + mock_bind.assert_called_once_with(app) + mock_load.assert_not_called() + + @pytest.mark.asyncio + async def test_skips_when_interview_not_active(self): + """No loading when interview is not active.""" + from app.speech.services.whisper_runtime import WhisperRuntime + + app = FastAPI() + config = AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + speech_model_size="small", + ) + + with ( + patch.object(WhisperRuntime, "bind_app") as mock_bind, + patch.object(WhisperRuntime, "load_size") as mock_load, + ): + await SpeechRuntimeCoordinator.preload_whisper_for_active_interview( + app, config, interview_active=False + ) + + mock_bind.assert_called_once_with(app) + mock_load.assert_not_called() + + @pytest.mark.asyncio + async def test_skips_when_already_loaded(self): + """No loading when model is already in memory.""" + from app.speech.services.whisper_runtime import WhisperRuntime + + app = FastAPI() + config = AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + speech_model_size="small", + ) + + with ( + patch.object(WhisperRuntime, "bind_app") as mock_bind, + patch( + "app.platform.services.speech_runtime.is_installed", + return_value=True, + ), + patch.object( + WhisperRuntime, + "is_loaded", + return_value=True, + ), + patch.object(WhisperRuntime, "load_size") as mock_load, + ): + await SpeechRuntimeCoordinator.preload_whisper_for_active_interview( + app, config, interview_active=True + ) + + mock_bind.assert_called_once_with(app) + mock_load.assert_not_called() diff --git a/tests/platform/services/test_speech_settings.py b/tests/platform/services/test_speech_settings.py new file mode 100644 index 0000000..b484ac1 --- /dev/null +++ b/tests/platform/services/test_speech_settings.py @@ -0,0 +1,129 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for speech settings extraction from configuration.""" + +from dataclasses import FrozenInstanceError + +import pytest + +from app.platform.services.config import AppConfig +from app.platform.services.speech_settings import ( + QuestionVoiceSettings, + SpeechSettings, + question_voice_settings_from_config, + speech_settings_from_config, +) + + +class TestSpeechSettings: + """Tests for speech_settings_from_config.""" + + def test_extracts_whisper_settings(self): + """speech_settings_from_config yields size and locale.""" + config = AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + speech_model_size="medium", + locale="ru", + ) + settings = speech_settings_from_config(config) + assert settings == SpeechSettings( + speech_model_size="medium", + locale="ru", + ) + + def test_uses_defaults_from_config(self): + """Default config values are extracted correctly.""" + config = AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + ) + settings = speech_settings_from_config(config) + assert settings.speech_model_size == "small" + assert settings.locale == "en" + + def test_speech_settings_is_frozen(self): + """SpeechSettings dataclass is immutable.""" + settings = SpeechSettings(speech_model_size="small", locale="en") + with pytest.raises(FrozenInstanceError): + settings.speech_model_size = "large" + + +class TestQuestionVoiceSettings: + """Tests for question_voice_settings_from_config.""" + + def test_extracts_piper_settings(self): + """question_voice_settings_from_config yields enabled, voice_id, locale.""" + config = AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + question_voice_enabled=True, + tts_voice_id="ru_RU-dmitri-medium", + locale="ru", + ) + settings = question_voice_settings_from_config(config) + assert settings == QuestionVoiceSettings( + enabled=True, + voice_id="ru_RU-dmitri-medium", + locale="ru", + ) + + def test_disabled_voice(self): + """Disabled voice sets enabled flag to False.""" + config = AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + question_voice_enabled=False, + tts_voice_id="en_US-lessac-medium", + locale="en", + ) + settings = question_voice_settings_from_config(config) + assert settings.enabled is False + assert settings.voice_id == "en_US-lessac-medium" + assert settings.locale == "en" + + def test_question_voice_settings_is_frozen(self): + """QuestionVoiceSettings dataclass is immutable.""" + settings = QuestionVoiceSettings( + enabled=True, + voice_id="en_US-lessac-medium", + locale="en", + ) + with pytest.raises(FrozenInstanceError): + settings.enabled = False + + def test_question_voice_settings_is_frozen_for_voice_id(self): + """Mutating voice_id on QuestionVoiceSettings raises error.""" + settings = QuestionVoiceSettings( + enabled=True, + voice_id="en_US-lessac-medium", + locale="en", + ) + with pytest.raises(FrozenInstanceError): + settings.voice_id = "ru_RU-dmitri-medium" + + def test_question_voice_settings_is_frozen_for_locale(self): + """Mutating locale on QuestionVoiceSettings raises error.""" + settings = QuestionVoiceSettings( + enabled=True, + voice_id="en_US-lessac-medium", + locale="en", + ) + with pytest.raises(FrozenInstanceError): + settings.locale = "fr" + + def test_defaults_from_config(self): + """Default config yields default voice settings.""" + config = AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + ) + settings = question_voice_settings_from_config(config) + assert settings.enabled is False + assert settings.voice_id == "en_US-lessac-medium" + assert settings.locale == "en" diff --git a/tests/question_voice/api/test_routes.py b/tests/question_voice/api/test_routes.py new file mode 100644 index 0000000..62e0393 --- /dev/null +++ b/tests/question_voice/api/test_routes.py @@ -0,0 +1,306 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for question-voice HTTP routes (TTS status and download).""" + +from dataclasses import replace +from unittest.mock import AsyncMock, patch + +import pytest + +from app.platform.services.config import AppConfig +from app.question_voice.schemas import PiperVoiceStatusRead + + +@pytest.fixture +def voice_config(minimal_app_config): + """Provider config with question voice enabled.""" + return replace( + minimal_app_config, + locale="en", + question_voice_enabled=True, + ) + + +class TestTtsStatusRoute: + """Tests for GET /speech/tts/status.""" + + def test_status_returns_json_when_voice_disabled(self, client): + """Status reports unavailable when voice is off.""" + config = AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + question_voice_enabled=False, + ) + missing = PiperVoiceStatusRead( + voice_id="en_US-lessac-medium", + locale="en", + locale_label="English", + state="missing", + percent=0, + message="Question voice is not installed.", + voice_display_name="Lessac (US English, medium)", + ) + with ( + patch( + "app.platform.services.config.ConfigService.get_config", + return_value=config, + ), + patch( + "app.question_voice.services.piper_voice.PiperVoiceService.get_status", + return_value=missing, + ), + ): + response = client.get( + "/speech/tts/status", + headers={"Accept": "application/json"}, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["state"] == "unavailable" + assert payload["enabled"] is False + + def test_status_returns_json_when_voice_ready(self, client, voice_config): + """Status reports ready when the Piper voice is installed.""" + ready = PiperVoiceStatusRead( + voice_id="en_US-lessac-medium", + locale="en", + locale_label="English", + state="ready", + percent=100, + message="Question voice ready for English.", + voice_display_name="Lessac (US English, medium)", + loaded_in_memory=True, + ) + with ( + patch( + "app.platform.services.config.ConfigService.get_config", + return_value=voice_config, + ), + patch( + "app.question_voice.services.piper_voice.PiperVoiceService.get_status", + return_value=ready, + ), + ): + response = client.get( + "/speech/tts/status", + headers={"Accept": "application/json"}, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["state"] == "ready" + assert payload["enabled"] is True + assert payload["loaded_in_memory"] is True + + def test_status_uses_negotiated_response_html(self, client, voice_config): + """Without JSON Accept header, routes return HTML partial.""" + ready = PiperVoiceStatusRead( + voice_id="en_US-lessac-medium", + locale="en", + locale_label="English", + state="ready", + percent=100, + message="Question voice ready for English.", + voice_display_name="Lessac (US English, medium)", + ) + with ( + patch( + "app.platform.services.config.ConfigService.get_config", + return_value=voice_config, + ), + patch( + "app.question_voice.services.piper_voice.PiperVoiceService.get_status", + return_value=ready, + ), + ): + response = client.get("/speech/tts/status") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + def test_status_locale_query_overrides_saved_config(self, client, voice_config): + """Status endpoint honors locale query param over saved config.""" + missing = PiperVoiceStatusRead( + voice_id="ru_RU-dmitri-medium", + locale="ru", + locale_label="Russian", + state="missing", + percent=0, + message="Question voice is not installed.", + voice_display_name="Dmitri (Russian, medium)", + ) + with ( + patch( + "app.platform.services.config.ConfigService.get_config", + return_value=voice_config, + ), + patch( + "app.question_voice.services.piper_voice.PiperVoiceService.get_status", + return_value=missing, + ) as get_status, + ): + response = client.get( + "/speech/tts/status?locale=ru", + headers={"Accept": "application/json"}, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["voice_id"] == "ru_RU-dmitri-medium" + assert payload["locale"] == "ru" + get_status.assert_called_once_with("ru_RU-dmitri-medium", "ru") + + def test_status_voice_id_query_overrides_defaults(self, client, voice_config): + """Status endpoint honors voice_id query param.""" + missing = PiperVoiceStatusRead( + voice_id="fr_FR-siwis-medium", + locale="fr", + locale_label="French", + state="missing", + percent=0, + message="Question voice is not installed.", + voice_display_name="Siwis (French, medium)", + ) + with ( + patch( + "app.platform.services.config.ConfigService.get_config", + return_value=voice_config, + ), + patch( + "app.question_voice.services.piper_voice.PiperVoiceService.get_status", + return_value=missing, + ) as get_status, + ): + response = client.get( + "/speech/tts/status?locale=fr&voice_id=fr_FR-siwis-medium", + headers={"Accept": "application/json"}, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["voice_id"] == "fr_FR-siwis-medium" + get_status.assert_called_once_with("fr_FR-siwis-medium", "fr") + + +class TestTtsVoiceDownloadRoute: + """Tests for POST /speech/tts/voice/download.""" + + def test_voice_download_without_config_uses_defaults(self, client): + """Download schedules work before provider config is saved.""" + downloading = PiperVoiceStatusRead( + voice_id="en_US-lessac-medium", + locale="en", + locale_label="English", + state="downloading", + percent=5, + message="Downloading question voice…", + voice_display_name="Lessac (US English, medium)", + ) + with ( + patch( + "app.platform.services.config.ConfigService.get_config", + return_value=None, + ), + patch( + "app.question_voice.services.piper_voice.PiperVoiceService.start_download", + new_callable=AsyncMock, + return_value=downloading, + ) as start_download, + ): + response = client.post( + "/speech/tts/voice/download", + headers={"Accept": "application/json"}, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["state"] == "downloading" + assert payload["enabled"] is False + start_download.assert_called_once_with("en_US-lessac-medium", "en") + + def test_voice_download_schedules_work(self, client, voice_config): + """Download returns status after scheduling Piper voice install.""" + downloading = PiperVoiceStatusRead( + voice_id="en_US-lessac-medium", + locale="en", + locale_label="English", + state="downloading", + percent=5, + message="Downloading question voice…", + voice_display_name="Lessac (US English, medium)", + ) + with ( + patch( + "app.platform.services.config.ConfigService.get_config", + return_value=voice_config, + ), + patch( + "app.question_voice.services.piper_voice.PiperVoiceService.start_download", + new_callable=AsyncMock, + return_value=downloading, + ) as start_download, + ): + response = client.post( + "/speech/tts/voice/download", + headers={"Accept": "application/json"}, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["state"] == "downloading" + assert payload["enabled"] is True + start_download.assert_called_once_with("en_US-lessac-medium", "en") + + def test_voice_download_with_locale_override(self, client, voice_config): + """Download endpoint honors locale and voice_id query params.""" + downloading = PiperVoiceStatusRead( + voice_id="ru_RU-dmitri-medium", + locale="ru", + locale_label="Russian", + state="downloading", + percent=5, + message="Downloading question voice…", + voice_display_name="Dmitri (Russian, medium)", + ) + with ( + patch( + "app.platform.services.config.ConfigService.get_config", + return_value=voice_config, + ), + patch( + "app.question_voice.services.piper_voice.PiperVoiceService.start_download", + new_callable=AsyncMock, + return_value=downloading, + ) as start_download, + ): + response = client.post( + "/speech/tts/voice/download?locale=ru&voice_id=ru_RU-dmitri-medium", + headers={"Accept": "application/json"}, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["voice_id"] == "ru_RU-dmitri-medium" + start_download.assert_called_once_with("ru_RU-dmitri-medium", "ru") + + def test_voice_download_returns_html_when_no_json_accept( + self, client, voice_config + ): + """Download route returns HTML partial via negotiated_response.""" + downloading = PiperVoiceStatusRead( + voice_id="en_US-lessac-medium", + locale="en", + locale_label="English", + state="downloading", + percent=5, + message="Downloading question voice…", + voice_display_name="Lessac (US English, medium)", + ) + with ( + patch( + "app.platform.services.config.ConfigService.get_config", + return_value=voice_config, + ), + patch( + "app.question_voice.services.piper_voice.PiperVoiceService.start_download", + new_callable=AsyncMock, + return_value=downloading, + ), + ): + response = client.post("/speech/tts/voice/download") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] diff --git a/tests/question_voice/services/test_piper_runtime.py b/tests/question_voice/services/test_piper_runtime.py new file mode 100644 index 0000000..c36b50c --- /dev/null +++ b/tests/question_voice/services/test_piper_runtime.py @@ -0,0 +1,188 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for PiperRuntime in-process voice loading and synthesis.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from app.question_voice.services.piper_runtime import PiperRuntime + + +class TestPiperRuntimeLoad: + """Tests for PiperRuntime.load_voice.""" + + @pytest.fixture(autouse=True) + def reset_runtime(self): + """Reset the runtime class state before each test.""" + PiperRuntime._artifact = None + PiperRuntime._loaded_key = None + PiperRuntime._load_error = None + yield + PiperRuntime._artifact = None + PiperRuntime._loaded_key = None + PiperRuntime._load_error = None + + @pytest.mark.asyncio + async def test_load_voice_returns_true_when_installed(self): + """load_voice returns True when voice is installed and loads.""" + mock_voice = MagicMock() + with ( + patch.object(PiperRuntime, "is_installed", return_value=True), + patch.object( + PiperRuntime, "load_sync", return_value=mock_voice + ) as mock_load_sync, + ): + result = await PiperRuntime.load_voice("en_US-lessac-medium") + assert result is True + assert PiperRuntime.is_loaded("en_US-lessac-medium") + mock_load_sync.assert_called_once_with("en_US-lessac-medium") + + @pytest.mark.asyncio + async def test_load_voice_returns_false_when_not_installed(self): + """load_voice returns False when voice is not on disk.""" + with patch.object(PiperRuntime, "is_installed", return_value=False): + result = await PiperRuntime.load_voice("en_US-lessac-medium") + assert result is False + assert not PiperRuntime.is_loaded("en_US-lessac-medium") + + @pytest.mark.asyncio + async def test_load_voice_logs_success(self): + """Successful load logs the voice directory.""" + mock_voice = MagicMock() + with ( + patch.object(PiperRuntime, "is_installed", return_value=True), + patch.object(PiperRuntime, "load_sync", return_value=mock_voice), + patch("app.question_voice.services.piper_runtime.logger") as mock_logger, + ): + await PiperRuntime.load_voice("en_US-lessac-medium") + mock_logger.info.assert_called_once() + args = mock_logger.info.call_args[0] + assert "en_US-lessac-medium" in args + + @pytest.mark.asyncio + async def test_load_voice_handles_error(self): + """load_voice records error and returns False on exception.""" + with ( + patch.object(PiperRuntime, "is_installed", return_value=True), + patch.object( + PiperRuntime, "load_sync", side_effect=RuntimeError("corrupt model") + ), + patch("app.shared.infrastructure.in_process_runtime.logger") as mock_logger, + ): + result = await PiperRuntime.load_voice("en_US-lessac-medium") + assert result is False + assert PiperRuntime.load_error() == "corrupt model" + mock_logger.exception.assert_called_once() + + +class TestPiperRuntimeSynthesize: + """Tests for PiperRuntime.synthesize_wav_bytes.""" + + @pytest.fixture(autouse=True) + def reset_runtime(self): + """Reset the runtime class state before each test.""" + PiperRuntime._artifact = None + PiperRuntime._loaded_key = None + PiperRuntime._load_error = None + yield + PiperRuntime._artifact = None + PiperRuntime._loaded_key = None + PiperRuntime._load_error = None + + def test_synthesize_wav_bytes_sync_raises_when_no_voice_loaded(self): + """synthesize_wav_bytes_sync raises RuntimeError when voice not loaded.""" + with pytest.raises(RuntimeError, match="Piper voice is not loaded"): + PiperRuntime.synthesize_wav_bytes_sync("Hello world") + + def test_synthesize_wav_bytes_sync_returns_bytes(self): + """synthesize_wav_bytes_sync returns raw WAV bytes.""" + mock_voice = MagicMock() + mock_buffer = MagicMock() + mock_buffer.getvalue.return_value = b"RIFFwavdata" + + PiperRuntime._artifact = mock_voice + PiperRuntime._loaded_key = "en_US-lessac-medium" + + mock_wave_file = MagicMock() + with ( + patch("io.BytesIO", return_value=mock_buffer), + patch("wave.open", return_value=mock_wave_file), + ): + result = PiperRuntime.synthesize_wav_bytes_sync("Hello world") + + assert result == b"RIFFwavdata" + mock_voice.synthesize_wav.assert_called_once_with( + "Hello world", mock_wave_file.__enter__() + ) + + @pytest.mark.asyncio + async def test_synthesize_wav_bytes_delegates_to_sync(self): + """synthesize_wav_bytes runs sync version in a worker thread.""" + with patch.object( + PiperRuntime, + "synthesize_wav_bytes_sync", + return_value=b"RIFFasyncwav", + ) as mock_sync: + result = await PiperRuntime.synthesize_wav_bytes("Hello world") + assert result == b"RIFFasyncwav" + mock_sync.assert_called_once_with("Hello world") + + +class TestPiperRuntimeNormalizeAndInstalled: + """Tests for normalize_key and is_installed.""" + + def test_normalize_key_returns_normalized_voice_id(self): + """normalize_key delegates to normalize_tts_voice_id.""" + assert ( + PiperRuntime.normalize_key("en_US-lessac-medium") == "en_US-lessac-medium" + ) + + def test_is_installed_delegates_to_piper_storage(self): + """is_installed checks voice installation via storage module.""" + with patch( + "app.question_voice.services.piper_runtime.is_voice_installed", + return_value=True, + ) as mock_is_installed: + result = PiperRuntime.is_installed("en_US-lessac-medium") + assert result is True + mock_is_installed.assert_called_once_with("en_US-lessac-medium") + + def test_is_installed_returns_false_for_uninstalled(self): + """is_installed returns False when voice is not on disk.""" + with patch( + "app.question_voice.services.piper_runtime.is_voice_installed", + return_value=False, + ): + result = PiperRuntime.is_installed("ru_RU-dmitri-medium") + assert result is False + + def test_load_sync_builds_paths_and_calls_piper_voice(self): + """load_sync constructs paths and delegates to PiperVoice.load.""" + mock_piper_voice_class = MagicMock() + mock_voice_instance = MagicMock() + mock_piper_voice_class.load.return_value = mock_voice_instance + + mock_dir = MagicMock() + mock_model_path = MagicMock() + mock_config_path = MagicMock() + mock_dir.__truediv__ = MagicMock( + side_effect=[mock_model_path, mock_config_path] + ) + + with ( + patch( + "app.question_voice.services.piper_runtime.voice_dir", + return_value=mock_dir, + ), + patch.dict( + "sys.modules", + {"piper": MagicMock(PiperVoice=mock_piper_voice_class)}, + ), + ): + result = PiperRuntime.load_sync("en_US-lessac-medium") + + assert result == mock_voice_instance + mock_piper_voice_class.load.assert_called_once_with( + mock_model_path, config_path=mock_config_path + ) diff --git a/tests/question_voice/services/test_question_audio.py b/tests/question_voice/services/test_question_audio.py new file mode 100644 index 0000000..907bcf1 --- /dev/null +++ b/tests/question_voice/services/test_question_audio.py @@ -0,0 +1,221 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for question-audio orchestration.""" + +from datetime import datetime +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest + +from app.interview.schemas.interview import AnswerRead, InterviewRead +from app.platform.services.config import AppConfig +from app.question_voice.services.question_audio import ( + _resolve_answer, + get_question_audio_path, +) +from app.question_voice.services.tts_exceptions import QuestionVoiceDisabledError + + +def _make_answer( + *, + answer_id: int = 1, + question_text: str = "What is Python?", + answer_text: str | None = None, +) -> AnswerRead: + """Build an AnswerRead for tests.""" + return AnswerRead( + id=answer_id, + question_id="q1", + order=1, + round=0, + question_text=question_text, + question_code=None, + answer_text=answer_text, + score=None, + feedback=None, + started_at=None, + ) + + +def _make_interview(answers: list[AnswerRead]) -> InterviewRead: + """Build an InterviewRead for tests.""" + return InterviewRead( + id="test-interview-id", + status="active", + locale="en", + selection_spec="{}", + question_ids='["q1"]', + question_count=len(answers), + question_time_limit_seconds=None, + answers=answers, + score=None, + overall_feedback=None, + started_at=datetime.now(), + completed_at=None, + ) + + +class TestGetQuestionAudioPath: + """Tests for get_question_audio_path.""" + + @pytest.mark.asyncio + async def test_raises_when_no_config(self): + """Missing config raises QuestionVoiceDisabledError.""" + with ( + patch( + "app.question_voice.services.question_audio.ConfigService.get_config", + return_value=None, + ), + pytest.raises(QuestionVoiceDisabledError), + ): + await get_question_audio_path("interview-id") + + @pytest.mark.asyncio + async def test_raises_when_voice_disabled(self): + """Disabled voice in config raises QuestionVoiceDisabledError.""" + config = AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + question_voice_enabled=False, + ) + with ( + patch( + "app.question_voice.services.question_audio.ConfigService.get_config", + return_value=config, + ), + pytest.raises(QuestionVoiceDisabledError), + ): + await get_question_audio_path("interview-id") + + @pytest.mark.asyncio + async def test_returns_cached_path_with_answer_id(self): + """Enabled voice returns WAV path for a specific answer_id.""" + config = AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + question_voice_enabled=True, + tts_voice_id="en_US-lessac-medium", + locale="en", + ) + answer = _make_answer(answer_id=7) + interview = _make_interview([answer]) + cached_path = Path("/tmp/cache/question.wav") + + with ( + patch( + "app.question_voice.services.question_audio.ConfigService.get_config", + return_value=config, + ), + patch( + "app.question_voice.services.question_audio.InterviewQuery.get_active_interview_or_raise", + return_value=interview, + ), + patch( + "app.question_voice.services.question_audio.TtsCacheService.get_or_fetch", + new_callable=AsyncMock, + return_value=cached_path, + ) as mock_cache, + ): + result = await get_question_audio_path("interview-id", answer_id=7) + + assert result == cached_path + mock_cache.assert_called_once_with( + "en_US-lessac-medium", + "en", + "What is Python?", + ) + + @pytest.mark.asyncio + async def test_returns_cached_path_for_current_question(self): + """Enabled voice returns path for current unanswered question.""" + config = AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + question_voice_enabled=True, + tts_voice_id="en_US-lessac-medium", + locale="en", + ) + answer = _make_answer(answer_id=3) + interview = _make_interview([answer]) + cached_path = Path("/tmp/cache/current.wav") + + with ( + patch( + "app.question_voice.services.question_audio.ConfigService.get_config", + return_value=config, + ), + patch( + "app.question_voice.services.question_audio.InterviewQuery.get_active_interview_or_raise", + return_value=interview, + ), + patch( + "app.question_voice.services.question_audio.TtsCacheService.get_or_fetch", + new_callable=AsyncMock, + return_value=cached_path, + ) as mock_cache, + ): + result = await get_question_audio_path("interview-id") + + assert result == cached_path + mock_cache.assert_called_once_with( + "en_US-lessac-medium", + "en", + "What is Python?", + ) + + +class TestResolveAnswer: + """Tests for _resolve_answer helper.""" + + def test_returns_matching_answer_by_id(self): + """answer_id selects the matching answer.""" + target = _make_answer(answer_id=2, question_text="Second question") + interview = _make_interview([_make_answer(answer_id=1), target]) + result = _resolve_answer(interview, answer_id=2) + assert result.id == 2 + assert result.question_text == "Second question" + + def test_raises_when_answer_not_found(self): + """Unknown answer_id raises ValueError.""" + interview = _make_interview([_make_answer(answer_id=1)]) + with pytest.raises(ValueError, match="Answer not found"): + _resolve_answer(interview, answer_id=99) + + def test_raises_when_answer_already_submitted(self): + """Already answered answer raises ValueError.""" + answered = _make_answer(answer_id=1, answer_text="Already answered") + interview = _make_interview([answered]) + with pytest.raises(ValueError, match="already submitted"): + _resolve_answer(interview, answer_id=1) + + def test_raises_when_question_text_empty(self): + """Empty question text raises ValueError.""" + empty = _make_answer(answer_id=1, question_text=" ") + interview = _make_interview([empty]) + with pytest.raises(ValueError, match="Question text is empty"): + _resolve_answer(interview, answer_id=1) + + def test_returns_current_unanswered_when_no_answer_id(self): + """No answer_id picks the first unanswered question.""" + unanswered = _make_answer(answer_id=5) + interview = _make_interview([unanswered]) + result = _resolve_answer(interview, answer_id=None) + assert result.id == 5 + + def test_raises_when_no_unanswered_question(self): + """All answered raises ValueError.""" + answered = _make_answer(answer_id=1, answer_text="Done") + interview = _make_interview([answered]) + with pytest.raises(ValueError, match="No unanswered question"): + _resolve_answer(interview, answer_id=None) + + def test_raises_when_current_question_text_empty(self): + """Empty current question text raises ValueError.""" + empty = _make_answer(answer_id=1, question_text="") + interview = _make_interview([empty]) + with pytest.raises(ValueError, match="Question text is empty"): + _resolve_answer(interview, answer_id=None) diff --git a/tests/question_voice/services/test_status.py b/tests/question_voice/services/test_status.py new file mode 100644 index 0000000..3ca1f1f --- /dev/null +++ b/tests/question_voice/services/test_status.py @@ -0,0 +1,233 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for QuestionVoiceStatusService.""" + +from unittest.mock import patch + +from app.platform.services.config import AppConfig +from app.question_voice.schemas import PiperVoiceStatusRead +from app.question_voice.services.status import QuestionVoiceStatusService + + +class TestResolveTtsTarget: + """Tests for resolve_tts_target.""" + + def test_defaults_when_no_config_no_overrides(self): + """No config and no overrides use default locale and voice.""" + voice_id, locale = QuestionVoiceStatusService.resolve_tts_target(None) + assert voice_id == "en_US-lessac-medium" + assert locale == "en" + + def test_uses_config_when_present(self): + """Saved config determines voice and locale.""" + config = AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + locale="ru", + tts_voice_id="ru_RU-dmitri-medium", + ) + voice_id, locale = QuestionVoiceStatusService.resolve_tts_target(config) + assert voice_id == "ru_RU-dmitri-medium" + assert locale == "ru" + + def test_locale_override_takes_precedence(self): + """Query locale overrides saved config locale and voice.""" + config = AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + locale="en", + tts_voice_id="en_US-lessac-medium", + ) + voice_id, locale = QuestionVoiceStatusService.resolve_tts_target( + config, locale="de" + ) + assert locale == "de" + assert voice_id == "de_DE-thorsten-medium" + + def test_voice_id_override_takes_precedence(self): + """Query voice_id overrides all other sources.""" + config = AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + locale="en", + tts_voice_id="en_US-lessac-medium", + ) + voice_id, locale = QuestionVoiceStatusService.resolve_tts_target( + config, voice_id="fr_FR-siwis-medium" + ) + assert voice_id == "fr_FR-siwis-medium" + assert locale == "en" + + def test_locale_and_voice_id_override_together(self): + """Both query params override saved config.""" + config = AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + locale="en", + tts_voice_id="en_US-lessac-medium", + ) + voice_id, locale = QuestionVoiceStatusService.resolve_tts_target( + config, locale="es", voice_id="es_ES-davefx-medium" + ) + assert voice_id == "es_ES-davefx-medium" + assert locale == "es" + + def test_defaults_with_only_locale_override(self): + """No config but locale override sets voice by locale.""" + voice_id, locale = QuestionVoiceStatusService.resolve_tts_target( + None, locale="fr" + ) + assert voice_id == "fr_FR-siwis-medium" + assert locale == "fr" + + def test_defaults_with_only_voice_id_override(self): + """No config but voice_id override uses that voice and default locale.""" + voice_id, locale = QuestionVoiceStatusService.resolve_tts_target( + None, voice_id="de_DE-thorsten-medium" + ) + assert voice_id == "de_DE-thorsten-medium" + assert locale == "en" + + +class TestResolveForConfig: + """Tests for resolve_for_config.""" + + def test_returns_status_and_enabled_true(self): + """Enabled config returns status and True.""" + config = AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + locale="en", + question_voice_enabled=True, + tts_voice_id="en_US-lessac-medium", + ) + ready = PiperVoiceStatusRead( + voice_id="en_US-lessac-medium", + locale="en", + locale_label="English", + state="ready", + percent=100, + message="Ready", + voice_display_name="Lessac (US English, medium)", + ) + with patch( + "app.question_voice.services.status.PiperVoiceService.get_status", + return_value=ready, + ): + status, enabled = QuestionVoiceStatusService.resolve_for_config(config) + assert status.state == "ready" + assert enabled is True + + def test_returns_status_and_enabled_false(self): + """Disabled config returns status and False.""" + config = AppConfig( + provider_type="openai-compatible", + base_url="http://localhost", + model="gpt-4", + locale="en", + question_voice_enabled=False, + ) + missing = PiperVoiceStatusRead( + voice_id="en_US-lessac-medium", + locale="en", + locale_label="English", + state="missing", + percent=0, + message="Not installed", + voice_display_name="Lessac (US English, medium)", + ) + with patch( + "app.question_voice.services.status.PiperVoiceService.get_status", + return_value=missing, + ): + status, enabled = QuestionVoiceStatusService.resolve_for_config(config) + assert status.state == "missing" + assert enabled is False + + def test_no_config_returns_default_and_false(self): + """No config returns default voice status and disabled.""" + missing = PiperVoiceStatusRead( + voice_id="en_US-lessac-medium", + locale="en", + locale_label="English", + state="missing", + percent=0, + message="Not installed", + voice_display_name="Lessac (US English, medium)", + ) + with patch( + "app.question_voice.services.status.PiperVoiceService.get_status", + return_value=missing, + ): + status, enabled = QuestionVoiceStatusService.resolve_for_config(None) + assert enabled is False + + +class TestApiPayload: + """Tests for api_payload.""" + + def test_serializes_status_with_enabled_true(self): + """Enabled flag is added to payload.""" + status = PiperVoiceStatusRead( + voice_id="en_US-lessac-medium", + locale="en", + locale_label="English", + state="ready", + percent=100, + message="Ready", + voice_display_name="Lessac (US English, medium)", + loaded_in_memory=True, + ) + payload = QuestionVoiceStatusService.api_payload(status, enabled=True) + assert payload["state"] == "ready" + assert payload["enabled"] is True + assert payload["loaded_in_memory"] is True + + def test_replaces_missing_with_unavailable_when_disabled(self): + """Missing state becomes unavailable when voice is disabled.""" + status = PiperVoiceStatusRead( + voice_id="en_US-lessac-medium", + locale="en", + locale_label="English", + state="missing", + percent=0, + message="Not installed", + voice_display_name="Lessac (US English, medium)", + ) + payload = QuestionVoiceStatusService.api_payload(status, enabled=False) + assert payload["state"] == "unavailable" + assert payload["enabled"] is False + + def test_preserves_missing_when_enabled(self): + """Missing state stays missing when voice is enabled.""" + status = PiperVoiceStatusRead( + voice_id="en_US-lessac-medium", + locale="en", + locale_label="English", + state="missing", + percent=0, + message="Not installed", + voice_display_name="Lessac (US English, medium)", + ) + payload = QuestionVoiceStatusService.api_payload(status, enabled=True) + assert payload["state"] == "missing" + + def test_preserves_non_missing_states_when_disabled(self): + """Non-missing states are unchanged even when disabled.""" + for state in ("ready", "downloading", "error"): + status = PiperVoiceStatusRead( + voice_id="en_US-lessac-medium", + locale="en", + locale_label="English", + state=state, + percent=50, + message="Progress", + voice_display_name="Lessac (US English, medium)", + ) + payload = QuestionVoiceStatusService.api_payload(status, enabled=False) + assert payload["state"] == state diff --git a/tests/speech/services/test_readiness.py b/tests/speech/services/test_readiness.py new file mode 100644 index 0000000..0649c2a --- /dev/null +++ b/tests/speech/services/test_readiness.py @@ -0,0 +1,48 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for WhisperReadinessService.""" + +from unittest.mock import patch + +import pytest + +from app.speech.services.readiness import WhisperReadinessService + + +class TestWhisperReadinessService: + """Tests for WhisperReadinessService checks.""" + + def test_is_model_installed_true(self): + """Returns True when model is installed on disk.""" + with patch( + "app.speech.services.readiness.is_installed", return_value=True + ) as mock_installed: + result = WhisperReadinessService.is_model_installed("small") + + assert result is True + mock_installed.assert_called_once_with("small") + + def test_is_model_installed_false(self): + """Returns False when model is not installed.""" + with patch( + "app.speech.services.readiness.is_installed", return_value=False + ) as mock_installed: + result = WhisperReadinessService.is_model_installed("medium") + + assert result is False + mock_installed.assert_called_once_with("medium") + + def test_normalizes_size(self): + """Normalizes the speech model size before checking.""" + with patch( + "app.speech.services.readiness.is_installed", return_value=True + ) as mock_installed: + result = WhisperReadinessService.is_model_installed(" LARGE ") + + assert result is True + mock_installed.assert_called_once_with("large") + + def test_invalid_size_raises(self): + """Raises ValueError for unsupported model sizes.""" + with pytest.raises(ValueError, match="Unsupported speech model size"): + WhisperReadinessService.is_model_installed("huge") diff --git a/tests/speech/services/test_transcriber_resolver.py b/tests/speech/services/test_transcriber_resolver.py new file mode 100644 index 0000000..3356739 --- /dev/null +++ b/tests/speech/services/test_transcriber_resolver.py @@ -0,0 +1,198 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for transcriber resolution.""" + +from unittest.mock import patch + +import pytest +from starlette.applications import Starlette + +from app.speech.services.transcriber_resolver import ( + resolve_speech_transcriber, + speech_transcriber_unavailable_message, +) + + +class FakeTranscriber: + """Fake transcriber implementing SpeechTranscriber protocol.""" + + async def transcribe(self, audio, locale): + return "fake transcript" + + +class TestResolveSpeechTranscriber: + """Tests for resolve_speech_transcriber.""" + + @pytest.fixture + def app(self): + """Create a Starlette app with clean state.""" + return Starlette() + + @pytest.fixture + def mock_config_service(self): + """Return a mock ConfigService class with no config.""" + + class MockConfigService: + @staticmethod + def get_config(): + return None + + return MockConfigService + + @pytest.mark.asyncio + async def test_returns_app_state_transcriber_when_present( + self, app, mock_config_service + ): + """Returns speech_transcriber from app.state when available.""" + fake = FakeTranscriber() + app.state.speech_transcriber = fake + + result = await resolve_speech_transcriber(app, mock_config_service) + + assert result is fake + + @pytest.mark.asyncio + async def test_loads_from_runtime_when_app_state_none( + self, app, mock_config_service + ): + """Falls back to WhisperRuntime.load_size when app.state is empty.""" + fake = FakeTranscriber() + app.state.speech_transcriber = None + + class ConfigWithModel: + speech_model_size = "small" + + class MockConfigServiceWithModel: + @staticmethod + def get_config(): + return ConfigWithModel() + + with ( + patch( + "app.speech.services.transcriber_resolver.is_installed", + return_value=True, + ), + patch( + "app.speech.services.transcriber_resolver.WhisperRuntime.load_size" + ) as mock_load, + ): + + async def _load_and_set(size): + app.state.speech_transcriber = fake + return True + + mock_load.side_effect = _load_and_set + result = await resolve_speech_transcriber(app, MockConfigServiceWithModel) + + assert result is fake + mock_load.assert_called_once_with("small") + + @pytest.mark.asyncio + async def test_returns_none_when_not_installed(self, app, mock_config_service): + """Returns None when model is not installed.""" + app.state.speech_transcriber = None + + class ConfigWithModel: + speech_model_size = "small" + + class MockConfigServiceWithModel: + @staticmethod + def get_config(): + return ConfigWithModel() + + with patch( + "app.speech.services.transcriber_resolver.is_installed", + return_value=False, + ): + result = await resolve_speech_transcriber(app, MockConfigServiceWithModel) + + assert result is None + + @pytest.mark.asyncio + async def test_returns_none_when_no_config(self, app, mock_config_service): + """Returns None when there is no saved config.""" + app.state.speech_transcriber = None + + result = await resolve_speech_transcriber(app, mock_config_service) + + assert result is None + + @pytest.mark.asyncio + async def test_returns_none_when_load_fails(self, app, mock_config_service): + """Returns None when runtime load fails and app.state stays empty.""" + app.state.speech_transcriber = None + + class ConfigWithModel: + speech_model_size = "medium" + + class MockConfigServiceWithModel: + @staticmethod + def get_config(): + return ConfigWithModel() + + with ( + patch( + "app.speech.services.transcriber_resolver.is_installed", + return_value=True, + ), + patch( + "app.speech.services.transcriber_resolver.WhisperRuntime.load_size", + return_value=False, + ) as mock_load, + ): + result = await resolve_speech_transcriber(app, MockConfigServiceWithModel) + + assert result is None + mock_load.assert_called_once_with("medium") + + @pytest.mark.asyncio + async def test_normalizes_model_size_via_config(self, app, mock_config_service): + """Config speech_model_size is used for runtime lookup.""" + app.state.speech_transcriber = None + + class ConfigWithModel: + speech_model_size = "large" + + class MockConfigServiceWithModel: + @staticmethod + def get_config(): + return ConfigWithModel() + + with ( + patch( + "app.speech.services.transcriber_resolver.is_installed", + return_value=True, + ), + patch( + "app.speech.services.transcriber_resolver.WhisperRuntime.load_size", + return_value=False, + ) as mock_load, + ): + await resolve_speech_transcriber(app, MockConfigServiceWithModel) + + mock_load.assert_called_once_with("large") + + +class TestSpeechTranscriberUnavailableMessage: + """Tests for speech_transcriber_unavailable_message.""" + + def test_returns_base_message(self): + """Returns the standard unavailable message.""" + with patch( + "app.speech.services.transcriber_resolver.WhisperRuntime.load_error", + return_value=None, + ): + msg = speech_transcriber_unavailable_message() + + assert "not loaded" in msg + assert "Download it in Configuration" in msg + + def test_includes_load_error_when_present(self): + """Appends runtime load error when one exists.""" + with patch( + "app.speech.services.transcriber_resolver.WhisperRuntime.load_error", + return_value="Out of memory", + ): + msg = speech_transcriber_unavailable_message() + + assert "Speech model load error: Out of memory" in msg diff --git a/tests/speech/services/test_whisper_model.py b/tests/speech/services/test_whisper_model.py new file mode 100644 index 0000000..da141bd --- /dev/null +++ b/tests/speech/services/test_whisper_model.py @@ -0,0 +1,323 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for WhisperModelService download and status.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from app.speech.services.whisper_model import WhisperModelService +from app.speech.services.whisper_runtime import WhisperRuntime + + +@pytest.fixture(autouse=True) +def reset_service(): + """Clear shared download state between tests.""" + WhisperModelService.reset_download_state() + WhisperRuntime.unload() + WhisperRuntime._load_error = None + yield + WhisperModelService.reset_download_state() + WhisperRuntime.unload() + WhisperRuntime._load_error = None + + +class TestGetStatus: + """Tests for WhisperModelService.get_status.""" + + def test_missing_when_not_installed(self): + """Returns missing state when model is not on disk.""" + with patch( + "app.speech.services.whisper_model.is_installed", return_value=False + ): + status = WhisperModelService.get_status("small", "en") + + assert status.size == "small" + assert status.locale == "en" + assert status.state == "missing" + assert status.loaded_in_memory is False + assert "not installed" in status.message + + def test_ready_when_installed_and_loaded(self): + """Returns ready state when model is installed and loaded.""" + with ( + patch("app.speech.services.whisper_model.is_installed", return_value=True), + patch( + "app.speech.services.whisper_model.WhisperRuntime.is_loaded", + return_value=True, + ), + ): + status = WhisperModelService.get_status("small", "ru") + + assert status.size == "small" + assert status.locale == "ru" + assert status.state == "ready" + assert status.loaded_in_memory is True + assert "ready" in status.message.lower() + + def test_ready_when_installed_not_loaded(self): + """Returns ready state when installed but not yet loaded.""" + with ( + patch("app.speech.services.whisper_model.is_installed", return_value=True), + patch( + "app.speech.services.whisper_model.WhisperRuntime.is_loaded", + return_value=False, + ), + ): + status = WhisperModelService.get_status("medium", "en") + + assert status.state == "ready" + assert status.loaded_in_memory is False + assert "loading" in status.message.lower() + + def test_normalizes_size_and_locale(self): + """Normalizes size and locale parameters.""" + with patch( + "app.speech.services.whisper_model.is_installed", return_value=False + ): + status = WhisperModelService.get_status(" SMALL ", " EN ") + + assert status.size == "small" + assert status.locale == "en" + + def test_locale_label_populated(self): + """Locale label is set from SUPPORTED_LOCALES.""" + with patch( + "app.speech.services.whisper_model.is_installed", return_value=False + ): + status = WhisperModelService.get_status("small", "fr") + + assert status.locale_label == "French" + + def test_model_display_name_set(self): + """Model display name comes from SpeechModelSpec.""" + with patch( + "app.speech.services.whisper_model.is_installed", return_value=False + ): + status = WhisperModelService.get_status("large", "en") + + assert status.model_display_name == "Whisper large" + + +class TestStartDownload: + """Tests for WhisperModelService.start_download.""" + + @pytest.mark.asyncio + async def test_skips_when_already_installed(self): + """Returns ready status without scheduling when already installed.""" + with patch("app.speech.services.whisper_model.is_installed", return_value=True): + status = await WhisperModelService.start_download("small", "en") + + assert status.state in ("ready", "missing") + assert not WhisperModelService.is_downloading("small") + + @pytest.mark.asyncio + async def test_schedules_when_not_installed(self): + """Schedules a download when model is missing.""" + with patch( + "app.speech.services.whisper_model.is_installed", return_value=False + ): + status = await WhisperModelService.start_download("small", "en") + + assert status.state == "downloading" + assert status.message == "Downloading speech model…" + + @pytest.mark.asyncio + async def test_normalizes_inputs(self): + """Normalizes size and locale before scheduling.""" + with patch( + "app.speech.services.whisper_model.is_installed", return_value=False + ): + status = await WhisperModelService.start_download(" SMALL ", " RU ") + + assert status.size == "small" + assert status.locale == "ru" + + +class TestDownloadAndInstall: + """Tests for WhisperModelService._download_and_install.""" + + @pytest.mark.asyncio + async def test_calls_snapshot_download(self, tmp_path): + """_download_and_install calls snapshot_download with correct repo.""" + whisper_root = tmp_path / "whisper-models" + staging_dir = tmp_path / ".staging-small" + + with ( + patch( + "app.speech.services.whisper_model.WHISPER_MODELS_ROOT", whisper_root + ), + patch( + "app.speech.services.whisper_model.prepare_staging_dir", + return_value=staging_dir, + ), + patch( + "app.speech.services.whisper_model.WhisperModelService._snapshot_download" + ) as mock_snapshot, + patch( + "app.speech.services.whisper_model.is_valid_model_dir", + return_value=True, + ), + patch( + "app.speech.services.whisper_model.promote_staging_dir" + ) as mock_promote, + patch("app.speech.services.whisper_model.cleanup_staging_dir"), + ): + await WhisperModelService._download_and_install("small") + + mock_snapshot.assert_called_once() + mock_promote.assert_called_once() + + @pytest.mark.asyncio + async def test_raises_on_invalid_snapshot(self, tmp_path): + """Raises ValueError when downloaded snapshot lacks model.bin.""" + whisper_root = tmp_path / "whisper-models" + staging_dir = tmp_path / ".staging-small" + + with ( + patch( + "app.speech.services.whisper_model.WHISPER_MODELS_ROOT", whisper_root + ), + patch( + "app.speech.services.whisper_model.prepare_staging_dir", + return_value=staging_dir, + ), + patch( + "app.speech.services.whisper_model.WhisperModelService._snapshot_download" + ), + patch( + "app.speech.services.whisper_model.is_valid_model_dir", + return_value=False, + ), + patch("app.speech.services.whisper_model.cleanup_staging_dir"), + pytest.raises(ValueError, match="does not contain a valid Whisper"), + ): + await WhisperModelService._download_and_install("small") + + @pytest.mark.asyncio + async def test_ensures_whisper_root_exists(self, tmp_path): + """Creates WHISPER_MODELS_ROOT if it does not exist.""" + whisper_root = tmp_path / "nested" / "whisper-models" + staging_dir = tmp_path / ".staging-small" + + with ( + patch( + "app.speech.services.whisper_model.WHISPER_MODELS_ROOT", whisper_root + ), + patch( + "app.speech.services.whisper_model.prepare_staging_dir", + return_value=staging_dir, + ), + patch( + "app.speech.services.whisper_model.WhisperModelService._snapshot_download" + ), + patch( + "app.speech.services.whisper_model.is_valid_model_dir", + return_value=True, + ), + patch("app.speech.services.whisper_model.promote_staging_dir"), + patch("app.speech.services.whisper_model.cleanup_staging_dir"), + ): + await WhisperModelService._download_and_install("small") + + assert whisper_root.exists() + + @pytest.mark.asyncio + async def test_sets_percent_to_95_then_100(self, tmp_path): + """Progress goes to 95 after download and 100 after promotion.""" + whisper_root = tmp_path / "whisper-models" + staging_dir = tmp_path / ".staging-small" + + with ( + patch( + "app.speech.services.whisper_model.WHISPER_MODELS_ROOT", whisper_root + ), + patch( + "app.speech.services.whisper_model.prepare_staging_dir", + return_value=staging_dir, + ), + patch( + "app.speech.services.whisper_model.WhisperModelService._snapshot_download" + ), + patch( + "app.speech.services.whisper_model.is_valid_model_dir", + return_value=True, + ), + patch("app.speech.services.whisper_model.promote_staging_dir"), + patch("app.speech.services.whisper_model.cleanup_staging_dir"), + ): + await WhisperModelService._download_and_install("small") + + assert WhisperModelService.download_percent() == 100 + + @pytest.mark.asyncio + async def test_cleanup_runs_on_error(self, tmp_path): + """Staging directory is cleaned up even on failure.""" + whisper_root = tmp_path / "whisper-models" + staging_dir = tmp_path / ".staging-small" + staging_dir.mkdir(parents=True) + + with ( + patch( + "app.speech.services.whisper_model.WHISPER_MODELS_ROOT", whisper_root + ), + patch( + "app.speech.services.whisper_model.prepare_staging_dir", + return_value=staging_dir, + ), + patch( + "app.speech.services.whisper_model.WhisperModelService._snapshot_download", + side_effect=Exception("network down"), + ), + patch( + "app.speech.services.whisper_model.cleanup_staging_dir" + ) as mock_cleanup, + pytest.raises(Exception, match="network down"), + ): + await WhisperModelService._download_and_install("small") + + mock_cleanup.assert_called_once() + + +class TestRunDownload: + """Tests for WhisperModelService._run_download.""" + + @pytest.mark.asyncio + async def test_calls_download_and_load(self): + """_run_download installs then loads the model.""" + with ( + patch( + "app.speech.services.whisper_model.WhisperModelService._download_and_install" + ) as mock_download, + patch( + "app.speech.services.whisper_model.WhisperRuntime.load_size", + return_value=True, + ) as mock_load, + ): + await WhisperModelService._run_download("medium") + + mock_download.assert_called_once_with("medium") + mock_load.assert_called_once_with("medium") + + +class TestSnapshotDownload: + """Tests for WhisperModelService._snapshot_download.""" + + def test_calls_huggingface_snapshot_download(self): + """_snapshot_download delegates to huggingface_hub.snapshot_download.""" + spec = MagicMock() + spec.hf_repo_id = "test/repo" + spec.approx_download_mb = 100 + destination = MagicMock() + set_percent = MagicMock() + + with patch( + "app.speech.services.whisper_model.snapshot_download" + ) as mock_snapshot: + WhisperModelService._snapshot_download(spec, destination, set_percent) + + mock_snapshot.assert_called_once() + call_kwargs = mock_snapshot.call_args.kwargs + assert call_kwargs["repo_id"] == "test/repo" + assert call_kwargs["local_dir"] == str(destination) + assert call_kwargs["tqdm_class"] is not None diff --git a/tests/speech/services/test_whisper_storage.py b/tests/speech/services/test_whisper_storage.py new file mode 100644 index 0000000..8df3ec3 --- /dev/null +++ b/tests/speech/services/test_whisper_storage.py @@ -0,0 +1,114 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for Whisper on-disk storage helpers.""" + +from unittest.mock import patch + +import pytest + +from app.speech.services.whisper_storage import ( + is_installed, + is_valid_model_dir, + model_dir, +) + + +class TestModelDir: + """Tests for model_dir path helper.""" + + def test_returns_correct_path(self, tmp_path): + """model_dir returns the expected path under whisper-models root.""" + with patch("app.speech.services.whisper_storage.WHISPER_MODELS_ROOT", tmp_path): + result = model_dir("small") + assert result == tmp_path / "small" + + def test_normalizes_size(self, tmp_path): + """model_dir normalizes the size parameter.""" + with patch("app.speech.services.whisper_storage.WHISPER_MODELS_ROOT", tmp_path): + result = model_dir(" SMALL ") + assert result == tmp_path / "small" + + def test_invalid_size_raises(self, tmp_path): + """model_dir raises ValueError for unsupported sizes.""" + with ( + patch("app.speech.services.whisper_storage.WHISPER_MODELS_ROOT", tmp_path), + pytest.raises(ValueError, match="Unsupported speech model size"), + ): + model_dir("huge") + + +class TestIsValidModelDir: + """Tests for is_valid_model_dir validator.""" + + def test_valid_directory_with_model_bin(self, tmp_path): + """Returns True when directory contains model.bin.""" + model_path = tmp_path / "small" + model_path.mkdir() + (model_path / "model.bin").write_bytes(b"fake-model") + assert is_valid_model_dir(model_path) is True + + def test_missing_model_bin(self, tmp_path): + """Returns False when model.bin is absent.""" + model_path = tmp_path / "small" + model_path.mkdir() + assert is_valid_model_dir(model_path) is False + + def test_empty_directory(self, tmp_path): + """Returns False for an empty directory.""" + model_path = tmp_path / "small" + model_path.mkdir() + assert is_valid_model_dir(model_path) is False + + def test_directory_with_other_files(self, tmp_path): + """Returns False when only other files exist.""" + model_path = tmp_path / "small" + model_path.mkdir() + (model_path / "config.json").write_text("{}") + assert is_valid_model_dir(model_path) is False + + def test_not_a_directory(self, tmp_path): + """Returns False when path is a file, not a directory.""" + file_path = tmp_path / "small" + file_path.write_bytes(b"not-a-dir") + assert is_valid_model_dir(file_path) is False + + def test_nonexistent_path(self, tmp_path): + """Returns False when path does not exist.""" + missing_path = tmp_path / "small" / "nonexistent" + assert is_valid_model_dir(missing_path) is False + + +class TestIsInstalled: + """Tests for is_installed helper.""" + + def test_returns_true_when_installed(self, tmp_path): + """is_installed returns True when model.bin exists.""" + model_path = tmp_path / "small" + model_path.mkdir() + (model_path / "model.bin").write_bytes(b"fake-model") + + with patch("app.speech.services.whisper_storage.WHISPER_MODELS_ROOT", tmp_path): + assert is_installed("small") is True + + def test_returns_false_when_missing(self, tmp_path): + """is_installed returns False when directory does not exist.""" + with patch("app.speech.services.whisper_storage.WHISPER_MODELS_ROOT", tmp_path): + assert is_installed("small") is False + + def test_returns_false_when_no_model_bin(self, tmp_path): + """is_installed returns False when model.bin is absent.""" + model_path = tmp_path / "medium" + model_path.mkdir() + (model_path / "other.file").write_text("data") + + with patch("app.speech.services.whisper_storage.WHISPER_MODELS_ROOT", tmp_path): + assert is_installed("medium") is False + + def test_normalizes_size(self, tmp_path): + """is_installed normalizes the size parameter.""" + model_path = tmp_path / "large" + model_path.mkdir() + (model_path / "model.bin").write_bytes(b"fake-model") + + with patch("app.speech.services.whisper_storage.WHISPER_MODELS_ROOT", tmp_path): + assert is_installed("LARGE") is True diff --git a/tests/theory/api/test_theory_full_flow.py b/tests/theory/api/test_theory_full_flow.py new file mode 100644 index 0000000..f1e4489 --- /dev/null +++ b/tests/theory/api/test_theory_full_flow.py @@ -0,0 +1,206 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for full theory flow: submit answers, next questions, finish, end interview.""" + +from app.interview.services.query import InterviewQuery +from app.shared.infrastructure.models import Answer, Interview +from tests.fakes import answer_evaluation_json, follow_up_evaluation_json +from tests.helpers.interview_seed import persist_interview_with_answers +from tests.helpers.selection import minimal_selection_spec + + +class TestTheoryFullFlow: + """End-to-end theory interaction via WebSocket.""" + + def test_submit_all_questions_finish_section( + self, client, isolated_db, override_ws_ai_provider + ): + """Answer all theory questions + finish section moves to completed.""" + interview_id = persist_interview_with_answers( + Interview( + id="theory-full-1", + locale="en", + selection_spec=minimal_selection_spec(categories=["basics"]), + status="active", + ), + [ + Answer( + question_id="q1", + order=1, + round=0, + question_text="Q1?", + ), + Answer( + question_id="q2", + order=2, + round=0, + question_text="Q2?", + ), + ], + question_count=2, + ) + + override_ws_ai_provider( + client, + [ + answer_evaluation_json(score=4, follow_up_needed=False), + answer_evaluation_json(score=4, follow_up_needed=False), + ], + ) + + with client.websocket_connect(f"/interview/{interview_id}/theory/ws") as ws: + # Answer Q1 + ws.send_json( + { + "type": "answer", + "question_id": "q1", + "answer_text": "Answer one", + } + ) + assert ws.receive_json() == {"type": "saved"} + assert ws.receive_json() == {"type": "evaluating"} + fb1 = ws.receive_json() + assert fb1["type"] == "feedback" + assert fb1["question_id"] == "q1" + + # Next question shown + assert fb1["next_question"]["question_id"] == "q2" + + # Answer Q2 + ws.send_json( + { + "type": "answer", + "question_id": "q2", + "answer_text": "Answer two", + } + ) + assert ws.receive_json() == {"type": "saved"} + assert ws.receive_json() == {"type": "evaluating"} + fb2 = ws.receive_json() + assert fb2["type"] == "feedback" + assert fb2["question_id"] == "q2" + assert fb2["next_question"] is None + + # Finish the section + ws.send_json({"type": "complete"}) + assert ws.receive_json() == {"type": "evaluating"} + complete = ws.receive_json() + assert complete["type"] == "interview_completed" + + # Check DB state + reloaded = InterviewQuery.load(interview_id) + assert reloaded is not None + assert reloaded.status == "completed" + q1 = next(a for a in reloaded.answers if a.question_id == "q1") + q2 = next(a for a in reloaded.answers if a.question_id == "q2") + assert q1.answer_text == "Answer one" + assert q1.score == 4 + assert q2.answer_text == "Answer two" + assert q2.score == 4 + + def test_follow_up_chain(self, client, isolated_db, override_ws_ai_provider): + """Answer triggers follow-up; follow-up answer finishes round.""" + interview_id = persist_interview_with_answers( + Interview( + id="theory-followup-1", + locale="en", + selection_spec=minimal_selection_spec(categories=["basics"]), + status="active", + ), + [ + Answer( + question_id="q1", + order=1, + round=0, + question_text="Q1?", + ), + Answer( + question_id="q2", + order=2, + round=0, + question_text="Q2?", + ), + ], + question_count=2, + ) + + # First evaluation: follow-up needed + # Second evaluation: follow-up answer (no more follow-ups) + override_ws_ai_provider( + client, + [ + answer_evaluation_json( + score=3, + follow_up_needed=True, + follow_up_question="Explain deeper.", + ), + follow_up_evaluation_json( + score=4, + needs_further_follow_up=False, + ), + ], + ) + + with client.websocket_connect(f"/interview/{interview_id}/theory/ws") as ws: + # Answer Q1 → triggers follow-up + ws.send_json( + { + "type": "answer", + "question_id": "q1", + "answer_text": "Hint of an answer", + } + ) + assert ws.receive_json() == {"type": "saved"} + assert ws.receive_json() == {"type": "evaluating"} + fb1 = ws.receive_json() + assert fb1["type"] == "feedback" + assert fb1["follow_up_question"] == "Explain deeper." + + # Answer follow-up + ws.send_json( + { + "type": "answer", + "question_id": "q1", + "answer_text": "Deeper explanation", + } + ) + assert ws.receive_json() == {"type": "saved"} + assert ws.receive_json() == {"type": "evaluating"} + fb2 = ws.receive_json() + assert fb2["type"] == "feedback" + assert fb2["follow_up_question"] is None + assert fb2["next_question"]["question_id"] == "q2" + + def test_end_interview_mid_session( + self, client, isolated_db, override_ws_ai_provider + ): + """End interview sidebar button completes session early.""" + interview_id = persist_interview_with_answers( + Interview( + id="theory-end-1", + locale="en", + selection_spec=minimal_selection_spec(categories=["basics"]), + status="active", + ), + [ + Answer( + question_id="q1", + order=1, + round=0, + question_text="Q1?", + ), + ], + question_count=1, + ) + + override_ws_ai_provider(client, []) + + with client.websocket_connect(f"/interview/{interview_id}/theory/ws") as ws: + ws.send_json({"type": "complete"}) + assert ws.receive_json() == {"type": "evaluating"} + msg = ws.receive_json() + assert msg["type"] == "interview_completed" + + reloaded = InterviewQuery.load(interview_id) + assert reloaded is not None + assert reloaded.status == "completed" diff --git a/tests/theory/services/test_creation.py b/tests/theory/services/test_creation.py new file mode 100644 index 0000000..71377b6 --- /dev/null +++ b/tests/theory/services/test_creation.py @@ -0,0 +1,158 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for TheorySectionCreationService.""" + +from unittest.mock import patch + +import pytest + +from app.interview.domain.value_objects import InterviewSelection, TrackSelection +from app.interview.repositories.uow import InterviewUnitOfWork +from app.theory.domain.value_objects import PlannedTheoryQuestion +from app.theory.services.creation import TheorySectionCreationService + +_SELECTION = InterviewSelection( + sources=( + TrackSelection( + track="python", + level="junior", + categories=("data-structures",), + ), + ) +) + + +_PLANNED = ( + PlannedTheoryQuestion(id="q1", text="Q1", code=None, expected_points=()), + PlannedTheoryQuestion(id="q2", text="Q2", code=None, expected_points=()), +) + + +def test_create_builds_section_with_planned_questions() -> None: + """create persists a section with tasks matching the question plan.""" + with patch( + "app.theory.services.creation.build_theory_question_plan", + return_value=_PLANNED, + ): + mock_uow = InterviewUnitOfWork(auto_commit=False) + mock_uow.theory_sections.create_aggregate = lambda s: s + service = TheorySectionCreationService(mock_uow) + + section = service.create( + interview_id="iv-1", + selection=_SELECTION, + locale="en", + question_count=2, + task_time_limit_seconds=None, + ) + + assert section.interview_id == "iv-1" + assert section.locale == "en" + assert section.question_count == 2 + assert len(section.tasks) == 2 + assert section.tasks[0].question_id == "q1" + assert section.tasks[1].question_id == "q2" + assert section.tasks[0].order == 1 + assert section.tasks[1].order == 2 + + +def test_create_validates_question_count() -> None: + """create raises ValueError when question_count is below the topic count.""" + selection = InterviewSelection( + sources=( + TrackSelection( + track="python", + level="junior", + categories=("data-structures", "algorithms"), + ), + ) + ) + with patch( + "app.theory.services.creation.build_theory_question_plan", + return_value=(), + ): + mock_uow = InterviewUnitOfWork(auto_commit=False) + service = TheorySectionCreationService(mock_uow) + + with pytest.raises(ValueError, match="at least 2"): + service.create( + interview_id="iv-1", + selection=selection, + locale="en", + question_count=1, + task_time_limit_seconds=None, + ) + + +def test_create_starts_first_task_timer_when_enabled() -> None: + """create starts the first task timer when start_first_task_timer is True.""" + with patch( + "app.theory.services.creation.build_theory_question_plan", + return_value=_PLANNED, + ): + mock_uow = InterviewUnitOfWork(auto_commit=False) + mock_uow.theory_sections.create_aggregate = lambda s: s + service = TheorySectionCreationService(mock_uow) + + section = service.create( + interview_id="iv-1", + selection=_SELECTION, + locale="en", + question_count=2, + task_time_limit_seconds=60, + start_first_task_timer=True, + ) + + assert section.task_time_limit_seconds == 60 + assert section.tasks[0].started_at is not None + assert section.tasks[1].started_at is None + + +def test_create_does_not_start_timer_when_disabled() -> None: + """create leaves started_at None on all tasks when start_first_task_timer is False.""" + with patch( + "app.theory.services.creation.build_theory_question_plan", + return_value=_PLANNED, + ): + mock_uow = InterviewUnitOfWork(auto_commit=False) + mock_uow.theory_sections.create_aggregate = lambda s: s + service = TheorySectionCreationService(mock_uow) + + section = service.create( + interview_id="iv-1", + selection=_SELECTION, + locale="en", + question_count=2, + task_time_limit_seconds=60, + start_first_task_timer=False, + ) + + assert section.tasks[0].started_at is None + assert section.tasks[1].started_at is None + + +def test_create_passes_excluded_ids_to_planner() -> None: + """create forwards excluded_ids to build_theory_question_plan.""" + with patch( + "app.theory.services.creation.build_theory_question_plan", + return_value=_PLANNED, + ) as mock_plan: + mock_uow = InterviewUnitOfWork(auto_commit=False) + mock_uow.theory_sections.create_aggregate = lambda s: s + service = TheorySectionCreationService(mock_uow) + + service.create( + interview_id="iv-1", + selection=_SELECTION, + locale="en", + question_count=2, + task_time_limit_seconds=None, + excluded_ids=frozenset({"q3"}), + ) + + mock_plan.assert_called_once_with( + _SELECTION, + 2, + locale="en", + excluded_ids=frozenset({"q3"}), + ) diff --git a/tests/theory/services/test_navigation.py b/tests/theory/services/test_navigation.py new file mode 100644 index 0000000..f0fd5df --- /dev/null +++ b/tests/theory/services/test_navigation.py @@ -0,0 +1,209 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for TheoryNavigationService and next_task_payload.""" + +from datetime import UTC, datetime +from unittest.mock import MagicMock, patch + +import pytest + +from app.interview.repositories.uow import InterviewUnitOfWork +from app.theory.domain.entities import TheorySection, TheoryTask +from app.theory.domain.exceptions import ( + TheorySectionNotActiveError, + TheorySectionNotFoundError, +) +from app.theory.services.navigation import TheoryNavigationService, next_task_payload + + +def _section_with_tasks( + interview_id: str = "iv-1", + *, + task_time_limit_seconds: int | None = None, + tasks: list[TheoryTask] | None = None, + status: str = "active", +) -> TheorySection: + """Build a theory section with the given tasks.""" + return TheorySection( + id=1, + interview_id=interview_id, + locale="en", + selection=MagicMock(), + question_count=2, + question_ids=("q1", "q2"), + task_time_limit_seconds=task_time_limit_seconds, + status=status, + section_score=None, + section_feedback=None, + tasks=tuple(tasks) if tasks else (), + ) + + +def _task( + task_id: int, + question_id: str, + order: int, + round_num: int = 0, + answer_text: str | None = None, + started_at: datetime | None = None, +) -> TheoryTask: + """Build a minimal theory task.""" + return TheoryTask( + id=task_id, + theory_section_id=1, + interview_id="iv-1", + question_id=question_id, + order=order, + round=round_num, + question_text=f"Q{order}?", + question_code=None, + answer_text=answer_text, + score=None, + feedback=None, + started_at=started_at, + created_at=datetime.now(UTC), + expected_points=(), + ) + + +def test_next_task_payload_returns_correct_dict() -> None: + """next_task_payload returns task fields in the expected shape.""" + task = _task(1, "q1", 1) + payload = next_task_payload(task) + + assert payload == { + "id": 1, + "question_id": "q1", + "order": 1, + "question_text": "Q1?", + "question_code": None, + "round": 0, + } + + +def test_advance_to_next_unanswered_returns_next_task_payload() -> None: + """advance_to_next_unanswered returns the next unanswered task and starts timer.""" + t1 = _task(1, "q1", 1, answer_text="done") + t2 = _task(2, "q2", 2) + section = _section_with_tasks(tasks=[t1, t2], task_time_limit_seconds=30) + + mock_uow = MagicMock(spec=InterviewUnitOfWork) + mock_uow.theory_sections.get_aggregate.return_value = section + + service = TheoryNavigationService(mock_uow) + payload, timer = service.advance_to_next_unanswered( + "iv-1", + question_id="q1", + round_num=0, + ) + + assert payload is not None + assert payload["question_id"] == "q2" + assert payload["order"] == 2 + assert timer is not None + assert timer <= 30 + + +def test_advance_to_next_unanswered_returns_none_when_complete() -> None: + """advance_to_next_unanswered returns None when all tasks are answered.""" + t1 = _task(1, "q1", 1, answer_text="done") + t2 = _task(2, "q2", 2, answer_text="done") + section = _section_with_tasks(tasks=[t1, t2]) + + mock_uow = MagicMock(spec=InterviewUnitOfWork) + mock_uow.theory_sections.get_aggregate.return_value = section + + service = TheoryNavigationService(mock_uow) + + with patch.object( + service, + "_notify_phase_complete_if_needed", + ) as mock_notify: + payload, timer = service.advance_to_next_unanswered( + "iv-1", + question_id="q1", + round_num=0, + ) + + assert payload is None + assert timer is None + mock_notify.assert_called_once_with("iv-1", section) + + +def test_advance_to_next_unanswered_calls_notify_when_done() -> None: + """Completing the last task triggers section-complete notification.""" + t1 = _task(1, "q1", 1, answer_text="done") + t2 = _task(2, "q2", 2, answer_text="done") + section = _section_with_tasks(tasks=[t1, t2]) + + mock_uow = MagicMock(spec=InterviewUnitOfWork) + mock_uow.theory_sections.get_aggregate.return_value = section + + service = TheoryNavigationService(mock_uow) + + with patch.object( + service, + "_notify_phase_complete_if_needed", + ) as mock_notify: + service.advance_to_next_unanswered( + "iv-1", + question_id="q2", + round_num=0, + ) + + mock_notify.assert_called_once_with("iv-1", section) + + +def test_advance_raises_when_section_not_found() -> None: + """TheorySectionNotFoundError is raised when the section does not exist.""" + mock_uow = MagicMock(spec=InterviewUnitOfWork) + mock_uow.theory_sections.get_aggregate.return_value = None + + service = TheoryNavigationService(mock_uow) + + with pytest.raises(TheorySectionNotFoundError): + service.advance_to_next_unanswered( + "missing", + question_id="q1", + round_num=0, + ) + + +def test_advance_raises_when_section_not_active() -> None: + """TheorySectionNotActiveError is raised when the section is not active.""" + t1 = _task(1, "q1", 1) + section = _section_with_tasks(tasks=[t1], status="completed") + + mock_uow = MagicMock(spec=InterviewUnitOfWork) + mock_uow.theory_sections.get_aggregate.return_value = section + + service = TheoryNavigationService(mock_uow) + + with pytest.raises(TheorySectionNotActiveError): + service.advance_to_next_unanswered( + "iv-1", + question_id="q1", + round_num=0, + ) + + +def test_advance_saves_aggregate_with_started_next_task() -> None: + """The section aggregate is saved with the next task timer started.""" + t1 = _task(1, "q1", 1, answer_text="done") + t2 = _task(2, "q2", 2) + section = _section_with_tasks(tasks=[t1, t2], task_time_limit_seconds=30) + + mock_uow = MagicMock(spec=InterviewUnitOfWork) + mock_uow.theory_sections.get_aggregate.return_value = section + + service = TheoryNavigationService(mock_uow) + service.advance_to_next_unanswered( + "iv-1", + question_id="q1", + round_num=0, + ) + + saved = mock_uow.theory_sections.save_aggregate.call_args[0][0] + assert saved is not None + second_task = next(t for t in saved.tasks if t.id == 2) + assert second_task.started_at is not None diff --git a/tests/theory/services/test_query.py b/tests/theory/services/test_query.py new file mode 100644 index 0000000..f7a14db --- /dev/null +++ b/tests/theory/services/test_query.py @@ -0,0 +1,191 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for TheoryQueryService.""" + +from dataclasses import replace +from datetime import UTC, datetime +from unittest.mock import MagicMock + +from app.interview.domain.value_objects import InterviewSelection, TrackSelection +from app.interview.repositories.uow import InterviewUnitOfWork +from app.interview.services.sections import SectionEvaluationSummary +from app.theory.domain.entities import TheorySection, TheoryTask +from app.theory.services.query import TheoryQueryService + + +def _task( + task_id: int, + question_id: str, + order: int, + answer_text: str | None = None, + score: int | None = None, + feedback: str | None = None, + round_num: int = 0, +) -> TheoryTask: + """Build a minimal theory task.""" + return TheoryTask( + id=task_id, + theory_section_id=1, + interview_id="iv-1", + question_id=question_id, + order=order, + round=round_num, + question_text=f"Q{order}?", + question_code=None, + answer_text=answer_text, + score=score, + feedback=feedback, + started_at=None, + created_at=datetime.now(UTC), + expected_points=(), + ) + + +def _section( + *, + tasks: list[TheoryTask] | None = None, + section_feedback: dict[str, object] | None = None, +) -> TheorySection: + """Build a theory section with the given tasks.""" + return TheorySection( + id=1, + interview_id="iv-1", + locale="en", + selection=InterviewSelection( + sources=( + TrackSelection( + track="python", + level="junior", + categories=("basics",), + ), + ) + ), + question_count=2, + question_ids=("q1", "q2"), + task_time_limit_seconds=None, + status="active", + section_score=None, + section_feedback=section_feedback, + tasks=tuple(tasks) if tasks else (), + ) + + +def test_get_evaluation_summary_returns_correct_summary() -> None: + """get_evaluation_summary returns score, items, and cached narrative.""" + t1 = _task(1, "q1", 1, answer_text="A1", score=4, feedback="Good") + t2 = _task(2, "q2", 2, answer_text="A2", score=5, feedback="Great") + section = _section(tasks=[t1, t2]) + + mock_uow = MagicMock(spec=InterviewUnitOfWork) + mock_uow.theory_sections.get_aggregate.return_value = section + + service = TheoryQueryService(mock_uow) + result = service.get_evaluation_summary("iv-1") + + assert isinstance(result, SectionEvaluationSummary) + assert result.section == "theory" + assert result.score == 9 + assert result.max_score == 10 + assert result.skipped is False + assert len(result.items) == 2 + assert result.items[0]["question_id"] == "q1" + assert result.items[0]["score"] == 4 + assert result.items[0]["feedback"] == "Good" + assert result.cached_narrative is None + + +def test_get_evaluation_summary_uses_cached_narrative() -> None: + """Cached section_feedback is forwarded in the summary when present.""" + t1 = _task(1, "q1", 1, answer_text="A1", score=4) + cached = {"summary": "Already evaluated"} + section = _section(tasks=[t1], section_feedback=cached) + + mock_uow = MagicMock(spec=InterviewUnitOfWork) + mock_uow.theory_sections.get_aggregate.return_value = section + + service = TheoryQueryService(mock_uow) + result = service.get_evaluation_summary("iv-1") + + assert result.cached_narrative == cached + + +def test_get_evaluation_summary_returns_none_when_missing() -> None: + """get_evaluation_summary returns None when no theory section exists.""" + mock_uow = MagicMock(spec=InterviewUnitOfWork) + mock_uow.theory_sections.get_aggregate.return_value = None + + service = TheoryQueryService(mock_uow) + result = service.get_evaluation_summary("missing") + + assert result is None + + +def test_get_evaluation_summary_skipped_section() -> None: + """Skipped sections report zero scores.""" + t1 = _task(1, "q1", 1, answer_text="A1", score=4) + section = replace(_section(tasks=[t1]), status="skipped") + + mock_uow = MagicMock(spec=InterviewUnitOfWork) + mock_uow.theory_sections.get_aggregate.return_value = section + + service = TheoryQueryService(mock_uow) + result = service.get_evaluation_summary("iv-1") + + assert result.score == 0 + assert result.max_score == 0 + assert result.skipped is True + + +def test_sources_text_for_section_returns_text() -> None: + """sources_text_for_section returns selection summary for prompts.""" + section = _section() + + mock_uow = MagicMock(spec=InterviewUnitOfWork) + mock_uow.theory_sections.get_aggregate.return_value = section + + service = TheoryQueryService(mock_uow) + result = service.sources_text_for_section("iv-1") + + assert "Python" in result + assert "junior" in result + assert "basics" in result + + +def test_sources_text_for_section_returns_empty_when_missing() -> None: + """sources_text_for_section returns empty string when no section exists.""" + mock_uow = MagicMock(spec=InterviewUnitOfWork) + mock_uow.theory_sections.get_aggregate.return_value = None + + service = TheoryQueryService(mock_uow) + result = service.sources_text_for_section("missing") + + assert result == "" + + +def test_qa_items_from_section_builds_correct_items() -> None: + """_qa_items_from_section includes only answered tasks.""" + t1 = _task(1, "q1", 1, answer_text="A1", score=3, feedback="OK", round_num=0) + t2 = _task(2, "q1", 1, answer_text=None, score=None, feedback=None, round_num=1) + t3 = _task(3, "q2", 2, answer_text="A2", score=5, feedback="Nice", round_num=0) + section = _section(tasks=[t1, t2, t3]) + + items = TheoryQueryService._qa_items_from_section(section) + + assert len(items) == 2 + assert items[0]["question_id"] == "q1" + assert items[0]["answer_text"] == "A1" + assert items[0]["score"] == 3 + assert items[0]["round"] == 0 + assert items[0]["feedback"] == "OK" + assert items[1]["question_id"] == "q2" + assert items[1]["score"] == 5 + + +def test_qa_items_ignores_unanswered_tasks() -> None: + """Tasks without answer_text are omitted from Q&A items.""" + t1 = _task(1, "q1", 1, answer_text=None, score=None) + section = _section(tasks=[t1]) + + items = TheoryQueryService._qa_items_from_section(section) + + assert items == () diff --git a/tests/theory/services/test_section.py b/tests/theory/services/test_section.py new file mode 100644 index 0000000..ac17cf7 --- /dev/null +++ b/tests/theory/services/test_section.py @@ -0,0 +1,209 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for TheorySectionService.""" + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.interview.domain.value_objects import InterviewSelection, TrackSelection +from app.interview.repositories.uow import InterviewUnitOfWork +from app.interview.services.sections import SectionEvaluationSummary, SectionPageContext +from app.theory.domain.entities import TheorySection, TheoryTask +from app.theory.services.query import TheoryQueryService +from app.theory.services.section import TheorySectionService + + +def _task( + task_id: int, + question_id: str, + order: int, + answer_text: str | None = None, +) -> TheoryTask: + """Build a minimal theory task.""" + return TheoryTask( + id=task_id, + theory_section_id=1, + interview_id="iv-1", + question_id=question_id, + order=order, + round=0, + question_text=f"Q{order}?", + question_code=None, + answer_text=answer_text, + score=None, + feedback=None, + started_at=None, + created_at=datetime.now(UTC), + expected_points=(), + ) + + +def _section( + *, + tasks: list[TheoryTask] | None = None, + status: str = "active", +) -> TheorySection: + """Build a theory section with the given tasks.""" + return TheorySection( + id=1, + interview_id="iv-1", + locale="en", + selection=InterviewSelection( + sources=( + TrackSelection( + track="python", + level="junior", + categories=("basics",), + ), + ) + ), + question_count=2, + question_ids=("q1", "q2"), + task_time_limit_seconds=None, + status=status, + section_score=None, + section_feedback=None, + tasks=tuple(tasks) if tasks else (), + ) + + +def _mock_uow(section: TheorySection | None = None) -> MagicMock: + """Build a mock UoW that returns the given section.""" + mock = MagicMock(spec=InterviewUnitOfWork) + mock.theory_sections.get_aggregate.return_value = section + return mock + + +def test_is_complete_returns_true_when_all_tasks_answered() -> None: + """is_complete returns True when every task has answer_text.""" + section = _section(tasks=[_task(1, "q1", 1, "A1"), _task(2, "q2", 2, "A2")]) + service = TheorySectionService(_mock_uow(section)) + + assert service.is_complete("iv-1") is True + + +def test_is_complete_returns_false_when_tasks_remain() -> None: + """is_complete returns False when at least one task is unanswered.""" + section = _section(tasks=[_task(1, "q1", 1, "A1"), _task(2, "q2", 2)]) + service = TheorySectionService(_mock_uow(section)) + + assert service.is_complete("iv-1") is False + + +def test_is_complete_returns_false_when_no_section() -> None: + """is_complete returns False when no theory section exists.""" + service = TheorySectionService(_mock_uow(None)) + + assert service.is_complete("iv-1") is False + + +def test_is_user_facing_returns_true_when_unanswered_remain() -> None: + """is_user_facing returns True when the section exists and is incomplete.""" + section = _section(tasks=[_task(1, "q1", 1, "A1"), _task(2, "q2", 2)]) + service = TheorySectionService(_mock_uow(section)) + + assert service.is_user_facing("iv-1") is True + + +def test_is_user_facing_returns_false_when_complete() -> None: + """is_user_facing returns False when the section is fully answered.""" + section = _section(tasks=[_task(1, "q1", 1, "A1"), _task(2, "q2", 2, "A2")]) + service = TheorySectionService(_mock_uow(section)) + + assert service.is_user_facing("iv-1") is False + + +def test_is_user_facing_returns_false_when_no_section() -> None: + """is_user_facing returns False when no theory section exists.""" + service = TheorySectionService(_mock_uow(None)) + + assert service.is_user_facing("iv-1") is False + + +def test_activate_if_pending_always_returns_false() -> None: + """Theory sections are created active, so activate_if_pending is always False.""" + service = TheorySectionService(_mock_uow(_section())) + + assert service.activate_if_pending("iv-1") is False + + +def test_get_page_context_returns_correct_context() -> None: + """get_page_context returns active=True/complete=False for incomplete sections.""" + section = _section(tasks=[_task(1, "q1", 1), _task(2, "q2", 2)]) + service = TheorySectionService(_mock_uow(section)) + + ctx = service.get_page_context("iv-1") + + assert isinstance(ctx, SectionPageContext) + assert ctx.section == "theory" + assert ctx.active is True + assert ctx.complete is False + + +def test_get_page_context_returns_complete_context() -> None: + """get_page_context returns active=False/complete=True for completed sections.""" + section = _section(tasks=[_task(1, "q1", 1, "A1"), _task(2, "q2", 2, "A2")]) + service = TheorySectionService(_mock_uow(section)) + + ctx = service.get_page_context("iv-1") + + assert ctx.complete is True + assert ctx.active is False + + +def test_get_page_context_returns_none_when_no_section() -> None: + """get_page_context returns None when no theory section exists.""" + service = TheorySectionService(_mock_uow(None)) + + assert service.get_page_context("iv-1") is None + + +def test_get_evaluation_summary_delegates_to_query_service() -> None: + """get_evaluation_summary delegates to the injected TheoryQueryService.""" + summary = SectionEvaluationSummary( + section="theory", + score=4, + max_score=5, + items=(), + ) + mock_query = MagicMock(spec=TheoryQueryService) + mock_query.get_evaluation_summary.return_value = summary + mock_uow = _mock_uow(_section()) + + service = TheorySectionService(mock_uow, query=mock_query) + result = service.get_evaluation_summary("iv-1") + + assert result == summary + mock_query.get_evaluation_summary.assert_called_once_with("iv-1") + + +def test_on_phase_complete_prefetches_feedback() -> None: + """on_phase_complete delegates to the feedback prefetch helper.""" + mock_feedback = MagicMock() + mock_feedback.on_phase_complete = MagicMock() + + section = _section(tasks=[_task(1, "q1", 1, "A1")]) + service = TheorySectionService(_mock_uow(section)) + + # Replace the private _feedback helper with our mock + service._feedback = mock_feedback + service.on_phase_complete("iv-1") + + mock_feedback.on_phase_complete.assert_called_once_with("iv-1") + + +@pytest.mark.asyncio +async def test_ensure_section_feedback_delegates_to_prefetch() -> None: + """ensure_section_feedback delegates to the feedback prefetch helper.""" + mock_feedback = MagicMock() + mock_feedback.ensure_section_feedback = AsyncMock() + + section = _section(tasks=[_task(1, "q1", 1, "A1")]) + service = TheorySectionService(_mock_uow(section)) + service._feedback = mock_feedback + + await service.ensure_section_feedback("iv-1") + + mock_feedback.ensure_section_feedback.assert_awaited_once_with("iv-1") diff --git a/tests/theory/services/test_timer.py b/tests/theory/services/test_timer.py new file mode 100644 index 0000000..fc1a264 --- /dev/null +++ b/tests/theory/services/test_timer.py @@ -0,0 +1,206 @@ +# Copyright 2026 GrillKit Contributors +# SPDX-License-Identifier: Apache-2.0 +"""Tests for TheoryTimerService.""" + +from datetime import UTC, datetime +from unittest.mock import MagicMock, patch + +import pytest + +from app.interview.repositories.uow import InterviewUnitOfWork +from app.interview.services.events import AnswerFeedbackEvent +from app.theory.domain.entities import TheorySection, TheoryTask +from app.theory.domain.exceptions import TheorySectionNotFoundError +from app.theory.services.navigation import TheoryNavigationService +from app.theory.services.timer import TheoryTimerService + + +def _task( + task_id: int, + question_id: str, + order: int, + answer_text: str | None = None, +) -> TheoryTask: + """Build a minimal theory task.""" + return TheoryTask( + id=task_id, + theory_section_id=1, + interview_id="iv-1", + question_id=question_id, + order=order, + round=0, + question_text=f"Q{order}?", + question_code=None, + answer_text=answer_text, + score=None, + feedback=None, + started_at=None, + created_at=datetime.now(UTC), + expected_points=(), + ) + + +def _section(tasks: list[TheoryTask]) -> TheorySection: + """Build a theory section with the given tasks.""" + return TheorySection( + id=1, + interview_id="iv-1", + locale="en", + selection=MagicMock(), + question_count=len(tasks), + question_ids=tuple(t.question_id for t in tasks), + task_time_limit_seconds=None, + status="active", + section_score=None, + section_feedback=None, + tasks=tuple(tasks), + ) + + +def test_persist_timed_out_round_saves_timeout_with_zero_score() -> None: + """persist_timed_out_round marks the task with timeout text and score 0.""" + t1 = _task(1, "q1", 1) + section = _section([t1]) + + mock_uow = MagicMock(spec=InterviewUnitOfWork) + mock_uow.theory_sections.get_aggregate.return_value = section + + mock_nav = MagicMock(spec=TheoryNavigationService) + mock_nav.advance_to_next_unanswered.return_value = (None, None) + + service = TheoryTimerService(mock_uow, navigation=mock_nav) + + with patch( + "app.theory.services.timer.timeout_feedback_for_locale", + return_value="Time is up", + ): + service.persist_timed_out_round( + interview_id="iv-1", + question_id="q1", + round_num=0, + order=1, + locale="en", + ) + + saved = mock_uow.theory_sections.save_aggregate.call_args[0][0] + assert saved is not None + task = next(t for t in saved.tasks if t.question_id == "q1" and t.round == 0) + assert task.answer_text == TheoryTask.TIME_EXPIRED_ANSWER_TEXT + assert task.score == 0 + assert task.feedback == "Time is up" + + +def test_persist_timed_out_round_returns_feedback_event() -> None: + """persist_timed_out_round returns an AnswerFeedbackEvent with timed_out=True.""" + t1 = _task(1, "q1", 1) + section = _section([t1]) + + mock_uow = MagicMock(spec=InterviewUnitOfWork) + mock_uow.theory_sections.get_aggregate.return_value = section + + mock_nav = MagicMock(spec=TheoryNavigationService) + mock_nav.advance_to_next_unanswered.return_value = ( + {"id": 2, "question_id": "q2", "order": 2}, + 45, + ) + + service = TheoryTimerService(mock_uow, navigation=mock_nav) + + with patch( + "app.theory.services.timer.timeout_feedback_for_locale", + return_value="Time is up", + ): + result = service.persist_timed_out_round( + interview_id="iv-1", + question_id="q1", + round_num=0, + order=1, + locale="en", + ) + + assert isinstance(result, AnswerFeedbackEvent) + assert result.timed_out is True + assert result.question_id == "q1" + assert result.order == 1 + assert result.round == 0 + assert result.feedback == "Time is up" + assert result.follow_up_needed is False + assert result.follow_up_text is None + + +def test_persist_timed_out_round_advances_to_next_question() -> None: + """persist_timed_out_round calls navigation to advance after saving timeout.""" + t1 = _task(1, "q1", 1) + section = _section([t1]) + + mock_uow = MagicMock(spec=InterviewUnitOfWork) + mock_uow.theory_sections.get_aggregate.return_value = section + + mock_nav = MagicMock(spec=TheoryNavigationService) + mock_nav.advance_to_next_unanswered.return_value = (None, None) + + service = TheoryTimerService(mock_uow, navigation=mock_nav) + + with patch( + "app.theory.services.timer.timeout_feedback_for_locale", + return_value="Time is up", + ): + service.persist_timed_out_round( + interview_id="iv-1", + question_id="q1", + round_num=0, + order=1, + locale="en", + ) + + mock_nav.advance_to_next_unanswered.assert_called_once_with( + "iv-1", + question_id="q1", + round_num=0, + ) + + +def test_persist_timed_out_round_uses_locale_specific_feedback() -> None: + """Locale is passed to timeout_feedback_for_locale for localized text.""" + t1 = _task(1, "q1", 1) + section = _section([t1]) + + mock_uow = MagicMock(spec=InterviewUnitOfWork) + mock_uow.theory_sections.get_aggregate.return_value = section + + mock_nav = MagicMock(spec=TheoryNavigationService) + mock_nav.advance_to_next_unanswered.return_value = (None, None) + + service = TheoryTimerService(mock_uow, navigation=mock_nav) + + with patch( + "app.theory.services.timer.timeout_feedback_for_locale", + return_value="Время вышло", + ) as mock_feedback: + result = service.persist_timed_out_round( + interview_id="iv-1", + question_id="q1", + round_num=0, + order=1, + locale="ru", + ) + + mock_feedback.assert_called_once_with("ru") + assert result.feedback == "Время вышло" + + +def test_persist_timed_out_round_raises_when_section_not_found() -> None: + """TheorySectionNotFoundError is raised when the section does not exist.""" + mock_uow = MagicMock(spec=InterviewUnitOfWork) + mock_uow.theory_sections.get_aggregate.return_value = None + + service = TheoryTimerService(mock_uow) + + with pytest.raises(TheorySectionNotFoundError): + service.persist_timed_out_round( + interview_id="missing", + question_id="q1", + round_num=0, + order=1, + locale="en", + )