Files
LEDMatrix/test/test_error_aggregator.py
T
ChuckandClaude Opus 5.5 7e066174d9 chore: remove dead code, deprecate unused plugin APIs (over-engineering audit)
Whole-tree audit. Every symbol was checked against core, the plugin
monorepo and all eight third-party plugins in plugins.json first.

- Deprecate (removal 3.10.0) plugin-facing methods nothing calls:
  LogoDownloader bulk download, ConfigManager backup/secret wrappers,
  APIHelper extras, BackgroundDataService poll API, PluginManager /
  PluginStateManager info readers, and a few CacheManager, FontManager,
  BaseOddsManager, DynamicTeamResolver methods and PluginTestCase.
  plugin_api_usage.py learns their receiver names; DEPRECATIONS doc
  regenerated.
- Remove core-internal dead code: CacheMetrics, Vegas status/stats
  plumbing, sync "new cycle" message (followers ignore unknown types),
  unused operation types, test-only PluginCatalog readers, IPC to_dict
  and ping, _parse_form_value, CacheStrategyProtocol, ErrorAggregator
  callbacks, duplicate web response helpers.
- Web UI: drop never-mounted json-file-manager.js, the example widget,
  utils/error_handler.js, four uncalled PluginAPI methods, and 29
  escapeHtml shims (call window.LEDEscape directly). Public globals,
  BaseWidget and widget names unchanged.
- Remove six one-off scripts (owner decision) and the unused markupsafe
  and pytest-mock pins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:27:40 -04:00

396 lines
12 KiB
Python

