Review your settings
Confirm everything looks right, then start the interview.
diff --git a/templates/theory_review.html b/templates/theory_review.html
index 977363e..6be9a33 100644
--- a/templates/theory_review.html
+++ b/templates/theory_review.html
@@ -40,8 +40,18 @@
Conversation History
{% for answer in answers %}
-
Q{{ answer.order }}:
- {% if answer.round > 0 %}
(follow-up){% endif %}
+
{{ answer.question_text }}
{% if answer.question_code %}
{{ answer.question_code }}
@@ -73,3 +83,14 @@
Conversation History
{% endblock %}
+
+{% block scripts %}
+
+
+{% endblock %}
diff --git a/tests/coding/api/test_routes.py b/tests/coding/api/test_routes.py
index d573a99..b4d04ae 100644
--- a/tests/coding/api/test_routes.py
+++ b/tests/coding/api/test_routes.py
@@ -2,10 +2,13 @@
# SPDX-License-Identifier: Apache-2.0
"""Tests for coding HTTP and WebSocket routes."""
+from datetime import UTC, datetime, timedelta
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.repositories.uow import InterviewUnitOfWork
+from app.shared.infrastructure.models import CodingTask
from tests.helpers.coding_seed import seed_active_coding_interview
@@ -72,6 +75,27 @@ def test_run_rate_limit(self, client, isolated_db, monkeypatch):
assert first.status_code == 200
assert second.status_code == 429
+ def test_run_rechecks_current_task_after_judge0(self, client, isolated_db):
+ """Run is rejected if the task is completed before persistence."""
+ interview_id, task_id = seed_active_coding_interview("coding-run-race")
+
+ async def complete_task_before_return(**_kwargs):
+ with InterviewUnitOfWork(auto_commit=True) as uow:
+ task = uow.session.query(CodingTask).filter_by(task_id=task_id).one()
+ task.submitted_code = "already submitted"
+ return _success_run_result()
+
+ with patch(
+ "app.coding.services.run_execution.CodingRunnerService.run_public_tests",
+ new=AsyncMock(side_effect=complete_task_before_return),
+ ):
+ response = client.post(
+ f"/interview/{interview_id}/coding/run",
+ json={"task_id": task_id, "source_code": "pass"},
+ )
+
+ assert response.status_code == 400
+
class TestCodingStateApi:
"""Tests for GET /interview/{id}/coding/state."""
@@ -152,3 +176,29 @@ def test_submit_requires_fields(self, client, isolated_db, override_ws_ai_provid
ws.send_json({"type": "submit", "task_id": "cod-001"})
error = ws.receive_json()
assert error["type"] == "error"
+
+ def test_timeout_marks_current_task_as_zero_score(
+ self, client, isolated_db, override_ws_ai_provider
+ ):
+ """Expired coding timers submit the round with zero score."""
+ interview_id, task_id = seed_active_coding_interview("coding-ws-timeout")
+ with InterviewUnitOfWork(auto_commit=True) as uow:
+ section = uow.coding_sections.get_aggregate(interview_id)
+ assert section is not None
+ row = uow.session.query(CodingTask).filter_by(task_id=task_id).one()
+ db_section = row.coding_section
+ db_section.task_time_limit_seconds = 1
+ row.started_at = datetime.now(UTC) - timedelta(seconds=5)
+
+ override_ws_ai_provider(client, [])
+ with client.websocket_connect(f"/interview/{interview_id}/coding/ws") as ws:
+ ws.send_json({"type": "timeout", "task_id": task_id, "round": 0})
+ feedback = ws.receive_json()
+
+ assert feedback["type"] == "feedback"
+ assert feedback["task_id"] == task_id
+ assert feedback["feedback"]
+ with InterviewUnitOfWork() as uow:
+ task = uow.session.query(CodingTask).filter_by(task_id=task_id).one()
+ assert task.submitted_code == "[Time expired]"
+ assert task.score == 0
diff --git a/tests/coding/services/test_page.py b/tests/coding/services/test_page.py
index 8901849..b5c9bd7 100644
--- a/tests/coding/services/test_page.py
+++ b/tests/coding/services/test_page.py
@@ -6,6 +6,7 @@
import json
from app.coding.services.page import CodingPageService
+from app.interview.repositories.uow import InterviewUnitOfWork
from app.interview.services.page import SessionPageService
from tests.helpers.coding_seed import seed_active_coding_interview
@@ -13,13 +14,13 @@
def test_build_context_returns_none_without_section(isolated_db):
"""Coding page context is absent when the session has no coding section."""
del isolated_db
- assert CodingPageService.build_context("missing-session") is None
+ assert CodingPageService.build_context_for("missing-session") is None
def test_build_context_exposes_current_task(isolated_db):
"""Coding page context includes the active task and progress fields."""
interview_id, task_id = seed_active_coding_interview("coding-page-1")
- context = CodingPageService.build_context(interview_id)
+ context = CodingPageService.build_context_for(interview_id)
assert context is not None
assert context.task_count == 1
assert context.completed_tasks == 0
@@ -34,14 +35,15 @@ def test_full_template_context_includes_coding_key(isolated_db):
interview_id, _task_id = seed_active_coding_interview("coding-page-2")
from app.interview.services.query import InterviewQuery
- interview = InterviewQuery.get_interview(interview_id)
+ interview = InterviewQuery.load(interview_id)
assert interview is not None
- template_context = asyncio.run(
- SessionPageService.build_full_template_context(
- interview,
- config=None,
+ with InterviewUnitOfWork() as uow:
+ template_context = asyncio.run(
+ SessionPageService(uow).build_full_template_context(
+ interview,
+ config=None,
+ )
)
- )
assert template_context["coding"] is not None
assert template_context["coding"]["task_count"] == 1
assert template_context["session_mode_label"]
@@ -54,4 +56,5 @@ def test_interview_route_uses_coding_template(client, isolated_db):
assert response.status_code == 200
assert "coding-session" in response.text
assert "coding-session__assignment" in response.text
+ assert "I know this" in response.text
assert "interview-chat-panel" not in response.text
diff --git a/tests/coding/services/test_planning.py b/tests/coding/services/test_planning.py
index 34a6ed6..34fc560 100644
--- a/tests/coding/services/test_planning.py
+++ b/tests/coding/services/test_planning.py
@@ -69,6 +69,29 @@ def test_build_coding_task_plan_from_bank() -> None:
assert planned[0].task_spec["language"] == "python"
+def test_build_coding_task_plan_excludes_known_ids() -> None:
+ """Coding planning omits excluded task IDs from the result."""
+ selection = InterviewSelection(
+ sources=(
+ TrackSelection(
+ track="python",
+ level="junior",
+ categories=("basics",),
+ ),
+ )
+ )
+ all_planned = build_coding_task_plan(selection, task_count=1, locale="en")
+ excluded_id = all_planned[0].id
+ planned = build_coding_task_plan(
+ selection,
+ task_count=1,
+ locale="en",
+ excluded_ids=frozenset({excluded_id}),
+ )
+ assert len(planned) == 1
+ assert planned[0].id != excluded_id
+
+
def test_validate_task_count_requires_one_per_topic() -> None:
"""Task count must cover every selected topic."""
selection = InterviewSelection(
diff --git a/tests/coding/services/test_review.py b/tests/coding/services/test_review.py
index a1271fa..f9b4a81 100644
--- a/tests/coding/services/test_review.py
+++ b/tests/coding/services/test_review.py
@@ -29,7 +29,8 @@ def test_coding_review_service_groups_task_rounds(isolated_db) -> None:
)
uow.session.add(follow_up)
- context = CodingReviewService.build_context(interview_id)
+ with InterviewUnitOfWork() as uow:
+ context = CodingReviewService(uow).build_context_for(interview_id)
assert context is not None
assert len(context.tasks) == 1
assert len(context.tasks[0].rounds) == 2
diff --git a/tests/coding/services/test_section.py b/tests/coding/services/test_section.py
index a773125..2f5fbd1 100644
--- a/tests/coding/services/test_section.py
+++ b/tests/coding/services/test_section.py
@@ -22,17 +22,19 @@ def test_coding_section_service_reports_incomplete_until_submitted(
selection_spec=minimal_selection_spec(),
status="active",
)
- from app.coding.repositories.uow import CodingUnitOfWork
+ from app.interview.repositories.uow import InterviewUnitOfWork
- with CodingUnitOfWork() as uow:
+ with InterviewUnitOfWork() as uow:
uow.session.add(interview)
uow.commit()
section = create_coding_section_for_interview(uow.session, interview)
attach_coding_tasks(uow.session, section)
uow.commit()
- assert CodingSectionService.is_complete(interview_id) is False
- context = CodingSectionService.get_page_context(interview_id)
+ with InterviewUnitOfWork() as uow:
+ service = CodingSectionService(uow)
+ assert service.is_complete(interview_id) is False
+ context = service.get_page_context(interview_id)
assert context is not None
assert context.section == "coding"
assert context.active is True
diff --git a/tests/conftest.py b/tests/conftest.py
index 9e523c0..26a78bb 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -14,6 +14,7 @@
from app.interview.repositories.uow import InterviewUnitOfWork
from app.main import create_app
from app.platform.services.config import AppConfig
+from app.shared.infrastructure import models # noqa: F401 - registers all ORM models
from app.shared.infrastructure.database import Base
from tests.fakes import FakeProvider
diff --git a/tests/helpers/session_creation.py b/tests/helpers/session_creation.py
new file mode 100644
index 0000000..c321d48
--- /dev/null
+++ b/tests/helpers/session_creation.py
@@ -0,0 +1,26 @@
+# Copyright 2026 GrillKit Contributors
+# SPDX-License-Identifier: Apache-2.0
+"""Test helpers for session creation."""
+
+from app.interview.domain.value_objects import SessionSelection
+from app.interview.repositories.uow import InterviewUnitOfWork
+from app.interview.schemas.interview import InterviewRead
+from app.interview.services.creation import SessionCreationService
+
+
+def create_session(
+ session: SessionSelection,
+ *,
+ locale: str = "en",
+) -> InterviewRead:
+ """Create a session inside an auto-commit application UoW.
+
+ Args:
+ session: Full session selection from setup.
+ locale: Locale for AI feedback and follow-ups.
+
+ Returns:
+ Read model for the created session.
+ """
+ with InterviewUnitOfWork(auto_commit=True) as uow:
+ return SessionCreationService(uow).create_session(session, locale=locale)
diff --git a/tests/interview/api/test_errors.py b/tests/interview/api/test_errors.py
index 3422dd3..6f72e88 100644
--- a/tests/interview/api/test_errors.py
+++ b/tests/interview/api/test_errors.py
@@ -5,14 +5,17 @@
from fastapi import HTTPException
import pytest
-from app.interview.api.errors import http_exception_from_domain_error, ws_error_payload
+from app.interview.api.errors import http_exception_from_domain_error
from app.interview.domain.exceptions import (
- AnswerNotFoundError,
InterviewNotActiveError,
InterviewNotFoundError,
- UnansweredAnswerNotFoundError,
)
from app.interview.services.ai_errors import ai_error_message_for_client
+from app.theory.api.ws_protocol import domain_error_to_wire
+from app.theory.domain.exceptions import (
+ TheoryTaskNotFoundError,
+ UnansweredTaskNotFoundError,
+)
def test_ai_error_message_model_not_found():
@@ -34,10 +37,10 @@ def test_ai_error_message_timeout():
assert "/config" in msg
-def test_ws_error_payload():
- """ws_error_payload wraps domain errors for WebSocket clients."""
+def test_domain_error_to_wire():
+ """domain_error_to_wire wraps domain errors for WebSocket clients."""
exc = InterviewNotFoundError("id-1")
- assert ws_error_payload(exc) == {
+ assert domain_error_to_wire(exc) == {
"type": "error",
"message": "Interview not found: id-1",
}
@@ -47,9 +50,9 @@ def test_ws_error_payload():
("exc", "status_code"),
[
(InterviewNotFoundError("id-1"), 404),
- (AnswerNotFoundError("id-1", "q1", 0), 404),
+ (TheoryTaskNotFoundError("id-1", "q1", 0), 404),
(InterviewNotActiveError("id-1"), 400),
- (UnansweredAnswerNotFoundError("id-1", "q1"), 400),
+ (UnansweredTaskNotFoundError("id-1", "q1"), 400),
],
)
def test_http_exception_from_domain_error(exc, status_code):
diff --git a/tests/interview/api/test_known_questions.py b/tests/interview/api/test_known_questions.py
new file mode 100644
index 0000000..c755947
--- /dev/null
+++ b/tests/interview/api/test_known_questions.py
@@ -0,0 +1,63 @@
+# Copyright 2026 GrillKit Contributors
+# SPDX-License-Identifier: Apache-2.0
+"""Tests for known questions HTTP API."""
+
+import json
+
+from app.interview.repositories.uow import InterviewUnitOfWork
+from app.interview.services.known_questions import KnownQuestionsService
+
+
+class TestKnownQuestionsApi:
+ """Tests for /known-questions endpoints."""
+
+ def test_list_empty(self, client, isolated_db) -> None:
+ """GET returns empty lists when nothing is marked."""
+ del isolated_db
+ response = client.get("/known-questions")
+ assert response.status_code == 200
+ assert response.json() == {"theory": [], "coding": []}
+
+ def test_mark_and_unmark(self, client, isolated_db) -> None:
+ """POST and DELETE update the stored known lists."""
+ del isolated_db
+ mark = client.post(
+ "/known-questions",
+ json={"branch": "theory", "item_id": "bas-001"},
+ )
+ assert mark.status_code == 200
+ assert mark.json()["theory"] == ["bas-001"]
+
+ repeat = client.post(
+ "/known-questions",
+ json={"branch": "theory", "item_id": "bas-001"},
+ )
+ assert repeat.status_code == 200
+ assert repeat.json()["theory"] == ["bas-001"]
+
+ unmark = client.request(
+ "DELETE",
+ "/known-questions",
+ content=json.dumps({"branch": "theory", "item_id": "bas-001"}),
+ headers={"Content-Type": "application/json"},
+ )
+ assert unmark.status_code == 200
+ assert unmark.json() == {"theory": [], "coding": []}
+
+ missing = client.request(
+ "DELETE",
+ "/known-questions",
+ content=json.dumps({"branch": "theory", "item_id": "bas-001"}),
+ headers={"Content-Type": "application/json"},
+ )
+ assert missing.status_code == 200
+ assert missing.json() == {"theory": [], "coding": []}
+
+ def test_manage_page_renders(self, client, isolated_db) -> None:
+ """Manage page returns HTML with marked IDs."""
+ del isolated_db
+ with InterviewUnitOfWork(auto_commit=True) as uow:
+ KnownQuestionsService(uow).mark_known("coding", "bas-001")
+ response = client.get("/known-questions/manage")
+ assert response.status_code == 200
+ assert "bas-001" in response.text
diff --git a/tests/interview/api/test_setup.py b/tests/interview/api/test_setup.py
index 912b57f..5d87b87 100644
--- a/tests/interview/api/test_setup.py
+++ b/tests/interview/api/test_setup.py
@@ -242,3 +242,23 @@ def test_setup_post_rejects_question_count_below_topics(self, client):
assert response.status_code == 200
assert "at least 2" in response.text
+
+
+class TestSetupExcludeKnown:
+ """Tests for exclude_known in setup selection JSON."""
+
+ def test_parse_session_json_reads_exclude_known(self):
+ """selection_json may disable known-question exclusion."""
+ from app.interview.services.rules.selection import parse_session_json
+
+ raw = (
+ '{"version":2,"session_mode":"theory_only","exclude_known":false,'
+ '"theory":{"enabled":true,"question_count":1,'
+ '"task_time_limit_seconds":null,'
+ '"sources":[{"track":"python","level":"junior",'
+ '"categories":["basics"]}]},'
+ '"coding":{"enabled":false,"question_count":0,'
+ '"task_time_limit_seconds":null,"sources":[]}}'
+ )
+ session = parse_session_json(raw)
+ assert session.exclude_known is False
diff --git a/tests/interview/repositories/test_interview.py b/tests/interview/repositories/test_interview.py
index b973790..d700447 100644
--- a/tests/interview/repositories/test_interview.py
+++ b/tests/interview/repositories/test_interview.py
@@ -11,9 +11,11 @@
from app.interview.domain.entities import Interview as DomainInterview
from app.interview.domain.value_objects import SessionSelection, TrackSelection
from app.interview.repositories.interview import InterviewRepository
+from app.interview.services.read_model import assemble_interview_read
from app.shared.infrastructure.database import Base
from app.shared.infrastructure.models import Answer, Interview
from app.shared.repositories.base import SqlAlchemyRepository
+from app.theory.repositories.theory_section import TheorySectionRepository
from tests.helpers.selection import minimal_selection_spec
from tests.helpers.theory_seed import attach_theory_section_to_answers
@@ -232,15 +234,18 @@ def test_get_aggregate_maps_domain_shell(self, db_session):
assert aggregate.id == "session-1"
assert aggregate.status == "active"
- def test_get_read_model_composes_theory_tasks(self, db_session):
- """get_read_model composes answers from the linked theory section."""
+ def test_load_interview_read_composes_theory_tasks(self, db_session):
+ """assemble_interview_read composes answers from the linked theory section."""
_create_test_interview(db_session)
_create_test_answer(db_session, question_id="q1", order=1)
- repo = InterviewRepository(db_session)
- read_model = repo.get_read_model("session-1")
+ interview_repo = InterviewRepository(db_session)
+ theory_repo = TheorySectionRepository(db_session)
+ shell = interview_repo.get_aggregate("session-1")
+ theory = theory_repo.get_aggregate("session-1")
+ assert shell is not None
+ read_model = assemble_interview_read(shell, theory, None)
- assert read_model is not None
assert read_model.question_count == 3
assert len(read_model.answers) == 1
assert read_model.answers[0].question_id == "q1"
diff --git a/tests/interview/repositories/test_known_questions.py b/tests/interview/repositories/test_known_questions.py
new file mode 100644
index 0000000..343c6e8
--- /dev/null
+++ b/tests/interview/repositories/test_known_questions.py
@@ -0,0 +1,53 @@
+# Copyright 2026 GrillKit Contributors
+# SPDX-License-Identifier: Apache-2.0
+"""Tests for known questions repository."""
+
+from app.interview.repositories.known_questions import KnownQuestionsRepository
+from app.interview.repositories.uow import InterviewUnitOfWork
+
+
+def test_mark_and_list_ids(isolated_db) -> None:
+ """Marking a question stores it for the matching branch."""
+ del isolated_db
+ with InterviewUnitOfWork() as uow:
+ repo = KnownQuestionsRepository(uow.session)
+ repo.mark("theory", "bas-001")
+ uow.commit()
+ assert repo.list_ids("theory") == frozenset({"bas-001"})
+ assert repo.list_ids("coding") == frozenset()
+
+
+def test_mark_is_idempotent(isolated_db) -> None:
+ """Repeated marks do not create duplicate rows."""
+ del isolated_db
+ with InterviewUnitOfWork() as uow:
+ repo = KnownQuestionsRepository(uow.session)
+ repo.mark("theory", "bas-001")
+ repo.mark("theory", "bas-001")
+ uow.commit()
+ assert repo.count() == 1
+
+
+def test_unmark_is_idempotent(isolated_db) -> None:
+ """Unmarking a missing row succeeds without error."""
+ del isolated_db
+ with InterviewUnitOfWork() as uow:
+ repo = KnownQuestionsRepository(uow.session)
+ repo.unmark("theory", "missing")
+ uow.commit()
+ assert repo.list_ids("theory") == frozenset()
+
+
+def test_list_all_grouped(isolated_db) -> None:
+ """list_all_grouped returns sorted IDs per branch."""
+ del isolated_db
+ with InterviewUnitOfWork() as uow:
+ repo = KnownQuestionsRepository(uow.session)
+ repo.mark("theory", "bas-002")
+ repo.mark("theory", "bas-001")
+ repo.mark("coding", "algo-001")
+ uow.commit()
+ grouped = repo.list_all_grouped()
+ assert grouped["theory"] == ["bas-001", "bas-002"]
+ assert grouped["coding"] == ["algo-001"]
+ assert repo.count() == 3
diff --git a/tests/interview/services/test_completion.py b/tests/interview/services/test_completion.py
index b41c207..08c750a 100644
--- a/tests/interview/services/test_completion.py
+++ b/tests/interview/services/test_completion.py
@@ -6,7 +6,7 @@
import pytest
-from app.coding.repositories.uow import CodingUnitOfWork
+from app.interview.repositories.uow import InterviewUnitOfWork
from app.interview.services.completion import SessionCompletionService
from app.interview.services.query import InterviewQuery
from app.shared.infrastructure.models import Answer, Interview
@@ -17,6 +17,13 @@
from tests.helpers.selection import minimal_selection_spec
+async def _complete_session(interview_id: str, provider: FakeProvider):
+ """Run session completion inside an auto-commit application UoW."""
+ with InterviewUnitOfWork(auto_commit=True) as uow:
+ service = SessionCompletionService(uow)
+ return await service.complete_session(interview_id, provider)
+
+
@pytest.mark.asyncio
async def test_complete_interview_persists_completed_status(isolated_db):
"""After completion, interview is stored as completed with score and time."""
@@ -60,14 +67,14 @@ async def test_complete_interview_persists_completed_status(isolated_db):
new_callable=AsyncMock,
return_value=mock_eval,
):
- events = await SessionCompletionService.complete_session(
+ events = await _complete_session(
interview_id,
- provider=FakeProvider([]),
+ FakeProvider([]),
)
assert len(events) == 2
- reloaded = InterviewQuery.get_interview(interview_id)
+ reloaded = InterviewQuery.load(interview_id)
assert reloaded is not None
assert reloaded.status == "completed"
assert reloaded.score == 5
@@ -82,7 +89,7 @@ async def test_complete_interview_persists_completed_status(isolated_db):
async def test_complete_coding_only_session_includes_coding_breakdown(isolated_db):
"""Completion merges coding section scores into the session breakdown."""
interview_id, _task_id = seed_active_coding_interview("coding-completion-1")
- with CodingUnitOfWork(auto_commit=True) as uow:
+ with InterviewUnitOfWork(auto_commit=True) as uow:
section = uow.coding_sections.get_aggregate(interview_id)
assert section is not None
task = section.find_first_unsubmitted()
@@ -112,16 +119,83 @@ async def test_complete_coding_only_session_includes_coding_breakdown(isolated_d
new_callable=AsyncMock,
return_value=mock_eval,
):
- events = await SessionCompletionService.complete_session(
+ events = await _complete_session(
interview_id,
- provider=FakeProvider([]),
+ FakeProvider([]),
)
assert len(events) == 2
- reloaded = InterviewQuery.get_interview(interview_id)
+ reloaded = InterviewQuery.load(interview_id)
assert reloaded is not None
assert reloaded.status == "completed"
assert reloaded.score == 4
coding_breakdown = reloaded.overall_feedback["score_breakdown"]["coding"]
assert coding_breakdown["score"] == 4
assert coding_breakdown["questions"]["cod-001"]["score"] == 4
+
+
+@pytest.mark.asyncio
+async def test_complete_session_preserves_partial_theory_scores(isolated_db):
+ """Early completion keeps earned points when not every theory task is answered."""
+ interview_id = "completion-partial-theory-1"
+
+ persist_interview_with_answers(
+ Interview(
+ id=interview_id,
+ locale="en",
+ selection_spec=minimal_selection_spec(categories=["basics"]),
+ status="active",
+ ),
+ [
+ Answer(
+ question_id="q1",
+ order=1,
+ round=0,
+ question_text="What is Python?",
+ answer_text="A programming language",
+ score=4,
+ ),
+ Answer(
+ question_id="q2",
+ order=2,
+ round=0,
+ question_text="What is a list?",
+ ),
+ Answer(
+ question_id="q3",
+ order=3,
+ round=0,
+ question_text="What is a dict?",
+ ),
+ ],
+ question_count=3,
+ )
+
+ mock_eval = InterviewEvaluation(
+ overall_feedback="Partial session",
+ strengths_summary=[],
+ topics_to_review=[],
+ score_breakdown={},
+ )
+
+ with patch(
+ "app.interview.services.completion.SessionEvaluatorService.evaluate_session",
+ new_callable=AsyncMock,
+ return_value=mock_eval,
+ ):
+ events = await _complete_session(
+ interview_id,
+ FakeProvider([]),
+ )
+
+ assert len(events) == 2
+ reloaded = InterviewQuery.load(interview_id)
+ assert reloaded is not None
+ assert reloaded.status == "completed"
+ assert reloaded.score == 4
+
+ theory_breakdown = reloaded.overall_feedback["score_breakdown"]["theory"]
+ assert theory_breakdown["skipped"] is True
+ assert theory_breakdown["score"] == 4
+ assert theory_breakdown["max"] == 5
+ assert theory_breakdown["questions"]["q1"]["score"] == 4
diff --git a/tests/interview/services/test_creation.py b/tests/interview/services/test_creation.py
index f782678..ff7f329 100644
--- a/tests/interview/services/test_creation.py
+++ b/tests/interview/services/test_creation.py
@@ -6,7 +6,6 @@
import pytest
-from app.coding.repositories.uow import CodingUnitOfWork
from app.interview.domain.value_objects import (
InterviewSelection,
SectionBranchSpec,
@@ -14,9 +13,9 @@
TrackSelection,
)
from app.interview.repositories.uow import InterviewUnitOfWork
-from app.interview.services.creation import SessionCreationService
from app.interview.services.query import InterviewQuery
from app.interview.services.rules.selection import get_interview_selection
+from tests.helpers.session_creation import create_session
def _session_from_selection(
@@ -58,7 +57,7 @@ def test_create_interview_persists_questions(
del temp_questions_dir
monkeypatch.setattr("random.shuffle", lambda items: None)
- interview = SessionCreationService.create_session(
+ interview = create_session(
_session_from_selection(_single_selection(), question_count=1),
locale="en",
)
@@ -73,7 +72,7 @@ def test_create_interview_persists_questions(
assert len(question_ids) == 1
assert question_ids[0] == "ds-001"
- reloaded = InterviewQuery.get_interview(interview.id)
+ reloaded = InterviewQuery.load(interview.id)
assert reloaded is not None
assert len(reloaded.answers) == 1
answer = reloaded.answers[0]
@@ -93,7 +92,7 @@ def test_create_interview_with_timer_starts_first_round(
del temp_questions_dir
monkeypatch.setattr("random.shuffle", lambda items: None)
- interview = SessionCreationService.create_session(
+ interview = create_session(
_session_from_selection(
_single_selection(),
question_count=1,
@@ -104,7 +103,7 @@ def test_create_interview_with_timer_starts_first_round(
assert interview.question_time_limit_seconds == 180
- reloaded = InterviewQuery.get_interview(interview.id)
+ reloaded = InterviewQuery.load(interview.id)
assert reloaded is not None
assert len(reloaded.answers) == 1
assert reloaded.answers[0].started_at is not None
@@ -114,7 +113,7 @@ def test_create_interview_unknown_category_raises(isolated_db, temp_questions_di
"""Missing category in the bank raises ValueError."""
del temp_questions_dir
with pytest.raises(ValueError, match="Unknown topic"):
- SessionCreationService.create_session(
+ create_session(
_session_from_selection(
_single_selection(categories=("nonexistent",)),
question_count=1,
@@ -125,7 +124,7 @@ def test_create_interview_unknown_category_raises(isolated_db, temp_questions_di
def test_create_interview_expunged_instance_is_usable(isolated_db, temp_questions_dir):
"""Returned interview is detached but id and fields remain readable."""
del temp_questions_dir
- interview = SessionCreationService.create_session(
+ interview = create_session(
_session_from_selection(
_single_selection(categories=("algorithms",)),
question_count=1,
@@ -157,7 +156,7 @@ def test_create_multi_topic_interview(isolated_db, temp_questions_dir, monkeypat
),
)
)
- interview = SessionCreationService.create_session(
+ interview = create_session(
_session_from_selection(selection, question_count=2),
)
@@ -166,7 +165,7 @@ def test_create_multi_topic_interview(isolated_db, temp_questions_dir, monkeypat
assert "data-structures" in interview.selection_spec
assert "algorithms" in interview.selection_spec
- reloaded = InterviewQuery.get_interview(interview.id)
+ reloaded = InterviewQuery.load(interview.id)
assert reloaded is not None
parsed = get_interview_selection(reloaded)
assert len(parsed.sources[0].categories) == 2
@@ -197,13 +196,13 @@ def test_create_coding_only_session(isolated_db, monkeypatch) -> None:
),
),
)
- interview = SessionCreationService.create_session(session, locale="en")
+ interview = create_session(session, locale="en")
assert interview.id
assert interview.status == "active"
assert "coding_only" in interview.selection_spec
- with CodingUnitOfWork() as uow:
+ with InterviewUnitOfWork() as uow:
section = uow.coding_sections.get_aggregate(interview.id)
assert section is not None
assert section.status == "active"
@@ -247,9 +246,58 @@ def test_create_theory_then_coding_session_pending_coding(
),
),
)
- interview = SessionCreationService.create_session(session, locale="en")
+ interview = create_session(session, locale="en")
- with CodingUnitOfWork() as uow:
+ with InterviewUnitOfWork() as uow:
section = uow.coding_sections.get_aggregate(interview.id)
assert section is not None
assert section.status == "pending"
+
+
+def test_create_session_excludes_known_questions(
+ isolated_db, temp_questions_dir, monkeypatch
+):
+ """Sessions with exclude_known load marked IDs from the database."""
+ from pathlib import Path
+
+ import yaml
+
+ category_path = (
+ Path(temp_questions_dir) / "python" / "junior" / "data-structures.yaml"
+ )
+ with open(category_path) as handle:
+ content = yaml.safe_load(handle)
+ content["questions"].append(
+ {
+ "id": "ds-002",
+ "type": "knowledge",
+ "difficulty": 1,
+ "question": {"text": "Second question", "code": None},
+ }
+ )
+ with open(category_path, "w") as handle:
+ yaml.dump(content, handle)
+
+ monkeypatch.setattr("random.shuffle", lambda items: None)
+
+ with InterviewUnitOfWork(auto_commit=True) as uow:
+ from app.interview.services.known_questions import KnownQuestionsService
+
+ KnownQuestionsService(uow).mark_known("theory", "ds-001")
+
+ session = SessionSelection.theory_only(
+ sources=(
+ TrackSelection(
+ track="python",
+ level="junior",
+ categories=("data-structures",),
+ ),
+ ),
+ question_count=1,
+ exclude_known=True,
+ )
+ interview = create_session(session, locale="en")
+
+ question_ids = json.loads(interview.question_ids)
+ assert len(question_ids) == 1
+ assert question_ids[0] == "ds-002"
diff --git a/tests/interview/services/test_dashboard.py b/tests/interview/services/test_dashboard.py
index ba56239..1e7fb3e 100644
--- a/tests/interview/services/test_dashboard.py
+++ b/tests/interview/services/test_dashboard.py
@@ -14,11 +14,12 @@
SessionSelection,
TrackSelection,
)
-from app.interview.repositories.interview import InterviewRepository
from app.interview.repositories.mappers import interview_read_from_orm
+from app.interview.repositories.uow import InterviewUnitOfWork
from app.interview.schemas.dashboard import DashboardRowRead
from app.interview.schemas.interview import InterviewRead
from app.interview.services.dashboard import DashboardBuilder
+from app.interview.services.read_model import load_recent_interview_reads
from app.shared.infrastructure.database import Base
from app.shared.infrastructure.models import Interview
from tests.helpers.selection import minimal_selection_spec
@@ -92,7 +93,7 @@ def test_interview_display_title_coding_only():
def test_list_recent_ordering(db_session):
- """list_recent_read_models returns completed before older active when newer."""
+ """load_recent_interview_reads returns completed before older active when newer."""
now = datetime.now(UTC)
active = Interview(
id="active-1",
@@ -122,8 +123,13 @@ def test_list_recent_ordering(db_session):
)
db_session.commit()
- repo = InterviewRepository(db_session)
- recent = repo.list_recent_read_models(limit=20)
+ class TestUow(InterviewUnitOfWork):
+ def __init__(self) -> None:
+ super().__init__()
+ self._session = db_session
+
+ with TestUow() as uow:
+ recent = load_recent_interview_reads(uow, limit=20)
assert [i.id for i in recent] == ["done-1", "active-1"]
@@ -191,27 +197,21 @@ def test_list_dashboard_rows(monkeypatch):
started_at=now,
)
- class FakeInterviews:
- @staticmethod
- def list_recent_read_models(limit=20):
- return [completed, active]
-
- class FakeUow:
- def __init__(self, auto_commit=False):
- self.interviews = FakeInterviews()
-
- def __enter__(self):
- return self
-
- def __exit__(self, *args):
- return False
-
monkeypatch.setattr(
- "app.interview.services.dashboard.InterviewUnitOfWork",
- FakeUow,
+ "app.interview.services.dashboard.load_recent_interview_reads",
+ lambda uow, limit=20: [completed, active],
)
- rows = DashboardBuilder.list_rows(limit=20)
+ class _FakeCodingSections:
+ @staticmethod
+ def get_aggregates_by_interview_ids(interview_ids):
+ del interview_ids
+ return {}
+
+ class _FakeUow:
+ coding_sections = _FakeCodingSections()
+
+ rows = DashboardBuilder(_FakeUow()).list_rows(limit=20) # type: ignore[arg-type]
assert len(rows) == 2
assert rows[0].title == "Python Interview"
assert rows[0].session_mode_label == "Theory"
diff --git a/tests/interview/services/test_known_questions.py b/tests/interview/services/test_known_questions.py
new file mode 100644
index 0000000..7ccfbc9
--- /dev/null
+++ b/tests/interview/services/test_known_questions.py
@@ -0,0 +1,19 @@
+# Copyright 2026 GrillKit Contributors
+# SPDX-License-Identifier: Apache-2.0
+"""Tests for known questions service."""
+
+from app.interview.repositories.uow import InterviewUnitOfWork
+from app.interview.services.known_questions import KnownQuestionsService
+
+
+def test_mark_unmark_and_list(isolated_db) -> None:
+ """Service delegates mark, unmark, and list to the repository."""
+ del isolated_db
+ with InterviewUnitOfWork(auto_commit=True) as uow:
+ service = KnownQuestionsService(uow)
+ service.mark_known("theory", "bas-001")
+ assert service.list_ids("theory") == frozenset({"bas-001"})
+ assert service.count() == 1
+ service.unmark("theory", "bas-001")
+ assert service.list_ids("theory") == frozenset()
+ assert service.list_all() == {"theory": [], "coding": []}
diff --git a/tests/interview/services/test_page.py b/tests/interview/services/test_page.py
index b27672b..885900f 100644
--- a/tests/interview/services/test_page.py
+++ b/tests/interview/services/test_page.py
@@ -42,8 +42,9 @@ def _session() -> InterviewRead:
)
-def test_build_page_context_sets_audio_flag_from_catalog(monkeypatch):
+def test_build_page_context_sets_audio_flag_from_catalog(monkeypatch, isolated_db):
"""Page context reflects whether the configured LLM accepts audio input."""
+ del isolated_db
monkeypatch.setattr(
"app.interview.services.page.LLMCatalogService.get_model",
lambda preset_id: type(
diff --git a/tests/interview/services/test_phases.py b/tests/interview/services/test_phases.py
index a3d8712..698a8a3 100644
--- a/tests/interview/services/test_phases.py
+++ b/tests/interview/services/test_phases.py
@@ -6,7 +6,6 @@
from unittest.mock import AsyncMock, patch
from app.coding.domain.value_objects import CodingRunResult
-from app.coding.repositories.uow import CodingUnitOfWork
from app.coding.services.page import CodingPageService
from app.coding.services.section import CodingSectionService
from app.interview.domain.value_objects import (
@@ -14,10 +13,10 @@
SessionSelection,
TrackSelection,
)
-from app.interview.services.creation import SessionCreationService
+from app.interview.repositories.uow import InterviewUnitOfWork
from app.interview.services.phases import SessionPhaseOrchestrator
-from app.theory.repositories.uow import TheoryUnitOfWork
from app.theory.services.section import TheorySectionService
+from tests.helpers.session_creation import create_session
def _theory_then_coding_session() -> SessionSelection:
@@ -53,7 +52,7 @@ def _theory_then_coding_session() -> SessionSelection:
def _complete_theory_section(interview_id: str) -> None:
"""Mark every theory task in a section as answered."""
- with TheoryUnitOfWork(auto_commit=True) as uow:
+ with InterviewUnitOfWork(auto_commit=True) as uow:
section = uow.theory_sections.get_aggregate(interview_id)
assert section is not None
tasks = tuple(
@@ -69,12 +68,12 @@ def test_pending_coding_section_does_not_start_timer_at_creation(
del temp_questions_dir
monkeypatch.setattr("random.shuffle", lambda items: None)
- interview = SessionCreationService.create_session(
+ interview = create_session(
_theory_then_coding_session(),
locale="en",
)
- with CodingUnitOfWork() as uow:
+ with InterviewUnitOfWork() as uow:
section = uow.coding_sections.get_aggregate(interview.id)
assert section is not None
assert section.status == "pending"
@@ -115,14 +114,14 @@ def test_coding_then_theory_defers_theory_timer_until_theory_phase(
),
),
)
- interview = SessionCreationService.create_session(session, locale="en")
+ interview = create_session(session, locale="en")
- with TheoryUnitOfWork() as uow:
+ with InterviewUnitOfWork() as uow:
theory = uow.theory_sections.get_aggregate(interview.id)
assert theory is not None
assert theory.tasks[0].started_at is None
- with CodingUnitOfWork() as uow:
+ with InterviewUnitOfWork() as uow:
coding = uow.coding_sections.get_aggregate(interview.id)
assert coding is not None
assert coding.status == "active"
@@ -136,23 +135,29 @@ def test_activate_pending_promotes_coding_after_theory_complete(
del temp_questions_dir
monkeypatch.setattr("random.shuffle", lambda items: None)
- interview = SessionCreationService.create_session(
+ interview = create_session(
_theory_then_coding_session(),
locale="en",
)
- with CodingUnitOfWork() as uow:
+ with InterviewUnitOfWork() as uow:
section = uow.coding_sections.get_aggregate(interview.id)
assert section is not None
assert section.status == "pending"
- assert CodingSectionService.activate_pending(interview.id) is False
+ with InterviewUnitOfWork(auto_commit=True) as uow:
+ coding = CodingSectionService(uow)
+ assert coding.activate_pending(interview.id) is False
_complete_theory_section(interview.id)
- assert TheorySectionService.is_complete(interview.id) is True
+ with InterviewUnitOfWork() as uow:
+ theory = TheorySectionService(uow)
+ assert theory.is_complete(interview.id) is True
- assert CodingSectionService.activate_pending(interview.id) is True
+ with InterviewUnitOfWork(auto_commit=True) as uow:
+ coding = CodingSectionService(uow)
+ assert coding.activate_pending(interview.id) is True
- with CodingUnitOfWork() as uow:
+ with InterviewUnitOfWork() as uow:
section = uow.coding_sections.get_aggregate(interview.id)
assert section is not None
assert section.status == "active"
@@ -166,16 +171,22 @@ def test_notify_theory_complete_activates_pending_coding(
del temp_questions_dir
monkeypatch.setattr("random.shuffle", lambda items: None)
- interview = SessionCreationService.create_session(
+ interview = create_session(
_theory_then_coding_session(),
locale="en",
)
_complete_theory_section(interview.id)
- with patch.object(TheorySectionService, "on_phase_complete"):
- SessionPhaseOrchestrator.notify_section_complete(interview.id, "theory")
+ with (
+ patch.object(TheorySectionService, "on_phase_complete"),
+ InterviewUnitOfWork(auto_commit=True) as uow,
+ ):
+ SessionPhaseOrchestrator(uow).notify_section_complete(
+ interview.id,
+ "theory",
+ )
- with CodingUnitOfWork() as uow:
+ with InterviewUnitOfWork() as uow:
section = uow.coding_sections.get_aggregate(interview.id)
assert section is not None
assert section.status == "active"
@@ -188,14 +199,15 @@ def test_coding_run_works_after_theory_phase_activation(
del temp_questions_dir
monkeypatch.setattr("random.shuffle", lambda items: None)
- interview = SessionCreationService.create_session(
+ interview = create_session(
_theory_then_coding_session(),
locale="en",
)
_complete_theory_section(interview.id)
- CodingPageService.activate_timer(interview.id)
+ with InterviewUnitOfWork(auto_commit=True) as uow:
+ CodingPageService(uow).activate_timer(interview.id)
- with CodingUnitOfWork() as uow:
+ with InterviewUnitOfWork() as uow:
section = uow.coding_sections.get_aggregate(interview.id)
assert section is not None
task_id = section.tasks[0].task_id
@@ -230,7 +242,7 @@ def test_interview_page_switches_to_coding_template_after_theory(
del temp_questions_dir
monkeypatch.setattr("random.shuffle", lambda items: None)
- interview = SessionCreationService.create_session(
+ interview = create_session(
_theory_then_coding_session(),
locale="en",
)
diff --git a/tests/interview/services/test_results_page.py b/tests/interview/services/test_results_page.py
index 846a168..bf3178e 100644
--- a/tests/interview/services/test_results_page.py
+++ b/tests/interview/services/test_results_page.py
@@ -3,6 +3,7 @@
"""Tests for SessionResultsPageService."""
from app.interview.repositories.uow import InterviewUnitOfWork
+from app.interview.services.read_model import load_interview_read
from app.interview.services.results_page import SessionResultsPageService
from tests.helpers.completed_session_seed import seed_completed_theory_interview
@@ -11,9 +12,9 @@ def test_session_results_page_service_builds_section_cards(isolated_db) -> None:
"""Results hub includes enabled section cards with review links."""
interview_id = seed_completed_theory_interview("results-hub-1")
with InterviewUnitOfWork() as uow:
- interview = uow.interviews.get_read_model(interview_id)
- assert interview is not None
- context = SessionResultsPageService.build_context(interview)
+ interview = load_interview_read(uow, interview_id)
+ assert interview is not None
+ context = SessionResultsPageService(uow).build_context(interview)
assert context is not None
assert context.theory_review_url == f"/interview/{interview_id}/theory"
assert len(context.section_cards) == 1
diff --git a/tests/interview/services/test_selection.py b/tests/interview/services/test_selection.py
index 56eceaa..45de6c8 100644
--- a/tests/interview/services/test_selection.py
+++ b/tests/interview/services/test_selection.py
@@ -119,6 +119,97 @@ def test_orders_by_track_blocks(self, monkeypatch):
assert db_indices
assert max(py_indices) < min(db_indices)
+ def test_excludes_known_ids(self):
+ """Excluded IDs are removed before planning."""
+ selection = InterviewSelection(
+ sources=(
+ TrackSelection(
+ track="python",
+ level="junior",
+ categories=("basics",),
+ ),
+ )
+ )
+ pools = [
+ TrackQuestionPools(
+ source=selection.sources[0],
+ full_pool=(
+ _question("keep-1"),
+ _question("skip-1"),
+ _question("keep-2"),
+ ),
+ category_pools={
+ "basics": (
+ _question("keep-1"),
+ _question("skip-1"),
+ _question("keep-2"),
+ ),
+ },
+ )
+ ]
+ plan = plan_questions(
+ selection,
+ 2,
+ pools,
+ excluded_ids=frozenset({"skip-1"}),
+ )
+ assert len(plan) == 2
+ assert all(question.id != "skip-1" for question in plan)
+
+ def test_raises_when_category_fully_excluded(self):
+ """All-known category raises a descriptive error."""
+ selection = InterviewSelection(
+ sources=(
+ TrackSelection(
+ track="python",
+ level="junior",
+ categories=("basics",),
+ ),
+ )
+ )
+ pools = [
+ TrackQuestionPools(
+ source=selection.sources[0],
+ full_pool=(_question("only-1"),),
+ category_pools={"basics": (_question("only-1"),)},
+ )
+ ]
+ with pytest.raises(ValueError, match="marked as known"):
+ plan_questions(
+ selection,
+ 1,
+ pools,
+ excluded_ids=frozenset({"only-1"}),
+ )
+
+ def test_raises_when_not_enough_unfamiliar_questions(self):
+ """Planning fails when too few questions remain after exclusion."""
+ selection = InterviewSelection(
+ sources=(
+ TrackSelection(
+ track="python",
+ level="junior",
+ categories=("basics",),
+ ),
+ )
+ )
+ pools = [
+ TrackQuestionPools(
+ source=selection.sources[0],
+ full_pool=(_question("keep-1"), _question("skip-1")),
+ category_pools={
+ "basics": (_question("keep-1"), _question("skip-1")),
+ },
+ )
+ ]
+ with pytest.raises(ValueError, match="Not enough unfamiliar questions"):
+ plan_questions(
+ selection,
+ 2,
+ pools,
+ excluded_ids=frozenset({"skip-1"}),
+ )
+
class TestSelectionSpec:
"""Tests for selection_spec JSON round-trip."""
@@ -145,6 +236,23 @@ def test_v2_session_round_trip(self):
assert '"version":2' in raw
assert '"session_mode":"theory_only"' in raw
+ def test_exclude_known_round_trip(self):
+ """session_to_spec preserves exclude_known flag."""
+ session = SessionSelection.theory_only(
+ sources=(
+ TrackSelection(
+ track="python",
+ level="junior",
+ categories=("basics",),
+ ),
+ ),
+ exclude_known=False,
+ )
+ raw = session_to_spec(session)
+ parsed = parse_session_spec(raw)
+ assert parsed.exclude_known is False
+ assert '"exclude_known":false' in raw
+
def test_v1_theory_sources_compat(self):
"""parse_selection_spec extracts theory sources from legacy v1 JSON."""
selection = InterviewSelection(
diff --git a/tests/platform/services/test_llm_catalog.py b/tests/platform/services/test_llm_catalog.py
index 85b526a..f6084ff 100644
--- a/tests/platform/services/test_llm_catalog.py
+++ b/tests/platform/services/test_llm_catalog.py
@@ -8,8 +8,9 @@
from app.ai.llm_models import (
CUSTOM_PRESET_ID,
+ generate_model_id,
normalize_model_id,
- validate_new_model_id,
+ slugify_model_id,
)
from app.platform.schemas import NewLLMModel
from app.platform.services.llm_catalog import LLMCatalogService
@@ -38,57 +39,58 @@ def test_add_user_model_persists_accepts_audio_input(self, llm_catalog_path):
"""Audio capability flag round-trips through llm_models.json."""
entry = LLMCatalogService.add_user_model(
NewLLMModel(
- model_id="audio",
display_name="Audio API",
base_url="https://api.example.com/v1",
model="gpt-4o-audio",
accepts_audio_input=True,
)
)
+ assert entry.id == "audio-api"
assert entry.accepts_audio_input is True
saved = json.loads(llm_catalog_path.read_text())
- assert saved["models"]["audio"]["accepts_audio_input"] is True
+ assert saved["models"]["audio-api"]["accepts_audio_input"] is True
def test_add_user_model_persists_api_key(self, llm_catalog_path):
"""Models can store an API key in llm_models.json."""
entry = LLMCatalogService.add_user_model(
NewLLMModel(
- model_id="cloud",
display_name="Cloud API",
base_url="https://api.example.com/v1",
model="gpt-4",
api_key="secret-key",
)
)
+ assert entry.id == "cloud-api"
assert entry.api_key == "secret-key"
saved = json.loads(llm_catalog_path.read_text())
- assert saved["models"]["cloud"]["api_key"] == "secret-key"
- assert saved["models"]["cloud"]["base_url"] == "https://api.example.com/v1"
+ assert saved["models"]["cloud-api"]["api_key"] == "secret-key"
+ assert saved["models"]["cloud-api"]["base_url"] == "https://api.example.com/v1"
def test_add_user_model_sets_selected(self, llm_catalog_path):
"""Adding a model selects it in llm_models.json."""
LLMCatalogService.add_user_model(
NewLLMModel(
- model_id="my-work",
display_name="Work API",
base_url="http://192.168.1.10:11434/v1",
model="deepseek-coder-v2:16b",
)
)
saved = json.loads(llm_catalog_path.read_text())
- assert saved["selected"] == "my-work"
+ assert saved["selected"] == "work-api"
- def test_add_user_model_rejects_duplicate_id(self, llm_catalog_path):
- """Duplicate model ids are rejected."""
+ def test_add_user_model_generates_unique_id_for_duplicates(self, llm_catalog_path):
+ """Duplicate display names get distinct auto-generated ids."""
payload = NewLLMModel(
- model_id="cloud",
display_name="Cloud",
base_url="https://api.example.com/v1",
model="gpt-4",
)
- LLMCatalogService.add_user_model(payload)
- with pytest.raises(ValueError, match="already exists"):
- LLMCatalogService.add_user_model(payload)
+ first = LLMCatalogService.add_user_model(payload)
+ second = LLMCatalogService.add_user_model(payload)
+ assert first.id == "cloud"
+ assert second.id == "cloud-2"
+ saved = json.loads(llm_catalog_path.read_text())
+ assert set(saved["models"]) == {"cloud", "cloud-2"}
def test_normalize_model_id_rejects_custom(self, llm_catalog_path):
"""Custom sentinel is not a valid catalog selection."""
@@ -126,9 +128,24 @@ def test_get_model_strips_trailing_slash_from_base_url(self, llm_catalog_path):
class TestLLMModelHelpers:
- """Tests for id validation."""
+ """Tests for id slugification and generation."""
+
+ def test_slugify_model_id_normalizes_text(self):
+ """Display names become lowercase hyphenated slugs."""
+ assert slugify_model_id(" Work GPT-4!! ") == "work-gpt-4"
+
+ def test_slugify_model_id_empty_for_symbols_only(self):
+ """Names without usable characters yield an empty slug."""
+ assert slugify_model_id("###") == ""
+
+ def test_generate_model_id_falls_back_when_empty(self):
+ """Empty slugs fall back to the default base id."""
+ assert generate_model_id("###", set()) == "model"
+
+ def test_generate_model_id_avoids_reserved_sentinel(self):
+ """The reserved custom sentinel is never used verbatim."""
+ assert generate_model_id("custom", set()) == f"{CUSTOM_PRESET_ID}-model"
- def test_validate_new_model_id_rejects_custom_sentinel(self):
- """Reserved custom sentinel cannot become a catalog id."""
- with pytest.raises(ValueError, match="reserved"):
- validate_new_model_id(CUSTOM_PRESET_ID)
+ def test_generate_model_id_appends_suffix_on_collision(self):
+ """Existing ids force a numeric suffix."""
+ assert generate_model_id("Cloud", {"cloud", "cloud-2"}) == "cloud-3"
diff --git a/tests/question_voice/api/test_tts.py b/tests/question_voice/api/test_tts.py
index 1eaa37e..7f49212 100644
--- a/tests/question_voice/api/test_tts.py
+++ b/tests/question_voice/api/test_tts.py
@@ -8,10 +8,10 @@
import pytest
-from app.interview.services.creation import SessionCreationService
from app.interview.services.query import InterviewQuery
from app.platform.services.config import AppConfig
from app.question_voice.schemas import PiperVoiceStatusRead
+from tests.helpers.session_creation import create_session
@pytest.fixture
@@ -208,7 +208,7 @@ def test_question_audio_requires_voice_enabled(
TrackSelection,
)
- interview = SessionCreationService.create_session(
+ interview = create_session(
SessionSelection.theory_only(
sources=(
TrackSelection(
@@ -245,7 +245,7 @@ def test_question_audio_streams_cached_wav(
TrackSelection,
)
- interview = SessionCreationService.create_session(
+ interview = create_session(
SessionSelection.theory_only(
sources=(
TrackSelection(
@@ -258,7 +258,7 @@ def test_question_audio_streams_cached_wav(
),
locale="en",
)
- reloaded = InterviewQuery.get_interview(interview.id)
+ reloaded = InterviewQuery.load(interview.id)
assert reloaded is not None
answer = reloaded.answers[0]
wav_path = tmp_path / "question.wav"
diff --git a/tests/shared/infrastructure/test_audio_wav.py b/tests/shared/infrastructure/test_audio_wav.py
index 5b53c86..3cfbc02 100644
--- a/tests/shared/infrastructure/test_audio_wav.py
+++ b/tests/shared/infrastructure/test_audio_wav.py
@@ -14,7 +14,6 @@
pcm16le_to_float32,
validate_wav_bytes,
wav_bytes_to_float32,
- wav_duration_sec,
)
@@ -100,17 +99,3 @@ def test_invalid_wav_raises(self) -> None:
"""Invalid payloads fail before decoding."""
with pytest.raises(ValueError, match="valid WAV"):
wav_bytes_to_float32(b"not-a-wav")
-
-
-class TestWavDurationSec:
- """Tests for duration helper."""
-
- def test_wav_duration_sec_matches_payload(self) -> None:
- """Duration helper reports the encoded length."""
- payload = minimal_wav_bytes(duration_sec=0.2)
- duration = wav_duration_sec(payload)
- assert 0.15 <= duration <= 0.25
-
- def test_invalid_payload_returns_zero(self) -> None:
- """Unparseable payloads report zero duration."""
- assert wav_duration_sec(b"invalid") == 0.0
diff --git a/tests/shared/infrastructure/test_uow.py b/tests/shared/infrastructure/test_uow.py
index 1a26ec7..017c484 100644
--- a/tests/shared/infrastructure/test_uow.py
+++ b/tests/shared/infrastructure/test_uow.py
@@ -171,6 +171,15 @@ def test_lazy_session_initialization(self, patch_session_local):
_ = uow.session # trigger creation
assert uow._session is not None
+ def test_code_run_attempts_accessor(self, patch_session_local):
+ """Test that ``.code_run_attempts`` returns the run attempt repository."""
+ with InterviewUnitOfWork() as uow:
+ from app.coding.repositories.code_run_attempt import (
+ CodeRunAttemptRepository,
+ )
+
+ assert isinstance(uow.code_run_attempts, CodeRunAttemptRepository)
+
def test_lazy_repository_initialization(self, patch_session_local):
"""Test that the interview repository is created lazily."""
uow = InterviewUnitOfWork()
diff --git a/tests/speech/api/test_dictation_ws.py b/tests/speech/api/test_dictation_ws.py
index 2732387..9301a39 100644
--- a/tests/speech/api/test_dictation_ws.py
+++ b/tests/speech/api/test_dictation_ws.py
@@ -46,7 +46,7 @@ def test_rejects_when_model_not_loaded(self, client):
"""Connection closes with error when speech_transcriber is absent."""
with (
patch(
- "app.interview.services.query.InterviewQuery.get_interview",
+ "app.interview.services.query.InterviewQuery.load",
return_value=_active_interview(),
),
client.websocket_connect("/interview/test-session/dictation") as ws,
@@ -63,7 +63,7 @@ def test_start_stop_returns_final_text(self, client):
with (
patch(
- "app.interview.services.query.InterviewQuery.get_interview",
+ "app.interview.services.query.InterviewQuery.load",
return_value=_active_interview(),
),
patch(
@@ -87,7 +87,7 @@ def test_rejects_completed_interview(self, client):
interview.status = "completed"
with (
patch(
- "app.interview.services.query.InterviewQuery.get_interview",
+ "app.interview.services.query.InterviewQuery.load",
return_value=interview,
),
client.websocket_connect("/interview/test-session/dictation") as ws,
diff --git a/tests/theory/api/test_audio_answer.py b/tests/theory/api/test_audio_answer.py
index e41a32c..090f397 100644
--- a/tests/theory/api/test_audio_answer.py
+++ b/tests/theory/api/test_audio_answer.py
@@ -225,9 +225,10 @@ class TestInterviewAudioAnswerPage:
"""Tests for audio answer controls on the interview page."""
def test_interview_page_shows_audio_controls_when_enabled(
- self, client, monkeypatch
+ self, client, monkeypatch, isolated_db
):
"""Record / Send audio buttons render when LLM and Whisper are ready."""
+ del isolated_db
interview = _active_interview_read("audio-ui-1")
monkeypatch.setattr(
"app.interview.services.page.LLMCatalogService.get_model",
@@ -254,7 +255,7 @@ def test_interview_page_shows_audio_controls_when_enabled(
with (
patch(
- "app.interview.api.routes.SessionPageService.prepare_page",
+ "app.interview.services.page.SessionPageService.prepare_page",
new=AsyncMock(
return_value=SessionPageRender(
redirect_url=None,
@@ -270,8 +271,11 @@ def test_interview_page_shows_audio_controls_when_enabled(
assert 'data-audio-answer-enabled="true"' in response.text
assert "interview_audio_answer.js" in response.text
- def test_interview_page_hides_audio_controls_without_catalog_flag(self, client):
+ def test_interview_page_hides_audio_controls_without_catalog_flag(
+ self, client, isolated_db
+ ):
"""Audio controls stay hidden when the configured model is text-only."""
+ del isolated_db
interview = _active_interview_read("audio-ui-2")
page_context = SessionPageService.build_page_context(
interview,
@@ -286,7 +290,7 @@ def test_interview_page_hides_audio_controls_without_catalog_flag(self, client):
with (
patch(
- "app.interview.api.routes.SessionPageService.prepare_page",
+ "app.interview.services.page.SessionPageService.prepare_page",
new=AsyncMock(
return_value=SessionPageRender(
redirect_url=None,
diff --git a/tests/theory/api/test_ws_routes.py b/tests/theory/api/test_ws_routes.py
index 048fcf2..bbfb3bd 100644
--- a/tests/theory/api/test_ws_routes.py
+++ b/tests/theory/api/test_ws_routes.py
@@ -75,10 +75,7 @@ class TestTheoryWebSocket:
def test_websocket_unknown_message(self, client):
"""Test WebSocket returns error for unknown message type."""
- with (
- patch("app.interview.services.query.InterviewQuery.get_interview"),
- client.websocket_connect("/interview/test-id/theory/ws") as ws,
- ):
+ with client.websocket_connect("/interview/test-id/theory/ws") as ws:
ws.send_json({"type": "unknown_command"})
response = ws.receive_json()
assert response["type"] == "error"
@@ -120,10 +117,7 @@ async def mock_stream(
def test_websocket_answer_missing_fields(self, client):
"""Test WebSocket returns error when question_id or answer_text is missing."""
- with (
- patch("app.interview.services.query.InterviewQuery.get_interview"),
- client.websocket_connect("/interview/test-id/theory/ws") as ws,
- ):
+ with client.websocket_connect("/interview/test-id/theory/ws") as ws:
ws.send_json({"type": "answer", "question_id": ""})
response = ws.receive_json()
assert response["type"] == "error"
diff --git a/tests/theory/integration/test_ws.py b/tests/theory/integration/test_ws.py
index ab905dc..f152c52 100644
--- a/tests/theory/integration/test_ws.py
+++ b/tests/theory/integration/test_ws.py
@@ -91,7 +91,7 @@ def test_websocket_answer_runs_full_processing_pipeline(
assert feedback["round"] == 0
assert feedback["timed_out"] is False
assert feedback["follow_up_question"] is None
- reloaded = InterviewQuery.get_interview(interview_id)
+ reloaded = InterviewQuery.load(interview_id)
assert reloaded is not None
answer2 = next(a for a in reloaded.answers if a.question_id == "q2")
assert feedback["next_question"] == {
@@ -176,7 +176,7 @@ def test_websocket_timeout_scores_zero(client, isolated_db, override_ws_ai_provi
assert feedback["timed_out"] is True
assert feedback["next_question"]["question_id"] == "q2"
- reloaded = InterviewQuery.get_interview(interview_id)
+ 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
diff --git a/tests/theory/repositories/test_theory_section.py b/tests/theory/repositories/test_theory_section.py
index e777fcd..9028a66 100644
--- a/tests/theory/repositories/test_theory_section.py
+++ b/tests/theory/repositories/test_theory_section.py
@@ -13,13 +13,13 @@
from alembic import command
from app.interview.domain.value_objects import InterviewSelection, TrackSelection
+from app.interview.repositories.uow import InterviewUnitOfWork
from app.shared.infrastructure.database import Base
from app.shared.infrastructure.models import Interview, TheorySection
from app.shared.paths import ALEMBIC_INI
from app.theory.domain.entities import TheorySection as DomainTheorySection
from app.theory.domain.entities import TheoryTask
from app.theory.domain.value_objects import PlannedTheoryQuestion
-from app.theory.repositories.uow import TheoryUnitOfWork
from tests.helpers.legacy_interview import insert_pre_session_mode_interview
@@ -138,7 +138,7 @@ class TestTheorySectionRepository:
def test_create_aggregate_persists_tasks(self, isolated_db) -> None:
"""Repository round-trips a theory section with linked answer rows."""
- with TheoryUnitOfWork() as uow:
+ with InterviewUnitOfWork() as uow:
uow.session.add(
Interview(
id="iv-theory",
@@ -154,7 +154,7 @@ def test_create_aggregate_persists_tasks(self, isolated_db) -> None:
planned_questions=_sample_planned(),
task_time_limit_seconds=120,
)
- with TheoryUnitOfWork() as uow:
+ with InterviewUnitOfWork() as uow:
created = uow.theory_sections.create_aggregate(section)
uow.commit()
@@ -165,7 +165,7 @@ def test_create_aggregate_persists_tasks(self, isolated_db) -> None:
assert created.tasks[0].id != TheoryTask.NEW_ID
assert created.question_ids == ("py-001", "py-002")
- with TheoryUnitOfWork() as uow:
+ with InterviewUnitOfWork() as uow:
loaded = uow.theory_sections.get_aggregate("iv-theory")
assert loaded is not None
@@ -176,7 +176,7 @@ def test_create_aggregate_persists_tasks(self, isolated_db) -> None:
def test_create_aggregate_round_trips_expected_points(self, isolated_db) -> None:
"""Repository persists rubric bullets on answer rows."""
- with TheoryUnitOfWork() as uow:
+ with InterviewUnitOfWork() as uow:
uow.session.add(
Interview(
id="iv-rubric",
@@ -191,11 +191,11 @@ def test_create_aggregate_round_trips_expected_points(self, isolated_db) -> None
locale="en",
planned_questions=_sample_planned(),
)
- with TheoryUnitOfWork() as uow:
+ with InterviewUnitOfWork() as uow:
uow.theory_sections.create_aggregate(section)
uow.commit()
- with TheoryUnitOfWork() as uow:
+ with InterviewUnitOfWork() as uow:
loaded = uow.theory_sections.get_aggregate("iv-rubric")
assert loaded is not None
diff --git a/tests/theory/services/test_planning.py b/tests/theory/services/test_planning.py
index 38c6d54..6c2743b 100644
--- a/tests/theory/services/test_planning.py
+++ b/tests/theory/services/test_planning.py
@@ -69,3 +69,40 @@ def test_build_theory_question_plan_skips_coding_type_rows(
planned = build_theory_question_plan(selection, question_count=1, locale="en")
assert len(planned) == 1
assert planned[0].id == "theory-001"
+
+
+def test_build_theory_question_plan_excludes_known_ids(
+ temp_questions_dir: Path,
+) -> None:
+ """Theory planning omits excluded question IDs from the result."""
+ category_path = temp_questions_dir / "python" / "junior" / "data-structures.yaml"
+ with open(category_path) as handle:
+ content = yaml.safe_load(handle)
+ content["questions"].append(
+ {
+ "id": "ds-002",
+ "type": "knowledge",
+ "difficulty": 1,
+ "question": {"text": "Second question", "code": None},
+ }
+ )
+ with open(category_path, "w") as handle:
+ yaml.dump(content, handle)
+
+ selection = InterviewSelection(
+ sources=(
+ TrackSelection(
+ track="python",
+ level="junior",
+ categories=("data-structures",),
+ ),
+ )
+ )
+ planned = build_theory_question_plan(
+ selection,
+ question_count=1,
+ locale="en",
+ excluded_ids=frozenset({"ds-001"}),
+ )
+ assert len(planned) == 1
+ assert planned[0].id == "ds-002"
diff --git a/tests/theory/services/test_review.py b/tests/theory/services/test_review.py
index 68ca4b5..884a4b0 100644
--- a/tests/theory/services/test_review.py
+++ b/tests/theory/services/test_review.py
@@ -2,6 +2,7 @@
# SPDX-License-Identifier: Apache-2.0
"""Tests for TheoryReviewService."""
+from app.interview.repositories.uow import InterviewUnitOfWork
from app.theory.services.review import TheoryReviewService
from tests.helpers.completed_session_seed import seed_completed_theory_interview
@@ -9,7 +10,8 @@
def test_theory_review_service_builds_chat_history(isolated_db) -> None:
"""Theory review exposes answered rounds and fallback section feedback."""
interview_id = seed_completed_theory_interview()
- context = TheoryReviewService.build_context(interview_id)
+ with InterviewUnitOfWork() as uow:
+ context = TheoryReviewService(uow).build_context_for(interview_id)
assert context is not None
assert len(context.answers) == 1
assert context.answers[0].feedback == "Clear and concise."
diff --git a/tests/theory/services/test_submission.py b/tests/theory/services/test_submission.py
index ad347e6..434c434 100644
--- a/tests/theory/services/test_submission.py
+++ b/tests/theory/services/test_submission.py
@@ -9,6 +9,7 @@
from app.ai.audio_probe import minimal_wav_bytes
from app.interview.domain.exceptions import InterviewNotActiveError
+from app.interview.repositories.uow import InterviewUnitOfWork
from app.interview.services.events import (
AnswerFeedbackEvent,
AnswerSavedEvent,
@@ -18,7 +19,6 @@
from app.interview.services.query import InterviewQuery
from app.shared.infrastructure.models import Answer, Interview
from app.theory.domain.entities import TheoryTask
-from app.theory.repositories.uow import TheoryUnitOfWork
from app.theory.services.evaluator.service import TheoryEvaluatorService
from app.theory.services.submission import TheorySubmissionService
from tests.fakes import answer_evaluation_json, follow_up_evaluation_json
@@ -30,6 +30,27 @@
from tests.helpers.transcription import FakeTranscriber
+async def _process_answer_submission(**kwargs):
+ """Run text answer submission inside an auto-commit UoW."""
+ with InterviewUnitOfWork(auto_commit=True) as uow:
+ service = TheorySubmissionService(uow)
+ return await service.process_answer_submission(**kwargs)
+
+
+async def _process_timeout_submission(**kwargs):
+ """Run timeout submission inside an auto-commit UoW."""
+ with InterviewUnitOfWork(auto_commit=True) as uow:
+ service = TheorySubmissionService(uow)
+ return await service.process_timeout_submission(**kwargs)
+
+
+async def _process_audio_answer_submission(**kwargs):
+ """Run audio answer submission inside an auto-commit UoW."""
+ with InterviewUnitOfWork(auto_commit=True) as uow:
+ service = TheorySubmissionService(uow)
+ return await service.process_audio_answer_submission(**kwargs)
+
+
@pytest.mark.asyncio
async def test_process_answer_persists_score_and_next_question(
isolated_db, fake_ai_provider
@@ -40,7 +61,7 @@ async def test_process_answer_persists_score_and_next_question(
[answer_evaluation_json(score=5, follow_up_needed=False)]
)
- events = await TheorySubmissionService.process_answer_submission(
+ events = await _process_answer_submission(
interview_id=interview_id,
question_id="q1",
answer_text="Lists are mutable.",
@@ -55,7 +76,7 @@ async def test_process_answer_persists_score_and_next_question(
feedback = events[2]
assert isinstance(feedback, AnswerFeedbackEvent)
assert feedback.follow_up_needed is False
- reloaded = InterviewQuery.get_interview(interview_id)
+ reloaded = InterviewQuery.load(interview_id)
assert reloaded is not None
q2 = next(a for a in reloaded.answers if a.question_id == "q2" and a.round == 0)
assert feedback.next_question == {
@@ -88,7 +109,7 @@ async def test_process_answer_creates_follow_up_round(isolated_db, fake_ai_provi
]
)
- events = await TheorySubmissionService.process_answer_submission(
+ events = await _process_answer_submission(
interview_id=interview_id,
question_id="q1",
answer_text="Partial answer.",
@@ -101,7 +122,7 @@ async def test_process_answer_creates_follow_up_round(isolated_db, fake_ai_provi
assert feedback.follow_up_text == "Explain big-O of append."
assert feedback.next_question is None
- reloaded = InterviewQuery.get_interview(interview_id)
+ reloaded = InterviewQuery.load(interview_id)
assert reloaded is not None
rounds = [a for a in reloaded.answers if a.question_id == "q1"]
assert len(rounds) == 2
@@ -155,7 +176,7 @@ async def test_process_follow_up_answer_without_another_follow_up(
[follow_up_evaluation_json(score=4, needs_further_follow_up=False)]
)
- events = await TheorySubmissionService.process_answer_submission(
+ events = await _process_answer_submission(
interview_id=interview_id,
question_id="q1",
answer_text="Follow-up answer text.",
@@ -169,7 +190,7 @@ async def test_process_follow_up_answer_without_another_follow_up(
assert feedback.next_question is not None
assert feedback.next_question["question_id"] == "q2"
- reloaded = InterviewQuery.get_interview(interview_id)
+ reloaded = InterviewQuery.load(interview_id)
assert reloaded is not None
follow_up = next(
a for a in reloaded.answers if a.question_id == "q1" and a.round == 1
@@ -232,7 +253,7 @@ async def test_last_follow_up_advances_immediately_and_evaluates_in_background(
[follow_up_evaluation_json(score=4, needs_further_follow_up=False)]
)
- events = await TheorySubmissionService.process_answer_submission(
+ events = await _process_answer_submission(
interview_id=interview_id,
question_id="q1",
answer_text="Second follow-up answer.",
@@ -248,7 +269,7 @@ async def test_last_follow_up_advances_immediately_and_evaluates_in_background(
assert feedback.next_question is not None
assert feedback.next_question["question_id"] == "q2"
- reloaded = InterviewQuery.get_interview(interview_id)
+ reloaded = InterviewQuery.load(interview_id)
assert reloaded is not None
last_follow_up = next(
a for a in reloaded.answers if a.question_id == "q1" and a.round == 2
@@ -258,7 +279,7 @@ async def test_last_follow_up_advances_immediately_and_evaluates_in_background(
await asyncio.sleep(0.05)
- reloaded = InterviewQuery.get_interview(interview_id)
+ reloaded = InterviewQuery.load(interview_id)
assert reloaded is not None
last_follow_up = next(
a for a in reloaded.answers if a.question_id == "q1" and a.round == 2
@@ -293,7 +314,7 @@ async def test_process_answer_rejects_completed_interview(
provider = fake_ai_provider([answer_evaluation_json()])
with pytest.raises(InterviewNotActiveError):
- await TheorySubmissionService.process_answer_submission(
+ await _process_answer_submission(
interview_id=interview_id,
question_id="q1",
answer_text="Too late.",
@@ -350,7 +371,7 @@ async def test_process_timeout_when_display_shows_zero(isolated_db):
started = datetime.now(UTC) - timedelta(seconds=59, milliseconds=500)
interview_id = _seed_timed_interview(started_at=started, limit_seconds=60)
- events = await TheorySubmissionService.process_timeout_submission(
+ events = await _process_timeout_submission(
interview_id=interview_id,
question_id="q1",
round_num=0,
@@ -366,7 +387,7 @@ async def test_process_timeout_scores_zero_and_advances(isolated_db):
started = datetime.now(UTC) - timedelta(seconds=120)
interview_id = _seed_timed_interview(started_at=started)
- events = await TheorySubmissionService.process_timeout_submission(
+ events = await _process_timeout_submission(
interview_id=interview_id,
question_id="q1",
round_num=0,
@@ -379,7 +400,7 @@ async def test_process_timeout_scores_zero_and_advances(isolated_db):
assert feedback.next_question is not None
assert feedback.next_question["question_id"] == "q2"
- reloaded = InterviewQuery.get_interview(interview_id)
+ reloaded = InterviewQuery.load(interview_id)
assert reloaded is not None
q1 = next(a for a in reloaded.answers if a.question_id == "q1" and a.round == 0)
assert q1.answer_text == TheoryTask.TIME_EXPIRED_ANSWER_TEXT
@@ -395,21 +416,21 @@ async def test_timeout_ignored_while_answer_pending_evaluation(
"""Timeout during AI evaluation must not overwrite a submitted answer."""
started = datetime.now(UTC) - timedelta(seconds=30)
interview_id = _seed_timed_interview(started_at=started)
- with TheoryUnitOfWork(auto_commit=True) as uow:
+ with InterviewUnitOfWork(auto_commit=True) as uow:
section = uow.theory_sections.get_aggregate(interview_id)
assert section is not None
current = section.find_task("q1", 0)
updated = section.with_task_text(current.id, "Answer in progress.")
uow.theory_sections.save_aggregate(updated)
- events = await TheorySubmissionService.process_timeout_submission(
+ events = await _process_timeout_submission(
interview_id=interview_id,
question_id="q1",
round_num=0,
)
assert events == []
- reloaded = InterviewQuery.get_interview(interview_id)
+ reloaded = InterviewQuery.load(interview_id)
assert reloaded is not None
q1 = next(a for a in reloaded.answers if a.question_id == "q1" and a.round == 0)
assert q1.answer_text == "Answer in progress."
@@ -440,27 +461,27 @@ async def slow_eval(**kwargs):
)
events: list = []
- gen = TheorySubmissionService.stream_answer_submission(
- interview_id=interview_id,
- question_id="q1",
- answer_text="Valid on-time answer.",
- provider=provider,
- )
- async for event in gen:
- events.append(event)
- if type(event).__name__ == "EvaluatingEvent":
- timeout_events = await TheorySubmissionService.process_timeout_submission(
- interview_id=interview_id,
- question_id="q1",
- round_num=0,
- )
- assert timeout_events == []
+ with InterviewUnitOfWork(auto_commit=True) as uow:
+ service = TheorySubmissionService(uow)
+ gen = service.stream_answer_submission(
+ interview_id=interview_id,
+ question_id="q1",
+ answer_text="Valid on-time answer.",
+ provider=provider,
+ )
+ async for event in gen:
+ events.append(event)
+ if type(event).__name__ == "EvaluatingEvent":
+ timeout_events = await _process_timeout_submission(
+ interview_id=interview_id,
+ question_id="q1",
+ round_num=0,
+ )
+ assert timeout_events == []
assert any(type(e).__name__ == "AnswerFeedbackEvent" for e in events)
q1 = next(
- a
- for a in InterviewQuery.get_interview(interview_id).answers
- if a.question_id == "q1"
+ a for a in InterviewQuery.load(interview_id).answers if a.question_id == "q1"
)
assert q1.answer_text == "Valid on-time answer."
assert q1.score == 5
@@ -473,7 +494,7 @@ async def test_late_answer_submission_treated_as_timeout(isolated_db, fake_ai_pr
interview_id = _seed_timed_interview(started_at=started)
provider = fake_ai_provider([answer_evaluation_json(score=5)])
- events = await TheorySubmissionService.process_answer_submission(
+ events = await _process_answer_submission(
interview_id=interview_id,
question_id="q1",
answer_text="Too late but trying anyway.",
@@ -484,7 +505,7 @@ async def test_late_answer_submission_treated_as_timeout(isolated_db, fake_ai_pr
assert isinstance(events[0], AnswerFeedbackEvent)
assert events[0].timed_out is True
- reloaded = InterviewQuery.get_interview(interview_id)
+ reloaded = InterviewQuery.load(interview_id)
assert reloaded is not None
q1 = next(a for a in reloaded.answers if a.question_id == "q1" and a.round == 0)
assert q1.score == 0
@@ -508,7 +529,7 @@ async def test_process_audio_answer_runs_transcription_and_evaluation(
transcriber = FakeTranscriber("spoken answer text")
wav_bytes = minimal_wav_bytes(duration_sec=0.2)
- events = await TheorySubmissionService.process_audio_answer_submission(
+ events = await _process_audio_answer_submission(
interview_id=interview_id,
question_id="q1",
wav_bytes=wav_bytes,
@@ -527,7 +548,7 @@ async def test_process_audio_answer_runs_transcription_and_evaluation(
assert transcript.text == "spoken answer text"
assert transcriber.last_audio is not None
- reloaded = InterviewQuery.get_interview(interview_id)
+ reloaded = InterviewQuery.load(interview_id)
assert reloaded is not None
answer = next(a for a in reloaded.answers if a.question_id == "q1" and a.round == 0)
assert answer.answer_text == "spoken answer text"
@@ -549,7 +570,7 @@ async def test_process_audio_answer_rejects_invalid_wav(
transcriber = FakeTranscriber()
with pytest.raises(ValueError, match="valid WAV"):
- await TheorySubmissionService.process_audio_answer_submission(
+ await _process_audio_answer_submission(
interview_id=interview_id,
question_id="q1",
wav_bytes=b"not-wav",
@@ -636,7 +657,7 @@ async def slow_audio_eval(**kwargs):
staticmethod(slow_audio_eval),
)
- events = await TheorySubmissionService.process_audio_answer_submission(
+ events = await _process_audio_answer_submission(
interview_id=interview_id,
question_id="q1",
wav_bytes=wav_bytes,
@@ -650,7 +671,7 @@ async def slow_audio_eval(**kwargs):
assert isinstance(events[2], TranscriptEvent)
assert not any(isinstance(event, EvaluatingEvent) for event in events)
- reloaded = InterviewQuery.get_interview(interview_id)
+ reloaded = InterviewQuery.load(interview_id)
assert reloaded is not None
last_follow_up = next(
a for a in reloaded.answers if a.question_id == "q1" and a.round == 2
@@ -660,7 +681,7 @@ async def slow_audio_eval(**kwargs):
await asyncio.sleep(0.05)
- reloaded = InterviewQuery.get_interview(interview_id)
+ reloaded = InterviewQuery.load(interview_id)
assert reloaded is not None
last_follow_up = next(
a for a in reloaded.answers if a.question_id == "q1" and a.round == 2