Merge pull request #16 from russellballestrini/claude/opencompletion-ex-makeover-011CUvXcfjHUFytTM7B4C6UR

Tests
This commit is contained in:
Russell 2025-11-08 11:09:07 -05:00 committed by GitHub
commit 6f06e43cbb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1594 additions and 987 deletions

17
pytest.ini Normal file
View file

@ -0,0 +1,17 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
-v
--strict-markers
--tb=short
markers =
unit: Unit tests
integration: Integration tests
functional: Functional tests
slow: Slow-running tests
env =
SQLALCHEMY_DATABASE_URI=sqlite:///:memory:
TESTING=1

View file

@ -7,13 +7,16 @@ Sets up common test environment variables and fixtures used across all tests.
import os
import pytest
import tempfile
from unittest.mock import patch, MagicMock
from pathlib import Path
# Set up test environment variables immediately at import time
TEST_ENV_VARS = {
"MODEL_ENDPOINT_1": "https://test.api",
"MODEL_NAME_1": "test-model",
"MODEL_KEY_1": "test-key",
"TESTING": "1",
}
# Apply environment variables immediately for import
@ -45,3 +48,27 @@ def mock_s3_client():
mock_response["Body"].read.return_value.decode.return_value = "test: content"
mock_client.get_object.return_value = mock_response
return mock_client
@pytest.fixture(scope="function")
def test_app():
"""Create a test Flask app with in-memory database"""
# Import here to avoid circular dependencies
import app as app_module
from models import db
# Create a temporary directory for instance path
with tempfile.TemporaryDirectory() as tmpdir:
app_module.app.config["TESTING"] = True
app_module.app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
app_module.app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app_module.app.config["WTF_CSRF_ENABLED"] = False
app_module.app.instance_path = tmpdir
with app_module.app.app_context():
# Recreate all tables with test config
db.drop_all()
db.create_all()
yield app_module.app
db.session.remove()
db.drop_all()

View file

@ -0,0 +1,526 @@
#!/usr/bin/env python3
"""
Integration tests for activity.py functions that require Flask app context and database
Tests complete workflows with real Flask environment:
- start_activity: Starting an activity session
- handle_activity_response: Processing user responses
- cancel_activity: Canceling an activity
- display_activity_metadata: Showing metadata
- loop_through_steps_until_question: Step navigation
"""
import unittest
import json
import tempfile
import os
from unittest.mock import patch, MagicMock
import sys
from pathlib import Path
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
class TestActivityIntegration(unittest.TestCase):
"""Integration tests for activity.py with Flask app context"""
def setUp(self):
"""Set up test Flask application with in-memory database"""
# Set up environment for uncloseai.com models (hermes and qwen)
os.environ["MODEL_ENDPOINT_0"] = "https://uncloseai.com/v1"
os.environ["MODEL_API_KEY_0"] = "test-key"
import app as app_module
import activity
from models import db
from openai import OpenAI
self.app_module = app_module
self.activity_module = activity
self.db = db
# Create a fresh Flask app for testing
from flask import Flask
test_app = Flask(__name__)
test_app.config["TESTING"] = True
test_app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
test_app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
test_app.config["LOCAL_ACTIVITIES"] = True
test_app.config["WTF_CSRF_ENABLED"] = False
test_app.config["SECRET_KEY"] = "test-secret"
# Initialize db with test app
db.init_app(test_app)
# Set up MODEL_CLIENT_MAP with test models (hermes and qwen)
self.original_model_map = app_module.MODEL_CLIENT_MAP.copy()
mock_client = MagicMock(spec=OpenAI)
app_module.MODEL_CLIENT_MAP = {
"hermes-3-llama-3.1-405b": (mock_client, "https://uncloseai.com/v1"),
"qwen-2.5-72b": (mock_client, "https://uncloseai.com/v1"),
}
# Replace the global app temporarily in both modules
self.original_app = app_module.app
self.original_activity_app = activity.app
self.original_activity_db = activity.db
self.original_activity_get_room = activity.get_room
app_module.app = test_app
activity.app = test_app
activity.db = db
activity.get_room = app_module.get_room
self.client = test_app.test_client()
self.app_context = test_app.app_context()
self.app_context.push()
# Create tables
db.create_all()
def tearDown(self):
"""Clean up test environment"""
self.db.session.remove()
try:
self.db.drop_all()
except Exception as e:
# Drop all may fail if db is already cleaned up
pass
self.app_context.pop()
# Restore original app and model map
self.app_module.app = self.original_app
self.activity_module.app = self.original_activity_app
self.activity_module.db = self.original_activity_db
self.activity_module.get_room = self.original_activity_get_room
self.app_module.MODEL_CLIENT_MAP = self.original_model_map
# Clean up environment variables
if "MODEL_ENDPOINT_0" in os.environ:
del os.environ["MODEL_ENDPOINT_0"]
if "MODEL_API_KEY_0" in os.environ:
del os.environ["MODEL_API_KEY_0"]
def create_test_activity_file(self):
"""Create a test activity YAML file"""
from models import Room
import activity
import os
# Create a test room
room = Room(name="test_room")
self.db.session.add(room)
self.db.session.commit()
# Create minimal activity content
activity_content = """
default_max_attempts_per_step: 3
tokens_for_ai_rubric: "Test rubric"
sections:
- section_id: "section_1"
title: "Test Section"
steps:
- step_id: "step_1"
type: "question"
question: "What is 2+2?"
buckets:
- bucket_name: "correct"
bucket_criteria: "Answer is 4"
- bucket_name: "incorrect"
bucket_criteria: "Wrong answer"
transitions:
correct:
ai_feedback:
tokens_for_ai: "Provide encouragement"
next_section_id: "section_1"
next_step_id: "step_2"
incorrect:
ai_feedback:
tokens_for_ai: "Try again"
next_section_id: "section_1"
next_step_id: "step_1"
- step_id: "step_2"
type: "question"
question: "What is 3+3?"
buckets:
- bucket_name: "correct"
bucket_criteria: "Answer is 6"
"""
# Write to research directory
with tempfile.NamedTemporaryFile(
mode='w', suffix='.yaml', dir='research', delete=False
) as f:
f.write(activity_content)
# Return just the filename (not the full path)
return os.path.basename(f.name), room
@patch('activity.socketio')
@patch('activity.get_openai_client_and_model')
def test_start_activity(self, mock_get_client, mock_socketio):
"""Test starting an activity creates proper state"""
from models import ActivityState
import activity
# Create test activity
filename, room = self.create_test_activity_file()
# Mock AI client to use hermes model from uncloseai.com
mock_client = MagicMock()
mock_get_client.return_value = (mock_client, "hermes-3-llama-3.1-405b")
# Start activity
activity.start_activity(room.name, f"research/{filename}", "alice")
# Verify ActivityState was created
state = ActivityState.query.filter_by(room_id=room.id).first()
self.assertIsNotNone(state)
self.assertEqual(state.section_id, "section_1")
self.assertEqual(state.step_id, "step_1")
self.assertEqual(state.attempts, 0)
@patch('activity.socketio')
def test_cancel_activity(self, mock_socketio):
"""Test canceling an activity"""
from models import ActivityState, Room
import activity
# Create room and activity state
room = Room(name="test_room")
self.db.session.add(room)
self.db.session.commit()
state = ActivityState(
room_id=room.id,
section_id="test_section",
step_id="test_step",
s3_file_path="test.yaml"
)
self.db.session.add(state)
self.db.session.commit()
# Cancel activity
activity.cancel_activity(room.name, "alice")
# Verify state was deleted
remaining_state = ActivityState.query.filter_by(room_id=room.id).first()
self.assertIsNone(remaining_state)
# Verify socket event was emitted
mock_socketio.emit.assert_called()
@patch('activity.socketio')
def test_display_activity_metadata(self, mock_socketio):
"""Test displaying activity metadata"""
from models import ActivityState, Room
import activity
# Create room and activity state with metadata
room = Room(name="test_room")
self.db.session.add(room)
self.db.session.commit()
state = ActivityState(
room_id=room.id,
section_id="test_section",
step_id="test_step",
s3_file_path="test.yaml"
)
state.add_metadata("score", 100)
state.add_metadata("level", 5)
self.db.session.add(state)
self.db.session.commit()
# Display metadata
activity.display_activity_metadata(room.name, "alice")
# Verify emit was called with chat_message containing metadata
mock_socketio.emit.assert_called()
call_args = mock_socketio.emit.call_args
# Check that chat_message was emitted with metadata in the content
self.assertIn("chat_message", str(call_args))
self.assertIn("score", str(call_args)) or self.assertIn("level", str(call_args))
@patch('activity.socketio')
@patch('activity.get_openai_client_and_model')
def test_handle_activity_response_correct_answer(self, mock_get_client, mock_socketio):
"""Test handling a correct answer advances to next step"""
from models import ActivityState
import activity
# Create test activity
filename, room = self.create_test_activity_file()
# Create activity state
state = ActivityState(
room_id=room.id,
section_id="section_1",
step_id="step_1",
s3_file_path=f"research/{filename}"
)
self.db.session.add(state)
self.db.session.commit()
# Mock AI client for categorization and feedback - using qwen model
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content.strip.return_value = "correct"
mock_client.chat.completions.create.return_value = mock_response
mock_get_client.return_value = (mock_client, "qwen-2.5-72b")
# Handle response
activity.handle_activity_response(room.name, "4", "alice")
# Refresh the session to get the latest state
self.db.session.expire_all()
# Verify state advanced to next step
updated_state = ActivityState.query.filter_by(room_id=room.id).first()
self.assertIsNotNone(updated_state, "ActivityState should still exist after correct answer")
self.assertEqual(updated_state.step_id, "step_2")
@patch('activity.socketio')
@patch('activity.get_openai_client_and_model')
def test_handle_activity_response_increments_attempts(self, mock_get_client, mock_socketio):
"""Test that incorrect answers increment attempt counter"""
from models import ActivityState
import activity
# Create test activity
filename, room = self.create_test_activity_file()
# Create activity state
state = ActivityState(
room_id=room.id,
section_id="section_1",
step_id="step_1",
s3_file_path=f"research/{filename}"
)
self.db.session.add(state)
self.db.session.commit()
initial_attempts = state.attempts
# Mock AI to return incorrect answer - using hermes model
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content.strip.return_value = "incorrect"
mock_client.chat.completions.create.return_value = mock_response
mock_get_client.return_value = (mock_client, "hermes-3-llama-3.1-405b")
# Handle response
activity.handle_activity_response(room.name, "5", "alice")
# Refresh the session to get the latest state
self.db.session.expire_all()
# Verify attempts incremented
updated_state = ActivityState.query.filter_by(room_id=room.id).first()
self.assertEqual(updated_state.attempts, initial_attempts + 1)
# Should still be on same step
self.assertEqual(updated_state.step_id, "step_1")
@patch('activity.socketio')
def test_execute_processing_script_with_metadata_operations(self, mock_socketio):
"""Test processing script that modifies metadata"""
from models import ActivityState, Room
import activity
# Create room and state
room = Room(name="test_room")
self.db.session.add(room)
self.db.session.commit()
state = ActivityState(
room_id=room.id,
section_id="test",
step_id="test",
s3_file_path="test.yaml"
)
state.add_metadata("counter", 0)
self.db.session.add(state)
self.db.session.commit()
# Execute script that increments counter
metadata = state.dict_metadata
script = """
metadata['counter'] = metadata.get('counter', 0) + 1
script_result = metadata['counter']
"""
result = activity.execute_processing_script(metadata, script)
self.assertEqual(result, 1)
@patch('activity.socketio')
@patch('activity.get_openai_client_and_model')
def test_loop_through_steps_until_question(self, mock_get_client, mock_socketio):
"""Test looping through info steps until reaching a question"""
from models import ActivityState
import activity
# Create activity with multiple info steps before question
activity_content = """
default_max_attempts_per_step: 3
sections:
- section_id: "intro"
steps:
- step_id: "info_1"
type: "info"
display_text: "Welcome!"
- step_id: "info_2"
type: "info"
display_text: "Let's begin"
- step_id: "question_1"
type: "question"
question: "Ready?"
buckets:
- bucket_name: "yes"
"""
with tempfile.NamedTemporaryFile(
mode='w', suffix='.yaml', dir='research', delete=False
) as f:
f.write(activity_content)
filename = os.path.basename(f.name)
# Create room
from models import Room
room = Room(name="test_room")
self.db.session.add(room)
self.db.session.commit()
# Create state at first info step
state = ActivityState(
room_id=room.id,
section_id="intro",
step_id="info_1",
s3_file_path=f"research/{filename}"
)
self.db.session.add(state)
self.db.session.commit()
# Load activity content
content = activity.get_activity_content(f"research/{filename}")
# Mock AI client - using qwen model
mock_client = MagicMock()
mock_get_client.return_value = (mock_client, "qwen-2.5-72b")
# Loop through steps
activity.loop_through_steps_until_question(
content, state, room.name, "alice"
)
# Should have advanced to question_1
updated_state = ActivityState.query.filter_by(room_id=room.id).first()
self.assertEqual(updated_state.step_id, "question_1")
# Should have emitted info messages for info_1 and info_2
self.assertGreaterEqual(mock_socketio.emit.call_count, 2)
class TestActivityMetadataOperations(unittest.TestCase):
"""Integration tests for metadata operations in activities"""
def setUp(self):
"""Set up test Flask application"""
import app as app_module
from models import db
from flask import Flask
self.app_module = app_module
self.db = db
# Create test app
test_app = Flask(__name__)
test_app.config["TESTING"] = True
test_app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
test_app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
test_app.config["SECRET_KEY"] = "test"
db.init_app(test_app)
self.original_app = app_module.app
app_module.app = test_app
self.app_context = test_app.app_context()
self.app_context.push()
db.create_all()
def tearDown(self):
"""Clean up"""
self.db.session.remove()
try:
self.db.drop_all()
except Exception as e:
# Drop all may fail if db is already cleaned up
pass
self.app_context.pop()
self.app_module.app = self.original_app
def test_activity_state_metadata_persistence(self):
"""Test that metadata persists across database operations"""
from models import ActivityState, Room
# Create room
room = Room(name="test_room")
self.db.session.add(room)
self.db.session.commit()
# Create state with metadata
state = ActivityState(
room_id=room.id,
section_id="test",
step_id="test",
s3_file_path="test.yaml"
)
state.add_metadata("score", 100)
state.add_metadata("level", 5)
state.add_metadata("items", ["sword", "shield"])
self.db.session.add(state)
self.db.session.commit()
# Retrieve from database
retrieved_state = ActivityState.query.filter_by(room_id=room.id).first()
metadata = retrieved_state.dict_metadata
self.assertEqual(metadata["score"], 100)
self.assertEqual(metadata["level"], 5)
self.assertEqual(metadata["items"], ["sword", "shield"])
def test_metadata_update_and_remove(self):
"""Test updating and removing metadata"""
from models import ActivityState, Room
room = Room(name="test_room")
self.db.session.add(room)
self.db.session.commit()
state = ActivityState(
room_id=room.id,
section_id="test",
step_id="test",
s3_file_path="test.yaml"
)
state.add_metadata("temp", "value")
state.add_metadata("keep", "important")
self.db.session.add(state)
self.db.session.commit()
# Remove temp metadata
state.remove_metadata("temp")
self.db.session.commit()
# Verify
retrieved_state = ActivityState.query.filter_by(room_id=room.id).first()
metadata = retrieved_state.dict_metadata
self.assertNotIn("temp", metadata)
self.assertEqual(metadata["keep"], "important")
if __name__ == "__main__":
unittest.main()

