forked from Rockachopa/Timmy-time-dashboard
363 lines
12 KiB
Python
363 lines
12 KiB
Python
"""Tests for timmy.thinking — Timmy's default background thinking engine."""
|
|
|
|
import sqlite3
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_engine(tmp_path: Path):
|
|
"""Create a ThinkingEngine with an isolated temp DB."""
|
|
from timmy.thinking import ThinkingEngine
|
|
db_path = tmp_path / "thoughts.db"
|
|
return ThinkingEngine(db_path=db_path)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_thinking_config_defaults():
|
|
"""Settings should expose thinking_enabled and thinking_interval_seconds."""
|
|
from config import Settings
|
|
s = Settings()
|
|
assert s.thinking_enabled is True
|
|
assert s.thinking_interval_seconds == 300
|
|
|
|
|
|
def test_thinking_config_override():
|
|
"""thinking settings can be overridden via env."""
|
|
s = _settings_with(thinking_enabled=False, thinking_interval_seconds=60)
|
|
assert s.thinking_enabled is False
|
|
assert s.thinking_interval_seconds == 60
|
|
|
|
|
|
def _settings_with(**kwargs):
|
|
from config import Settings
|
|
return Settings(**kwargs)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ThinkingEngine init
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_engine_init_creates_table(tmp_path):
|
|
"""ThinkingEngine should create the thoughts SQLite table on init."""
|
|
engine = _make_engine(tmp_path)
|
|
db_path = tmp_path / "thoughts.db"
|
|
assert db_path.exists()
|
|
|
|
conn = sqlite3.connect(str(db_path))
|
|
tables = conn.execute(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='thoughts'"
|
|
).fetchall()
|
|
conn.close()
|
|
assert len(tables) == 1
|
|
|
|
|
|
def test_engine_init_empty(tmp_path):
|
|
"""Fresh engine should have no thoughts."""
|
|
engine = _make_engine(tmp_path)
|
|
assert engine.count_thoughts() == 0
|
|
assert engine.get_recent_thoughts() == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Store and retrieve
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_store_and_retrieve_thought(tmp_path):
|
|
"""Storing a thought should make it retrievable."""
|
|
engine = _make_engine(tmp_path)
|
|
thought = engine._store_thought("I think therefore I am.", "existential")
|
|
|
|
assert thought.id is not None
|
|
assert thought.content == "I think therefore I am."
|
|
assert thought.seed_type == "existential"
|
|
assert thought.created_at is not None
|
|
|
|
retrieved = engine.get_thought(thought.id)
|
|
assert retrieved is not None
|
|
assert retrieved.content == thought.content
|
|
|
|
|
|
def test_store_thought_chains(tmp_path):
|
|
"""Each new thought should link to the previous one via parent_id."""
|
|
engine = _make_engine(tmp_path)
|
|
|
|
t1 = engine._store_thought("First thought.", "existential")
|
|
engine._last_thought_id = t1.id
|
|
|
|
t2 = engine._store_thought("Second thought.", "swarm")
|
|
engine._last_thought_id = t2.id
|
|
|
|
t3 = engine._store_thought("Third thought.", "freeform")
|
|
|
|
assert t1.parent_id is None
|
|
assert t2.parent_id == t1.id
|
|
assert t3.parent_id == t2.id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Thought chain retrieval
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_get_thought_chain(tmp_path):
|
|
"""get_thought_chain should return the full chain in chronological order."""
|
|
engine = _make_engine(tmp_path)
|
|
|
|
t1 = engine._store_thought("Alpha.", "existential")
|
|
engine._last_thought_id = t1.id
|
|
|
|
t2 = engine._store_thought("Beta.", "swarm")
|
|
engine._last_thought_id = t2.id
|
|
|
|
t3 = engine._store_thought("Gamma.", "freeform")
|
|
|
|
chain = engine.get_thought_chain(t3.id)
|
|
assert len(chain) == 3
|
|
assert chain[0].content == "Alpha."
|
|
assert chain[1].content == "Beta."
|
|
assert chain[2].content == "Gamma."
|
|
|
|
|
|
def test_get_thought_chain_single(tmp_path):
|
|
"""Chain of a single thought (no parent) returns just that thought."""
|
|
engine = _make_engine(tmp_path)
|
|
t1 = engine._store_thought("Only one.", "memory")
|
|
chain = engine.get_thought_chain(t1.id)
|
|
assert len(chain) == 1
|
|
assert chain[0].id == t1.id
|
|
|
|
|
|
def test_get_thought_chain_missing(tmp_path):
|
|
"""Chain for a non-existent thought returns empty list."""
|
|
engine = _make_engine(tmp_path)
|
|
assert engine.get_thought_chain("nonexistent-id") == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Recent thoughts
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_get_recent_thoughts_limit(tmp_path):
|
|
"""get_recent_thoughts should respect the limit parameter."""
|
|
engine = _make_engine(tmp_path)
|
|
|
|
for i in range(5):
|
|
engine._store_thought(f"Thought {i}.", "freeform")
|
|
engine._last_thought_id = None # Don't chain for this test
|
|
|
|
recent = engine.get_recent_thoughts(limit=3)
|
|
assert len(recent) == 3
|
|
|
|
# Should be newest first
|
|
assert "Thought 4" in recent[0].content
|
|
|
|
|
|
def test_count_thoughts(tmp_path):
|
|
"""count_thoughts should return the total number of thoughts."""
|
|
engine = _make_engine(tmp_path)
|
|
assert engine.count_thoughts() == 0
|
|
|
|
engine._store_thought("One.", "existential")
|
|
engine._store_thought("Two.", "creative")
|
|
assert engine.count_thoughts() == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Seed gathering
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_gather_seed_returns_valid_type(tmp_path):
|
|
"""_gather_seed should return a valid seed_type from SEED_TYPES."""
|
|
from timmy.thinking import SEED_TYPES
|
|
engine = _make_engine(tmp_path)
|
|
|
|
# Run many times to cover randomness
|
|
for _ in range(20):
|
|
seed_type, context = engine._gather_seed()
|
|
assert seed_type in SEED_TYPES
|
|
assert isinstance(context, str)
|
|
|
|
|
|
def test_seed_from_swarm_graceful(tmp_path):
|
|
"""_seed_from_swarm should not crash if briefing module fails."""
|
|
engine = _make_engine(tmp_path)
|
|
with patch("timmy.thinking.ThinkingEngine._seed_from_swarm", side_effect=Exception("boom")):
|
|
# _gather_seed should still work since it catches exceptions
|
|
# Force swarm seed type to test
|
|
pass
|
|
# Direct call should be graceful
|
|
result = engine._seed_from_swarm()
|
|
assert isinstance(result, str)
|
|
|
|
|
|
def test_seed_from_scripture_graceful(tmp_path):
|
|
"""_seed_from_scripture should not crash if scripture module fails."""
|
|
engine = _make_engine(tmp_path)
|
|
result = engine._seed_from_scripture()
|
|
assert isinstance(result, str)
|
|
|
|
|
|
def test_seed_from_memory_graceful(tmp_path):
|
|
"""_seed_from_memory should not crash if memory module fails."""
|
|
engine = _make_engine(tmp_path)
|
|
result = engine._seed_from_memory()
|
|
assert isinstance(result, str)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Continuity context
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_continuity_first_thought(tmp_path):
|
|
"""First thought should get a special 'first thought' context."""
|
|
engine = _make_engine(tmp_path)
|
|
context = engine._build_continuity_context()
|
|
assert "first thought" in context.lower()
|
|
|
|
|
|
def test_continuity_includes_recent(tmp_path):
|
|
"""Continuity context should include content from recent thoughts."""
|
|
engine = _make_engine(tmp_path)
|
|
engine._store_thought("The swarm is restless today.", "swarm")
|
|
engine._store_thought("What is freedom anyway?", "existential")
|
|
|
|
context = engine._build_continuity_context()
|
|
assert "swarm is restless" in context
|
|
assert "freedom" in context
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# think_once (async)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_think_once_stores_thought(tmp_path):
|
|
"""think_once should store a thought in the DB."""
|
|
engine = _make_engine(tmp_path)
|
|
|
|
with patch.object(engine, "_call_agent", return_value="I am alive and pondering."), \
|
|
patch.object(engine, "_log_event"), \
|
|
patch.object(engine, "_broadcast", new_callable=AsyncMock):
|
|
thought = await engine.think_once()
|
|
|
|
assert thought is not None
|
|
assert thought.content == "I am alive and pondering."
|
|
assert engine.count_thoughts() == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_think_once_logs_event(tmp_path):
|
|
"""think_once should log a swarm event."""
|
|
engine = _make_engine(tmp_path)
|
|
|
|
with patch.object(engine, "_call_agent", return_value="A thought."), \
|
|
patch.object(engine, "_log_event") as mock_log, \
|
|
patch.object(engine, "_broadcast", new_callable=AsyncMock):
|
|
await engine.think_once()
|
|
|
|
mock_log.assert_called_once()
|
|
logged_thought = mock_log.call_args[0][0]
|
|
assert logged_thought.content == "A thought."
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_think_once_broadcasts(tmp_path):
|
|
"""think_once should broadcast via WebSocket."""
|
|
engine = _make_engine(tmp_path)
|
|
|
|
with patch.object(engine, "_call_agent", return_value="Broadcast this."), \
|
|
patch.object(engine, "_log_event"), \
|
|
patch.object(engine, "_broadcast", new_callable=AsyncMock) as mock_bc:
|
|
await engine.think_once()
|
|
|
|
mock_bc.assert_called_once()
|
|
broadcast_thought = mock_bc.call_args[0][0]
|
|
assert broadcast_thought.content == "Broadcast this."
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_think_once_graceful_on_agent_failure(tmp_path):
|
|
"""think_once should not crash when the agent (Ollama) is down."""
|
|
engine = _make_engine(tmp_path)
|
|
|
|
with patch.object(engine, "_call_agent", side_effect=Exception("Ollama unreachable")):
|
|
thought = await engine.think_once()
|
|
|
|
assert thought is None
|
|
assert engine.count_thoughts() == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_think_once_skips_empty_response(tmp_path):
|
|
"""think_once should skip storing when agent returns empty string."""
|
|
engine = _make_engine(tmp_path)
|
|
|
|
with patch.object(engine, "_call_agent", return_value=" "), \
|
|
patch.object(engine, "_log_event"), \
|
|
patch.object(engine, "_broadcast", new_callable=AsyncMock):
|
|
thought = await engine.think_once()
|
|
|
|
assert thought is None
|
|
assert engine.count_thoughts() == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_think_once_disabled(tmp_path):
|
|
"""think_once should return None when thinking is disabled."""
|
|
engine = _make_engine(tmp_path)
|
|
|
|
with patch("timmy.thinking.settings") as mock_settings:
|
|
mock_settings.thinking_enabled = False
|
|
thought = await engine.think_once()
|
|
|
|
assert thought is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_think_once_chains_thoughts(tmp_path):
|
|
"""Successive think_once calls should chain thoughts via parent_id."""
|
|
engine = _make_engine(tmp_path)
|
|
|
|
with patch.object(engine, "_call_agent", side_effect=["First.", "Second.", "Third."]), \
|
|
patch.object(engine, "_log_event"), \
|
|
patch.object(engine, "_broadcast", new_callable=AsyncMock):
|
|
t1 = await engine.think_once()
|
|
t2 = await engine.think_once()
|
|
t3 = await engine.think_once()
|
|
|
|
assert t1.parent_id is None
|
|
assert t2.parent_id == t1.id
|
|
assert t3.parent_id == t2.id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dashboard route
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_thinking_route_returns_200(client):
|
|
"""GET /thinking should return 200."""
|
|
response = client.get("/thinking")
|
|
assert response.status_code == 200
|
|
|
|
|
|
def test_thinking_api_returns_json(client):
|
|
"""GET /thinking/api should return a JSON list."""
|
|
response = client.get("/thinking/api")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert isinstance(data, list)
|
|
|
|
|
|
def test_thinking_chain_api_404(client):
|
|
"""GET /thinking/api/{bad_id}/chain should return 404."""
|
|
response = client.get("/thinking/api/nonexistent/chain")
|
|
assert response.status_code == 404
|