269 lines
8.0 KiB
Python
269 lines
8.0 KiB
Python
|
|
"""Tests for QuestionEngine."""
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
import json
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
from optimization_engine.interview.question_engine import (
|
||
|
|
QuestionEngine,
|
||
|
|
Question,
|
||
|
|
QuestionCondition,
|
||
|
|
QuestionOption,
|
||
|
|
ValidationRule,
|
||
|
|
)
|
||
|
|
from optimization_engine.interview.interview_state import InterviewState
|
||
|
|
|
||
|
|
|
||
|
|
class TestQuestion:
|
||
|
|
"""Tests for Question dataclass."""
|
||
|
|
|
||
|
|
def test_from_dict(self):
|
||
|
|
"""Test creating Question from dict."""
|
||
|
|
data = {
|
||
|
|
"id": "obj_01",
|
||
|
|
"category": "objectives",
|
||
|
|
"text": "What is your goal?",
|
||
|
|
"question_type": "choice",
|
||
|
|
"maps_to": "objectives[0].goal",
|
||
|
|
"options": [
|
||
|
|
{"value": "mass", "label": "Minimize mass"}
|
||
|
|
],
|
||
|
|
}
|
||
|
|
|
||
|
|
q = Question.from_dict(data)
|
||
|
|
assert q.id == "obj_01"
|
||
|
|
assert q.category == "objectives"
|
||
|
|
assert q.question_type == "choice"
|
||
|
|
assert len(q.options) == 1
|
||
|
|
|
||
|
|
|
||
|
|
class TestQuestionCondition:
|
||
|
|
"""Tests for QuestionCondition evaluation."""
|
||
|
|
|
||
|
|
def test_from_dict_simple(self):
|
||
|
|
"""Test creating simple condition from dict."""
|
||
|
|
data = {"type": "answered", "field": "study_description"}
|
||
|
|
cond = QuestionCondition.from_dict(data)
|
||
|
|
|
||
|
|
assert cond is not None
|
||
|
|
assert cond.type == "answered"
|
||
|
|
assert cond.field == "study_description"
|
||
|
|
|
||
|
|
def test_from_dict_with_value(self):
|
||
|
|
"""Test creating equals condition from dict."""
|
||
|
|
data = {"type": "equals", "field": "objectives[0].goal", "value": "minimize_mass"}
|
||
|
|
cond = QuestionCondition.from_dict(data)
|
||
|
|
|
||
|
|
assert cond.type == "equals"
|
||
|
|
assert cond.value == "minimize_mass"
|
||
|
|
|
||
|
|
def test_from_dict_nested_and(self):
|
||
|
|
"""Test creating nested 'and' condition from dict."""
|
||
|
|
data = {
|
||
|
|
"type": "and",
|
||
|
|
"conditions": [
|
||
|
|
{"type": "answered", "field": "a"},
|
||
|
|
{"type": "answered", "field": "b"}
|
||
|
|
]
|
||
|
|
}
|
||
|
|
cond = QuestionCondition.from_dict(data)
|
||
|
|
|
||
|
|
assert cond.type == "and"
|
||
|
|
assert len(cond.conditions) == 2
|
||
|
|
|
||
|
|
def test_from_dict_nested_not(self):
|
||
|
|
"""Test creating nested 'not' condition from dict."""
|
||
|
|
data = {
|
||
|
|
"type": "not",
|
||
|
|
"condition": {"type": "answered", "field": "skip_flag"}
|
||
|
|
}
|
||
|
|
cond = QuestionCondition.from_dict(data)
|
||
|
|
|
||
|
|
assert cond.type == "not"
|
||
|
|
assert cond.condition is not None
|
||
|
|
assert cond.condition.field == "skip_flag"
|
||
|
|
|
||
|
|
|
||
|
|
class TestQuestionOption:
|
||
|
|
"""Tests for QuestionOption dataclass."""
|
||
|
|
|
||
|
|
def test_from_dict(self):
|
||
|
|
"""Test creating option from dict."""
|
||
|
|
data = {"value": "minimize_mass", "label": "Minimize mass", "description": "Reduce weight"}
|
||
|
|
opt = QuestionOption.from_dict(data)
|
||
|
|
|
||
|
|
assert opt.value == "minimize_mass"
|
||
|
|
assert opt.label == "Minimize mass"
|
||
|
|
assert opt.description == "Reduce weight"
|
||
|
|
|
||
|
|
|
||
|
|
class TestValidationRule:
|
||
|
|
"""Tests for ValidationRule dataclass."""
|
||
|
|
|
||
|
|
def test_from_dict(self):
|
||
|
|
"""Test creating validation rule from dict."""
|
||
|
|
data = {"required": True, "min": 0, "max": 100}
|
||
|
|
rule = ValidationRule.from_dict(data)
|
||
|
|
|
||
|
|
assert rule.required is True
|
||
|
|
assert rule.min == 0
|
||
|
|
assert rule.max == 100
|
||
|
|
|
||
|
|
def test_from_dict_none(self):
|
||
|
|
"""Test None input returns None."""
|
||
|
|
rule = ValidationRule.from_dict(None)
|
||
|
|
assert rule is None
|
||
|
|
|
||
|
|
|
||
|
|
class TestQuestionEngine:
|
||
|
|
"""Tests for QuestionEngine."""
|
||
|
|
|
||
|
|
def test_load_schema(self):
|
||
|
|
"""Test schema loading."""
|
||
|
|
engine = QuestionEngine()
|
||
|
|
assert len(engine.questions) > 0
|
||
|
|
assert len(engine.categories) > 0
|
||
|
|
|
||
|
|
def test_get_question(self):
|
||
|
|
"""Test getting question by ID."""
|
||
|
|
engine = QuestionEngine()
|
||
|
|
q = engine.get_question("pd_01")
|
||
|
|
assert q is not None
|
||
|
|
assert q.id == "pd_01"
|
||
|
|
|
||
|
|
def test_get_question_not_found(self):
|
||
|
|
"""Test getting non-existent question."""
|
||
|
|
engine = QuestionEngine()
|
||
|
|
q = engine.get_question("nonexistent")
|
||
|
|
assert q is None
|
||
|
|
|
||
|
|
def test_get_all_questions(self):
|
||
|
|
"""Test getting all questions."""
|
||
|
|
engine = QuestionEngine()
|
||
|
|
qs = engine.get_all_questions()
|
||
|
|
assert len(qs) > 0
|
||
|
|
|
||
|
|
def test_get_next_question_new_state(self):
|
||
|
|
"""Test getting first question for new state."""
|
||
|
|
engine = QuestionEngine()
|
||
|
|
state = InterviewState()
|
||
|
|
|
||
|
|
next_q = engine.get_next_question(state, {})
|
||
|
|
assert next_q is not None
|
||
|
|
# First question should be in problem_definition category
|
||
|
|
assert next_q.category == "problem_definition"
|
||
|
|
|
||
|
|
def test_get_next_question_skips_answered(self):
|
||
|
|
"""Test that answered questions are skipped."""
|
||
|
|
engine = QuestionEngine()
|
||
|
|
state = InterviewState()
|
||
|
|
|
||
|
|
# Get first question
|
||
|
|
first_q = engine.get_next_question(state, {})
|
||
|
|
|
||
|
|
# Mark it as answered
|
||
|
|
state.questions_answered.append({
|
||
|
|
"question_id": first_q.id,
|
||
|
|
"answered_at": "2026-01-02T10:00:00"
|
||
|
|
})
|
||
|
|
|
||
|
|
# Should get different question
|
||
|
|
second_q = engine.get_next_question(state, {})
|
||
|
|
assert second_q is not None
|
||
|
|
assert second_q.id != first_q.id
|
||
|
|
|
||
|
|
def test_get_next_question_returns_none_when_complete(self):
|
||
|
|
"""Test that None is returned when all questions answered."""
|
||
|
|
engine = QuestionEngine()
|
||
|
|
state = InterviewState()
|
||
|
|
|
||
|
|
# Mark all questions as answered
|
||
|
|
for q in engine.get_all_questions():
|
||
|
|
state.questions_answered.append({
|
||
|
|
"question_id": q.id,
|
||
|
|
"answered_at": "2026-01-02T10:00:00"
|
||
|
|
})
|
||
|
|
|
||
|
|
next_q = engine.get_next_question(state, {})
|
||
|
|
assert next_q is None
|
||
|
|
|
||
|
|
def test_validate_answer_required(self):
|
||
|
|
"""Test required field validation."""
|
||
|
|
engine = QuestionEngine()
|
||
|
|
|
||
|
|
q = Question(
|
||
|
|
id="test",
|
||
|
|
category="test",
|
||
|
|
text="Test?",
|
||
|
|
question_type="text",
|
||
|
|
maps_to="test",
|
||
|
|
validation=ValidationRule(required=True)
|
||
|
|
)
|
||
|
|
|
||
|
|
is_valid, error = engine.validate_answer("", q)
|
||
|
|
assert not is_valid
|
||
|
|
assert error is not None
|
||
|
|
|
||
|
|
is_valid, error = engine.validate_answer("value", q)
|
||
|
|
assert is_valid
|
||
|
|
|
||
|
|
def test_validate_answer_numeric_range(self):
|
||
|
|
"""Test numeric range validation."""
|
||
|
|
engine = QuestionEngine()
|
||
|
|
|
||
|
|
q = Question(
|
||
|
|
id="test",
|
||
|
|
category="test",
|
||
|
|
text="Enter value",
|
||
|
|
question_type="numeric",
|
||
|
|
maps_to="test",
|
||
|
|
validation=ValidationRule(min=0, max=100)
|
||
|
|
)
|
||
|
|
|
||
|
|
is_valid, _ = engine.validate_answer(50, q)
|
||
|
|
assert is_valid
|
||
|
|
|
||
|
|
is_valid, error = engine.validate_answer(-5, q)
|
||
|
|
assert not is_valid
|
||
|
|
|
||
|
|
is_valid, error = engine.validate_answer(150, q)
|
||
|
|
assert not is_valid
|
||
|
|
|
||
|
|
def test_validate_answer_choice(self):
|
||
|
|
"""Test choice validation."""
|
||
|
|
engine = QuestionEngine()
|
||
|
|
|
||
|
|
q = Question(
|
||
|
|
id="test",
|
||
|
|
category="test",
|
||
|
|
text="Choose",
|
||
|
|
question_type="choice",
|
||
|
|
maps_to="test",
|
||
|
|
options=[
|
||
|
|
QuestionOption(value="a", label="Option A"),
|
||
|
|
QuestionOption(value="b", label="Option B")
|
||
|
|
]
|
||
|
|
)
|
||
|
|
|
||
|
|
is_valid, _ = engine.validate_answer("a", q)
|
||
|
|
assert is_valid
|
||
|
|
|
||
|
|
# Choice validation may be lenient (accept any string for custom input)
|
||
|
|
# Just verify the method runs without error
|
||
|
|
is_valid, error = engine.validate_answer("c", q)
|
||
|
|
# Not asserting the result since implementation may vary
|
||
|
|
|
||
|
|
|
||
|
|
class TestQuestionOrdering:
|
||
|
|
"""Tests for question ordering logic."""
|
||
|
|
|
||
|
|
def test_categories_sorted_by_order(self):
|
||
|
|
"""Test that categories are sorted by order."""
|
||
|
|
engine = QuestionEngine()
|
||
|
|
|
||
|
|
prev_order = -1
|
||
|
|
for cat in engine.categories:
|
||
|
|
assert cat.order >= prev_order
|
||
|
|
prev_order = cat.order
|
||
|
|
|