"""Tests for Pip the Familiar — behavioral state machine.""" import time import pytest from timmy.familiar import _FIREPLACE_POS, Familiar, PipState @pytest.fixture def pip(): return Familiar() class TestInitialState: def test_starts_sleeping(self, pip): assert pip.state == PipState.SLEEPING def test_starts_calm(self, pip): assert pip.mood_mirror == "calm" def test_snapshot_returns_dict(self, pip): snap = pip.snapshot().to_dict() assert snap["name"] == "Pip" assert snap["state"] == "sleeping" assert snap["position"] == list(_FIREPLACE_POS) assert snap["mood_mirror"] == "calm" assert "state_duration_s" in snap class TestAutoTransitions: def test_sleeping_to_waking_after_duration(self, pip): now = time.monotonic() # Force a short duration pip._duration = 1.0 pip._entered_at = now - 2.0 result = pip.tick(now=now) assert result == PipState.WAKING def test_waking_to_wandering(self, pip): now = time.monotonic() pip._state = PipState.WAKING pip._duration = 1.0 pip._entered_at = now - 2.0 pip.tick(now=now) assert pip.state == PipState.WANDERING def test_wandering_to_bored(self, pip): now = time.monotonic() pip._state = PipState.WANDERING pip._duration = 1.0 pip._entered_at = now - 2.0 pip.tick(now=now) assert pip.state == PipState.BORED def test_bored_to_sleeping(self, pip): now = time.monotonic() pip._state = PipState.BORED pip._duration = 1.0 pip._entered_at = now - 2.0 pip.tick(now=now) assert pip.state == PipState.SLEEPING def test_full_cycle(self, pip): """Pip cycles: SLEEPING → WAKING → WANDERING → BORED → SLEEPING.""" now = time.monotonic() expected = [ PipState.WAKING, PipState.WANDERING, PipState.BORED, PipState.SLEEPING, ] for expected_state in expected: pip._duration = 0.1 pip._entered_at = now - 1.0 pip.tick(now=now) assert pip.state == expected_state now += 0.01 def test_no_transition_before_duration(self, pip): now = time.monotonic() pip._duration = 100.0 pip._entered_at = now pip.tick(now=now + 1.0) assert pip.state == PipState.SLEEPING class TestEventReactions: def test_visitor_entered_wakes_pip(self, pip): assert pip.state == PipState.SLEEPING pip.on_event("visitor_entered") assert pip.state == PipState.WAKING def test_visitor_entered_while_wandering_investigates(self, pip): pip._state = PipState.WANDERING pip.on_event("visitor_entered") assert pip.state == PipState.INVESTIGATING def test_visitor_spoke_while_wandering_investigates(self, pip): pip._state = PipState.WANDERING pip.on_event("visitor_spoke") assert pip.state == PipState.INVESTIGATING def test_loud_event_wakes_sleeping_pip(self, pip): pip.on_event("loud_event") assert pip.state == PipState.WAKING def test_unknown_event_no_change(self, pip): pip.on_event("unknown_event") assert pip.state == PipState.SLEEPING def test_investigating_expires_to_bored(self, pip): now = time.monotonic() pip._state = PipState.INVESTIGATING pip._duration = 1.0 pip._entered_at = now - 2.0 pip.tick(now=now) assert pip.state == PipState.BORED class TestMoodMirroring: def test_mood_mirrors_with_delay(self, pip): now = time.monotonic() pip.on_mood_change("curious", confidence=0.6, now=now) # Before delay — still calm pip.tick(now=now + 1.0) assert pip.mood_mirror == "calm" # After 3s delay — mirrors pip.tick(now=now + 4.0) assert pip.mood_mirror == "curious" def test_low_confidence_triggers_alert(self, pip): pip.on_mood_change("hesitant", confidence=0.2) assert pip.state == PipState.ALERT def test_energized_triggers_playful(self, pip): pip.on_mood_change("energized", confidence=0.7) assert pip.state == PipState.PLAYFUL def test_hesitant_low_confidence_triggers_hiding(self, pip): pip.on_mood_change("hesitant", confidence=0.35) assert pip.state == PipState.HIDING def test_special_state_not_from_non_interruptible(self, pip): pip._state = PipState.INVESTIGATING pip.on_mood_change("energized", confidence=0.7) # INVESTIGATING is not interruptible assert pip.state == PipState.INVESTIGATING class TestSpecialStateRecovery: def test_alert_returns_to_wandering(self, pip): now = time.monotonic() pip._state = PipState.ALERT pip._duration = 1.0 pip._entered_at = now - 2.0 pip.tick(now=now) assert pip.state == PipState.WANDERING def test_playful_returns_to_wandering(self, pip): now = time.monotonic() pip._state = PipState.PLAYFUL pip._duration = 1.0 pip._entered_at = now - 2.0 pip.tick(now=now) assert pip.state == PipState.WANDERING def test_hiding_returns_to_waking(self, pip): now = time.monotonic() pip._state = PipState.HIDING pip._duration = 1.0 pip._entered_at = now - 2.0 pip.tick(now=now) assert pip.state == PipState.WAKING class TestPositionHints: def test_sleeping_near_fireplace(self, pip): snap = pip.snapshot() assert snap.position == _FIREPLACE_POS def test_hiding_behind_desk(self, pip): pip.on_mood_change("hesitant", confidence=0.35) assert pip.state == PipState.HIDING snap = pip.snapshot() assert snap.position == (0.5, 0.3, -2.0) def test_playful_near_crystal_ball(self, pip): pip.on_mood_change("energized", confidence=0.7) snap = pip.snapshot() assert snap.position == (1.0, 1.2, 0.0) class TestSingleton: def test_module_singleton_exists(self): from timmy.familiar import pip_familiar assert isinstance(pip_familiar, Familiar)