"""
Tests for the error aggregation service.
Tests:
- Error recording
- Pattern detection
- Error summary generation
- Plugin health tracking
- Thread safety
"""
from datetime import datetime, timedelta
from pathlib import Path
import threading
import sys
# Add project root to path
project_root = Path(__file__).parent.parent
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
from src.error_aggregator import (
ErrorAggregator,
ErrorRecord,
ErrorPattern,
get_error_aggregator,
record_error
)
from src.exceptions import PluginError
class TestErrorRecording:
"""Test basic error recording functionality."""
def test_record_error_creates_record(self):
"""Recording an error should create an ErrorRecord."""
aggregator = ErrorAggregator(max_records=100)
error = ValueError("Test error message")
record = aggregator.record_error(
error=error,
plugin_id="test-plugin",
operation="update"
)
assert record.error_type == "ValueError"
assert record.message == "Test error message"
assert record.plugin_id == "test-plugin"
assert record.operation == "update"
assert record.stack_trace is not None
def test_record_error_with_context(self):
"""Error context should be preserved."""
aggregator = ErrorAggregator()
error = ValueError("Test error")
context = {"key": "value", "count": 42}
record = aggregator.record_error(
error=error,
context=context,
plugin_id="test-plugin"
)
assert record.context["key"] == "value"
assert record.context["count"] == 42
def test_ledmatrix_error_context_extracted(self):
"""Context from LEDMatrixError subclasses should be extracted."""
aggregator = ErrorAggregator()
error = PluginError(
"Plugin failed",
plugin_id="failing-plugin",
context={"additional": "info"}
)
record = aggregator.record_error(error=error)
assert "plugin_id" in record.context
assert record.context["additional"] == "info"
def test_max_records_limit(self):
"""Records should not exceed max_records limit."""
aggregator = ErrorAggregator(max_records=5)
for i in range(10):
aggregator.record_error(error=ValueError(f"Error {i}"))
assert len(aggregator._records) == 5
# Oldest records should be removed
assert "Error 5" in aggregator._records[0].message
def test_error_counts_updated(self):
"""Error counts should be updated correctly."""
aggregator = ErrorAggregator()
for _ in range(3):
aggregator.record_error(error=ValueError("Test"))
for _ in range(2):
aggregator.record_error(error=TypeError("Test"))
assert aggregator._error_counts["ValueError"] == 3
assert aggregator._error_counts["TypeError"] == 2
def test_plugin_error_counts_updated(self):
"""Plugin-specific error counts should be updated."""
aggregator = ErrorAggregator()
aggregator.record_error(
error=ValueError("Error 1"),
plugin_id="plugin-a"
)
aggregator.record_error(
error=ValueError("Error 2"),
plugin_id="plugin-a"
)
aggregator.record_error(
error=ValueError("Error 3"),
plugin_id="plugin-b"
)
assert aggregator._plugin_error_counts["plugin-a"]["ValueError"] == 2
assert aggregator._plugin_error_counts["plugin-b"]["ValueError"] == 1
def test_stack_trace_recorded_outside_except_block(self):
"""The trace comes from the exception, not from the handler in progress.
plugin_executor records exceptions caught on a worker thread after
its except block has ended, where format_exc() only says
"NoneType: None".
"""
def failing_plugin_update():
raise ValueError("boom")
caught = []
def worker():
try:
failing_plugin_update()
except ValueError as e:
caught.append(e)
thread = threading.Thread(target=worker)
thread.start()
thread.join()
record = ErrorAggregator().record_error(caught[0], plugin_id="p")
assert "NoneType: None" not in record.stack_trace
assert "failing_plugin_update" in record.stack_trace
assert "ValueError: boom" in record.stack_trace
def test_record_error_leaves_caller_context_unchanged(self):
"""LEDMatrixError context is merged into a copy of the caller's dict."""
context = {"caller": "value"}
error = PluginError("failed", plugin_id="p", context={"extra": 1})
record = ErrorAggregator().record_error(error, context=context)
assert context == {"caller": "value"}
assert record.context["caller"] == "value"
assert record.context["extra"] == 1
class TestPatternDetection:
"""Test error pattern detection."""
def test_pattern_detected_after_threshold(self):
"""Pattern should be detected after threshold occurrences."""
aggregator = ErrorAggregator(
pattern_threshold=3,
pattern_window_minutes=60
)
# Record 3 errors of same type
for _ in range(3):
aggregator.record_error(error=ValueError("Recurring error"))
assert "ValueError" in aggregator._patterns
def test_pattern_not_detected_below_threshold(self):
"""Pattern should not be detected below threshold."""
aggregator = ErrorAggregator(
pattern_threshold=5,
pattern_window_minutes=60
)
# Record only 2 errors
for _ in range(2):
aggregator.record_error(error=ValueError("Infrequent error"))
assert "ValueError" not in aggregator._patterns
def test_affected_plugins_stays_bounded(self):
"""Each repeat used to append every plugin in the window again, so a
plugin failing in a loop grew this list without limit (3,000 errors
from three plugins reached 2.5 million entries)."""
aggregator = ErrorAggregator(pattern_threshold=2, pattern_window_minutes=60)
for i in range(300):
aggregator.record_error(error=ValueError("loop"), plugin_id=f"p{i % 3}")
assert aggregator._patterns["ValueError"].affected_plugins == ["p0", "p1", "p2"]
assert aggregator._patterns["ValueError"].to_dict()["affected_plugins"] == ["p0", "p1", "p2"]
def test_pattern_severity_increases_with_count(self):
"""Pattern severity should increase with more occurrences."""
aggregator = ErrorAggregator(
pattern_threshold=2,
pattern_window_minutes=60
)
# Record enough to trigger critical severity
for _ in range(10):
aggregator.record_error(error=ValueError("Many errors"))
pattern = aggregator._patterns.get("ValueError")
assert pattern is not None
assert pattern.severity in ["error", "critical"]
class TestErrorSummary:
"""Test error summary generation."""
def test_summary_contains_required_fields(self):
"""Summary should contain all required fields."""
aggregator = ErrorAggregator()
aggregator.record_error(
error=ValueError("Test"),
plugin_id="test-plugin"
)
summary = aggregator.get_error_summary()
assert "session_start" in summary
assert "total_errors" in summary
assert "error_rate_per_hour" in summary
assert "error_counts_by_type" in summary
assert "plugin_error_counts" in summary
assert "active_patterns" in summary
assert "recent_errors" in summary
def test_summary_error_counts(self):
"""Summary should have correct error counts."""
aggregator = ErrorAggregator()
aggregator.record_error(error=ValueError("Error 1"))
aggregator.record_error(error=ValueError("Error 2"))
aggregator.record_error(error=TypeError("Error 3"))
summary = aggregator.get_error_summary()
assert summary["total_errors"] == 3
assert summary["error_counts_by_type"]["ValueError"] == 2
assert summary["error_counts_by_type"]["TypeError"] == 1
class TestPluginHealth:
"""Test plugin health tracking."""
def test_healthy_plugin_status(self):
"""Plugin with no recent errors should be healthy."""
aggregator = ErrorAggregator()
health = aggregator.get_plugin_health("healthy-plugin")
assert health["status"] == "healthy"
assert health["total_errors"] == 0
assert health["recent_error_count"] == 0
def test_degraded_plugin_status(self):
"""Plugin with some errors should be degraded."""
aggregator = ErrorAggregator()
for _ in range(3):
aggregator.record_error(
error=ValueError("Error"),
plugin_id="degraded-plugin"
)
health = aggregator.get_plugin_health("degraded-plugin")
assert health["status"] == "degraded"
assert health["recent_error_count"] == 3
def test_unhealthy_plugin_status(self):
"""Plugin with many errors should be unhealthy."""
aggregator = ErrorAggregator()
for _ in range(10):
aggregator.record_error(
error=ValueError("Error"),
plugin_id="unhealthy-plugin"
)
health = aggregator.get_plugin_health("unhealthy-plugin")
assert health["status"] == "unhealthy"
assert health["recent_error_count"] == 10
class TestThreadSafety:
"""Test thread safety of error aggregator."""
def test_concurrent_recording(self):
"""Multiple threads should be able to record errors concurrently."""
aggregator = ErrorAggregator(max_records=1000)
errors_per_thread = 100
num_threads = 5
def record_errors(thread_id):
for i in range(errors_per_thread):
aggregator.record_error(
error=ValueError(f"Thread {thread_id} error {i}"),
plugin_id=f"plugin-{thread_id}"
)
threads = [
threading.Thread(target=record_errors, args=(i,))
for i in range(num_threads)
]
for t in threads:
t.start()
for t in threads:
t.join()
# All errors should be recorded
assert len(aggregator._records) == errors_per_thread * num_threads
class TestGlobalAggregator:
"""Test global aggregator singleton."""
def test_get_error_aggregator_returns_same_instance(self):
"""get_error_aggregator should return the same instance."""
agg1 = get_error_aggregator()
agg2 = get_error_aggregator()
assert agg1 is agg2
def test_record_error_convenience_function(self):
"""record_error convenience function should work."""
record = record_error(
error=ValueError("Convenience function test"),
plugin_id="test"
)
assert record.error_type == "ValueError"
assert record.plugin_id == "test"
class TestSerialization:
"""Test error record serialization."""
def test_error_record_to_dict(self):
"""ErrorRecord should serialize to dict correctly."""
record = ErrorRecord(
error_type="ValueError",
message="Test message",
timestamp=datetime.now(),
context={"key": "value"},
plugin_id="test-plugin",
operation="update",
stack_trace="traceback..."
)
data = record.to_dict()
assert data["error_type"] == "ValueError"
assert data["message"] == "Test message"
assert data["plugin_id"] == "test-plugin"
assert data["operation"] == "update"
assert "timestamp" in data
def test_error_pattern_to_dict(self):
"""ErrorPattern should serialize to dict correctly."""
pattern = ErrorPattern(
error_type="ValueError",
count=5,
first_seen=datetime.now() - timedelta(hours=1),
last_seen=datetime.now(),
affected_plugins=["plugin-a", "plugin-b"],
sample_messages=["Error 1", "Error 2"],
severity="warning"
)
data = pattern.to_dict()
assert data["error_type"] == "ValueError"
assert data["count"] == 5
assert data["severity"] == "warning"
assert len(data["affected_plugins"]) == 2