feat(notifications): Phase 2.3b digest scheduler

Adds DigestScheduler class that fires digest at configured time (default 07:00)
and routes to rules with trigger_type=schedule and schedule_match=digest.

- DigestScheduler: asyncio task with start/stop lifecycle
- Config: DigestConfig dataclass with schedule and include fields
- Config: schedule_match field on NotificationRuleConfig
- Pipeline: start_pipeline/stop_pipeline async lifecycle functions
- Mesh channels get per-chunk delivery, email/webhook get full text
- 26 new tests covering schedule computation, fire behavior, lifecycle

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
K7ZVX 2026-05-14 22:32:51 +00:00
commit 493b43f7cf
5 changed files with 1998 additions and 1082 deletions

View file

@ -230,6 +230,17 @@ notifications:
quiet_hours_start: "22:00" # Suppress non-emergency alerts during quiet hours quiet_hours_start: "22:00" # Suppress non-emergency alerts during quiet hours
quiet_hours_end: "06:00" quiet_hours_end: "06:00"
# Digest scheduler settings
# The digest collects priority/routine events and delivers a summary
# at the configured time to rules with trigger_type='schedule' and
# schedule_match='digest'.
digest:
schedule: "07:00" # HH:MM local time to fire digest
include: [] # Toggle names to include (empty = default set)
# Default set: weather, fire, seismic, avalanche, roads, mesh_health, tracking, other
# Excludes rf_propagation by default
# Example: include: ["weather", "fire", "mesh_health"]
# Notification rules - each rule is self-contained with its own delivery config # Notification rules - each rule is self-contained with its own delivery config
# Default baseline rules are created on fresh install # Default baseline rules are created on fresh install
rules: rules:
@ -277,6 +288,29 @@ notifications:
cooldown_minutes: 30 cooldown_minutes: 30
override_quiet: false override_quiet: false
# Example: Morning Digest -> mesh broadcast
# Delivers the accumulated digest at the configured schedule time
# - name: "Morning Digest Mesh"
# enabled: false
# trigger_type: schedule
# schedule_match: "digest" # Required for digest delivery
# delivery_type: mesh_broadcast
# broadcast_channel: 0
# Example: Morning Digest -> email
# - name: "Morning Digest Email"
# enabled: false
# trigger_type: schedule
# schedule_match: "digest"
# delivery_type: email
# smtp_host: "smtp.gmail.com"
# smtp_port: 587
# smtp_user: "you@gmail.com"
# smtp_password: "${SMTP_PASSWORD}"
# smtp_tls: true
# from_address: "meshai@yourdomain.com"
# recipients: ["admin@yourdomain.com"]
# Example: Fire alerts -> email # Example: Fire alerts -> email
# - name: "Fire Alerts Email" # - name: "Fire Alerts Email"
# enabled: true # enabled: true
@ -303,16 +337,6 @@ notifications:
# webhook_url: "https://discord.com/api/webhooks/..." # webhook_url: "https://discord.com/api/webhooks/..."
# cooldown_minutes: 10 # cooldown_minutes: 10
# Example: Daily health report -> mesh broadcast
# - name: "Morning Briefing"
# enabled: true
# trigger_type: schedule
# schedule_frequency: daily
# schedule_time: "07:00"
# message_type: mesh_health_summary
# delivery_type: mesh_broadcast
# broadcast_channel: 0
# Example: Rule with no delivery (matches and logs, but doesn't send) # Example: Rule with no delivery (matches and logs, but doesn't send)
# - name: "Monitor Only" # - name: "Monitor Only"
# enabled: true # enabled: true

View file

