diff --git a/Makefile b/Makefile index 25ba21a..686975d 100644 --- a/Makefile +++ b/Makefile @@ -5,82 +5,196 @@ help: @echo "OpenCompletion Testing Framework" @echo "================================" @echo "" - @echo "Available targets:" - @echo " venv - Create virtual environment and install dependencies" - @echo " test - Run all tests" - @echo " test-unit - Run only unit tests" - @echo " test-integration - Run only integration tests" - @echo " test-functional - Run only functional tests" - @echo " test-validator - Run only YAML validator tests" - @echo " validate-yaml - Validate all YAML files in research/" - @echo " lint - Run code linting" - @echo " clean - Clean up generated files" - @echo " clean-all - Remove virtual environment" + @echo "๐Ÿงช Test Commands:" + @echo " test - Run all tests (unit, integration, functional)" + @echo " test-unit - Run only unit tests" + @echo " test-integration - Run only integration tests" + @echo " test-functional - Run only functional tests" + @echo " test-validator - Run YAML validator tests" + @echo " test-yaml-loading - Run YAML loading/parsing tests" + @echo " test-activity-flows - Run activity flow tests" + @echo " test-battleship - Run battleship game tests" + @echo " test-guarded-ai - Run guarded_ai.py functionality tests" + @echo " test-multiple-files - Run integration tests across all activity files" + @echo "" + @echo "๐Ÿ“‹ Validation Commands:" + @echo " validate-yaml - Validate all YAML files in research/" + @echo "" + @echo "๐Ÿ› ๏ธ Development Commands:" + @echo " venv - Create virtual environment and install dependencies" + @echo " dev-setup - Install development dependencies" + @echo " lint - Run code linting and formatting" + @echo " clean - Clean up generated files" + @echo " clean-all - Remove virtual environment" # Setup virtual environment .PHONY: venv venv: - @echo "๐Ÿš€ Creating virtual environment..." - python3 -m venv venv - @echo "๐Ÿ“ฆ Installing dependencies..." - venv/bin/pip install --upgrade pip - venv/bin/pip install -r requirements.txt - venv/bin/pip install -r requirements-test.txt - @echo "โœ… Virtual environment ready!" + @if [ ! -d "venv" ]; then \ + echo "๐Ÿš€ Creating virtual environment..."; \ + python3 -m venv venv; \ + echo "๐Ÿ“ฆ Installing basic dependencies..."; \ + venv/bin/pip install --upgrade pip; \ + venv/bin/pip install pyyaml openai || echo "โš ๏ธ Failed to install basic dependencies"; \ + echo "โœ… Virtual environment ready!"; \ + else \ + echo "โœ… Virtual environment already exists"; \ + fi + +# ============================================================================ +# MAIN TEST COMMANDS +# ============================================================================ # Run all tests .PHONY: test -test: venv - @echo "๐Ÿงช Running all tests..." - venv/bin/python -m pytest tests/ -v --tb=short - @echo "๐Ÿ“‹ Validating YAML files..." - venv/bin/python activity_yaml_validator.py research/*.yaml || true +test: test-unit test-integration test-functional validate-yaml + @echo "" + @echo "๐ŸŽ‰ All tests completed!" + @echo "๐Ÿ“Š Test Summary:" + @echo " โœ… Unit tests - Core functionality" + @echo " โœ… Integration tests - Cross-component testing" + @echo " โœ… Functional tests - End-to-end workflows" + @echo " โœ… YAML validation - All activity files" -# Run unit tests only +# Run unit tests only .PHONY: test-unit -test-unit: +test-unit: venv @echo "๐Ÿ”ฌ Running unit tests..." - venv/bin/python -m pytest tests/unit/ -v --tb=short + @if command -v pytest >/dev/null 2>&1; then \ + python -m pytest tests/unit/ -v --tb=short; \ + else \ + echo "๐Ÿ“ Running unit tests directly..."; \ + python tests/unit/test_yaml_loading.py; \ + python tests/unit/test_activity_yaml_validator.py; \ + fi # Run integration tests only -.PHONY: test-integration -test-integration: +.PHONY: test-integration +test-integration: venv @echo "๐Ÿ”— Running integration tests..." - venv/bin/python -m pytest tests/integration/ -v --tb=short + @if command -v pytest >/dev/null 2>&1; then \ + python -m pytest tests/integration/ -v --tb=short; \ + else \ + echo "๐Ÿ“ Running integration tests directly..."; \ + python tests/integration/test_multiple_activities.py; \ + fi # Run functional tests only .PHONY: test-functional -test-functional: +test-functional: venv @echo "โšก Running functional tests..." - venv/bin/python -m pytest tests/functional/ -v --tb=short + @if command -v pytest >/dev/null 2>&1; then \ + python -m pytest tests/functional/ -v --tb=short; \ + else \ + echo "๐Ÿ“ Running functional tests directly..."; \ + python tests/functional/test_activity_flows.py; \ + python tests/functional/test_battleship_pre_script.py; \ + fi + +# ============================================================================ +# SPECIFIC TEST COMMANDS +# ============================================================================ # Run YAML validator tests only .PHONY: test-validator -test-validator: +test-validator: venv @echo "๐Ÿ“‹ Running YAML validator tests..." - venv/bin/python -m pytest tests/unit/test_activity_yaml_validator.py -v --tb=short + python tests/unit/test_activity_yaml_validator.py -# Validate YAML files +# Run YAML loading tests only +.PHONY: test-yaml-loading +test-yaml-loading: venv + @echo "๐Ÿ“„ Running YAML loading/parsing tests..." + python tests/unit/test_yaml_loading.py + +# Run activity flow tests +.PHONY: test-activity-flows +test-activity-flows: venv + @echo "๐Ÿ”„ Running activity flow tests..." + python tests/functional/test_activity_flows.py + +# Run battleship game tests +.PHONY: test-battleship +test-battleship: venv + @echo "๐Ÿšข Running battleship game tests..." + python tests/functional/test_battleship_pre_script.py + +# Run guarded_ai functionality tests +.PHONY: test-guarded-ai +test-guarded-ai: venv + @echo "๐Ÿ›ก๏ธ Running guarded_ai.py functionality tests..." + python tests/integration/test_regression_fixes.py + +# Run integration tests across all activity files +.PHONY: test-multiple-files +test-multiple-files: venv + @echo "๐Ÿ“ Running integration tests across all activity files..." + python tests/integration/test_multiple_activities.py + +# ============================================================================ +# VALIDATION COMMANDS +# ============================================================================ + +# Validate all YAML files .PHONY: validate-yaml -validate-yaml: - @echo "๐Ÿ“‹ Validating YAML files..." - venv/bin/python activity_yaml_validator.py research/*.yaml +validate-yaml: venv + @echo "๐Ÿ“‹ Validating all YAML files..." + python activity_yaml_validator.py research/*.yaml -# Run tests with coverage +# ============================================================================ +# DEVELOPMENT AND CI/CD COMMANDS +# ============================================================================ + +# Run tests with coverage (requires pytest and coverage) .PHONY: test-cov -test-cov: - @echo "๐Ÿงช Running tests with coverage..." +test-cov: dev-setup + @echo "๐Ÿ“Š Running tests with coverage..." + venv/bin/pip install pytest-cov venv/bin/python -m pytest tests/ --cov=. --cov-report=html --cov-report=term-missing -v -# Format and lint code (combined target) + +# Format and lint code .PHONY: format lint -format lint: +format lint: dev-setup @echo "๐ŸŽจ Formatting and linting code..." - venv/bin/pip install black isort flake8 || true - venv/bin/black . - venv/bin/isort . + venv/bin/black . || echo "โš ๏ธ black formatting failed" + venv/bin/isort . || echo "โš ๏ธ isort import sorting failed" venv/bin/flake8 . || echo "โš ๏ธ Linting issues found" +# Install development dependencies +.PHONY: dev-setup +dev-setup: venv + @echo "๐Ÿ› ๏ธ Installing development dependencies..." + venv/bin/pip install black flake8 isort pytest coverage + @echo "โœ… Development environment ready!" + +# ============================================================================ +# CI/CD AND AUTOMATION COMMANDS +# ============================================================================ + +# Full CI pipeline +.PHONY: ci +ci: clean test validate-yaml lint + @echo "" + @echo "๐ŸŽฏ CI Pipeline Results:" + @echo " โœ… Tests passed" + @echo " โœ… YAML validation passed" + @echo " โœ… Code linting completed" + @echo "๐Ÿš€ Ready for deployment!" + +# Pre-commit hook simulation +.PHONY: pre-commit +pre-commit: + @echo "๐Ÿ”’ Running pre-commit checks..." + $(MAKE) test-yaml-loading + $(MAKE) validate-yaml + $(MAKE) lint + @echo "โœ… Pre-commit checks passed!" + +# ============================================================================ +# UTILITY COMMANDS +# ============================================================================ + # Clean generated files .PHONY: clean clean: @@ -92,6 +206,7 @@ clean: rm -rf .pytest_cache/ 2>/dev/null || true rm -rf htmlcov/ 2>/dev/null || true rm -rf .coverage 2>/dev/null || true + rm -rf *.tmp 2>/dev/null || true # Remove virtual environment .PHONY: clean-all @@ -99,15 +214,21 @@ clean-all: clean @echo "๐Ÿ’ฃ Removing virtual environment..." rm -rf venv -# Quick test run (for development) -.PHONY: quick -quick: - @echo "โšก Quick test run..." - venv/bin/python -m pytest tests/unit/test_activity_yaml_validator.py -v -x - -# Install development dependencies -.PHONY: dev-setup -dev-setup: venv - @echo "๐Ÿ› ๏ธ Installing development dependencies..." - venv/bin/pip install black flake8 isort mypy pre-commit - @echo "โœ… Development environment ready!" \ No newline at end of file +# Show test structure +.PHONY: test-info +test-info: + @echo "๐Ÿ“ Test Structure:" + @echo " tests/" + @echo " โ”œโ”€โ”€ unit/ - Unit tests for individual components" + @echo " โ”‚ โ”œโ”€โ”€ test_yaml_loading.py - YAML loading/parsing tests" + @echo " โ”‚ โ””โ”€โ”€ test_activity_yaml_validator.py - Validator functionality tests" + @echo " โ”œโ”€โ”€ integration/ - Integration tests across components" + @echo " โ”‚ โ”œโ”€โ”€ test_multiple_activities.py - Tests across all activity files" + @echo " โ”‚ โ””โ”€โ”€ test_regression_fixes.py - Regression and fix validation" + @echo " โ””โ”€โ”€ functional/ - End-to-end functional tests" + @echo " โ”œโ”€โ”€ test_activity_flows.py - Complete activity workflows" + @echo " โ””โ”€โ”€ test_battleship_pre_script.py - Battleship game functionality" + @echo "" + @echo "๐ŸŽฏ Key Test Commands:" + @echo " make test - Run all tests" + @echo " make validate-yaml - Validate all YAML files" \ No newline at end of file diff --git a/migrations/versions/190d5ef26e20_add_token_count_to_message.py b/migrations/versions/190d5ef26e20_add_token_count_to_message.py index 3b81852..38df8ca 100644 --- a/migrations/versions/190d5ef26e20_add_token_count_to_message.py +++ b/migrations/versions/190d5ef26e20_add_token_count_to_message.py @@ -5,6 +5,7 @@ Revises: a9e886c56482 Create Date: 2023-12-07 08:55:50.378439 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/1ac5a8e0f577_user_session_table.py b/migrations/versions/1ac5a8e0f577_user_session_table.py index f49e9c3..773989a 100644 --- a/migrations/versions/1ac5a8e0f577_user_session_table.py +++ b/migrations/versions/1ac5a8e0f577_user_session_table.py @@ -5,28 +5,30 @@ Revises: 38a330686a17 Create Date: 2024-11-23 11:25:01.723169 """ + from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import sqlite # revision identifiers, used by Alembic. -revision = '1ac5a8e0f577' -down_revision = '38a330686a17' +revision = "1ac5a8e0f577" +down_revision = "38a330686a17" branch_labels = None depends_on = None def upgrade(): - op.create_table('user_session', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('session_id', sa.String(length=128), nullable=False), - sa.Column('username', sa.String(length=128), nullable=True), - sa.Column('room_name', sa.String(length=128), nullable=True), - sa.Column('room_id', sa.Integer(), nullable=True), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('session_id') + op.create_table( + "user_session", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("session_id", sa.String(length=128), nullable=False), + sa.Column("username", sa.String(length=128), nullable=True), + sa.Column("room_name", sa.String(length=128), nullable=True), + sa.Column("room_id", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("session_id"), ) def downgrade(): - op.drop_table('user_session') + op.drop_table("user_session") diff --git a/migrations/versions/38a330686a17_room_active_users.py b/migrations/versions/38a330686a17_room_active_users.py index 93856c9..345c0a5 100644 --- a/migrations/versions/38a330686a17_room_active_users.py +++ b/migrations/versions/38a330686a17_room_active_users.py @@ -5,21 +5,23 @@ Revises: d737de68d6fa Create Date: 2024-11-23 09:52:50.824162 """ + from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import sqlite # revision identifiers, used by Alembic. -revision = '38a330686a17' -down_revision = 'd737de68d6fa' +revision = "38a330686a17" +down_revision = "d737de68d6fa" branch_labels = None depends_on = None def upgrade(): - with op.batch_alter_table('room', schema=None) as batch_op: - batch_op.add_column(sa.Column('active_users', sa.Text(), nullable=True)) + with op.batch_alter_table("room", schema=None) as batch_op: + batch_op.add_column(sa.Column("active_users", sa.Text(), nullable=True)) + def downgrade(): - with op.batch_alter_table('room', schema=None) as batch_op: - batch_op.drop_column('active_users') + with op.batch_alter_table("room", schema=None) as batch_op: + batch_op.drop_column("active_users") diff --git a/migrations/versions/5d93cdf18549_room_inactive_users_column.py b/migrations/versions/5d93cdf18549_room_inactive_users_column.py index 2635762..d15e15b 100644 --- a/migrations/versions/5d93cdf18549_room_inactive_users_column.py +++ b/migrations/versions/5d93cdf18549_room_inactive_users_column.py @@ -5,21 +5,23 @@ Revises: 1ac5a8e0f577 Create Date: 2024-11-24 14:04:30.488155 """ + from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import sqlite # revision identifiers, used by Alembic. -revision = '5d93cdf18549' -down_revision = '1ac5a8e0f577' +revision = "5d93cdf18549" +down_revision = "1ac5a8e0f577" branch_labels = None depends_on = None def upgrade(): - with op.batch_alter_table('room', schema=None) as batch_op: - batch_op.add_column(sa.Column('inactive_users', sa.Text(), nullable=True)) + with op.batch_alter_table("room", schema=None) as batch_op: + batch_op.add_column(sa.Column("inactive_users", sa.Text(), nullable=True)) + def downgrade(): - with op.batch_alter_table('room', schema=None) as batch_op: - batch_op.drop_column('inactive_users') + with op.batch_alter_table("room", schema=None) as batch_op: + batch_op.drop_column("inactive_users") diff --git a/migrations/versions/d04950c5a624_add_activitystate_table2.py b/migrations/versions/d04950c5a624_add_activitystate_table2.py index 8950a1e..aac2ecd 100644 --- a/migrations/versions/d04950c5a624_add_activitystate_table2.py +++ b/migrations/versions/d04950c5a624_add_activitystate_table2.py @@ -5,6 +5,7 @@ Revises: d3631b8bb652 Create Date: 2024-07-27 09:36:50.422693 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/d3631b8bb652_add_activitystate_table.py b/migrations/versions/d3631b8bb652_add_activitystate_table.py index 926eb3a..e168ff8 100644 --- a/migrations/versions/d3631b8bb652_add_activitystate_table.py +++ b/migrations/versions/d3631b8bb652_add_activitystate_table.py @@ -5,6 +5,7 @@ Revises: 190d5ef26e20 Create Date: 2024-07-27 09:33:52.544550 """ + from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/d737de68d6fa_add_metadata_field_to_activitystate.py b/migrations/versions/d737de68d6fa_add_metadata_field_to_activitystate.py index 6779615..1cadd6d 100644 --- a/migrations/versions/d737de68d6fa_add_metadata_field_to_activitystate.py +++ b/migrations/versions/d737de68d6fa_add_metadata_field_to_activitystate.py @@ -5,6 +5,7 @@ Revises: d04950c5a624 Create Date: 2024-07-28 17:02:11.872502 """ + from alembic import op import sqlalchemy as sa diff --git a/models.py b/models.py index b67a14f..b56d574 100644 --- a/models.py +++ b/models.py @@ -6,6 +6,7 @@ import json db = SQLAlchemy() + class Room(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(128), nullable=False, unique=True) diff --git a/research/guarded_ai.py b/research/guarded_ai.py index 85ed817..f5e3723 100644 --- a/research/guarded_ai.py +++ b/research/guarded_ai.py @@ -2,9 +2,64 @@ import argparse import yaml import json import random +import os from openai import OpenAI -client = OpenAI() +# Global model-client mapping +MODEL_CLIENT_MAP = {} + + +def get_client_for_endpoint(endpoint, api_key): + """Create OpenAI client for any endpoint""" + return OpenAI(api_key=api_key, base_url=endpoint) + + +def initialize_model_map(): + """Initialize the model-client mapping from environment variables""" + global MODEL_CLIENT_MAP + + # Load endpoints from environment variables + for i in range(1000): # Support up to 1000 endpoints + endpoint_key = f"MODEL_ENDPOINT_{i}" + api_key_key = f"MODEL_API_KEY_{i}" + + endpoint = os.getenv(endpoint_key) + api_key = os.getenv(api_key_key) + + if endpoint and api_key: + try: + client = get_client_for_endpoint(endpoint, api_key) + # Try to get models (simplified - just register endpoint) + MODEL_CLIENT_MAP[f"endpoint_{i}"] = (client, endpoint) + except Exception as e: + print(f"Warning: Failed to initialize endpoint {endpoint}: {e}") + + +def get_openai_client_and_model(model_name=None): + """Get OpenAI client and model name""" + if not model_name: + model_name = "adamo1139/Hermes-3-Llama-3.1-8B-FP8-Dynamic" + + # Try to find client for specific model + for stored_model, (client, base_url) in MODEL_CLIENT_MAP.items(): + if model_name in stored_model or stored_model == model_name: + return client, model_name + + # Fallback to first available client + if MODEL_CLIENT_MAP: + client, _ = next(iter(MODEL_CLIENT_MAP.values())) + return client, model_name + + # Final fallback to environment or default OpenAI + api_key = os.getenv("OPENAI_API_KEY", "dummy-key") + endpoint = os.getenv("MODEL_ENDPOINT_0", "https://api.openai.com/v1") + + client = get_client_for_endpoint(endpoint, api_key) + return client, model_name + + +# Initialize the model mapping on startup +initialize_model_map() # Load the YAML activity file @@ -15,7 +70,7 @@ def load_yaml_activity(file_path): # Categorize the user's response using gpt-4o-mini def categorize_response(question, response, buckets, tokens_for_ai): - bucket_list = ", ".join(buckets) + bucket_list = ", ".join([str(bucket) for bucket in buckets]) messages = [ { "role": "system", @@ -28,8 +83,9 @@ def categorize_response(question, response, buckets, tokens_for_ai): ] try: + client, model_name = get_openai_client_and_model() completion = client.chat.completions.create( - model="gpt-4o-mini", + model=model_name, messages=messages, max_tokens=5, temperature=0, @@ -56,8 +112,9 @@ def generate_ai_feedback(category, question, user_response, tokens_for_ai, metad ] try: + client, model_name = get_openai_client_and_model() completion = client.chat.completions.create( - model="gpt-4o-mini", messages=messages, max_tokens=250, temperature=0.7 + model=model_name, messages=messages, max_tokens=250, temperature=0.7 ) feedback = completion.choices[0].message.content.strip() return feedback @@ -78,8 +135,15 @@ def provide_feedback( feedback = "" if "ai_feedback" in transition: tokens_for_ai += f" Provide the feedback in {user_language}. {transition['ai_feedback'].get('tokens_for_ai', '')}." + + # Filter metadata for feedback if metadata_feedback_filter is specified + feedback_metadata = metadata + if "metadata_feedback_filter" in transition: + filter_keys = transition["metadata_feedback_filter"] + feedback_metadata = {k: v for k, v in metadata.items() if k in filter_keys} + ai_feedback = generate_ai_feedback( - category, question, user_response, tokens_for_ai, metadata + category, question, user_response, tokens_for_ai, feedback_metadata ) feedback += f"\n\nAI Feedback: {ai_feedback}" @@ -196,14 +260,44 @@ def simulate_activity(yaml_file_path): while attempts < max_attempts: user_response = input("\nYour Response: ") + # Execute pre-script if it exists (runs before categorization, with user_response available) + if "pre_script" in step: + print(f"DEBUG: Executing pre-script") + # Add user_response to a temporary copy of metadata for pre_script + temp_metadata = metadata.copy() + temp_metadata["user_response"] = user_response + pre_result = execute_processing_script( + temp_metadata, step["pre_script"] + ) + + # Update metadata with pre-script results + for key, value in pre_result.get("metadata", {}).items(): + metadata[key] = value + print(f"DEBUG: Pre-script completed, updated metadata") + category = categorize_response( question, user_response, step["buckets"], step["tokens_for_ai"] ) print(f"\nCategory: {category}") - transition = step["transitions"].get(category, None) + # Determine the transition based on the category (with integer/boolean matching) + transition = None + if category in step["transitions"]: + transition = step["transitions"][category] + elif category.isdigit() and int(category) in step["transitions"]: + transition = step["transitions"][int(category)] + else: + if category.lower() in ["yes", "true"]: + category = True + elif category.lower() in ["no", "false"]: + category = False + if category in step["transitions"]: + transition = step["transitions"][category] + if not transition: - print("\nError: No valid transition found. Please try again.") + print( + f"\nError: No valid transition found for category '{category}'. Please try again." + ) continue # Check metadata conditions @@ -275,6 +369,10 @@ def simulate_activity(yaml_file_path): if key in metadata: del metadata[key] + # Handle metadata_clear - clear all metadata if set to True + if "metadata_clear" in transition and transition["metadata_clear"] == True: + metadata.clear() + # Handle metadata_random if "metadata_random" in transition: random_key = random.choice(list(transition["metadata_random"].keys())) @@ -290,8 +388,21 @@ def simulate_activity(yaml_file_path): metadata_tmp_keys.append(random_key) # Track temporary keys # Execute the processing script if it exists - if "processing_script" in step and transition.get("run_processing_script", False): - result = execute_processing_script(metadata, step["processing_script"]) + if "processing_script" in step and transition.get( + "run_processing_script", False + ): + # Add user_response to metadata temporarily for processing script + temp_metadata = metadata.copy() + temp_metadata["user_response"] = user_response + + result = execute_processing_script( + temp_metadata, step["processing_script"] + ) + + # Copy any changes back to main metadata (except user_response) + for key, value in temp_metadata.items(): + if key != "user_response": + metadata[key] = value metadata["processing_script_result"] = result metadata_tmp_keys.append("processing_script_result") diff --git a/tests/functional/test_activity_flows.py b/tests/functional/test_activity_flows.py new file mode 100644 index 0000000..1c64f5f --- /dev/null +++ b/tests/functional/test_activity_flows.py @@ -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) diff --git a/tests/functional/test_battleship_pre_script.py b/tests/functional/test_battleship_pre_script.py new file mode 100644 index 0000000..7f0a8a4 --- /dev/null +++ b/tests/functional/test_battleship_pre_script.py @@ -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) diff --git a/tests/functional/test_guarded_ai.py b/tests/functional/test_guarded_ai.py new file mode 100644 index 0000000..57151f4 --- /dev/null +++ b/tests/functional/test_guarded_ai.py @@ -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) diff --git a/tests/integration/test_multiple_activities.py b/tests/integration/test_multiple_activities.py new file mode 100644 index 0000000..7d18767 --- /dev/null +++ b/tests/integration/test_multiple_activities.py @@ -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) diff --git a/tests/unit/test_activity_yaml_validator.py b/tests/unit/test_activity_yaml_validator.py index adb2bbf..0397207 100644 --- a/tests/unit/test_activity_yaml_validator.py +++ b/tests/unit/test_activity_yaml_validator.py @@ -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__": diff --git a/tests/unit/test_yaml_loading.py b/tests/unit/test_yaml_loading.py new file mode 100644 index 0000000..9b90585 --- /dev/null +++ b/tests/unit/test_yaml_loading.py @@ -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)