369 lines
14 KiB
Python
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
|