From b5a8f5cf97fe6ab4a73808680e2dfecb071fb099 Mon Sep 17 00:00:00 2001 From: "Matt Johnson (via Claude)" Date: Fri, 12 Jun 2026 04:39:57 +0000 Subject: [PATCH] fix(fire_digest): trim lines to name + location, correct tail count after budget trim Co-Authored-By: Claude Opus 4.6 --- meshai/notifications/scheduled/fire_digest.py | 40 +++++-- tests/test_fire_digest_recency.py | 111 ++++++++++++++---- 2 files changed, 118 insertions(+), 33 deletions(-) diff --git a/meshai/notifications/scheduled/fire_digest.py b/meshai/notifications/scheduled/fire_digest.py index 38ea967..84d41d2 100644 --- a/meshai/notifications/scheduled/fire_digest.py +++ b/meshai/notifications/scheduled/fire_digest.py @@ -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) diff --git a/tests/test_fire_digest_recency.py b/tests/test_fire_digest_recency.py index abe8a86..b1a30ec 100644 --- a/tests/test_fire_digest_recency.py +++ b/tests/test_fire_digest_recency.py @@ -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"