@ -450,6 +450,7 @@ class NotificationRuleConfig:
schedule_time_2: str = "19:00" # For twice_daily schedule_time_2: str = "19:00" # For twice_daily
schedule_days: list = field(default_factory=list) # For weekly schedule_days: list = field(default_factory=list) # For weekly
schedule_cron: str = "" # For custom schedule_cron: str = "" # For custom
schedule_match: Optional[str] = None # "digest" for digest deliveries
message_type: str = "mesh_health_summary" message_type: str = "mesh_health_summary"
custom_message: str = "" custom_message: str = ""
@ -483,6 +484,14 @@ class NotificationRuleConfig:
channel_ids: list = field(default_factory=list) channel_ids: list = field(default_factory=list)
@dataclass
class DigestConfig:
"""Digest scheduler settings."""
schedule: str = "07:00" # HH:MM time to fire digest
include: list[str] = field(default_factory=list) # Toggle names to include (empty = default set)
@dataclass @dataclass
class NotificationsConfig: class NotificationsConfig:
"""Notification system settings.""" """Notification system settings."""
@ -491,6 +500,7 @@ class NotificationsConfig:
quiet_hours_enabled: bool = True # Master toggle for quiet hours quiet_hours_enabled: bool = True # Master toggle for quiet hours
quiet_hours_start: str = "22:00" quiet_hours_start: str = "22:00"
quiet_hours_end: str = "06:00" quiet_hours_end: str = "06:00"
digest: DigestConfig = field(default_factory=DigestConfig)
rules: list = field(default_factory=list) # List of NotificationRuleConfig rules: list = field(default_factory=list) # List of NotificationRuleConfig
@dataclass @dataclass
@ -662,6 +672,8 @@ def _dict_to_dataclass(cls, data: dict):
kwargs[key] = _dict_to_dataclass(FIRMSConfig, value) kwargs[key] = _dict_to_dataclass(FIRMSConfig, value)
elif key == "dashboard" and isinstance(value, dict): elif key == "dashboard" and isinstance(value, dict):
kwargs[key] = _dict_to_dataclass(DashboardConfig, value) kwargs[key] = _dict_to_dataclass(DashboardConfig, value)
elif key == "digest" and isinstance(value, dict):
kwargs[key] = _dict_to_dataclass(DigestConfig, value)
elif key == "notifications" and isinstance(value, dict): elif key == "notifications" and isinstance(value, dict):
notifications = _dict_to_dataclass(NotificationsConfig, value) notifications = _dict_to_dataclass(NotificationsConfig, value)
if "rules" in value and isinstance(value["rules"], list): if "rules" in value and isinstance(value["rules"], list):

View file