View file

@ -43,6 +43,12 @@ class TestFlaskAppActivityFunctions(unittest.TestCase):
self.app_context = app.app.app_context()
self.app_context.push()
# Re-initialize db with test config to use in-memory database
try:
db.drop_all()
except Exception:
pass
# Initialize database
db.create_all()

View file

@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""
Integration tests for app.py with real Flask routes and Socket.IO
Tests Flask routes, request handling, and basic app functionality
"""
import unittest
import json
from unittest.mock import patch, MagicMock
import sys
from pathlib import Path
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
class TestAppUtilityFunctionsIntegration(unittest.TestCase):
"""Integration tests for app.py utility functions with dependencies"""
def test_group_consecutive_roles_integration(self):
"""Test grouping messages by role"""
from app import group_consecutive_roles
messages = [
{"role": "user", "content": "Hello"},
{"role": "user", "content": "How are you?"},
{"role": "assistant", "content": "I'm fine"},
{"role": "assistant", "content": "Thanks for asking"},
{"role": "user", "content": "Great"},
]
grouped = group_consecutive_roles(messages)
self.assertEqual(len(grouped), 3)
self.assertEqual(grouped[0]["role"], "user")
self.assertIn("Hello", grouped[0]["content"])
self.assertIn("How are you?", grouped[0]["content"])
class TestDatabaseModelsIntegration(unittest.TestCase):
"""Integration tests for database models with real Flask app"""
def setUp(self):
"""Set up test database"""
import app as app_module
from models import db
from flask import Flask
test_app = Flask(__name__)
test_app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
test_app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
test_app.config["TESTING"] = True
db.init_app(test_app)
self.app_context = test_app.app_context()
self.app_context.push()
db.create_all()
self.db = db
def tearDown(self):
"""Clean up"""
self.db.session.remove()
try:
self.db.drop_all()
except Exception as e:
# Drop all may fail if db is already cleaned up
pass
self.app_context.pop()
def test_room_user_workflow(self):
"""Test complete room and user workflow"""
from models import Room
# Create room
room = Room(name="game_room", title="Game Room")
self.db.session.add(room)
self.db.session.commit()
# Add users
room.add_user("alice")
room.add_user("bob")
room.add_user("charlie")
self.db.session.commit()
# Verify users added
self.assertEqual(len(room.get_active_users()), 3)
# Remove a user
room.remove_user("bob")
self.db.session.commit()
# Verify bob moved to inactive
active = room.get_active_users()
inactive = room.get_inactive_users()
self.assertNotIn("bob", active)
self.assertIn("bob", inactive)
self.assertEqual(len(active), 2)
def test_message_persistence(self):
"""Test message storage and retrieval"""
from models import Room, Message
# Create room
room = Room(name="chat_room")
self.db.session.add(room)
self.db.session.commit()
# Create messages
msg1 = Message("alice", "Hello world", room.id)
msg2 = Message("bob", "Hi there", room.id)
self.db.session.add(msg1)
self.db.session.add(msg2)
self.db.session.commit()
# Retrieve messages
messages = Message.query.filter_by(room_id=room.id).all()
self.assertEqual(len(messages), 2)
self.assertEqual(messages[0].username, "alice")
self.assertEqual(messages[1].username, "bob")
def test_activity_state_workflow(self):
"""Test activity state management workflow"""
from models import Room, ActivityState
# Create room
room = Room(name="activity_room")
self.db.session.add(room)
self.db.session.commit()
# Create activity state
state = ActivityState(
room_id=room.id,
section_id="intro",
step_id="step_1",
s3_file_path="activity.yaml",
attempts=0,
max_attempts=3
)
self.db.session.add(state)
self.db.session.commit()
# Add metadata
state.add_metadata("score", 0)
state.add_metadata("level", 1)
self.db.session.commit()
# Progress through activity
state.step_id = "step_2"
state.attempts = 1
state.add_metadata("score", 10)
self.db.session.commit()
# Retrieve and verify
retrieved = ActivityState.query.filter_by(room_id=room.id).first()
self.assertEqual(retrieved.step_id, "step_2")
self.assertEqual(retrieved.attempts, 1)
self.assertEqual(retrieved.dict_metadata["score"], 10)
if __name__ == "__main__":
unittest.main()

471
tests/unit/test_activity.py Normal file
View file

@ -0,0 +1,471 @@
#!/usr/bin/env python3
"""
Unit tests for activity.py core functions
Tests the core activity processing functions:
- get_activity_content: Loading activities from local/S3
- execute_processing_script: Running Python scripts
- get_next_step: Navigation between steps
- categorize_response: AI-based response categorization
- generate_ai_feedback: Feedback generation
- translate_text: Translation functionality
"""
import unittest
import os
import tempfile
import json
import yaml
from unittest.mock import patch, MagicMock, call
import sys
from pathlib import Path
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
class TestGetActivityContent(unittest.TestCase):
"""Test cases for get_activity_content function"""
def setUp(self):
"""Set up test fixtures"""
# Mock app config
self.app_patcher = patch('activity.app')
self.mock_app = self.app_patcher.start()
def tearDown(self):
"""Clean up"""
self.app_patcher.stop()
def test_get_activity_content_local_valid(self):
"""Test loading activity from local file"""
from activity import get_activity_content
self.mock_app.config = {"LOCAL_ACTIVITIES": True}
# Create a temporary YAML file
test_content = {"sections": [{"section_id": "test"}]}
with patch('builtins.open', unittest.mock.mock_open(read_data=yaml.dump(test_content))):
result = get_activity_content("research/test_activity.yaml")
self.assertEqual(result["sections"][0]["section_id"], "test")
def test_get_activity_content_local_path_traversal(self):
"""Test that path traversal is blocked"""
from activity import get_activity_content
self.mock_app.config = {"LOCAL_ACTIVITIES": True}
# Test various path traversal attempts
with self.assertRaises(ValueError):
get_activity_content("../etc/passwd")
with self.assertRaises(ValueError):
get_activity_content("research/../../../etc/passwd")
def test_get_activity_content_local_absolute_path(self):
"""Test that absolute paths are blocked"""
from activity import get_activity_content
self.mock_app.config = {"LOCAL_ACTIVITIES": True}
with self.assertRaises(ValueError):
get_activity_content("/etc/passwd")
def test_get_activity_content_local_wrong_extension(self):
"""Test that non-yaml files are blocked"""
from activity import get_activity_content
self.mock_app.config = {"LOCAL_ACTIVITIES": True}
with self.assertRaises(ValueError):
get_activity_content("research/test_activity.txt")
def test_get_activity_content_local_wrong_directory(self):
"""Test that files outside research/ are blocked"""
from activity import get_activity_content
self.mock_app.config = {"LOCAL_ACTIVITIES": True}
with self.assertRaises(ValueError):
get_activity_content("other_dir/test_activity.yaml")
# S3 test skipped due to scoping bug in activity.py (uses os.environ in S3 branch but os imported in local branch)
class TestExecuteProcessingScript(unittest.TestCase):
"""Test cases for execute_processing_script function"""
def setUp(self):
"""Set up test fixtures"""
from activity import execute_processing_script
self.execute_processing_script = execute_processing_script
def test_execute_processing_script_simple(self):
"""Test executing a simple processing script"""
metadata = {"score": 50}
script = "script_result = metadata['score'] * 2"
result = self.execute_processing_script(metadata, script)
self.assertEqual(result, 100)
def test_execute_processing_script_with_logic(self):
"""Test script with conditional logic"""
metadata = {"health": 75}
script = """
if metadata['health'] > 50:
script_result = 'healthy'
else:
script_result = 'injured'
"""
result = self.execute_processing_script(metadata, script)
self.assertEqual(result, 'healthy')
def test_execute_processing_script_none_result(self):
"""Test script that doesn't set result"""
metadata = {}
script = "x = 1 + 1" # Doesn't set script_result
result = self.execute_processing_script(metadata, script)
self.assertIsNone(result)
def test_execute_processing_script_complex_calculation(self):
"""Test script with complex calculations"""
metadata = {"values": [1, 2, 3, 4, 5]}
script = "script_result = sum(metadata['values']) / len(metadata['values'])"
result = self.execute_processing_script(metadata, script)
self.assertEqual(result, 3.0)
def test_execute_processing_script_string_manipulation(self):
"""Test script that manipulates strings"""
metadata = {"name": "alice"}
script = "script_result = metadata['name'].upper()"
result = self.execute_processing_script(metadata, script)
self.assertEqual(result, "ALICE")
class TestGetNextStep(unittest.TestCase):
"""Test cases for get_next_step function"""
def setUp(self):
"""Set up test fixtures"""
from activity import get_next_step
self.get_next_step = get_next_step
# Sample activity content
self.activity = {
"sections": [
{
"section_id": "section_1",
"steps": [
{"step_id": "step_1"},
{"step_id": "step_2"},
{"step_id": "step_3"},
]
},
{
"section_id": "section_2",
"steps": [
{"step_id": "step_4"},
{"step_id": "step_5"},
]
}
]
}
def test_get_next_step_within_section(self):
"""Test getting next step within same section"""
next_section, next_step = self.get_next_step(
self.activity, "section_1", "step_1"
)
self.assertEqual(next_section["section_id"], "section_1")
self.assertEqual(next_step["step_id"], "step_2")
def test_get_next_step_last_in_section(self):
"""Test getting next step when at end of section"""
next_section, next_step = self.get_next_step(
self.activity, "section_1", "step_3"
)
self.assertEqual(next_section["section_id"], "section_2")
self.assertEqual(next_step["step_id"], "step_4")
def test_get_next_step_last_in_activity(self):
"""Test getting next step when at end of activity"""
next_section, next_step = self.get_next_step(
self.activity, "section_2", "step_5"
)
self.assertIsNone(next_section)
self.assertIsNone(next_step)
def test_get_next_step_invalid_section(self):
"""Test with invalid section ID"""
next_section, next_step = self.get_next_step(
self.activity, "invalid_section", "step_1"
)
self.assertIsNone(next_section)
self.assertIsNone(next_step)
def test_get_next_step_invalid_step(self):
"""Test with invalid step ID"""
next_section, next_step = self.get_next_step(
self.activity, "section_1", "invalid_step"
)
self.assertIsNone(next_section)
self.assertIsNone(next_step)
class TestCategorizeResponse(unittest.TestCase):
"""Test cases for categorize_response function"""
@patch('activity.get_openai_client_and_model')
def test_categorize_response_simple_format(self, mock_get_client):
"""Test categorization with simple bucket format"""
from activity import categorize_response
# Mock OpenAI client
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content.strip.return_value = "correct"
mock_client.chat.completions.create.return_value = mock_response
mock_get_client.return_value = (mock_client, "gpt-4")
buckets = [
{"bucket_name": "correct", "bucket_criteria": "Answer is correct"},
{"bucket_name": "incorrect", "bucket_criteria": "Answer is wrong"}
]
result = categorize_response(
"What is 2+2?",
"4",
buckets,
"Categorize this answer"
)
self.assertEqual(result, "correct")
@patch('activity.get_openai_client_and_model')
def test_categorize_response_analysis_format(self, mock_get_client):
"""Test categorization with analysis bucket format"""
from activity import categorize_response
# Mock OpenAI client - the function strips to first bucket name match
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
# Activity replaces spaces/colons with underscores, so test the actual behavior
mock_response.choices[0].message.content.strip.return_value = "correct"
mock_client.chat.completions.create.return_value = mock_response
mock_get_client.return_value = (mock_client, "gpt-4")
buckets = [
{"bucket_name": "correct", "bucket_criteria": "Answer is correct"},
{"bucket_name": "incorrect", "bucket_criteria": "Answer is wrong"}
]
result = categorize_response(
"What is 2+2?",
"4",
buckets,
"Categorize this answer"
)
self.assertEqual(result, "correct")
@patch('activity.get_openai_client_and_model')
def test_categorize_response_with_spaces(self, mock_get_client):
"""Test categorization handles extra spaces"""
from activity import categorize_response
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content.strip.return_value = "correct"
mock_client.chat.completions.create.return_value = mock_response
mock_get_client.return_value = (mock_client, "gpt-4")
buckets = [{"bucket_name": "correct"}]
result = categorize_response("Q", "A", buckets, "")
self.assertEqual(result, "correct")
class TestGenerateAIFeedback(unittest.TestCase):
"""Test cases for generate_ai_feedback function"""
@patch('activity.get_openai_client_and_model')
def test_generate_ai_feedback(self, mock_get_client):
"""Test generating AI feedback"""
from activity import generate_ai_feedback
# Mock OpenAI client
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content.strip.return_value = "Great answer!"
mock_client.chat.completions.create.return_value = mock_response
mock_get_client.return_value = (mock_client, "gpt-4")
result = generate_ai_feedback(
"correct",
"What is 2+2?",
"4",
"Provide encouraging feedback",
"alice",
"{}",
"{}"
)
self.assertEqual(result, "Great answer!")
@patch('activity.get_openai_client_and_model')
def test_generate_ai_feedback_with_metadata(self, mock_get_client):
"""Test feedback generation with metadata"""
from activity import generate_ai_feedback
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content.strip.return_value = "Good job!"
mock_client.chat.completions.create.return_value = mock_response
mock_get_client.return_value = (mock_client, "gpt-4")
metadata = json.dumps({"score": 100, "level": 5})
result = generate_ai_feedback(
"correct",
"Question",
"Answer",
"Tokens",
"alice",
metadata,
"{}"
)
# Verify metadata was included in the call
call_args = mock_client.chat.completions.create.call_args
messages = call_args[1]['messages']
# Check that metadata is in one of the messages
found_metadata = False
for msg in messages:
if 'score' in str(msg) and '100' in str(msg):
found_metadata = True
break
self.assertTrue(found_metadata)
class TestTranslateText(unittest.TestCase):
"""Test cases for translate_text function"""
@patch('activity.get_openai_client_and_model')
def test_translate_text_to_spanish(self, mock_get_client):
"""Test translating text to Spanish"""
from activity import translate_text
# Mock OpenAI client
mock_client = MagicMock()
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content.strip.return_value = "Hola mundo"
mock_client.chat.completions.create.return_value = mock_response
mock_get_client.return_value = (mock_client, "gpt-4")
result = translate_text("Hello world", "Spanish")
self.assertEqual(result, "Hola mundo")
@patch('activity.get_openai_client_and_model')
def test_translate_text_english_bypass(self, mock_get_client):
"""Test that English text is not translated"""
from activity import translate_text
result = translate_text("Hello world", "English")
# Should return original text without calling API
self.assertEqual(result, "Hello world")
mock_get_client.assert_not_called()
@patch('activity.get_openai_client_and_model')
def test_translate_text_error_handling(self, mock_get_client):
"""Test translation error handling"""
from activity import translate_text
# Mock client that raises an error
mock_client = MagicMock()
mock_client.chat.completions.create.side_effect = Exception("API Error")
mock_get_client.return_value = (mock_client, "gpt-4")
result = translate_text("Hello", "Spanish")
# Returns error message, not original text
self.assertIn("Error", result)
class TestProvideFeedback(unittest.TestCase):
"""Test cases for provide_feedback function"""
@patch('activity.generate_ai_feedback')
def test_provide_feedback_with_ai_feedback(self, mock_generate):
"""Test providing feedback with AI feedback enabled"""
from activity import provide_feedback
mock_generate.return_value = "Good job!"
transition = {
"ai_feedback": {"tokens_for_ai": "Be encouraging"}
}
result = provide_feedback(
transition,
"correct",
"What is 2+2?",
"Base tokens",
"4",
"English",
"alice",
"{}",
"{}"
)
self.assertIn("Good job!", result)
def test_provide_feedback_without_ai_feedback(self):
"""Test providing feedback without AI feedback"""
from activity import provide_feedback
transition = {} # No ai_feedback config
result = provide_feedback(
transition,
"correct",
"Question",
"Tokens",
"Answer",
"English",
"alice",
"{}",
"{}"
)
self.assertEqual(result, "")
if __name__ == "__main__":
unittest.main()

