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

492 lines
20 KiB
Python

# #region Test.DatasetReview.ClarificationHelpers [C:3] [TYPE Module] [SEMANTICS test,dataset,clarification,helpers,question,selection]
# @BRIEF Tests for clarification_pkg/_helpers.py — pure helper functions: select_next_open_question,
# count_resolved_questions, count_remaining_questions, normalize_answer_value,
# build_impact_summary, upsert_clarification_finding, derive_readiness_state, derive_recommended_action.
# @RELATION BINDS_TO -> [ClarificationHelpers]
# @TEST_EDGE: select_next_open_question -> Priority ordering, no open questions
# @TEST_EDGE: count_resolved_questions -> All answered, mixed, none
# @TEST_EDGE: count_remaining_questions -> Open, skipped, expert review counts
# @TEST_EDGE: normalize_answer_value -> Selected valid/invalid, skipped, expert, missing required
# @TEST_EDGE: build_impact_summary -> SKIPPED, EXPERT_REVIEW, selected
# @TEST_EDGE: upsert_clarification_finding -> New finding creation, existing update
# @TEST_EDGE: derive_readiness_state -> Clarification active, needed, review ready
# @TEST_EDGE: derive_recommended_action -> Answer next, start, review
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
import pytest
# ── Fixtures ────────────────────────────────────────────────
@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 does this dataset cover?"
q.why_it_matters = "Region affects row-level 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"
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 = 3
cs.summary_delta = ""
return cs
# ── Tests for select_next_open_question ────────────────────
class TestSelectNextOpenQuestion:
"""select_next_open_question — priority-driven selection."""
def test_returns_highest_priority(self):
from src.services.dataset_review.clarification_pkg._helpers import select_next_open_question
from src.models.dataset_review import QuestionState
q1 = MagicMock()
q1.question_id = "q-1"
q1.priority = 5
q1.state = QuestionState.OPEN
q1.created_at = datetime(2024, 6, 1, tzinfo=timezone.utc)
q2 = MagicMock()
q2.question_id = "q-2"
q2.priority = 10
q2.state = QuestionState.OPEN
q2.created_at = datetime(2024, 6, 2, tzinfo=timezone.utc)
cs = MagicMock()
cs.questions = [q1, q2]
result = select_next_open_question(cs)
assert result is not None
assert result.question_id == "q-2" # Higher priority first
def test_returns_none_when_no_open(self):
from src.services.dataset_review.clarification_pkg._helpers import select_next_open_question
from src.models.dataset_review import QuestionState
q1 = MagicMock()
q1.state = QuestionState.ANSWERED
cs = MagicMock()
cs.questions = [q1]
result = select_next_open_question(cs)
assert result is None
def test_empty_questions_returns_none(self):
from src.services.dataset_review.clarification_pkg._helpers import select_next_open_question
cs = MagicMock()
cs.questions = []
result = select_next_open_question(cs)
assert result is None
# ── Tests for count_resolved_questions ─────────────────────
class TestCountResolvedQuestions:
"""count_resolved_questions — count of ANSWERED questions."""
def test_all_answered(self):
from src.services.dataset_review.clarification_pkg._helpers import count_resolved_questions
from src.models.dataset_review import QuestionState
cs = MagicMock()
q1 = MagicMock()
q1.state = QuestionState.ANSWERED
q2 = MagicMock()
q2.state = QuestionState.ANSWERED
cs.questions = [q1, q2]
assert count_resolved_questions(cs) == 2
def test_mixed_states(self):
from src.services.dataset_review.clarification_pkg._helpers import count_resolved_questions
from src.models.dataset_review import QuestionState
cs = MagicMock()
states = [QuestionState.ANSWERED, QuestionState.OPEN, QuestionState.SKIPPED]
cs.questions = [
MagicMock(state=s) for s in states
]
assert count_resolved_questions(cs) == 1
def test_no_questions(self):
from src.services.dataset_review.clarification_pkg._helpers import count_resolved_questions
cs = MagicMock()
cs.questions = []
assert count_resolved_questions(cs) == 0
# ── Tests for count_remaining_questions ────────────────────
class TestCountRemainingQuestions:
"""count_remaining_questions — unresolved (open/skipped/expert) counts."""
def test_counts_open_skipped_expert(self):
from src.services.dataset_review.clarification_pkg._helpers import count_remaining_questions
from src.models.dataset_review import QuestionState
cs = MagicMock()
states = [QuestionState.OPEN, QuestionState.SKIPPED, QuestionState.EXPERT_REVIEW, QuestionState.ANSWERED]
cs.questions = [
MagicMock(state=s) for s in states
]
assert count_remaining_questions(cs) == 3
def test_all_resolved(self):
from src.services.dataset_review.clarification_pkg._helpers import count_remaining_questions
from src.models.dataset_review import QuestionState
cs = MagicMock()
cs.questions = [
MagicMock(state=QuestionState.ANSWERED),
]
assert count_remaining_questions(cs) == 0
# ── Tests for normalize_answer_value ───────────────────────
class TestNormalizeAnswerValue:
"""normalize_answer_value — validation and normalization."""
def test_selected_valid(self):
from src.services.dataset_review.clarification_pkg._helpers import normalize_answer_value
from src.models.dataset_review import AnswerKind, ClarificationQuestion
q = MagicMock(spec=ClarificationQuestion)
opt = MagicMock()
opt.value = "US"
q.options = [opt]
result = normalize_answer_value(AnswerKind.SELECTED, "US", q)
assert result == "US"
def test_selected_invalid_raises(self):
from src.services.dataset_review.clarification_pkg._helpers import normalize_answer_value
from src.models.dataset_review import AnswerKind, ClarificationQuestion
q = MagicMock(spec=ClarificationQuestion)
opt = MagicMock()
opt.value = "US"
q.options = [opt]
with pytest.raises(ValueError, match="must match"):
normalize_answer_value(AnswerKind.SELECTED, "EU", q)
def test_selected_empty_value_raises(self):
from src.services.dataset_review.clarification_pkg._helpers import normalize_answer_value
from src.models.dataset_review import AnswerKind, ClarificationQuestion
q = MagicMock(spec=ClarificationQuestion)
q.options = []
with pytest.raises(ValueError, match="required"):
normalize_answer_value(AnswerKind.SELECTED, "", q)
def test_custom_requires_non_empty(self):
from src.services.dataset_review.clarification_pkg._helpers import normalize_answer_value
from src.models.dataset_review import AnswerKind, ClarificationQuestion
q = MagicMock(spec=ClarificationQuestion)
q.options = []
result = normalize_answer_value(AnswerKind.CUSTOM, "my_value", q)
assert result == "my_value"
def test_custom_empty_raises(self):
from src.services.dataset_review.clarification_pkg._helpers import normalize_answer_value
from src.models.dataset_review import AnswerKind, ClarificationQuestion
q = MagicMock(spec=ClarificationQuestion)
q.options = []
with pytest.raises(ValueError, match="required"):
normalize_answer_value(AnswerKind.CUSTOM, "", q)
def test_skipped_returns_skipped(self):
from src.services.dataset_review.clarification_pkg._helpers import normalize_answer_value
from src.models.dataset_review import AnswerKind, ClarificationQuestion
q = MagicMock(spec=ClarificationQuestion)
q.options = []
result = normalize_answer_value(AnswerKind.SKIPPED, None, q)
assert result == "skipped"
def test_expert_review_default(self):
from src.services.dataset_review.clarification_pkg._helpers import normalize_answer_value
from src.models.dataset_review import AnswerKind, ClarificationQuestion
q = MagicMock(spec=ClarificationQuestion)
q.options = []
result = normalize_answer_value(AnswerKind.EXPERT_REVIEW, None, q)
assert result == "expert_review"
# ── Tests for build_impact_summary ─────────────────────────
class TestBuildImpactSummary:
"""build_impact_summary — readable audit note."""
def test_skipped(self):
from src.services.dataset_review.clarification_pkg._helpers import build_impact_summary
from src.models.dataset_review import AnswerKind
q = MagicMock()
q.topic_ref = "region_mapping"
summary = build_impact_summary(q, AnswerKind.SKIPPED, None)
assert "skipped" in summary
assert "region_mapping" in summary
def test_expert_review(self):
from src.services.dataset_review.clarification_pkg._helpers import build_impact_summary
from src.models.dataset_review import AnswerKind
q = MagicMock()
q.topic_ref = "region_mapping"
summary = build_impact_summary(q, AnswerKind.EXPERT_REVIEW, None)
assert "expert review" in summary
def test_selected(self):
from src.services.dataset_review.clarification_pkg._helpers import build_impact_summary
from src.models.dataset_review import AnswerKind
q = MagicMock()
q.topic_ref = "region_mapping"
summary = build_impact_summary(q, AnswerKind.SELECTED, "US")
assert "US" in summary
assert "recorded" in summary
# ── Tests for upsert_clarification_finding ─────────────────
class TestUpsertClarificationFinding:
"""upsert_clarification_finding — create or update findings per question."""
def test_creates_new_finding_for_selected(self):
from src.services.dataset_review.clarification_pkg._helpers import upsert_clarification_finding
from src.models.dataset_review import AnswerKind, DatasetReviewSession
session = MagicMock(spec=DatasetReviewSession)
session.session_id = "sess-1"
session.findings = []
question = MagicMock()
question.question_id = "q-1"
question.topic_ref = "region_mapping"
db = MagicMock()
finding = upsert_clarification_finding(session, question, AnswerKind.SELECTED, "US", db)
assert finding is not None
assert finding.area.value == "clarification"
assert finding.code == "CLARIFICATION_RESOLVED"
assert finding.resolution_state.value == "resolved"
db.add.assert_called_once()
def test_creates_new_finding_for_skipped(self):
from src.services.dataset_review.clarification_pkg._helpers import upsert_clarification_finding
from src.models.dataset_review import AnswerKind, DatasetReviewSession
session = MagicMock(spec=DatasetReviewSession)
session.session_id = "sess-1"
session.findings = []
question = MagicMock()
question.question_id = "q-1"
question.topic_ref = "region_mapping"
db = MagicMock()
finding = upsert_clarification_finding(session, question, AnswerKind.SKIPPED, None, db)
assert finding.code == "CLARIFICATION_SKIPPED"
assert finding.resolution_state.value == "skipped"
def test_creates_new_finding_for_expert_review(self):
from src.services.dataset_review.clarification_pkg._helpers import upsert_clarification_finding
from src.models.dataset_review import AnswerKind, DatasetReviewSession
session = MagicMock(spec=DatasetReviewSession)
session.session_id = "sess-1"
session.findings = []
question = MagicMock()
question.question_id = "q-1"
question.topic_ref = "region_mapping"
db = MagicMock()
finding = upsert_clarification_finding(session, question, AnswerKind.EXPERT_REVIEW, None, db)
assert finding.code == "CLARIFICATION_EXPERT_REVIEW"
assert finding.resolution_state.value == "expert_review"
def test_updates_existing_finding(self):
from src.services.dataset_review.clarification_pkg._helpers import upsert_clarification_finding
from src.models.dataset_review import (
AnswerKind, DatasetReviewSession, ValidationFinding, FindingArea, ResolutionState,
)
session = MagicMock(spec=DatasetReviewSession)
session.session_id = "sess-1"
existing = MagicMock(spec=ValidationFinding)
existing.area = FindingArea.CLARIFICATION
existing.caused_by_ref = "clarification:q-1"
session.findings = [existing]
question = MagicMock()
question.question_id = "q-1"
question.topic_ref = "region_mapping"
db = MagicMock()
finding = upsert_clarification_finding(session, question, AnswerKind.SELECTED, "US", db)
# Should NOT call db.add for existing
db.add.assert_not_called()
assert finding.code == "CLARIFICATION_RESOLVED"
def test_skipped_keeps_open_resolution_state(self):
from src.services.dataset_review.clarification_pkg._helpers import upsert_clarification_finding
from src.models.dataset_review import AnswerKind, DatasetReviewSession
session = MagicMock(spec=DatasetReviewSession)
session.session_id = "sess-1"
session.findings = []
question = MagicMock()
question.question_id = "q-1"
question.topic_ref = "region_mapping"
db = MagicMock()
finding = upsert_clarification_finding(session, question, AnswerKind.SKIPPED, None, db)
assert finding.resolved_at is None
# ── Tests for derive_readiness_state ───────────────────────
class TestDeriveReadinessState:
"""derive_readiness_state — post-clarification readiness."""
def test_none_clarification_preserves_current(self):
from src.services.dataset_review.clarification_pkg._helpers import derive_readiness_state
from src.models.dataset_review import DatasetReviewSession
session = MagicMock(spec=DatasetReviewSession)
session.readiness_state = MagicMock(value="review_ready")
result = derive_readiness_state(session, None)
assert result.value == "review_ready"
def test_current_question_active(self):
from src.services.dataset_review.clarification_pkg._helpers import derive_readiness_state
from src.models.dataset_review import ClarificationSession, DatasetReviewSession
session = MagicMock(spec=DatasetReviewSession)
cs = MagicMock(spec=ClarificationSession)
cs.current_question_id = "q-1"
cs.remaining_count = 5
result = derive_readiness_state(session, cs)
assert result.value == "clarification_active"
def test_remaining_count_over_zero(self):
from src.services.dataset_review.clarification_pkg._helpers import derive_readiness_state
from src.models.dataset_review import ClarificationSession, DatasetReviewSession
session = MagicMock(spec=DatasetReviewSession)
cs = MagicMock(spec=ClarificationSession)
cs.current_question_id = None
cs.remaining_count = 2
result = derive_readiness_state(session, cs)
assert result.value == "clarification_needed"
def test_all_resolved(self):
from src.services.dataset_review.clarification_pkg._helpers import derive_readiness_state
from src.models.dataset_review import ClarificationSession, DatasetReviewSession
session = MagicMock(spec=DatasetReviewSession)
cs = MagicMock(spec=ClarificationSession)
cs.current_question_id = None
cs.remaining_count = 0
result = derive_readiness_state(session, cs)
assert result.value == "review_ready"
# ── Tests for derive_recommended_action ────────────────────
class TestDeriveRecommendedAction:
"""derive_recommended_action — post-clarification action guidance."""
def test_none_clarification_preserves_current(self):
from src.services.dataset_review.clarification_pkg._helpers import derive_recommended_action
from src.models.dataset_review import DatasetReviewSession
session = MagicMock(spec=DatasetReviewSession)
session.recommended_action = MagicMock(value="launch_dataset")
result = derive_recommended_action(session, None)
assert result.value == "launch_dataset"
def test_current_question_prompts_answer(self):
from src.services.dataset_review.clarification_pkg._helpers import derive_recommended_action
from src.models.dataset_review import ClarificationSession, DatasetReviewSession
session = MagicMock(spec=DatasetReviewSession)
cs = MagicMock(spec=ClarificationSession)
cs.current_question_id = "q-1"
cs.remaining_count = 3
result = derive_recommended_action(session, cs)
assert result.value == "answer_next_question"
def test_remaining_unresolved_prompts_start(self):
from src.services.dataset_review.clarification_pkg._helpers import derive_recommended_action
from src.models.dataset_review import ClarificationSession, DatasetReviewSession
session = MagicMock(spec=DatasetReviewSession)
cs = MagicMock(spec=ClarificationSession)
cs.current_question_id = None
cs.remaining_count = 2
result = derive_recommended_action(session, cs)
assert result.value == "start_clarification"
def test_all_resolved_returns_review(self):
from src.services.dataset_review.clarification_pkg._helpers import derive_recommended_action
from src.models.dataset_review import ClarificationSession, DatasetReviewSession
session = MagicMock(spec=DatasetReviewSession)
cs = MagicMock(spec=ClarificationSession)
cs.current_question_id = None
cs.remaining_count = 0
result = derive_recommended_action(session, cs)
assert result.value == "review_documentation"
# #endregion Test.DatasetReview.ClarificationHelpers