Files
ss-tools/backend/tests/services/dataset_review/test_clarification_engine.py

369 lines
14 KiB
Python

# #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