claude-os/tests/test_learning_jobs.py
2025-12-02 15:28:25 -06:00

435 lines
17 KiB
Python

"""
Tests for learning jobs functionality.
"""
import pytest
import tempfile
import json
import time
from pathlib import Path
from unittest.mock import patch, MagicMock, mock_open
from app.core.learning_jobs import (
process_learning_detection,
prompt_user_for_confirmation,
ingest_to_mcp,
handle_conversation_message,
wait_for_confirmation,
subscribe_to_conversations
)
@pytest.mark.unit
class TestLearningJobs:
"""Test learning jobs functions."""
@patch('app.core.learning_jobs.RedisConfig')
def test_process_learning_detection_confirmed(self, mock_redis_config, tmp_path):
"""Test processing learning detection with user confirmation."""
# Configure RedisConfig mock
mock_redis_config.set_confirmation = MagicMock()
mock_redis_config.queue_prompt_job = MagicMock(return_value="prompt_job_123")
mock_redis_config.queue_ingest_job = MagicMock(return_value="ingest_job_123")
detection = {
"id": "detection_123",
"trigger": "switching",
"text": "switching from A to B",
"confidence": 0.95
}
with patch('app.core.learning_jobs.wait_for_confirmation') as mock_wait:
mock_wait.return_value = True
result = process_learning_detection(123, detection)
assert result["status"] == "confirmed"
assert result["ingest_job_id"] == "ingest_job_123"
assert result["detection_id"] == "detection_123"
# Verify Redis calls
mock_redis_config.set_confirmation.assert_called_once_with(123, "detection_123", False)
mock_redis_config.queue_prompt_job.assert_called_once_with(123, detection)
mock_wait.assert_called_once_with(123, "detection_123", timeout=600)
@patch('app.core.learning_jobs.RedisConfig')
def test_process_learning_detection_skipped(self, mock_redis_config, tmp_path):
"""Test processing learning detection with user skipping."""
# Configure RedisConfig mock
mock_redis_config.set_confirmation = MagicMock()
mock_redis_config.queue_prompt_job = MagicMock(return_value="prompt_job_123")
mock_redis_config.queue_ingest_job = MagicMock()
detection = {
"id": "detection_123",
"trigger": "switching",
"text": "switching from A to B",
"confidence": 0.95
}
with patch('app.core.learning_jobs.wait_for_confirmation') as mock_wait:
mock_wait.return_value = False
result = process_learning_detection(123, detection)
assert result["status"] == "skipped"
assert result["detection_id"] == "detection_123"
# Verify Redis calls
mock_redis_config.set_confirmation.assert_called_once_with(123, "detection_123", False)
mock_redis_config.queue_prompt_job.assert_called_once_with(123, detection)
mock_wait.assert_called_once_with(123, "detection_123", timeout=600)
# Should not call ingest when skipped
mock_redis_config.queue_ingest_job.assert_not_called()
@patch('app.core.learning_jobs.RedisConfig')
def test_process_learning_detection_timeout(self, mock_redis_config, tmp_path):
"""Test processing learning detection with timeout."""
# Configure RedisConfig mock
mock_redis_config.set_confirmation = MagicMock()
mock_redis_config.queue_prompt_job = MagicMock(return_value="prompt_job_123")
mock_redis_config.queue_ingest_job = MagicMock()
detection = {
"id": "detection_123",
"trigger": "switching",
"text": "switching from A to B",
"confidence": 0.95
}
with patch('app.core.learning_jobs.wait_for_confirmation') as mock_wait:
mock_wait.return_value = False # Timeout returns False
result = process_learning_detection(123, detection)
assert result["status"] == "skipped"
assert result["detection_id"] == "detection_123"
# Should not call ingest when timeout
mock_redis_config.queue_ingest_job.assert_not_called()
@patch('app.core.learning_jobs.ConversationWatcher')
def test_prompt_user_for_confirmation(self, mock_watcher_class):
"""Test prompting user for confirmation."""
mock_watcher = MagicMock()
mock_watcher_class.return_value = mock_watcher
detection = {
"id": "detection_123",
"trigger": "switching",
"text": "switching from A to B",
"confidence": 0.95,
"context": "Some context around the switch"
}
result = prompt_user_for_confirmation(123, detection)
assert result["status"] == "prompted"
assert result["detection_id"] == "detection_123"
assert result["trigger"] == "switching"
@patch('app.core.learning_jobs.ConversationWatcher')
@patch('app.core.learning_jobs.urllib.request.urlopen')
@patch('app.core.learning_jobs.urllib.request.Request')
def test_ingest_to_mcp_success(self, mock_request, mock_urlopen, mock_watcher_class, tmp_path):
"""Test successful ingestion to MCP."""
mock_watcher = MagicMock()
mock_watcher_class.return_value = mock_watcher
# Create insights file
insights_dir = tmp_path / ".claude-os" / "project-profile"
insights_dir.mkdir(parents=True)
insights_file = insights_dir / "LEARNED_INSIGHTS.md"
insights_file.write_text("# Test Insights\n\nThis is a test insight.")
# Mock successful API response
mock_response = MagicMock()
mock_response.status = 200
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
mock_urlopen.return_value = mock_response
result = ingest_to_mcp(123, str(tmp_path))
assert result["status"] == "ingested"
assert result["project_id"] == 123
assert str(insights_file) in result["file"]
@patch('app.core.learning_jobs.ConversationWatcher')
def test_ingest_to_mcp_no_file(self, mock_watcher_class, tmp_path):
"""Test ingestion to MCP with no insights file."""
mock_watcher = MagicMock()
mock_watcher_class.return_value = mock_watcher
# Don't create insights file
insights_dir = tmp_path / ".claude-os" / "project-profile"
insights_dir.mkdir(parents=True)
result = ingest_to_mcp(123, str(tmp_path))
assert result["status"] == "error"
assert result["message"] == "insights_file_not_found"
@patch('app.core.learning_jobs.ConversationWatcher')
@patch('app.core.learning_jobs.urllib.request.urlopen')
@patch('app.core.learning_jobs.urllib.request.Request')
def test_ingest_to_mcp_api_error(self, mock_request, mock_urlopen, mock_watcher_class, tmp_path):
"""Test ingestion to MCP with API error."""
mock_watcher = MagicMock()
mock_watcher_class.return_value = mock_watcher
# Create insights file
insights_dir = tmp_path / ".claude-os" / "project-profile"
insights_dir.mkdir(parents=True)
insights_file = insights_dir / "LEARNED_INSIGHTS.md"
insights_file.write_text("# Test Insights")
# Mock API error
mock_urlopen.side_effect = Exception("API Error")
result = ingest_to_mcp(123, str(tmp_path))
assert result["status"] == "error"
assert "API Error" in result["message"]
@patch('app.core.learning_jobs.ConversationWatcher')
@patch('app.core.learning_jobs.urllib.request.urlopen')
@patch('app.core.learning_jobs.urllib.request.Request')
def test_ingest_to_mcp_request_error(self, mock_request, mock_urlopen, mock_watcher_class, tmp_path):
"""Test ingestion to MCP with request error."""
mock_watcher = MagicMock()
mock_watcher_class.return_value = mock_watcher
# Create insights file
insights_dir = tmp_path / ".claude-os" / "project-profile"
insights_dir.mkdir(parents=True)
insights_file = insights_dir / "LEARNED_INSIGHTS.md"
insights_file.write_text("# Test Insights")
# Mock request error
mock_urlopen.side_effect = Exception("Request failed")
result = ingest_to_mcp(123, str(tmp_path))
assert result["status"] == "error"
assert "Request failed" in result["message"]
@patch('app.core.learning_jobs.RedisConfig')
@patch('app.core.learning_jobs.ConversationWatcher')
def test_handle_conversation_message_user(self, mock_watcher_class, mock_redis_config):
"""Test handling user conversation message."""
# Configure mocks
mock_watcher = MagicMock()
mock_watcher.detect_triggers.return_value = [
{
"id": "detection_1",
"trigger": "switching",
"text": "switching from A to B",
"confidence": 0.95
}
]
mock_watcher.should_prompt_user.return_value = True
mock_watcher_class.return_value = mock_watcher
mock_redis_config.queue_learning_job = MagicMock(return_value="job_123")
message = {
"role": "user",
"text": "We're switching from A to B"
}
handle_conversation_message(123, message)
# Should process user message
mock_watcher.detect_triggers.assert_called_once_with("We're switching from A to B")
mock_watcher.should_prompt_user.assert_called_once()
# Should queue learning job
mock_redis_config.queue_learning_job.assert_called_once()
@patch('app.core.learning_jobs.RedisConfig')
@patch('app.core.learning_jobs.ConversationWatcher')
def test_handle_conversation_message_assistant(self, mock_watcher_class, mock_redis_config):
"""Test handling assistant conversation message."""
mock_watcher = MagicMock()
mock_watcher_class.return_value = mock_watcher
message = {
"role": "assistant",
"text": "I can help you with that switch"
}
handle_conversation_message(123, message)
# Should not process assistant message
mock_watcher.detect_triggers.assert_not_called()
@patch('app.core.learning_jobs.RedisConfig')
@patch('app.core.learning_jobs.ConversationWatcher')
def test_handle_conversation_message_no_detections(self, mock_watcher_class, mock_redis_config):
"""Test handling message with no detections."""
mock_watcher = MagicMock()
mock_watcher.detect_triggers.return_value = []
mock_watcher_class.return_value = mock_watcher
mock_redis_config.queue_learning_job = MagicMock()
message = {
"role": "user",
"text": "This is a normal message"
}
handle_conversation_message(123, message)
# Should not queue job for no detections
mock_watcher.detect_triggers.assert_called_once()
mock_redis_config.queue_learning_job.assert_not_called()
@patch('app.core.learning_jobs.RedisConfig')
@patch('app.core.learning_jobs.ConversationWatcher')
def test_handle_conversation_message_low_confidence(self, mock_watcher_class, mock_redis_config):
"""Test handling message with low confidence detections."""
mock_watcher = MagicMock()
mock_watcher.detect_triggers.return_value = [
{
"id": "detection_1",
"trigger": "switching",
"text": "switching from A to B",
"confidence": 0.70 # Low confidence
}
]
mock_watcher.should_prompt_user.return_value = False
mock_watcher_class.return_value = mock_watcher
mock_redis_config.queue_learning_job = MagicMock()
message = {
"role": "user",
"text": "We're switching from A to B"
}
handle_conversation_message(123, message)
# Should not queue job for low confidence
mock_watcher.detect_triggers.assert_called_once()
mock_watcher.should_prompt_user.assert_called_once()
mock_redis_config.queue_learning_job.assert_not_called()
@patch('app.core.learning_jobs.RedisConfig')
@patch('app.core.learning_jobs.time.sleep')
def test_wait_for_confirmation_confirmed(self, mock_sleep, mock_redis_config):
"""Test waiting for confirmation when confirmed."""
# First call returns None, second returns True
mock_redis_config.get_confirmation = MagicMock(side_effect=[None, True])
result = wait_for_confirmation(123, "detection_123", timeout=600)
assert result is True
assert mock_redis_config.get_confirmation.call_count == 2
mock_sleep.assert_called_once_with(1)
@patch('app.core.learning_jobs.RedisConfig')
@patch('app.core.learning_jobs.time.sleep')
@patch('app.core.learning_jobs.time.time')
def test_wait_for_confirmation_timeout(self, mock_time, mock_sleep, mock_redis_config):
"""Test waiting for confirmation with timeout."""
# Always return None
mock_redis_config.get_confirmation = MagicMock(return_value=None)
# Simulate time passing
mock_time.side_effect = [0, 0.5, 601] # start, first check, timeout
result = wait_for_confirmation(123, "detection_123", timeout=600)
assert result is False
@patch('app.core.learning_jobs.RedisConfig')
@patch('app.core.learning_jobs.time.sleep')
def test_wait_for_confirmation_immediate(self, mock_sleep, mock_redis_config):
"""Test waiting for confirmation when immediately confirmed."""
mock_redis_config.get_confirmation = MagicMock(return_value=True)
result = wait_for_confirmation(123, "detection_123", timeout=600)
assert result is True
# Should not sleep when immediately confirmed
mock_sleep.assert_not_called()
@patch('app.core.learning_jobs.RedisConfig')
@patch('app.core.learning_jobs.handle_conversation_message')
def test_subscribe_to_conversations(self, mock_handle, mock_redis_config):
"""Test subscribing to conversations."""
mock_pubsub = MagicMock()
mock_redis_config.subscribe_to_conversation = MagicMock(return_value=mock_pubsub)
# Mock pubsub messages
message1 = json.dumps({"role": "user", "text": "Test message 1"})
# Mock pubsub listen to return one message then raise KeyboardInterrupt
def mock_listen():
yield {"type": "message", "data": message1}
raise KeyboardInterrupt()
mock_pubsub.listen = mock_listen
# Run subscription (will stop on KeyboardInterrupt)
subscribe_to_conversations(123)
# Should handle valid message
mock_handle.assert_called_once_with(123, {"role": "user", "text": "Test message 1"})
# Should close pubsub
mock_pubsub.close.assert_called_once()
@pytest.mark.integration
class TestLearningJobsIntegration:
"""Integration tests for learning jobs."""
@patch('app.core.learning_jobs.RedisConfig')
@patch('app.core.learning_jobs.ConversationWatcher')
@patch('app.core.learning_jobs.wait_for_confirmation')
def test_full_learning_workflow(self, mock_wait, mock_watcher_class, mock_redis_config, tmp_path):
"""Test full learning workflow from message to ingestion."""
# Configure RedisConfig mock
mock_redis_config.set_confirmation = MagicMock()
mock_redis_config.queue_prompt_job = MagicMock(return_value="prompt_job_123")
mock_redis_config.queue_ingest_job = MagicMock(return_value="ingest_job_123")
mock_redis_config.queue_learning_job = MagicMock(return_value="learning_job_123")
# Configure watcher mock
mock_watcher = MagicMock()
mock_watcher.detect_triggers.return_value = [
{
"id": "detection_123",
"trigger": "switching",
"text": "switching from A to B",
"confidence": 0.95
}
]
mock_watcher.should_prompt_user.return_value = True
mock_watcher_class.return_value = mock_watcher
# User confirms
mock_wait.return_value = True
# Handle message
message = {"role": "user", "text": "We're switching from A to B"}
handle_conversation_message(123, message)
# Process detection
result = process_learning_detection(123, {
"id": "detection_123",
"trigger": "switching",
"text": "switching from A to B",
"confidence": 0.95
})
assert result["status"] == "confirmed"
assert result["ingest_job_id"] == "ingest_job_123"
# Verify workflow
mock_watcher.detect_triggers.assert_called_once()
mock_redis_config.queue_learning_job.assert_called_once()
mock_redis_config.queue_prompt_job.assert_called_once()
mock_wait.assert_called_once()
mock_redis_config.queue_ingest_job.assert_called_once()