Co-authored-by: Perplexity Computer <perplexity@tower.local> Co-committed-by: Perplexity Computer <perplexity@tower.local>
385 lines
15 KiB
Python
385 lines
15 KiB
Python
"""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)
|
|
|
|
# Implementation uses >= 0.85 (inclusive threshold)
|
|
assert result.confidence == 0.85
|
|
assert result.state is not None
|
|
assert result.state["template_name"] == "threshold_match"
|
|
|
|
@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] # raw image array is NOT in JSON
|
|
# image_path is stored for .npy file reference
|
|
assert "name" in data[0]
|
|
assert "threshold" in data[0]
|
|
|
|
|
|
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
|
|
# Images are now persisted as .npy files and loaded back
|
|
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") == []
|