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:
Russell Ballestrini 2025-08-10 19:32:49 -04:00
parent 51b74be7d9
commit 1b44c2d66b
16 changed files with 2803 additions and 104 deletions

View 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)

View 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)

View 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)

View 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)

View file

@ -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__":

View 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)