mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
build: normalize all line endings to LF
One-time renormalization pass under the .gitattributes added in the previous commit. Every tracked text file now uses LF. No semantic changes — verified via git diff --cached --ignore-all-space showing zero real differences. Future diffs will only show real content changes. This commit will appear huge in git log --stat but represents zero behavior change. Use git log --follow --ignore-all-space or git blame -w when archaeologically tracing through this commit.
This commit is contained in:
parent
211c642b60
commit
d6bc6b2b89
46 changed files with 11450 additions and 11450 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -1,194 +1,194 @@
|
|||
"""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
|
||||
"""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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,212 +1,212 @@
|
|||
"""Test cases for Phase 2.1 notification pipeline skeleton.
|
||||
|
||||
These tests verify the core routing and dispatch behavior of the
|
||||
notification pipeline without requiring real channel backends.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from meshai.notifications.events import Event, make_event
|
||||
from meshai.notifications.pipeline import build_pipeline_components
|
||||
from meshai.notifications.pipeline.bus import EventBus
|
||||
from meshai.notifications.pipeline.dispatcher import Dispatcher
|
||||
from meshai.notifications.pipeline.severity_router import SeverityRouter, StubDigestQueue
|
||||
|
||||
|
||||
# Minimal config stubs for testing
|
||||
@dataclass
|
||||
class NotificationRuleConfigStub:
|
||||
name: str = "test_rule"
|
||||
enabled: bool = True
|
||||
trigger_type: str = "condition"
|
||||
categories: list = field(default_factory=list)
|
||||
min_severity: str = "routine"
|
||||
delivery_type: str = "mesh_broadcast"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NotificationsConfigStub:
|
||||
rules: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConfigStub:
|
||||
notifications: NotificationsConfigStub = field(default_factory=NotificationsConfigStub)
|
||||
|
||||
|
||||
class TestImmediateDispatch:
|
||||
|
||||
def test_immediate_event_with_matching_rule_dispatches(self):
|
||||
rule = NotificationRuleConfigStub(
|
||||
enabled=True,
|
||||
trigger_type="condition",
|
||||
categories=["test_cat"],
|
||||
min_severity="routine",
|
||||
delivery_type="mesh_broadcast",
|
||||
)
|
||||
config = ConfigStub(
|
||||
notifications=NotificationsConfigStub(rules=[rule])
|
||||
)
|
||||
mock_channel = Mock()
|
||||
mock_factory = Mock(return_value=mock_channel)
|
||||
bus = EventBus()
|
||||
dispatcher = Dispatcher(config, mock_factory)
|
||||
digest = StubDigestQueue()
|
||||
router = SeverityRouter(
|
||||
immediate_handler=dispatcher.dispatch,
|
||||
digest_handler=digest.enqueue,
|
||||
)
|
||||
bus.subscribe(router.handle)
|
||||
event = make_event(
|
||||
source="test",
|
||||
category="test_cat",
|
||||
severity="immediate",
|
||||
title="Test Alert",
|
||||
summary="Test summary message",
|
||||
)
|
||||
bus.emit(event)
|
||||
assert mock_channel.deliver.call_count == 1
|
||||
alert = mock_channel.deliver.call_args[0][0]
|
||||
assert alert["category"] == "test_cat"
|
||||
assert alert["severity"] == "immediate"
|
||||
assert alert["message"]
|
||||
|
||||
|
||||
class TestDigestRouting:
|
||||
|
||||
def test_routine_event_goes_to_digest_not_dispatcher(self):
|
||||
rule = NotificationRuleConfigStub(
|
||||
enabled=True,
|
||||
trigger_type="condition",
|
||||
categories=["test_cat"],
|
||||
min_severity="routine",
|
||||
)
|
||||
config = ConfigStub(
|
||||
notifications=NotificationsConfigStub(rules=[rule])
|
||||
)
|
||||
mock_factory = Mock()
|
||||
bus = EventBus()
|
||||
dispatcher = Dispatcher(config, mock_factory)
|
||||
digest = StubDigestQueue()
|
||||
with patch.object(dispatcher, "dispatch", wraps=dispatcher.dispatch) as mock_dispatch:
|
||||
router = SeverityRouter(
|
||||
immediate_handler=mock_dispatch,
|
||||
digest_handler=digest.enqueue,
|
||||
)
|
||||
bus.subscribe(router.handle)
|
||||
event = make_event(
|
||||
source="test",
|
||||
category="test_cat",
|
||||
severity="routine",
|
||||
title="Routine Alert",
|
||||
)
|
||||
bus.emit(event)
|
||||
assert len(digest) == 1
|
||||
mock_dispatch.assert_not_called()
|
||||
|
||||
def test_priority_event_goes_to_digest_not_dispatcher(self):
|
||||
rule = NotificationRuleConfigStub(
|
||||
enabled=True,
|
||||
trigger_type="condition",
|
||||
categories=["test_cat"],
|
||||
min_severity="routine",
|
||||
)
|
||||
config = ConfigStub(
|
||||
notifications=NotificationsConfigStub(rules=[rule])
|
||||
)
|
||||
mock_factory = Mock()
|
||||
bus = EventBus()
|
||||
dispatcher = Dispatcher(config, mock_factory)
|
||||
digest = StubDigestQueue()
|
||||
with patch.object(dispatcher, "dispatch", wraps=dispatcher.dispatch) as mock_dispatch:
|
||||
router = SeverityRouter(
|
||||
immediate_handler=mock_dispatch,
|
||||
digest_handler=digest.enqueue,
|
||||
)
|
||||
bus.subscribe(router.handle)
|
||||
event = make_event(
|
||||
source="test",
|
||||
category="test_cat",
|
||||
severity="priority",
|
||||
title="Priority Alert",
|
||||
)
|
||||
bus.emit(event)
|
||||
assert len(digest) == 1
|
||||
mock_dispatch.assert_not_called()
|
||||
|
||||
|
||||
class TestNoMatchingRule:
|
||||
|
||||
def test_immediate_event_with_no_matching_rule_skips_silently(self):
|
||||
config = ConfigStub(
|
||||
notifications=NotificationsConfigStub(rules=[])
|
||||
)
|
||||
mock_factory = Mock()
|
||||
bus = EventBus()
|
||||
dispatcher = Dispatcher(config, mock_factory)
|
||||
digest = StubDigestQueue()
|
||||
router = SeverityRouter(
|
||||
immediate_handler=dispatcher.dispatch,
|
||||
digest_handler=digest.enqueue,
|
||||
)
|
||||
bus.subscribe(router.handle)
|
||||
event = make_event(
|
||||
source="test",
|
||||
category="test_cat",
|
||||
severity="immediate",
|
||||
title="No Rule Alert",
|
||||
)
|
||||
bus.emit(event)
|
||||
mock_factory.assert_not_called()
|
||||
|
||||
|
||||
class TestSubscriberIsolation:
|
||||
|
||||
def test_subscriber_exception_isolation(self):
|
||||
bus = EventBus()
|
||||
|
||||
def failing_handler(event):
|
||||
raise RuntimeError("Handler failed")
|
||||
|
||||
second_handler = Mock()
|
||||
bus.subscribe(failing_handler)
|
||||
bus.subscribe(second_handler)
|
||||
event = make_event(
|
||||
source="test",
|
||||
category="test_cat",
|
||||
severity="immediate",
|
||||
title="Test Event",
|
||||
)
|
||||
bus.emit(event)
|
||||
second_handler.assert_called_once()
|
||||
|
||||
|
||||
class TestUnknownSeverity:
|
||||
|
||||
def test_unknown_severity_dropped_without_crash(self):
|
||||
config = ConfigStub(
|
||||
notifications=NotificationsConfigStub(rules=[])
|
||||
)
|
||||
mock_factory = Mock()
|
||||
bus = EventBus()
|
||||
dispatcher = Dispatcher(config, mock_factory)
|
||||
digest = StubDigestQueue()
|
||||
mock_dispatch = Mock()
|
||||
mock_enqueue = Mock()
|
||||
router = SeverityRouter(
|
||||
immediate_handler=mock_dispatch,
|
||||
digest_handler=mock_enqueue,
|
||||
)
|
||||
bus.subscribe(router.handle)
|
||||
event = Event(
|
||||
id="test123",
|
||||
source="test",
|
||||
category="test_cat",
|
||||
severity="bogus",
|
||||
title="Bogus Severity",
|
||||
)
|
||||
bus.emit(event)
|
||||
mock_dispatch.assert_not_called()
|
||||
mock_enqueue.assert_not_called()
|
||||
"""Test cases for Phase 2.1 notification pipeline skeleton.
|
||||
|
||||
These tests verify the core routing and dispatch behavior of the
|
||||
notification pipeline without requiring real channel backends.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from meshai.notifications.events import Event, make_event
|
||||
from meshai.notifications.pipeline import build_pipeline_components
|
||||
from meshai.notifications.pipeline.bus import EventBus
|
||||
from meshai.notifications.pipeline.dispatcher import Dispatcher
|
||||
from meshai.notifications.pipeline.severity_router import SeverityRouter, StubDigestQueue
|
||||
|
||||
|
||||
# Minimal config stubs for testing
|
||||
@dataclass
|
||||
class NotificationRuleConfigStub:
|
||||
name: str = "test_rule"
|
||||
enabled: bool = True
|
||||
trigger_type: str = "condition"
|
||||
categories: list = field(default_factory=list)
|
||||
min_severity: str = "routine"
|
||||
delivery_type: str = "mesh_broadcast"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NotificationsConfigStub:
|
||||
rules: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConfigStub:
|
||||
notifications: NotificationsConfigStub = field(default_factory=NotificationsConfigStub)
|
||||
|
||||
|
||||
class TestImmediateDispatch:
|
||||
|
||||
def test_immediate_event_with_matching_rule_dispatches(self):
|
||||
rule = NotificationRuleConfigStub(
|
||||
enabled=True,
|
||||
trigger_type="condition",
|
||||
categories=["test_cat"],
|
||||
min_severity="routine",
|
||||
delivery_type="mesh_broadcast",
|
||||
)
|
||||
config = ConfigStub(
|
||||
notifications=NotificationsConfigStub(rules=[rule])
|
||||
)
|
||||
mock_channel = Mock()
|
||||
mock_factory = Mock(return_value=mock_channel)
|
||||
bus = EventBus()
|
||||
dispatcher = Dispatcher(config, mock_factory)
|
||||
digest = StubDigestQueue()
|
||||
router = SeverityRouter(
|
||||
immediate_handler=dispatcher.dispatch,
|
||||
digest_handler=digest.enqueue,
|
||||
)
|
||||
bus.subscribe(router.handle)
|
||||
event = make_event(
|
||||
source="test",
|
||||
category="test_cat",
|
||||
severity="immediate",
|
||||
title="Test Alert",
|
||||
summary="Test summary message",
|
||||
)
|
||||
bus.emit(event)
|
||||
assert mock_channel.deliver.call_count == 1
|
||||
alert = mock_channel.deliver.call_args[0][0]
|
||||
assert alert["category"] == "test_cat"
|
||||
assert alert["severity"] == "immediate"
|
||||
assert alert["message"]
|
||||
|
||||
|
||||
class TestDigestRouting:
|
||||
|
||||
def test_routine_event_goes_to_digest_not_dispatcher(self):
|
||||
rule = NotificationRuleConfigStub(
|
||||
enabled=True,
|
||||
trigger_type="condition",
|
||||
categories=["test_cat"],
|
||||
min_severity="routine",
|
||||
)
|
||||
config = ConfigStub(
|
||||
notifications=NotificationsConfigStub(rules=[rule])
|
||||
)
|
||||
mock_factory = Mock()
|
||||
bus = EventBus()
|
||||
dispatcher = Dispatcher(config, mock_factory)
|
||||
digest = StubDigestQueue()
|
||||
with patch.object(dispatcher, "dispatch", wraps=dispatcher.dispatch) as mock_dispatch:
|
||||
router = SeverityRouter(
|
||||
immediate_handler=mock_dispatch,
|
||||
digest_handler=digest.enqueue,
|
||||
)
|
||||
bus.subscribe(router.handle)
|
||||
event = make_event(
|
||||
source="test",
|
||||
category="test_cat",
|
||||
severity="routine",
|
||||
title="Routine Alert",
|
||||
)
|
||||
bus.emit(event)
|
||||
assert len(digest) == 1
|
||||
mock_dispatch.assert_not_called()
|
||||
|
||||
def test_priority_event_goes_to_digest_not_dispatcher(self):
|
||||
rule = NotificationRuleConfigStub(
|
||||
enabled=True,
|
||||
trigger_type="condition",
|
||||
categories=["test_cat"],
|
||||
min_severity="routine",
|
||||
)
|
||||
config = ConfigStub(
|
||||
notifications=NotificationsConfigStub(rules=[rule])
|
||||
)
|
||||
mock_factory = Mock()
|
||||
bus = EventBus()
|
||||
dispatcher = Dispatcher(config, mock_factory)
|
||||
digest = StubDigestQueue()
|
||||
with patch.object(dispatcher, "dispatch", wraps=dispatcher.dispatch) as mock_dispatch:
|
||||
router = SeverityRouter(
|
||||
immediate_handler=mock_dispatch,
|
||||
digest_handler=digest.enqueue,
|
||||
)
|
||||
bus.subscribe(router.handle)
|
||||
event = make_event(
|
||||
source="test",
|
||||
category="test_cat",
|
||||
severity="priority",
|
||||
title="Priority Alert",
|
||||
)
|
||||
bus.emit(event)
|
||||
assert len(digest) == 1
|
||||
mock_dispatch.assert_not_called()
|
||||
|
||||
|
||||
class TestNoMatchingRule:
|
||||
|
||||
def test_immediate_event_with_no_matching_rule_skips_silently(self):
|
||||
config = ConfigStub(
|
||||
notifications=NotificationsConfigStub(rules=[])
|
||||
)
|
||||
mock_factory = Mock()
|
||||
bus = EventBus()
|
||||
dispatcher = Dispatcher(config, mock_factory)
|
||||
digest = StubDigestQueue()
|
||||
router = SeverityRouter(
|
||||
immediate_handler=dispatcher.dispatch,
|
||||
digest_handler=digest.enqueue,
|
||||
)
|
||||
bus.subscribe(router.handle)
|
||||
event = make_event(
|
||||
source="test",
|
||||
category="test_cat",
|
||||
severity="immediate",
|
||||
title="No Rule Alert",
|
||||
)
|
||||
bus.emit(event)
|
||||
mock_factory.assert_not_called()
|
||||
|
||||
|
||||
class TestSubscriberIsolation:
|
||||
|
||||
def test_subscriber_exception_isolation(self):
|
||||
bus = EventBus()
|
||||
|
||||
def failing_handler(event):
|
||||
raise RuntimeError("Handler failed")
|
||||
|
||||
second_handler = Mock()
|
||||
bus.subscribe(failing_handler)
|
||||
bus.subscribe(second_handler)
|
||||
event = make_event(
|
||||
source="test",
|
||||
category="test_cat",
|
||||
severity="immediate",
|
||||
title="Test Event",
|
||||
)
|
||||
bus.emit(event)
|
||||
second_handler.assert_called_once()
|
||||
|
||||
|
||||
class TestUnknownSeverity:
|
||||
|
||||
def test_unknown_severity_dropped_without_crash(self):
|
||||
config = ConfigStub(
|
||||
notifications=NotificationsConfigStub(rules=[])
|
||||
)
|
||||
mock_factory = Mock()
|
||||
bus = EventBus()
|
||||
dispatcher = Dispatcher(config, mock_factory)
|
||||
digest = StubDigestQueue()
|
||||
mock_dispatch = Mock()
|
||||
mock_enqueue = Mock()
|
||||
router = SeverityRouter(
|
||||
immediate_handler=mock_dispatch,
|
||||
digest_handler=mock_enqueue,
|
||||
)
|
||||
bus.subscribe(router.handle)
|
||||
event = Event(
|
||||
id="test123",
|
||||
source="test",
|
||||
category="test_cat",
|
||||
severity="bogus",
|
||||
title="Bogus Severity",
|
||||
)
|
||||
bus.emit(event)
|
||||
mock_dispatch.assert_not_called()
|
||||
mock_enqueue.assert_not_called()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue