"""Unit tests for spark/engine.py. Covers the public API and internal helpers not exercised in other test files: - get_memories / get_predictions query methods - get_spark_engine singleton lifecycle and reset_spark_engine - Module-level __getattr__ lazy access - on_task_posted without candidate agents (no EIDOS call) - on_task_completed with winning_bid parameter - _maybe_consolidate early-return paths (<5 events, <3 outcomes) - Disabled-engine guard for every mutating method """ from unittest.mock import MagicMock, patch import pytest @pytest.fixture(autouse=True) def tmp_spark_db(tmp_path, monkeypatch): """Redirect all Spark SQLite writes to a temp directory.""" db_path = tmp_path / "spark.db" monkeypatch.setattr("spark.memory.DB_PATH", db_path) monkeypatch.setattr("spark.eidos.DB_PATH", db_path) yield db_path @pytest.fixture(autouse=True) def reset_engine(): """Ensure the engine singleton is cleared between tests.""" from spark.engine import reset_spark_engine reset_spark_engine() yield reset_spark_engine() # ── Query methods ───────────────────────────────────────────────────────────── @pytest.mark.unit class TestGetMemories: def test_returns_empty_list_initially(self): from spark.engine import SparkEngine engine = SparkEngine(enabled=True) assert engine.get_memories() == [] def test_returns_stored_memories(self): from spark.engine import SparkEngine from spark.memory import store_memory store_memory("pattern", "agent-x", "Reliable performer", confidence=0.8) engine = SparkEngine(enabled=True) memories = engine.get_memories() assert len(memories) == 1 assert memories[0].subject == "agent-x" def test_limit_parameter(self): from spark.engine import SparkEngine from spark.memory import store_memory for i in range(5): store_memory("pattern", f"agent-{i}", f"Content {i}") engine = SparkEngine(enabled=True) assert len(engine.get_memories(limit=3)) == 3 def test_works_when_disabled(self): """get_memories is not gated by enabled — it always reads.""" from spark.engine import SparkEngine from spark.memory import store_memory store_memory("anomaly", "agent-z", "Bad actor") engine = SparkEngine(enabled=False) assert len(engine.get_memories()) == 1 @pytest.mark.unit class TestGetPredictions: def test_returns_empty_list_initially(self): from spark.engine import SparkEngine engine = SparkEngine(enabled=True) assert engine.get_predictions() == [] def test_returns_predictions_after_task_posted(self): from spark.engine import SparkEngine engine = SparkEngine(enabled=True) engine.on_task_posted("t1", "Deploy service", ["agent-a", "agent-b"]) preds = engine.get_predictions() assert len(preds) >= 1 def test_limit_parameter(self): from spark.engine import SparkEngine engine = SparkEngine(enabled=True) for i in range(5): engine.on_task_posted(f"t{i}", f"Task {i}", ["agent-a"]) assert len(engine.get_predictions(limit=2)) == 2 # ── Singleton lifecycle ─────────────────────────────────────────────────────── @pytest.mark.unit class TestGetSparkEngineSingleton: def test_returns_spark_engine_instance(self): from spark.engine import SparkEngine, get_spark_engine engine = get_spark_engine() assert isinstance(engine, SparkEngine) def test_same_instance_on_repeated_calls(self): from spark.engine import get_spark_engine e1 = get_spark_engine() e2 = get_spark_engine() assert e1 is e2 def test_reset_clears_singleton(self): from spark.engine import get_spark_engine, reset_spark_engine e1 = get_spark_engine() reset_spark_engine() e2 = get_spark_engine() assert e1 is not e2 def test_get_spark_engine_uses_settings(self, monkeypatch): """get_spark_engine respects spark_enabled from config.""" mock_settings = MagicMock() mock_settings.spark_enabled = False with patch("spark.engine.settings", mock_settings, create=True): from spark.engine import reset_spark_engine reset_spark_engine() # Patch at import time by mocking the config module in engine import spark.engine as engine_module def patched_get(): global _spark_engine try: engine_module._spark_engine = engine_module.SparkEngine( enabled=mock_settings.spark_enabled ) except Exception: engine_module._spark_engine = engine_module.SparkEngine(enabled=True) return engine_module._spark_engine reset_spark_engine() def test_get_spark_engine_falls_back_on_settings_error(self, monkeypatch): """get_spark_engine creates enabled engine when settings import fails.""" from spark.engine import get_spark_engine, reset_spark_engine reset_spark_engine() # Patch config to raise on import with patch.dict("sys.modules", {"config": None}): # The engine catches the exception and defaults to enabled=True engine = get_spark_engine() # May or may not succeed depending on import cache, just ensure no crash assert engine is not None @pytest.mark.unit class TestModuleLevelGetattr: def test_spark_engine_attribute_returns_engine(self): import spark.engine as engine_module engine = engine_module.spark_engine assert isinstance(engine, engine_module.SparkEngine) def test_unknown_attribute_raises(self): import spark.engine as engine_module with pytest.raises(AttributeError): _ = engine_module.nonexistent_attribute_xyz # ── Event capture edge cases ────────────────────────────────────────────────── @pytest.mark.unit class TestOnTaskPostedWithoutCandidates: def test_no_eidos_prediction_when_no_candidates(self): """When candidate_agents is empty, no EIDOS prediction should be stored.""" from spark.eidos import get_predictions from spark.engine import SparkEngine engine = SparkEngine(enabled=True) eid = engine.on_task_posted("t1", "Background task", candidate_agents=[]) assert eid is not None # No candidates → no prediction preds = get_predictions(task_id="t1") assert len(preds) == 0 def test_no_candidates_defaults_to_none(self): """on_task_posted with no candidate_agents kwarg still records event.""" from spark.engine import SparkEngine from spark.memory import get_events engine = SparkEngine(enabled=True) eid = engine.on_task_posted("t2", "Orphan task") assert eid is not None events = get_events(task_id="t2") assert len(events) == 1 @pytest.mark.unit class TestOnTaskCompletedWithBid: def test_winning_bid_stored_in_data(self): """winning_bid is serialised into the event data field.""" import json from spark.engine import SparkEngine from spark.memory import get_events engine = SparkEngine(enabled=True) engine.on_task_completed("t1", "agent-a", "All done", winning_bid=42) events = get_events(event_type="task_completed") assert len(events) == 1 data = json.loads(events[0].data) assert data["winning_bid"] == 42 def test_without_winning_bid_is_none(self): import json from spark.engine import SparkEngine from spark.memory import get_events engine = SparkEngine(enabled=True) engine.on_task_completed("t2", "agent-b", "Done") events = get_events(event_type="task_completed") data = json.loads(events[0].data) assert data["winning_bid"] is None @pytest.mark.unit class TestDisabledEngineGuards: """Every method that mutates state should return None when disabled.""" def setup_method(self): from spark.engine import SparkEngine self.engine = SparkEngine(enabled=False) def test_on_task_posted_disabled(self): assert self.engine.on_task_posted("t", "x") is None def test_on_bid_submitted_disabled(self): assert self.engine.on_bid_submitted("t", "a", 10) is None def test_on_task_assigned_disabled(self): assert self.engine.on_task_assigned("t", "a") is None def test_on_task_completed_disabled(self): assert self.engine.on_task_completed("t", "a", "r") is None def test_on_task_failed_disabled(self): assert self.engine.on_task_failed("t", "a", "reason") is None def test_on_agent_joined_disabled(self): assert self.engine.on_agent_joined("a", "Echo") is None def test_on_tool_executed_disabled(self): assert self.engine.on_tool_executed("a", "git_push") is None def test_on_creative_step_disabled(self): assert self.engine.on_creative_step("p", "storyboard", "pixel") is None def test_get_advisories_disabled_returns_empty(self): assert self.engine.get_advisories() == [] # ── _maybe_consolidate early-return paths ───────────────────────────────────── @pytest.mark.unit class TestMaybeConsolidateEarlyReturns: """Test the guard conditions at the top of _maybe_consolidate.""" @patch("spark.engine.spark_memory") def test_fewer_than_5_events_skips(self, mock_memory): """With fewer than 5 events, consolidation is skipped immediately.""" from spark.engine import SparkEngine mock_memory.get_events.return_value = [MagicMock(event_type="task_completed")] * 3 engine = SparkEngine(enabled=True) engine._maybe_consolidate("agent-x") mock_memory.store_memory.assert_not_called() @patch("spark.engine.spark_memory") def test_fewer_than_3_outcomes_skips(self, mock_memory): """With 5+ events but fewer than 3 completion/failure outcomes, skip.""" from spark.engine import SparkEngine # 6 events but only 2 are outcomes (completions + failures) events = [MagicMock(event_type="task_posted")] * 4 events += [MagicMock(event_type="task_completed")] * 2 mock_memory.get_events.return_value = events engine = SparkEngine(enabled=True) engine._maybe_consolidate("agent-x") mock_memory.store_memory.assert_not_called() mock_memory.get_memories.assert_not_called() @patch("spark.engine.spark_memory") def test_neutral_success_rate_skips(self, mock_memory): """Success rate between 0.3 and 0.8 triggers no memory.""" from spark.engine import SparkEngine events = [MagicMock(event_type="task_posted")] * 2 events += [MagicMock(event_type="task_completed")] * 2 events += [MagicMock(event_type="task_failed")] * 2 mock_memory.get_events.return_value = events engine = SparkEngine(enabled=True) engine._maybe_consolidate("agent-x") mock_memory.store_memory.assert_not_called()