"""Detector tests (D4–D16) against scripted PollOutcomes with a fake clock. Pure logic — no COM, no threads (IMPLEMENTATION_PLAN §9). """ from datetime import datetime, timedelta, timezone import pytest from opcda_diagnozer.config import ( Config, DeviceEntry, LatencyConfig, OpcConfig, StaleConfig, TagEntry, ) from opcda_diagnozer.events import EventKind, Severity from opcda_diagnozer.monitor.detectors import ( MASS_BAD_FRACTION, Detection, DetectorSuite, PollOutcome, ) from opcda_diagnozer.opc.base import ReadResult from opcda_diagnozer.quality import status_text T0 = datetime(2026, 10, 7, 12, 0, 0, tzinfo=timezone.utc) def _config(**overrides) -> Config: """A minimal valid config; overrides replace whole sections when given.""" return Config( opc=OpcConfig(server="Fake.Server.1"), tags=(TagEntry(name="T.Good", expect_change=False), TagEntry(name="T.Spin", expect_change=True)), devices=(DeviceEntry(name="Pump A", tag="T.PumpConnected", ok_values=("true",)),), latency=overrides.pop("latency", LatencyConfig()), stale=overrides.pop("stale", StaleConfig()), ) def _read(tag: str, quality: int = 0xC0, value=0, at: datetime = T0, error: str | None = None) -> ReadResult: return ReadResult(tag=tag, value=value, quality_raw=quality, device_time=at, error=error) def _outcome( rtt_ms: float = 10.0, results: tuple[ReadResult, ...] = (), error: str | None = None, at: datetime = T0, ) -> PollOutcome: return PollOutcome(started_at=at, rtt_ms=rtt_ms, results=results, error=error) # --------------------------------------------------------------------------- # # Detection basics # --------------------------------------------------------------------------- # def test_heartbeat_and_connected_are_not_anomalies() -> None: assert Detection(EventKind.HEARTBEAT, Severity.INFO).is_anomaly is False assert Detection(EventKind.CONNECTED, Severity.INFO).is_anomaly is False assert Detection(EventKind.QUALITY_BAD, Severity.WARN).is_anomaly is True def test_status_text_clear_text_with_code_in_parens() -> None: assert status_text(0xC0) == "GOOD (0xC0)" assert status_text(0x18) == "BAD / COMM FAILURE (0x18)" # --------------------------------------------------------------------------- # # D4/D5/D6 — latency # --------------------------------------------------------------------------- # def test_d4_slow_poll_spike() -> None: suite = DetectorSuite(_config()) detections = suite.observe(_outcome(rtt_ms=1500.0)) # > warn_ms 1000 kinds = [d.kind for d in detections] assert EventKind.SLOW_POLL in kinds spike = next(d for d in detections if d.kind is EventKind.SLOW_POLL) assert spike.severity is Severity.WARN assert spike.detail["rtt_ms"] == 1500.0 def test_d4_no_spike_below_threshold() -> None: suite = DetectorSuite(_config()) assert all( d.kind is not EventKind.SLOW_POLL for d in suite.observe(_outcome(rtt_ms=999.9)) ) def test_d5_streak_after_consecutive_slow_polls() -> None: suite = DetectorSuite(_config()) for _ in range(4): # streak_limit 5: not yet suite.observe(_outcome(rtt_ms=1200.0)) detections = suite.observe(_outcome(rtt_ms=1200.0)) # 5th consecutive streak = next(d for d in detections if d.kind is EventKind.SLOW_POLL_STREAK) assert streak.severity is Severity.CRITICAL assert streak.detail["streak"] == 5 # one fast poll resets the streak suite.observe(_outcome(rtt_ms=10.0)) suite.observe(_outcome(rtt_ms=1200.0)) detections = suite.observe(_outcome(rtt_ms=1200.0)) assert all(d.kind is not EventKind.SLOW_POLL_STREAK for d in detections) def test_d6_p95_trend_over_window() -> None: suite = DetectorSuite(_config()) # window_polls=60: fill with fast polls plus a few slow outliers for _ in range(56): suite.observe(_outcome(rtt_ms=50.0)) for _ in range(4): suite.observe(_outcome(rtt_ms=2000.0)) detections = suite.observe(_outcome(rtt_ms=50.0)) trend = next(d for d in detections if d.kind is EventKind.LATENCY_TREND) assert trend.detail["rtt_p95_ms"] == 2000.0 def test_failed_poll_resets_streak_but_keeps_window() -> None: suite = DetectorSuite(_config()) for _ in range(3): suite.observe(_outcome(rtt_ms=1200.0)) # a failed poll carries no RTT evidence: streak resets, no spike events detections = suite.observe(_outcome(rtt_ms=99999.0, error="RPC call failed")) assert all(d.kind not in (EventKind.SLOW_POLL, EventKind.SLOW_POLL_STREAK) for d in detections) suite.observe(_outcome(rtt_ms=1200.0)) assert all(d.kind is not EventKind.SLOW_POLL_STREAK for d in suite.observe(_outcome(rtt_ms=1200.0))) # --------------------------------------------------------------------------- # # D8/D9/D10/D11 — quality # --------------------------------------------------------------------------- # def test_d8_bad_transition_with_clear_text_status() -> None: suite = DetectorSuite(_config()) suite.observe(_outcome(results=(_read("T.Good", 0xC0),))) detections = suite.observe(_outcome(results=(_read("T.Good", 0x18),))) bad = next(d for d in detections if d.kind is EventKind.QUALITY_BAD) assert bad.severity is Severity.WARN assert bad.detail["tag"] == "T.Good" assert bad.detail["quality"] == "BAD / COMM FAILURE (0x18)" assert bad.detail["previous_quality"] == "GOOD" def test_d9_uncertain_transition_is_info() -> None: suite = DetectorSuite(_config()) suite.observe(_outcome(results=(_read("T.Good", 0xC0),))) detections = suite.observe(_outcome(results=(_read("T.Good", 0x44),))) uncertain = next(d for d in detections if d.kind is EventKind.QUALITY_UNCERTAIN) assert uncertain.severity is Severity.INFO def test_no_synthetic_transition_on_first_reading() -> None: """A tag that starts BAD reports no transition until it first recovers.""" suite = DetectorSuite(_config()) detections = suite.observe(_outcome(results=(_read("T.Good", 0x00),))) assert all(d.kind is not EventKind.QUALITY_BAD for d in detections) def test_d10_persistent_bad_escalates_after_threshold() -> None: suite = DetectorSuite(_config()) # persistent_bad_s 30 for second in range(0, 29, 2): # 15 polls < 30 s: not yet suite.observe(_outcome(results=(_read("T.Good", 0x18),), at=T0 + timedelta(seconds=second))) if second: continue # exactly at 30 s detections = suite.observe( _outcome(results=(_read("T.Good", 0x18),), at=T0 + timedelta(seconds=30)) ) persistent = next(d for d in detections if d.kind is EventKind.QUALITY_BAD_PERSISTENT) assert persistent.severity is Severity.WARN assert persistent.detail["bad_for_s"] == 30.0 # reported once, not every poll detections = suite.observe( _outcome(results=(_read("T.Good", 0x18),), at=T0 + timedelta(seconds=32)) ) assert all(d.kind is not EventKind.QUALITY_BAD_PERSISTENT for d in detections) # recovery resets the persistence latch suite.observe(_outcome(results=(_read("T.Good", 0xC0),), at=T0 + timedelta(seconds=34))) suite.observe(_outcome(results=(_read("T.Good", 0x18),), at=T0 + timedelta(seconds=36))) detections = suite.observe( _outcome(results=(_read("T.Good", 0x18),), at=T0 + timedelta(seconds=70)) ) assert any(d.kind is EventKind.QUALITY_BAD_PERSISTENT for d in detections) def test_d11_flapping_quality() -> None: suite = DetectorSuite(_config()) # >5 transitions per 600 s window t = T0 for quality in (0xC0, 0x18, 0xC0, 0x18, 0xC0, 0x18): t += timedelta(seconds=5) suite.observe(_outcome(results=(_read("T.Good", quality),), at=t)) # 6 transitions happened; the 6th poll reports flapping detections = suite.quality.observe(_outcome(results=(_read("T.Good", 0xC0),), at=t)) assert any(d.kind is EventKind.QUALITY_FLAPPING for d in detections) # --------------------------------------------------------------------------- # # D12/D13 — stale & frozen # --------------------------------------------------------------------------- # def test_d12_stale_timestamp_while_good() -> None: suite = DetectorSuite(_config()) # max_age_s 120 stale_device_time = T0 - timedelta(seconds=200) detections = suite.observe( _outcome( results=(_read("T.Good", 0xC0, at=stale_device_time),), at=T0, ) ) stale = next(d for d in detections if d.kind is EventKind.STALE_TIMESTAMP) assert stale.detail["age_s"] == 200.0 assert stale.detail["max_age_s"] == 120 def test_d12_ignores_stale_when_quality_not_good() -> None: suite = DetectorSuite(_config()) stale_device_time = T0 - timedelta(seconds=200) detections = suite.observe( _outcome(results=(_read("T.Good", 0x18, at=stale_device_time),), at=T0) ) assert all(d.kind is not EventKind.STALE_TIMESTAMP for d in detections) def test_d13_frozen_value_on_expect_change_tag() -> None: suite = DetectorSuite(_config()) # T.Spin has expect_change frozen_time = T0 - timedelta(seconds=150) first = suite.observe(_outcome(results=(_read("T.Spin", 0xC0, value=7, at=frozen_time),), at=T0)) second = suite.observe( _outcome(results=(_read("T.Spin", 0xC0, value=7, at=frozen_time),), at=T0 + timedelta(seconds=1)) ) assert all(d.kind is not EventKind.FROZEN_VALUE for d in first) # needs history frozen = next(d for d in second if d.kind is EventKind.FROZEN_VALUE) assert frozen.detail["tag"] == "T.Spin" assert frozen.detail["value"] == 7 def test_d13_not_frozen_when_device_time_advances() -> None: suite = DetectorSuite(_config()) t_device = T0 - timedelta(seconds=100) suite.observe(_outcome(results=(_read("T.Spin", 0xC0, value=7, at=t_device),), at=T0)) detections = suite.observe( _outcome( results=(_read("T.Spin", 0xC0, value=7, at=t_device + timedelta(seconds=10)),), at=T0 + timedelta(seconds=1), ) ) assert all(d.kind is not EventKind.FROZEN_VALUE for d in detections) # --------------------------------------------------------------------------- # # D14 — mass bad # --------------------------------------------------------------------------- # def test_d14_mass_bad_when_half_or_more_bad() -> None: config = _config() suite = DetectorSuite(config) tags = ("A", "B", "C", "D") results = tuple(_read(t, 0x18) for t in tags) # 100% BAD detections = suite.observe(_outcome(results=results)) mass = next(d for d in detections if d.kind is EventKind.CONTROL_LINK_DEGRADED) assert mass.severity is Severity.CRITICAL assert mass.detail == {"tags_bad": 4, "tags_total": 4} # latched: not re-emitted while still degraded assert all( d.kind is not EventKind.CONTROL_LINK_DEGRADED for d in suite.observe(_outcome(results=results)) ) # recovery re-arms the latch good = tuple(_read(t, 0xC0) for t in tags) suite.observe(_outcome(results=good)) assert any( d.kind is EventKind.CONTROL_LINK_DEGRADED for d in suite.observe(_outcome(results=results)) ) def test_d14_below_half_is_not_mass_bad() -> None: suite = DetectorSuite(_config()) results = (_read("A", 0x18), _read("B", 0xC0), _read("C", 0xC0), _read("D", 0xC0)) assert all( d.kind is not EventKind.CONTROL_LINK_DEGRADED for d in suite.observe(_outcome(results=results)) ) def test_mass_bad_fraction_is_half() -> None: assert MASS_BAD_FRACTION == 0.5 # --------------------------------------------------------------------------- # # D16 — device link indicators # --------------------------------------------------------------------------- # def _pump(at: datetime = T0, quality: int = 0xC0, value=True, error: str | None = None) -> ReadResult: return _read("T.PumpConnected", quality, value, at, error) def test_d16_false_value_is_down() -> None: suite = DetectorSuite(_config()) suite.observe(_outcome(results=(_pump(),))) # normal first detections = suite.observe(_outcome(results=(_pump(value=False),))) down = next(d for d in detections if d.kind is EventKind.DEVICE_LINK_DOWN) assert down.severity is Severity.CRITICAL assert down.detail["device"] == "Pump A" assert down.detail["reason"] == "read 'false', expected one of ['true']" assert suite.devices_down() == 1 def test_d16_non_good_quality_is_down_regardless_of_value() -> None: suite = DetectorSuite(_config()) detections = suite.observe(_outcome(results=(_pump(quality=0x08),))) # BAD not connected down = next(d for d in detections if d.kind is EventKind.DEVICE_LINK_DOWN) assert "quality BAD / NOT CONNECTED (0x08)" in down.detail["reason"] def test_d16_read_error_is_down() -> None: suite = DetectorSuite(_config()) detections = suite.observe( _outcome(results=(_pump(error="cannot add tag 'T.PumpConnected'"),)) ) assert any(d.kind is EventKind.DEVICE_LINK_DOWN for d in detections) def test_d16_recovery_edge_level_no_spam() -> None: suite = DetectorSuite(_config()) suite.observe(_outcome(results=(_pump(),))) suite.observe(_outcome(results=(_pump(value=False),))) # DOWN # staying down: exactly one DOWN so far, no repeats down_count = sum( 1 for events in ( suite.observe(_outcome(results=(_pump(value=False),))), suite.observe(_outcome(results=(_pump(value=False),))), ) for d in events if d.kind is EventKind.DEVICE_LINK_DOWN ) assert down_count == 0 # recovery detections = suite.observe(_outcome(results=(_pump(),))) up = next(d for d in detections if d.kind is EventKind.DEVICE_LINK_UP) assert up.severity is Severity.INFO assert up.detail["device"] == "Pump A" assert suite.devices_down() == 0 def test_d16_rendering_matches_config_normalization() -> None: """The comparison string matches the config's ok_values rendering (True->'true').""" config = _config() suite = DetectorSuite(config) detections = suite.observe(_outcome(results=(_pump(),))) # Python True assert all(d.kind is not EventKind.DEVICE_LINK_DOWN for d in detections) detections = suite.observe(_outcome(results=(_read("T.PumpConnected", 0xC0, "true"),))) assert all(d.kind is not EventKind.DEVICE_LINK_DOWN for d in detections) def test_d16_first_reading_already_down_reports_once() -> None: """No synthetic 'was up' baseline: a device born down reports immediately.""" suite = DetectorSuite(_config()) detections = suite.observe(_outcome(results=(_pump(value=False),))) assert any(d.kind is EventKind.DEVICE_LINK_DOWN for d in detections) # --------------------------------------------------------------------------- # # suite helpers (heartbeat context) # --------------------------------------------------------------------------- # def test_suite_helpers_expose_heartbeat_inputs() -> None: suite = DetectorSuite(_config()) suite.observe(_outcome(results=(_read("T.Good", 0x18),))) assert suite.tags_bad() == 1 assert suite.devices_down() == 0 assert suite.rtt_p95_ms() == 10.0 @pytest.mark.parametrize( "raw, expected", [ (0xC0, "GOOD (0xC0)"), (0xD8, "GOOD / LOCAL OVERRIDE (0xD8)"), (0x04, "BAD / CONFIG ERROR (0x04)"), (0x44, "UNCERTAIN / LAST USABLE (0x44)"), (0x01, "BAD / 0x01 (0x01)"), # unknown subcode: class + raw label ], ) def test_status_text_table(raw: int, expected: str) -> None: assert status_text(raw) == expected