"""Tests for cognitive state tracking in src/timmy/cognitive_state.py.""" import asyncio from unittest.mock import patch from timmy.cognitive_state import ( ENGAGEMENT_LEVELS, MOOD_VALUES, CognitiveState, CognitiveTracker, _extract_commitments, _extract_topic, _infer_engagement, _infer_mood, ) class TestCognitiveState: """Test the CognitiveState dataclass.""" def test_defaults(self): state = CognitiveState() assert state.focus_topic is None assert state.engagement == "idle" assert state.mood == "settled" assert state.conversation_depth == 0 assert state.last_initiative is None assert state.active_commitments == [] def test_to_dict_excludes_private_fields(self): state = CognitiveState(focus_topic="testing") d = state.to_dict() assert "focus_topic" in d assert "_confidence_sum" not in d assert "_confidence_count" not in d def test_to_dict_includes_public_fields(self): state = CognitiveState( focus_topic="loop architecture", engagement="deep", mood="curious", conversation_depth=42, last_initiative="proposed refactor", active_commitments=["draft ticket", "review PR"], ) d = state.to_dict() assert d["focus_topic"] == "loop architecture" assert d["engagement"] == "deep" assert d["mood"] == "curious" assert d["conversation_depth"] == 42 class TestInferEngagement: """Test engagement level inference.""" def test_deep_keywords(self): assert _infer_engagement("help me debug this", "looking at the stack trace") == "deep" def test_architecture_is_deep(self): assert ( _infer_engagement("explain the architecture", "the system has three layers") == "deep" ) def test_short_response_is_surface(self): assert _infer_engagement("hi", "hello there") == "surface" def test_normal_conversation_is_surface(self): result = _infer_engagement("what time is it", "It is 3pm right now.") assert result == "surface" class TestInferMood: """Test mood inference.""" def test_low_confidence_is_hesitant(self): assert _infer_mood("I'm not really sure about this", 0.3) == "hesitant" def test_exclamation_with_positive_words_is_energized(self): assert _infer_mood("That's a great idea!", 0.8) == "energized" def test_question_words_are_curious(self): assert _infer_mood("I wonder if that would work", 0.6) == "curious" def test_neutral_is_settled(self): assert _infer_mood("The answer is 42.", 0.7) == "settled" def test_valid_mood_values(self): for mood in MOOD_VALUES: assert isinstance(mood, str) class TestExtractTopic: """Test topic extraction from messages.""" def test_simple_message(self): assert _extract_topic("Python decorators") == "Python decorators" def test_strips_question_prefix(self): topic = _extract_topic("what is a monad") assert topic == "a monad" def test_truncates_long_messages(self): long_msg = "a" * 100 topic = _extract_topic(long_msg) assert len(topic) <= 60 def test_empty_returns_none(self): assert _extract_topic("") is None assert _extract_topic(" ") is None class TestExtractCommitments: """Test commitment extraction from responses.""" def test_i_will_commitment(self): result = _extract_commitments("I will draft the skeleton ticket for you.") assert len(result) >= 1 assert "I will draft the skeleton ticket for you" in result[0] def test_let_me_commitment(self): result = _extract_commitments("Let me look into that for you.") assert len(result) >= 1 def test_no_commitments(self): result = _extract_commitments("The answer is 42.") assert result == [] def test_caps_at_three(self): text = "I will do A. I'll do B. Let me do C. I'm going to do D." result = _extract_commitments(text) assert len(result) <= 3 class TestCognitiveTracker: """Test the CognitiveTracker behaviour.""" def test_update_increments_depth(self): tracker = CognitiveTracker() tracker.update("hello", "Hi there, how can I help?") assert tracker.get_state().conversation_depth == 1 tracker.update("thanks", "You're welcome!") assert tracker.get_state().conversation_depth == 2 def test_update_sets_focus_topic(self): tracker = CognitiveTracker() tracker.update( "Python decorators", "Decorators are syntactic sugar for wrapping functions." ) assert tracker.get_state().focus_topic == "Python decorators" def test_reset_clears_state(self): tracker = CognitiveTracker() tracker.update("hello", "world") tracker.reset() state = tracker.get_state() assert state.conversation_depth == 0 assert state.focus_topic is None def test_to_json(self): import json tracker = CognitiveTracker() tracker.update("test", "response") data = json.loads(tracker.to_json()) assert "focus_topic" in data assert "engagement" in data assert "mood" in data def test_engagement_values_are_valid(self): for level in ENGAGEMENT_LEVELS: assert isinstance(level, str) async def test_update_emits_cognitive_state_changed(self): """CognitiveTracker.update() emits a sensory event.""" from timmy.event_bus import SensoryBus mock_bus = SensoryBus() received = [] mock_bus.subscribe("cognitive_state_changed", lambda e: received.append(e)) with patch("timmy.event_bus.get_sensory_bus", return_value=mock_bus): tracker = CognitiveTracker() tracker.update("debug the memory leak", "Looking at the stack trace now.") # Give the fire-and-forget task a chance to run await asyncio.sleep(0.05) assert len(received) == 1 event = received[0] assert event.source == "cognitive" assert event.event_type == "cognitive_state_changed" assert "mood" in event.data assert "engagement" in event.data assert "depth" in event.data assert event.data["depth"] == 1 async def test_update_tracks_mood_change(self): """Event data includes whether mood/engagement changed.""" from timmy.event_bus import SensoryBus mock_bus = SensoryBus() received = [] mock_bus.subscribe("cognitive_state_changed", lambda e: received.append(e)) with patch("timmy.event_bus.get_sensory_bus", return_value=mock_bus): tracker = CognitiveTracker() # First message — "!" + "great" with high confidence → "energized" tracker.update("wow", "That's a great discovery!") await asyncio.sleep(0.05) assert len(received) == 1 # Default mood is "settled", energized response → mood changes assert received[0].data["mood"] == "energized" assert received[0].data["mood_changed"] is True def test_emit_skipped_without_event_loop(self): """Event emission gracefully skips when no async loop is running.""" tracker = CognitiveTracker() # Should not raise — just silently skips tracker.update("hello", "Hi there!")