"""Tests for NSCT metrics system (self-built, no prometheus-client required).""" from __future__ import annotations import os import threading import time import pytest # Ensure src is on path import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) from nsct import metrics class TestCounters: """Test counter operations.""" def setup_method(self) -> None: """Reset counters before each test.""" from nsct.metrics import _counters, _lock with _lock: _counters.clear() def test_increment(self) -> None: """Counter erhöht sich um 1.""" val = metrics.increment("test_counter") assert val == 1 val = metrics.increment("test_counter") assert val == 2 def test_increment_amount(self) -> None: """Counter erhöht sich um den angegebenen Betrag.""" val = metrics.increment("test_counter", 5) assert val == 5 val = metrics.increment("test_counter", 3) assert val == 8 def test_get_counter_zero(self) -> None: """Nicht existenter Counter gibt 0 zurück.""" assert metrics.get_counter("nonexistent") == 0 def test_counter_persistence(self) -> None: """Counter-Wert wird über mehrere Aufrufe beibehalten.""" metrics.increment("persist", 10) assert metrics.get_counter("persist") == 10 class TestHistograms: """Test histogram operations.""" def setup_method(self) -> None: from nsct.metrics import _histograms, _lock with _lock: _histograms.clear() def test_observe(self) -> None: """Beobachtung wird im Histogramm gespeichert.""" metrics.observe("request_seconds", 0.5) val = metrics.get_counter("request_seconds_count") if hasattr(metrics, 'get_counter') else None assert metrics.observe("request_seconds", 1.0) == 1.0 def test_observe_bucket(self) -> None: """Beobachtungen werden in die korrekten Buckets gezählt.""" metrics.observe("test_hist", 0.3) metrics.observe("test_hist", 0.8) metrics.observe("test_hist", 2.5) from nsct.metrics import _histograms, _lock with _lock: h = _histograms["test_hist"] assert h["_count"] == 3 assert h["_sum"] == 3.6 def test_histogram_context_manager(self) -> None: """Context manager misst die Dauer korrekt.""" from nsct.metrics import _histograms, _lock # Clear histogram state with _lock: _histograms["hist_test"] = {"_count": 0, "_sum": 0.0, "buckets": {str(b): 0 for b in (0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 25.0, 50.0, 100.0)}} with metrics.histogram("hist_test"): time.sleep(0.05) with _lock: h = _histograms["hist_test"] assert h["_count"] == 1 assert h["_sum"] >= 0.05 def test_default_buckets(self) -> None: """Histogram hat die Standard-Buckets.""" from nsct.metrics import _histograms, _lock metrics.observe("bucket_test", 0.5) with _lock: h = _histograms["bucket_test"] assert "0.5" in h["buckets"] assert "1.0" in h["buckets"] class TestGauges: """Test gauge operations.""" def setup_method(self) -> None: from nsct.metrics import _gauges, _lock with _lock: _gauges.clear() def test_gauge_set_and_get(self) -> None: """Gauge-Wert wird gesetzt und gelesen.""" metrics.gauge("active_runs", 5) assert metrics.get_gauge("active_runs") == 5.0 def test_gauge_default_zero(self) -> None: """Nicht gesetzter Gauge gibt 0.0 zurück.""" assert metrics.get_gauge("nonexistent") == 0.0 def test_gauge_overwrite(self) -> None: """Gauge-Wert wird überschrieben.""" metrics.gauge("x", 1) metrics.gauge("x", 10) assert metrics.get_gauge("x") == 10.0 class TestPrometheusExport: """Test Prometheus text format rendering.""" def setup_method(self) -> None: from nsct.metrics import _counters, _histograms, _gauges, _lock with _lock: _counters.clear() _histograms.clear() _gauges.clear() def test_render_empty(self) -> None: """Leere Metrics geben nur eine leere Zeile zurück.""" text = metrics.render_prometheus() # Empty string or just newline is acceptable assert text.strip() == "" def test_render_counter(self) -> None: """Counter werden korrekt exportiert.""" metrics.increment("my_counter", 42) text = metrics.render_prometheus() assert "my_counter 42" in text assert "# HELP my_counter" in text assert "# TYPE my_counter counter" in text def test_render_histogram(self) -> None: """Histogram wird korrekt exportiert.""" metrics.observe("my_hist", 0.1) metrics.observe("my_hist", 1.0) text = metrics.render_prometheus() assert "my_hist_count 2" in text assert "my_hist_sum 1.1" in text assert "# TYPE my_hist histogram" in text assert 'my_hist_bucket{le="+Inf"}' in text def test_render_gauge(self) -> None: """Gauge wird korrekt exportiert.""" metrics.gauge("my_gauge", 7) text = metrics.render_prometheus() assert "my_gauge 7.0" in text assert "# TYPE my_gauge gauge" in text def test_render_combined(self) -> None: """Kombinierter Export aller Metrik-Typen.""" metrics.increment("search_queries_total", 100) metrics.increment("sources_fetched_total", 50) metrics.increment("claims_extracted_total", 25) metrics.increment("contradictions_detected_total", 5) metrics.increment("research_completed_total", 8) metrics.increment("research_failed_total", 2) metrics.observe("research_duration_seconds", 2.5) metrics.observe("llm_request_duration_seconds", 0.3) metrics.gauge("active_research_runs", 3) text = metrics.render_prometheus() assert "search_queries_total 100" in text assert "sources_fetched_total 50" in text assert "claims_extracted_total 25" in text assert "contradictions_detected_total 5" in text assert "research_completed_total 8" in text assert "research_failed_total 2" in text assert "research_duration_seconds_count 1" in text assert "llm_request_duration_seconds_count 1" in text assert "active_research_runs 3.0" in text def test_render_thread_safety(self) -> None: """render_prometheus ist thread-sicher.""" errors = [] def worker() -> None: try: for _ in range(100): metrics.increment("concurrent", 1) metrics.render_prometheus() except Exception as e: errors.append(str(e)) threads = [threading.Thread(target=worker) for _ in range(4)] for t in threads: t.start() for t in threads: t.join() assert errors == [] class TestConstants: """Test pre-defined metric name constants.""" def test_constant_values(self) -> None: """Konstanten sind korrekt definiert.""" from nsct.metrics import ( C_SEARCH_QUERIES_TOTAL, C_SOURCES_FETCHED_TOTAL, C_CLAIMS_EXTRACTED_TOTAL, C_CONTRADICTIONS_DETECTED_TOTAL, C_RESEARCH_COMPLETED_TOTAL, C_RESEARCH_FAILED_TOTAL, H_RESEARCH_DURATION, H_LLM_REQUEST_DURATION, G_ACTIVE_RESEARCH_RUNS, ) assert C_SEARCH_QUERIES_TOTAL == "search_queries_total" assert C_SOURCES_FETCHED_TOTAL == "sources_fetched_total" assert C_CLAIMS_EXTRACTED_TOTAL == "claims_extracted_total" assert C_CONTRADICTIONS_DETECTED_TOTAL == "contradictions_detected_total" assert C_RESEARCH_COMPLETED_TOTAL == "research_completed_total" assert C_RESEARCH_FAILED_TOTAL == "research_failed_total" assert H_RESEARCH_DURATION == "research_duration_seconds" assert H_LLM_REQUEST_DURATION == "llm_request_duration_seconds" assert G_ACTIVE_RESEARCH_RUNS == "active_research_runs" def test_constants_on_metrics_instance(self) -> None: """Konstanten sind auch auf metrics-Instanz verfügbar.""" assert metrics.C_SEARCH_QUERIES_TOTAL == "search_queries_total" assert metrics.H_RESEARCH_DURATION == "research_duration_seconds" class TestMetricsIntegration: """Integration tests — realistic pipeline usage.""" def setup_method(self) -> None: from nsct.metrics import _counters, _histograms, _gauges, _lock with _lock: _counters.clear() _histograms.clear() _gauges.clear() def test_full_pipeline_metrics(self) -> None: """Simuliert einen vollständigen Pipeline-Durchlauf.""" # Search phase metrics.increment("search_queries_total", 5) # Fetch phase metrics.increment("sources_fetched_total", 3) # Extract phase metrics.increment("claims_extracted_total", 12) # Contradiction detected metrics.increment("contradictions_detected_total", 2) # Research completed metrics.observe("research_duration_seconds", 3.2) metrics.increment("research_completed_total", 1) # Active runs metrics.gauge("active_research_runs", 0) # Verify export text = metrics.render_prometheus() assert "search_queries_total 5" in text assert "sources_fetched_total 3" in text assert "claims_extracted_total 12" in text assert "contradictions_detected_total 2" in text assert "research_completed_total 1" in text assert "active_research_runs 0.0" in text assert "research_duration_seconds_count 1" in text def test_no_new_dependencies(self) -> None: """metrics.py hat keine externen Abhängigkeiten.""" from nsct import metrics as mod import inspect source = inspect.getsource(mod) assert "import prometheus" not in source assert "from prometheus" not in source assert "import loguru" not in source # Should only import stdlib assert "import threading" in source assert "import time" in source