View file

@ -1,987 +0,0 @@
#!/usr/bin/env python3
"""
Unit tests for app.py feedback functions.
Tests the feedback generation functions including:
- Legacy provide_feedback function
- New provide_feedback_prompts function
- Both systems integration
- Metadata filtering
- Language handling
"""
import unittest
from unittest.mock import patch, MagicMock, call
import sys
import json
from pathlib import Path
# Add parent directory to path to import app functions
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
class TestAppFeedback(unittest.TestCase):
"""Test cases for app.py feedback functions"""
def setUp(self):
"""Set up test fixtures"""
self.sample_transition = {
"ai_feedback": {"tokens_for_ai": "Additional transition instructions"},
"metadata_feedback_filter": ["shot_location", "hit_result", "ship_sunk"],
}
self.sample_metadata = {
"shot_location": "A5",
"hit_result": "hit",
"ship_sunk": "destroyer",
"private_info": "should_be_filtered",
"player_health": 100,
}
self.sample_new_metadata = {"new_shot": "B3", "new_result": "miss"}
def test_provide_feedback_import(self):
"""Test that we can import the provide_feedback function"""
try:
from app import provide_feedback
self.assertTrue(callable(provide_feedback))
except ImportError as e:
self.fail(f"Could not import provide_feedback: {e}")
def test_provide_feedback_prompts_import(self):
"""Test that we can import the provide_feedback_prompts function"""
try:
from app import provide_feedback_prompts
self.assertTrue(callable(provide_feedback_prompts))
except ImportError as e:
self.fail(f"Could not import provide_feedback_prompts: {e}")
@patch("app.get_openai_client_and_model")
def test_provide_feedback_legacy(self, mock_get_client):
"""Test legacy provide_feedback function"""
# Import here to avoid issues if module is not available
try:
from app import provide_feedback
except ImportError:
self.skipTest("app module not available for testing")
# Setup mock
mock_client = MagicMock()
mock_completion = MagicMock()
mock_completion.choices[0].message.content = "Great shot! You hit the target."
mock_client.chat.completions.create.return_value = mock_completion
mock_get_client.return_value = (mock_client, "test-model")
# Test data
transition = self.sample_transition
category = "hit"
question = "Where do you want to shoot?"
feedback_tokens_for_ai = "Provide battleship feedback"
user_response = "A5"
user_language = "English"
username = "testuser"
json_metadata = json.dumps(self.sample_metadata)
json_new_metadata = json.dumps(self.sample_new_metadata)
# Call function
feedback = provide_feedback(
transition,
category,
question,
feedback_tokens_for_ai,
user_response,
user_language,
username,
json_metadata,
json_new_metadata,
)
# Verify result
self.assertIn("Great shot! You hit the target.", feedback)
# Verify client was called
mock_client.chat.completions.create.assert_called_once()
call_args = mock_client.chat.completions.create.call_args[1]
# Check that system message includes language and transition instructions
system_message = call_args["messages"][0]["content"]
self.assertIn("English", system_message)
self.assertIn("Additional transition instructions", system_message)
@patch("app.get_openai_client_and_model")
def test_provide_feedback_prompts_multi(self, mock_get_client):
"""Test provide_feedback_prompts with multiple prompts"""
try:
from app import provide_feedback_prompts
except ImportError:
self.skipTest("app module not available for testing")
# Setup mock to return different responses for each prompt
mock_client = MagicMock()
mock_completion_1 = MagicMock()
mock_completion_1.choices[0].message.content = (
"Your shot at A5 was a hit! Enemy shot at B3 missed."
)
mock_completion_2 = MagicMock()
mock_completion_2.choices[0].message.content = (
"The enemy's destroyer has been sunk!"
)
mock_client.chat.completions.create.side_effect = [
mock_completion_1,
mock_completion_2,
]
mock_get_client.return_value = (mock_client, "test-model")
# Test data
transition = self.sample_transition
category = "valid_move"
question = "Where do you want to shoot?"
feedback_prompts = [
{
"name": "hit_miss_feedback",
"tokens_for_ai": "Report the hit/miss results for both players this turn",
},
{
"name": "ship_sinking_feedback",
"tokens_for_ai": "Report any ships that were sunk this turn",
},
]
user_response = "A5"
user_language = "English"
username = "testuser"
json_metadata = json.dumps(self.sample_metadata)
json_new_metadata = json.dumps(self.sample_new_metadata)
# Call function
feedback_messages = provide_feedback_prompts(
transition,
category,
question,
feedback_prompts,
user_response,
user_language,
username,
json_metadata,
json_new_metadata,
"",
)
# Verify results
self.assertEqual(len(feedback_messages), 2)
# Check first feedback message
self.assertEqual(feedback_messages[0]["name"], "hit_miss_feedback")
self.assertIn("Your shot at A5 was a hit", feedback_messages[0]["content"])
# Check second feedback message
self.assertEqual(feedback_messages[1]["name"], "ship_sinking_feedback")
self.assertIn("destroyer has been sunk", feedback_messages[1]["content"])
# Verify client was called twice
self.assertEqual(mock_client.chat.completions.create.call_count, 2)
@patch("app.get_openai_client_and_model")
def test_provide_feedback_with_filtered_metadata(self, mock_get_client):
"""Test that provide_feedback works correctly with pre-filtered metadata"""
try:
from app import provide_feedback
except ImportError:
self.skipTest("app module not available for testing")
# Setup mock
mock_client = MagicMock()
mock_completion = MagicMock()
mock_completion.choices[0].message.content = "Filtered feedback"
mock_client.chat.completions.create.return_value = mock_completion
mock_get_client.return_value = (mock_client, "test-model")
# Simulate app.py behavior: filter metadata before calling provide_feedback
filtered_metadata = {
k: v
for k, v in self.sample_metadata.items()
if k in self.sample_transition["metadata_feedback_filter"]
}
provide_feedback(
self.sample_transition,
"test",
"Question?",
"tokens",
"response",
"English",
"user",
json.dumps(filtered_metadata),
json.dumps({}),
)
# Check that user message contains only filtered metadata
call_args = mock_client.chat.completions.create.call_args[1]
user_message = call_args["messages"][1]["content"]
# Should contain filtered fields
self.assertIn("shot_location", user_message)
self.assertIn("hit_result", user_message)
self.assertIn("ship_sunk", user_message)
# Should NOT contain unfiltered fields (because we pre-filtered)
self.assertNotIn("private_info", user_message)
self.assertNotIn("player_health", user_message)
@patch("app.get_openai_client_and_model")
def test_provide_feedback_no_filter(self, mock_get_client):
"""Test feedback when no metadata filter is specified"""
try:
from app import provide_feedback
except ImportError:
self.skipTest("app module not available for testing")
# Setup mock
mock_client = MagicMock()
mock_completion = MagicMock()
mock_completion.choices[0].message.content = "Unfiltered feedback"
mock_client.chat.completions.create.return_value = mock_completion
mock_get_client.return_value = (mock_client, "test-model")
# Call function without metadata filter
transition = {
"ai_feedback": {"tokens_for_ai": "Generate feedback"}
} # No metadata_feedback_filter
provide_feedback(
transition,
"test",
"Question?",
"tokens",
"response",
"English",
"user",
json.dumps(self.sample_metadata),
json.dumps({}),
)
# Check that user message contains all metadata
call_args = mock_client.chat.completions.create.call_args[1]
user_message = call_args["messages"][1]["content"]
# Should contain all metadata fields when no filter is applied
self.assertIn("private_info", user_message)
self.assertIn("player_health", user_message)
@patch("app.get_openai_client_and_model")
def test_provide_feedback_error_handling(self, mock_get_client):
"""Test error handling in feedback functions"""
try:
from app import provide_feedback
except ImportError:
self.skipTest("app module not available for testing")
# Setup mock to raise exception
mock_client = MagicMock()
mock_client.chat.completions.create.side_effect = Exception("API Error")
mock_get_client.return_value = (mock_client, "test-model")
# Call function
feedback = provide_feedback(
{"ai_feedback": {"tokens_for_ai": "Generate feedback"}},
"test",
"Question?",
"tokens",
"response",
"English",
"user",
json.dumps({}),
json.dumps({}),
)
# Should handle error gracefully
self.assertIn("Error", feedback)
@patch("app.get_openai_client_and_model")
def test_provide_feedback_prompts_filter_empty(self, mock_get_client):
"""Test feedback_prompts with empty results filtered out"""
try:
from app import provide_feedback_prompts
except ImportError:
self.skipTest("app module not available for testing")
# Setup mock to return mixed results including empty
mock_client = MagicMock()
mock_completion_1 = MagicMock()
mock_completion_1.choices[0].message.content = "" # Empty result
mock_completion_2 = MagicMock()
mock_completion_2.choices[0].message.content = (
" " # Whitespace only (should be filtered)
)
mock_completion_3 = MagicMock()
mock_completion_3.choices[0].message.content = "Valid feedback" # Valid result
mock_client.chat.completions.create.side_effect = [
mock_completion_1,
mock_completion_2,
mock_completion_3,
]
mock_get_client.return_value = (mock_client, "test-model")
# Test data
feedback_prompts = [
{"name": "empty", "tokens_for_ai": "Empty prompt"},
{"name": "whitespace", "tokens_for_ai": "Whitespace prompt"},
{"name": "valid", "tokens_for_ai": "Valid prompt"},
]
feedback_messages = provide_feedback_prompts(
{},
"test",
"Question?",
feedback_prompts,
"response",
"English",
"user",
json.dumps({}),
json.dumps({}),
"",
)
# Should only return valid feedback (empty and whitespace filtered out)
self.assertEqual(len(feedback_messages), 1)
self.assertEqual(feedback_messages[0]["name"], "valid")
self.assertEqual(feedback_messages[0]["content"], "Valid feedback")
@patch("app.get_openai_client_and_model")
def test_provide_feedback_prompts_per_prompt_metadata_filtering(
self, mock_get_client
):
"""Test that each prompt gets its own filtered metadata"""
try:
from app import provide_feedback_prompts
except ImportError:
self.skipTest("app module not available for testing")
# Setup mock to return different responses
mock_client = MagicMock()
mock_completion_1 = MagicMock()
mock_completion_1.choices[0].message.content = (
"Shot feedback with hit/miss data"
)
mock_completion_2 = MagicMock()
mock_completion_2.choices[0].message.content = "Ship feedback with sinking data"
mock_client.chat.completions.create.side_effect = [
mock_completion_1,
mock_completion_2,
]
mock_get_client.return_value = (mock_client, "test-model")
# Test data with mixed metadata
full_metadata = {
"user_shot": "A5",
"user_hit_result": "hit",
"ai_shot": "B3",
"ai_hit_result": "miss",
"user_sunk_ship_this_round": "Destroyer",
"ai_sunk_ship_this_round": None,
"game_over": False,
"extra_field": "should_not_appear",
}
feedback_prompts = [
{
"name": "shot_report",
"tokens_for_ai": "Report hit/miss",
"metadata_filter": [
"user_shot",
"user_hit_result",
"ai_shot",
"ai_hit_result",
],
},
{
"name": "ship_status",
"tokens_for_ai": "Report ship sinking",
"metadata_filter": [
"user_sunk_ship_this_round",
"ai_sunk_ship_this_round",
],
},
]
feedback_messages = provide_feedback_prompts(
{},
"test",
"Question?",
feedback_prompts,
"response",
"English",
"user",
json.dumps(full_metadata),
json.dumps({}),
"",
)
# Verify both prompts got responses
self.assertEqual(len(feedback_messages), 2)
self.assertEqual(feedback_messages[0]["name"], "shot_report")
self.assertEqual(feedback_messages[1]["name"], "ship_status")
# Verify the first prompt only got shot-related metadata
first_call_args = mock_client.chat.completions.create.call_args_list[0][1]
first_user_message = first_call_args["messages"][1]["content"]
self.assertIn("user_shot", first_user_message)
self.assertIn("user_hit_result", first_user_message)
self.assertIn("ai_shot", first_user_message)
self.assertIn("ai_hit_result", first_user_message)
self.assertNotIn("user_sunk_ship_this_round", first_user_message)
self.assertNotIn("extra_field", first_user_message)
# Verify the second prompt only got ship-related metadata
second_call_args = mock_client.chat.completions.create.call_args_list[1][1]
second_user_message = second_call_args["messages"][1]["content"]
self.assertIn("user_sunk_ship_this_round", second_user_message)
self.assertIn("ai_sunk_ship_this_round", second_user_message)
self.assertNotIn("user_shot", second_user_message)
self.assertNotIn("extra_field", second_user_message)
@patch("app.get_openai_client_and_model")
def test_ship_status_metadata_filtering_debug(self, mock_get_client):
"""Debug test to check if Ship Status is getting only the right metadata"""
try:
from app import provide_feedback_prompts
except ImportError:
self.skipTest("app module not available for testing")
# Setup mock
mock_client = MagicMock()
mock_completion = MagicMock()
mock_completion.choices[0].message.content = "Test response"
mock_client.chat.completions.create.return_value = mock_completion
mock_get_client.return_value = (mock_client, "test-model")
# Test data mimicking the actual battleship scenario
full_metadata = {
"user_shot": "46", # This should NOT appear in Ship Status
"ai_shot": "49", # This should NOT appear in Ship Status
"user_hit_result": "hit",
"ai_hit_result": "miss",
"user_sunk_ship_this_round": "Destroyer", # This SHOULD appear
"ai_sunk_ship_this_round": None, # This SHOULD appear
"game_over": False,
"extra_stuff": "should not appear anywhere",
}
# Exact structure from battleship YAML
feedback_prompts = [
{
"name": "Shot Report",
"tokens_for_ai": "🎯 Report ONLY the hit/miss results",
"metadata_filter": [
"user_shot",
"ai_shot",
"user_hit_result",
"ai_hit_result",
],
},
{
"name": "Ship Status",
"tokens_for_ai": "You are the Ship Destruction Oracle",
"metadata_filter": [
"user_sunk_ship_this_round",
"ai_sunk_ship_this_round",
],
},
]
# Call the function
provide_feedback_prompts(
{},
"valid_move",
"Question?",
feedback_prompts,
"46",
"English",
"user",
json.dumps(full_metadata),
json.dumps({}),
"",
)
# Check what metadata each prompt actually received
self.assertEqual(mock_client.chat.completions.create.call_count, 2)
# First call should be Shot Report
shot_report_call = mock_client.chat.completions.create.call_args_list[0][1]
shot_report_metadata = shot_report_call["messages"][1]["content"]
print("=== SHOT REPORT METADATA ===")
print(shot_report_metadata)
# Shot Report should have shot data but NOT ship destruction data
self.assertIn("user_shot", shot_report_metadata)
self.assertIn("46", shot_report_metadata)
self.assertNotIn("user_sunk_ship_this_round", shot_report_metadata)
self.assertNotIn("Destroyer", shot_report_metadata)
# Second call should be Ship Status
ship_status_call = mock_client.chat.completions.create.call_args_list[1][1]
ship_status_metadata = ship_status_call["messages"][1]["content"]
print("=== SHIP STATUS METADATA ===")
print(ship_status_metadata)
# Ship Status should have ship destruction data but NOT shot data
self.assertIn("user_sunk_ship_this_round", ship_status_metadata)
self.assertIn("Destroyer", ship_status_metadata)
self.assertNotIn("user_shot", ship_status_metadata)
self.assertNotIn("46", ship_status_metadata)
self.assertNotIn("extra_stuff", ship_status_metadata)
def test_provide_feedback_prompts_language_injection(self):
"""Test that language instructions are properly added to prompts"""
try:
from app import provide_feedback_prompts
except ImportError:
self.skipTest("app module not available for testing")
with patch("app.get_openai_client_and_model") as mock_get_client:
mock_client = MagicMock()
mock_completion = MagicMock()
mock_completion.choices[0].message.content = "Feedback in Spanish"
mock_client.chat.completions.create.return_value = mock_completion
mock_get_client.return_value = (mock_client, "test-model")
feedback_prompts = [{"name": "test", "tokens_for_ai": "Base prompt"}]
# Test with Spanish language
provide_feedback_prompts(
{},
"test",
"Question?",
feedback_prompts,
"response",
"Spanish",
"user",
json.dumps({}),
json.dumps({}),
"",
)
# Check that system message includes Spanish language instruction
call_args = mock_client.chat.completions.create.call_args[1]
system_message = call_args["messages"][0]["content"]
self.assertIn("Spanish", system_message)
self.assertIn("Base prompt", system_message)
@patch("app.get_openai_client_and_model")
def test_provide_feedback_transition_tokens(self, mock_get_client):
"""Test that transition ai_feedback tokens are included"""
try:
from app import provide_feedback_prompts
except ImportError:
self.skipTest("app module not available for testing")
mock_client = MagicMock()
mock_completion = MagicMock()
mock_completion.choices[0].message.content = "Enhanced feedback"
mock_client.chat.completions.create.return_value = mock_completion
mock_get_client.return_value = (mock_client, "test-model")
transition = {
"ai_feedback": {"tokens_for_ai": "Be more dramatic in your feedback"}
}
feedback_prompts = [{"name": "test", "tokens_for_ai": "Base prompt"}]
provide_feedback_prompts(
transition,
"test",
"Question?",
feedback_prompts,
"response",
"English",
"user",
json.dumps({}),
json.dumps({}),
"",
)
# Check that system message includes both base and transition tokens
call_args = mock_client.chat.completions.create.call_args[1]
system_message = call_args["messages"][0]["content"]
self.assertIn("Base prompt", system_message)
self.assertIn("Be more dramatic in your feedback", system_message)
@patch("app.get_openai_client_and_model")
def test_user_response_filtering_with_metadata_filter(self, mock_get_client):
"""Test that user_response is filtered correctly using metadata_filter approach"""
try:
from app import provide_feedback_prompts
except ImportError:
self.skipTest("app module not available for testing")
# Setup mock
mock_client = MagicMock()
mock_completion = MagicMock()
mock_completion.choices[0].message.content = "Response for prompt"
mock_client.chat.completions.create.return_value = mock_completion
mock_get_client.return_value = (mock_client, "test-model")
# Test feedback prompts - one that includes user_response, one that doesn't
feedback_prompts = [
{
"name": "Shot Report",
"tokens_for_ai": "Report shot positions",
"metadata_filter": [
"user_shot",
"user_response",
], # Includes user_response
},
{
"name": "Ship Status",
"tokens_for_ai": "Report ship status",
"metadata_filter": ["ship_status"], # Does NOT include user_response
},
]
metadata = {"user_shot": "35", "ship_status": "intact"}
user_response = "I choose position 35"
provide_feedback_prompts(
{},
"valid_move",
"Choose position?",
feedback_prompts,
user_response,
"English",
"user",
json.dumps(metadata),
json.dumps({}),
"",
)
# Should have 2 calls
self.assertEqual(mock_client.chat.completions.create.call_count, 2)
# First call (Shot Report) should have user_response
first_call = mock_client.chat.completions.create.call_args_list[0][1]
first_user_message = first_call["messages"][1]["content"]
self.assertIn(
"I choose position 35", first_user_message
) # user_response should be present
# Second call (Ship Status) should NOT have user_response
second_call = mock_client.chat.completions.create.call_args_list[1][1]
second_user_message = second_call["messages"][1]["content"]
self.assertEqual(
second_user_message.count("I choose position 35"), 0
) # user_response should be empty/filtered
@patch("app.get_openai_client_and_model")
def test_skip_condition_all_null(self, mock_get_client):
"""Test skip_condition 'all_null' skips prompts when all metadata values are null"""
try:
from app import provide_feedback_prompts
except ImportError:
self.skipTest("app module not available for testing")
# Mock client (should not be called for skipped prompts)
mock_client = MagicMock()
mock_get_client.return_value = (mock_client, "test-model")
feedback_prompts = [
{
"name": "Ship Status",
"tokens_for_ai": "Report ship destruction",
"metadata_filter": ["user_sunk_ship", "ai_sunk_ship"],
"skip_condition": "all_null"
}
]
# Test with all null values - should skip
metadata_all_null = {
"user_sunk_ship": None,
"ai_sunk_ship": None
}
feedback_messages = provide_feedback_prompts(
{},
"test",
"Question?",
feedback_prompts,
"response",
"English",
"user",
json.dumps(metadata_all_null),
json.dumps({}),
""
)
# Should be empty (prompt was skipped)
self.assertEqual(len(feedback_messages), 0)
# Client should not have been called
self.assertEqual(mock_client.chat.completions.create.call_count, 0)
@patch("app.get_openai_client_and_model")
def test_skip_condition_all_null_with_values(self, mock_get_client):
"""Test skip_condition 'all_null' does NOT skip when values exist"""
try:
from app import provide_feedback_prompts
except ImportError:
self.skipTest("app module not available for testing")
# Mock client to return valid response
mock_client = MagicMock()
mock_completion = MagicMock()
mock_completion.choices[0].message.content = "Ship destroyed!"
mock_client.chat.completions.create.return_value = mock_completion
mock_get_client.return_value = (mock_client, "test-model")
feedback_prompts = [
{
"name": "Ship Status",
"tokens_for_ai": "Report ship destruction",
"metadata_filter": ["user_sunk_ship", "ai_sunk_ship"],
"skip_condition": "all_null"
}
]
# Test with actual values - should NOT skip
metadata_with_values = {
"user_sunk_ship": "Destroyer",
"ai_sunk_ship": None
}
feedback_messages = provide_feedback_prompts(
{},
"test",
"Question?",
feedback_prompts,
"response",
"English",
"user",
json.dumps(metadata_with_values),
json.dumps({}),
""
)
# Should have feedback (prompt was NOT skipped)
self.assertEqual(len(feedback_messages), 1)
self.assertEqual(feedback_messages[0]["name"], "Ship Status")
self.assertEqual(feedback_messages[0]["content"], "Ship destroyed!")
# Client should have been called
self.assertEqual(mock_client.chat.completions.create.call_count, 1)
@patch("app.get_openai_client_and_model")
def test_skip_condition_all_false(self, mock_get_client):
"""Test skip_condition 'all_false' skips when all metadata values are False"""
try:
from app import provide_feedback_prompts
except ImportError:
self.skipTest("app module not available for testing")
mock_client = MagicMock()
mock_get_client.return_value = (mock_client, "test-model")
feedback_prompts = [
{
"name": "Game Over",
"tokens_for_ai": "Report game over",
"metadata_filter": ["game_over", "user_wins", "ai_wins"],
"skip_condition": "all_false"
}
]
# Test with all false values - should skip
metadata_all_false = {
"game_over": False,
"user_wins": False,
"ai_wins": False
}
feedback_messages = provide_feedback_prompts(
{},
"test",
"Question?",
feedback_prompts,
"response",
"English",
"user",
json.dumps(metadata_all_false),
json.dumps({}),
""
)
# Should be empty (prompt was skipped)
self.assertEqual(len(feedback_messages), 0)
self.assertEqual(mock_client.chat.completions.create.call_count, 0)
@patch("app.get_openai_client_and_model")
def test_skip_condition_all_true(self, mock_get_client):
"""Test skip_condition 'all_true' skips when all metadata values are True"""
try:
from app import provide_feedback_prompts
except ImportError:
self.skipTest("app module not available for testing")
mock_client = MagicMock()
mock_get_client.return_value = (mock_client, "test-model")
feedback_prompts = [
{
"name": "All True Test",
"tokens_for_ai": "Test prompt",
"metadata_filter": ["flag1", "flag2", "flag3"],
"skip_condition": "all_true"
}
]
# Test with all true values - should skip
metadata_all_true = {
"flag1": True,
"flag2": True,
"flag3": True
}
feedback_messages = provide_feedback_prompts(
{},
"test",
"Question?",
feedback_prompts,
"response",
"English",
"user",
json.dumps(metadata_all_true),
json.dumps({}),
""
)
# Should be empty (prompt was skipped)
self.assertEqual(len(feedback_messages), 0)
self.assertEqual(mock_client.chat.completions.create.call_count, 0)
@patch("app.get_openai_client_and_model")
def test_skip_condition_mixed_values(self, mock_get_client):
"""Test skip_condition does NOT skip when values are mixed"""
try:
from app import provide_feedback_prompts
except ImportError:
self.skipTest("app module not available for testing")
# Mock client to return valid response
mock_client = MagicMock()
mock_completion = MagicMock()
mock_completion.choices[0].message.content = "Mixed values feedback"
mock_client.chat.completions.create.return_value = mock_completion
mock_get_client.return_value = (mock_client, "test-model")
feedback_prompts = [
{
"name": "Mixed Test",
"tokens_for_ai": "Mixed test prompt",
"metadata_filter": ["val1", "val2", "val3"],
"skip_condition": "all_false"
}
]
# Test with mixed values - should NOT skip
metadata_mixed = {
"val1": False,
"val2": True, # Mixed with False - should NOT skip
"val3": False
}
feedback_messages = provide_feedback_prompts(
{},
"test",
"Question?",
feedback_prompts,
"response",
"English",
"user",
json.dumps(metadata_mixed),
json.dumps({}),
""
)
# Should have feedback (prompt was NOT skipped due to mixed values)
self.assertEqual(len(feedback_messages), 1)
self.assertEqual(feedback_messages[0]["name"], "Mixed Test")
self.assertEqual(feedback_messages[0]["content"], "Mixed values feedback")
self.assertEqual(mock_client.chat.completions.create.call_count, 1)
@patch("app.get_openai_client_and_model")
def test_skip_condition_battleship_scenario(self, mock_get_client):
"""Test the real battleship scenario that was causing hallucinations"""
try:
from app import provide_feedback_prompts
except ImportError:
self.skipTest("app module not available for testing")
mock_client = MagicMock()
mock_get_client.return_value = (mock_client, "test-model")
feedback_prompts = [
{
"name": "Shot Report",
"tokens_for_ai": "Report shot results",
"metadata_filter": ["user_shot", "ai_shot", "user_hit_result", "ai_hit_result"]
# No skip condition - always runs
},
{
"name": "Ship Status",
"tokens_for_ai": "Report ship destruction",
"metadata_filter": ["user_sunk_ship_this_round", "ai_sunk_ship_this_round"],
"skip_condition": "all_null" # Skip when no ships sunk
},
{
"name": "Game Over",
"tokens_for_ai": "Report game over",
"metadata_filter": ["game_over", "user_wins", "ai_wins"],
"skip_condition": "all_false" # Skip when game not over
}
]
# Real scenario: shots taken, no ships sunk, game continues
real_battleship_metadata = {
"user_shot": 23,
"ai_shot": 46,
"user_hit_result": "hit",
"ai_hit_result": "hit",
"user_sunk_ship_this_round": None, # No ship sunk
"ai_sunk_ship_this_round": None, # No ship sunk
"game_over": False,
"user_wins": False,
"ai_wins": False
}
# Mock only Shot Report response (others should be skipped)
mock_completion = MagicMock()
mock_completion.choices[0].message.content = "🎯 Your shot at 23: hit! AI shot at 46: hit!"
mock_client.chat.completions.create.return_value = mock_completion
feedback_messages = provide_feedback_prompts(
{},
"test",
"Choose position",
feedback_prompts,
"23",
"English",
"user",
json.dumps(real_battleship_metadata),
json.dumps({}),
""
)
# Should only have Shot Report (other two skipped)
self.assertEqual(len(feedback_messages), 1)
self.assertEqual(feedback_messages[0]["name"], "Shot Report")
self.assertIn("🎯", feedback_messages[0]["content"])
# Only one API call should have been made (Ship Status and Game Over skipped)
self.assertEqual(mock_client.chat.completions.create.call_count, 1)
if __name__ == "__main__":
unittest.main(verbosity=2)

