# #region Test.DatasetReview.ClarificationEngine [C:3] [TYPE Module] [SEMANTICS test,dataset,clarification,engine,state,answer] # @BRIEF Tests for clarification_engine.py — build_question_payload, record_answer, summarize_progress, # _get_latest_clarification_session, _find_question. # @RELATION BINDS_TO -> [ClarificationEngine] # @TEST_EDGE: build_question_payload -> Active question, completed state, no clarification session # @TEST_EDGE: record_answer -> Selected/custom/skipped/expert, duplicate rejection, wrong question rejection # @TEST_EDGE: summarize_progress -> String format with counts # @TEST_EDGE: _get_latest_clarification_session -> Sorted by started_at # @TEST_EDGE: _find_question -> Found and not found from __future__ import annotations from pathlib import Path import sys sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) from datetime import datetime, timezone from unittest.mock import MagicMock, patch, PropertyMock import pytest # ── Fixtures ──────────────────────────────────────────────── @pytest.fixture def mock_repository(): """Mock DatasetReviewSessionRepository with MagicMock DB.""" repo = MagicMock() repo.db = MagicMock() repo.event_logger = MagicMock() return repo @pytest.fixture def mock_question(): from src.models.dataset_review import ClarificationQuestion, QuestionState q = MagicMock(spec=ClarificationQuestion) q.question_id = "q-1" q.topic_ref = "region_mapping" q.question_text = "What region?" q.why_it_matters = "Affects access" q.current_guess = "US" q.priority = 5 q.state = QuestionState.OPEN q.created_at = datetime(2024, 6, 1, tzinfo=timezone.utc) q.answer = None q.clarification_session_id = "cs-1" q.updated_at = datetime(2024, 6, 1, tzinfo=timezone.utc) return q @pytest.fixture def mock_option(): from src.models.dataset_review import ClarificationOption opt = MagicMock(spec=ClarificationOption) opt.option_id = "opt-1" opt.question_id = "q-1" opt.label = "US" opt.value = "US" opt.is_recommended = True opt.display_order = 1 return opt @pytest.fixture def mock_clarification_session(): from src.models.dataset_review import ClarificationSession, QuestionState cs = MagicMock(spec=ClarificationSession) cs.clarification_session_id = "cs-1" cs.current_question_id = "q-1" cs.status = MagicMock(value="active") cs.resolved_count = 0 cs.remaining_count = 1 cs.summary_delta = "" cs.started_at = datetime(2024, 6, 1, tzinfo=timezone.utc) cs.updated_at = datetime(2024, 6, 1, tzinfo=timezone.utc) return cs @pytest.fixture def mock_session(): from src.models.dataset_review import ( DatasetReviewSession, SessionPhase, ReadinessState, RecommendedAction, ) s = MagicMock(spec=DatasetReviewSession) s.session_id = "sess-1" s.current_phase = SessionPhase.CLARIFICATION s.readiness_state = ReadinessState.CLARIFICATION_ACTIVE s.recommended_action = RecommendedAction.ANSWER_NEXT_QUESTION s.user_id = "user-1" s.version = 1 s.clarification_sessions = [] s.findings = [] return s # ── Tests for ClarificationEngine.build_question_payload ─── class TestBuildQuestionPayload: """build_question_payload — active question selection.""" def test_returns_question_payload(self, mock_repository, mock_session, mock_clarification_session, mock_question, mock_option): from src.services.dataset_review.clarification_engine import ClarificationEngine mock_clarification_session.questions = [mock_question] mock_question.options = [mock_option] mock_session.clarification_sessions = [mock_clarification_session] engine = ClarificationEngine(mock_repository) result = engine.build_question_payload(mock_session) assert result is not None assert result.question_id == "q-1" assert result.topic_ref == "region_mapping" assert len(result.options) == 1 assert result.options[0]["value"] == "US" assert mock_clarification_session.current_question_id == "q-1" def test_returns_none_when_no_clarification_session(self, mock_repository, mock_session): from src.services.dataset_review.clarification_engine import ClarificationEngine mock_session.clarification_sessions = [] engine = ClarificationEngine(mock_repository) result = engine.build_question_payload(mock_session) assert result is None def test_returns_none_when_all_answered(self, mock_repository, mock_session, mock_clarification_session, mock_question): from src.services.dataset_review.clarification_engine import ClarificationEngine from src.models.dataset_review import QuestionState mock_question.state = QuestionState.ANSWERED mock_clarification_session.questions = [mock_question] mock_session.clarification_sessions = [mock_clarification_session] engine = ClarificationEngine(mock_repository) result = engine.build_question_payload(mock_session) assert result is None assert mock_clarification_session.current_question_id is None assert mock_clarification_session.status.value == "completed" def test_advances_phase_from_clarification_to_review(self, mock_repository, mock_session, mock_clarification_session, mock_question): from src.services.dataset_review.clarification_engine import ClarificationEngine from src.models.dataset_review import QuestionState, SessionPhase mock_question.state = QuestionState.ANSWERED mock_clarification_session.questions = [mock_question] mock_session.clarification_sessions = [mock_clarification_session] mock_session.current_phase = SessionPhase.CLARIFICATION engine = ClarificationEngine(mock_repository) engine.build_question_payload(mock_session) assert mock_session.current_phase == SessionPhase.REVIEW # ── Tests for ClarificationEngine.record_answer ──────────── class TestRecordAnswer: """record_answer — answer persistence and state advancement.""" @pytest.fixture def engine(self, mock_repository): from src.services.dataset_review.clarification_engine import ClarificationEngine return ClarificationEngine(mock_repository) def _make_command(self, answer_kind, answer_value): from src.models.auth import User from src.services.dataset_review.clarification_engine import ClarificationAnswerCommand from src.models.dataset_review import AnswerKind user = MagicMock(spec=User) user.id = "user-1" return ClarificationAnswerCommand( session=MagicMock(), question_id="q-1", answer_kind=answer_kind, answer_value=answer_value, user=user, ) def test_record_selected_answer(self, engine, mock_repository, mock_session, mock_clarification_session, mock_question, mock_option): from src.models.dataset_review import AnswerKind, QuestionState mock_question.options = [mock_option] mock_clarification_session.questions = [mock_question] mock_session.clarification_sessions = [mock_clarification_session] command = self._make_command(AnswerKind.SELECTED, "US") command.session = mock_session with patch.object(engine, 'summarize_progress', return_value="1 resolved, 0 unresolved"): result = engine.record_answer(command) assert result.clarification_session is not None assert mock_question.state == QuestionState.ANSWERED mock_repository.bump_session_version.assert_called_once() mock_repository.db.add.assert_called() mock_repository.db.commit.assert_called() mock_repository.db.flush.assert_called() mock_repository.db.refresh.assert_called() def test_rejects_duplicate_answer(self, engine, mock_repository, mock_session, mock_clarification_session, mock_question): from src.models.dataset_review import AnswerKind, ClarificationAnswer mock_question.answer = MagicMock(spec=ClarificationAnswer) mock_clarification_session.questions = [mock_question] mock_session.clarification_sessions = [mock_clarification_session] command = self._make_command(AnswerKind.SELECTED, "US") command.session = mock_session with pytest.raises(ValueError, match="already answered"): engine.record_answer(command) def test_rejects_non_active_question(self, engine, mock_repository, mock_session, mock_clarification_session, mock_question): from src.models.dataset_review import AnswerKind mock_clarification_session.questions = [mock_question] mock_clarification_session.current_question_id = "q-2" # different question is active mock_session.clarification_sessions = [mock_clarification_session] mock_question.answer = None command = self._make_command(AnswerKind.SELECTED, "US") command.session = mock_session with pytest.raises(ValueError, match="Only the active"): engine.record_answer(command) def test_rejects_no_clarification_session(self, engine, mock_repository, mock_session): from src.models.dataset_review import AnswerKind mock_session.clarification_sessions = [] command = self._make_command(AnswerKind.SELECTED, "US") command.session = mock_session with pytest.raises(ValueError, match="Clarification session not found"): engine.record_answer(command) def test_rejects_foreign_question(self, engine, mock_repository, mock_session, mock_clarification_session): from src.models.dataset_review import AnswerKind mock_clarification_session.questions = [] # no questions mock_session.clarification_sessions = [mock_clarification_session] command = self._make_command(AnswerKind.SELECTED, "US") command.session = mock_session with pytest.raises(ValueError, match="Clarification question not found"): engine.record_answer(command) def test_skipped_answer_sets_state(self, engine, mock_repository, mock_session, mock_clarification_session, mock_question, mock_option): from src.models.dataset_review import AnswerKind, QuestionState mock_question.options = [mock_option] mock_clarification_session.questions = [mock_question] mock_session.clarification_sessions = [mock_clarification_session] command = self._make_command(AnswerKind.SKIPPED, None) command.session = mock_session with patch.object(engine, 'summarize_progress', return_value="0 resolved, 1 unresolved"): result = engine.record_answer(command) assert mock_question.state == QuestionState.SKIPPED def test_expert_review_answer_sets_state(self, engine, mock_repository, mock_session, mock_clarification_session, mock_question, mock_option): from src.models.dataset_review import AnswerKind, QuestionState mock_question.options = [mock_option] mock_clarification_session.questions = [mock_question] mock_session.clarification_sessions = [mock_clarification_session] command = self._make_command(AnswerKind.EXPERT_REVIEW, None) command.session = mock_session with patch.object(engine, 'summarize_progress', return_value="0 resolved, 1 unresolved"): result = engine.record_answer(command) assert mock_question.state == QuestionState.EXPERT_REVIEW # ── Tests for summarize_progress ─────────────────────────── class TestSummarizeProgress: """summarize_progress — readable progress string.""" def test_format(self): from src.services.dataset_review.clarification_engine import ClarificationEngine from src.models.dataset_review import ClarificationSession, QuestionState mock_repo = MagicMock() engine = ClarificationEngine(mock_repo) cs = MagicMock(spec=ClarificationSession) q1 = MagicMock() q1.state = QuestionState.ANSWERED q2 = MagicMock() q2.state = QuestionState.OPEN cs.questions = [q1, q2] result = engine.summarize_progress(cs) assert result == "1 resolved, 1 unresolved" # ── Tests for _get_latest_clarification_session ──────────── class TestGetLatestClarificationSession: """_get_latest_clarification_session — sorted by started_at.""" def test_returns_latest(self): from src.services.dataset_review.clarification_engine import ClarificationEngine mock_repo = MagicMock() engine = ClarificationEngine(mock_repo) session = MagicMock() session.clarification_sessions = [ MagicMock(started_at=datetime(2024, 1, 1, tzinfo=timezone.utc), clarification_session_id="cs-1"), MagicMock(started_at=datetime(2024, 6, 1, tzinfo=timezone.utc), clarification_session_id="cs-2"), ] result = engine._get_latest_clarification_session(session) assert result.clarification_session_id == "cs-2" def test_none_when_empty(self): from src.services.dataset_review.clarification_engine import ClarificationEngine mock_repo = MagicMock() engine = ClarificationEngine(mock_repo) session = MagicMock() session.clarification_sessions = [] assert engine._get_latest_clarification_session(session) is None # ── Tests for _find_question ─────────────────────────────── class TestFindQuestion: """_find_question — resolve question by ID.""" def test_found(self): from src.services.dataset_review.clarification_engine import ClarificationEngine mock_repo = MagicMock() engine = ClarificationEngine(mock_repo) cs = MagicMock() q1 = MagicMock() q1.question_id = "q-1" q2 = MagicMock() q2.question_id = "q-2" cs.questions = [q1, q2] result = engine._find_question(cs, "q-2") assert result.question_id == "q-2" def test_not_found(self): from src.services.dataset_review.clarification_engine import ClarificationEngine mock_repo = MagicMock() engine = ClarificationEngine(mock_repo) cs = MagicMock() cs.questions = [MagicMock(question_id="q-1")] result = engine._find_question(cs, "q-nonexistent") assert result is None # #endregion Test.DatasetReview.ClarificationEngine