opencompletion.com/tests/unit/test_activity_yaml_validator.py
Russell Ballestrini f90df2ae57 modified: activity_yaml_validator.py
modified:   app.py
	modified:   research/activity29-battleship.yaml
	modified:   research/activity29-testship.yaml
	modified:   research/guarded_ai.py
	modified:   tests/functional/test_activity_flows.py
	modified:   tests/functional/test_battleship_pre_script.py
	modified:   tests/functional/test_guarded_ai.py
	modified:   tests/unit/test_activity_yaml_validator.py
	modified:   tests/unit/test_app_feedback.py
	modified:   tests/unit/test_guarded_ai.py
2025-08-11 12:39:42 -04:00

818 lines
25 KiB
Python

#!/usr/bin/env python3
"""
Unit tests for the activity_yaml_validator.py module.
Tests all validation features including:
- YAML syntax validation
- Structure validation
- Metadata operations validation
- Python code validation
- Logic flow validation
- Terminal step validation
"""
import unittest
import tempfile
import os
import sys
from pathlib import Path
# Add parent directory to path to import the validator
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from activity_yaml_validator import ActivityYAMLValidator, ValidationError
class TestActivityYAMLValidator(unittest.TestCase):
"""Test cases for ActivityYAMLValidator"""
def setUp(self):
"""Set up test fixtures"""
self.validator = ActivityYAMLValidator()
def create_temp_yaml(self, content: str) -> str:
"""Create a temporary YAML file with given content"""
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
f.write(content)
return f.name
def tearDown(self):
"""Clean up any temporary files"""
# Clean up is handled by tempfile
pass
def test_valid_yaml_passes(self):
"""Test that a valid YAML file passes validation"""
valid_yaml = """
default_max_attempts_per_step: 3
tokens_for_ai_rubric: "Test rubric"
sections:
- section_id: "section_1"
title: "Test Section"
steps:
- step_id: "step_1"
title: "Question Step"
question: "What do you want?"
tokens_for_ai: "Categorize response"
feedback_tokens_for_ai: "Provide feedback"
buckets:
- valid
- invalid
transitions:
valid:
content_blocks:
- "Great!"
next_section_and_step: "section_1:step_2"
invalid:
content_blocks:
- "Try again"
next_section_and_step: "section_1:step_1"
- step_id: "step_2"
title: "Final Step"
content_blocks:
- "All done!"
"""
temp_file = self.create_temp_yaml(valid_yaml)
try:
is_valid, errors, warnings = self.validator.validate_file(temp_file)
self.assertTrue(is_valid)
self.assertEqual(len(errors), 0)
finally:
os.unlink(temp_file)
def test_yaml_syntax_error(self):
"""Test that YAML syntax errors are caught"""
invalid_yaml = """
sections:
- section_id: "test"
title: "Test"
steps:
- step_id: "step1"
title: "Test Step"
content_blocks:
- "Test"
invalid_key: [unclosed list
"""
temp_file = self.create_temp_yaml(invalid_yaml)
try:
is_valid, errors, warnings = self.validator.validate_file(temp_file)
self.assertFalse(is_valid)
self.assertGreater(len(errors), 0)
self.assertIn("YAML syntax error", errors[0])
finally:
os.unlink(temp_file)
def test_missing_required_fields(self):
"""Test that missing required fields are caught"""
missing_sections = """
default_max_attempts_per_step: 3
"""
temp_file = self.create_temp_yaml(missing_sections)
try:
is_valid, errors, warnings = self.validator.validate_file(temp_file)
self.assertFalse(is_valid)
self.assertIn("Missing required field: sections", errors)
finally:
os.unlink(temp_file)
def test_invalid_field_types(self):
"""Test that invalid field types are caught"""
invalid_types = """
default_max_attempts_per_step: "should_be_integer"
tokens_for_ai_rubric: 123
sections:
- section_id: "test"
title: "Test"
steps: "should_be_list"
"""
temp_file = self.create_temp_yaml(invalid_types)
try:
is_valid, errors, warnings = self.validator.validate_file(temp_file)
self.assertFalse(is_valid)
self.assertTrue(
any("must be a positive integer" in error for error in errors)
)
self.assertTrue(any("must be a string" in error for error in errors))
finally:
os.unlink(temp_file)
def test_duplicate_ids(self):
"""Test that duplicate section and step IDs are caught"""
duplicate_ids = """
sections:
- section_id: "duplicate"
title: "First Section"
steps:
- step_id: "step_duplicate"
title: "First Step"
content_blocks:
- "Content"
- step_id: "step_duplicate"
title: "Second Step"
content_blocks:
- "More content"
- section_id: "duplicate"
title: "Second Section"
steps:
- step_id: "step_1"
title: "Step"
content_blocks:
- "Content"
"""
temp_file = self.create_temp_yaml(duplicate_ids)
try:
is_valid, errors, warnings = self.validator.validate_file(temp_file)
self.assertFalse(is_valid)
self.assertTrue(any("Duplicate section_id" in error for error in errors))
self.assertTrue(any("Duplicate step_id" in error for error in errors))
finally:
os.unlink(temp_file)
def test_terminal_step_validation(self):
"""Test that terminal steps cannot have questions or buckets"""
terminal_with_question = """
sections:
- section_id: "section_1"
title: "First Section"
steps:
- step_id: "step_1"
title: "First Step"
content_blocks:
- "This step is fine"
- step_id: "step_2"
title: "Also fine"
question: "Questions are OK in non-terminal steps"
buckets: ["yes", "no"]
transitions:
yes:
content_blocks: ["Good"]
next_section_and_step: "section_2:step_1"
no:
content_blocks: ["Try again"]
- section_id: "section_2"
title: "Last Section"
steps:
- step_id: "step_1"
title: "Not terminal - has another step after"
question: "This is OK"
buckets: ["answer"]
transitions:
answer:
content_blocks: ["Continue"]
- step_id: "step_2"
title: "This is the real terminal step"
question: "This is invalid"
buckets:
- some_bucket
transitions:
some_bucket:
content_blocks:
- "Done"
# No next_section_and_step and last step of last section = terminal
"""
temp_file = self.create_temp_yaml(terminal_with_question)
try:
is_valid, errors, warnings = self.validator.validate_file(temp_file)
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.assertTrue(
any(
"section_2" in error and "step_2" in error
for error in terminal_errors
)
)
finally:
os.unlink(temp_file)
def test_metadata_operations_validation(self):
"""Test validation of metadata operations"""
metadata_test = """
sections:
- section_id: "section_1"
title: "Test"
steps:
- step_id: "step_1"
title: "Test Step"
question: "Test?"
buckets:
- test
transitions:
test:
metadata_clear: "should_be_boolean"
metadata_feedback_filter: "should_be_list"
metadata_remove: 123
metadata_add: "should_be_dict"
next_section_and_step: "section_1:step_2"
- step_id: "step_2"
title: "Final"
content_blocks:
- "Done"
"""
temp_file = self.create_temp_yaml(metadata_test)
try:
is_valid, errors, warnings = self.validator.validate_file(temp_file)
self.assertFalse(is_valid)
self.assertTrue(
any("metadata_clear' must be boolean" in error for error in errors)
)
self.assertTrue(
any(
"metadata_feedback_filter' must be a list" in error
for error in errors
)
)
self.assertTrue(
any(
"metadata_remove' must be a string or list of strings" in error
for error in errors
)
)
self.assertTrue(
any("metadata_add' must be a dictionary" in error for error in errors)
)
finally:
os.unlink(temp_file)
def test_valid_metadata_operations(self):
"""Test that valid metadata operations pass"""
valid_metadata = """
sections:
- section_id: "section_1"
title: "Test"
steps:
- step_id: "step_1"
title: "Test Step"
question: "Test?"
feedback_tokens_for_ai: "Provide feedback"
buckets:
- test
transitions:
test:
metadata_clear: true
metadata_feedback_filter:
- "field1"
- "field2"
metadata_remove: "single_field"
metadata_add:
new_field: "value"
next_section_and_step: "section_1:step_2"
- step_id: "step_2"
title: "Test Step 2"
question: "Another test?"
buckets:
- test2
transitions:
test2:
metadata_remove:
- "field1"
- "field2"
next_section_and_step: "section_1:step_3"
- step_id: "step_3"
title: "Final"
content_blocks:
- "Done"
"""
temp_file = self.create_temp_yaml(valid_metadata)
try:
is_valid, errors, warnings = self.validator.validate_file(temp_file)
self.assertTrue(is_valid)
self.assertEqual(len(errors), 0)
finally:
os.unlink(temp_file)
def test_python_syntax_validation(self):
"""Test that Python syntax errors in scripts are caught"""
python_syntax_error = """
sections:
- section_id: "section_1"
title: "Test"
steps:
- step_id: "step_1"
title: "Test Step"
question: "Test?"
pre_script: |
if True # Missing colon
print("error")
processing_script: |
def invalid_function(
# Missing closing parenthesis
pass
buckets:
- test
transitions:
test:
next_section_and_step: "section_1:step_2"
- step_id: "step_2"
title: "Final"
content_blocks:
- "Done"
"""
temp_file = self.create_temp_yaml(python_syntax_error)
try:
is_valid, errors, warnings = self.validator.validate_file(temp_file)
self.assertFalse(is_valid)
self.assertTrue(any("Python syntax error" in error for error in errors))
finally:
os.unlink(temp_file)
def test_invalid_transitions(self):
"""Test validation of transition references"""
invalid_transitions = """
sections:
- section_id: "section_1"
title: "Test"
steps:
- step_id: "step_1"
title: "Test Step"
question: "Test?"
buckets:
- valid_bucket
- another_bucket
transitions:
valid_bucket:
next_section_and_step: "nonexistent_section:step_1"
another_bucket:
next_section_and_step: "invalid_format"
unused_transition:
content_blocks:
- "This transition has no corresponding bucket"
"""
temp_file = self.create_temp_yaml(invalid_transitions)
try:
is_valid, errors, warnings = self.validator.validate_file(temp_file)
self.assertFalse(is_valid)
# Should have errors for invalid transition targets and missing transitions
self.assertTrue(
any("Invalid transition target" in error for error in errors)
)
self.assertTrue(
any(
"must be in format 'section_id:step_id'" in error
for error in errors
)
)
# Should have warnings for unused transitions
self.assertTrue(any("Unused transition" in warning for warning in warnings))
finally:
os.unlink(temp_file)
def test_metadata_feedback_filter_warning(self):
"""Test warning when metadata_feedback_filter used without feedback_tokens_for_ai"""
metadata_filter_no_feedback = """
sections:
- section_id: "section_1"
title: "Test"
steps:
- step_id: "step_1"
title: "Test Step"
question: "Test?"
buckets:
- test
transitions:
test:
metadata_feedback_filter:
- "field1"
next_section_and_step: "section_1:step_2"
- step_id: "step_2"
title: "Final"
content_blocks:
- "Done"
"""
temp_file = self.create_temp_yaml(metadata_filter_no_feedback)
try:
is_valid, errors, warnings = self.validator.validate_file(temp_file)
self.assertTrue(is_valid) # Should be valid but with warning
self.assertTrue(
any(
"metadata_feedback_filter used but no feedback_tokens_for_ai"
in warning
for warning in warnings
)
)
finally:
os.unlink(temp_file)
def test_pre_script_warning(self):
"""Test warning when pre_script used without question"""
pre_script_no_question = """
sections:
- section_id: "section_1"
title: "Test"
steps:
- step_id: "step_1"
title: "Test Step"
content_blocks:
- "Content"
pre_script: |
print("This is unusual without a question")
"""
temp_file = self.create_temp_yaml(pre_script_no_question)
try:
is_valid, errors, warnings = self.validator.validate_file(temp_file)
self.assertTrue(is_valid) # Should be valid but with warning
self.assertTrue(
any(
"pre_script typically used with question steps" in warning
for warning in warnings
)
)
finally:
os.unlink(temp_file)
def test_empty_else_block_detection(self):
"""Test detection of empty else blocks in Python code"""
empty_else_block = """
sections:
- section_id: "section_1"
title: "Test"
steps:
- step_id: "step_1"
title: "Test Step"
question: "Test?"
processing_script: |
if condition:
do_something()
else:
# Only comments here, should trigger error
buckets:
- test
transitions:
test:
next_section_and_step: "section_1:step_2"
- step_id: "step_2"
title: "Final"
content_blocks:
- "Done"
"""
temp_file = self.create_temp_yaml(empty_else_block)
try:
is_valid, errors, warnings = self.validator.validate_file(temp_file)
# This should detect the empty else block
self.assertTrue(
any("'else:' block contains only comments" in error for error in errors)
)
finally:
os.unlink(temp_file)
def test_content_blocks_validation(self):
"""Test validation of content_blocks structure"""
invalid_content_blocks = """
sections:
- section_id: "section_1"
title: "Test"
steps:
- step_id: "step_1"
title: "Test Step"
content_blocks: "should_be_list"
- step_id: "step_2"
title: "Another Test"
content_blocks:
- "Valid string"
- 123 # Should be string
- "Another valid string"
"""
temp_file = self.create_temp_yaml(invalid_content_blocks)
try:
is_valid, errors, warnings = self.validator.validate_file(temp_file)
self.assertFalse(is_valid)
self.assertTrue(
any("content_blocks must be a list" in error for error in errors)
)
self.assertTrue(any("must be a string" in error for error in errors))
finally:
os.unlink(temp_file)
def test_transition_fields_validation(self):
"""Test validation of various transition fields"""
invalid_transition_fields = """
sections:
- section_id: "section_1"
title: "Test"
steps:
- step_id: "step_1"
title: "Test Step"
question: "Test?"
buckets:
- test
transitions:
test:
run_processing_script: "should_be_boolean"
ai_feedback: "should_be_dict"
content_blocks: "should_be_list"
next_section_and_step: "section_1:step_2"
- step_id: "step_2"
title: "Another Test"
question: "Test?"
buckets:
- test2
transitions:
test2:
ai_feedback:
tokens_for_ai: 123 # Should be string
content_blocks:
- "Valid"
- 456 # Should be string
next_section_and_step: "section_1:step_3"
- step_id: "step_3"
title: "Final"
content_blocks:
- "Done"
"""
temp_file = self.create_temp_yaml(invalid_transition_fields)
try:
is_valid, errors, warnings = self.validator.validate_file(temp_file)
self.assertFalse(is_valid)
self.assertTrue(
any(
"run_processing_script' must be boolean" in error
for error in errors
)
)
self.assertTrue(
any("ai_feedback' must be a dictionary" in error for error in errors)
)
self.assertTrue(
any("tokens_for_ai must be a string" in error for error in errors)
)
self.assertTrue(
any("content_blocks' must be a list" in error for error in errors)
)
finally:
os.unlink(temp_file)
def test_using_existing_failing_fixture(self):
"""Test using the existing failing fixture we created"""
fixture_path = "tests/fixtures/test_invalid.yaml"
if os.path.exists(fixture_path):
is_valid, errors, warnings = self.validator.validate_file(fixture_path)
self.assertFalse(is_valid)
self.assertGreater(len(errors), 0)
# Should catch the YAML syntax error we know is in there
self.assertTrue(any("YAML syntax error" in error for error in errors))
def test_feedback_prompts_validation(self):
"""Test validation of feedback_prompts structure"""
valid_feedback_prompts = """
sections:
- section_id: "section_1"
title: "Test"
steps:
- step_id: "step_1"
title: "Test Step"
question: "Test?"
feedback_prompts:
- name: "hit_miss"
tokens_for_ai: "Report hit/miss for both players"
- name: "ship_sinking"
tokens_for_ai: "Report any ship sinking events"
buckets:
- test
transitions:
test:
next_section_and_step: "section_1:step_2"
- step_id: "step_2"
title: "Final"
content_blocks:
- "Done"
"""
temp_file = self.create_temp_yaml(valid_feedback_prompts)
try:
is_valid, errors, warnings = self.validator.validate_file(temp_file)
self.assertTrue(is_valid)
self.assertEqual(len(errors), 0)
finally:
os.unlink(temp_file)
def test_invalid_feedback_prompts(self):
"""Test validation of invalid feedback_prompts structure"""
invalid_feedback_prompts = """
sections:
- section_id: "section_1"
title: "Test"
steps:
- step_id: "step_1"
title: "Test Step"
question: "Test?"
feedback_prompts: "should_be_list"
buckets:
- test
transitions:
test:
next_section_and_step: "section_1:step_2"
- step_id: "step_2"
title: "Test Step 2"
question: "Another test?"
feedback_prompts: [] # Empty list should error
buckets:
- test2
transitions:
test2:
next_section_and_step: "section_1:step_3"
- step_id: "step_3"
title: "Test Step 3"
question: "Third test?"
feedback_prompts:
- "should_be_dict"
- name: "valid_name"
# Missing tokens_for_ai
- name: "duplicate"
tokens_for_ai: "First prompt"
- name: "duplicate" # Duplicate name
tokens_for_ai: "Second prompt"
- name: 123 # Invalid name type
tokens_for_ai: "Valid tokens"
- name: "valid_name2"
tokens_for_ai: 456 # Invalid tokens type
buckets:
- test3
transitions:
test3:
content_blocks: ["Done"]
"""
temp_file = self.create_temp_yaml(invalid_feedback_prompts)
try:
is_valid, errors, warnings = self.validator.validate_file(temp_file)
self.assertFalse(is_valid)
# Check for specific error types
self.assertTrue(
any("feedback_prompts' must be a list" in error for error in errors)
)
self.assertTrue(
any("feedback_prompts' cannot be empty" in error for error in errors)
)
self.assertTrue(any("must be a dictionary" in error for error in errors))
self.assertTrue(any("missing required field" in error for error in errors))
self.assertTrue(
any("duplicate feedback prompt name" in error for error in errors)
)
self.assertTrue(any("name must be a string" in error for error in errors))
self.assertTrue(
any("tokens_for_ai must be a string" in error for error in errors)
)
finally:
os.unlink(temp_file)
def test_both_feedback_systems(self):
"""Test that both feedback_tokens_for_ai and feedback_prompts can be used together"""
both_feedback_systems = """
sections:
- section_id: "section_1"
title: "Test"
steps:
- step_id: "step_1"
title: "Test Step"
question: "Test?"
feedback_tokens_for_ai: "Legacy feedback system"
feedback_prompts:
- name: "new_system_1"
tokens_for_ai: "New system prompt 1"
- name: "new_system_2"
tokens_for_ai: "New system prompt 2"
buckets:
- test
transitions:
test:
next_section_and_step: "section_1:step_2"
- step_id: "step_2"
title: "Final"
content_blocks:
- "Done"
"""
temp_file = self.create_temp_yaml(both_feedback_systems)
try:
is_valid, errors, warnings = self.validator.validate_file(temp_file)
self.assertTrue(is_valid, f"Should be valid but got errors: {errors}")
self.assertEqual(len(errors), 0)
finally:
os.unlink(temp_file)
def test_cli_integration(self):
"""Test the command line interface"""
import subprocess
import sys
# Test with valid battleship YAML
result = subprocess.run(
[
sys.executable,
"activity_yaml_validator.py",
"research/activity29-battleship.yaml",
],
capture_output=True,
text=True,
cwd=".",
)
# Should succeed (exit code 0) despite warnings
self.assertEqual(result.returncode, 0)
self.assertIn("valid", result.stdout.lower())
# 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': {}}
"""
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__":
# Run the tests
unittest.main(verbosity=2)