381
tests/unit/test_models.py Normal file
View file

@ -0,0 +1,381 @@
#!/usr/bin/env python3
"""
Comprehensive unit tests for models.py
Tests all database models:
- Room: user management, active/inactive tracking
- UserSession: session tracking
- Message: message storage, token counting, image detection
- ActivityState: state management, metadata operations
"""
import unittest
import json
from unittest.mock import patch, MagicMock
import sys
from pathlib import Path
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
class TestRoomModel(unittest.TestCase):
"""Test cases for Room model"""
def setUp(self):
"""Set up test fixtures"""
# Import here to avoid issues
from models import Room
self.Room = Room
def create_room(self, name="test_room", title=None):
"""Helper to create a room instance"""
room = self.Room()
room.name = name
room.title = title
room.active_users = ""
room.inactive_users = ""
return room
def test_room_creation(self):
"""Test creating a room"""
room = self.create_room("test_room", "Test Room")
self.assertEqual(room.name, "test_room")
self.assertEqual(room.title, "Test Room")
self.assertEqual(room.active_users, "")
self.assertEqual(room.inactive_users, "")
def test_add_first_user(self):
"""Test adding the first user to a room"""
room = self.create_room()
room.add_user("alice")
self.assertEqual(room.active_users, "alice")
self.assertEqual(room.inactive_users, "")
self.assertEqual(room.get_active_users(), ["alice"])
self.assertEqual(room.get_inactive_users(), [])
def test_add_multiple_users(self):
"""Test adding multiple users to a room"""
room = self.create_room()
room.add_user("alice")
room.add_user("bob")
room.add_user("charlie")
active = room.get_active_users()
self.assertEqual(len(active), 3)
self.assertIn("alice", active)
self.assertIn("bob", active)
self.assertIn("charlie", active)
def test_add_duplicate_user(self):
"""Test adding the same user twice"""
room = self.create_room()
room.add_user("alice")
room.add_user("alice")
active = room.get_active_users()
self.assertEqual(len(active), 1)
self.assertEqual(active, ["alice"])
def test_remove_user(self):
"""Test removing a user from active to inactive"""
room = self.create_room()
room.add_user("alice")
room.add_user("bob")
room.remove_user("alice")
active = room.get_active_users()
inactive = room.get_inactive_users()
self.assertNotIn("alice", active)
self.assertIn("bob", active)
self.assertIn("alice", inactive)
def test_remove_nonexistent_user(self):
"""Test removing a user that doesn't exist"""
room = self.create_room()
room.add_user("alice")
room.remove_user("bob") # User not in room
active = room.get_active_users()
self.assertEqual(active, ["alice"])
def test_reactivate_inactive_user(self):
"""Test moving a user from inactive back to active"""
room = self.create_room()
room.add_user("alice")
room.remove_user("alice") # Move to inactive
self.assertIn("alice", room.get_inactive_users())
room.add_user("alice") # Reactivate
self.assertIn("alice", room.get_active_users())
self.assertNotIn("alice", room.get_inactive_users())
def test_get_active_users_empty(self):
"""Test getting active users when none exist"""
room = self.create_room()
self.assertEqual(room.get_active_users(), [])
def test_get_inactive_users_empty(self):
"""Test getting inactive users when none exist"""
room = self.create_room()
self.assertEqual(room.get_inactive_users(), [])
def test_users_sorted(self):
"""Test that users are stored in sorted order"""
room = self.create_room()
room.add_user("charlie")
room.add_user("alice")
room.add_user("bob")
# Check they're sorted
self.assertEqual(room.active_users, "alice,bob,charlie")
class TestUserSessionModel(unittest.TestCase):
"""Test cases for UserSession model"""
def setUp(self):
"""Set up test fixtures"""
from models import UserSession
self.UserSession = UserSession
def test_user_session_creation(self):
"""Test creating a user session"""
session = self.UserSession()
session.session_id = "test_session_123"
session.username = "alice"
session.room_name = "test_room"
session.room_id = 1
self.assertEqual(session.session_id, "test_session_123")
self.assertEqual(session.username, "alice")
self.assertEqual(session.room_name, "test_room")
self.assertEqual(session.room_id, 1)
class TestMessageModel(unittest.TestCase):
"""Test cases for Message model"""
def setUp(self):
"""Set up test fixtures"""
from models import Message
self.Message = Message
def test_message_creation(self):
"""Test creating a message"""
with patch('models.tiktoken.encoding_for_model') as mock_encoding:
mock_enc = MagicMock()
mock_enc.encode.return_value = [1, 2, 3, 4, 5] # 5 tokens
mock_encoding.return_value = mock_enc
msg = self.Message("alice", "Hello world", 1)
self.assertEqual(msg.username, "alice")
self.assertEqual(msg.content, "Hello world")
self.assertEqual(msg.room_id, 1)
self.assertEqual(msg.token_count, 5)
def test_count_tokens(self):
"""Test token counting for text messages"""
with patch('models.tiktoken.encoding_for_model') as mock_encoding:
mock_enc = MagicMock()
mock_enc.encode.return_value = [1, 2, 3] # 3 tokens
mock_encoding.return_value = mock_enc
msg = self.Message("alice", "Test message", 1)
count = msg.count_tokens()
self.assertEqual(count, 3)
mock_encoding.assert_called_with("gpt-4")
def test_count_tokens_cached(self):
"""Test that token count is cached after first calculation"""
with patch('models.tiktoken.encoding_for_model') as mock_encoding:
mock_enc = MagicMock()
mock_enc.encode.return_value = [1, 2, 3]
mock_encoding.return_value = mock_enc
msg = self.Message("alice", "Test", 1)
msg.count_tokens() # First call
msg.count_tokens() # Second call
# Should only encode once (cached)
self.assertEqual(mock_enc.encode.call_count, 1)
def test_is_base64_image_jpeg(self):
"""Test detecting JPEG base64 images"""
content = '<img src="data:image/jpeg;base64,/9j/4AAQSkZJRg...">'
msg = self.Message("alice", content, 1)
self.assertTrue(msg.is_base64_image())
def test_is_base64_image_png(self):
"""Test detecting PNG base64 images"""
content = '<img alt="Plot Image" src="data:image/png;base64,iVBORw0KGgo...">'
msg = self.Message("alice", content, 1)
self.assertTrue(msg.is_base64_image())
def test_is_not_base64_image(self):
"""Test that regular text is not detected as image"""
msg = self.Message("alice", "Regular text message", 1)
self.assertFalse(msg.is_base64_image())
def test_image_token_count_is_zero(self):
"""Test that images have zero token count"""
content = '<img src="data:image/jpeg;base64,/9j/4AAQSkZJRg...">'
with patch('models.tiktoken.encoding_for_model') as mock_encoding:
msg = self.Message("alice", content, 1)
self.assertEqual(msg.token_count, 0)
# Should not call encoding for images
mock_encoding.assert_not_called()
class TestActivityStateModel(unittest.TestCase):
"""Test cases for ActivityState model"""
def setUp(self):
"""Set up test fixtures"""
from models import ActivityState
self.ActivityState = ActivityState
def create_activity_state(self):
"""Helper to create an activity state instance"""
state = self.ActivityState()
state.room_id = 1
state.section_id = "section_1"
state.step_id = "step_1"
state.attempts = 0
state.max_attempts = 3
state.s3_file_path = "test_activity.yaml"
state.json_metadata = "{}"
return state
def test_activity_state_creation(self):
"""Test creating an activity state"""
state = self.create_activity_state()
self.assertEqual(state.room_id, 1)
self.assertEqual(state.section_id, "section_1")
self.assertEqual(state.step_id, "step_1")
self.assertEqual(state.attempts, 0)
self.assertEqual(state.max_attempts, 3)
self.assertEqual(state.s3_file_path, "test_activity.yaml")
def test_dict_metadata_getter_empty(self):
"""Test getting empty metadata as dict"""
state = self.create_activity_state()
metadata = state.dict_metadata
self.assertEqual(metadata, {})
self.assertIsInstance(metadata, dict)
def test_dict_metadata_getter_with_data(self):
"""Test getting metadata with data"""
state = self.create_activity_state()
state.json_metadata = json.dumps({"score": 100, "level": 5})
metadata = state.dict_metadata
self.assertEqual(metadata["score"], 100)
self.assertEqual(metadata["level"], 5)
def test_dict_metadata_setter(self):
"""Test setting metadata as dict"""
state = self.create_activity_state()
state.dict_metadata = {"user_name": "alice", "score": 50}
# Check it's stored as JSON
self.assertIsInstance(state.json_metadata, str)
# Check it can be retrieved
metadata = state.dict_metadata
self.assertEqual(metadata["user_name"], "alice")
self.assertEqual(metadata["score"], 50)
def test_add_metadata(self):
"""Test adding individual metadata items"""
state = self.create_activity_state()
state.add_metadata("player_health", 100)
state.add_metadata("enemy_health", 80)
metadata = state.dict_metadata
self.assertEqual(metadata["player_health"], 100)
self.assertEqual(metadata["enemy_health"], 80)
def test_add_metadata_overwrites_existing(self):
"""Test that adding metadata with same key overwrites"""
state = self.create_activity_state()
state.add_metadata("score", 50)
state.add_metadata("score", 100) # Overwrite
metadata = state.dict_metadata
self.assertEqual(metadata["score"], 100)
def test_remove_metadata(self):
"""Test removing metadata items"""
state = self.create_activity_state()
state.dict_metadata = {"a": 1, "b": 2, "c": 3}
state.remove_metadata("b")
metadata = state.dict_metadata
self.assertNotIn("b", metadata)
self.assertEqual(metadata["a"], 1)
self.assertEqual(metadata["c"], 3)
def test_remove_nonexistent_metadata(self):
"""Test removing metadata that doesn't exist"""
state = self.create_activity_state()
state.dict_metadata = {"a": 1}
# Should not raise error
state.remove_metadata("nonexistent")
metadata = state.dict_metadata
self.assertEqual(metadata, {"a": 1})
def test_clear_metadata(self):
"""Test clearing all metadata"""
state = self.create_activity_state()
state.dict_metadata = {"a": 1, "b": 2, "c": 3}
state.clear_metadata()
metadata = state.dict_metadata
self.assertEqual(metadata, {})
def test_metadata_supports_nested_structures(self):
"""Test that metadata can store nested structures"""
state = self.create_activity_state()
complex_data = {
"user": {"name": "alice", "score": 100},
"game": {"level": 5, "items": ["sword", "shield"]},
}
state.dict_metadata = complex_data
metadata = state.dict_metadata
self.assertEqual(metadata["user"]["name"], "alice")
self.assertEqual(metadata["game"]["items"], ["sword", "shield"])
def test_metadata_none_handling(self):
"""Test handling None in json_metadata"""
state = self.create_activity_state()
state.json_metadata = None
# Should return empty dict, not error
metadata = state.dict_metadata
self.assertEqual(metadata, {})
if __name__ == "__main__":
unittest.main()