@ -1,17 +1,23 @@
"""Notification pipeline package. """Notification pipeline package.
Phase 2.1 + 2.2 + 2.3a: Phase 2.1 + 2.2 + 2.3a + 2.3b:
- EventBus: pub/sub ingress - EventBus: pub/sub ingress
- Inhibitor: suppresses redundant events by inhibit_keys - Inhibitor: suppresses redundant events by inhibit_keys
- Grouper: coalesces events sharing group_key within a window - Grouper: coalesces events sharing group_key within a window
- SeverityRouter: forks immediate vs digest - SeverityRouter: forks immediate vs digest
- Dispatcher: routes immediate via channels (existing rules schema) - Dispatcher: routes immediate via channels (existing rules schema)
- DigestAccumulator: tracks priority/routine events for periodic digest - DigestAccumulator: tracks priority/routine events for periodic digest
- DigestScheduler: fires digest at configured time (Phase 2.3b)
Usage: Usage:
from meshai.notifications.pipeline import build_pipeline from meshai.notifications.pipeline import build_pipeline, start_pipeline, stop_pipeline
bus = build_pipeline(config) bus = build_pipeline(config)
bus.emit(event) bus.emit(event)
# Async lifecycle
scheduler = await start_pipeline(bus, config)
...
await stop_pipeline(scheduler)
""" """
from meshai.notifications.channels import create_channel from meshai.notifications.channels import create_channel
@ -24,13 +30,26 @@ from meshai.notifications.pipeline.dispatcher import Dispatcher
from meshai.notifications.pipeline.inhibitor import Inhibitor from meshai.notifications.pipeline.inhibitor import Inhibitor
from meshai.notifications.pipeline.grouper import Grouper from meshai.notifications.pipeline.grouper import Grouper
from meshai.notifications.pipeline.digest import DigestAccumulator, Digest from meshai.notifications.pipeline.digest import DigestAccumulator, Digest
from meshai.notifications.pipeline.scheduler import DigestScheduler
def build_pipeline(config) -> EventBus: def build_pipeline(config) -> EventBus:
"""Build the pipeline and return the EventBus.""" """Build the pipeline and return the EventBus.
Components are stashed on bus._pipeline_components for lifecycle use.
"""
bus = EventBus() bus = EventBus()
dispatcher = Dispatcher(config, create_channel) dispatcher = Dispatcher(config, create_channel)
digest = DigestAccumulator()
# Build include_toggles from config
digest_cfg = getattr(config.notifications, "digest", None)
include_toggles = None
if digest_cfg is not None:
include_list = getattr(digest_cfg, "include", None)
if include_list:
include_toggles = list(include_list)
digest = DigestAccumulator(include_toggles=include_toggles)
severity_router = SeverityRouter( severity_router = SeverityRouter(
immediate_handler=dispatcher.dispatch, immediate_handler=dispatcher.dispatch,
digest_handler=digest.enqueue, digest_handler=digest.enqueue,
@ -38,6 +57,16 @@ def build_pipeline(config) -> EventBus:
grouper = Grouper(next_handler=severity_router.handle) grouper = Grouper(next_handler=severity_router.handle)
inhibitor = Inhibitor(next_handler=grouper.handle) inhibitor = Inhibitor(next_handler=grouper.handle)
bus.subscribe(inhibitor.handle) bus.subscribe(inhibitor.handle)
# Stash components for lifecycle management
bus._pipeline_components = {
"inhibitor": inhibitor,
"grouper": grouper,
"severity_router": severity_router,
"dispatcher": dispatcher,
"digest": digest,
}
return bus return bus
@ -48,7 +77,16 @@ def build_pipeline_components(config) -> tuple:
""" """
bus = EventBus() bus = EventBus()
dispatcher = Dispatcher(config, create_channel) dispatcher = Dispatcher(config, create_channel)
digest = DigestAccumulator()
# Build include_toggles from config
digest_cfg = getattr(config.notifications, "digest", None)
include_toggles = None
if digest_cfg is not None:
include_list = getattr(digest_cfg, "include", None)
if include_list:
include_toggles = list(include_list)
digest = DigestAccumulator(include_toggles=include_toggles)
severity_router = SeverityRouter( severity_router = SeverityRouter(
immediate_handler=dispatcher.dispatch, immediate_handler=dispatcher.dispatch,
digest_handler=digest.enqueue, digest_handler=digest.enqueue,
@ -59,6 +97,45 @@ def build_pipeline_components(config) -> tuple:
return bus, inhibitor, grouper, severity_router, dispatcher, digest return bus, inhibitor, grouper, severity_router, dispatcher, digest
async def start_pipeline(bus: EventBus, config) -> DigestScheduler:
"""Start the pipeline's async components (scheduler).
Args:
bus: EventBus returned by build_pipeline()
config: Config object with notifications.digest settings
Returns:
DigestScheduler instance (running). Call stop_pipeline() to stop.
"""
components = getattr(bus, "_pipeline_components", None)
if components is None:
raise RuntimeError("bus missing _pipeline_components; use build_pipeline()")
digest = components["digest"]
scheduler = DigestScheduler(
accumulator=digest,
config=config,
channel_factory=create_channel,
)
await scheduler.start()
# Stash scheduler for stop_pipeline
bus._pipeline_scheduler = scheduler
return scheduler
async def stop_pipeline(scheduler: DigestScheduler) -> None:
"""Stop the pipeline's async components.
Args:
scheduler: DigestScheduler returned by start_pipeline()
"""
if scheduler is not None:
await scheduler.stop()
__all__ = [ __all__ = [
"EventBus", "EventBus",
"SeverityRouter", "SeverityRouter",
@ -68,7 +145,10 @@ __all__ = [
"Grouper", "Grouper",
"DigestAccumulator", "DigestAccumulator",
"Digest", "Digest",
"DigestScheduler",
"build_pipeline", "build_pipeline",
"build_pipeline_components", "build_pipeline_components",
"start_pipeline",
"stop_pipeline",
"get_bus", "get_bus",
] ]

View file

@ -0,0 +1,213 @@
"""Digest scheduler — fires the digest at a configured time of day.
Reads schedule and channel routing from config; calls
accumulator.render_digest() at the scheduled time; delivers the
result to all rules matching trigger_type=='schedule' and
schedule_match=='digest'.
"""
import asyncio
import logging
import time
from datetime import datetime, timedelta
from typing import Callable, Optional
from meshai.notifications.pipeline.digest import DigestAccumulator
class DigestScheduler:
"""Fires digest at configured time and routes to matching channels."""
def __init__(
self,
accumulator: DigestAccumulator,
config,
channel_factory: Callable,
clock: Optional[Callable[[], float]] = None,
sleep: Optional[Callable[[float], "asyncio.Future"]] = None,
):
self._accumulator = accumulator
self._config = config
self._channel_factory = channel_factory
self._clock = clock or time.time
self._sleep = sleep or asyncio.sleep
self._task: Optional[asyncio.Task] = None
self._stop_event: Optional[asyncio.Event] = None
self._last_fire_at: float = 0.0
self._logger = logging.getLogger("meshai.pipeline.scheduler")
async def start(self) -> None:
"""Begin the scheduler loop as an asyncio task."""
if self._task is not None and not self._task.done():
raise RuntimeError("Scheduler already running")
self._stop_event = asyncio.Event()
self._task = asyncio.create_task(self._run(), name="digest-scheduler")
self._logger.info(
f"Digest scheduler started, schedule={self._schedule_str()!r}"
)
async def stop(self) -> None:
"""Signal stop and wait for the task to finish."""
if self._task is None:
return
if self._stop_event:
self._stop_event.set()
self._task.cancel()
try:
await self._task
except (asyncio.CancelledError, Exception):
# Cancellation is expected; other exceptions already logged
pass
self._task = None
self._logger.info("Digest scheduler stopped")
async def _run(self) -> None:
"""Main loop: sleep until next fire, fire, repeat."""
try:
while self._stop_event and not self._stop_event.is_set():
now = self._clock()
next_fire = self._next_fire_at(now)
delay = max(0.0, next_fire - now)
self._logger.info(
f"Next digest at {datetime.fromtimestamp(next_fire):%Y-%m-%d %H:%M}, "
f"sleeping {delay:.0f}s"
)
# Interruptible sleep — wakes early if stop() is called
try:
await asyncio.wait_for(
self._stop_event.wait(),
timeout=delay,
)
# If we got here without timeout, stop was requested
return
except asyncio.TimeoutError:
pass # Timeout fired = digest time arrived
if self._stop_event.is_set():
return
try:
await self._fire(self._clock())
except Exception:
self._logger.exception("Digest fire failed; will retry next cycle")
except asyncio.CancelledError:
raise
except Exception:
self._logger.exception("Scheduler loop crashed unexpectedly")
raise
async def _fire(self, now: float) -> None:
"""Render and deliver one digest."""
self._logger.info(f"Firing digest at {datetime.fromtimestamp(now):%H:%M}")
digest = self._accumulator.render_digest(now)
self._last_fire_at = now
rules = self._matching_rules()
if not rules:
self._logger.warning(
"No digest delivery rules configured (need rules with "
"trigger_type=='schedule' and schedule_match=='digest')"
)
return
for rule in rules:
try:
await self._deliver_to_rule(rule, digest, now)
except Exception:
self._logger.exception(
f"Digest delivery failed for rule {rule.name!r}"
)
async def _deliver_to_rule(self, rule, digest, now: float) -> None:
"""Hand the rendered digest to a channel based on rule.delivery_type."""
channel = self._channel_factory(rule)
delivery_type = rule.delivery_type
if delivery_type in ("mesh_broadcast", "mesh_dm"):
# One deliver call per chunk
chunks = digest.mesh_chunks
total = len(chunks)
for i, chunk in enumerate(chunks, start=1):
payload = {
"category": "digest",
"severity": "routine",
"message": chunk,
"node_id": None,
"region": None,
"timestamp": now,
"chunk_index": i,
"chunk_total": total,
}
channel.deliver(payload)
self._logger.info(
f"Delivered {total} mesh chunk(s) to rule {rule.name!r}"
)
else:
# Single full-form delivery
payload = {
"category": "digest",
"severity": "routine",
"message": digest.full,
"node_id": None,
"region": None,
"timestamp": now,
}
channel.deliver(payload)
self._logger.info(
f"Delivered digest to rule {rule.name!r} via {delivery_type}"
)
def _matching_rules(self) -> list:
"""Find enabled schedule rules tagged as digest deliveries."""
matches = []
for rule in self._config.notifications.rules:
if not rule.enabled:
continue
if rule.trigger_type != "schedule":
continue
# schedule_match is the discriminator. Operators set it to
# "digest" to receive the morning digest. Other values
# reserved for future schedule types.
schedule_match = getattr(rule, "schedule_match", None)
if schedule_match != "digest":
continue
matches.append(rule)
return matches
def _next_fire_at(self, now: float) -> float:
"""Compute the next epoch timestamp when the digest should fire.
Reads schedule HH:MM from config. If today's fire time has
already passed, returns tomorrow's. Uses local timezone.
"""
schedule_str = self._schedule_str()
h, m = self._parse_schedule(schedule_str)
now_dt = datetime.fromtimestamp(now)
target_today = now_dt.replace(hour=h, minute=m, second=0, microsecond=0)
if target_today.timestamp() <= now:
target = target_today + timedelta(days=1)
else:
target = target_today
return target.timestamp()
def _schedule_str(self) -> str:
digest_cfg = getattr(self._config.notifications, "digest", None)
if digest_cfg is None:
return "07:00"
return getattr(digest_cfg, "schedule", "07:00")
@staticmethod
def _parse_schedule(s: str) -> tuple[int, int]:
"""Parse 'HH:MM' to (hour, minute). Falls back to 07:00 on bad input."""
try:
hh, mm = s.strip().split(":", 1)
h = int(hh)
m = int(mm)
if not (0 <= h <= 23 and 0 <= m <= 59):
raise ValueError(f"out of range: {s}")
return h, m
except (ValueError, AttributeError):
# Fall back to 07:00 rather than crash the loop
return 7, 0
def last_fire_at(self) -> float:
return self._last_fire_at

View file

@ -0,0 +1,587 @@
"""Tests for DigestScheduler (Phase 2.3b).
Uses asyncio.run() since pytest-asyncio is not available in the container.
"""
import asyncio
import time
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Optional
from unittest.mock import MagicMock, call
import pytest
from meshai.notifications.events import make_event
from meshai.notifications.pipeline.digest import DigestAccumulator
from meshai.notifications.pipeline.scheduler import DigestScheduler
# ---- Test Fixtures ----
@dataclass
class MockRule:
"""Mock notification rule for testing."""
name: str = "test-rule"
enabled: bool = True
trigger_type: str = "schedule"
schedule_match: str = "digest"
delivery_type: str = "mesh_broadcast"
broadcast_channel: int = 0
@dataclass
class MockDigestConfig:
"""Mock digest config."""
schedule: str = "07:00"
include: list = field(default_factory=list)
@dataclass
class MockNotificationsConfig:
"""Mock notifications config."""
enabled: bool = True
digest: MockDigestConfig = field(default_factory=MockDigestConfig)
rules: list = field(default_factory=list)
@dataclass
class MockConfig:
"""Mock config for scheduler tests."""
notifications: MockNotificationsConfig = field(default_factory=MockNotificationsConfig)
class MockChannel:
"""Mock channel that records deliveries."""
def __init__(self):
self.deliveries = []
def deliver(self, payload: dict):
self.deliveries.append(payload)
def make_scheduler(
schedule: str = "07:00",
rules: Optional[list] = None,
clock: Optional[callable] = None,
sleep: Optional[callable] = None,
accumulator: Optional[DigestAccumulator] = None,
) -> tuple[DigestScheduler, MockConfig, dict]:
"""Factory for creating test schedulers.
Returns (scheduler, config, channels_by_rule_name).
"""
if rules is None:
rules = [MockRule()]
config = MockConfig(
notifications=MockNotificationsConfig(
digest=MockDigestConfig(schedule=schedule),
rules=rules,
)
)
channels = {}
def channel_factory(rule):
ch = MockChannel()
channels[rule.name] = ch
return ch
if accumulator is None:
accumulator = DigestAccumulator()
scheduler = DigestScheduler(
accumulator=accumulator,
config=config,
channel_factory=channel_factory,
clock=clock,
sleep=sleep,
)
return scheduler, config, channels
# ---- Schedule Computation Tests ----
class TestScheduleComputation:
"""Tests for _next_fire_at and _parse_schedule."""
def test_parse_schedule_valid(self):
"""Valid HH:MM parses correctly."""
scheduler, _, _ = make_scheduler()
assert scheduler._parse_schedule("07:00") == (7, 0)
assert scheduler._parse_schedule("23:59") == (23, 59)
assert scheduler._parse_schedule("00:00") == (0, 0)
assert scheduler._parse_schedule("12:30") == (12, 30)
def test_parse_schedule_with_whitespace(self):
"""Whitespace is stripped."""
scheduler, _, _ = make_scheduler()
assert scheduler._parse_schedule(" 07:00 ") == (7, 0)
def test_parse_schedule_invalid_falls_back(self):
"""Invalid schedules fall back to 07:00."""
scheduler, _, _ = make_scheduler()
# Bad format
assert scheduler._parse_schedule("7:00:00") == (7, 0)
assert scheduler._parse_schedule("invalid") == (7, 0)
assert scheduler._parse_schedule("") == (7, 0)
# Out of range
assert scheduler._parse_schedule("25:00") == (7, 0)
assert scheduler._parse_schedule("12:60") == (7, 0)
def test_next_fire_at_future_today(self):
"""If schedule time is later today, returns today's timestamp."""
# Set clock to 06:00 on a known date
base_dt = datetime(2024, 6, 15, 6, 0, 0)
base_ts = base_dt.timestamp()
scheduler, _, _ = make_scheduler(schedule="07:00", clock=lambda: base_ts)
next_fire = scheduler._next_fire_at(base_ts)
# Should be 07:00 same day
expected_dt = datetime(2024, 6, 15, 7, 0, 0)
assert abs(next_fire - expected_dt.timestamp()) < 1
def test_next_fire_at_past_today_schedules_tomorrow(self):
"""If schedule time has passed today, returns tomorrow's timestamp."""
# Set clock to 08:00 on a known date
base_dt = datetime(2024, 6, 15, 8, 0, 0)
base_ts = base_dt.timestamp()
scheduler, _, _ = make_scheduler(schedule="07:00", clock=lambda: base_ts)
next_fire = scheduler._next_fire_at(base_ts)
# Should be 07:00 next day
expected_dt = datetime(2024, 6, 16, 7, 0, 0)
assert abs(next_fire - expected_dt.timestamp()) < 1
def test_next_fire_at_exact_time_schedules_tomorrow(self):
"""If clock is exactly at schedule time, schedules tomorrow."""
base_dt = datetime(2024, 6, 15, 7, 0, 0)
base_ts = base_dt.timestamp()
scheduler, _, _ = make_scheduler(schedule="07:00", clock=lambda: base_ts)
next_fire = scheduler._next_fire_at(base_ts)
# Should be 07:00 next day
expected_dt = datetime(2024, 6, 16, 7, 0, 0)
assert abs(next_fire - expected_dt.timestamp()) < 1
def test_schedule_str_reads_from_config(self):
"""_schedule_str reads from config.notifications.digest.schedule."""
scheduler, _, _ = make_scheduler(schedule="19:30")
assert scheduler._schedule_str() == "19:30"
def test_schedule_str_defaults_to_0700(self):
"""Missing digest config defaults to 07:00."""
config = MockConfig()
config.notifications.digest = None
scheduler = DigestScheduler(
accumulator=DigestAccumulator(),
config=config,
channel_factory=lambda r: MockChannel(),
)
assert scheduler._schedule_str() == "07:00"
# ---- Fire Behavior Tests ----
class TestFireBehavior:
"""Tests for _fire() digest delivery."""
def test_fire_delivers_to_matching_rule(self):
"""_fire() delivers digest to rules with schedule_match='digest'."""
accumulator = DigestAccumulator()
# Add an event so digest has content
accumulator.enqueue(make_event(
source="test",
category="weather_warning",
severity="priority",
title="Test Alert",
summary="Test alert summary",
))
scheduler, _, channels = make_scheduler(
rules=[MockRule(name="digest-mesh")],
accumulator=accumulator,
)
now = time.time()
async def run_fire():
await scheduler._fire(now)
asyncio.run(run_fire())
assert "digest-mesh" in channels
ch = channels["digest-mesh"]
assert len(ch.deliveries) == 1
payload = ch.deliveries[0]
assert payload["category"] == "digest"
assert payload["severity"] == "routine"
assert "Test alert" in payload["message"] or "Weather" in payload["message"]
def test_fire_skips_disabled_rules(self):
"""Disabled rules are not delivered to."""
scheduler, _, channels = make_scheduler(
rules=[MockRule(name="disabled", enabled=False)],
)
async def run_fire():
await scheduler._fire(time.time())
asyncio.run(run_fire())
# Channel should not be created for disabled rule
assert "disabled" not in channels
def test_fire_skips_non_schedule_rules(self):
"""Rules with trigger_type != 'schedule' are skipped."""
rule = MockRule(name="condition-rule", trigger_type="condition")
scheduler, _, channels = make_scheduler(rules=[rule])
async def run_fire():
await scheduler._fire(time.time())
asyncio.run(run_fire())
assert "condition-rule" not in channels
def test_fire_skips_non_digest_schedule_rules(self):
"""Schedule rules with schedule_match != 'digest' are skipped."""
rule = MockRule(name="other-schedule", schedule_match="daily_report")
scheduler, _, channels = make_scheduler(rules=[rule])
async def run_fire():
await scheduler._fire(time.time())
asyncio.run(run_fire())
assert "other-schedule" not in channels
def test_fire_mesh_delivery_chunks(self):
"""Mesh delivery types get per-chunk delivery."""
accumulator = DigestAccumulator(mesh_char_limit=100)
# Add multiple events to force chunking
for i in range(5):
accumulator.enqueue(make_event(
source="test",
category="weather_warning",
severity="priority",
title=f"Alert {i}",
summary=f"Weather alert number {i} with enough text to use space",
))
scheduler, _, channels = make_scheduler(
rules=[MockRule(name="mesh", delivery_type="mesh_broadcast")],
accumulator=accumulator,
)
now = time.time()
async def run_fire():
await scheduler._fire(now)
asyncio.run(run_fire())
ch = channels["mesh"]
# Should have multiple deliveries (one per chunk)
assert len(ch.deliveries) >= 1
# Check chunk metadata
for payload in ch.deliveries:
assert "chunk_index" in payload
assert "chunk_total" in payload
def test_fire_email_delivery_full_text(self):
"""Email delivery type gets single full-text delivery."""
accumulator = DigestAccumulator()
accumulator.enqueue(make_event(
source="test",
category="weather_warning",
severity="priority",
title="Test Alert",
summary="Test alert summary",
))
scheduler, _, channels = make_scheduler(
rules=[MockRule(name="email", delivery_type="email")],
accumulator=accumulator,
)
async def run_fire():
await scheduler._fire(time.time())
asyncio.run(run_fire())
ch = channels["email"]
assert len(ch.deliveries) == 1
payload = ch.deliveries[0]
assert "chunk_index" not in payload
assert "--- " in payload["message"] # Full format has header
def test_fire_updates_last_fire_at(self):
"""_fire() updates last_fire_at timestamp."""
scheduler, _, _ = make_scheduler()
assert scheduler.last_fire_at() == 0.0
now = time.time()
async def run_fire():
await scheduler._fire(now)
asyncio.run(run_fire())
assert scheduler.last_fire_at() == now
def test_fire_empty_digest_still_delivers(self):
"""Empty digest is still delivered (with 'no alerts' message)."""
scheduler, _, channels = make_scheduler(
rules=[MockRule(name="mesh")],
)
async def run_fire():
await scheduler._fire(time.time())
asyncio.run(run_fire())
ch = channels["mesh"]
assert len(ch.deliveries) == 1
assert "No alerts" in ch.deliveries[0]["message"]
# ---- Lifecycle Tests ----
class TestLifecycle:
"""Tests for start/stop lifecycle."""
def test_start_creates_task(self):
"""start() creates and runs an asyncio task."""
scheduler, _, _ = make_scheduler()
async def run_start():
await scheduler.start()
assert scheduler._task is not None
assert not scheduler._task.done()
await scheduler.stop()
asyncio.run(run_start())
def test_start_twice_raises(self):
"""Starting twice raises RuntimeError."""
scheduler, _, _ = make_scheduler()
async def run_double_start():
await scheduler.start()
try:
with pytest.raises(RuntimeError, match="already running"):
await scheduler.start()
finally:
await scheduler.stop()
asyncio.run(run_double_start())
def test_stop_cancels_task(self):
"""stop() cancels the running task."""
scheduler, _, _ = make_scheduler()
async def run_stop():
await scheduler.start()
task = scheduler._task
await scheduler.stop()
assert scheduler._task is None
assert task.done()
asyncio.run(run_stop())
def test_stop_idempotent(self):
"""stop() on non-running scheduler is safe."""
scheduler, _, _ = make_scheduler()
async def run_stop():
# Never started
await scheduler.stop()
# Should not raise
asyncio.run(run_stop())
def test_stop_event_interrupts_sleep(self):
"""stop() interrupts the sleep and exits cleanly."""
sleep_calls = []
async def fake_sleep(duration):
sleep_calls.append(duration)
# Actually sleep briefly so we can cancel
await asyncio.sleep(0.01)
# Set clock far from schedule time to get long sleep
base_dt = datetime(2024, 6, 15, 8, 0, 0)
scheduler, _, _ = make_scheduler(
schedule="07:00",
clock=lambda: base_dt.timestamp(),
sleep=fake_sleep,
)
async def run_test():
await scheduler.start()
# Give task time to enter sleep
await asyncio.sleep(0.05)
await scheduler.stop()
asyncio.run(run_test())
# Task should have exited cleanly
# ---- Integration Tests ----
class TestIntegration:
"""Integration tests with real timing (short intervals)."""
def test_scheduler_fires_on_schedule(self):
"""Scheduler fires when schedule time arrives."""
fire_times = []
accumulator = DigestAccumulator()
# Start at 06:59:59.95 (50ms before 07:00), delay will be ~50ms
clock_time = [datetime(2024, 6, 15, 6, 59, 59, 950000).timestamp()]
def fake_clock():
return clock_time[0]
scheduler, _, channels = make_scheduler(
schedule="07:00",
clock=fake_clock,
accumulator=accumulator,
)
# Track when fire happens
original_fire = scheduler._fire
async def tracking_fire(now):
fire_times.append(now)
await original_fire(now)
# After first fire, advance clock so next cycle has long delay
clock_time[0] = datetime(2024, 6, 15, 8, 0, 0).timestamp()
scheduler._fire = tracking_fire
async def run_test():
await scheduler.start()
# Wait for the ~50ms delay plus some buffer
await asyncio.sleep(0.2)
await scheduler.stop()
asyncio.run(run_test())
# Should have fired once
assert len(fire_times) >= 1
def test_scheduler_multiple_rules(self):
"""Scheduler delivers to multiple matching rules."""
accumulator = DigestAccumulator()
accumulator.enqueue(make_event(
source="test",
category="weather_warning",
severity="priority",
title="Test",
summary="Test summary",
))
rules = [
MockRule(name="mesh1", delivery_type="mesh_broadcast"),
MockRule(name="mesh2", delivery_type="mesh_dm"),
MockRule(name="email", delivery_type="email"),
]
scheduler, _, channels = make_scheduler(
rules=rules,
accumulator=accumulator,
)
async def run_fire():
await scheduler._fire(time.time())
asyncio.run(run_fire())
# All three should have received deliveries
assert "mesh1" in channels
assert "mesh2" in channels
assert "email" in channels
assert len(channels["mesh1"].deliveries) >= 1
assert len(channels["mesh2"].deliveries) >= 1
assert len(channels["email"].deliveries) == 1
def test_scheduler_handles_delivery_error(self):
"""Scheduler continues after delivery error."""
accumulator = DigestAccumulator()
accumulator.enqueue(make_event(
source="test",
category="weather_warning",
severity="priority",
title="Test",
summary="Test",
))
rules = [
MockRule(name="bad"),
MockRule(name="good"),
]
call_order = []
def bad_channel_factory(rule):
call_order.append(rule.name)
if rule.name == "bad":
ch = MagicMock()
ch.deliver.side_effect = RuntimeError("delivery failed")
return ch
return MockChannel()
scheduler = DigestScheduler(
accumulator=accumulator,
config=MockConfig(
notifications=MockNotificationsConfig(rules=rules)
),
channel_factory=bad_channel_factory,
)
async def run_fire():
await scheduler._fire(time.time())
asyncio.run(run_fire())
# Both rules should have been attempted
assert "bad" in call_order
assert "good" in call_order
# ---- Matching Rules Tests ----
class TestMatchingRules:
"""Tests for _matching_rules() filter logic."""
def test_matching_rules_filters_correctly(self):
"""Only enabled schedule rules with schedule_match='digest' match."""
rules = [
MockRule(name="good", enabled=True, trigger_type="schedule", schedule_match="digest"),
MockRule(name="disabled", enabled=False, trigger_type="schedule", schedule_match="digest"),
MockRule(name="condition", enabled=True, trigger_type="condition", schedule_match="digest"),
MockRule(name="other-match", enabled=True, trigger_type="schedule", schedule_match="daily"),
MockRule(name="no-match", enabled=True, trigger_type="schedule", schedule_match=None),
]
scheduler, _, _ = make_scheduler(rules=rules)
matches = scheduler._matching_rules()
assert len(matches) == 1
assert matches[0].name == "good"
def test_matching_rules_empty_when_no_rules(self):
"""Returns empty list when no rules configured."""
scheduler, _, _ = make_scheduler(rules=[])
matches = scheduler._matching_rules()
assert matches == []