2026-05-14 22:43:06 +00:00
|
|
|
"""Tests for Phase 2.2 inhibitor and grouper."""
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from unittest.mock import Mock
|
|
|
|
|
|
|
|
|
|
from meshai.notifications.events import Event
|
|
|
|
|
from meshai.notifications.pipeline.inhibitor import Inhibitor
|
|
|
|
|
from meshai.notifications.pipeline.grouper import Grouper
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def make_event(id, severity, inhibit_keys=None, group_key=None):
|
|
|
|
|
return Event(
|
|
|
|
|
id=id,
|
|
|
|
|
source="test",
|
|
|
|
|
category="test_cat",
|
|
|
|
|
severity=severity,
|
|
|
|
|
title=f"Event {id}",
|
|
|
|
|
inhibit_keys=inhibit_keys or [],
|
|
|
|
|
group_key=group_key,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===================== INHIBITOR TESTS =====================
|
|
|
|
|
|
|
|
|
|
class TestInhibitor:
|
|
|
|
|
|
|
|
|
|
def test_event_without_inhibit_keys_passes_through(self):
|
|
|
|
|
next_handler = Mock()
|
|
|
|
|
inhibitor = Inhibitor(next_handler)
|
|
|
|
|
event = make_event("e1", "immediate", inhibit_keys=[])
|
|
|
|
|
inhibitor.handle(event)
|
|
|
|
|
next_handler.assert_called_once_with(event)
|
|
|
|
|
|
|
|
|
|
def test_lower_severity_after_higher_is_suppressed(self):
|
|
|
|
|
next_handler = Mock()
|
|
|
|
|
inhibitor = Inhibitor(next_handler)
|
|
|
|
|
ev1 = make_event("e1", "immediate", inhibit_keys=["battery:NODE1"])
|
|
|
|
|
ev2 = make_event("e2", "priority", inhibit_keys=["battery:NODE1"])
|
|
|
|
|
inhibitor.handle(ev1)
|
|
|
|
|
inhibitor.handle(ev2)
|
|
|
|
|
assert next_handler.call_count == 1
|
|
|
|
|
next_handler.assert_called_once_with(ev1)
|
|
|
|
|
|
|
|
|
|
def test_equal_severity_is_suppressed(self):
|
|
|
|
|
next_handler = Mock()
|
|
|
|
|
inhibitor = Inhibitor(next_handler)
|
|
|
|
|
ev1 = make_event("e1", "priority", inhibit_keys=["key1"])
|
|
|
|
|
ev2 = make_event("e2", "priority", inhibit_keys=["key1"])
|
|
|
|
|
inhibitor.handle(ev1)
|
|
|
|
|
inhibitor.handle(ev2)
|
|
|
|
|
assert next_handler.call_count == 1
|
|
|
|
|
|
|
|
|
|
def test_higher_severity_after_lower_passes_and_upgrades(self):
|
|
|
|
|
next_handler = Mock()
|
|
|
|
|
inhibitor = Inhibitor(next_handler)
|
|
|
|
|
ev1 = make_event("e1", "priority", inhibit_keys=["key1"])
|
|
|
|
|
ev2 = make_event("e2", "immediate", inhibit_keys=["key1"])
|
|
|
|
|
inhibitor.handle(ev1)
|
|
|
|
|
inhibitor.handle(ev2)
|
|
|
|
|
assert next_handler.call_count == 2
|
|
|
|
|
keys = inhibitor.active_keys()
|
|
|
|
|
assert keys["key1"][0] == 2 # immediate rank
|
|
|
|
|
|
|
|
|
|
def test_inhibit_key_expires_after_ttl(self):
|
|
|
|
|
next_handler = Mock()
|
|
|
|
|
inhibitor = Inhibitor(next_handler, ttl_seconds=10)
|
|
|
|
|
current_time = [0.0]
|
|
|
|
|
inhibitor._now = lambda: current_time[0]
|
|
|
|
|
|
|
|
|
|
ev1 = make_event("e1", "immediate", inhibit_keys=["key1"])
|
|
|
|
|
current_time[0] = 0.0
|
|
|
|
|
inhibitor.handle(ev1)
|
|
|
|
|
|
|
|
|
|
current_time[0] = 15.0
|
|
|
|
|
ev2 = make_event("e2", "routine", inhibit_keys=["key1"])
|
|
|
|
|
inhibitor.handle(ev2)
|
|
|
|
|
|
|
|
|
|
assert next_handler.call_count == 2
|
|
|
|
|
|
|
|
|
|
def test_multiple_keys_any_active_suppresses(self):
|
|
|
|
|
next_handler = Mock()
|
|
|
|
|
inhibitor = Inhibitor(next_handler)
|
|
|
|
|
|
|
|
|
|
ev1 = make_event("e1", "immediate", inhibit_keys=["a", "b"])
|
|
|
|
|
inhibitor.handle(ev1)
|
|
|
|
|
assert next_handler.call_count == 1
|
|
|
|
|
|
|
|
|
|
ev2 = make_event("e2", "routine", inhibit_keys=["b", "c"])
|
|
|
|
|
inhibitor.handle(ev2)
|
|
|
|
|
assert next_handler.call_count == 1 # suppressed by "b"
|
|
|
|
|
|
|
|
|
|
ev3 = make_event("e3", "routine", inhibit_keys=["c", "d"])
|
|
|
|
|
inhibitor.handle(ev3)
|
|
|
|
|
assert next_handler.call_count == 2 # passes, no active key
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===================== GROUPER TESTS =====================
|
|
|
|
|
|
|
|
|
|
class TestGrouper:
|
|
|
|
|
|
|
|
|
|
def test_event_without_group_key_emits_immediately(self):
|
|
|
|
|
next_handler = Mock()
|
|
|
|
|
grouper = Grouper(next_handler)
|
|
|
|
|
event = make_event("e1", "immediate", group_key=None)
|
|
|
|
|
grouper.handle(event)
|
|
|
|
|
next_handler.assert_called_once_with(event)
|
|
|
|
|
|
|
|
|
|
def test_event_with_group_key_is_held_not_emitted(self):
|
|
|
|
|
next_handler = Mock()
|
|
|
|
|
grouper = Grouper(next_handler)
|
|
|
|
|
event = make_event("e1", "immediate", group_key="fire:42")
|
|
|
|
|
grouper.handle(event)
|
|
|
|
|
next_handler.assert_not_called()
|
|
|
|
|
assert grouper.held_count() == 1
|
|
|
|
|
|
|
|
|
|
def test_second_same_group_key_replaces_first(self):
|
|
|
|
|
next_handler = Mock()
|
|
|
|
|
grouper = Grouper(next_handler)
|
|
|
|
|
ev1 = make_event("e1", "immediate", group_key="fire:42")
|
|
|
|
|
ev2 = make_event("e2", "immediate", group_key="fire:42")
|
|
|
|
|
grouper.handle(ev1)
|
|
|
|
|
grouper.handle(ev2)
|
|
|
|
|
next_handler.assert_not_called()
|
|
|
|
|
assert grouper.held_count() == 1
|
|
|
|
|
grouper.flush_all()
|
|
|
|
|
assert next_handler.call_count == 1
|
|
|
|
|
emitted_event = next_handler.call_args[0][0]
|
|
|
|
|
assert emitted_event.id == "e2"
|
|
|
|
|
|
|
|
|
|
def test_tick_emits_when_window_expired(self):
|
|
|
|
|
next_handler = Mock()
|
|
|
|
|
grouper = Grouper(next_handler, window_seconds=5)
|
|
|
|
|
current_time = [0.0]
|
|
|
|
|
grouper._now = lambda: current_time[0]
|
|
|
|
|
|
|
|
|
|
current_time[0] = 0.0
|
|
|
|
|
event = make_event("e1", "immediate", group_key="g")
|
|
|
|
|
grouper.handle(event)
|
|
|
|
|
assert grouper.held_count() == 1
|
|
|
|
|
|
|
|
|
|
current_time[0] = 3.0
|
|
|
|
|
grouper.tick()
|
|
|
|
|
next_handler.assert_not_called()
|
|
|
|
|
assert grouper.held_count() == 1
|
|
|
|
|
|
|
|
|
|
current_time[0] = 10.0
|
|
|
|
|
grouper.tick()
|
|
|
|
|
next_handler.assert_called_once()
|
|
|
|
|
assert grouper.held_count() == 0
|
|
|
|
|
|
|
|
|
|
def test_flush_all_emits_everything_immediately(self):
|
|
|
|
|
next_handler = Mock()
|
|
|
|
|
grouper = Grouper(next_handler)
|
|
|
|
|
ev1 = make_event("e1", "immediate", group_key="g1")
|
|
|
|
|
ev2 = make_event("e2", "immediate", group_key="g2")
|
|
|
|
|
ev3 = make_event("e3", "immediate", group_key="g3")
|
|
|
|
|
grouper.handle(ev1)
|
|
|
|
|
grouper.handle(ev2)
|
|
|
|
|
grouper.handle(ev3)
|
|
|
|
|
assert grouper.held_count() == 3
|
|
|
|
|
grouper.flush_all()
|
|
|
|
|
assert next_handler.call_count == 3
|
|
|
|
|
assert grouper.held_count() == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===================== INTEGRATION TEST =====================
|
|
|
|
|
|
|
|
|
|
class TestInhibitorGrouperChain:
|
|
|
|
|
|
|
|
|
|
def test_inhibitor_then_grouper_chain(self):
|
|
|
|
|
terminal = Mock()
|
|
|
|
|
grouper = Grouper(next_handler=terminal)
|
|
|
|
|
inhibitor = Inhibitor(next_handler=grouper.handle)
|
|
|
|
|
|
|
|
|
|
# Send immediate event with group_key and inhibit_keys
|
|
|
|
|
ev1 = make_event("e1", "immediate", group_key="g1", inhibit_keys=["k1"])
|
|
|
|
|
inhibitor.handle(ev1)
|
|
|
|
|
# After inhibitor: passed (no prior key)
|
|
|
|
|
# After grouper: held (group_key present)
|
|
|
|
|
terminal.assert_not_called()
|
|
|
|
|
assert grouper.held_count() == 1
|
|
|
|
|
|
|
|
|
|
# Send routine event with same group_key and inhibit_keys
|
|
|
|
|
ev2 = make_event("e2", "routine", group_key="g1", inhibit_keys=["k1"])
|
|
|
|
|
inhibitor.handle(ev2)
|
|
|
|
|
# After inhibitor: SUPPRESSED (k1 active at higher rank)
|
|
|
|
|
terminal.assert_not_called()
|
|
|
|
|
assert grouper.held_count() == 1 # still 1, not 2
|
|
|
|
|
|
|
|
|
|
# Flush grouper
|
|
|
|
|
grouper.flush_all()
|
|
|
|
|
terminal.assert_called_once()
|
|
|
|
|
emitted = terminal.call_args[0][0]
|
|
|
|
|
assert emitted.id == "e1" # the immediate, not suppressed routine
|