meshai/tests/test_adapter_config_foundation.py
Matt Johnson (via Claude) 566b06de06 feat(v0.6-tail): close 5 v0.6-phase1-complete.md follow-ups
(1) Auto-call refresh-toggles on PUT /api/config/notifications
    meshai/dashboard/api/config_routes.py adds register_config_routes_hooks(app)
    which registers a FastAPI HTTP middleware: on any 2xx PUT whose path
    matches /api/config/notifications or /api/config, the middleware
    invokes _refresh_toggle_filter(app) which reaches into app.state.bus._
    pipeline_components["toggle_filter"] and calls .refresh(app.state.config).
    The dashboard no longer has to remember to ping POST /api/notifications/
    refresh-toggles after a toggle change. The explicit endpoint stays for
    backwards-compat.

(2) env_reporter block-size cap moved to adapter_config
    New registry row pipeline.env_reporter_block_chars (int, default 3000).
    meshai/notifications/env_reporter.py replaces the hardcoded
    _BLOCK_MAX_CHARS = 3000 with _DEFAULT_BLOCK_MAX_CHARS (the fallback) +
    a _block_cap() helper that reads from adapter_config on every slice.
    Mutating the row via PUT /api/adapter-config takes effect on the next
    env_reporter call -- no restart.

(3) Bulk-import endpoint for gauge_sites
    meshai/dashboard/api/gauge_sites_import.py adds
    POST /api/gauge-sites/import with two paths:
      format=csv      -- expects "data" (CSV text with header row matching
                          gauge_sites columns: site_id, gauge_name, lat, lon,
                          and optionally action_ft/flood_minor_ft/
                          flood_moderate_ft/flood_major_ft/enabled). UPSERT
                          via ON CONFLICT(site_id) DO UPDATE. Returns
                          {inserted, updated, skipped}.
      format=nws-ahps -- expects "wfo" (list of WFO codes). Fetches
                          water.weather.gov/ahps2/index.php?wfo=<WFO> for each,
                          regex-parses gauge links, then fetches up to 50
                          gauge detail pages per request and regex-parses
                          lat/lon + four threshold values. Best-effort; rows
                          stored under "AHPS-<gauge_id>" so they dont collide
                          with USGS-* ids. Returns the same shape plus
                          detail_fetched + errors list.
    Frontend (dashboard-frontend/src/pages/GaugeSites.tsx) gains a
    Import button + modal with two tabs (Paste CSV / Scrape NWS-AHPS)
    rendered via an ImportModal component. CSV tab has a 48-row textarea
    with the column-header hint inline; AHPS tab has a comma-separated WFO
    input defaulting to BOI. Both submit via fetch() and show the JSON
    response inline. Invalidates the curation cache server-side on any
    successful insert/update so nwis_handler sees the new gauges on its
    next call.

(4) WFIGS tombstone column -- CORRECTNESS
    v12.sql adds fires.tombstoned_at REAL (nullable) + idx_fires_tombstoned_at.
    meshai/central/wfigs_handler.py: the tombstone branch
    (kind=="wfigs_tombstone") UPDATE fires SET tombstoned_at=COALESCE(
    tombstoned_at, ?) so the first tombstone-time wins (idempotent against
    repeated tombstone envelopes).
    meshai/notifications/reminders/__init__.py: the wfigs tombstone
    termination condition now checks row["tombstoned_at"] IS NOT NULL.
    Reminders correctly STOP for closed fires -- before this change the
    8h cadence would have kept Active: broadcasts going indefinitely past
    a WFIGS removal.
    SCHEMA_VERSION 11 -> 12.

(5) Delete INCIDENT_BROADCAST_HEARTBEAT_S
    meshai/central/incident_handler.py: removed the dead constant
    (v0.5.9 REVISED dropped the heartbeat path but left the constant
    imported-but-never-read).
    tests/test_incident_handler.py: removed the orphan
    test_i_8h_heartbeat_triggers_update test (asserted None, used the
    deleted constant for time arithmetic) and the stray import line.

