Files
Timmy-time-dashboard/tests/timmy/test_familiar.py
Kimi Agent 996ccec170
All checks were successful
Tests / lint (push) Successful in 5s
Tests / test (push) Successful in 1m32s
feat: Pip the Familiar — behavioral state machine (#367)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-18 21:50:36 -04:00

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)