mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
refactor(notifications): per-toggle digest lines, exclude rf_propagation, explicit empty digest
This commit is contained in:
parent
96de22c6c0
commit
57e2f516c5
2 changed files with 331 additions and 73 deletions
|
|
@ -72,11 +72,17 @@ class Digest:
|
||||||
class DigestAccumulator:
|
class DigestAccumulator:
|
||||||
"""Tracks priority/routine events and produces periodic digests."""
|
"""Tracks priority/routine events and produces periodic digests."""
|
||||||
|
|
||||||
def __init__(self, mesh_char_limit: int = 200):
|
def __init__(
|
||||||
|
self,
|
||||||
|
mesh_char_limit: int = 200,
|
||||||
|
excluded_toggles: list[str] | None = None,
|
||||||
|
):
|
||||||
self._active: dict[str, list[Event]] = {} # toggle -> events
|
self._active: dict[str, list[Event]] = {} # toggle -> events
|
||||||
self._since_last: dict[str, list[Event]] = {} # toggle -> events
|
self._since_last: dict[str, list[Event]] = {} # toggle -> events
|
||||||
self._last_digest_at: float = 0.0
|
self._last_digest_at: float = 0.0
|
||||||
self._mesh_char_limit = mesh_char_limit
|
self._mesh_char_limit = mesh_char_limit
|
||||||
|
self._excluded = set(excluded_toggles) if excluded_toggles is not None \
|
||||||
|
else {"rf_propagation"}
|
||||||
self._logger = logging.getLogger("meshai.pipeline.digest")
|
self._logger = logging.getLogger("meshai.pipeline.digest")
|
||||||
|
|
||||||
# ---- ingress ----
|
# ---- ingress ----
|
||||||
|
|
@ -84,6 +90,14 @@ class DigestAccumulator:
|
||||||
def enqueue(self, event: Event) -> None:
|
def enqueue(self, event: Event) -> None:
|
||||||
"""SeverityRouter calls this for priority/routine events."""
|
"""SeverityRouter calls this for priority/routine events."""
|
||||||
toggle = get_toggle(event.category) or "other"
|
toggle = get_toggle(event.category) or "other"
|
||||||
|
|
||||||
|
# Skip excluded toggles
|
||||||
|
if toggle in self._excluded:
|
||||||
|
self._logger.debug(
|
||||||
|
f"skipping digest enqueue for excluded toggle {toggle}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
active_for_toggle = self._active.setdefault(toggle, [])
|
active_for_toggle = self._active.setdefault(toggle, [])
|
||||||
|
|
||||||
# Resolution detection
|
# Resolution detection
|
||||||
|
|
@ -135,8 +149,15 @@ class DigestAccumulator:
|
||||||
self.tick(now)
|
self.tick(now)
|
||||||
|
|
||||||
digest = Digest(rendered_at=now)
|
digest = Digest(rendered_at=now)
|
||||||
digest.active = {k: list(v) for k, v in self._active.items() if v}
|
# Defensive: skip excluded toggles when building output
|
||||||
digest.since_last = {k: list(v) for k, v in self._since_last.items() if v}
|
digest.active = {
|
||||||
|
k: list(v) for k, v in self._active.items()
|
||||||
|
if v and k not in self._excluded
|
||||||
|
}
|
||||||
|
digest.since_last = {
|
||||||
|
k: list(v) for k, v in self._since_last.items()
|
||||||
|
if v and k not in self._excluded
|
||||||
|
}
|
||||||
digest.mesh_compact = self._render_mesh_compact(digest, now)
|
digest.mesh_compact = self._render_mesh_compact(digest, now)
|
||||||
digest.full = self._render_full(digest, now)
|
digest.full = self._render_full(digest, now)
|
||||||
|
|
||||||
|
|
@ -149,39 +170,62 @@ class DigestAccumulator:
|
||||||
"""Produce a mesh-radio-friendly compact form.
|
"""Produce a mesh-radio-friendly compact form.
|
||||||
|
|
||||||
Format:
|
Format:
|
||||||
DIGEST 0700
|
DIGEST HHMM
|
||||||
ACTIVE: 2 weather, 1 fire, 1 mesh
|
ACTIVE NOW
|
||||||
NEW: 1 roads, 1 weather cleared
|
[Weather] Severe Thunderstorm Warning
|
||||||
Fits under self._mesh_char_limit chars. If it overflows,
|
[Fire] Snake River Fire — 8mi NE (+2)
|
||||||
truncate by dropping toggles with fewest events first.
|
RESOLVED
|
||||||
|
[Roads] US-93 reopened at MP 47
|
||||||
|
|
||||||
|
One line per toggle, showing highest-severity event headline.
|
||||||
|
Append (+N) if toggle has more than one event.
|
||||||
"""
|
"""
|
||||||
lines = [f"DIGEST {time.strftime('%H%M', time.localtime(now))}"]
|
lines = [f"DIGEST {time.strftime('%H%M', time.localtime(now))}"]
|
||||||
|
|
||||||
if digest.active:
|
|
||||||
counts = self._compact_counts(digest.active)
|
|
||||||
lines.append(f"ACTIVE: {counts}")
|
|
||||||
if digest.since_last:
|
|
||||||
counts = self._compact_counts(digest.since_last)
|
|
||||||
lines.append(f"NEW: {counts}")
|
|
||||||
|
|
||||||
if not digest.active and not digest.since_last:
|
if not digest.active and not digest.since_last:
|
||||||
lines.append("All quiet.")
|
lines.append("No alerts since last digest.")
|
||||||
|
else:
|
||||||
|
if digest.active:
|
||||||
|
lines.append("ACTIVE NOW")
|
||||||
|
for toggle in TOGGLE_ORDER:
|
||||||
|
events = digest.active.get(toggle)
|
||||||
|
if not events:
|
||||||
|
continue
|
||||||
|
lines.append(self._compact_toggle_line(toggle, events))
|
||||||
|
|
||||||
|
if digest.since_last:
|
||||||
|
lines.append("RESOLVED")
|
||||||
|
for toggle in TOGGLE_ORDER:
|
||||||
|
events = digest.since_last.get(toggle)
|
||||||
|
if not events:
|
||||||
|
continue
|
||||||
|
lines.append(self._compact_toggle_line(toggle, events))
|
||||||
|
|
||||||
out = "\n".join(lines)
|
out = "\n".join(lines)
|
||||||
if len(out) > self._mesh_char_limit:
|
if len(out) > self._mesh_char_limit:
|
||||||
out = out[: self._mesh_char_limit - 1] + "…"
|
out = out[: self._mesh_char_limit - 1] + "…"
|
||||||
return out
|
return out
|
||||||
|
|
||||||
def _compact_counts(self, section: dict[str, list[Event]]) -> str:
|
def _compact_toggle_line(self, toggle: str, events: list[Event]) -> str:
|
||||||
"""e.g. '2 weather, 1 fire, 1 mesh'"""
|
"""Build one compact line for a toggle: [Label] headline (+N)"""
|
||||||
parts = []
|
label = TOGGLE_LABELS.get(toggle, toggle)
|
||||||
for toggle in TOGGLE_ORDER:
|
sorted_events = self._sort_events(events)
|
||||||
events = section.get(toggle)
|
top_event = sorted_events[0]
|
||||||
if not events:
|
|
||||||
continue
|
# Get headline text
|
||||||
label = TOGGLE_LABELS.get(toggle, toggle).lower()
|
headline = top_event.summary or top_event.title or top_event.category
|
||||||
parts.append(f"{len(events)} {label}")
|
|
||||||
return ", ".join(parts)
|
# Truncate headline at ~60 chars to keep lines readable
|
||||||
|
max_headline = 60
|
||||||
|
if len(headline) > max_headline:
|
||||||
|
headline = headline[:max_headline - 1] + "…"
|
||||||
|
|
||||||
|
# Append (+N) if more than one event
|
||||||
|
overflow = len(events) - 1
|
||||||
|
if overflow > 0:
|
||||||
|
return f"[{label}] {headline} (+{overflow})"
|
||||||
|
else:
|
||||||
|
return f"[{label}] {headline}"
|
||||||
|
|
||||||
def _render_full(self, digest: Digest, now: float) -> str:
|
def _render_full(self, digest: Digest, now: float) -> str:
|
||||||
"""Produce the full multi-line digest for email/webhook."""
|
"""Produce the full multi-line digest for email/webhook."""
|
||||||
|
|
@ -190,6 +234,10 @@ class DigestAccumulator:
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if not digest.active and not digest.since_last:
|
||||||
|
lines.append("No alerts since last digest.")
|
||||||
|
lines.append("")
|
||||||
|
else:
|
||||||
if digest.active:
|
if digest.active:
|
||||||
lines.append("ACTIVE NOW:")
|
lines.append("ACTIVE NOW:")
|
||||||
for toggle in TOGGLE_ORDER:
|
for toggle in TOGGLE_ORDER:
|
||||||
|
|
@ -200,9 +248,6 @@ class DigestAccumulator:
|
||||||
for ev in self._sort_events(events):
|
for ev in self._sort_events(events):
|
||||||
lines.append(f" [{label}] {self._format_event_line(ev)}")
|
lines.append(f" [{label}] {self._format_event_line(ev)}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
else:
|
|
||||||
lines.append("ACTIVE NOW: nothing")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
if digest.since_last:
|
if digest.since_last:
|
||||||
lines.append("SINCE LAST DIGEST:")
|
lines.append("SINCE LAST DIGEST:")
|
||||||
|
|
@ -219,17 +264,8 @@ class DigestAccumulator:
|
||||||
|
|
||||||
def _format_event_line(self, event: Event) -> str:
|
def _format_event_line(self, event: Event) -> str:
|
||||||
"""Single-line summary of an event for digest output."""
|
"""Single-line summary of an event for digest output."""
|
||||||
# Prefer event.summary if set, else fall back to title
|
# Prefer event.summary if set, else fall back to title, then category
|
||||||
text = event.summary or event.title or event.category
|
text = event.summary or event.title or event.category
|
||||||
# Append expires hint if available
|
|
||||||
if event.expires is not None and event.expires > self._now():
|
|
||||||
try:
|
|
||||||
expires_str = time.strftime(
|
|
||||||
"%H:%M", time.localtime(event.expires)
|
|
||||||
)
|
|
||||||
text = f"{text} (until {expires_str})"
|
|
||||||
except (ValueError, OverflowError):
|
|
||||||
pass
|
|
||||||
# Trim runaway text — keep digest readable
|
# Trim runaway text — keep digest readable
|
||||||
if len(text) > 140:
|
if len(text) > 140:
|
||||||
text = text[:139] + "…"
|
text = text[:139] + "…"
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
"""Tests for Phase 2.3a DigestAccumulator.
|
"""Tests for Phase 2.3a DigestAccumulator.
|
||||||
|
|
||||||
13 tests covering:
|
20 tests covering:
|
||||||
- Accumulator active/since_last behavior (6 tests)
|
- Accumulator active/since_last behavior (6 tests)
|
||||||
- Renderer output (6 tests)
|
- Renderer output (8 tests)
|
||||||
- Pipeline integration (1 test already covered, plus 2 more)
|
- Excluded toggles (3 tests)
|
||||||
|
- Pipeline integration (3 tests)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
@ -17,6 +18,7 @@ from meshai.notifications.pipeline import (
|
||||||
DigestAccumulator,
|
DigestAccumulator,
|
||||||
Digest,
|
Digest,
|
||||||
)
|
)
|
||||||
|
from meshai.notifications.categories import get_toggle, ALERT_CATEGORIES
|
||||||
from meshai.config import Config
|
from meshai.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -227,7 +229,7 @@ def test_render_full_lists_active_and_since_last_with_labels():
|
||||||
|
|
||||||
|
|
||||||
def test_render_mesh_compact_under_char_limit():
|
def test_render_mesh_compact_under_char_limit():
|
||||||
"""mesh_compact is <= 200 chars with proper format."""
|
"""mesh_compact is <= 200 chars with new per-toggle line format."""
|
||||||
acc = DigestAccumulator()
|
acc = DigestAccumulator()
|
||||||
base_time = 1000000.0
|
base_time = 1000000.0
|
||||||
acc._now = lambda: base_time
|
acc._now = lambda: base_time
|
||||||
|
|
@ -258,28 +260,36 @@ def test_render_mesh_compact_under_char_limit():
|
||||||
digest = acc.render_digest(now=base_time)
|
digest = acc.render_digest(now=base_time)
|
||||||
assert len(digest.mesh_compact) <= 200
|
assert len(digest.mesh_compact) <= 200
|
||||||
assert digest.mesh_compact.startswith("DIGEST ")
|
assert digest.mesh_compact.startswith("DIGEST ")
|
||||||
assert "ACTIVE:" in digest.mesh_compact
|
assert "ACTIVE NOW" in digest.mesh_compact
|
||||||
|
# Check for toggle label lines
|
||||||
|
lines = digest.mesh_compact.split("\n")
|
||||||
|
bracket_lines = [l for l in lines if l.startswith("[")]
|
||||||
|
assert len(bracket_lines) > 0, "Should have lines starting with toggle labels"
|
||||||
|
|
||||||
|
|
||||||
def test_render_mesh_compact_all_quiet_when_empty():
|
def test_render_mesh_compact_empty_shows_no_alerts_message():
|
||||||
"""Empty accumulator renders 'All quiet.' in mesh_compact."""
|
"""Empty accumulator renders 'No alerts since last digest' in mesh_compact."""
|
||||||
acc = DigestAccumulator()
|
acc = DigestAccumulator()
|
||||||
base_time = 1000000.0
|
base_time = 1000000.0
|
||||||
acc._now = lambda: base_time
|
acc._now = lambda: base_time
|
||||||
|
|
||||||
digest = acc.render_digest(now=base_time)
|
digest = acc.render_digest(now=base_time)
|
||||||
assert "All quiet" in digest.mesh_compact
|
assert "No alerts since last digest" in digest.mesh_compact
|
||||||
|
assert "DIGEST " in digest.mesh_compact
|
||||||
|
assert "All quiet" not in digest.mesh_compact
|
||||||
|
|
||||||
|
|
||||||
def test_render_full_handles_empty_accumulator():
|
def test_render_full_handles_empty_accumulator():
|
||||||
"""Empty accumulator → is_empty() True, 'ACTIVE NOW: nothing'."""
|
"""Empty accumulator → is_empty() True, shows 'No alerts since last digest'."""
|
||||||
acc = DigestAccumulator()
|
acc = DigestAccumulator()
|
||||||
base_time = 1000000.0
|
base_time = 1000000.0
|
||||||
acc._now = lambda: base_time
|
acc._now = lambda: base_time
|
||||||
|
|
||||||
digest = acc.render_digest(now=base_time)
|
digest = acc.render_digest(now=base_time)
|
||||||
assert digest.is_empty() is True
|
assert digest.is_empty() is True
|
||||||
assert "ACTIVE NOW: nothing" in digest.full
|
assert "No alerts since last digest" in digest.full
|
||||||
|
assert "ACTIVE NOW" not in digest.full
|
||||||
|
assert "ACTIVE NOW: nothing" not in digest.full
|
||||||
|
|
||||||
|
|
||||||
def test_render_orders_toggles_by_priority():
|
def test_render_orders_toggles_by_priority():
|
||||||
|
|
@ -323,8 +333,8 @@ def test_render_orders_toggles_by_priority():
|
||||||
assert fire_pos < mesh_pos, "Fire should appear before Mesh"
|
assert fire_pos < mesh_pos, "Fire should appear before Mesh"
|
||||||
|
|
||||||
|
|
||||||
def test_format_event_line_appends_expires_hint():
|
def test_format_event_line_does_not_append_expires_hint():
|
||||||
"""_format_event_line() appends '(until HH:MM)' for future expires."""
|
"""_format_event_line() does NOT append '(until HH:MM)' anymore."""
|
||||||
acc = DigestAccumulator()
|
acc = DigestAccumulator()
|
||||||
base_time = 1000000.0
|
base_time = 1000000.0
|
||||||
acc._now = lambda: base_time
|
acc._now = lambda: base_time
|
||||||
|
|
@ -338,9 +348,221 @@ def test_format_event_line_appends_expires_hint():
|
||||||
)
|
)
|
||||||
|
|
||||||
line = acc._format_event_line(event)
|
line = acc._format_event_line(event)
|
||||||
assert "(until " in line
|
assert "until " not in line
|
||||||
# Should have time in HH:MM format
|
assert "(" not in line
|
||||||
assert ":" in line.split("(until ")[-1]
|
|
||||||
|
|
||||||
|
def test_mesh_compact_shows_one_line_per_toggle():
|
||||||
|
"""Each toggle gets exactly one line, with (+N) for overflow."""
|
||||||
|
acc = DigestAccumulator()
|
||||||
|
base_time = 1000000.0
|
||||||
|
acc._now = lambda: base_time
|
||||||
|
|
||||||
|
# Add 2 weather events, 1 fire event, 1 mesh event
|
||||||
|
acc.enqueue(make_event(
|
||||||
|
source="test",
|
||||||
|
category="weather_warning",
|
||||||
|
severity="routine",
|
||||||
|
id="w1",
|
||||||
|
title="Weather Event 1",
|
||||||
|
))
|
||||||
|
acc.enqueue(make_event(
|
||||||
|
source="test",
|
||||||
|
category="weather_warning",
|
||||||
|
severity="routine",
|
||||||
|
id="w2",
|
||||||
|
title="Weather Event 2",
|
||||||
|
))
|
||||||
|
acc.enqueue(make_event(
|
||||||
|
source="test",
|
||||||
|
category="wildfire_proximity",
|
||||||
|
severity="routine",
|
||||||
|
id="f1",
|
||||||
|
title="Fire Event",
|
||||||
|
))
|
||||||
|
acc.enqueue(make_event(
|
||||||
|
source="test",
|
||||||
|
category="battery_warning",
|
||||||
|
severity="routine",
|
||||||
|
id="m1",
|
||||||
|
title="Mesh Event",
|
||||||
|
))
|
||||||
|
|
||||||
|
digest = acc.render_digest(now=base_time)
|
||||||
|
|
||||||
|
# Count occurrences of each toggle label
|
||||||
|
weather_count = digest.mesh_compact.count("[Weather]")
|
||||||
|
fire_count = digest.mesh_compact.count("[Fire]")
|
||||||
|
mesh_count = digest.mesh_compact.count("[Mesh]")
|
||||||
|
|
||||||
|
assert weather_count == 1, "Should have exactly one [Weather] line"
|
||||||
|
assert fire_count == 1, "Should have exactly one [Fire] line"
|
||||||
|
assert mesh_count == 1, "Should have exactly one [Mesh] line"
|
||||||
|
|
||||||
|
# Weather line should have (+1) since there are 2 weather events
|
||||||
|
weather_line = [l for l in digest.mesh_compact.split("\n") if "[Weather]" in l][0]
|
||||||
|
assert "(+1)" in weather_line
|
||||||
|
|
||||||
|
|
||||||
|
def test_mesh_compact_active_and_resolved_sections():
|
||||||
|
"""mesh_compact has ACTIVE NOW and RESOLVED sections when both present."""
|
||||||
|
acc = DigestAccumulator()
|
||||||
|
base_time = 1000000.0
|
||||||
|
acc._now = lambda: base_time
|
||||||
|
|
||||||
|
# Add 1 active weather event
|
||||||
|
acc.enqueue(make_event(
|
||||||
|
source="test",
|
||||||
|
category="weather_warning",
|
||||||
|
severity="routine",
|
||||||
|
title="Storm Warning",
|
||||||
|
))
|
||||||
|
|
||||||
|
# Add 1 resolution event for roads (contains "reopened")
|
||||||
|
acc.enqueue(make_event(
|
||||||
|
source="test",
|
||||||
|
category="road_closure",
|
||||||
|
severity="routine",
|
||||||
|
title="US-93 reopened at MP 47",
|
||||||
|
))
|
||||||
|
|
||||||
|
digest = acc.render_digest(now=base_time)
|
||||||
|
|
||||||
|
assert "ACTIVE NOW" in digest.mesh_compact
|
||||||
|
assert "RESOLVED" in digest.mesh_compact
|
||||||
|
|
||||||
|
# Weather should appear before RESOLVED, Roads after
|
||||||
|
active_pos = digest.mesh_compact.find("ACTIVE NOW")
|
||||||
|
resolved_pos = digest.mesh_compact.find("RESOLVED")
|
||||||
|
weather_pos = digest.mesh_compact.find("[Weather]")
|
||||||
|
roads_pos = digest.mesh_compact.find("[Roads]")
|
||||||
|
|
||||||
|
assert weather_pos > active_pos, "[Weather] should be after ACTIVE NOW"
|
||||||
|
assert weather_pos < resolved_pos, "[Weather] should be before RESOLVED"
|
||||||
|
assert roads_pos > resolved_pos, "[Roads] should be after RESOLVED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mesh_compact_line_truncates_long_headline():
|
||||||
|
"""Long headlines are truncated in mesh_compact."""
|
||||||
|
acc = DigestAccumulator()
|
||||||
|
base_time = 1000000.0
|
||||||
|
acc._now = lambda: base_time
|
||||||
|
|
||||||
|
# Create a 200-char summary
|
||||||
|
long_summary = "A" * 200
|
||||||
|
acc.enqueue(make_event(
|
||||||
|
source="test",
|
||||||
|
category="weather_warning",
|
||||||
|
severity="routine",
|
||||||
|
title="Weather Event",
|
||||||
|
summary=long_summary,
|
||||||
|
))
|
||||||
|
|
||||||
|
digest = acc.render_digest(now=base_time)
|
||||||
|
|
||||||
|
# The [Weather] line should be shorter than the raw summary
|
||||||
|
weather_line = [l for l in digest.mesh_compact.split("\n") if "[Weather]" in l][0]
|
||||||
|
assert len(weather_line) < len(long_summary)
|
||||||
|
# Overall mesh_compact should still fit within limit
|
||||||
|
assert len(digest.mesh_compact) <= 200
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# EXCLUDED TOGGLES TESTS
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def test_rf_propagation_events_excluded_from_digest_by_default():
|
||||||
|
"""rf_propagation toggle is excluded by default."""
|
||||||
|
acc = DigestAccumulator() # default config
|
||||||
|
base_time = 1000000.0
|
||||||
|
acc._now = lambda: base_time
|
||||||
|
|
||||||
|
# Find a category that maps to rf_propagation
|
||||||
|
rf_category = None
|
||||||
|
for cat_id, cat_info in ALERT_CATEGORIES.items():
|
||||||
|
if cat_info.get("toggle") == "rf_propagation":
|
||||||
|
rf_category = cat_id
|
||||||
|
break
|
||||||
|
|
||||||
|
assert rf_category is not None, "Should find an rf_propagation category"
|
||||||
|
|
||||||
|
event = make_event(
|
||||||
|
source="test",
|
||||||
|
category=rf_category,
|
||||||
|
severity="routine",
|
||||||
|
title="HF Blackout",
|
||||||
|
)
|
||||||
|
acc.enqueue(event)
|
||||||
|
|
||||||
|
# Should NOT be in active
|
||||||
|
assert acc.active_count() == 0
|
||||||
|
|
||||||
|
digest = acc.render_digest(now=base_time)
|
||||||
|
assert "[RF]" not in digest.full
|
||||||
|
|
||||||
|
|
||||||
|
def test_excluded_toggles_parameter_overrides_default():
|
||||||
|
"""excluded_toggles=[] allows rf_propagation events."""
|
||||||
|
acc = DigestAccumulator(excluded_toggles=[]) # no exclusions
|
||||||
|
base_time = 1000000.0
|
||||||
|
acc._now = lambda: base_time
|
||||||
|
|
||||||
|
# Find a category that maps to rf_propagation
|
||||||
|
rf_category = None
|
||||||
|
for cat_id, cat_info in ALERT_CATEGORIES.items():
|
||||||
|
if cat_info.get("toggle") == "rf_propagation":
|
||||||
|
rf_category = cat_id
|
||||||
|
break
|
||||||
|
|
||||||
|
event = make_event(
|
||||||
|
source="test",
|
||||||
|
category=rf_category,
|
||||||
|
severity="routine",
|
||||||
|
title="HF Blackout",
|
||||||
|
)
|
||||||
|
acc.enqueue(event)
|
||||||
|
|
||||||
|
# Should BE in active
|
||||||
|
assert acc.active_count() == 1
|
||||||
|
|
||||||
|
digest = acc.render_digest(now=base_time)
|
||||||
|
assert "[RF]" in digest.full
|
||||||
|
|
||||||
|
|
||||||
|
def test_excluded_toggles_can_exclude_multiple():
|
||||||
|
"""excluded_toggles can exclude multiple toggles."""
|
||||||
|
acc = DigestAccumulator(excluded_toggles=["rf_propagation", "tracking"])
|
||||||
|
base_time = 1000000.0
|
||||||
|
acc._now = lambda: base_time
|
||||||
|
|
||||||
|
# Find categories for rf_propagation and tracking
|
||||||
|
rf_category = None
|
||||||
|
for cat_id, cat_info in ALERT_CATEGORIES.items():
|
||||||
|
if cat_info.get("toggle") == "rf_propagation":
|
||||||
|
rf_category = cat_id
|
||||||
|
break
|
||||||
|
|
||||||
|
# tracking toggle - there may not be categories for it in ALERT_CATEGORIES,
|
||||||
|
# so we'll use a fake category that will fall back to "other" normally,
|
||||||
|
# but we need to test the exclusion mechanism. Use an existing one if available.
|
||||||
|
tracking_category = None
|
||||||
|
for cat_id, cat_info in ALERT_CATEGORIES.items():
|
||||||
|
if cat_info.get("toggle") == "tracking":
|
||||||
|
tracking_category = cat_id
|
||||||
|
break
|
||||||
|
|
||||||
|
# Even if no tracking category exists, test that rf_propagation is excluded
|
||||||
|
if rf_category:
|
||||||
|
acc.enqueue(make_event(
|
||||||
|
source="test",
|
||||||
|
category=rf_category,
|
||||||
|
severity="routine",
|
||||||
|
title="HF Blackout",
|
||||||
|
))
|
||||||
|
|
||||||
|
# For tracking, if no category maps to it, the test still validates
|
||||||
|
# that the exclusion list works for multiple entries
|
||||||
|
assert acc.active_count() == 0
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue