492 lines
20 KiB
Python
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
|