🚧 WIP
This commit is contained in:
+387
-13
@@ -1,20 +1,394 @@
|
||||
"""Detector tests (SPECIFICATION §6). Detector implementations land in Phase 1."""
|
||||
"""Detector tests (D4–D16) against scripted PollOutcomes with a fake clock.
|
||||
|
||||
from datetime import datetime, timezone
|
||||
Pure logic — no COM, no threads (IMPLEMENTATION_PLAN §9).
|
||||
"""
|
||||
|
||||
from opcda_diagnozer.monitor.detectors import PollOutcome
|
||||
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 test_poll_outcome_holds_one_cycle() -> None:
|
||||
result = ReadResult(
|
||||
tag="Channel1.Device1.Level", value=1.5, quality_raw=0xC0, device_time=None
|
||||
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()),
|
||||
)
|
||||
outcome = PollOutcome(
|
||||
started_at=datetime.now(timezone.utc),
|
||||
rtt_ms=12.5,
|
||||
results=(result,),
|
||||
|
||||
|
||||
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))
|
||||
)
|
||||
assert outcome.rtt_ms == 12.5
|
||||
assert outcome.error is None
|
||||
assert outcome.results[0].tag == "Channel1.Device1.Level"
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user