claude-os/tests/test_conversation_watcher.py

525 lines
20 KiB
Python
Raw Normal View History

"""
Tests for conversation watcher functionality.
"""
import pytest
import re
import tempfile
from pathlib import Path
from unittest.mock import patch, mock_open, MagicMock
from app.core.conversation_watcher import ConversationWatcher, detect_learning_opportunities
@pytest.mark.unit
class TestConversationWatcher:
"""Test ConversationWatcher class."""
def test_watcher_initialization(self):
"""Test conversation watcher initialization."""
with tempfile.TemporaryDirectory() as temp_dir:
project_id = 123
watcher = ConversationWatcher(project_id, temp_dir)
assert watcher.project_id == project_id
assert watcher.project_path == Path(temp_dir).resolve()
assert watcher.insights_dir == (Path(temp_dir).resolve() / ".claude-os" / "project-profile")
def test_detect_triggers_switching(self):
"""Test detecting technology switching triggers."""
watcher = ConversationWatcher(123, "/tmp")
text = "We're switching from React to Vue.js for the frontend."
detections = watcher.detect_triggers(text)
assert len(detections) > 0
switching_detections = [d for d in detections if d["trigger"] == "switching"]
assert len(switching_detections) > 0
detection = switching_detections[0]
assert detection["text"] == "switching from React to Vue.js"
assert detection["groups"] == ("React", "Vue.js")
assert detection["confidence"] == 0.95
assert "Technology/library switch detected" in detection["description"]
def test_detect_triggers_decided_to_use(self):
"""Test detecting 'decided to use' triggers."""
watcher = ConversationWatcher(123, "/tmp")
text = "We decided to use TypeScript for the new project."
detections = watcher.detect_triggers(text)
decided_detections = [d for d in detections if d["trigger"] == "decided_to_use"]
assert len(decided_detections) > 0
detection = decided_detections[0]
assert detection["text"] == "decided to use TypeScript"
assert detection["groups"] == ("TypeScript",)
assert detection["confidence"] == 0.90
def test_detect_triggers_no_longer(self):
"""Test detecting 'no longer using' triggers."""
watcher = ConversationWatcher(123, "/tmp")
text = "We're no longer using jQuery in this project."
detections = watcher.detect_triggers(text)
no_longer_detections = [d for d in detections if d["trigger"] == "no_longer"]
assert len(no_longer_detections) > 0
detection = no_longer_detections[0]
assert detection["text"] == "no longer using jQuery"
assert detection["groups"] == ("jQuery",)
assert detection["confidence"] == 0.85
def test_detect_triggers_now_using(self):
"""Test detecting 'now using' triggers."""
watcher = ConversationWatcher(123, "/tmp")
text = "We're now using Docker for containerization."
detections = watcher.detect_triggers(text)
now_using_detections = [d for d in detections if d["trigger"] == "now_using"]
assert len(now_using_detections) > 0
detection = now_using_detections[0]
assert detection["text"] == "now using Docker"
assert detection["groups"] == ("Docker",)
assert detection["confidence"] == 0.85
def test_detect_triggers_performance_issue(self):
"""Test detecting performance issue triggers."""
watcher = ConversationWatcher(123, "/tmp")
text = "The database queries are too slow."
detections = watcher.detect_triggers(text)
perf_detections = [d for d in detections if d["trigger"] == "performance_issue"]
assert len(perf_detections) > 0
detection = perf_detections[0]
assert detection["text"] == "database queries are too slow"
assert detection["groups"] == ("database queries",)
assert detection["confidence"] == 0.85
def test_detect_triggers_bug_fixed(self):
"""Test detecting bug fix triggers."""
watcher = ConversationWatcher(123, "/tmp")
text = "We fixed a bug in the authentication module."
detections = watcher.detect_triggers(text)
bug_detections = [d for d in detections if d["trigger"] == "bug_fixed"]
assert len(bug_detections) > 0
detection = bug_detections[0]
assert detection["text"] == "fixed a bug in the authentication module"
assert detection["groups"] == ("the authentication module",)
assert detection["confidence"] == 0.80
def test_detect_triggers_architecture_change(self):
"""Test detecting architecture change triggers."""
watcher = ConversationWatcher(123, "/tmp")
text = "We're refactoring the monolith to microservices."
detections = watcher.detect_triggers(text)
arch_detections = [d for d in detections if d["trigger"] == "architecture_change"]
assert len(arch_detections) > 0
detection = arch_detections[0]
assert detection["text"] == "refactoring the monolith to microservices"
assert detection["groups"] == ("the monolith", "microservices")
assert detection["confidence"] == 0.85
def test_detect_triggers_rejected_idea(self):
"""Test detecting rejected idea triggers."""
watcher = ConversationWatcher(123, "/tmp")
text = "We decided against using WebAssembly for this project."
detections = watcher.detect_triggers(text)
rejected_detections = [d for d in detections if d["trigger"] == "rejected_idea"]
assert len(rejected_detections) > 0
detection = rejected_detections[0]
assert detection["text"] == "decided against using WebAssembly"
assert detection["groups"] == ("using WebAssembly",)
assert detection["confidence"] == 0.75
def test_detect_triggers_edge_case(self):
"""Test detecting edge case triggers."""
watcher = ConversationWatcher(123, "/tmp")
text = "Beware of the gotcha with async/await in loops."
detections = watcher.detect_triggers(text)
edge_detections = [d for d in detections if d["trigger"] == "edge_case"]
assert len(edge_detections) > 0
detection = edge_detections[0]
assert "gotcha with async/await" in detection["text"]
assert detection["confidence"] == 0.80
def test_detect_multiple_triggers(self):
"""Test detecting multiple triggers in one text."""
watcher = ConversationWatcher(123, "/tmp")
text = """
We're switching from React to Vue.js and decided to use TypeScript.
We're no longer using jQuery because it was too slow.
We fixed a bug in the authentication module.
"""
detections = watcher.detect_triggers(text)
# Should detect multiple triggers
assert len(detections) >= 4
trigger_types = {d["trigger"] for d in detections}
assert "switching" in trigger_types
assert "decided_to_use" in trigger_types
assert "no_longer" in trigger_types
assert "bug_fixed" in trigger_types
def test_detect_no_triggers(self):
"""Test text with no triggers."""
watcher = ConversationWatcher(123, "/tmp")
text = "This is a normal conversation about the weather and other topics."
detections = watcher.detect_triggers(text)
assert len(detections) == 0
def test_detections_sorted_by_confidence(self):
"""Test that detections are sorted by confidence."""
watcher = ConversationWatcher(123, "/tmp")
text = """
We decided to use TypeScript (90% confidence).
We're switching from React to Vue.js (95% confidence).
We're no longer using jQuery (85% confidence).
"""
detections = watcher.detect_triggers(text)
# Should be sorted by confidence (descending)
confidences = [d["confidence"] for d in detections]
assert confidences == sorted(confidences, reverse=True)
def test_extract_context(self):
"""Test context extraction around matches."""
watcher = ConversationWatcher(123, "/tmp")
text = "This is some context before the trigger. We decided to use TypeScript. This is some context after the trigger."
# Find the match manually for testing
import re
pattern = r"decided to (?:use|adopt|switch to) (.+?)(?:\.|,|$)"
match = re.search(pattern, text, re.IGNORECASE | re.MULTILINE)
if match:
context = watcher._extract_context(text, match)
# Should include text before and after
assert "context before" in context.lower()
assert "decided to use TypeScript" in context
assert "context after" in context.lower()
# Should be reasonably sized
assert len(context) <= len(text) + 100 # Allow some padding
def test_generate_id(self):
"""Test ID generation for detections."""
watcher = ConversationWatcher(123, "/tmp")
# Generate IDs for same trigger multiple times
id1 = watcher._generate_id("switching", "switching from A to B")
id2 = watcher._generate_id("switching", "switching from A to B")
# Should be different due to timestamp
assert id1 != id2
assert len(id1) == 8 # MD5 hash truncated to 8 chars
assert len(id2) == 8
def test_save_insight(self, tmp_path):
"""Test saving insights to file."""
with tempfile.TemporaryDirectory() as temp_dir:
project_id = 123
watcher = ConversationWatcher(project_id, temp_dir)
detection = {
"id": "test123",
"trigger": "switching",
"text": "switching from A to B",
"confidence": 0.95,
"description": "Technology switch",
"timestamp": "2023-01-01T12:00:00",
"context": "Some context"
}
result = watcher.save_insight(detection)
assert result is True
# Check file was created
insights_file = Path(temp_dir) / ".claude-os" / "project-profile" / "LEARNED_INSIGHTS.md"
assert insights_file.exists()
# Check content
content = insights_file.read_text()
assert "switching" in content
assert "switching from A to B" in content
assert "95%" in content
assert "Technology switch" in content
def test_save_insight_creates_directory(self, tmp_path):
"""Test that save_insight creates directory if needed."""
with tempfile.TemporaryDirectory() as temp_dir:
project_id = 123
watcher = ConversationWatcher(project_id, temp_dir)
detection = {
"id": "test123",
"trigger": "switching",
"text": "test",
"confidence": 0.95,
"description": "Test",
"timestamp": "2023-01-01T12:00:00",
"context": "test"
}
result = watcher.save_insight(detection)
assert result is True
# Directory should be created
insights_dir = Path(temp_dir) / ".claude-os" / "project-profile"
assert insights_dir.exists()
assert insights_dir.is_dir()
def test_save_insight_error_handling(self, tmp_path):
"""Test save_insight error handling."""
# Use invalid path that should cause error
watcher = ConversationWatcher(123, "/invalid/path/that/does/not/exist")
detection = {
"id": "test123",
"trigger": "switching",
"text": "test",
"confidence": 0.95,
"description": "Test",
"timestamp": "2023-01-01T12:00:00",
"context": "test"
}
result = watcher.save_insight(detection)
# Should handle error gracefully
assert result is False
def test_format_insight(self):
"""Test insight formatting."""
watcher = ConversationWatcher(123, "/tmp")
detection = {
"timestamp": "2023-01-01T12:00:00",
"trigger": "switching",
"confidence": 0.95,
"text": "switching from React to Vue.js",
"context": "Some context around the switch",
"description": "Technology switch detected"
}
formatted = watcher._format_insight(detection)
assert "### 2023-01-01T12:00:00" in formatted
assert "**Type**: switching" in formatted
assert "**Confidence**: 95%" in formatted
assert "**Text**: switching from React to Vue.js" in formatted
assert "**Context**: Some context around the switch" in formatted
assert "**Description**: Technology switch detected" in formatted
def test_get_learned_insights_no_file(self, tmp_path):
"""Test getting insights when no file exists."""
with tempfile.TemporaryDirectory() as temp_dir:
project_id = 123
watcher = ConversationWatcher(project_id, temp_dir)
insights = watcher.get_learned_insights()
assert insights == []
def test_get_learned_insights_with_file(self, tmp_path):
"""Test getting insights when file exists."""
with tempfile.TemporaryDirectory() as temp_dir:
project_id = 123
watcher = ConversationWatcher(project_id, temp_dir)
# Create insights file
insights_dir = Path(temp_dir) / ".claude-os" / "project-profile"
insights_dir.mkdir(parents=True)
insights_file = insights_dir / "LEARNED_INSIGHTS.md"
content = """
### 2023-01-01T12:00:00
**Type**: switching
**Confidence**: 95%
**Text**: switching from A to B
### 2023-01-02T13:00:00
**Type**: decided_to_use
**Confidence**: 90%
**Text**: decided to use TypeScript
"""
insights_file.write_text(content)
insights = watcher.get_learned_insights()
assert insights["count"] == 2
assert "switching" in insights["content"]
assert "decided_to_use" in insights["content"]
def test_should_prompt_user(self):
"""Test user prompt decision logic."""
watcher = ConversationWatcher(123, "/tmp")
# High confidence should prompt
high_conf = {"confidence": 0.80}
assert watcher.should_prompt_user(high_conf) is True
# Low confidence should not prompt
low_conf = {"confidence": 0.70}
assert watcher.should_prompt_user(low_conf) is False
# Edge case - exactly 0.75 should prompt
edge_conf = {"confidence": 0.75}
assert watcher.should_prompt_user(edge_conf) is True
@pytest.mark.unit
class TestConversationWatcherUtility:
"""Test conversation watcher utility functions."""
def test_detect_learning_opportunities_function(self):
"""Test the utility function for detecting opportunities."""
text = "We're switching from React to Vue.js and decided to use TypeScript."
detections = detect_learning_opportunities(123, "/tmp", text)
assert len(detections) >= 2
trigger_types = {d["trigger"] for d in detections}
assert "switching" in trigger_types
assert "decided_to_use" in trigger_types
def test_detect_learning_opportunities_empty(self):
"""Test utility function with no triggers."""
text = "This is a normal conversation."
detections = detect_learning_opportunities(123, "/tmp", text)
assert len(detections) == 0
def test_detect_learning_opportunities_case_insensitive(self):
"""Test that detection handles mixed case."""
# Use mixed case with proper sentence structure for pattern matching
text = "We're Switching from React to Vue.js for our frontend."
detections = detect_learning_opportunities(123, "/tmp", text)
switching_detections = [d for d in detections if d["trigger"] == "switching"]
assert len(switching_detections) > 0
def test_detect_learning_opportunities_multiline(self):
"""Test detection across multiple lines."""
text = """We decided to use TypeScript
for the new project. We're no longer
using jQuery because it was too slow."""
detections = detect_learning_opportunities(123, "/tmp", text)
trigger_types = {d["trigger"] for d in detections}
assert "decided_to_use" in trigger_types
assert "no_longer" in trigger_types
def test_detect_learning_opportunities_with_punctuation(self):
"""Test detection with various punctuation endings."""
# Test with standard sentences that include punctuation
test_cases = [
"We decided to use TypeScript for this project.",
"We're switching from React to Vue.js today.",
"We're no longer using jQuery anymore.",
"We're now using Docker for containers."
]
for text in test_cases:
detections = detect_learning_opportunities(123, "/tmp", text)
assert len(detections) > 0, f"Failed to detect in: {text}"
def test_trigger_patterns_completeness(self):
"""Test that all expected trigger patterns are defined."""
watcher = ConversationWatcher(123, "/tmp")
expected_triggers = {
"switching", "decided_to_use", "no_longer", "now_using",
"implement_change", "performance_issue", "bug_fixed",
"architecture_change", "rejected_idea", "edge_case"
}
actual_triggers = set(watcher.TRIGGERS.keys())
assert actual_triggers == expected_triggers
def test_trigger_pattern_structure(self):
"""Test that all trigger patterns have required structure."""
watcher = ConversationWatcher(123, "/tmp")
for trigger_name, trigger_config in watcher.TRIGGERS.items():
assert "pattern" in trigger_config
assert "confidence" in trigger_config
assert "description" in trigger_config
# Check that pattern is a valid regex
import re
assert isinstance(trigger_config["pattern"], str)
assert trigger_config["confidence"] >= 0.0
assert trigger_config["confidence"] <= 1.0
assert isinstance(trigger_config["description"], str)
def test_context_extraction_edge_cases(self):
"""Test context extraction edge cases."""
watcher = ConversationWatcher(123, "/tmp")
# Test with match at beginning
text_beginning = "Switching from A to B. More text here."
match_beginning = re.search(r"switching from (.+?) to (.+?)", text_beginning, re.IGNORECASE)
context_beginning = watcher._extract_context(text_beginning, match_beginning)
assert "Switching from A to B" in context_beginning
# Test with match at end
text_ending = "Text here. Switching from A to B"
match_ending = re.search(r"switching from (.+?) to (.+?)", text_ending, re.IGNORECASE)
context_ending = watcher._extract_context(text_ending, match_ending)
assert "Switching from A to B" in context_ending
# Test with short text (no context)
text_short = "Switching from A to B"
match_short = re.search(r"switching from (.+?) to (.+?)", text_short, re.IGNORECASE)
context_short = watcher._extract_context(text_short, match_short)
assert "Switching from A to B" in context_short
def test_id_uniqueness(self):
"""Test that generated IDs are unique."""
watcher = ConversationWatcher(123, "/tmp")
# Generate multiple IDs
ids = []
for i in range(10):
id_val = watcher._generate_id("test", f"test text {i}")
ids.append(id_val)
# All should be unique
assert len(set(ids)) == len(ids)
# All should be valid hex strings
for id_val in ids:
assert len(id_val) == 8
assert all(c in "0123456789abcdef" for c in id_val)