Co-authored-by: Kimi Agent <kimi@timmy.local> Co-committed-by: Kimi Agent <kimi@timmy.local>
199 lines
6.1 KiB
Python
199 lines
6.1 KiB
Python
"""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)
|