Ctrl K
ComunidadGuías…

tests/test_plan_validation.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""
Tests for `src.pipeline.plan_validation`.

Acceptance:
  - conforming plan → empty issue list
  - plan without questions AND without alternatives → ERROR
  - plan without questions BUT with direct_urls → WARN
  - question without search_terms → ERROR
  - question without ID/text → ERROR
  - invalid priority → ERROR
  - invalid source_scope → WARN (not blocking)
  - unknown language code → WARN
  - duplicate question IDs → ERROR
  - has_blocking_errors filters correctly
"""

import unittest

from src.pipeline.models import (
    DirectURL,
    ResearchPlan,
    ResearchQuestion,
)
from src.pipeline.plan_validation import (
    PlanValidationIssue,
    format_issues_for_log,
    has_blocking_errors,
    validate_plan_conventions,
)


def _ok_plan() -> ResearchPlan:
    """Minimal conforming plan."""
    return ResearchPlan(
        summary="Knappe Zusammenfassung der Recherche-Ziele.",
        questions=[
            ResearchQuestion(
                id="F1",
                question="Was ist X?",
                search_terms=["X Definition"],
                priority="hoch",
                source_scope="web",
                search_langs=["de"],
            ),
        ],
    )


class TestConformPlan(unittest.TestCase):

    def test_minimal_conform_plan_has_no_issues(self):
        plan = _ok_plan()
        issues = validate_plan_conventions(plan)
        self.assertEqual(issues, [])
        self.assertFalse(has_blocking_errors(issues))

    def test_format_says_conforms_for_clean_plan(self):
        out = format_issues_for_log([])
        self.assertIn("conforms", out.lower())


class TestSummary(unittest.TestCase):

    def test_missing_summary_warns(self):
        plan = _ok_plan()
        plan.summary = ""
        issues = validate_plan_conventions(plan)
        # only a warning — research can run without a summary
        self.assertTrue(any(
            i.is_warning() and i.location == "plan.summary"
            for i in issues
        ))
        self.assertFalse(has_blocking_errors(issues))

    def test_too_short_summary_warns(self):
        plan = _ok_plan()
        plan.summary = "x"
        issues = validate_plan_conventions(plan)
        self.assertTrue(any(
            i.category == "out_of_range" and "characters" in i.message
            for i in issues
        ))


class TestQuestionsCount(unittest.TestCase):

    def test_no_questions_no_alternatives_is_error(self):
        plan = ResearchPlan(summary="Test", questions=[])
        issues = validate_plan_conventions(plan)
        errors = [i for i in issues if i.is_error()]
        self.assertEqual(len(errors), 1)
        self.assertIn("the research has nothing to do", errors[0].message)

    def test_no_questions_but_direct_urls_only_warns(self):
        """Research can run with direct_urls alone — only a warning."""
        plan = ResearchPlan(
            summary="Test",
            questions=[],
            direct_urls=[DirectURL(url="https://example.com", reason="test")],
        )
        issues = validate_plan_conventions(plan)
        # The plan has 0 questions → exactly one issue about the number of questions
        question_count_issues = [
            i for i in issues if i.location == "plan.questions"
        ]
        self.assertEqual(len(question_count_issues), 1)
        self.assertTrue(question_count_issues[0].is_warning())
        self.assertFalse(has_blocking_errors(issues))

    def test_too_many_questions_warns(self):
        plan = _ok_plan()
        plan.questions = [
            ResearchQuestion(
                id=f"F{i}",
                question=f"Frage {i}?",
                search_terms=[f"term{i}"],
                priority="mittel",
            )
            for i in range(20)
        ]
        issues = validate_plan_conventions(plan)
        self.assertTrue(any(
            i.is_warning() and "over-engineering" in i.message
            for i in issues
        ))


class TestQuestionFields(unittest.TestCase):

    def test_question_without_id_is_error(self):
        plan = _ok_plan()
        plan.questions[0].id = ""
        issues = validate_plan_conventions(plan)
        self.assertTrue(any(
            i.is_error() and "without an ID" in i.message
            for i in issues
        ))

    def test_question_without_text_is_error(self):
        plan = _ok_plan()
        plan.questions[0].question = "  "
        issues = validate_plan_conventions(plan)
        self.assertTrue(any(
            i.is_error() and i.location.endswith(".question")
            for i in issues
        ))

    def test_question_without_search_terms_is_error(self):
        plan = _ok_plan()
        plan.questions[0].search_terms = []
        plan.questions[0].search_terms_by_lang = {}
        issues = validate_plan_conventions(plan)
        self.assertTrue(any(
            i.is_error() and "the search cannot start" in i.message
            for i in issues
        ))

    def test_search_terms_by_lang_alone_is_ok(self):
        """If only search_terms_by_lang is set and search_terms is empty: OK."""
        plan = _ok_plan()
        plan.questions[0].search_terms = []
        plan.questions[0].search_terms_by_lang = {"de": ["x"], "en": ["y"]}
        issues = validate_plan_conventions(plan)
        self.assertFalse(has_blocking_errors(issues))

    def test_invalid_priority_is_error(self):
        plan = _ok_plan()
        plan.questions[0].priority = "very_high"
        issues = validate_plan_conventions(plan)
        self.assertTrue(any(
            i.is_error() and "priority" in i.message
            for i in issues
        ))

    def test_unknown_source_scope_is_warn(self):
        plan = _ok_plan()
        plan.questions[0].source_scope = "telegram"
        issues = validate_plan_conventions(plan)
        scope_issues = [
            i for i in issues if i.location.endswith(".source_scope")
        ]
        self.assertEqual(len(scope_issues), 1)
        self.assertTrue(scope_issues[0].is_warning())

    def test_too_many_search_terms_warns(self):
        plan = _ok_plan()
        plan.questions[0].search_terms = [f"t{i}" for i in range(15)]
        issues = validate_plan_conventions(plan)
        self.assertTrue(any(
            i.is_warning() and i.location.endswith(".search_terms")
            for i in issues
        ))

    def test_unknown_lang_is_warn(self):
        plan = _ok_plan()
        plan.questions[0].search_langs = ["de", "klingon"]
        issues = validate_plan_conventions(plan)
        self.assertTrue(any(
            i.is_warning() and "klingon" in i.message
            for i in issues
        ))

    def test_non_string_lang_is_error(self):
        plan = _ok_plan()
        plan.questions[0].search_langs = ["de", 42]
        issues = validate_plan_conventions(plan)
        self.assertTrue(any(
            i.is_error() and "not a string" in i.message
            for i in issues
        ))


class TestDirectUrls(unittest.TestCase):

    def test_url_without_scheme_is_error(self):
        plan = _ok_plan()
        plan.direct_urls = [DirectURL(url="example.com", reason="x")]
        issues = validate_plan_conventions(plan)
        self.assertTrue(any(
            i.is_error() and "http" in i.message
            for i in issues
        ))

    def test_empty_url_is_error(self):
        plan = _ok_plan()
        plan.direct_urls = [DirectURL(url="", reason="x")]
        issues = validate_plan_conventions(plan)
        self.assertTrue(any(
            i.is_error() and "without a URL field" in i.message
            for i in issues
        ))

    def test_valid_url_no_issue(self):
        plan = _ok_plan()
        plan.direct_urls = [
            DirectURL(url="https://example.com/path", reason="x"),
        ]
        issues = validate_plan_conventions(plan)
        self.assertFalse(has_blocking_errors(issues))


class TestQuestionIdUniqueness(unittest.TestCase):

    def test_duplicate_ids_is_error(self):
        plan = _ok_plan()
        plan.questions = [
            ResearchQuestion(
                id="F1", question="?", search_terms=["x"], priority="hoch",
            ),
            ResearchQuestion(
                id="F1", question="?", search_terms=["y"], priority="hoch",
            ),
        ]
        issues = validate_plan_conventions(plan)
        self.assertTrue(any(
            i.is_error() and "duplicate question IDs" in i.message and "F1" in i.message
            for i in issues
        ))

    def test_empty_ids_dont_count_as_duplicates(self):
        """Several empty IDs → one error per question, no additional
        duplicates error."""
        plan = _ok_plan()
        plan.questions = [
            ResearchQuestion(id="", question="?", search_terms=["x"]),
            ResearchQuestion(id="", question="?", search_terms=["y"]),
        ]
        issues = validate_plan_conventions(plan)
        # There are errors for the two missing IDs, but no
        # "duplicate" error
        self.assertFalse(any("duplicate question IDs" in i.message for i in issues))


class TestHasBlockingErrors(unittest.TestCase):

    def test_warns_only_no_blocking(self):
        issues = [
            PlanValidationIssue("warn", "x", "msg1"),
            PlanValidationIssue("warn", "y", "msg2"),
        ]
        self.assertFalse(has_blocking_errors(issues))

    def test_one_error_blocks(self):
        issues = [
            PlanValidationIssue("warn", "x", "msg1"),
            PlanValidationIssue("error", "y", "msg2"),
        ]
        self.assertTrue(has_blocking_errors(issues))


class TestFormatIssuesForLog(unittest.TestCase):

    def test_error_marker(self):
        out = format_issues_for_log([
            PlanValidationIssue("error", "x", "Etwas Schlimmes"),
        ])
        self.assertIn("❌", out)
        self.assertIn("Etwas Schlimmes", out)

    def test_warn_marker(self):
        out = format_issues_for_log([
            PlanValidationIssue("warn", "x", "Etwas Komisches"),
        ])
        self.assertIn("⚠️", out)

    def test_location_appended(self):
        out = format_issues_for_log([
            PlanValidationIssue("error", "x", "msg",
                                location="plan.questions[0]"),
        ])
        self.assertIn("[plan.questions[0]]", out)


if __name__ == "__main__":
    unittest.main()