forked from Rockachopa/Timmy-time-dashboard
feat: Pip the Familiar — behavioral state machine (#367)
Co-authored-by: Kimi Agent <kimi@timmy.local> Co-committed-by: Kimi Agent <kimi@timmy.local>
This commit is contained in:
198
tests/timmy/test_familiar.py
Normal file
198
tests/timmy/test_familiar.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user