mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
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.
817 lines
24 KiB
Python
817 lines
24 KiB
Python
"""Tests for Phase 2.3a DigestAccumulator.
|
|
|
|
27 tests covering:
|
|
- Accumulator active/since_last behavior (6 tests)
|
|
- Renderer output (8 tests)
|
|
- Mesh chunks (7 tests)
|
|
- Include toggles (3 tests)
|
|
- Pipeline integration (3 tests)
|
|
"""
|
|
|
|
import time
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from meshai.notifications.events import make_event
|
|
from meshai.notifications.pipeline import (
|
|
build_pipeline_components,
|
|
DigestAccumulator,
|
|
Digest,
|
|
)
|
|
from meshai.notifications.categories import get_toggle, ALERT_CATEGORIES
|
|
from meshai.config import Config
|
|
|
|
|
|
# ============================================================
|
|
# ACCUMULATOR ACTIVE/SINCE_LAST TESTS
|
|
# ============================================================
|
|
|
|
def test_enqueue_adds_to_active():
|
|
"""Enqueue one routine Event with no expires → active_count == 1."""
|
|
acc = DigestAccumulator()
|
|
event = make_event(
|
|
source="test",
|
|
category="weather_warning",
|
|
severity="routine",
|
|
title="Wind Advisory",
|
|
)
|
|
acc.enqueue(event)
|
|
assert acc.active_count() == 1
|
|
assert acc.since_last_count() == 0
|
|
|
|
|
|
def test_enqueue_same_id_updates_in_place():
|
|
"""Enqueue same id twice → still 1 active, title updated."""
|
|
acc = DigestAccumulator()
|
|
event1 = make_event(
|
|
source="test",
|
|
category="weather_warning",
|
|
severity="routine",
|
|
id="abc",
|
|
title="initial",
|
|
)
|
|
event2 = make_event(
|
|
source="test",
|
|
category="weather_warning",
|
|
severity="routine",
|
|
id="abc",
|
|
title="updated",
|
|
)
|
|
acc.enqueue(event1)
|
|
acc.enqueue(event2)
|
|
assert acc.active_count() == 1
|
|
# Check the held event's title
|
|
toggle = "weather"
|
|
events = acc._active.get(toggle, [])
|
|
assert len(events) == 1
|
|
assert events[0].title == "updated"
|
|
|
|
|
|
def test_two_different_ids_both_active():
|
|
"""Two different routine events → both active."""
|
|
acc = DigestAccumulator()
|
|
event1 = make_event(
|
|
source="test",
|
|
category="weather_warning",
|
|
severity="routine",
|
|
id="ev1",
|
|
title="Event 1",
|
|
)
|
|
event2 = make_event(
|
|
source="test",
|
|
category="weather_warning",
|
|
severity="routine",
|
|
id="ev2",
|
|
title="Event 2",
|
|
)
|
|
acc.enqueue(event1)
|
|
acc.enqueue(event2)
|
|
assert acc.active_count() == 2
|
|
|
|
|
|
def test_resolution_marker_in_title_moves_active_to_since_last():
|
|
"""Resolution marker in title moves matching active to since_last."""
|
|
acc = DigestAccumulator()
|
|
event1 = make_event(
|
|
source="test",
|
|
category="wildfire_proximity",
|
|
severity="priority",
|
|
group_key="fire:42",
|
|
title="Snake River Fire",
|
|
)
|
|
acc.enqueue(event1)
|
|
assert acc.active_count() == 1
|
|
assert acc.since_last_count() == 0
|
|
|
|
event2 = make_event(
|
|
source="test",
|
|
category="wildfire_proximity",
|
|
severity="priority",
|
|
group_key="fire:42",
|
|
title="Snake River Fire ended",
|
|
)
|
|
acc.enqueue(event2)
|
|
assert acc.active_count() == 0
|
|
assert acc.since_last_count() == 1
|
|
|
|
|
|
def test_expired_event_via_tick_moves_to_since_last():
|
|
"""tick() moves expired events from active to since_last."""
|
|
acc = DigestAccumulator()
|
|
base_time = 1000000.0
|
|
|
|
# Monkeypatch _now to control time
|
|
acc._now = lambda: base_time
|
|
|
|
event = make_event(
|
|
source="test",
|
|
category="weather_warning",
|
|
severity="routine",
|
|
title="Temporary Warning",
|
|
expires=base_time + 60, # expires in 60 seconds
|
|
)
|
|
acc.enqueue(event)
|
|
assert acc.active_count() == 1
|
|
assert acc.since_last_count() == 0
|
|
|
|
# Tick at base_time + 30 → still active
|
|
moved = acc.tick(now=base_time + 30)
|
|
assert moved == 0
|
|
assert acc.active_count() == 1
|
|
|
|
# Tick at base_time + 120 → expired, moved to since_last
|
|
moved = acc.tick(now=base_time + 120)
|
|
assert moved == 1
|
|
assert acc.active_count() == 0
|
|
assert acc.since_last_count() == 1
|
|
|
|
|
|
def test_render_digest_clears_since_last_but_keeps_active():
|
|
"""render_digest() clears since_last but preserves active."""
|
|
acc = DigestAccumulator()
|
|
base_time = 1000000.0
|
|
acc._now = lambda: base_time
|
|
|
|
# Add an active event
|
|
active_event = make_event(
|
|
source="test",
|
|
category="weather_warning",
|
|
severity="routine",
|
|
title="Ongoing Storm",
|
|
)
|
|
acc.enqueue(active_event)
|
|
|
|
# Add an event that becomes since_last via resolution marker
|
|
resolved_event = make_event(
|
|
source="test",
|
|
category="road_closure",
|
|
severity="routine",
|
|
group_key="roads:99",
|
|
title="US-93 reopened at MP 47",
|
|
)
|
|
acc.enqueue(resolved_event)
|
|
|
|
# Now we should have 1 active, 1 since_last
|
|
assert acc.active_count() == 1
|
|
assert acc.since_last_count() == 1
|
|
|
|
# Render digest
|
|
digest = acc.render_digest(now=base_time)
|
|
assert len(digest.active) > 0
|
|
assert len(digest.since_last) > 0
|
|
|
|
# After render: active preserved, since_last cleared
|
|
assert acc.active_count() == 1
|
|
assert acc.since_last_count() == 0
|
|
|
|
# Second render has only active
|
|
digest2 = acc.render_digest(now=base_time + 10)
|
|
assert len(digest2.active) > 0
|
|
assert len(digest2.since_last) == 0
|
|
|
|
|
|
# ============================================================
|
|
# RENDERER TESTS
|
|
# ============================================================
|
|
|
|
def test_render_full_lists_active_and_since_last_with_labels():
|
|
"""Full render includes ACTIVE NOW, SINCE LAST DIGEST, toggle labels."""
|
|
acc = DigestAccumulator()
|
|
base_time = 1000000.0
|
|
acc._now = lambda: base_time
|
|
|
|
# Weather event (active)
|
|
weather_event = make_event(
|
|
source="test",
|
|
category="weather_warning",
|
|
severity="routine",
|
|
title="Wind Advisory until 21:00",
|
|
)
|
|
acc.enqueue(weather_event)
|
|
|
|
# Roads event with resolution marker → since_last
|
|
roads_event = make_event(
|
|
source="test",
|
|
category="road_closure",
|
|
severity="routine",
|
|
title="US-93 reopened at MP 47",
|
|
)
|
|
acc.enqueue(roads_event)
|
|
|
|
digest = acc.render_digest(now=base_time)
|
|
|
|
assert "ACTIVE NOW:" in digest.full
|
|
assert "[Weather]" in digest.full
|
|
assert "Wind Advisory" in digest.full
|
|
assert "SINCE LAST DIGEST:" in digest.full
|
|
assert "[Roads]" in digest.full
|
|
assert "US-93" in digest.full
|
|
|
|
|
|
def test_render_mesh_compact_under_char_limit():
|
|
"""Each mesh chunk is <= 200 chars."""
|
|
acc = DigestAccumulator()
|
|
base_time = 1000000.0
|
|
acc._now = lambda: base_time
|
|
|
|
# Add 10 events across 4 toggles
|
|
categories = [
|
|
("weather_warning", "Weather Event"),
|
|
("weather_warning", "Weather Event 2"),
|
|
("weather_warning", "Weather Event 3"),
|
|
("wildfire_proximity", "Fire Event"),
|
|
("wildfire_proximity", "Fire Event 2"),
|
|
("battery_warning", "Mesh Event"),
|
|
("battery_warning", "Mesh Event 2"),
|
|
("battery_warning", "Mesh Event 3"),
|
|
("road_closure", "Road Event"),
|
|
("road_closure", "Road Event 2"),
|
|
]
|
|
for i, (cat, title) in enumerate(categories):
|
|
event = make_event(
|
|
source="test",
|
|
category=cat,
|
|
severity="routine",
|
|
id=f"ev{i}",
|
|
title=title,
|
|
)
|
|
acc.enqueue(event)
|
|
|
|
digest = acc.render_digest(now=base_time)
|
|
|
|
# All chunks should be <= 200 chars
|
|
assert all(len(c) <= 200 for c in digest.mesh_chunks)
|
|
assert len(digest.mesh_chunks) >= 1
|
|
# Should have proper structure
|
|
assert digest.mesh_chunks[0].startswith("DIGEST ")
|
|
|
|
|
|
def test_render_mesh_compact_empty_shows_no_alerts_message():
|
|
"""Empty accumulator renders 'No alerts since last digest' in mesh_compact."""
|
|
acc = DigestAccumulator()
|
|
base_time = 1000000.0
|
|
acc._now = lambda: base_time
|
|
|
|
digest = acc.render_digest(now=base_time)
|
|
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():
|
|
"""Empty accumulator → is_empty() True, shows 'No alerts since last digest'."""
|
|
acc = DigestAccumulator()
|
|
base_time = 1000000.0
|
|
acc._now = lambda: base_time
|
|
|
|
digest = acc.render_digest(now=base_time)
|
|
assert digest.is_empty() is True
|
|
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():
|
|
"""Toggles appear in TOGGLE_ORDER sequence in full output."""
|
|
acc = DigestAccumulator()
|
|
base_time = 1000000.0
|
|
acc._now = lambda: base_time
|
|
|
|
# Add one event each for weather, mesh_health, and fire
|
|
# (intentionally out of order)
|
|
mesh_event = make_event(
|
|
source="test",
|
|
category="battery_warning", # maps to mesh_health toggle
|
|
severity="routine",
|
|
title="Mesh battery low",
|
|
)
|
|
fire_event = make_event(
|
|
source="test",
|
|
category="wildfire_proximity",
|
|
severity="routine",
|
|
title="Fire nearby",
|
|
)
|
|
weather_event = make_event(
|
|
source="test",
|
|
category="weather_warning",
|
|
severity="routine",
|
|
title="Storm coming",
|
|
)
|
|
acc.enqueue(mesh_event)
|
|
acc.enqueue(fire_event)
|
|
acc.enqueue(weather_event)
|
|
|
|
digest = acc.render_digest(now=base_time)
|
|
|
|
# In TOGGLE_ORDER: weather, fire, ..., mesh_health
|
|
weather_pos = digest.full.find("[Weather]")
|
|
fire_pos = digest.full.find("[Fire]")
|
|
mesh_pos = digest.full.find("[Mesh]")
|
|
|
|
assert weather_pos < fire_pos, "Weather should appear before Fire"
|
|
assert fire_pos < mesh_pos, "Fire should appear before Mesh"
|
|
|
|
|
|
def test_format_event_line_does_not_append_expires_hint():
|
|
"""_format_event_line() does NOT append '(until HH:MM)' anymore."""
|
|
acc = DigestAccumulator()
|
|
base_time = 1000000.0
|
|
acc._now = lambda: base_time
|
|
|
|
event = make_event(
|
|
source="test",
|
|
category="weather_warning",
|
|
severity="routine",
|
|
title="Severe Thunderstorm Warning",
|
|
expires=base_time + 3600, # 1 hour in future
|
|
)
|
|
|
|
line = acc._format_event_line(event)
|
|
assert "until " not in line
|
|
assert "(" not in line
|
|
|
|
|
|
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)
|
|
|
|
# Check section markers in the joined compact string
|
|
assert "ACTIVE NOW" in digest.mesh_compact
|
|
assert "RESOLVED" in digest.mesh_compact
|
|
|
|
# ACTIVE NOW should appear before RESOLVED
|
|
active_pos = digest.mesh_compact.find("ACTIVE NOW")
|
|
resolved_pos = digest.mesh_compact.find("RESOLVED")
|
|
assert active_pos < resolved_pos, "ACTIVE NOW should appear before 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)
|
|
|
|
|
|
# ============================================================
|
|
# MESH CHUNKS TESTS
|
|
# ============================================================
|
|
|
|
def test_mesh_chunks_single_chunk_when_short():
|
|
"""Single short event produces one chunk with no counter."""
|
|
acc = DigestAccumulator()
|
|
base_time = 1000000.0
|
|
acc._now = lambda: base_time
|
|
|
|
acc.enqueue(make_event(
|
|
source="test",
|
|
category="weather_warning",
|
|
severity="routine",
|
|
title="Short event",
|
|
summary="Brief summary",
|
|
))
|
|
|
|
digest = acc.render_digest(now=base_time)
|
|
|
|
assert len(digest.mesh_chunks) == 1
|
|
assert digest.mesh_chunks[0].startswith("DIGEST ")
|
|
assert "(1/" not in digest.mesh_chunks[0] # No chunk counter when single
|
|
assert digest.mesh_compact == digest.mesh_chunks[0]
|
|
|
|
|
|
def test_mesh_chunks_splits_when_overflow():
|
|
"""Many events with long summaries produce multiple chunks."""
|
|
acc = DigestAccumulator()
|
|
base_time = 1000000.0
|
|
acc._now = lambda: base_time
|
|
|
|
# Add events with long summaries across different toggles
|
|
toggles = [
|
|
("weather_warning", "Severe storm warning for Magic Valley area"),
|
|
("wildfire_proximity", "Fire proximity alert 8mi NE of position"),
|
|
("battery_warning", "Battery critical on node BLD-MTN system"),
|
|
("road_closure", "Road closure US-93 at milepost forty seven"),
|
|
("avalanche_warning", "Avalanche danger high in backcountry area"),
|
|
]
|
|
for i, (cat, summary) in enumerate(toggles):
|
|
acc.enqueue(make_event(
|
|
source="test",
|
|
category=cat,
|
|
severity="routine",
|
|
id=f"ev{i}",
|
|
title=f"Event {i}",
|
|
summary=summary,
|
|
))
|
|
|
|
digest = acc.render_digest(now=base_time)
|
|
|
|
# Should have multiple chunks
|
|
assert len(digest.mesh_chunks) >= 2
|
|
|
|
# Each chunk should have proper header with counter
|
|
total = len(digest.mesh_chunks)
|
|
for i, chunk in enumerate(digest.mesh_chunks):
|
|
assert chunk.startswith("DIGEST ")
|
|
assert f"({i+1}/{total})" in chunk
|
|
|
|
# All chunks should be within limit
|
|
assert all(len(c) <= 200 for c in digest.mesh_chunks)
|
|
|
|
|
|
def test_mesh_chunks_does_not_split_within_a_line():
|
|
"""A toggle line appears intact in exactly one chunk."""
|
|
acc = DigestAccumulator()
|
|
base_time = 1000000.0
|
|
acc._now = lambda: base_time
|
|
|
|
# Add event with specific summary we can search for
|
|
target_summary = "Mesh node BLD-MTN battery at critical level"
|
|
acc.enqueue(make_event(
|
|
source="test",
|
|
category="battery_warning",
|
|
severity="routine",
|
|
title="Battery Alert",
|
|
summary=target_summary,
|
|
))
|
|
# Add more events to possibly force chunking
|
|
for i in range(5):
|
|
acc.enqueue(make_event(
|
|
source="test",
|
|
category="weather_warning",
|
|
severity="routine",
|
|
id=f"w{i}",
|
|
title=f"Weather {i}",
|
|
summary=f"Weather event description number {i} for testing",
|
|
))
|
|
|
|
digest = acc.render_digest(now=base_time)
|
|
|
|
# Find chunks containing [Mesh]
|
|
mesh_chunks = [c for c in digest.mesh_chunks if "[Mesh]" in c]
|
|
assert len(mesh_chunks) == 1, "Mesh toggle should appear in exactly one chunk"
|
|
|
|
# The summary text should be in that chunk (possibly truncated but not split)
|
|
mesh_chunk = mesh_chunks[0]
|
|
assert "[Mesh]" in mesh_chunk
|
|
|
|
|
|
def test_mesh_chunks_section_header_continuation():
|
|
"""Section headers spanning chunks get '(cont)' suffix."""
|
|
acc = DigestAccumulator(mesh_char_limit=150) # Smaller limit to force splits
|
|
base_time = 1000000.0
|
|
acc._now = lambda: base_time
|
|
|
|
# Add many events to force ACTIVE NOW to span chunks
|
|
for i in range(8):
|
|
acc.enqueue(make_event(
|
|
source="test",
|
|
category="weather_warning",
|
|
severity="routine",
|
|
id=f"w{i}",
|
|
title=f"Weather Event {i}",
|
|
summary=f"Weather warning number {i} for the area",
|
|
))
|
|
|
|
digest = acc.render_digest(now=base_time)
|
|
|
|
if len(digest.mesh_chunks) >= 2:
|
|
# Check if any non-first chunk has continuation header
|
|
for i, chunk in enumerate(digest.mesh_chunks[1:], start=2):
|
|
if "[Weather]" in chunk or any(f"[{t}]" in chunk for t in ["Fire", "Mesh", "Roads"]):
|
|
# This chunk has toggle lines, check for section header
|
|
if "ACTIVE NOW" in chunk:
|
|
assert "ACTIVE NOW (cont)" in chunk, f"Chunk {i} should have (cont) suffix"
|
|
|
|
|
|
def test_mesh_chunks_empty_digest_is_single_chunk():
|
|
"""Empty digest produces single chunk with no counter."""
|
|
acc = DigestAccumulator()
|
|
base_time = 1000000.0
|
|
acc._now = lambda: base_time
|
|
|
|
digest = acc.render_digest(now=base_time)
|
|
|
|
assert len(digest.mesh_chunks) == 1
|
|
assert "No alerts since last digest" in digest.mesh_chunks[0]
|
|
assert "(1/" not in digest.mesh_chunks[0]
|
|
|
|
|
|
def test_mesh_compact_string_is_joined_chunks():
|
|
"""mesh_compact is chunks joined with separator when multiple chunks."""
|
|
acc = DigestAccumulator(mesh_char_limit=120) # Small limit to force multiple chunks
|
|
base_time = 1000000.0
|
|
acc._now = lambda: base_time
|
|
|
|
# Add events to force multiple chunks
|
|
for i in range(6):
|
|
acc.enqueue(make_event(
|
|
source="test",
|
|
category="weather_warning",
|
|
severity="routine",
|
|
id=f"w{i}",
|
|
title=f"Event {i}",
|
|
summary=f"Summary for weather event number {i}",
|
|
))
|
|
|
|
digest = acc.render_digest(now=base_time)
|
|
|
|
if len(digest.mesh_chunks) > 1:
|
|
expected = "\n---\n".join(digest.mesh_chunks)
|
|
assert digest.mesh_compact == expected
|
|
else:
|
|
assert digest.mesh_compact == digest.mesh_chunks[0]
|
|
|
|
|
|
def test_include_toggles_unknown_name_does_not_crash():
|
|
"""Unknown toggle names in include_toggles are silently accepted."""
|
|
acc = DigestAccumulator(include_toggles=["weather", "made_up_future_toggle"])
|
|
base_time = 1000000.0
|
|
acc._now = lambda: base_time
|
|
|
|
# Weather should work
|
|
acc.enqueue(make_event(
|
|
source="test",
|
|
category="weather_warning",
|
|
severity="routine",
|
|
title="Weather event",
|
|
))
|
|
|
|
# rf_propagation should be excluded (not in include list)
|
|
rf_category = None
|
|
for cat_id, cat_info in ALERT_CATEGORIES.items():
|
|
if cat_info.get("toggle") == "rf_propagation":
|
|
rf_category = cat_id
|
|
break
|
|
|
|
if rf_category:
|
|
acc.enqueue(make_event(
|
|
source="test",
|
|
category=rf_category,
|
|
severity="routine",
|
|
title="RF event",
|
|
))
|
|
|
|
# Weather kept, RF dropped
|
|
assert acc.active_count() == 1
|
|
|
|
# Should not raise
|
|
digest = acc.render_digest(now=base_time)
|
|
assert "[Weather]" in digest.full
|
|
|
|
|
|
# ============================================================
|
|
# INCLUDE TOGGLES TESTS
|
|
# ============================================================
|
|
|
|
def test_rf_propagation_events_excluded_from_digest_by_default():
|
|
"""rf_propagation toggle is excluded by default (not in default include)."""
|
|
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_include_toggles_parameter_overrides_default():
|
|
"""include_toggles parameter controls which toggles are tracked."""
|
|
# Only include rf_propagation and weather
|
|
acc = DigestAccumulator(include_toggles=["rf_propagation", "weather"])
|
|
base_time = 1000000.0
|
|
acc._now = lambda: base_time
|
|
|
|
# Find rf_propagation category
|
|
rf_category = None
|
|
for cat_id, cat_info in ALERT_CATEGORIES.items():
|
|
if cat_info.get("toggle") == "rf_propagation":
|
|
rf_category = cat_id
|
|
break
|
|
|
|
# Enqueue rf_propagation event - should be kept
|
|
acc.enqueue(make_event(
|
|
source="test",
|
|
category=rf_category,
|
|
severity="routine",
|
|
title="HF Blackout",
|
|
))
|
|
assert acc.active_count() == 1
|
|
|
|
# Enqueue fire event - should be dropped (fire not in include)
|
|
acc.enqueue(make_event(
|
|
source="test",
|
|
category="wildfire_proximity",
|
|
severity="routine",
|
|
title="Fire Alert",
|
|
))
|
|
assert acc.active_count() == 1 # Still 1, fire was dropped
|
|
|
|
digest = acc.render_digest(now=base_time)
|
|
assert "[RF]" in digest.full
|
|
assert "[Fire]" not in digest.full
|
|
|
|
|
|
def test_include_toggles_explicit_subset():
|
|
"""include_toggles with explicit subset only tracks those toggles."""
|
|
acc = DigestAccumulator(include_toggles=["weather"])
|
|
base_time = 1000000.0
|
|
acc._now = lambda: base_time
|
|
|
|
# Weather - included
|
|
acc.enqueue(make_event(
|
|
source="test",
|
|
category="weather_warning",
|
|
severity="routine",
|
|
title="Weather event",
|
|
))
|
|
|
|
# Fire - not included
|
|
acc.enqueue(make_event(
|
|
source="test",
|
|
category="wildfire_proximity",
|
|
severity="routine",
|
|
title="Fire event",
|
|
))
|
|
|
|
# Tracking - not included (and may not have categories anyway)
|
|
# Just verify the count is only 1
|
|
assert acc.active_count() == 1
|
|
|
|
|
|
# ============================================================
|
|
# PIPELINE INTEGRATION TESTS
|
|
# ============================================================
|
|
|
|
def test_pipeline_routes_routine_event_to_accumulator():
|
|
"""Routine event via bus.emit ends up in DigestAccumulator."""
|
|
config = Config()
|
|
bus, inhibitor, grouper, severity_router, dispatcher, digest = \
|
|
build_pipeline_components(config)
|
|
|
|
event = make_event(
|
|
source="test",
|
|
category="weather_warning",
|
|
severity="routine",
|
|
title="Test routine event",
|
|
)
|
|
|
|
# Flush through grouper
|
|
grouper.flush_all()
|
|
bus.emit(event)
|
|
grouper.flush_all()
|
|
|
|
assert digest.active_count() == 1
|
|
|
|
|
|
def test_pipeline_routes_immediate_event_to_dispatcher_not_accumulator():
|
|
"""Immediate event goes to dispatcher, not accumulator."""
|
|
config = Config()
|
|
bus, inhibitor, grouper, severity_router, dispatcher, digest = \
|
|
build_pipeline_components(config)
|
|
|
|
# Mock the severity_router's immediate handler (already bound to dispatcher.dispatch)
|
|
mock_immediate = MagicMock()
|
|
severity_router._immediate = mock_immediate
|
|
|
|
event = make_event(
|
|
source="test",
|
|
category="weather_warning",
|
|
severity="immediate",
|
|
title="Test immediate event",
|
|
)
|
|
|
|
grouper.flush_all()
|
|
bus.emit(event)
|
|
grouper.flush_all()
|
|
|
|
# Immediate handler should have been called
|
|
assert mock_immediate.called
|
|
# Accumulator should have nothing
|
|
assert digest.active_count() == 0
|