latest updates
This commit is contained in:
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Test package
|
||||
200
tests/test_base_adapter.py
Normal file
200
tests/test_base_adapter.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
Tests for the Base Adapter and IncomingMessage.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from adapters.base import BaseAdapter, IncomingMessage
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Concrete adapter for testing (implements all abstract methods)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MockAdapter(BaseAdapter):
|
||||
"""A minimal concrete adapter for testing BaseAdapter."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.sent_messages: list[dict] = []
|
||||
self._started = False
|
||||
|
||||
@property
|
||||
def source_name(self) -> str:
|
||||
return "mock"
|
||||
|
||||
def start(self) -> None:
|
||||
self._started = True
|
||||
|
||||
def start_async(self) -> None:
|
||||
self._started = True
|
||||
|
||||
def stop(self) -> None:
|
||||
self._started = False
|
||||
|
||||
def send_message(
|
||||
self,
|
||||
channel_id: str,
|
||||
text: str,
|
||||
thread_id: str | None = None,
|
||||
) -> None:
|
||||
self.sent_messages.append({
|
||||
"channel_id": channel_id,
|
||||
"text": text,
|
||||
"thread_id": thread_id,
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter():
|
||||
return MockAdapter()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_message():
|
||||
return IncomingMessage(
|
||||
text="Hello world",
|
||||
user_id="U123",
|
||||
user_name="testuser",
|
||||
channel_id="C456",
|
||||
channel_name="general",
|
||||
conversation_id="conv-789",
|
||||
source="mock",
|
||||
is_dm=False,
|
||||
raw_event={"thread_id": "T100"},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: IncomingMessage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIncomingMessage:
|
||||
def test_create_message(self, sample_message):
|
||||
assert sample_message.text == "Hello world"
|
||||
assert sample_message.user_id == "U123"
|
||||
assert sample_message.user_name == "testuser"
|
||||
assert sample_message.channel_id == "C456"
|
||||
assert sample_message.source == "mock"
|
||||
assert sample_message.is_dm is False
|
||||
|
||||
def test_timestamp_default(self):
|
||||
msg = IncomingMessage(
|
||||
text="test",
|
||||
user_id="U1",
|
||||
user_name="user",
|
||||
channel_id="C1",
|
||||
channel_name="ch",
|
||||
conversation_id="conv",
|
||||
source="test",
|
||||
is_dm=True,
|
||||
)
|
||||
assert msg.timestamp is not None
|
||||
assert msg.timestamp.tzinfo is not None # has timezone
|
||||
|
||||
def test_raw_event_default(self):
|
||||
msg = IncomingMessage(
|
||||
text="test",
|
||||
user_id="U1",
|
||||
user_name="user",
|
||||
channel_id="C1",
|
||||
channel_name="ch",
|
||||
conversation_id="conv",
|
||||
source="test",
|
||||
is_dm=False,
|
||||
)
|
||||
assert msg.raw_event == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: BaseAdapter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBaseAdapter:
|
||||
def test_register_handler(self, adapter):
|
||||
handler = lambda msg: "response"
|
||||
adapter.on_message(handler)
|
||||
assert len(adapter._message_handlers) == 1
|
||||
|
||||
def test_on_message_as_decorator(self, adapter):
|
||||
@adapter.on_message
|
||||
def my_handler(msg):
|
||||
return "decorated response"
|
||||
|
||||
assert len(adapter._message_handlers) == 1
|
||||
assert my_handler("test") == "decorated response"
|
||||
|
||||
def test_dispatch_calls_handler(self, adapter, sample_message):
|
||||
responses = []
|
||||
|
||||
@adapter.on_message
|
||||
def handler(msg):
|
||||
responses.append(msg.text)
|
||||
return f"reply to: {msg.text}"
|
||||
|
||||
adapter._dispatch(sample_message)
|
||||
assert responses == ["Hello world"]
|
||||
|
||||
def test_dispatch_sends_response(self, adapter, sample_message):
|
||||
@adapter.on_message
|
||||
def handler(msg):
|
||||
return "Auto reply"
|
||||
|
||||
adapter._dispatch(sample_message)
|
||||
assert len(adapter.sent_messages) == 1
|
||||
assert adapter.sent_messages[0]["text"] == "Auto reply"
|
||||
assert adapter.sent_messages[0]["channel_id"] == "C456"
|
||||
|
||||
def test_dispatch_no_response(self, adapter, sample_message):
|
||||
@adapter.on_message
|
||||
def handler(msg):
|
||||
return None # explicit no response
|
||||
|
||||
adapter._dispatch(sample_message)
|
||||
assert len(adapter.sent_messages) == 0
|
||||
|
||||
def test_dispatch_handler_error(self, adapter, sample_message):
|
||||
@adapter.on_message
|
||||
def bad_handler(msg):
|
||||
raise ValueError("Something broke")
|
||||
|
||||
# Should not raise — dispatch catches errors
|
||||
adapter._dispatch(sample_message)
|
||||
# Should send error message
|
||||
assert len(adapter.sent_messages) == 1
|
||||
assert "Something went wrong" in adapter.sent_messages[0]["text"]
|
||||
|
||||
def test_multiple_handlers(self, adapter, sample_message):
|
||||
calls = []
|
||||
|
||||
@adapter.on_message
|
||||
def handler1(msg):
|
||||
calls.append("h1")
|
||||
return None
|
||||
|
||||
@adapter.on_message
|
||||
def handler2(msg):
|
||||
calls.append("h2")
|
||||
return "from h2"
|
||||
|
||||
adapter._dispatch(sample_message)
|
||||
assert calls == ["h1", "h2"]
|
||||
assert len(adapter.sent_messages) == 1 # only h2 returned a response
|
||||
|
||||
def test_source_name(self, adapter):
|
||||
assert adapter.source_name == "mock"
|
||||
|
||||
def test_start_stop(self, adapter):
|
||||
adapter.start()
|
||||
assert adapter._started is True
|
||||
adapter.stop()
|
||||
assert adapter._started is False
|
||||
140
tests/test_scheduler.py
Normal file
140
tests/test_scheduler.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
Tests for the Scheduler Store (SQLite persistence).
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from scheduler.store import JobStore, ScheduledJob
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_path(tmp_path):
|
||||
"""Provide a temp database path."""
|
||||
return str(tmp_path / "test_scheduler.db")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(db_path):
|
||||
"""Create a fresh JobStore."""
|
||||
return JobStore(db_path=db_path)
|
||||
|
||||
|
||||
def _make_job(
|
||||
cron_expr: str | None = "*/5 * * * *",
|
||||
prompt: str = "Test prompt",
|
||||
channel_id: str = "C123",
|
||||
channel_type: str = "slack",
|
||||
) -> ScheduledJob:
|
||||
"""Helper to create a ScheduledJob."""
|
||||
return ScheduledJob(
|
||||
id=JobStore.new_id(),
|
||||
cron_expr=cron_expr,
|
||||
prompt=prompt,
|
||||
channel_id=channel_id,
|
||||
channel_type=channel_type,
|
||||
created_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: JobStore
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestJobStore:
|
||||
def test_add_and_get(self, store):
|
||||
job = _make_job(prompt="Hello scheduler")
|
||||
store.add(job)
|
||||
retrieved = store.get(job.id)
|
||||
assert retrieved is not None
|
||||
assert retrieved.id == job.id
|
||||
assert retrieved.prompt == "Hello scheduler"
|
||||
assert retrieved.cron_expr == "*/5 * * * *"
|
||||
|
||||
def test_remove(self, store):
|
||||
job = _make_job()
|
||||
store.add(job)
|
||||
assert store.remove(job.id) is True
|
||||
assert store.get(job.id) is None
|
||||
|
||||
def test_remove_nonexistent(self, store):
|
||||
assert store.remove("nonexistent") is False
|
||||
|
||||
def test_list_all(self, store):
|
||||
job1 = _make_job(prompt="Job 1")
|
||||
job2 = _make_job(prompt="Job 2")
|
||||
store.add(job1)
|
||||
store.add(job2)
|
||||
all_jobs = store.list_all()
|
||||
assert len(all_jobs) == 2
|
||||
|
||||
def test_list_recurring(self, store):
|
||||
cron_job = _make_job(cron_expr="0 9 * * *", prompt="Recurring")
|
||||
oneshot_job = _make_job(cron_expr=None, prompt="One-shot")
|
||||
store.add(cron_job)
|
||||
store.add(oneshot_job)
|
||||
recurring = store.list_recurring()
|
||||
assert len(recurring) == 1
|
||||
assert recurring[0].prompt == "Recurring"
|
||||
|
||||
def test_clear_oneshot(self, store):
|
||||
cron_job = _make_job(cron_expr="0 9 * * *")
|
||||
oneshot1 = _make_job(cron_expr=None, prompt="OS 1")
|
||||
oneshot2 = _make_job(cron_expr=None, prompt="OS 2")
|
||||
store.add(cron_job)
|
||||
store.add(oneshot1)
|
||||
store.add(oneshot2)
|
||||
removed = store.clear_oneshot()
|
||||
assert removed == 2
|
||||
remaining = store.list_all()
|
||||
assert len(remaining) == 1
|
||||
assert remaining[0].is_recurring
|
||||
|
||||
def test_is_recurring(self):
|
||||
cron = _make_job(cron_expr="*/5 * * * *")
|
||||
oneshot = _make_job(cron_expr=None)
|
||||
assert cron.is_recurring is True
|
||||
assert oneshot.is_recurring is False
|
||||
|
||||
def test_persistence(self, db_path):
|
||||
"""Jobs survive store re-creation."""
|
||||
store1 = JobStore(db_path=db_path)
|
||||
job = _make_job(prompt="Persistent job")
|
||||
store1.add(job)
|
||||
|
||||
# Create a new store instance pointing to same DB
|
||||
store2 = JobStore(db_path=db_path)
|
||||
retrieved = store2.get(job.id)
|
||||
assert retrieved is not None
|
||||
assert retrieved.prompt == "Persistent job"
|
||||
|
||||
def test_new_id_unique(self):
|
||||
ids = {JobStore.new_id() for _ in range(100)}
|
||||
assert len(ids) == 100 # all unique
|
||||
|
||||
def test_job_with_metadata(self, store):
|
||||
job = _make_job()
|
||||
job.thread_id = "T456"
|
||||
job.user_name = "Test User"
|
||||
store.add(job)
|
||||
retrieved = store.get(job.id)
|
||||
assert retrieved is not None
|
||||
assert retrieved.thread_id == "T456"
|
||||
assert retrieved.user_name == "Test User"
|
||||
|
||||
def test_telegram_channel_type(self, store):
|
||||
job = _make_job(channel_type="telegram", channel_id="987654")
|
||||
store.add(job)
|
||||
retrieved = store.get(job.id)
|
||||
assert retrieved is not None
|
||||
assert retrieved.channel_type == "telegram"
|
||||
assert retrieved.channel_id == "987654"
|
||||
272
tests/test_skills.py
Normal file
272
tests/test_skills.py
Normal file
@@ -0,0 +1,272 @@
|
||||
"""
|
||||
Tests for the Skills System.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from skills.skills import Skill, SkillsManager
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def skills_workspace(tmp_path):
|
||||
"""Create a temp workspace with sample skills."""
|
||||
skills_dir = tmp_path / "skills"
|
||||
|
||||
# Skill 1: weather
|
||||
weather_dir = skills_dir / "weather"
|
||||
weather_dir.mkdir(parents=True)
|
||||
(weather_dir / "SKILL.md").write_text(
|
||||
"""---
|
||||
name: weather
|
||||
description: Check weather for any city
|
||||
triggers: [weather, forecast, temperature, rain]
|
||||
---
|
||||
|
||||
# Weather Skill
|
||||
|
||||
When the user asks about weather:
|
||||
1. Extract the city name
|
||||
2. Provide info based on your knowledge
|
||||
"""
|
||||
)
|
||||
|
||||
# Skill 2: coding
|
||||
coding_dir = skills_dir / "coding"
|
||||
coding_dir.mkdir(parents=True)
|
||||
(coding_dir / "SKILL.md").write_text(
|
||||
"""---
|
||||
name: coding
|
||||
description: Help with programming tasks
|
||||
triggers: [code, python, javascript, bug, debug]
|
||||
---
|
||||
|
||||
# Coding Skill
|
||||
|
||||
When the user asks about coding:
|
||||
- Ask what language they're working in
|
||||
- Provide code snippets with explanations
|
||||
"""
|
||||
)
|
||||
|
||||
# Skill 3: invalid (no SKILL.md)
|
||||
invalid_dir = skills_dir / "empty_skill"
|
||||
invalid_dir.mkdir(parents=True)
|
||||
# No SKILL.md here
|
||||
|
||||
# Skill 4: minimal (no explicit triggers)
|
||||
minimal_dir = skills_dir / "minimal"
|
||||
minimal_dir.mkdir(parents=True)
|
||||
(minimal_dir / "SKILL.md").write_text(
|
||||
"""---
|
||||
name: minimal_skill
|
||||
description: A minimal skill
|
||||
---
|
||||
|
||||
This is a minimal skill with no explicit triggers.
|
||||
"""
|
||||
)
|
||||
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def empty_workspace(tmp_path):
|
||||
"""Create a temp workspace with no skills directory."""
|
||||
return tmp_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Skill Dataclass
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSkill:
|
||||
def test_matches_trigger(self):
|
||||
skill = Skill(
|
||||
name="weather",
|
||||
description="Weather skill",
|
||||
triggers=["weather", "forecast"],
|
||||
body="# Weather",
|
||||
path="/fake/path",
|
||||
)
|
||||
assert skill.matches("What's the weather today?")
|
||||
assert skill.matches("Give me the FORECAST")
|
||||
assert not skill.matches("Tell me a joke")
|
||||
|
||||
def test_matches_is_case_insensitive(self):
|
||||
skill = Skill(
|
||||
name="test",
|
||||
description="Test",
|
||||
triggers=["Python"],
|
||||
body="body",
|
||||
path="/fake",
|
||||
)
|
||||
assert skill.matches("I'm learning python")
|
||||
assert skill.matches("PYTHON is great")
|
||||
assert skill.matches("Python 3.14")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: SkillsManager Loading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSkillsManagerLoading:
|
||||
def test_load_all_finds_skills(self, skills_workspace):
|
||||
manager = SkillsManager(str(skills_workspace))
|
||||
loaded = manager.load_all()
|
||||
# Should find weather, coding, minimal (not empty_skill which has no SKILL.md)
|
||||
assert len(loaded) == 3
|
||||
names = {s.name for s in loaded}
|
||||
assert "weather" in names
|
||||
assert "coding" in names
|
||||
assert "minimal_skill" in names
|
||||
|
||||
def test_load_parses_frontmatter(self, skills_workspace):
|
||||
manager = SkillsManager(str(skills_workspace))
|
||||
manager.load_all()
|
||||
weather = next(s for s in manager.skills if s.name == "weather")
|
||||
assert weather.description == "Check weather for any city"
|
||||
assert "weather" in weather.triggers
|
||||
assert "forecast" in weather.triggers
|
||||
assert "temperature" in weather.triggers
|
||||
assert "rain" in weather.triggers
|
||||
|
||||
def test_load_parses_body(self, skills_workspace):
|
||||
manager = SkillsManager(str(skills_workspace))
|
||||
manager.load_all()
|
||||
weather = next(s for s in manager.skills if s.name == "weather")
|
||||
assert "# Weather Skill" in weather.body
|
||||
assert "Extract the city name" in weather.body
|
||||
|
||||
def test_empty_workspace(self, empty_workspace):
|
||||
manager = SkillsManager(str(empty_workspace))
|
||||
loaded = manager.load_all()
|
||||
assert loaded == []
|
||||
|
||||
def test_minimal_skill_uses_name_as_trigger(self, skills_workspace):
|
||||
manager = SkillsManager(str(skills_workspace))
|
||||
manager.load_all()
|
||||
minimal = next(s for s in manager.skills if s.name == "minimal_skill")
|
||||
assert minimal.triggers == ["minimal_skill"]
|
||||
|
||||
def test_reload_rediscovers(self, skills_workspace):
|
||||
manager = SkillsManager(str(skills_workspace))
|
||||
manager.load_all()
|
||||
assert len(manager.skills) == 3
|
||||
|
||||
# Add a new skill
|
||||
new_dir = skills_workspace / "skills" / "newskill"
|
||||
new_dir.mkdir()
|
||||
(new_dir / "SKILL.md").write_text(
|
||||
"---\nname: newskill\ndescription: New\ntriggers: [new]\n---\nNew body"
|
||||
)
|
||||
|
||||
reloaded = manager.reload()
|
||||
assert len(reloaded) == 4
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: SkillsManager Matching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSkillsManagerMatching:
|
||||
def test_get_relevant_finds_matching(self, skills_workspace):
|
||||
manager = SkillsManager(str(skills_workspace))
|
||||
manager.load_all()
|
||||
relevant = manager.get_relevant("What's the weather?")
|
||||
names = {s.name for s in relevant}
|
||||
assert "weather" in names
|
||||
assert "coding" not in names
|
||||
|
||||
def test_get_relevant_multiple_matches(self, skills_workspace):
|
||||
manager = SkillsManager(str(skills_workspace))
|
||||
manager.load_all()
|
||||
# This should match both weather (temperature) and coding (debug)
|
||||
relevant = manager.get_relevant("debug the temperature sensor code")
|
||||
names = {s.name for s in relevant}
|
||||
assert "weather" in names
|
||||
assert "coding" in names
|
||||
|
||||
def test_get_relevant_no_matches(self, skills_workspace):
|
||||
manager = SkillsManager(str(skills_workspace))
|
||||
manager.load_all()
|
||||
relevant = manager.get_relevant("Tell me a joke about cats")
|
||||
assert relevant == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Context Generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSkillsManagerContext:
|
||||
def test_get_context_with_matching(self, skills_workspace):
|
||||
manager = SkillsManager(str(skills_workspace))
|
||||
manager.load_all()
|
||||
context = manager.get_context("forecast for tomorrow")
|
||||
assert "# Active Skills" in context
|
||||
assert "Weather Skill" in context
|
||||
|
||||
def test_get_context_empty_when_no_match(self, skills_workspace):
|
||||
manager = SkillsManager(str(skills_workspace))
|
||||
manager.load_all()
|
||||
context = manager.get_context("random unrelated message")
|
||||
assert context == ""
|
||||
|
||||
def test_get_all_context(self, skills_workspace):
|
||||
manager = SkillsManager(str(skills_workspace))
|
||||
manager.load_all()
|
||||
summary = manager.get_all_context()
|
||||
assert "# Available Skills" in summary
|
||||
assert "weather" in summary
|
||||
assert "coding" in summary
|
||||
|
||||
def test_get_all_context_empty_workspace(self, empty_workspace):
|
||||
manager = SkillsManager(str(empty_workspace))
|
||||
manager.load_all()
|
||||
assert manager.get_all_context() == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Frontmatter Parsing Edge Cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFrontmatterParsing:
|
||||
def test_parse_list_inline_brackets(self):
|
||||
result = SkillsManager._parse_list("[a, b, c]")
|
||||
assert result == ["a", "b", "c"]
|
||||
|
||||
def test_parse_list_no_brackets(self):
|
||||
result = SkillsManager._parse_list("a, b, c")
|
||||
assert result == ["a", "b", "c"]
|
||||
|
||||
def test_parse_list_quoted_items(self):
|
||||
result = SkillsManager._parse_list("['hello', \"world\"]")
|
||||
assert result == ["hello", "world"]
|
||||
|
||||
def test_parse_list_empty(self):
|
||||
result = SkillsManager._parse_list("")
|
||||
assert result == []
|
||||
|
||||
def test_split_frontmatter(self):
|
||||
content = "---\nname: test\n---\nBody here"
|
||||
fm, body = SkillsManager._split_frontmatter(content)
|
||||
assert "name: test" in fm
|
||||
assert body == "Body here"
|
||||
|
||||
def test_split_no_frontmatter(self):
|
||||
content = "Just a body with no frontmatter"
|
||||
fm, body = SkillsManager._split_frontmatter(content)
|
||||
assert fm == ""
|
||||
assert body == content
|
||||
Reference in New Issue
Block a user