From ad30827f3f9182e7de9afb852dc348e8336ef787 Mon Sep 17 00:00:00 2001 From: kimi Date: Mon, 23 Mar 2026 21:53:01 -0400 Subject: [PATCH] test: Add unit tests for sovereignty/perception_cache.py Add comprehensive test coverage for PerceptionCache: - Test cache initialization (empty, from file, string path) - Test Template and CacheResult dataclasses - Test template matching with mock cv2 - Test confidence threshold behavior (below, at, above threshold) - Test best template selection across multiple templates - Test cache modification (add single/multiple templates) - Test persistence (creates file, stores metadata, no images) - Test loading (from file, empty file, empty images) - Test crystallize_perception placeholder function Fixes #1261 --- tests/sovereignty/__init__.py | 0 tests/sovereignty/test_perception_cache.py | 379 +++++++++++++++++++++ 2 files changed, 379 insertions(+) create mode 100644 tests/sovereignty/__init__.py create mode 100644 tests/sovereignty/test_perception_cache.py diff --git a/tests/sovereignty/__init__.py b/tests/sovereignty/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/sovereignty/test_perception_cache.py b/tests/sovereignty/test_perception_cache.py new file mode 100644 index 00000000..bcdfbe77 --- /dev/null +++ b/tests/sovereignty/test_perception_cache.py @@ -0,0 +1,379 @@ +"""Tests for the sovereignty perception cache (template matching). + +Refs: #1261 +""" + +import json +from unittest.mock import patch + +import numpy as np + + +class TestTemplate: + """Tests for the Template dataclass.""" + + def test_template_default_values(self): + """Template dataclass has correct defaults.""" + from timmy.sovereignty.perception_cache import Template + + image = np.array([[1, 2], [3, 4]]) + template = Template(name="test_template", image=image) + + assert template.name == "test_template" + assert np.array_equal(template.image, image) + assert template.threshold == 0.85 + + def test_template_custom_threshold(self): + """Template can have custom threshold.""" + from timmy.sovereignty.perception_cache import Template + + image = np.array([[1, 2], [3, 4]]) + template = Template(name="test_template", image=image, threshold=0.95) + + assert template.threshold == 0.95 + + +class TestCacheResult: + """Tests for the CacheResult dataclass.""" + + def test_cache_result_with_state(self): + """CacheResult stores confidence and state.""" + from timmy.sovereignty.perception_cache import CacheResult + + result = CacheResult(confidence=0.92, state={"template_name": "test"}) + assert result.confidence == 0.92 + assert result.state == {"template_name": "test"} + + def test_cache_result_no_state(self): + """CacheResult can have None state.""" + from timmy.sovereignty.perception_cache import CacheResult + + result = CacheResult(confidence=0.5, state=None) + assert result.confidence == 0.5 + assert result.state is None + + +class TestPerceptionCacheInit: + """Tests for PerceptionCache initialization.""" + + def test_init_creates_empty_cache_when_no_file(self, tmp_path): + """Cache initializes empty when templates file doesn't exist.""" + from timmy.sovereignty.perception_cache import PerceptionCache + + templates_path = tmp_path / "nonexistent_templates.json" + cache = PerceptionCache(templates_path=templates_path) + + assert cache.templates_path == templates_path + assert cache.templates == [] + + def test_init_loads_existing_templates(self, tmp_path): + """Cache loads templates from existing JSON file.""" + from timmy.sovereignty.perception_cache import PerceptionCache + + templates_path = tmp_path / "templates.json" + templates_data = [ + {"name": "template1", "threshold": 0.85}, + {"name": "template2", "threshold": 0.90}, + ] + with open(templates_path, "w") as f: + json.dump(templates_data, f) + + cache = PerceptionCache(templates_path=templates_path) + + assert len(cache.templates) == 2 + assert cache.templates[0].name == "template1" + assert cache.templates[0].threshold == 0.85 + assert cache.templates[1].name == "template2" + assert cache.templates[1].threshold == 0.90 + + def test_init_with_string_path(self, tmp_path): + """Cache accepts string path for templates.""" + from timmy.sovereignty.perception_cache import PerceptionCache + + templates_path = str(tmp_path / "templates.json") + cache = PerceptionCache(templates_path=templates_path) + + assert str(cache.templates_path) == templates_path + + +class TestPerceptionCacheMatch: + """Tests for PerceptionCache.match() template matching.""" + + def test_match_no_templates_returns_low_confidence(self, tmp_path): + """Matching with no templates returns low confidence and None state.""" + from timmy.sovereignty.perception_cache import PerceptionCache + + cache = PerceptionCache(templates_path=tmp_path / "templates.json") + screenshot = np.array([[1, 2], [3, 4]]) + + result = cache.match(screenshot) + + assert result.confidence == 0.0 + assert result.state is None + + @patch("timmy.sovereignty.perception_cache.cv2") + def test_match_finds_best_template(self, mock_cv2, tmp_path): + """Match returns the best matching template above threshold.""" + from timmy.sovereignty.perception_cache import PerceptionCache, Template + + # Setup mock cv2 behavior + mock_cv2.matchTemplate.return_value = np.array([[0.5, 0.6], [0.7, 0.8]]) + mock_cv2.TM_CCOEFF_NORMED = "TM_CCOEFF_NORMED" + mock_cv2.minMaxLoc.return_value = (None, 0.92, None, None) + + cache = PerceptionCache(templates_path=tmp_path / "templates.json") + template = Template(name="best_match", image=np.array([[1, 2], [3, 4]])) + cache.add([template]) + + screenshot = np.array([[5, 6], [7, 8]]) + result = cache.match(screenshot) + + assert result.confidence == 0.92 + assert result.state == {"template_name": "best_match"} + + @patch("timmy.sovereignty.perception_cache.cv2") + def test_match_respects_global_threshold(self, mock_cv2, tmp_path): + """Match returns None state when confidence is below threshold.""" + from timmy.sovereignty.perception_cache import PerceptionCache, Template + + # Setup mock cv2 to return confidence below 0.85 threshold + mock_cv2.matchTemplate.return_value = np.array([[0.1, 0.2], [0.3, 0.4]]) + mock_cv2.TM_CCOEFF_NORMED = "TM_CCOEFF_NORMED" + mock_cv2.minMaxLoc.return_value = (None, 0.75, None, None) + + cache = PerceptionCache(templates_path=tmp_path / "templates.json") + template = Template(name="low_match", image=np.array([[1, 2], [3, 4]])) + cache.add([template]) + + screenshot = np.array([[5, 6], [7, 8]]) + result = cache.match(screenshot) + + # Confidence is recorded but state is None (below threshold) + assert result.confidence == 0.75 + assert result.state is None + + @patch("timmy.sovereignty.perception_cache.cv2") + def test_match_selects_highest_confidence(self, mock_cv2, tmp_path): + """Match selects template with highest confidence across all templates.""" + from timmy.sovereignty.perception_cache import PerceptionCache, Template + + mock_cv2.TM_CCOEFF_NORMED = "TM_CCOEFF_NORMED" + + # Each template will return a different confidence + mock_cv2.minMaxLoc.side_effect = [ + (None, 0.70, None, None), # template1 + (None, 0.95, None, None), # template2 (best) + (None, 0.80, None, None), # template3 + ] + + cache = PerceptionCache(templates_path=tmp_path / "templates.json") + templates = [ + Template(name="template1", image=np.array([[1, 2], [3, 4]])), + Template(name="template2", image=np.array([[5, 6], [7, 8]])), + Template(name="template3", image=np.array([[9, 10], [11, 12]])), + ] + cache.add(templates) + + screenshot = np.array([[13, 14], [15, 16]]) + result = cache.match(screenshot) + + assert result.confidence == 0.95 + assert result.state == {"template_name": "template2"} + + @patch("timmy.sovereignty.perception_cache.cv2") + def test_match_exactly_at_threshold(self, mock_cv2, tmp_path): + """Match returns state when confidence is exactly at threshold boundary.""" + from timmy.sovereignty.perception_cache import PerceptionCache, Template + + mock_cv2.matchTemplate.return_value = np.array([[0.1]]) + mock_cv2.TM_CCOEFF_NORMED = "TM_CCOEFF_NORMED" + mock_cv2.minMaxLoc.return_value = (None, 0.85, None, None) # Exactly at threshold + + cache = PerceptionCache(templates_path=tmp_path / "templates.json") + template = Template(name="threshold_match", image=np.array([[1, 2], [3, 4]])) + cache.add([template]) + + screenshot = np.array([[5, 6], [7, 8]]) + result = cache.match(screenshot) + + # Note: current implementation uses > 0.85, so exactly 0.85 returns None state + assert result.confidence == 0.85 + assert result.state is None + + @patch("timmy.sovereignty.perception_cache.cv2") + def test_match_just_above_threshold(self, mock_cv2, tmp_path): + """Match returns state when confidence is just above threshold.""" + from timmy.sovereignty.perception_cache import PerceptionCache, Template + + mock_cv2.matchTemplate.return_value = np.array([[0.1]]) + mock_cv2.TM_CCOEFF_NORMED = "TM_CCOEFF_NORMED" + mock_cv2.minMaxLoc.return_value = (None, 0.851, None, None) # Just above threshold + + cache = PerceptionCache(templates_path=tmp_path / "templates.json") + template = Template(name="above_threshold", image=np.array([[1, 2], [3, 4]])) + cache.add([template]) + + screenshot = np.array([[5, 6], [7, 8]]) + result = cache.match(screenshot) + + assert result.confidence == 0.851 + assert result.state == {"template_name": "above_threshold"} + + +class TestPerceptionCacheAdd: + """Tests for PerceptionCache.add() method.""" + + def test_add_single_template(self, tmp_path): + """Can add a single template to the cache.""" + from timmy.sovereignty.perception_cache import PerceptionCache, Template + + cache = PerceptionCache(templates_path=tmp_path / "templates.json") + template = Template(name="new_template", image=np.array([[1, 2], [3, 4]])) + + cache.add([template]) + + assert len(cache.templates) == 1 + assert cache.templates[0].name == "new_template" + + def test_add_multiple_templates(self, tmp_path): + """Can add multiple templates at once.""" + from timmy.sovereignty.perception_cache import PerceptionCache, Template + + cache = PerceptionCache(templates_path=tmp_path / "templates.json") + templates = [ + Template(name="template1", image=np.array([[1, 2], [3, 4]])), + Template(name="template2", image=np.array([[5, 6], [7, 8]])), + ] + + cache.add(templates) + + assert len(cache.templates) == 2 + assert cache.templates[0].name == "template1" + assert cache.templates[1].name == "template2" + + def test_add_templates_accumulate(self, tmp_path): + """Adding templates multiple times accumulates them.""" + from timmy.sovereignty.perception_cache import PerceptionCache, Template + + cache = PerceptionCache(templates_path=tmp_path / "templates.json") + cache.add([Template(name="first", image=np.array([[1]]))]) + cache.add([Template(name="second", image=np.array([[2]]))]) + + assert len(cache.templates) == 2 + + +class TestPerceptionCachePersist: + """Tests for PerceptionCache.persist() method.""" + + def test_persist_creates_file(self, tmp_path): + """Persist creates templates JSON file.""" + from timmy.sovereignty.perception_cache import PerceptionCache, Template + + templates_path = tmp_path / "subdir" / "templates.json" + cache = PerceptionCache(templates_path=templates_path) + cache.add([Template(name="persisted", image=np.array([[1, 2], [3, 4]]))]) + + cache.persist() + + assert templates_path.exists() + + def test_persist_stores_template_names(self, tmp_path): + """Persist stores template names and thresholds.""" + from timmy.sovereignty.perception_cache import PerceptionCache, Template + + templates_path = tmp_path / "templates.json" + cache = PerceptionCache(templates_path=templates_path) + cache.add([ + Template(name="template1", image=np.array([[1]]), threshold=0.85), + Template(name="template2", image=np.array([[2]]), threshold=0.90), + ]) + + cache.persist() + + with open(templates_path) as f: + data = json.load(f) + + assert len(data) == 2 + assert data[0]["name"] == "template1" + assert data[0]["threshold"] == 0.85 + assert data[1]["name"] == "template2" + assert data[1]["threshold"] == 0.90 + + def test_persist_does_not_store_image_data(self, tmp_path): + """Persist only stores metadata, not actual image arrays.""" + from timmy.sovereignty.perception_cache import PerceptionCache, Template + + templates_path = tmp_path / "templates.json" + cache = PerceptionCache(templates_path=templates_path) + cache.add([Template(name="no_image", image=np.array([[1, 2, 3], [4, 5, 6]]))]) + + cache.persist() + + with open(templates_path) as f: + data = json.load(f) + + assert "image" not in data[0] + assert set(data[0].keys()) == {"name", "threshold"} + + +class TestPerceptionCacheLoad: + """Tests for PerceptionCache.load() method.""" + + def test_load_from_existing_file(self, tmp_path): + """Load restores templates from persisted file.""" + from timmy.sovereignty.perception_cache import PerceptionCache + + templates_path = tmp_path / "templates.json" + + # Create initial cache with templates and persist + cache1 = PerceptionCache(templates_path=templates_path) + from timmy.sovereignty.perception_cache import Template + + cache1.add([Template(name="loaded", image=np.array([[1]]), threshold=0.88)]) + cache1.persist() + + # Create new cache instance that loads from same file + cache2 = PerceptionCache(templates_path=templates_path) + + assert len(cache2.templates) == 1 + assert cache2.templates[0].name == "loaded" + assert cache2.templates[0].threshold == 0.88 + # Note: images are loaded as empty arrays per current implementation + assert cache2.templates[0].image.size == 0 + + def test_load_empty_file(self, tmp_path): + """Load handles empty template list in file.""" + from timmy.sovereignty.perception_cache import PerceptionCache + + templates_path = tmp_path / "templates.json" + with open(templates_path, "w") as f: + json.dump([], f) + + cache = PerceptionCache(templates_path=templates_path) + + assert cache.templates == [] + + +class TestCrystallizePerception: + """Tests for crystallize_perception function.""" + + def test_crystallize_returns_empty_list(self, tmp_path): + """crystallize_perception currently returns empty list (placeholder).""" + from timmy.sovereignty.perception_cache import crystallize_perception + + screenshot = np.array([[1, 2], [3, 4]]) + result = crystallize_perception(screenshot, {"some": "response"}) + + assert result == [] + + def test_crystallize_accepts_any_vlm_response(self, tmp_path): + """crystallize_perception accepts any vlm_response format.""" + from timmy.sovereignty.perception_cache import crystallize_perception + + screenshot = np.array([[1, 2], [3, 4]]) + + # Test with various response types + assert crystallize_perception(screenshot, None) == [] + assert crystallize_perception(screenshot, {}) == [] + assert crystallize_perception(screenshot, {"items": []}) == [] + assert crystallize_perception(screenshot, "string response") == [] -- 2.43.0