feat: Implement Study Interview Mode as default study creation method
Study Interview Mode is now the DEFAULT for all study creation requests. This intelligent Q&A system guides users through optimization setup with: - 7-phase interview flow: introspection → objectives → constraints → design_variables → validation → review → complete - Material-aware validation with 12 materials and fuzzy name matching - Anti-pattern detection for 12 common mistakes (mass-no-constraint, stress-over-yield, etc.) - Auto extractor mapping E1-E24 based on goal keywords - State persistence with JSON serialization and backup rotation - StudyBlueprint generation with full validation Triggers: "create a study", "new study", "optimize this", any study creation intent Skip with: "skip interview", "quick setup", "manual config" Components: - StudyInterviewEngine: Main orchestrator - QuestionEngine: Conditional logic evaluation - EngineeringValidator: MaterialsDatabase + AntiPatternDetector - InterviewPresenter: Markdown formatting for Claude - StudyBlueprint: Validated configuration output - InterviewState: Persistent state management All 129 tests passing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
295
tests/interview/test_interview_state.py
Normal file
295
tests/interview/test_interview_state.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""Tests for InterviewState and InterviewStateManager."""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from optimization_engine.interview.interview_state import (
|
||||
InterviewState,
|
||||
InterviewPhase,
|
||||
InterviewStateManager,
|
||||
AnsweredQuestion,
|
||||
LogEntry,
|
||||
)
|
||||
|
||||
|
||||
class TestInterviewPhase:
|
||||
"""Tests for InterviewPhase enum."""
|
||||
|
||||
def test_from_string(self):
|
||||
"""Test converting string to enum."""
|
||||
assert InterviewPhase.from_string("introspection") == InterviewPhase.INTROSPECTION
|
||||
assert InterviewPhase.from_string("objectives") == InterviewPhase.OBJECTIVES
|
||||
assert InterviewPhase.from_string("complete") == InterviewPhase.COMPLETE
|
||||
|
||||
def test_from_string_invalid(self):
|
||||
"""Test invalid string raises error."""
|
||||
with pytest.raises(ValueError):
|
||||
InterviewPhase.from_string("invalid_phase")
|
||||
|
||||
def test_next_phase(self):
|
||||
"""Test getting next phase."""
|
||||
assert InterviewPhase.INTROSPECTION.next_phase() == InterviewPhase.PROBLEM_DEFINITION
|
||||
assert InterviewPhase.OBJECTIVES.next_phase() == InterviewPhase.CONSTRAINTS
|
||||
assert InterviewPhase.COMPLETE.next_phase() is None
|
||||
|
||||
def test_previous_phase(self):
|
||||
"""Test getting previous phase."""
|
||||
assert InterviewPhase.OBJECTIVES.previous_phase() == InterviewPhase.PROBLEM_DEFINITION
|
||||
assert InterviewPhase.INTROSPECTION.previous_phase() is None
|
||||
|
||||
|
||||
class TestAnsweredQuestion:
|
||||
"""Tests for AnsweredQuestion dataclass."""
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test conversion to dict."""
|
||||
aq = AnsweredQuestion(
|
||||
question_id="obj_01",
|
||||
answered_at="2026-01-02T10:00:00",
|
||||
raw_response="minimize mass",
|
||||
parsed_value="minimize_mass",
|
||||
inferred={"extractor": "E4"}
|
||||
)
|
||||
|
||||
d = aq.to_dict()
|
||||
assert d["question_id"] == "obj_01"
|
||||
assert d["parsed_value"] == "minimize_mass"
|
||||
assert d["inferred"]["extractor"] == "E4"
|
||||
|
||||
def test_from_dict(self):
|
||||
"""Test creation from dict."""
|
||||
data = {
|
||||
"question_id": "obj_01",
|
||||
"answered_at": "2026-01-02T10:00:00",
|
||||
"raw_response": "minimize mass",
|
||||
"parsed_value": "minimize_mass",
|
||||
}
|
||||
|
||||
aq = AnsweredQuestion.from_dict(data)
|
||||
assert aq.question_id == "obj_01"
|
||||
assert aq.parsed_value == "minimize_mass"
|
||||
|
||||
|
||||
class TestInterviewState:
|
||||
"""Tests for InterviewState dataclass."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""Test default initialization."""
|
||||
state = InterviewState()
|
||||
assert state.version == "1.0"
|
||||
assert state.session_id != ""
|
||||
assert state.current_phase == InterviewPhase.INTROSPECTION.value
|
||||
assert state.complexity == "simple"
|
||||
assert state.answers["objectives"] == []
|
||||
|
||||
def test_get_phase(self):
|
||||
"""Test getting phase as enum."""
|
||||
state = InterviewState(current_phase="objectives")
|
||||
assert state.get_phase() == InterviewPhase.OBJECTIVES
|
||||
|
||||
def test_set_phase(self):
|
||||
"""Test setting phase."""
|
||||
state = InterviewState()
|
||||
state.set_phase(InterviewPhase.CONSTRAINTS)
|
||||
assert state.current_phase == "constraints"
|
||||
|
||||
def test_is_complete(self):
|
||||
"""Test completion check."""
|
||||
state = InterviewState(current_phase="review")
|
||||
assert not state.is_complete()
|
||||
|
||||
state.current_phase = "complete"
|
||||
assert state.is_complete()
|
||||
|
||||
def test_progress_percentage(self):
|
||||
"""Test progress calculation."""
|
||||
state = InterviewState(current_phase="introspection")
|
||||
assert state.progress_percentage() == 0.0
|
||||
|
||||
state.current_phase = "complete"
|
||||
assert state.progress_percentage() == 100.0
|
||||
|
||||
def test_add_answered_question(self):
|
||||
"""Test adding answered question."""
|
||||
state = InterviewState()
|
||||
aq = AnsweredQuestion(
|
||||
question_id="pd_01",
|
||||
answered_at=datetime.now().isoformat(),
|
||||
raw_response="test",
|
||||
parsed_value="test"
|
||||
)
|
||||
|
||||
state.add_answered_question(aq)
|
||||
assert len(state.questions_answered) == 1
|
||||
|
||||
def test_add_warning(self):
|
||||
"""Test adding warnings."""
|
||||
state = InterviewState()
|
||||
state.add_warning("Test warning")
|
||||
assert "Test warning" in state.warnings
|
||||
|
||||
# Duplicate should not be added
|
||||
state.add_warning("Test warning")
|
||||
assert len(state.warnings) == 1
|
||||
|
||||
def test_acknowledge_warning(self):
|
||||
"""Test acknowledging warnings."""
|
||||
state = InterviewState()
|
||||
state.add_warning("Test warning")
|
||||
state.acknowledge_warning("Test warning")
|
||||
assert "Test warning" in state.warnings_acknowledged
|
||||
|
||||
def test_to_json(self):
|
||||
"""Test JSON serialization."""
|
||||
state = InterviewState(study_name="test_study")
|
||||
json_str = state.to_json()
|
||||
|
||||
data = json.loads(json_str)
|
||||
assert data["study_name"] == "test_study"
|
||||
assert data["version"] == "1.0"
|
||||
|
||||
def test_from_json(self):
|
||||
"""Test JSON deserialization."""
|
||||
json_str = '{"version": "1.0", "session_id": "abc", "study_name": "test", "current_phase": "objectives", "answers": {}}'
|
||||
state = InterviewState.from_json(json_str)
|
||||
|
||||
assert state.study_name == "test"
|
||||
assert state.current_phase == "objectives"
|
||||
|
||||
def test_validate(self):
|
||||
"""Test state validation."""
|
||||
state = InterviewState()
|
||||
errors = state.validate()
|
||||
assert "Missing study_name" in errors
|
||||
|
||||
state.study_name = "test"
|
||||
errors = state.validate()
|
||||
assert "Missing study_name" not in errors
|
||||
|
||||
|
||||
class TestInterviewStateManager:
|
||||
"""Tests for InterviewStateManager."""
|
||||
|
||||
def test_init_creates_directories(self):
|
||||
"""Test initialization creates needed directories."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
study_path = Path(tmpdir) / "test_study"
|
||||
study_path.mkdir()
|
||||
|
||||
manager = InterviewStateManager(study_path)
|
||||
|
||||
assert (study_path / ".interview").exists()
|
||||
assert (study_path / ".interview" / "backups").exists()
|
||||
|
||||
def test_save_and_load_state(self):
|
||||
"""Test saving and loading state."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
study_path = Path(tmpdir) / "test_study"
|
||||
study_path.mkdir()
|
||||
|
||||
manager = InterviewStateManager(study_path)
|
||||
|
||||
state = InterviewState(
|
||||
study_name="test_study",
|
||||
study_path=str(study_path),
|
||||
current_phase="objectives"
|
||||
)
|
||||
|
||||
manager.save_state(state)
|
||||
assert manager.exists()
|
||||
|
||||
loaded = manager.load_state()
|
||||
assert loaded is not None
|
||||
assert loaded.study_name == "test_study"
|
||||
assert loaded.current_phase == "objectives"
|
||||
|
||||
def test_append_log(self):
|
||||
"""Test appending to log file."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
study_path = Path(tmpdir) / "test_study"
|
||||
study_path.mkdir()
|
||||
|
||||
manager = InterviewStateManager(study_path)
|
||||
|
||||
entry = LogEntry(
|
||||
timestamp=datetime.now(),
|
||||
question_id="obj_01",
|
||||
question_text="What is your goal?",
|
||||
answer_raw="minimize mass",
|
||||
answer_parsed="minimize_mass"
|
||||
)
|
||||
|
||||
manager.append_log(entry)
|
||||
|
||||
assert manager.log_file.exists()
|
||||
content = manager.log_file.read_text()
|
||||
assert "obj_01" in content
|
||||
assert "minimize mass" in content
|
||||
|
||||
def test_backup_rotation(self):
|
||||
"""Test backup rotation keeps only N backups."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
study_path = Path(tmpdir) / "test_study"
|
||||
study_path.mkdir()
|
||||
|
||||
manager = InterviewStateManager(study_path)
|
||||
manager.MAX_BACKUPS = 3
|
||||
|
||||
# Create multiple saves
|
||||
for i in range(5):
|
||||
state = InterviewState(
|
||||
study_name=f"test_{i}",
|
||||
study_path=str(study_path)
|
||||
)
|
||||
manager.save_state(state)
|
||||
|
||||
backups = list(manager.backup_dir.glob("state_*.json"))
|
||||
assert len(backups) <= 3
|
||||
|
||||
def test_get_history(self):
|
||||
"""Test getting modification history."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
study_path = Path(tmpdir) / "test_study"
|
||||
study_path.mkdir()
|
||||
|
||||
manager = InterviewStateManager(study_path)
|
||||
|
||||
# Save multiple states
|
||||
for i in range(3):
|
||||
state = InterviewState(
|
||||
study_name=f"test_{i}",
|
||||
study_path=str(study_path),
|
||||
current_phase=["objectives", "constraints", "review"][i]
|
||||
)
|
||||
manager.save_state(state)
|
||||
|
||||
history = manager.get_history()
|
||||
# Should have 2 backups (first save doesn't create backup)
|
||||
assert len(history) >= 1
|
||||
|
||||
|
||||
class TestLogEntry:
|
||||
"""Tests for LogEntry dataclass."""
|
||||
|
||||
def test_to_markdown(self):
|
||||
"""Test markdown generation."""
|
||||
entry = LogEntry(
|
||||
timestamp=datetime(2026, 1, 2, 10, 30, 0),
|
||||
question_id="obj_01",
|
||||
question_text="What is your primary optimization goal?",
|
||||
answer_raw="minimize mass",
|
||||
answer_parsed="minimize_mass",
|
||||
inferred={"extractor": "E4"},
|
||||
warnings=["Consider safety factor"]
|
||||
)
|
||||
|
||||
md = entry.to_markdown()
|
||||
|
||||
assert "## [2026-01-02 10:30:00]" in md
|
||||
assert "obj_01" in md
|
||||
assert "minimize mass" in md
|
||||
assert "Extractor" in md.lower() or "extractor" in md
|
||||
assert "Consider safety factor" in md
|
||||
Reference in New Issue
Block a user