Tests (tests/test_tail_followups.py, 16 cases):
  - middleware fires refresh on PUT /api/config/notifications (200), does
    NOT fire on PUT /api/config/llm
  - env_reporter _block_cap() default 3000; mutate via PUT, invalidate,
    next read returns the new cap
  - CSV import inserts new rows, updates existing, skips bad rows,
    rejects missing required columns, rejects bad format
  - AHPS index parser extracts (gauge_id, name) from realistic HTML
  - AHPS detail parser extracts lat/lon + four thresholds from realistic
    HTML
  - fires has tombstoned_at column after migrations
  - wfigs tombstone branch stamps tombstoned_at
  - ReminderScheduler skips a fire whose tombstoned_at is NOT NULL
  - ReminderScheduler still fires for a fire whose tombstoned_at IS NULL
  - INCIDENT_BROADCAST_HEARTBEAT_S no longer importable

Foundation/API test counts bumped:
  REGISTRY 58 -> 59 (+ env_reporter_block_chars)
  schema_meta v11 -> v12

Test count: 844 -> 859 (+16 new, -1 deleted dead test). 0 regressions.
2026-06-05 21:37:05 +00:00

347 lines
11 KiB
Python

"""v0.6-3a foundation tests: migration, seed, accessor, orphan prune.
v0.6-3a.1: trimmed registry to 43 keys per Matt's CONFIG-vs-CODE rule.
prune_orphans cleans up rows that were in the v0.6-3a draft but no longer
in the trimmed REGISTRY. Tests now assert the 43-key count and exercise
the prune path.
"""
from __future__ import annotations
import json
import logging
from unittest.mock import patch
import pytest
from meshai.adapter_config import (
adapter_config,
invalidate_cache,
seed_defaults,
prune_orphans,
REGISTRY,
ADAPTER_META,
)
from meshai.adapter_config import _accessor as accessor_mod
from meshai.persistence import close_thread_connection, init_db
from meshai.persistence import db as persistence_db
# ---------- fixtures ------------------------------------------------------
@pytest.fixture
def fresh_db(tmp_path, monkeypatch):
p = str(tmp_path / "ac-test.sqlite")
monkeypatch.setenv("MESHAI_DB_PATH", p)
persistence_db._initialised.clear()
close_thread_connection()
invalidate_cache()
conn = init_db()
yield conn
close_thread_connection()
persistence_db._initialised.discard(p)
invalidate_cache()
# ---------- schema --------------------------------------------------------
def test_v6_tables_exist(fresh_db):
tables = {r["name"] for r in fresh_db.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()}
assert "adapter_config" in tables
assert "adapter_meta" in tables
def test_schema_meta_at_v12(fresh_db):
v = fresh_db.execute(
"SELECT value FROM schema_meta WHERE key='version'"
).fetchone()["value"]
assert int(v) == 12
def test_adapter_config_type_check_constrains_vocabulary(fresh_db):
with pytest.raises(Exception):
fresh_db.execute(
"INSERT INTO adapter_config(adapter, key, value_json, default_json, "
"type, description, updated_at) VALUES (?,?,?,?,?,?,?)",
("x", "y", "1", "1", "integer", "", 0.0),
)
# ---------- registry shape -----------------------------------------------
def test_registry_at_59_entries():
"""v0.6-3a.1 trim: 43 CONFIG-only keys (was 77 in v0.6-3a draft)."""
assert len(REGISTRY) == 59, (
f"REGISTRY should have 43 entries after CONFIG-vs-CODE trim; got {len(REGISTRY)}. "
f"If a sentence template / emoji / heuristic snuck in, it belongs in CODE not config."
)
def test_adapter_meta_at_19(fresh_db):
assert len(ADAPTER_META) == 19
# ---------- seed ----------------------------------------------------------
def test_seed_populates_every_registry_row(fresh_db):
rows = fresh_db.execute("SELECT adapter, key FROM adapter_config").fetchall()
db_keys = {(r["adapter"], r["key"]) for r in rows}
assert db_keys == set(REGISTRY.keys())
def test_seed_value_matches_registry_default(fresh_db):
for (adapter, key), spec in REGISTRY.items():
row = fresh_db.execute(
"SELECT value_json, default_json, type FROM adapter_config "
"WHERE adapter=? AND key=?",
(adapter, key),
).fetchone()
expected = json.dumps(spec["default"])
assert row["value_json"] == expected, f"{adapter}.{key} value drift"
assert row["default_json"] == expected, f"{adapter}.{key} default drift"
assert row["type"] == spec["type"]
def test_seed_populates_every_adapter_meta_row(fresh_db):
rows = fresh_db.execute("SELECT adapter, include_in_llm_context FROM adapter_meta").fetchall()
db_adapters = {r["adapter"] for r in rows}
assert db_adapters == set(ADAPTER_META.keys())
def test_seed_is_idempotent(fresh_db):
a, b = seed_defaults(fresh_db)
assert a == 0 and b == 0
def test_seed_does_not_overwrite_user_edits(fresh_db):
fresh_db.execute(
"UPDATE adapter_config SET value_json=? WHERE adapter=? AND key=?",
("999", "wfigs", "cooldown_seconds"),
)
seed_defaults(fresh_db)
row = fresh_db.execute(
"SELECT value_json FROM adapter_config "
"WHERE adapter='wfigs' AND key='cooldown_seconds'"
).fetchone()
assert row["value_json"] == "999"
# ---------- prune_orphans -------------------------------------------------
def test_prune_orphans_removes_unknown_keys(fresh_db, caplog):
"""A row whose (adapter, key) is no longer in REGISTRY is deleted on
the next prune_orphans, and the delete is logged at INFO."""
fresh_db.execute(
"INSERT INTO adapter_config(adapter, key, value_json, default_json, "
"type, description, updated_at) VALUES (?,?,?,?,?,?,?)",
("wfigs", "deprecated_legacy_key", "\"old\"", "\"old\"",
"str", "", 0.0),
)
caplog.set_level(logging.INFO, logger="meshai.adapter_config")
removed = prune_orphans(fresh_db)
assert removed == 1
msgs = [r.getMessage() for r in caplog.records
if r.name.startswith("meshai.adapter_config")]
assert any(
"adapter_config orphan removed: wfigs.deprecated_legacy_key" in m
for m in msgs
), f"expected orphan-removed log line; got: {msgs}"
# Row gone.
assert fresh_db.execute(
"SELECT 1 FROM adapter_config WHERE adapter=? AND key=?",
("wfigs", "deprecated_legacy_key"),
).fetchone() is None
def test_prune_orphans_idempotent(fresh_db):
assert prune_orphans(fresh_db) == 0
assert prune_orphans(fresh_db) == 0
def test_prune_orphans_does_not_touch_known_keys(fresh_db):
"""Every REGISTRY row survives the prune."""
before = {(r["adapter"], r["key"]) for r in fresh_db.execute(
"SELECT adapter, key FROM adapter_config"
).fetchall()}
prune_orphans(fresh_db)
after = {(r["adapter"], r["key"]) for r in fresh_db.execute(
"SELECT adapter, key FROM adapter_config"
).fetchall()}
assert before == after == set(REGISTRY.keys())
def test_prune_orphans_does_not_touch_adapter_meta(fresh_db):
"""A previously-known adapter whose config keys all moved to CODE
keeps its adapter_meta row (for the include_in_llm_context toggle)."""
before = fresh_db.execute("SELECT COUNT(*) FROM adapter_meta").fetchone()[0]
prune_orphans(fresh_db)
after = fresh_db.execute("SELECT COUNT(*) FROM adapter_meta").fetchone()[0]
assert before == after == len(ADAPTER_META)
def test_prune_orphans_invalidates_cache(fresh_db):
"""If a key disappears, any cached read of it should NOT linger."""
invalidate_cache()
# Prime cache with a key that will become orphan.
fresh_db.execute(
"INSERT INTO adapter_config(adapter, key, value_json, default_json, "
"type, description, updated_at) VALUES (?,?,?,?,?,?,?)",
("wfigs", "ghost", "42", "42", "int", "", 0.0),
)
# We can't read it via accessor (not in REGISTRY -> no fallback) so
# we just verify the cache is empty after prune.
prune_orphans(fresh_db)
assert accessor_mod._cache == {}, "cache should be cleared after orphan prune"
# ---------- accessor ------------------------------------------------------
def test_accessor_returns_int(fresh_db):
invalidate_cache()
assert adapter_config.wfigs.cooldown_seconds == 28800
def test_accessor_returns_float(fresh_db):
invalidate_cache()
assert adapter_config.usgs_quake.global_mag_floor == 3.0
def test_accessor_returns_str(fresh_db):
invalidate_cache()
assert adapter_config.geocoder.photon_url == "http://100.64.0.24:2322"
def test_accessor_returns_bool(fresh_db):
invalidate_cache()
assert adapter_config.tomtom_incidents.drop_zero_magnitude is True
def test_accessor_returns_json_list(fresh_db):
invalidate_cache()
assert adapter_config.nws.broadcast_severities == ["Extreme", "Severe"]
def test_accessor_returns_json_dict(fresh_db):
invalidate_cache()
v = adapter_config.central.severity_thresholds
assert v == {"routine_max": 1, "priority_max": 2, "immediate_min": 3}
def test_accessor_returns_json_none(fresh_db):
invalidate_cache()
assert adapter_config.firms.bbox is None
def test_firms_dedup_distance_m_default(fresh_db):
"""v0.6-3a.1 Matt's call: user-facing unit is meters, default 5."""
invalidate_cache()
v = adapter_config.firms.dedup_distance_m
assert isinstance(v, int)
assert v == 5
# ---------- cache --------------------------------------------------------
def test_cache_hits_second_read(fresh_db):
invalidate_cache()
_ = adapter_config.wfigs.cooldown_seconds
with patch.object(accessor_mod, "_load_from_db",
side_effect=AssertionError("cache miss")):
v = adapter_config.wfigs.cooldown_seconds
assert v == 28800
def test_invalidate_forces_reload(fresh_db):
invalidate_cache()
_ = adapter_config.wfigs.cooldown_seconds
fresh_db.execute(
"UPDATE adapter_config SET value_json=? WHERE adapter='wfigs' AND key='cooldown_seconds'",
("3600",),
)
assert adapter_config.wfigs.cooldown_seconds == 28800 # still cached
invalidate_cache()
assert adapter_config.wfigs.cooldown_seconds == 3600
# ---------- defensive fallback paths -------------------------------------
def test_registry_fallback_when_db_row_missing(fresh_db, caplog):
invalidate_cache()
fresh_db.execute(
"DELETE FROM adapter_config WHERE adapter='wfigs' AND key='cooldown_seconds'"
)
caplog.set_level(logging.WARNING, logger="meshai.adapter_config._accessor")
v = adapter_config.wfigs.cooldown_seconds
assert v == 28800
assert any("missing from DB" in r.message for r in caplog.records)
def test_unknown_key_raises(fresh_db):
invalidate_cache()
with pytest.raises(AttributeError):
_ = adapter_config.wfigs.no_such_key
def test_setattr_blocked(fresh_db):
invalidate_cache()
with pytest.raises(AttributeError):
adapter_config.wfigs.cooldown_seconds = 999
# ---------- registry sanity ----------------------------------------------
def test_every_registry_default_round_trips_through_json():
for (adapter, key), spec in REGISTRY.items():
encoded = json.dumps(spec["default"])
decoded = json.loads(encoded)
assert decoded == spec["default"], f"{adapter}.{key}: JSON round-trip drift"
def test_every_registry_type_is_in_vocabulary():
valid = {"int", "float", "str", "bool", "json"}
for (adapter, key), spec in REGISTRY.items():
assert spec["type"] in valid, f"{adapter}.{key}: invalid type {spec['type']!r}"
def test_adapter_meta_includes_every_registry_adapter():
reg_adapters = {a for a, _ in REGISTRY}
meta_adapters = set(ADAPTER_META)
missing = reg_adapters - meta_adapters
assert not missing, f"adapters in REGISTRY but missing ADAPTER_META: {missing}"
# ---------- guard against CODE leaking back into the registry -----------
def test_no_emoji_keys_in_registry():
"""Emoji choices are CODE, not config (Matt's locked rule)."""
for (adapter, key) in REGISTRY:
assert "emoji" not in key, (
f"{adapter}.{key} looks like an emoji setting; emojis are CODE"
)
def test_no_template_keys_in_registry():
"""Sentence templates are CODE."""
for (adapter, key) in REGISTRY:
assert "template" not in key and "prefix" not in key, (
f"{adapter}.{key} looks like a sentence template / prefix; sentences are CODE"
)
def test_no_map_keys_in_registry():
"""Translation maps are CODE (TomTom icon_map, ITD sub_type_map, etc.)."""
for (adapter, key) in REGISTRY:
assert not key.endswith("_map"), (
f"{adapter}.{key} looks like a translation map; mapping functions are CODE"
)