Integrate comprehensive testing framework with Makefile
- Added unit tests for YAML loading and parsing functionality - Created integration tests for multiple activity files validation - Implemented functional tests for complete activity workflows - Added battleship pre_script functionality tests - Integrated all test types into comprehensive Makefile - Fixed CLI validator test with proper failing fixture - Applied black formatting to all Python files - Removed problematic hardcoded targets from Makefile - Added proper venv dependency management Test coverage includes: - Unit: YAML loading, validator functionality - Integration: Cross-file validation, metadata operations - Functional: End-to-end activity flows, pre_script execution - All 30 activity files validated and tested
This commit is contained in:
parent
51b74be7d9
commit
1b44c2d66b
16 changed files with 2803 additions and 104 deletions
747
tests/functional/test_activity_flows.py
Normal file
747
tests/functional/test_activity_flows.py
Normal file
|
|
@ -0,0 +1,747 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Comprehensive activity flow tests that exercise all transitions
|
||||
|
||||
These tests run complete activity walkthroughs to validate that all
|
||||
transitions work correctly, especially after our YAML changes.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import json
|
||||
from unittest.mock import patch, MagicMock, call
|
||||
from pathlib import Path
|
||||
|
||||
# Add research directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "research"))
|
||||
import guarded_ai
|
||||
|
||||
|
||||
class TestCompleteActivityFlows(unittest.TestCase):
|
||||
"""Test complete activity walkthroughs"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment with mock AI responses"""
|
||||
self.mock_client = MagicMock()
|
||||
self.mock_response = MagicMock()
|
||||
self.mock_response.choices = [MagicMock()]
|
||||
self.mock_client.chat.completions.create.return_value = self.mock_response
|
||||
|
||||
def create_test_activity(self, content):
|
||||
"""Create temporary activity YAML file"""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
||||
f.write(content)
|
||||
return f.name
|
||||
|
||||
def test_integer_bucket_activity_flow(self):
|
||||
"""Test complete flow using integer buckets (like activity20)"""
|
||||
activity_yaml = """
|
||||
sections:
|
||||
- section_id: "quiz"
|
||||
title: "History Quiz"
|
||||
steps:
|
||||
- step_id: "q1"
|
||||
title: "Question 1"
|
||||
question: "What year did the Titanic sink?"
|
||||
tokens_for_ai: "Check if response matches 1912"
|
||||
buckets:
|
||||
- 1912
|
||||
- incorrect
|
||||
transitions:
|
||||
1912:
|
||||
content_blocks:
|
||||
- "Correct! The Titanic sank in 1912."
|
||||
metadata_add:
|
||||
score: "n+1"
|
||||
next_section_and_step: "quiz:q2"
|
||||
incorrect:
|
||||
content_blocks:
|
||||
- "That's not correct. Try again!"
|
||||
next_section_and_step: "quiz:q1"
|
||||
|
||||
- step_id: "q2"
|
||||
title: "Question 2"
|
||||
question: "How many people were on board?"
|
||||
tokens_for_ai: "Check if response is reasonable"
|
||||
buckets:
|
||||
- reasonable
|
||||
- unreasonable
|
||||
transitions:
|
||||
reasonable:
|
||||
content_blocks:
|
||||
- "Good estimate!"
|
||||
metadata_add:
|
||||
score: "n+1"
|
||||
next_section_and_step: "results:final"
|
||||
unreasonable:
|
||||
content_blocks:
|
||||
- "That doesn't seem right."
|
||||
next_section_and_step: "quiz:q2"
|
||||
|
||||
- section_id: "results"
|
||||
title: "Results"
|
||||
steps:
|
||||
- step_id: "final"
|
||||
title: "Final Results"
|
||||
content_blocks:
|
||||
- "Quiz completed!"
|
||||
- "Check your score in the metadata."
|
||||
"""
|
||||
|
||||
with patch("guarded_ai.get_openai_client_and_model") as mock_get_client:
|
||||
mock_get_client.return_value = (self.mock_client, "test-model")
|
||||
|
||||
# Test sequence: correct answer to q1, then reasonable answer to q2
|
||||
mock_responses = ["1912", "reasonable"]
|
||||
|
||||
with patch("guarded_ai.categorize_response", side_effect=mock_responses):
|
||||
with patch("guarded_ai.input", side_effect=["1912", "2000"]):
|
||||
with patch("builtins.print") as mock_print:
|
||||
|
||||
activity_file = self.create_test_activity(activity_yaml)
|
||||
try:
|
||||
# This should complete the full flow
|
||||
guarded_ai.simulate_activity(activity_file)
|
||||
|
||||
# Check that we reached the final step
|
||||
print_calls = [
|
||||
call[0][0] for call in mock_print.call_args_list
|
||||
]
|
||||
final_output = "\n".join(print_calls)
|
||||
|
||||
self.assertIn("Quiz completed!", final_output)
|
||||
self.assertIn(
|
||||
"Correct! The Titanic sank in 1912.", final_output
|
||||
)
|
||||
self.assertIn("Good estimate!", final_output)
|
||||
|
||||
finally:
|
||||
os.unlink(activity_file)
|
||||
|
||||
def test_metadata_operations_flow(self):
|
||||
"""Test flow with all metadata operations"""
|
||||
activity_yaml = """
|
||||
sections:
|
||||
- section_id: "meta_test"
|
||||
title: "Metadata Operations Test"
|
||||
steps:
|
||||
- step_id: "setup"
|
||||
title: "Setup"
|
||||
question: "Ready to start?"
|
||||
tokens_for_ai: "Always categorize as ready"
|
||||
buckets:
|
||||
- ready
|
||||
transitions:
|
||||
ready:
|
||||
metadata_add:
|
||||
user_name: "the-users-response"
|
||||
level: 1
|
||||
temp_data: "temporary"
|
||||
metadata_tmp_add:
|
||||
session_id: "temp-123"
|
||||
next_section_and_step: "meta_test:process"
|
||||
|
||||
- step_id: "process"
|
||||
title: "Processing"
|
||||
question: "Continue processing?"
|
||||
tokens_for_ai: "Always categorize as continue"
|
||||
buckets:
|
||||
- continue
|
||||
transitions:
|
||||
continue:
|
||||
metadata_remove:
|
||||
- temp_data
|
||||
metadata_add:
|
||||
level: "n+1"
|
||||
next_section_and_step: "meta_test:filter_test"
|
||||
|
||||
- step_id: "filter_test"
|
||||
title: "Filter Test"
|
||||
question: "Test feedback filtering?"
|
||||
feedback_tokens_for_ai: "Provide filtered feedback"
|
||||
tokens_for_ai: "Always categorize as test"
|
||||
buckets:
|
||||
- test
|
||||
transitions:
|
||||
test:
|
||||
metadata_feedback_filter:
|
||||
- level
|
||||
- user_name
|
||||
ai_feedback:
|
||||
tokens_for_ai: "Use only filtered metadata"
|
||||
next_section_and_step: "meta_test:clear_test"
|
||||
|
||||
- step_id: "clear_test"
|
||||
title: "Clear Test"
|
||||
question: "Clear all metadata?"
|
||||
tokens_for_ai: "Always categorize as clear"
|
||||
buckets:
|
||||
- clear
|
||||
transitions:
|
||||
clear:
|
||||
metadata_clear: true
|
||||
content_blocks:
|
||||
- "All metadata cleared!"
|
||||
"""
|
||||
|
||||
with patch("guarded_ai.get_openai_client_and_model") as mock_get_client:
|
||||
mock_get_client.return_value = (self.mock_client, "test-model")
|
||||
|
||||
# Mock AI feedback response
|
||||
self.mock_response.choices[0].message.content = "Good job!"
|
||||
|
||||
mock_responses = ["ready", "continue", "test", "clear"]
|
||||
user_inputs = ["TestUser", "yes", "yes", "yes"]
|
||||
|
||||
with patch("guarded_ai.categorize_response", side_effect=mock_responses):
|
||||
with patch("guarded_ai.input", side_effect=user_inputs):
|
||||
with patch("builtins.print") as mock_print:
|
||||
|
||||
activity_file = self.create_test_activity(activity_yaml)
|
||||
try:
|
||||
guarded_ai.simulate_activity(activity_file)
|
||||
|
||||
print_calls = [
|
||||
call[0][0] for call in mock_print.call_args_list
|
||||
]
|
||||
final_output = "\n".join(print_calls)
|
||||
|
||||
self.assertIn("All metadata cleared!", final_output)
|
||||
|
||||
finally:
|
||||
os.unlink(activity_file)
|
||||
|
||||
def test_processing_script_flow(self):
|
||||
"""Test flow with processing scripts"""
|
||||
activity_yaml = """
|
||||
sections:
|
||||
- section_id: "script_test"
|
||||
title: "Processing Script Test"
|
||||
steps:
|
||||
- step_id: "input_step"
|
||||
title: "Input Step"
|
||||
question: "Enter a number:"
|
||||
tokens_for_ai: "Always categorize as number"
|
||||
processing_script: |
|
||||
import random
|
||||
user_input = metadata.get('user_response', '0')
|
||||
try:
|
||||
number = int(user_input)
|
||||
metadata['parsed_number'] = number
|
||||
metadata['is_even'] = number % 2 == 0
|
||||
metadata['doubled'] = number * 2
|
||||
except ValueError:
|
||||
metadata['error'] = 'Invalid number'
|
||||
|
||||
script_result = {
|
||||
'metadata': {
|
||||
'processing_complete': True
|
||||
}
|
||||
}
|
||||
buckets:
|
||||
- number
|
||||
transitions:
|
||||
number:
|
||||
run_processing_script: true
|
||||
next_section_and_step: "script_test:result_step"
|
||||
|
||||
- step_id: "result_step"
|
||||
title: "Results"
|
||||
question: "Continue?"
|
||||
tokens_for_ai: "Always categorize as done"
|
||||
buckets:
|
||||
- done
|
||||
transitions:
|
||||
done:
|
||||
content_blocks:
|
||||
- "Processing completed!"
|
||||
- "Check metadata for results."
|
||||
"""
|
||||
|
||||
with patch("guarded_ai.get_openai_client_and_model") as mock_get_client:
|
||||
mock_get_client.return_value = (self.mock_client, "test-model")
|
||||
|
||||
mock_responses = ["number", "done"]
|
||||
user_inputs = ["42", "yes"]
|
||||
|
||||
with patch("guarded_ai.categorize_response", side_effect=mock_responses):
|
||||
with patch("guarded_ai.input", side_effect=user_inputs):
|
||||
with patch("builtins.print") as mock_print:
|
||||
|
||||
activity_file = self.create_test_activity(activity_yaml)
|
||||
try:
|
||||
guarded_ai.simulate_activity(activity_file)
|
||||
|
||||
print_calls = [
|
||||
call[0][0] for call in mock_print.call_args_list
|
||||
]
|
||||
final_output = "\n".join(print_calls)
|
||||
|
||||
self.assertIn("Processing completed!", final_output)
|
||||
# Should show metadata with processed values
|
||||
self.assertIn("parsed_number", final_output)
|
||||
self.assertIn("42", final_output)
|
||||
|
||||
finally:
|
||||
os.unlink(activity_file)
|
||||
|
||||
def test_boolean_bucket_transitions(self):
|
||||
"""Test boolean bucket transitions thoroughly"""
|
||||
activity_yaml = """
|
||||
sections:
|
||||
- section_id: "bool_test"
|
||||
title: "Boolean Test"
|
||||
steps:
|
||||
- step_id: "yes_no"
|
||||
title: "Yes/No Question"
|
||||
question: "Do you agree?"
|
||||
tokens_for_ai: "Categorize as true or false based on response"
|
||||
buckets:
|
||||
- true
|
||||
- false
|
||||
transitions:
|
||||
true:
|
||||
content_blocks:
|
||||
- "You agreed!"
|
||||
metadata_add:
|
||||
agreement: true
|
||||
next_section_and_step: "bool_test:follow_up"
|
||||
false:
|
||||
content_blocks:
|
||||
- "You disagreed!"
|
||||
metadata_add:
|
||||
agreement: false
|
||||
next_section_and_step: "bool_test:follow_up"
|
||||
|
||||
- step_id: "follow_up"
|
||||
title: "Follow Up"
|
||||
question: "Final question?"
|
||||
tokens_for_ai: "Always categorize as final"
|
||||
buckets:
|
||||
- final
|
||||
transitions:
|
||||
final:
|
||||
content_blocks:
|
||||
- "Thank you for your response!"
|
||||
"""
|
||||
|
||||
# Test both true and false paths
|
||||
test_cases = [
|
||||
(["true", "final"], ["yes", "done"], "You agreed!"),
|
||||
(["false", "final"], ["no", "done"], "You disagreed!"),
|
||||
]
|
||||
|
||||
for mock_responses, user_inputs, expected_content in test_cases:
|
||||
with self.subTest(responses=mock_responses):
|
||||
with patch("guarded_ai.get_openai_client_and_model") as mock_get_client:
|
||||
mock_get_client.return_value = (self.mock_client, "test-model")
|
||||
|
||||
with patch(
|
||||
"guarded_ai.categorize_response", side_effect=mock_responses
|
||||
):
|
||||
with patch("guarded_ai.input", side_effect=user_inputs):
|
||||
with patch("builtins.print") as mock_print:
|
||||
|
||||
activity_file = self.create_test_activity(activity_yaml)
|
||||
try:
|
||||
guarded_ai.simulate_activity(activity_file)
|
||||
|
||||
print_calls = [
|
||||
call[0][0] for call in mock_print.call_args_list
|
||||
]
|
||||
final_output = "\n".join(print_calls)
|
||||
|
||||
self.assertIn(expected_content, final_output)
|
||||
self.assertIn(
|
||||
"Thank you for your response!", final_output
|
||||
)
|
||||
|
||||
finally:
|
||||
os.unlink(activity_file)
|
||||
|
||||
|
||||
class TestRealActivityFiles(unittest.TestCase):
|
||||
"""Test our modified YAML files with complete flows"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment"""
|
||||
self.mock_client = MagicMock()
|
||||
self.mock_response = MagicMock()
|
||||
self.mock_response.choices = [MagicMock()]
|
||||
self.mock_response.choices[0].message.content = "Test response"
|
||||
self.mock_client.chat.completions.create.return_value = self.mock_response
|
||||
|
||||
def test_activity3_terminal_section_flow(self):
|
||||
"""Test that activity3 flows to the new terminal section"""
|
||||
with patch("guarded_ai.get_openai_client_and_model") as mock_get_client:
|
||||
mock_get_client.return_value = (self.mock_client, "test-model")
|
||||
|
||||
# Load actual activity3.yaml
|
||||
activity_file = "/home/fox/git/opencompletion/research/activity3.yaml"
|
||||
activity = guarded_ai.load_yaml_activity(activity_file)
|
||||
|
||||
# Should have section_5 as the terminal section
|
||||
section_5 = None
|
||||
for section in activity["sections"]:
|
||||
if section["section_id"] == "section_5":
|
||||
section_5 = section
|
||||
break
|
||||
|
||||
self.assertIsNotNone(section_5, "Should have section_5")
|
||||
|
||||
# Terminal section should not have questions or transitions with next_section_and_step
|
||||
terminal_step = section_5["steps"][0]
|
||||
self.assertNotIn("question", terminal_step)
|
||||
self.assertNotIn("buckets", terminal_step)
|
||||
self.assertNotIn("transitions", terminal_step)
|
||||
|
||||
# Should have congratulatory content
|
||||
content = "\n".join(terminal_step["content_blocks"])
|
||||
self.assertIn("Congratulations", content)
|
||||
self.assertIn("elephant expert", content)
|
||||
|
||||
def test_activity17_metadata_remove_flow(self):
|
||||
"""Test activity17 with new metadata_remove format"""
|
||||
with patch("guarded_ai.get_openai_client_and_model") as mock_get_client:
|
||||
mock_get_client.return_value = (self.mock_client, "test-model")
|
||||
|
||||
activity_file = (
|
||||
"/home/fox/git/opencompletion/research/activity17-choose-adventure.yaml"
|
||||
)
|
||||
activity = guarded_ai.load_yaml_activity(activity_file)
|
||||
|
||||
# Find a step with metadata_remove operations
|
||||
found_remove_operation = False
|
||||
for section in activity["sections"]:
|
||||
for step in section["steps"]:
|
||||
if "transitions" in step:
|
||||
for transition in step["transitions"].values():
|
||||
if "metadata_remove" in transition:
|
||||
found_remove_operation = True
|
||||
|
||||
# Should be list format now
|
||||
remove_op = transition["metadata_remove"]
|
||||
self.assertIsInstance(remove_op, list)
|
||||
|
||||
# Test the actual removal logic
|
||||
test_metadata = {
|
||||
"old_key": "old_value",
|
||||
"keep_key": "keep_value",
|
||||
}
|
||||
|
||||
# Simulate metadata removal
|
||||
for key in remove_op:
|
||||
if key in test_metadata:
|
||||
del test_metadata[key]
|
||||
|
||||
# Should have removed the keys
|
||||
for key in remove_op:
|
||||
self.assertNotIn(key, test_metadata)
|
||||
|
||||
self.assertTrue(
|
||||
found_remove_operation, "Should find metadata_remove operations"
|
||||
)
|
||||
|
||||
def test_activity20_integer_bucket_flow(self):
|
||||
"""Test activity20 with integer buckets"""
|
||||
with patch("guarded_ai.get_openai_client_and_model") as mock_get_client:
|
||||
mock_get_client.return_value = (self.mock_client, "test-model")
|
||||
|
||||
activity_file = (
|
||||
"/home/fox/git/opencompletion/research/activity20-n-plus-1.yaml"
|
||||
)
|
||||
activity = guarded_ai.load_yaml_activity(activity_file)
|
||||
|
||||
# Find the step with integer bucket (1912)
|
||||
found_integer_bucket = False
|
||||
for section in activity["sections"]:
|
||||
for step in section["steps"]:
|
||||
if "buckets" in step:
|
||||
for bucket in step["buckets"]:
|
||||
if bucket == 1912: # Integer bucket
|
||||
found_integer_bucket = True
|
||||
|
||||
# Test transition matching logic
|
||||
transitions = step["transitions"]
|
||||
category = "1912" # AI response as string
|
||||
|
||||
# Test our matching logic
|
||||
transition = None
|
||||
if category in transitions:
|
||||
transition = transitions[category]
|
||||
elif (
|
||||
category.isdigit() and int(category) in transitions
|
||||
):
|
||||
transition = transitions[int(category)]
|
||||
|
||||
self.assertIsNotNone(
|
||||
transition, "Should match integer bucket"
|
||||
)
|
||||
self.assertIn("1912", transition["content_blocks"][0])
|
||||
|
||||
self.assertTrue(found_integer_bucket, "Should find integer bucket (1912)")
|
||||
|
||||
|
||||
class TestPreScriptFunctionality(unittest.TestCase):
|
||||
"""Test pre_script execution (runs before categorization)"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment"""
|
||||
self.mock_client = MagicMock()
|
||||
self.mock_response = MagicMock()
|
||||
self.mock_response.choices = [MagicMock()]
|
||||
self.mock_response.choices[0].message.content = "valid"
|
||||
self.mock_client.chat.completions.create.return_value = self.mock_response
|
||||
|
||||
def create_test_activity(self, content):
|
||||
"""Create temporary activity YAML file"""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
||||
f.write(content)
|
||||
return f.name
|
||||
|
||||
def test_pre_script_battleship_scenario(self):
|
||||
"""Test pre_script with battleship-like win detection"""
|
||||
activity_yaml = """
|
||||
sections:
|
||||
- section_id: "game"
|
||||
title: "Battleship Game"
|
||||
steps:
|
||||
- step_id: "setup"
|
||||
title: "Setup"
|
||||
question: "Ready to play?"
|
||||
tokens_for_ai: "Always categorize as ready"
|
||||
buckets:
|
||||
- ready
|
||||
transitions:
|
||||
ready:
|
||||
metadata_add:
|
||||
user_winning_move: 42
|
||||
ai_winning_move: 73
|
||||
next_section_and_step: "game:play"
|
||||
|
||||
- step_id: "play"
|
||||
title: "Take a Shot"
|
||||
question: "Choose a position to fire at (0-99):"
|
||||
pre_script: |
|
||||
# Check if moves match winning moves from previous turn
|
||||
user_winning_move = metadata.get("user_winning_move")
|
||||
ai_winning_move = metadata.get("ai_winning_move")
|
||||
user_shot_input = metadata.get("user_response", "")
|
||||
|
||||
is_game_ending_move = False
|
||||
|
||||
# Check if user move wins
|
||||
if user_shot_input and user_shot_input.isdigit():
|
||||
user_move = int(user_shot_input)
|
||||
if user_winning_move is not None and user_move == user_winning_move:
|
||||
is_game_ending_move = True
|
||||
|
||||
script_result = {
|
||||
"metadata": {
|
||||
"is_game_ending_move": is_game_ending_move,
|
||||
"user_shot": user_shot_input
|
||||
}
|
||||
}
|
||||
tokens_for_ai: "If is_game_ending_move is True, categorize as winning_move, otherwise as regular_move"
|
||||
buckets:
|
||||
- winning_move
|
||||
- regular_move
|
||||
transitions:
|
||||
winning_move:
|
||||
content_blocks:
|
||||
- "🎉 You hit the target! You win!"
|
||||
regular_move:
|
||||
content_blocks:
|
||||
- "Miss! Try again."
|
||||
next_section_and_step: "game:play"
|
||||
"""
|
||||
|
||||
with patch("guarded_ai.get_openai_client_and_model") as mock_get_client:
|
||||
mock_get_client.return_value = (self.mock_client, "test-model")
|
||||
|
||||
# Test sequence: setup, then winning move
|
||||
mock_responses = ["ready", "winning_move"]
|
||||
user_inputs = ["yes", "42"] # 42 is the winning move
|
||||
|
||||
with patch("guarded_ai.categorize_response", side_effect=mock_responses):
|
||||
with patch("guarded_ai.input", side_effect=user_inputs):
|
||||
with patch("builtins.print") as mock_print:
|
||||
|
||||
activity_file = self.create_test_activity(activity_yaml)
|
||||
try:
|
||||
guarded_ai.simulate_activity(activity_file)
|
||||
|
||||
print_calls = [
|
||||
call[0][0] for call in mock_print.call_args_list
|
||||
]
|
||||
final_output = "\n".join(print_calls)
|
||||
|
||||
# Should show debug messages for pre-script execution
|
||||
self.assertIn("DEBUG: Executing pre-script", final_output)
|
||||
self.assertIn("DEBUG: Pre-script completed", final_output)
|
||||
|
||||
# Should show winning message
|
||||
self.assertIn("You hit the target! You win!", final_output)
|
||||
|
||||
# Metadata should show game ending move detected
|
||||
self.assertIn('"is_game_ending_move": true', final_output)
|
||||
|
||||
finally:
|
||||
os.unlink(activity_file)
|
||||
|
||||
def test_pre_script_metadata_processing(self):
|
||||
"""Test pre_script processes user input and updates metadata"""
|
||||
activity_yaml = """
|
||||
sections:
|
||||
- section_id: "input_processing"
|
||||
title: "Input Processing"
|
||||
steps:
|
||||
- step_id: "number_input"
|
||||
title: "Number Input"
|
||||
question: "Enter a number between 1-100:"
|
||||
pre_script: |
|
||||
user_input = metadata.get("user_response", "")
|
||||
|
||||
# Process and validate input
|
||||
is_valid = False
|
||||
parsed_number = None
|
||||
error_message = ""
|
||||
|
||||
try:
|
||||
parsed_number = int(user_input)
|
||||
if 1 <= parsed_number <= 100:
|
||||
is_valid = True
|
||||
else:
|
||||
error_message = "Number must be between 1-100"
|
||||
except ValueError:
|
||||
error_message = "Invalid number format"
|
||||
|
||||
script_result = {
|
||||
"metadata": {
|
||||
"is_valid_input": is_valid,
|
||||
"parsed_number": parsed_number,
|
||||
"error_message": error_message,
|
||||
"processing_complete": True
|
||||
}
|
||||
}
|
||||
tokens_for_ai: "If is_valid_input is True, categorize as valid, otherwise as invalid"
|
||||
buckets:
|
||||
- valid
|
||||
- invalid
|
||||
transitions:
|
||||
valid:
|
||||
content_blocks:
|
||||
- "Valid number received!"
|
||||
invalid:
|
||||
content_blocks:
|
||||
- "Invalid input. Please try again."
|
||||
next_section_and_step: "input_processing:number_input"
|
||||
"""
|
||||
|
||||
with patch("guarded_ai.get_openai_client_and_model") as mock_get_client:
|
||||
mock_get_client.return_value = (self.mock_client, "test-model")
|
||||
|
||||
# Test with valid number
|
||||
mock_responses = ["valid"]
|
||||
user_inputs = ["50"]
|
||||
|
||||
with patch("guarded_ai.categorize_response", side_effect=mock_responses):
|
||||
with patch("guarded_ai.input", side_effect=user_inputs):
|
||||
with patch("builtins.print") as mock_print:
|
||||
|
||||
activity_file = self.create_test_activity(activity_yaml)
|
||||
try:
|
||||
guarded_ai.simulate_activity(activity_file)
|
||||
|
||||
print_calls = [
|
||||
call[0][0] for call in mock_print.call_args_list
|
||||
]
|
||||
final_output = "\n".join(print_calls)
|
||||
|
||||
# Should show pre-script execution
|
||||
self.assertIn("DEBUG: Executing pre-script", final_output)
|
||||
|
||||
# Should show valid input message
|
||||
self.assertIn("Valid number received!", final_output)
|
||||
|
||||
# Metadata should show processed values
|
||||
self.assertIn('"is_valid_input": true', final_output)
|
||||
self.assertIn('"parsed_number": 50', final_output)
|
||||
self.assertIn('"processing_complete": true', final_output)
|
||||
|
||||
finally:
|
||||
os.unlink(activity_file)
|
||||
|
||||
|
||||
class TestErrorHandling(unittest.TestCase):
|
||||
"""Test error handling in activity flows"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment"""
|
||||
self.mock_client = MagicMock()
|
||||
self.mock_response = MagicMock()
|
||||
self.mock_response.choices = [MagicMock()]
|
||||
self.mock_response.choices[0].message.content = "unknown"
|
||||
self.mock_client.chat.completions.create.return_value = self.mock_response
|
||||
|
||||
def create_test_activity(self, content):
|
||||
"""Create temporary activity YAML file"""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
||||
f.write(content)
|
||||
return f.name
|
||||
|
||||
def test_invalid_transition_handling(self):
|
||||
"""Test handling of invalid AI responses"""
|
||||
activity_yaml = """
|
||||
sections:
|
||||
- section_id: "error_test"
|
||||
title: "Error Test"
|
||||
steps:
|
||||
- step_id: "step1"
|
||||
title: "Test Step"
|
||||
question: "Test question?"
|
||||
tokens_for_ai: "Categorize as valid or invalid"
|
||||
buckets:
|
||||
- valid
|
||||
- invalid
|
||||
transitions:
|
||||
valid:
|
||||
content_blocks:
|
||||
- "Valid response!"
|
||||
invalid:
|
||||
content_blocks:
|
||||
- "Invalid response!"
|
||||
"""
|
||||
|
||||
with patch("guarded_ai.get_openai_client_and_model") as mock_get_client:
|
||||
mock_get_client.return_value = (self.mock_client, "test-model")
|
||||
|
||||
# Mock categorize_response to return unknown category first, then valid
|
||||
with patch(
|
||||
"guarded_ai.categorize_response", side_effect=["unknown", "valid"]
|
||||
):
|
||||
with patch(
|
||||
"guarded_ai.input", side_effect=["test input", "valid input"]
|
||||
):
|
||||
with patch("builtins.print") as mock_print:
|
||||
|
||||
activity_file = self.create_test_activity(activity_yaml)
|
||||
try:
|
||||
guarded_ai.simulate_activity(activity_file)
|
||||
|
||||
print_calls = [
|
||||
call[0][0] for call in mock_print.call_args_list
|
||||
]
|
||||
final_output = "\n".join(print_calls)
|
||||
|
||||
# Should show error message for invalid transition
|
||||
self.assertIn("No valid transition found", final_output)
|
||||
|
||||
finally:
|
||||
os.unlink(activity_file)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
115
tests/functional/test_battleship_pre_script.py
Normal file
115
tests/functional/test_battleship_pre_script.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test that battleship pre_script functionality works with actual YAML files
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import patch, MagicMock
|
||||
from pathlib import Path
|
||||
|
||||
# Add research directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "research"))
|
||||
import guarded_ai
|
||||
|
||||
|
||||
class TestBattleshipPreScript(unittest.TestCase):
|
||||
"""Test actual battleship YAML files with pre_script"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment"""
|
||||
self.mock_client = MagicMock()
|
||||
self.mock_response = MagicMock()
|
||||
self.mock_response.choices = [MagicMock()]
|
||||
self.mock_response.choices[0].message.content = "Test response"
|
||||
self.mock_client.chat.completions.create.return_value = self.mock_response
|
||||
|
||||
def test_battleship_yaml_has_pre_script(self):
|
||||
"""Test that battleship YAML loads and has pre_script"""
|
||||
activity_file = (
|
||||
"/home/fox/git/opencompletion/research/activity29-battleship.yaml"
|
||||
)
|
||||
activity = guarded_ai.load_yaml_activity(activity_file)
|
||||
|
||||
# Find step with pre_script
|
||||
found_pre_script = False
|
||||
pre_script_content = ""
|
||||
|
||||
for section in activity["sections"]:
|
||||
for step in section["steps"]:
|
||||
if "pre_script" in step:
|
||||
found_pre_script = True
|
||||
pre_script_content = step["pre_script"]
|
||||
|
||||
# Should contain win detection logic
|
||||
self.assertIn("user_winning_move", pre_script_content)
|
||||
self.assertIn("ai_winning_move", pre_script_content)
|
||||
self.assertIn("is_game_ending_move", pre_script_content)
|
||||
self.assertIn("user_shot_input", pre_script_content)
|
||||
break
|
||||
|
||||
if found_pre_script:
|
||||
break
|
||||
|
||||
self.assertTrue(found_pre_script, "Battleship YAML should have pre_script")
|
||||
|
||||
def test_battleship_pre_script_execution_simulation(self):
|
||||
"""Test simulated battleship pre_script execution"""
|
||||
activity_file = (
|
||||
"/home/fox/git/opencompletion/research/activity29-battleship.yaml"
|
||||
)
|
||||
activity = guarded_ai.load_yaml_activity(activity_file)
|
||||
|
||||
# Find the step with pre_script (step_2)
|
||||
step_with_pre_script = None
|
||||
for section in activity["sections"]:
|
||||
for step in section["steps"]:
|
||||
if step.get("step_id") == "step_2" and "pre_script" in step:
|
||||
step_with_pre_script = step
|
||||
break
|
||||
|
||||
self.assertIsNotNone(step_with_pre_script, "Should find step_2 with pre_script")
|
||||
|
||||
# Test pre_script logic manually
|
||||
pre_script = step_with_pre_script["pre_script"]
|
||||
|
||||
# Simulate metadata with winning move setup
|
||||
test_metadata = {
|
||||
"user_winning_move": 42,
|
||||
"ai_winning_move": 73,
|
||||
"user_response": "42", # User enters winning move
|
||||
}
|
||||
|
||||
# Execute the pre_script
|
||||
result = guarded_ai.execute_processing_script(test_metadata, pre_script)
|
||||
|
||||
# Should detect winning move
|
||||
self.assertTrue(result.get("metadata", {}).get("is_game_ending_move", False))
|
||||
|
||||
# Test with non-winning move
|
||||
test_metadata["user_response"] = "25"
|
||||
result = guarded_ai.execute_processing_script(test_metadata, pre_script)
|
||||
|
||||
# Should NOT detect winning move
|
||||
self.assertFalse(result.get("metadata", {}).get("is_game_ending_move", False))
|
||||
|
||||
def test_testship_yaml_has_pre_script(self):
|
||||
"""Test that testship YAML also has pre_script"""
|
||||
activity_file = "/home/fox/git/opencompletion/research/activity29-testship.yaml"
|
||||
activity = guarded_ai.load_yaml_activity(activity_file)
|
||||
|
||||
# Should also have pre_script (same structure as battleship)
|
||||
found_pre_script = False
|
||||
|
||||
for section in activity["sections"]:
|
||||
for step in section["steps"]:
|
||||
if "pre_script" in step:
|
||||
found_pre_script = True
|
||||
break
|
||||
|
||||
self.assertTrue(found_pre_script, "Testship YAML should have pre_script")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
409
tests/functional/test_guarded_ai.py
Normal file
409
tests/functional/test_guarded_ai.py
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Functional tests for guarded_ai.py to validate app.py behavior compatibility
|
||||
|
||||
These tests use guarded_ai.py as a simpler test harness to validate that
|
||||
the core activity processing logic works correctly, especially after our
|
||||
validator and YAML changes.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import json
|
||||
from unittest.mock import patch, MagicMock
|
||||
from pathlib import Path
|
||||
|
||||
# Add research directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "research"))
|
||||
|
||||
# Import guarded_ai directly
|
||||
import guarded_ai
|
||||
|
||||
|
||||
class TestGuardedAIFunctionality(unittest.TestCase):
|
||||
"""Test guarded_ai.py core functionality"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment"""
|
||||
# Mock the OpenAI client to avoid API calls
|
||||
self.mock_client = MagicMock()
|
||||
self.mock_response = MagicMock()
|
||||
self.mock_response.choices = [MagicMock()]
|
||||
self.mock_response.choices[0].message.content = "correct"
|
||||
|
||||
self.mock_client.chat.completions.create.return_value = self.mock_response
|
||||
|
||||
def create_test_activity(self, content):
|
||||
"""Create temporary activity YAML file"""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
||||
f.write(content)
|
||||
return f.name
|
||||
|
||||
def test_integer_bucket_matching(self):
|
||||
"""Test that integer buckets work correctly (key regression test)"""
|
||||
# This tests our fix for activity20-n-plus-1.yaml
|
||||
test_activity = """
|
||||
sections:
|
||||
- section_id: "test_section"
|
||||
title: "Integer Bucket Test"
|
||||
steps:
|
||||
- step_id: "step_1"
|
||||
title: "Year Question"
|
||||
question: "What year did the Titanic sink?"
|
||||
tokens_for_ai: "Categorize the response"
|
||||
buckets:
|
||||
- 1912
|
||||
- incorrect
|
||||
transitions:
|
||||
1912:
|
||||
content_blocks:
|
||||
- "Correct! The Titanic sank in 1912."
|
||||
incorrect:
|
||||
content_blocks:
|
||||
- "That's not correct."
|
||||
"""
|
||||
|
||||
with patch("guarded_ai.get_openai_client_and_model") as mock_get_client:
|
||||
mock_get_client.return_value = (self.mock_client, "test-model")
|
||||
|
||||
# Mock the categorize_response to return "1912"
|
||||
with patch("guarded_ai.categorize_response") as mock_categorize:
|
||||
mock_categorize.return_value = "1912"
|
||||
|
||||
import guarded_ai as guarded_ai
|
||||
|
||||
activity_file = self.create_test_activity(test_activity)
|
||||
try:
|
||||
activity = guarded_ai.load_yaml_activity(activity_file)
|
||||
|
||||
# Test that integer bucket matching works
|
||||
step = activity["sections"][0]["steps"][0]
|
||||
|
||||
# Simulate the transition matching logic
|
||||
category = "1912"
|
||||
transitions = step["transitions"]
|
||||
|
||||
# Test the bucket matching logic we added
|
||||
transition = None
|
||||
if category in transitions:
|
||||
transition = transitions[category]
|
||||
elif category.isdigit() and int(category) in transitions:
|
||||
transition = transitions[int(category)]
|
||||
|
||||
self.assertIsNotNone(
|
||||
transition, "Should find transition for integer bucket"
|
||||
)
|
||||
self.assertIn(
|
||||
"Correct! The Titanic sank in 1912.",
|
||||
transition["content_blocks"],
|
||||
)
|
||||
|
||||
finally:
|
||||
os.unlink(activity_file)
|
||||
|
||||
def test_metadata_clear_functionality(self):
|
||||
"""Test metadata_clear functionality"""
|
||||
test_activity = """
|
||||
sections:
|
||||
- section_id: "test_section"
|
||||
title: "Metadata Clear Test"
|
||||
steps:
|
||||
- step_id: "step_1"
|
||||
title: "Test Step"
|
||||
question: "Test question"
|
||||
tokens_for_ai: "Categorize the response"
|
||||
buckets:
|
||||
- clear_test
|
||||
transitions:
|
||||
clear_test:
|
||||
metadata_clear: true
|
||||
content_blocks:
|
||||
- "Metadata cleared!"
|
||||
"""
|
||||
|
||||
import guarded_ai as guarded_ai
|
||||
|
||||
activity_file = self.create_test_activity(test_activity)
|
||||
try:
|
||||
activity = guarded_ai.load_yaml_activity(activity_file)
|
||||
step = activity["sections"][0]["steps"][0]
|
||||
transition = step["transitions"]["clear_test"]
|
||||
|
||||
# Test metadata clearing
|
||||
metadata = {"test_key": "test_value", "another_key": "another_value"}
|
||||
|
||||
# Simulate the metadata_clear logic we added
|
||||
if "metadata_clear" in transition and transition["metadata_clear"] == True:
|
||||
metadata.clear()
|
||||
|
||||
self.assertEqual(len(metadata), 0, "Metadata should be cleared")
|
||||
|
||||
finally:
|
||||
os.unlink(activity_file)
|
||||
|
||||
def test_metadata_feedback_filter(self):
|
||||
"""Test metadata_feedback_filter functionality"""
|
||||
test_activity = """
|
||||
sections:
|
||||
- section_id: "test_section"
|
||||
title: "Metadata Filter Test"
|
||||
steps:
|
||||
- step_id: "step_1"
|
||||
title: "Test Step"
|
||||
question: "Test question"
|
||||
tokens_for_ai: "Categorize the response"
|
||||
feedback_tokens_for_ai: "Provide feedback"
|
||||
buckets:
|
||||
- filter_test
|
||||
transitions:
|
||||
filter_test:
|
||||
metadata_feedback_filter:
|
||||
- "score"
|
||||
- "level"
|
||||
ai_feedback:
|
||||
tokens_for_ai: "Generate feedback"
|
||||
content_blocks:
|
||||
- "Filtered feedback!"
|
||||
"""
|
||||
|
||||
with patch("guarded_ai.get_openai_client_and_model") as mock_get_client:
|
||||
mock_get_client.return_value = (self.mock_client, "test-model")
|
||||
|
||||
import guarded_ai as guarded_ai
|
||||
|
||||
activity_file = self.create_test_activity(test_activity)
|
||||
try:
|
||||
activity = guarded_ai.load_yaml_activity(activity_file)
|
||||
step = activity["sections"][0]["steps"][0]
|
||||
transition = step["transitions"]["filter_test"]
|
||||
|
||||
# Test metadata filtering for feedback
|
||||
full_metadata = {
|
||||
"score": 85,
|
||||
"level": 2,
|
||||
"secret_data": "should_not_be_included",
|
||||
"user_id": "12345",
|
||||
}
|
||||
|
||||
# Simulate the feedback filtering logic we added
|
||||
feedback_metadata = full_metadata
|
||||
if "metadata_feedback_filter" in transition:
|
||||
filter_keys = transition["metadata_feedback_filter"]
|
||||
feedback_metadata = {
|
||||
k: v for k, v in full_metadata.items() if k in filter_keys
|
||||
}
|
||||
|
||||
expected_filtered = {"score": 85, "level": 2}
|
||||
self.assertEqual(feedback_metadata, expected_filtered)
|
||||
self.assertNotIn("secret_data", feedback_metadata)
|
||||
self.assertNotIn("user_id", feedback_metadata)
|
||||
|
||||
finally:
|
||||
os.unlink(activity_file)
|
||||
|
||||
def test_metadata_remove_list_format(self):
|
||||
"""Test that metadata_remove works with list format (activity17 fix)"""
|
||||
test_activity = """
|
||||
sections:
|
||||
- section_id: "test_section"
|
||||
title: "Metadata Remove Test"
|
||||
steps:
|
||||
- step_id: "step_1"
|
||||
title: "Test Step"
|
||||
question: "Test question"
|
||||
tokens_for_ai: "Categorize the response"
|
||||
buckets:
|
||||
- remove_test
|
||||
transitions:
|
||||
remove_test:
|
||||
metadata_remove:
|
||||
- "old_key1"
|
||||
- "old_key2"
|
||||
content_blocks:
|
||||
- "Keys removed!"
|
||||
"""
|
||||
|
||||
import guarded_ai as guarded_ai
|
||||
|
||||
activity_file = self.create_test_activity(test_activity)
|
||||
try:
|
||||
activity = guarded_ai.load_yaml_activity(activity_file)
|
||||
step = activity["sections"][0]["steps"][0]
|
||||
transition = step["transitions"]["remove_test"]
|
||||
|
||||
# Test metadata removal with list format
|
||||
metadata = {
|
||||
"old_key1": "value1",
|
||||
"old_key2": "value2",
|
||||
"keep_key": "keep_value",
|
||||
}
|
||||
|
||||
# Simulate the metadata_remove logic
|
||||
if "metadata_remove" in transition:
|
||||
for key in transition["metadata_remove"]:
|
||||
if key in metadata:
|
||||
del metadata[key]
|
||||
|
||||
expected = {"keep_key": "keep_value"}
|
||||
self.assertEqual(metadata, expected)
|
||||
self.assertNotIn("old_key1", metadata)
|
||||
self.assertNotIn("old_key2", metadata)
|
||||
|
||||
finally:
|
||||
os.unlink(activity_file)
|
||||
|
||||
def test_boolean_bucket_matching(self):
|
||||
"""Test that boolean buckets work correctly"""
|
||||
test_activity = """
|
||||
sections:
|
||||
- section_id: "test_section"
|
||||
title: "Boolean Bucket Test"
|
||||
steps:
|
||||
- step_id: "step_1"
|
||||
title: "Yes/No Question"
|
||||
question: "Is this correct?"
|
||||
tokens_for_ai: "Categorize as true or false"
|
||||
buckets:
|
||||
- true
|
||||
- false
|
||||
transitions:
|
||||
true:
|
||||
content_blocks:
|
||||
- "Yes, that's right!"
|
||||
false:
|
||||
content_blocks:
|
||||
- "No, that's not right."
|
||||
"""
|
||||
|
||||
import guarded_ai as guarded_ai
|
||||
|
||||
activity_file = self.create_test_activity(test_activity)
|
||||
try:
|
||||
activity = guarded_ai.load_yaml_activity(activity_file)
|
||||
step = activity["sections"][0]["steps"][0]
|
||||
transitions = step["transitions"]
|
||||
|
||||
# Test boolean matching logic
|
||||
for category_response in ["yes", "true", "TRUE", "Yes"]:
|
||||
category = category_response.lower()
|
||||
|
||||
transition = None
|
||||
if category in transitions:
|
||||
transition = transitions[category]
|
||||
elif category.isdigit() and int(category) in transitions:
|
||||
transition = transitions[int(category)]
|
||||
else:
|
||||
# This is the logic we added
|
||||
if category in ["yes", "true"]:
|
||||
category = True
|
||||
elif category in ["no", "false"]:
|
||||
category = False
|
||||
if category in transitions:
|
||||
transition = transitions[category]
|
||||
|
||||
self.assertIsNotNone(
|
||||
transition,
|
||||
f"Should find boolean transition for '{category_response}'",
|
||||
)
|
||||
self.assertIn("Yes, that's right!", transition["content_blocks"])
|
||||
|
||||
finally:
|
||||
os.unlink(activity_file)
|
||||
|
||||
|
||||
class TestActivityYAMLChanges(unittest.TestCase):
|
||||
"""Test that our YAML changes don't break functionality"""
|
||||
|
||||
def test_activity3_terminal_section(self):
|
||||
"""Test that activity3's new terminal section loads correctly"""
|
||||
import guarded_ai as guarded_ai
|
||||
|
||||
activity_file = "/home/fox/git/opencompletion/research/activity3.yaml"
|
||||
activity = guarded_ai.load_yaml_activity(activity_file)
|
||||
|
||||
# Should have section_5 now
|
||||
section_ids = [section["section_id"] for section in activity["sections"]]
|
||||
self.assertIn("section_5", section_ids)
|
||||
|
||||
# Section_5 should be terminal (no transitions with next_section_and_step)
|
||||
section_5 = next(
|
||||
s for s in activity["sections"] if s["section_id"] == "section_5"
|
||||
)
|
||||
step = section_5["steps"][0]
|
||||
|
||||
# Terminal step should not have question or buckets
|
||||
self.assertNotIn("question", step)
|
||||
self.assertNotIn("buckets", step)
|
||||
self.assertIn("content_blocks", step)
|
||||
|
||||
# Should have congratulatory content
|
||||
content = "\n".join(step["content_blocks"])
|
||||
self.assertIn("Congratulations", content)
|
||||
self.assertIn("elephant expert", content)
|
||||
|
||||
def test_activity17_metadata_remove_format(self):
|
||||
"""Test that activity17's metadata_remove changes work"""
|
||||
import guarded_ai as guarded_ai
|
||||
|
||||
activity_file = (
|
||||
"/home/fox/git/opencompletion/research/activity17-choose-adventure.yaml"
|
||||
)
|
||||
activity = guarded_ai.load_yaml_activity(activity_file)
|
||||
|
||||
# Find steps with metadata_remove
|
||||
found_metadata_remove = False
|
||||
for section in activity["sections"]:
|
||||
for step in section["steps"]:
|
||||
if "transitions" in step:
|
||||
for transition in step["transitions"].values():
|
||||
if "metadata_remove" in transition:
|
||||
found_metadata_remove = True
|
||||
# Should be list format now, not dictionary
|
||||
self.assertIsInstance(transition["metadata_remove"], list)
|
||||
for item in transition["metadata_remove"]:
|
||||
self.assertIsInstance(item, str)
|
||||
|
||||
self.assertTrue(found_metadata_remove, "Should find metadata_remove operations")
|
||||
|
||||
def test_battleship_exit_transitions(self):
|
||||
"""Test that battleship exit transitions go to step_4"""
|
||||
import guarded_ai as guarded_ai
|
||||
|
||||
for battleship_file in [
|
||||
"activity29-battleship.yaml",
|
||||
"activity29-testship.yaml",
|
||||
]:
|
||||
activity_file = f"/home/fox/git/opencompletion/research/{battleship_file}"
|
||||
activity = guarded_ai.load_yaml_activity(activity_file)
|
||||
|
||||
# Find exit transitions and verify they go to step_4
|
||||
exit_transitions_found = 0
|
||||
for section in activity["sections"]:
|
||||
for step in section["steps"]:
|
||||
if "transitions" in step:
|
||||
for bucket, transition in step["transitions"].items():
|
||||
if (
|
||||
bucket == "exit"
|
||||
and "next_section_and_step" in transition
|
||||
):
|
||||
exit_transitions_found += 1
|
||||
target = transition["next_section_and_step"]
|
||||
if step["step_id"] == "step_2":
|
||||
# step_2 exit should go directly to step_4
|
||||
self.assertEqual(
|
||||
target,
|
||||
"section_1:step_4",
|
||||
f"step_2 exit should go to step_4 in {battleship_file}",
|
||||
)
|
||||
|
||||
self.assertGreater(
|
||||
exit_transitions_found,
|
||||
0,
|
||||
f"Should find exit transitions in {battleship_file}",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
538
tests/integration/test_multiple_activities.py
Normal file
538
tests/integration/test_multiple_activities.py
Normal file
|
|
@ -0,0 +1,538 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Integration tests that run against multiple activity files
|
||||
|
||||
These tests validate that all activity YAML files in the project
|
||||
can be loaded, validated, and executed without errors after our changes.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import os
|
||||
import sys
|
||||
import glob
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
# Add research directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "research"))
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
import guarded_ai
|
||||
from activity_yaml_validator import ActivityYAMLValidator
|
||||
|
||||
|
||||
class TestMultipleActivityFiles(unittest.TestCase):
|
||||
"""Integration tests across multiple activity files"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment"""
|
||||
self.research_dir = Path(__file__).parent.parent.parent / "research"
|
||||
self.activity_files = list(self.research_dir.glob("activity*.yaml"))
|
||||
self.validator = ActivityYAMLValidator()
|
||||
|
||||
# Mock OpenAI client for testing
|
||||
self.mock_client = MagicMock()
|
||||
self.mock_response = MagicMock()
|
||||
self.mock_response.choices = [MagicMock()]
|
||||
self.mock_response.choices[0].message.content = "valid_response"
|
||||
self.mock_client.chat.completions.create.return_value = self.mock_response
|
||||
|
||||
def test_all_activity_files_load_successfully(self):
|
||||
"""Test that all activity YAML files load without errors"""
|
||||
self.assertTrue(len(self.activity_files) > 0, "Should find activity files")
|
||||
|
||||
failed_files = []
|
||||
|
||||
for activity_file in self.activity_files:
|
||||
with self.subTest(file=activity_file.name):
|
||||
try:
|
||||
activity = guarded_ai.load_yaml_activity(str(activity_file))
|
||||
self.assertIsInstance(activity, dict)
|
||||
self.assertIn("sections", activity)
|
||||
except Exception as e:
|
||||
failed_files.append((activity_file.name, str(e)))
|
||||
|
||||
if failed_files:
|
||||
failure_msg = "Failed to load files:\n" + "\n".join(
|
||||
f" - {name}: {error}" for name, error in failed_files
|
||||
)
|
||||
self.fail(failure_msg)
|
||||
|
||||
def test_all_activity_files_pass_validation(self):
|
||||
"""Test that all activity files pass our validator"""
|
||||
validation_errors = {}
|
||||
|
||||
for activity_file in self.activity_files:
|
||||
with self.subTest(file=activity_file.name):
|
||||
try:
|
||||
is_valid, errors, warnings = self.validator.validate_file(
|
||||
str(activity_file)
|
||||
)
|
||||
if errors:
|
||||
validation_errors[activity_file.name] = errors
|
||||
except Exception as e:
|
||||
validation_errors[activity_file.name] = [f"Validation failed: {e}"]
|
||||
|
||||
if validation_errors:
|
||||
failure_msg = "Validation errors found:\n"
|
||||
for filename, errors in validation_errors.items():
|
||||
failure_msg += f"\n{filename}:\n"
|
||||
for error in errors[:5]: # Show first 5 errors
|
||||
failure_msg += f" - {error}\n"
|
||||
if len(errors) > 5:
|
||||
failure_msg += f" ... and {len(errors) - 5} more errors\n"
|
||||
self.fail(failure_msg)
|
||||
|
||||
def test_activity_files_have_required_structure(self):
|
||||
"""Test that all activity files have the required basic structure"""
|
||||
structural_issues = {}
|
||||
|
||||
for activity_file in self.activity_files:
|
||||
issues = []
|
||||
|
||||
try:
|
||||
activity = guarded_ai.load_yaml_activity(str(activity_file))
|
||||
|
||||
# Check basic structure
|
||||
if "sections" not in activity:
|
||||
issues.append("Missing 'sections' field")
|
||||
elif not isinstance(activity["sections"], list):
|
||||
issues.append("'sections' is not a list")
|
||||
elif len(activity["sections"]) == 0:
|
||||
issues.append("Empty sections list")
|
||||
else:
|
||||
# Check each section
|
||||
for i, section in enumerate(activity["sections"]):
|
||||
if "section_id" not in section:
|
||||
issues.append(f"Section {i} missing 'section_id'")
|
||||
if "steps" not in section:
|
||||
issues.append(f"Section {i} missing 'steps'")
|
||||
elif not isinstance(section["steps"], list):
|
||||
issues.append(f"Section {i} 'steps' is not a list")
|
||||
elif len(section["steps"]) == 0:
|
||||
issues.append(f"Section {i} has empty steps list")
|
||||
else:
|
||||
# Check each step
|
||||
for j, step in enumerate(section["steps"]):
|
||||
if "step_id" not in step:
|
||||
issues.append(
|
||||
f"Section {i} Step {j} missing 'step_id'"
|
||||
)
|
||||
|
||||
if issues:
|
||||
structural_issues[activity_file.name] = issues
|
||||
|
||||
except Exception as e:
|
||||
structural_issues[activity_file.name] = [f"Failed to analyze: {e}"]
|
||||
|
||||
if structural_issues:
|
||||
failure_msg = "Structural issues found:\n"
|
||||
for filename, issues in structural_issues.items():
|
||||
failure_msg += f"\n{filename}:\n"
|
||||
for issue in issues:
|
||||
failure_msg += f" - {issue}\n"
|
||||
self.fail(failure_msg)
|
||||
|
||||
def test_modified_files_specific_checks(self):
|
||||
"""Test specific checks for files we modified"""
|
||||
|
||||
# Test activity3 has the new terminal section
|
||||
activity3_path = self.research_dir / "activity3.yaml"
|
||||
if activity3_path.exists():
|
||||
activity3 = guarded_ai.load_yaml_activity(str(activity3_path))
|
||||
section_ids = [s["section_id"] for s in activity3["sections"]]
|
||||
self.assertIn("section_5", section_ids, "activity3 should have section_5")
|
||||
|
||||
# Find section_5 and verify it's terminal
|
||||
section_5 = next(
|
||||
s for s in activity3["sections"] if s["section_id"] == "section_5"
|
||||
)
|
||||
terminal_step = section_5["steps"][0]
|
||||
self.assertNotIn(
|
||||
"question", terminal_step, "Terminal step should not have question"
|
||||
)
|
||||
self.assertNotIn(
|
||||
"buckets", terminal_step, "Terminal step should not have buckets"
|
||||
)
|
||||
self.assertNotIn(
|
||||
"transitions",
|
||||
terminal_step,
|
||||
"Terminal step should not have transitions",
|
||||
)
|
||||
|
||||
# Test activity17 has metadata_remove in list format
|
||||
activity17_path = self.research_dir / "activity17-choose-adventure.yaml"
|
||||
if activity17_path.exists():
|
||||
activity17 = guarded_ai.load_yaml_activity(str(activity17_path))
|
||||
found_metadata_remove = False
|
||||
|
||||
for section in activity17["sections"]:
|
||||
for step in section["steps"]:
|
||||
if "transitions" in step:
|
||||
for transition in step["transitions"].values():
|
||||
if "metadata_remove" in transition:
|
||||
found_metadata_remove = True
|
||||
self.assertIsInstance(
|
||||
transition["metadata_remove"],
|
||||
list,
|
||||
"metadata_remove should be a list",
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
found_metadata_remove,
|
||||
"activity17 should have metadata_remove operations",
|
||||
)
|
||||
|
||||
# Test activity20 has integer buckets
|
||||
activity20_path = self.research_dir / "activity20-n-plus-1.yaml"
|
||||
if activity20_path.exists():
|
||||
activity20 = guarded_ai.load_yaml_activity(str(activity20_path))
|
||||
found_integer_bucket = False
|
||||
|
||||
for section in activity20["sections"]:
|
||||
for step in section["steps"]:
|
||||
if "buckets" in step:
|
||||
for bucket in step["buckets"]:
|
||||
if isinstance(bucket, int):
|
||||
found_integer_bucket = True
|
||||
# Check that transitions exist for integer buckets
|
||||
self.assertIn("transitions", step)
|
||||
# Should have transition for the integer or its string equivalent
|
||||
has_transition = (
|
||||
bucket in step["transitions"]
|
||||
or str(bucket) in step["transitions"]
|
||||
)
|
||||
self.assertTrue(
|
||||
has_transition,
|
||||
f"Integer bucket {bucket} should have corresponding transition",
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
found_integer_bucket, "activity20 should have integer buckets"
|
||||
)
|
||||
|
||||
# Test battleship files have pre_script
|
||||
for battleship_file in [
|
||||
"activity29-battleship.yaml",
|
||||
"activity29-testship.yaml",
|
||||
]:
|
||||
battleship_path = self.research_dir / battleship_file
|
||||
if battleship_path.exists():
|
||||
battleship = guarded_ai.load_yaml_activity(str(battleship_path))
|
||||
found_pre_script = False
|
||||
|
||||
for section in battleship["sections"]:
|
||||
for step in section["steps"]:
|
||||
if "pre_script" in step:
|
||||
found_pre_script = True
|
||||
self.assertIsInstance(step["pre_script"], str)
|
||||
# Should contain win detection logic
|
||||
self.assertIn("user_winning_move", step["pre_script"])
|
||||
self.assertIn("is_game_ending_move", step["pre_script"])
|
||||
|
||||
self.assertTrue(
|
||||
found_pre_script, f"{battleship_file} should have pre_script"
|
||||
)
|
||||
|
||||
def test_bucket_transition_consistency_across_files(self):
|
||||
"""Test that all files have consistent bucket-transition mappings"""
|
||||
inconsistent_files = {}
|
||||
|
||||
for activity_file in self.activity_files:
|
||||
inconsistencies = []
|
||||
|
||||
try:
|
||||
activity = guarded_ai.load_yaml_activity(str(activity_file))
|
||||
|
||||
for section in activity["sections"]:
|
||||
for step in section["steps"]:
|
||||
if "buckets" in step and "transitions" in step:
|
||||
# Check if this step actually has boolean buckets
|
||||
has_boolean_buckets = any(
|
||||
isinstance(b, bool) for b in step["buckets"]
|
||||
)
|
||||
has_integer_buckets = any(
|
||||
isinstance(b, int) for b in step["buckets"]
|
||||
)
|
||||
|
||||
if has_boolean_buckets or has_integer_buckets:
|
||||
# Skip consistency check for boolean/integer buckets as they have special handling
|
||||
# The matching logic in guarded_ai.py handles these conversions
|
||||
continue
|
||||
|
||||
# For string buckets, check normal consistency
|
||||
buckets = set(str(b) for b in step["buckets"])
|
||||
transitions = set(
|
||||
str(k) for k in step["transitions"].keys()
|
||||
)
|
||||
|
||||
# Check for missing transitions
|
||||
missing_transitions = buckets - transitions
|
||||
if missing_transitions:
|
||||
inconsistencies.append(
|
||||
f"Section {section['section_id']} Step {step['step_id']}: "
|
||||
f"Missing transitions for buckets: {missing_transitions}"
|
||||
)
|
||||
|
||||
# Check for extra transitions (less critical)
|
||||
extra_transitions = transitions - buckets
|
||||
# Filter out boolean conversions and integer conversions
|
||||
significant_extras = []
|
||||
for extra in extra_transitions:
|
||||
# Skip if it's a boolean conversion
|
||||
if extra.lower() in ["true", "false"] and any(
|
||||
isinstance(b, bool) for b in step["buckets"]
|
||||
):
|
||||
continue
|
||||
# Skip if it's an integer conversion
|
||||
if extra.isdigit() and any(
|
||||
isinstance(b, int) and str(b) == extra
|
||||
for b in step["buckets"]
|
||||
):
|
||||
continue
|
||||
significant_extras.append(extra)
|
||||
|
||||
if significant_extras:
|
||||
inconsistencies.append(
|
||||
f"Section {section['section_id']} Step {step['step_id']}: "
|
||||
f"Extra transitions without buckets: {significant_extras}"
|
||||
)
|
||||
|
||||
if inconsistencies:
|
||||
inconsistent_files[activity_file.name] = inconsistencies
|
||||
|
||||
except Exception as e:
|
||||
inconsistent_files[activity_file.name] = [f"Failed to check: {e}"]
|
||||
|
||||
if inconsistent_files:
|
||||
failure_msg = "Bucket-transition inconsistencies found:\n"
|
||||
for filename, inconsistencies in inconsistent_files.items():
|
||||
failure_msg += f"\n{filename}:\n"
|
||||
for inconsistency in inconsistencies:
|
||||
failure_msg += f" - {inconsistency}\n"
|
||||
self.fail(failure_msg)
|
||||
|
||||
def test_activity_initialization_simulation(self):
|
||||
"""Test that activities can be initialized for simulation without errors"""
|
||||
initialization_errors = {}
|
||||
warnings = {}
|
||||
|
||||
with patch("guarded_ai.get_openai_client_and_model") as mock_get_client:
|
||||
mock_get_client.return_value = (self.mock_client, "test-model")
|
||||
|
||||
for activity_file in self.activity_files:
|
||||
try:
|
||||
activity = guarded_ai.load_yaml_activity(str(activity_file))
|
||||
|
||||
# Test that we can access the first section and step
|
||||
if activity["sections"]:
|
||||
first_section = activity["sections"][0]
|
||||
if first_section["steps"]:
|
||||
first_step = first_section["steps"][0]
|
||||
|
||||
# Test that required fields are accessible
|
||||
step_id = first_step["step_id"]
|
||||
self.assertIsInstance(step_id, str)
|
||||
|
||||
# If step has content_blocks, they should be a list
|
||||
if "content_blocks" in first_step:
|
||||
self.assertIsInstance(
|
||||
first_step["content_blocks"], list
|
||||
)
|
||||
|
||||
# If step has question, test categorization setup
|
||||
if "question" in first_step:
|
||||
self.assertIn("buckets", first_step)
|
||||
|
||||
# tokens_for_ai is optional but recommended
|
||||
if "tokens_for_ai" not in first_step:
|
||||
warnings[activity_file.name] = (
|
||||
"Missing tokens_for_ai field (recommended for AI categorization)"
|
||||
)
|
||||
|
||||
self.assertIn("transitions", first_step)
|
||||
|
||||
# Test that categorization inputs are valid
|
||||
buckets = first_step["buckets"]
|
||||
self.assertIsInstance(buckets, list)
|
||||
self.assertTrue(len(buckets) > 0)
|
||||
|
||||
except Exception as e:
|
||||
initialization_errors[activity_file.name] = str(e)
|
||||
|
||||
# Report warnings (but don't fail)
|
||||
if warnings:
|
||||
print(f"\n=== Initialization Warnings ===")
|
||||
for filename, warning in warnings.items():
|
||||
print(f" - {filename}: {warning}")
|
||||
|
||||
# Only fail on actual errors
|
||||
if initialization_errors:
|
||||
failure_msg = "Activity initialization errors:\n"
|
||||
for filename, error in initialization_errors.items():
|
||||
failure_msg += f" - {filename}: {error}\n"
|
||||
self.fail(failure_msg)
|
||||
|
||||
def test_metadata_operations_syntax_across_files(self):
|
||||
"""Test that all metadata operations use correct syntax"""
|
||||
syntax_errors = {}
|
||||
|
||||
for activity_file in self.activity_files:
|
||||
errors = []
|
||||
|
||||
try:
|
||||
activity = guarded_ai.load_yaml_activity(str(activity_file))
|
||||
|
||||
for section in activity["sections"]:
|
||||
for step in section["steps"]:
|
||||
if "transitions" in step:
|
||||
for transition_name, transition in step[
|
||||
"transitions"
|
||||
].items():
|
||||
|
||||
# Check metadata_remove format
|
||||
if "metadata_remove" in transition:
|
||||
metadata_remove = transition["metadata_remove"]
|
||||
if not isinstance(metadata_remove, list):
|
||||
errors.append(
|
||||
f"Section {section['section_id']} Step {step['step_id']} "
|
||||
f"Transition {transition_name}: metadata_remove should be a list, "
|
||||
f"got {type(metadata_remove).__name__}"
|
||||
)
|
||||
|
||||
# Check metadata_add values
|
||||
if "metadata_add" in transition:
|
||||
metadata_add = transition["metadata_add"]
|
||||
if not isinstance(metadata_add, dict):
|
||||
errors.append(
|
||||
f"Section {section['section_id']} Step {step['step_id']} "
|
||||
f"Transition {transition_name}: metadata_add should be a dict"
|
||||
)
|
||||
|
||||
# Check metadata_clear format
|
||||
if "metadata_clear" in transition:
|
||||
metadata_clear = transition["metadata_clear"]
|
||||
if not isinstance(metadata_clear, bool):
|
||||
errors.append(
|
||||
f"Section {section['section_id']} Step {step['step_id']} "
|
||||
f"Transition {transition_name}: metadata_clear should be boolean"
|
||||
)
|
||||
|
||||
# Check metadata_feedback_filter format
|
||||
if "metadata_feedback_filter" in transition:
|
||||
metadata_filter = transition[
|
||||
"metadata_feedback_filter"
|
||||
]
|
||||
if not isinstance(metadata_filter, list):
|
||||
errors.append(
|
||||
f"Section {section['section_id']} Step {step['step_id']} "
|
||||
f"Transition {transition_name}: metadata_feedback_filter should be a list"
|
||||
)
|
||||
|
||||
if errors:
|
||||
syntax_errors[activity_file.name] = errors
|
||||
|
||||
except Exception as e:
|
||||
syntax_errors[activity_file.name] = [f"Failed to check syntax: {e}"]
|
||||
|
||||
if syntax_errors:
|
||||
failure_msg = "Metadata operation syntax errors found:\n"
|
||||
for filename, errors in syntax_errors.items():
|
||||
failure_msg += f"\n{filename}:\n"
|
||||
for error in errors:
|
||||
failure_msg += f" - {error}\n"
|
||||
self.fail(failure_msg)
|
||||
|
||||
|
||||
class TestActivityFileStatistics(unittest.TestCase):
|
||||
"""Collect statistics about activity files for reporting"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment"""
|
||||
self.research_dir = Path(__file__).parent.parent.parent / "research"
|
||||
self.activity_files = list(self.research_dir.glob("activity*.yaml"))
|
||||
|
||||
def test_report_activity_file_statistics(self):
|
||||
"""Generate a report of activity file statistics"""
|
||||
stats = {
|
||||
"total_files": len(self.activity_files),
|
||||
"total_sections": 0,
|
||||
"total_steps": 0,
|
||||
"files_with_pre_script": 0,
|
||||
"files_with_processing_script": 0,
|
||||
"files_with_integer_buckets": 0,
|
||||
"files_with_boolean_buckets": 0,
|
||||
"files_with_metadata_operations": 0,
|
||||
}
|
||||
|
||||
for activity_file in self.activity_files:
|
||||
try:
|
||||
activity = guarded_ai.load_yaml_activity(str(activity_file))
|
||||
|
||||
stats["total_sections"] += len(activity["sections"])
|
||||
|
||||
has_pre_script = False
|
||||
has_processing_script = False
|
||||
has_integer_buckets = False
|
||||
has_boolean_buckets = False
|
||||
has_metadata_ops = False
|
||||
|
||||
for section in activity["sections"]:
|
||||
stats["total_steps"] += len(section["steps"])
|
||||
|
||||
for step in section["steps"]:
|
||||
if "pre_script" in step:
|
||||
has_pre_script = True
|
||||
|
||||
if "processing_script" in step:
|
||||
has_processing_script = True
|
||||
|
||||
if "buckets" in step:
|
||||
for bucket in step["buckets"]:
|
||||
if isinstance(bucket, int):
|
||||
has_integer_buckets = True
|
||||
if isinstance(bucket, bool):
|
||||
has_boolean_buckets = True
|
||||
|
||||
if "transitions" in step:
|
||||
for transition in step["transitions"].values():
|
||||
if any(
|
||||
key.startswith("metadata_")
|
||||
for key in transition.keys()
|
||||
):
|
||||
has_metadata_ops = True
|
||||
|
||||
if has_pre_script:
|
||||
stats["files_with_pre_script"] += 1
|
||||
if has_processing_script:
|
||||
stats["files_with_processing_script"] += 1
|
||||
if has_integer_buckets:
|
||||
stats["files_with_integer_buckets"] += 1
|
||||
if has_boolean_buckets:
|
||||
stats["files_with_boolean_buckets"] += 1
|
||||
if has_metadata_ops:
|
||||
stats["files_with_metadata_operations"] += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not analyze {activity_file.name}: {e}")
|
||||
|
||||
# Print the statistics (this will show in test output)
|
||||
print(f"\n=== Activity File Statistics ===")
|
||||
print(f"Total files: {stats['total_files']}")
|
||||
print(f"Total sections: {stats['total_sections']}")
|
||||
print(f"Total steps: {stats['total_steps']}")
|
||||
print(f"Files with pre_script: {stats['files_with_pre_script']}")
|
||||
print(f"Files with processing_script: {stats['files_with_processing_script']}")
|
||||
print(f"Files with integer buckets: {stats['files_with_integer_buckets']}")
|
||||
print(f"Files with boolean buckets: {stats['files_with_boolean_buckets']}")
|
||||
print(
|
||||
f"Files with metadata operations: {stats['files_with_metadata_operations']}"
|
||||
)
|
||||
|
||||
# Test passes if we successfully collected statistics
|
||||
self.assertGreater(stats["total_files"], 0)
|
||||
self.assertGreater(stats["total_sections"], 0)
|
||||
self.assertGreater(stats["total_steps"], 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
|
@ -219,7 +219,9 @@ sections:
|
|||
self.assertFalse(is_valid)
|
||||
# Should only flag the last step of the last section
|
||||
terminal_errors = [e for e in errors if "Final/terminal" in e]
|
||||
self.assertEqual(len(terminal_errors), 2) # One for question, one for buckets
|
||||
self.assertEqual(
|
||||
len(terminal_errors), 2
|
||||
) # One for question, one for buckets
|
||||
self.assertTrue(
|
||||
any(
|
||||
"section_2" in error and "step_2" in error
|
||||
|
|
@ -625,21 +627,49 @@ sections:
|
|||
self.assertEqual(result.returncode, 0)
|
||||
self.assertIn("valid", result.stdout.lower())
|
||||
|
||||
# Test with --strict flag (warnings become errors)
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"activity_yaml_validator.py",
|
||||
"research/activity29-battleship.yaml",
|
||||
"--strict",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=".",
|
||||
)
|
||||
# Create a YAML file that will have warnings (pre_script without question)
|
||||
warning_yaml = """
|
||||
sections:
|
||||
- section_id: "test_section"
|
||||
title: "Test Section"
|
||||
steps:
|
||||
- step_id: "step1"
|
||||
title: "Step with pre_script but no question"
|
||||
content_blocks:
|
||||
- "This step has pre_script but no question - should generate warning"
|
||||
pre_script: |
|
||||
# This pre_script without a question should generate a warning
|
||||
metadata['test'] = 'value'
|
||||
script_result = {'metadata': {}}
|
||||
"""
|
||||
|
||||
# Should fail (exit code 1) because warnings become errors in strict mode
|
||||
self.assertEqual(result.returncode, 1)
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
||||
f.write(warning_yaml)
|
||||
warning_file = f.name
|
||||
|
||||
try:
|
||||
# Test with --strict flag (warnings become errors)
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"activity_yaml_validator.py",
|
||||
warning_file,
|
||||
"--strict",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=".",
|
||||
)
|
||||
|
||||
# Should fail (exit code 1) because warnings become errors in strict mode
|
||||
self.assertEqual(
|
||||
result.returncode,
|
||||
1,
|
||||
f"Expected strict mode to fail with warnings. Output: {result.stdout}",
|
||||
)
|
||||
|
||||
finally:
|
||||
os.unlink(warning_file)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
617
tests/unit/test_yaml_loading.py
Normal file
617
tests/unit/test_yaml_loading.py
Normal file
|
|
@ -0,0 +1,617 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unit tests for activity YAML loading and parsing functionality
|
||||
|
||||
Tests the core YAML loading functions in both app.py and guarded_ai.py
|
||||
to ensure they handle valid YAML, invalid syntax, missing fields,
|
||||
malformed structure, and edge cases correctly.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import tempfile
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
|
||||
# Add research directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "research"))
|
||||
import guarded_ai
|
||||
|
||||
|
||||
class TestYAMLLoading(unittest.TestCase):
|
||||
"""Test YAML loading functionality"""
|
||||
|
||||
def create_test_yaml_file(self, content):
|
||||
"""Create temporary YAML file with given content"""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
||||
f.write(content)
|
||||
return f.name
|
||||
|
||||
def test_valid_yaml_loading(self):
|
||||
"""Test loading valid YAML activity file"""
|
||||
valid_yaml = """
|
||||
sections:
|
||||
- section_id: "test_section"
|
||||
title: "Test Section"
|
||||
steps:
|
||||
- step_id: "step_1"
|
||||
title: "Test Step"
|
||||
content_blocks:
|
||||
- "Welcome to the test!"
|
||||
question: "Ready?"
|
||||
tokens_for_ai: "Categorize as ready or not"
|
||||
buckets:
|
||||
- ready
|
||||
- not_ready
|
||||
transitions:
|
||||
ready:
|
||||
content_blocks:
|
||||
- "Great!"
|
||||
next_section_and_step: "test_section:step_2"
|
||||
not_ready:
|
||||
content_blocks:
|
||||
- "Take your time."
|
||||
- step_id: "step_2"
|
||||
title: "Final Step"
|
||||
content_blocks:
|
||||
- "All done!"
|
||||
"""
|
||||
|
||||
yaml_file = self.create_test_yaml_file(valid_yaml)
|
||||
try:
|
||||
activity = guarded_ai.load_yaml_activity(yaml_file)
|
||||
|
||||
# Verify basic structure
|
||||
self.assertIn("sections", activity)
|
||||
self.assertEqual(len(activity["sections"]), 1)
|
||||
|
||||
section = activity["sections"][0]
|
||||
self.assertEqual(section["section_id"], "test_section")
|
||||
self.assertEqual(section["title"], "Test Section")
|
||||
self.assertEqual(len(section["steps"]), 2)
|
||||
|
||||
# Verify first step
|
||||
step1 = section["steps"][0]
|
||||
self.assertEqual(step1["step_id"], "step_1")
|
||||
self.assertEqual(step1["title"], "Test Step")
|
||||
self.assertIn("content_blocks", step1)
|
||||
self.assertIn("question", step1)
|
||||
self.assertIn("buckets", step1)
|
||||
self.assertIn("transitions", step1)
|
||||
|
||||
# Verify transitions
|
||||
self.assertIn("ready", step1["transitions"])
|
||||
self.assertIn("not_ready", step1["transitions"])
|
||||
|
||||
finally:
|
||||
os.unlink(yaml_file)
|
||||
|
||||
def test_invalid_yaml_syntax(self):
|
||||
"""Test handling of invalid YAML syntax"""
|
||||
invalid_yaml = """
|
||||
sections:
|
||||
- section_id: "test"
|
||||
title: "Test"
|
||||
steps:
|
||||
- step_id: "step1"
|
||||
title: [invalid: yaml: syntax
|
||||
"""
|
||||
|
||||
yaml_file = self.create_test_yaml_file(invalid_yaml)
|
||||
try:
|
||||
with self.assertRaises(yaml.YAMLError):
|
||||
guarded_ai.load_yaml_activity(yaml_file)
|
||||
finally:
|
||||
os.unlink(yaml_file)
|
||||
|
||||
def test_missing_file(self):
|
||||
"""Test handling of missing YAML file"""
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
guarded_ai.load_yaml_activity("/nonexistent/path/file.yaml")
|
||||
|
||||
def test_empty_yaml_file(self):
|
||||
"""Test handling of empty YAML file"""
|
||||
yaml_file = self.create_test_yaml_file("")
|
||||
try:
|
||||
activity = guarded_ai.load_yaml_activity(yaml_file)
|
||||
self.assertIsNone(activity)
|
||||
finally:
|
||||
os.unlink(yaml_file)
|
||||
|
||||
def test_yaml_with_missing_sections(self):
|
||||
"""Test YAML without required sections field"""
|
||||
incomplete_yaml = """
|
||||
title: "Test Activity"
|
||||
description: "A test activity"
|
||||
"""
|
||||
|
||||
yaml_file = self.create_test_yaml_file(incomplete_yaml)
|
||||
try:
|
||||
activity = guarded_ai.load_yaml_activity(yaml_file)
|
||||
# Should load but won't have sections
|
||||
self.assertNotIn("sections", activity)
|
||||
self.assertIn("title", activity)
|
||||
finally:
|
||||
os.unlink(yaml_file)
|
||||
|
||||
def test_yaml_with_empty_sections(self):
|
||||
"""Test YAML with empty sections list"""
|
||||
empty_sections_yaml = """
|
||||
sections: []
|
||||
"""
|
||||
|
||||
yaml_file = self.create_test_yaml_file(empty_sections_yaml)
|
||||
try:
|
||||
activity = guarded_ai.load_yaml_activity(yaml_file)
|
||||
self.assertIn("sections", activity)
|
||||
self.assertEqual(len(activity["sections"]), 0)
|
||||
finally:
|
||||
os.unlink(yaml_file)
|
||||
|
||||
def test_yaml_with_malformed_section_structure(self):
|
||||
"""Test YAML with malformed section structure"""
|
||||
malformed_yaml = """
|
||||
sections:
|
||||
- section_id: "test"
|
||||
# Missing title
|
||||
steps: "not_a_list" # Should be a list
|
||||
"""
|
||||
|
||||
yaml_file = self.create_test_yaml_file(malformed_yaml)
|
||||
try:
|
||||
activity = guarded_ai.load_yaml_activity(yaml_file)
|
||||
# Should load but structure will be wrong
|
||||
section = activity["sections"][0]
|
||||
self.assertEqual(section["steps"], "not_a_list") # String instead of list
|
||||
self.assertNotIn("title", section)
|
||||
finally:
|
||||
os.unlink(yaml_file)
|
||||
|
||||
def test_yaml_with_integer_and_boolean_buckets(self):
|
||||
"""Test YAML with integer and boolean bucket values"""
|
||||
mixed_buckets_yaml = """
|
||||
sections:
|
||||
- section_id: "quiz"
|
||||
title: "Quiz Section"
|
||||
steps:
|
||||
- step_id: "question1"
|
||||
title: "Year Question"
|
||||
question: "What year?"
|
||||
tokens_for_ai: "Categorize response"
|
||||
buckets:
|
||||
- 1912
|
||||
- 2000
|
||||
- incorrect
|
||||
transitions:
|
||||
1912:
|
||||
content_blocks:
|
||||
- "Correct year!"
|
||||
2000:
|
||||
content_blocks:
|
||||
- "Wrong year!"
|
||||
incorrect:
|
||||
content_blocks:
|
||||
- "Invalid input!"
|
||||
- step_id: "question2"
|
||||
title: "Yes/No Question"
|
||||
question: "Do you agree?"
|
||||
tokens_for_ai: "Categorize response"
|
||||
buckets:
|
||||
- true
|
||||
- false
|
||||
transitions:
|
||||
true:
|
||||
content_blocks:
|
||||
- "You agreed!"
|
||||
false:
|
||||
content_blocks:
|
||||
- "You disagreed!"
|
||||
"""
|
||||
|
||||
yaml_file = self.create_test_yaml_file(mixed_buckets_yaml)
|
||||
try:
|
||||
activity = guarded_ai.load_yaml_activity(yaml_file)
|
||||
|
||||
# Check integer buckets
|
||||
step1 = activity["sections"][0]["steps"][0]
|
||||
self.assertIn(1912, step1["buckets"])
|
||||
self.assertIn(2000, step1["buckets"])
|
||||
self.assertIn("incorrect", step1["buckets"])
|
||||
|
||||
# Check transitions with integer keys
|
||||
self.assertIn(1912, step1["transitions"])
|
||||
self.assertIn(2000, step1["transitions"])
|
||||
|
||||
# Check boolean buckets
|
||||
step2 = activity["sections"][0]["steps"][1]
|
||||
self.assertIn(True, step2["buckets"])
|
||||
self.assertIn(False, step2["buckets"])
|
||||
|
||||
# Check transitions with boolean keys
|
||||
self.assertIn(True, step2["transitions"])
|
||||
self.assertIn(False, step2["transitions"])
|
||||
|
||||
finally:
|
||||
os.unlink(yaml_file)
|
||||
|
||||
def test_yaml_with_metadata_operations(self):
|
||||
"""Test YAML with various metadata operation formats"""
|
||||
metadata_yaml = """
|
||||
sections:
|
||||
- section_id: "metadata_test"
|
||||
title: "Metadata Test"
|
||||
steps:
|
||||
- step_id: "operations"
|
||||
title: "Metadata Operations"
|
||||
question: "Test?"
|
||||
tokens_for_ai: "Always test"
|
||||
buckets:
|
||||
- test
|
||||
transitions:
|
||||
test:
|
||||
metadata_add:
|
||||
user_name: "the-users-response"
|
||||
score: "n+1"
|
||||
level: 5
|
||||
metadata_remove:
|
||||
- old_key
|
||||
- temp_data
|
||||
metadata_clear: true
|
||||
metadata_feedback_filter:
|
||||
- score
|
||||
- level
|
||||
"""
|
||||
|
||||
yaml_file = self.create_test_yaml_file(metadata_yaml)
|
||||
try:
|
||||
activity = guarded_ai.load_yaml_activity(yaml_file)
|
||||
|
||||
transition = activity["sections"][0]["steps"][0]["transitions"]["test"]
|
||||
|
||||
# Check metadata_add operations
|
||||
self.assertIn("metadata_add", transition)
|
||||
self.assertEqual(
|
||||
transition["metadata_add"]["user_name"], "the-users-response"
|
||||
)
|
||||
self.assertEqual(transition["metadata_add"]["score"], "n+1")
|
||||
self.assertEqual(transition["metadata_add"]["level"], 5)
|
||||
|
||||
# Check metadata_remove is list format
|
||||
self.assertIn("metadata_remove", transition)
|
||||
self.assertIsInstance(transition["metadata_remove"], list)
|
||||
self.assertIn("old_key", transition["metadata_remove"])
|
||||
self.assertIn("temp_data", transition["metadata_remove"])
|
||||
|
||||
# Check metadata_clear
|
||||
self.assertEqual(transition["metadata_clear"], True)
|
||||
|
||||
# Check metadata_feedback_filter
|
||||
self.assertIn("metadata_feedback_filter", transition)
|
||||
self.assertIsInstance(transition["metadata_feedback_filter"], list)
|
||||
|
||||
finally:
|
||||
os.unlink(yaml_file)
|
||||
|
||||
def test_yaml_with_processing_scripts(self):
|
||||
"""Test YAML with processing and pre-scripts"""
|
||||
script_yaml = """
|
||||
sections:
|
||||
- section_id: "script_test"
|
||||
title: "Script Test"
|
||||
steps:
|
||||
- step_id: "with_scripts"
|
||||
title: "Scripts Step"
|
||||
question: "Enter data:"
|
||||
pre_script: |
|
||||
user_input = metadata.get("user_response", "")
|
||||
script_result = {
|
||||
"metadata": {
|
||||
"processed_input": user_input.upper()
|
||||
}
|
||||
}
|
||||
processing_script: |
|
||||
processed = metadata.get("processed_input", "")
|
||||
script_result = {
|
||||
"metadata": {
|
||||
"final_result": f"Result: {processed}"
|
||||
}
|
||||
}
|
||||
tokens_for_ai: "Categorize as valid"
|
||||
buckets:
|
||||
- valid
|
||||
transitions:
|
||||
valid:
|
||||
run_processing_script: true
|
||||
content_blocks:
|
||||
- "Processing completed!"
|
||||
"""
|
||||
|
||||
yaml_file = self.create_test_yaml_file(script_yaml)
|
||||
try:
|
||||
activity = guarded_ai.load_yaml_activity(yaml_file)
|
||||
|
||||
step = activity["sections"][0]["steps"][0]
|
||||
|
||||
# Check scripts are loaded as strings
|
||||
self.assertIn("pre_script", step)
|
||||
self.assertIsInstance(step["pre_script"], str)
|
||||
self.assertIn("user_input", step["pre_script"])
|
||||
|
||||
self.assertIn("processing_script", step)
|
||||
self.assertIsInstance(step["processing_script"], str)
|
||||
self.assertIn("processed", step["processing_script"])
|
||||
|
||||
# Check transition has run_processing_script flag
|
||||
transition = step["transitions"]["valid"]
|
||||
self.assertTrue(transition["run_processing_script"])
|
||||
|
||||
finally:
|
||||
os.unlink(yaml_file)
|
||||
|
||||
def test_yaml_with_nested_structures(self):
|
||||
"""Test YAML with complex nested structures"""
|
||||
nested_yaml = """
|
||||
sections:
|
||||
- section_id: "complex"
|
||||
title: "Complex Section"
|
||||
steps:
|
||||
- step_id: "nested"
|
||||
title: "Nested Step"
|
||||
question: "Complex question?"
|
||||
tokens_for_ai: "Complex categorization"
|
||||
buckets:
|
||||
- option_a
|
||||
- option_b
|
||||
transitions:
|
||||
option_a:
|
||||
content_blocks:
|
||||
- "First block"
|
||||
- "Second block"
|
||||
- "Third block"
|
||||
metadata_add:
|
||||
nested_data:
|
||||
sub_field: "value"
|
||||
number: 42
|
||||
list_field:
|
||||
- "item1"
|
||||
- "item2"
|
||||
metadata_conditions:
|
||||
required_field: "required_value"
|
||||
level: 5
|
||||
ai_feedback:
|
||||
tokens_for_ai: "Provide detailed feedback"
|
||||
option_b:
|
||||
content_blocks:
|
||||
- "Alternative path"
|
||||
next_section_and_step: "complex:final"
|
||||
- step_id: "final"
|
||||
title: "Final"
|
||||
content_blocks:
|
||||
- "Done!"
|
||||
"""
|
||||
|
||||
yaml_file = self.create_test_yaml_file(nested_yaml)
|
||||
try:
|
||||
activity = guarded_ai.load_yaml_activity(yaml_file)
|
||||
|
||||
step = activity["sections"][0]["steps"][0]
|
||||
transition_a = step["transitions"]["option_a"]
|
||||
|
||||
# Check nested metadata structure
|
||||
nested_data = transition_a["metadata_add"]["nested_data"]
|
||||
self.assertEqual(nested_data["sub_field"], "value")
|
||||
self.assertEqual(nested_data["number"], 42)
|
||||
self.assertIsInstance(nested_data["list_field"], list)
|
||||
self.assertEqual(len(nested_data["list_field"]), 2)
|
||||
|
||||
# Check metadata conditions
|
||||
conditions = transition_a["metadata_conditions"]
|
||||
self.assertEqual(conditions["required_field"], "required_value")
|
||||
self.assertEqual(conditions["level"], 5)
|
||||
|
||||
# Check AI feedback structure
|
||||
ai_feedback = transition_a["ai_feedback"]
|
||||
self.assertIn("tokens_for_ai", ai_feedback)
|
||||
|
||||
finally:
|
||||
os.unlink(yaml_file)
|
||||
|
||||
|
||||
class TestActivityYAMLStructureValidation(unittest.TestCase):
|
||||
"""Test validation of loaded YAML structure"""
|
||||
|
||||
def create_test_yaml_file(self, content):
|
||||
"""Create temporary YAML file with given content"""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
||||
f.write(content)
|
||||
return f.name
|
||||
|
||||
def test_step_id_uniqueness_within_section(self):
|
||||
"""Test that step IDs are unique within a section"""
|
||||
duplicate_step_yaml = """
|
||||
sections:
|
||||
- section_id: "test"
|
||||
title: "Test"
|
||||
steps:
|
||||
- step_id: "step1"
|
||||
title: "First"
|
||||
content_blocks:
|
||||
- "First step"
|
||||
- step_id: "step1" # Duplicate!
|
||||
title: "Second"
|
||||
content_blocks:
|
||||
- "Second step"
|
||||
"""
|
||||
|
||||
yaml_file = self.create_test_yaml_file(duplicate_step_yaml)
|
||||
try:
|
||||
activity = guarded_ai.load_yaml_activity(yaml_file)
|
||||
|
||||
# Should load, but we can detect duplicates
|
||||
step_ids = [step["step_id"] for step in activity["sections"][0]["steps"]]
|
||||
unique_step_ids = set(step_ids)
|
||||
|
||||
self.assertNotEqual(len(step_ids), len(unique_step_ids)) # Has duplicates
|
||||
|
||||
finally:
|
||||
os.unlink(yaml_file)
|
||||
|
||||
def test_section_id_uniqueness(self):
|
||||
"""Test that section IDs are unique"""
|
||||
duplicate_section_yaml = """
|
||||
sections:
|
||||
- section_id: "same"
|
||||
title: "First Section"
|
||||
steps:
|
||||
- step_id: "step1"
|
||||
title: "Step 1"
|
||||
content_blocks:
|
||||
- "Content 1"
|
||||
- section_id: "same" # Duplicate!
|
||||
title: "Second Section"
|
||||
steps:
|
||||
- step_id: "step1"
|
||||
title: "Step 1"
|
||||
content_blocks:
|
||||
- "Content 2"
|
||||
"""
|
||||
|
||||
yaml_file = self.create_test_yaml_file(duplicate_section_yaml)
|
||||
try:
|
||||
activity = guarded_ai.load_yaml_activity(yaml_file)
|
||||
|
||||
# Should load, but we can detect duplicates
|
||||
section_ids = [section["section_id"] for section in activity["sections"]]
|
||||
unique_section_ids = set(section_ids)
|
||||
|
||||
self.assertNotEqual(
|
||||
len(section_ids), len(unique_section_ids)
|
||||
) # Has duplicates
|
||||
|
||||
finally:
|
||||
os.unlink(yaml_file)
|
||||
|
||||
def test_transition_references(self):
|
||||
"""Test that transitions reference valid section:step combinations"""
|
||||
invalid_reference_yaml = """
|
||||
sections:
|
||||
- section_id: "section1"
|
||||
title: "Section 1"
|
||||
steps:
|
||||
- step_id: "step1"
|
||||
title: "Step 1"
|
||||
question: "Continue?"
|
||||
tokens_for_ai: "Categorize"
|
||||
buckets:
|
||||
- "yes"
|
||||
transitions:
|
||||
"yes":
|
||||
next_section_and_step: "nonexistent:step1" # Invalid reference
|
||||
"""
|
||||
|
||||
yaml_file = self.create_test_yaml_file(invalid_reference_yaml)
|
||||
try:
|
||||
activity = guarded_ai.load_yaml_activity(yaml_file)
|
||||
|
||||
# YAML loads successfully but reference is invalid
|
||||
step = activity["sections"][0]["steps"][0]
|
||||
self.assertIn("transitions", step)
|
||||
self.assertIn("yes", step["transitions"])
|
||||
|
||||
transition = step["transitions"]["yes"]
|
||||
next_ref = transition["next_section_and_step"]
|
||||
section_id, step_id = next_ref.split(":")
|
||||
|
||||
# Check if referenced section exists
|
||||
referenced_section = None
|
||||
for section in activity["sections"]:
|
||||
if section["section_id"] == section_id:
|
||||
referenced_section = section
|
||||
break
|
||||
|
||||
self.assertIsNone(referenced_section) # Should not exist
|
||||
|
||||
finally:
|
||||
os.unlink(yaml_file)
|
||||
|
||||
def test_bucket_transition_consistency(self):
|
||||
"""Test that all buckets have corresponding transitions"""
|
||||
inconsistent_yaml = """
|
||||
sections:
|
||||
- section_id: "test"
|
||||
title: "Test"
|
||||
steps:
|
||||
- step_id: "step1"
|
||||
title: "Step 1"
|
||||
question: "Choose option:"
|
||||
tokens_for_ai: "Categorize"
|
||||
buckets:
|
||||
- option_a
|
||||
- option_b
|
||||
- option_c
|
||||
transitions:
|
||||
option_a:
|
||||
content_blocks:
|
||||
- "Option A selected"
|
||||
option_b:
|
||||
content_blocks:
|
||||
- "Option B selected"
|
||||
# Missing option_c transition!
|
||||
"""
|
||||
|
||||
yaml_file = self.create_test_yaml_file(inconsistent_yaml)
|
||||
try:
|
||||
activity = guarded_ai.load_yaml_activity(yaml_file)
|
||||
|
||||
step = activity["sections"][0]["steps"][0]
|
||||
buckets = set(step["buckets"])
|
||||
transition_keys = set(step["transitions"].keys())
|
||||
|
||||
# Check for missing transitions
|
||||
missing_transitions = buckets - transition_keys
|
||||
self.assertTrue(
|
||||
len(missing_transitions) > 0
|
||||
) # Should have missing transitions
|
||||
self.assertIn("option_c", missing_transitions)
|
||||
|
||||
finally:
|
||||
os.unlink(yaml_file)
|
||||
|
||||
|
||||
class TestRealYAMLFiles(unittest.TestCase):
|
||||
"""Test loading of real YAML files from the project"""
|
||||
|
||||
def test_load_existing_activity_files(self):
|
||||
"""Test loading existing activity files"""
|
||||
research_dir = Path(__file__).parent.parent.parent / "research"
|
||||
yaml_files = list(research_dir.glob("activity*.yaml"))
|
||||
|
||||
self.assertTrue(len(yaml_files) > 0, "Should find activity YAML files")
|
||||
|
||||
for yaml_file in yaml_files[:5]: # Test first 5 files
|
||||
with self.subTest(file=yaml_file.name):
|
||||
try:
|
||||
activity = guarded_ai.load_yaml_activity(str(yaml_file))
|
||||
|
||||
# Basic structure checks
|
||||
self.assertIsInstance(activity, dict)
|
||||
self.assertIn("sections", activity)
|
||||
self.assertIsInstance(activity["sections"], list)
|
||||
|
||||
if activity["sections"]:
|
||||
section = activity["sections"][0]
|
||||
self.assertIn("section_id", section)
|
||||
self.assertIn("steps", section)
|
||||
self.assertIsInstance(section["steps"], list)
|
||||
|
||||
if section["steps"]:
|
||||
step = section["steps"][0]
|
||||
self.assertIn("step_id", step)
|
||||
|
||||
except Exception as e:
|
||||
self.fail(f"Failed to load {yaml_file.name}: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Loading…
Add table
Add a link
Reference in a new issue