mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
fix(fire_digest): trim lines to name + location, correct tail count after budget trim
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bf88bdc9e6
commit
b5a8f5cf97
2 changed files with 118 additions and 33 deletions
|
|
@ -97,36 +97,50 @@ async def render_digest(*, now: Optional[int] = None) -> tuple[str, str]:
|
|||
|
||||
total = len(rows)
|
||||
top = rows[:2]
|
||||
remaining = total - len(top)
|
||||
|
||||
header = f"\U0001f525 Fire Digest \u2014 {total} active wildfire(s)"
|
||||
|
||||
fire_lines: list[str] = []
|
||||
for row in top:
|
||||
name = row["incident_name"] or "(unnamed)"
|
||||
anchor = f"{row['county']} Co" if row["county"] else row["state"] or ""
|
||||
contained = (f"{int(row['current_contained_pct'])}% cont"
|
||||
if row["current_contained_pct"] is not None else "uncontained")
|
||||
acres = (f"{int(row['current_acres']):,} ac"
|
||||
if row["current_acres"] else "size unknown")
|
||||
fire_lines.append(f"{name}: {acres}, {contained}, {anchor}")
|
||||
|
||||
tail = ""
|
||||
if remaining > 0:
|
||||
tail = f"There are {remaining} additional wildfires. DM me for the full list."
|
||||
county = row["county"]
|
||||
state = row["state"]
|
||||
if county and state:
|
||||
fire_lines.append(f"{name} in {county} Co, {state}")
|
||||
elif state:
|
||||
fire_lines.append(f"{name} in {state}")
|
||||
else:
|
||||
fire_lines.append(name)
|
||||
|
||||
# Assemble within ~200-byte LoRa budget; trim fire lines, never the tail
|
||||
shown: list[str] = []
|
||||
for line in fire_lines:
|
||||
# Estimate tail for budget check
|
||||
est_remaining = total - len(shown) - 1
|
||||
if est_remaining == 1:
|
||||
est_tail = "There is 1 additional wildfire. DM me for the full list."
|
||||
elif est_remaining > 1:
|
||||
est_tail = f"There are {est_remaining} additional wildfires. DM me for the full list."
|
||||
else:
|
||||
est_tail = ""
|
||||
parts = [header] + shown + [line]
|
||||
if tail:
|
||||
parts.append(tail)
|
||||
if est_tail:
|
||||
parts.append(est_tail)
|
||||
candidate = "\n".join(parts)
|
||||
if len(candidate.encode("utf-8")) <= 200:
|
||||
shown.append(line)
|
||||
else:
|
||||
break
|
||||
|
||||
# Compute tail AFTER budget loop with actual shown count
|
||||
remaining = total - len(shown)
|
||||
if remaining == 1:
|
||||
tail = "There is 1 additional wildfire. DM me for the full list."
|
||||
elif remaining > 1:
|
||||
tail = f"There are {remaining} additional wildfires. DM me for the full list."
|
||||
else:
|
||||
tail = ""
|
||||
|
||||
parts = [header] + shown
|
||||
if tail:
|
||||
parts.append(tail)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"""Tests for the fire digest deterministic renderer recency ordering.
|
||||
"""Tests for the fire digest deterministic renderer.
|
||||
|
||||
Validates that render_digest() lists the 2 most recent fires in
|
||||
last_event_at descending order, excludes contained/tombstoned fires,
|
||||
shows the correct "N additional" count, and stays within 200 bytes.
|
||||
Validates recency ordering, contained/tombstoned exclusion, the
|
||||
"Name in County Co, ST" line format, correct tail count after budget
|
||||
trimming, singular/plural grammar, and the 200-byte LoRa budget.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -33,27 +33,24 @@ def _seed_scenario(conn):
|
|||
now = int(time.time())
|
||||
day = 86400
|
||||
|
||||
# 3 active fires with distinct recency
|
||||
_seed_fire(conn, irwin_id="F-01", name="Alpha Fire",
|
||||
acres=500, contained=None,
|
||||
last_event_at=now - 3600, # 1 hour ago (most recent)
|
||||
last_event_at=now - 3600,
|
||||
county="Ada", state="ID")
|
||||
_seed_fire(conn, irwin_id="F-02", name="Bravo Fire",
|
||||
acres=200, contained=25,
|
||||
last_event_at=now - 7200, # 2 hours ago
|
||||
last_event_at=now - 7200,
|
||||
county="Boise", state="ID")
|
||||
_seed_fire(conn, irwin_id="F-03", name="Charlie Fire",
|
||||
acres=1000, contained=None,
|
||||
last_event_at=now - 2 * day, # 2 days ago
|
||||
last_event_at=now - 2 * day,
|
||||
county="Elmore", state="ID")
|
||||
|
||||
# 1 fire 100% contained (should be excluded)
|
||||
_seed_fire(conn, irwin_id="F-04", name="Contained Fire",
|
||||
acres=800, contained=100,
|
||||
last_event_at=now - 1800, # 30 min ago — very recent!
|
||||
last_event_at=now - 1800,
|
||||
county="Gem", state="ID")
|
||||
|
||||
# 1 tombstoned fire (should be excluded)
|
||||
_seed_fire(conn, irwin_id="F-05", name="Tombstoned Fire",
|
||||
acres=3000, contained=50,
|
||||
last_event_at=now - day,
|
||||
|
|
@ -78,13 +75,39 @@ class TestFireDigestRecency:
|
|||
pos_bravo = wire.index("Bravo Fire")
|
||||
assert pos_alpha < pos_bravo, "Alpha Fire (most recent) should appear before Bravo Fire"
|
||||
|
||||
def test_line_format_name_in_county_co_state(self):
|
||||
"""Fire lines render as 'Name in County Co, ST'."""
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
_seed_scenario(conn)
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
wire, _ = asyncio.run(render_digest(now=now))
|
||||
assert "Alpha Fire in Ada Co, ID" in wire
|
||||
assert "Bravo Fire in Boise Co, ID" in wire
|
||||
|
||||
def test_missing_county_renders_name_in_state(self):
|
||||
"""Fire with no county renders as 'Name in ST'."""
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
_seed_fire(conn, irwin_id="NC-01", name="No County Blaze",
|
||||
acres=100, contained=None,
|
||||
last_event_at=now - 3600,
|
||||
county=None, state="MT")
|
||||
_seed_fire(conn, irwin_id="NC-02", name="Second Fire",
|
||||
acres=50, contained=None,
|
||||
last_event_at=now - 7200,
|
||||
county="Ada", state="ID")
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
wire, _ = asyncio.run(render_digest(now=now))
|
||||
assert "No County Blaze in MT" in wire
|
||||
|
||||
def test_contained_excluded(self):
|
||||
"""100%-contained fires are excluded from the digest."""
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
_seed_scenario(conn)
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
wire, source = asyncio.run(render_digest(now=now))
|
||||
wire, _ = asyncio.run(render_digest(now=now))
|
||||
assert "Contained Fire" not in wire
|
||||
|
||||
def test_tombstoned_excluded(self):
|
||||
|
|
@ -93,24 +116,38 @@ class TestFireDigestRecency:
|
|||
now = int(time.time())
|
||||
_seed_scenario(conn)
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
wire, source = asyncio.run(render_digest(now=now))
|
||||
wire, _ = asyncio.run(render_digest(now=now))
|
||||
assert "Tombstoned Fire" not in wire
|
||||
|
||||
def test_n_additional_count_correct(self):
|
||||
"""The 'N additional' tail sentence has the right count."""
|
||||
def test_n1_grammar_singular(self):
|
||||
"""N == 1 renders 'There is 1 additional wildfire.'."""
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
_seed_scenario(conn)
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
wire, source = asyncio.run(render_digest(now=now))
|
||||
# 3 active fires total, 2 shown, 1 remaining
|
||||
assert "There are 1 additional wildfires. DM me for the full list." in wire
|
||||
wire, _ = asyncio.run(render_digest(now=now))
|
||||
# 3 active total, 2 shown, 1 remaining
|
||||
assert "There is 1 additional wildfire. DM me for the full list." in wire
|
||||
|
||||
def test_n_plural_grammar(self):
|
||||
"""N > 1 renders 'There are N additional wildfires.'."""
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
day = 86400
|
||||
for i in range(4):
|
||||
_seed_fire(conn, irwin_id=f"PL-{i:02d}", name=f"Fire {i}",
|
||||
acres=100 + i, contained=None,
|
||||
last_event_at=now - 3600 * (i + 1),
|
||||
county="Ada", state="ID")
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
wire, _ = asyncio.run(render_digest(now=now))
|
||||
# 4 active, 2 shown, 2 remaining
|
||||
assert "There are 2 additional wildfires. DM me for the full list." in wire
|
||||
|
||||
def test_n_zero_omits_sentence(self):
|
||||
"""When N == 0, the tail sentence is omitted entirely."""
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
# Only 2 active fires
|
||||
_seed_fire(conn, irwin_id="X-01", name="Fire One",
|
||||
acres=100, contained=None,
|
||||
last_event_at=now - 3600,
|
||||
|
|
@ -126,13 +163,47 @@ class TestFireDigestRecency:
|
|||
assert "Fire One" in wire
|
||||
assert "Fire Two" in wire
|
||||
|
||||
def test_tail_count_correct_after_budget_trim(self):
|
||||
"""When budget trims a line, N reflects actual shown count."""
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
# Long names force the second line to be trimmed
|
||||
long_name_1 = "A" * 60
|
||||
long_name_2 = "B" * 60
|
||||
_seed_fire(conn, irwin_id="LN-01", name=long_name_1,
|
||||
acres=500, contained=None,
|
||||
last_event_at=now - 3600,
|
||||
county="Bonneville", state="ID")
|
||||
_seed_fire(conn, irwin_id="LN-02", name=long_name_2,
|
||||
acres=200, contained=None,
|
||||
last_event_at=now - 7200,
|
||||
county="Bannock", state="ID")
|
||||
_seed_fire(conn, irwin_id="LN-03", name="Short Fire",
|
||||
acres=100, contained=None,
|
||||
last_event_at=now - 3 * 86400,
|
||||
county="Ada", state="ID")
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
wire, source = asyncio.run(render_digest(now=now))
|
||||
assert source == "deterministic"
|
||||
byte_len = len(wire.encode("utf-8"))
|
||||
assert byte_len <= 200, f"Wire is {byte_len} bytes, exceeds 200"
|
||||
# If both long lines fit, remaining = 1; if only one fits, remaining = 2
|
||||
# Either way the tail count must match (total - shown)
|
||||
lines = wire.split("\n")
|
||||
shown_fires = [l for l in lines if long_name_1 in l or long_name_2 in l]
|
||||
remaining = 3 - len(shown_fires)
|
||||
if remaining == 1:
|
||||
assert "There is 1 additional wildfire." in wire
|
||||
elif remaining > 1:
|
||||
assert f"There are {remaining} additional wildfires." in wire
|
||||
|
||||
def test_rendered_within_200_bytes(self):
|
||||
"""Rendered output must be <= 200 bytes for LoRa budget."""
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
_seed_scenario(conn)
|
||||
from meshai.notifications.scheduled.fire_digest import render_digest
|
||||
wire, source = asyncio.run(render_digest(now=now))
|
||||
wire, _ = asyncio.run(render_digest(now=now))
|
||||
byte_len = len(wire.encode("utf-8"))
|
||||
assert byte_len <= 200, f"Digest is {byte_len} bytes, exceeds 200-byte budget"
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue