#36 — the correction pass shipped in 58efdbd was right, its reference-data
shape was not. One shape now, at both scopes, every field nullable:
area_context: { municipality?, county?, state?,
center?, radius_km?, resolved_from?, resolved_at?,
local_knowledge?: [{term, meaning}] }
`state` closes the ambiguity that made "Ossining" a national guess.
`local_knowledge` replaces roads[]/landmarks[], which could not hold
intersections, schools or nicknames and carried no meanings — `11-X-ray` is
useless alone, `11-X-ray — MTA PD patrol unit` is what a corrector can act on.
Pre-#36 roads[]/landmarks[] are read forward as bare terms so nothing an
operator already entered is lost.
Nullability is the mechanism: which scope gets filled is the operator's
declaration of how homogeneous the system is. One town — fill it once at system
level. Statewide — leave it blank and fill each talkgroup.
The backend owns the derived anchor. PUT /systems/{id} merges config.talkgroups[]
against what is stored instead of writing the client's blob verbatim, which
would have erased the anchor and the pending queue — the same defect as the
ten_codes wipe.
#37 — Maps as a verifier, not as prompt stuffing. The corrector emits its
location nouns; each is geocoded against the talkgroup's anchor, and on a miss
we look for a sound-alike that does resolve there, correct to it, and propose
{term, meaning} to that talkgroup. Cost scales with location nouns, not calls.
No anchor means SKIP. An area too wide to discriminate stores no anchor at all,
because a statewide radius would confirm anything inside it — verification that
passes everything is worse than none, since it reads as a check in the data.
Also re-anchors _geocode_location, which rejected results >40km from the NODE
(server-26#6). An antenna is not a jurisdiction; distance-from-node was always
a stand-in for the anchor and is now only the fallback.
The induction loop proposes at talkgroup level and never promotes. Blast
radius: a wrong term on a channel misleads that channel, the same term
system-wide misleads one 400km away on a statewide system.
38 new tests; 240 pass. Frontend typechecks clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
306 lines
11 KiB
Python
306 lines
11 KiB
Python
"""
|
|
Unit tests for the area_context schema and anchor (server-26#36).
|
|
|
|
Three properties carry the real risk:
|
|
|
|
* NULLABILITY IS THE MECHANISM. Which scope an operator fills is their
|
|
declaration of how homogeneous the system is. Merging must let a talkgroup
|
|
narrow the system without dropping what the system already said — a
|
|
talkgroup that sets only a town must still inherit the state, or "Ossining"
|
|
is nationally ambiguous again.
|
|
|
|
* NO ANCHOR IS BETTER THAN A USELESS ONE. An anchor wider than
|
|
area_anchor_max_radius_km, or one whose resolved_from no longer matches the
|
|
place it came from, must read as ABSENT. Verification then skips. Treating
|
|
either as usable would rubber-stamp any location while looking like a check.
|
|
|
|
* THE CLIENT DOES NOT WRITE SERVER FIELDS. The systems form sends
|
|
config.talkgroups[] in full; taking it verbatim destroys the resolved anchor
|
|
and the pending queue, which is the same bug as the ten_codes wipe.
|
|
"""
|
|
import pytest
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
from app.internal import area_context as ac
|
|
|
|
SYSTEM_AREA = {
|
|
"county": "Westchester",
|
|
"state": "New York",
|
|
"local_knowledge": [{"term": "Route 9", "meaning": "state highway"}],
|
|
"center": {"lat": 41.1, "lng": -73.8},
|
|
"radius_km": 30.0,
|
|
"resolved_from": "|westchester|new york",
|
|
"resolved_at": "2026-08-23T00:00:00+00:00",
|
|
}
|
|
|
|
TG_AREA = {
|
|
"municipality": "Ossining",
|
|
"local_knowledge": [{"term": "Sing Sing", "meaning": "state prison"}],
|
|
"center": {"lat": 41.16, "lng": -73.86},
|
|
"radius_km": 6.0,
|
|
"resolved_from": "ossining|westchester|new york",
|
|
"resolved_at": "2026-08-23T00:00:00+00:00",
|
|
}
|
|
|
|
|
|
# -- Merging -------------------------------------------------------------------
|
|
|
|
def test_talkgroup_narrows_without_dropping_the_system():
|
|
merged = ac.effective(SYSTEM_AREA, TG_AREA)
|
|
assert merged["municipality"] == "Ossining"
|
|
assert merged["county"] == "Westchester"
|
|
assert merged["state"] == "New York", "the state must survive the narrowing"
|
|
|
|
|
|
def test_talkgroup_knowledge_ranks_first_and_dedupes():
|
|
system = {"local_knowledge": [{"term": "Route 9"}, {"term": "Metro-North"}]}
|
|
tg = {"local_knowledge": [{"term": "route 9", "meaning": "the local name"}]}
|
|
merged = ac.effective(system, tg)
|
|
assert [e["term"] for e in merged["local_knowledge"]] == ["route 9", "Metro-North"]
|
|
assert merged["local_knowledge"][0]["meaning"] == "the local name"
|
|
|
|
|
|
def test_empty_at_both_scopes_is_legal():
|
|
assert ac.effective(None, None) == {}
|
|
assert ac.effective({}, {}) == {}
|
|
|
|
|
|
def test_bare_strings_are_accepted_as_terms():
|
|
"""roads[]/landmarks[] from the old shape, and anything a model returns."""
|
|
assert ac.normalize_local_knowledge(["Route 9", "", "Route 9", 7]) == [{"term": "Route 9"}]
|
|
|
|
|
|
def test_pre_36_roads_and_landmarks_are_read_forward():
|
|
"""
|
|
Real systems still have the old shape stored. Dropping it the day this
|
|
shipped would silently discard ground truth an operator already entered.
|
|
"""
|
|
legacy = {"county": "Westchester", "roads": ["Route 9"], "landmarks": ["Sing Sing"]}
|
|
merged = ac.effective(legacy, None)
|
|
assert [e["term"] for e in merged["local_knowledge"]] == ["Route 9", "Sing Sing"]
|
|
assert ac.normalize(legacy) == {
|
|
"county": "Westchester",
|
|
"local_knowledge": [{"term": "Route 9"}, {"term": "Sing Sing"}],
|
|
}, "and the next save writes them in the new shape"
|
|
|
|
|
|
def test_normalize_drops_client_sent_server_fields():
|
|
out = ac.normalize({"municipality": " Ossining ", "radius_km": 5000, "center": {"lat": 0}})
|
|
assert out == {"municipality": "Ossining"}
|
|
|
|
|
|
# -- Anchor selection ----------------------------------------------------------
|
|
|
|
def test_talkgroup_anchor_wins():
|
|
anchor = ac.anchor_for(SYSTEM_AREA, TG_AREA)
|
|
assert anchor == {"lat": 41.16, "lng": -73.86, "radius_km": 6.0}
|
|
|
|
|
|
def test_system_anchor_used_when_talkgroup_sets_no_place():
|
|
anchor = ac.anchor_for(SYSTEM_AREA, {"local_knowledge": [{"term": "Post 4"}]})
|
|
assert anchor == {"lat": 41.1, "lng": -73.8, "radius_km": 30.0}
|
|
|
|
|
|
def test_no_anchor_when_nothing_is_configured():
|
|
assert ac.anchor_for({}, {}) is None
|
|
|
|
|
|
def test_stale_anchor_reads_as_absent():
|
|
"""
|
|
Someone edited the town and the refresh has not run yet. The stored centre
|
|
is for the OLD place, so using it would validate locations against an area
|
|
the channel no longer covers.
|
|
"""
|
|
stale = {**TG_AREA, "municipality": "Croton"}
|
|
assert ac.anchor_for(SYSTEM_AREA, stale) is None
|
|
|
|
|
|
def test_anchor_key_ignores_case_and_padding():
|
|
assert ac.anchor_key({"municipality": " OSSINING "}) == ac.anchor_key({"municipality": "ossining"})
|
|
|
|
|
|
# -- Anchor resolution ---------------------------------------------------------
|
|
|
|
def _maps(viewport_span_deg: float):
|
|
"""A geocode response whose viewport spans roughly the given degrees."""
|
|
payload = {
|
|
"status": "OK",
|
|
"results": [{
|
|
"geometry": {
|
|
"location": {"lat": 41.0, "lng": -73.0},
|
|
"viewport": {
|
|
"northeast": {"lat": 41.0 + viewport_span_deg, "lng": -73.0 + viewport_span_deg},
|
|
"southwest": {"lat": 41.0 - viewport_span_deg, "lng": -73.0 - viewport_span_deg},
|
|
},
|
|
}
|
|
}],
|
|
}
|
|
|
|
class _Resp:
|
|
def raise_for_status(self): pass
|
|
def json(self): return payload
|
|
|
|
class _Client:
|
|
async def __aenter__(self): return self
|
|
async def __aexit__(self, *a): return False
|
|
async def get(self, *a, **k): return _Resp()
|
|
|
|
return patch("httpx.AsyncClient", lambda *a, **k: _Client())
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_cache():
|
|
ac._anchor_cache.clear()
|
|
with patch.object(ac.settings, "google_maps_api_key", "test-key"):
|
|
yield
|
|
ac._anchor_cache.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_small_place_produces_an_anchor():
|
|
with _maps(0.05):
|
|
anchor = await ac.resolve_anchor({"municipality": "Ossining", "state": "New York"})
|
|
assert anchor is not None
|
|
assert anchor["radius_km"] < 10
|
|
assert anchor["resolved_from"] == "ossining||new york"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_statewide_place_produces_no_anchor():
|
|
"""
|
|
A radius that covers a state would confirm any location inside it. Storing
|
|
it would make the geocode check worse than useless — it would look like
|
|
verification and pass everything.
|
|
"""
|
|
with _maps(4.0), patch.object(ac.settings, "area_anchor_max_radius_km", 60.0):
|
|
assert await ac.resolve_anchor({"state": "Colorado"}) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_place_never_calls_maps():
|
|
with patch("httpx.AsyncClient") as client:
|
|
assert await ac.resolve_anchor({"local_knowledge": [{"term": "Post 4"}]}) is None
|
|
client.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refresh_skips_scopes_whose_place_is_unchanged():
|
|
doc = {"area_context": SYSTEM_AREA, "config": {"talkgroups": [{"id": 1, "area_context": TG_AREA}]}}
|
|
with patch("httpx.AsyncClient") as client:
|
|
assert await ac.refresh_anchors(doc) == {}
|
|
client.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_editing_the_system_place_re_anchors_its_talkgroups():
|
|
"""
|
|
A talkgroup's anchor derives from its EFFECTIVE place, so changing the
|
|
system's county silently changes what every talkgroup should be anchored to.
|
|
"""
|
|
doc = {
|
|
"area_context": {"county": "Putnam", "state": "New York"},
|
|
"config": {"talkgroups": [{"id": 1, "area_context": {"municipality": "Ossining"}}]},
|
|
}
|
|
with _maps(0.05):
|
|
patch_out = await ac.refresh_anchors(doc)
|
|
tg = patch_out["config"]["talkgroups"][0]
|
|
assert tg["area_context"]["resolved_from"] == "ossining|putnam|new york"
|
|
assert tg["area_context"]["center"]["lat"] == 41.0
|
|
|
|
|
|
# -- Client writes -------------------------------------------------------------
|
|
|
|
def test_merge_config_preserves_the_anchor_and_the_pending_queue():
|
|
existing = {"talkgroups": [{
|
|
"id": 9048,
|
|
"area_context": TG_AREA,
|
|
ac.PENDING_KEY: [{"term": "Snowden Avenue"}],
|
|
}]}
|
|
# What the systems form actually sends: no anchor, no pending queue.
|
|
incoming = {"talkgroups": [{"id": 9048, "name": "Ossining PD",
|
|
"area_context": {"municipality": "Ossining"}}]}
|
|
merged = ac.merge_config(incoming, existing)
|
|
tg = merged["talkgroups"][0]
|
|
assert tg["area_context"]["center"] == TG_AREA["center"]
|
|
assert tg[ac.PENDING_KEY] == [{"term": "Snowden Avenue"}]
|
|
assert tg["name"] == "Ossining PD", "the client still owns the fields it owns"
|
|
|
|
|
|
def test_merge_config_drops_an_emptied_area():
|
|
existing = {"talkgroups": [{"id": 1, "area_context": TG_AREA}]}
|
|
merged = ac.merge_config({"talkgroups": [{"id": 1}]}, existing)
|
|
assert "area_context" not in merged["talkgroups"][0]
|
|
|
|
|
|
# -- Pending terms -------------------------------------------------------------
|
|
|
|
def _store(doc):
|
|
saved = {}
|
|
|
|
async def _get(_col, _id):
|
|
return doc
|
|
|
|
async def _update(_col, _id, patch):
|
|
saved.update(patch)
|
|
|
|
return saved, patch.multiple(
|
|
"app.internal.firestore", doc_get=AsyncMock(side_effect=_get),
|
|
doc_update=AsyncMock(side_effect=_update),
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pending_terms_land_on_the_talkgroup():
|
|
doc = {"config": {"talkgroups": [{"id": 9048}]}, "vocabulary": []}
|
|
saved, store = _store(doc)
|
|
with store:
|
|
assert await ac.add_pending("sys-1", 9048, [{"term": "Snowden Avenue"}]) == 1
|
|
assert saved["config"]["talkgroups"][0][ac.PENDING_KEY][0]["term"] == "Snowden Avenue"
|
|
assert "vocabulary" not in saved, "nothing writes to the system"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_already_known_terms_are_not_re_proposed():
|
|
doc = {
|
|
"vocabulary": ["Metro-North"],
|
|
"area_context": {"local_knowledge": [{"term": "Route 9"}]},
|
|
"config": {"talkgroups": [{"id": 9048, "local_knowledge_pending": [{"term": "Sing Sing"}]}]},
|
|
}
|
|
saved, store = _store(doc)
|
|
with store:
|
|
queued = await ac.add_pending("sys-1", 9048, [
|
|
{"term": "route 9"}, {"term": "Metro-North"}, {"term": "sing sing"},
|
|
])
|
|
assert queued == 0
|
|
assert saved == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approving_writes_to_the_talkgroup_and_never_the_system():
|
|
"""
|
|
Blast radius: the same term at system level misleads every channel on the
|
|
system, including one 400km away on a statewide system.
|
|
"""
|
|
doc = {"config": {"talkgroups": [{"id": 9048, ac.PENDING_KEY: [
|
|
{"term": "Snowden Avenue", "meaning": "residential street"}]}]}}
|
|
saved, store = _store(doc)
|
|
with store:
|
|
assert await ac.resolve_pending("sys-1", 9048, "snowden avenue", approve=True) is True
|
|
tg = saved["config"]["talkgroups"][0]
|
|
assert tg["area_context"]["local_knowledge"] == [
|
|
{"term": "Snowden Avenue", "meaning": "residential street"}
|
|
]
|
|
assert tg[ac.PENDING_KEY] == []
|
|
assert "vocabulary" not in saved and "area_context" not in saved
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dismissing_adds_nothing():
|
|
doc = {"config": {"talkgroups": [{"id": 9048, ac.PENDING_KEY: [{"term": "Optum"}]}]}}
|
|
saved, store = _store(doc)
|
|
with store:
|
|
assert await ac.resolve_pending("sys-1", 9048, "Optum", approve=False) is True
|
|
tg = saved["config"]["talkgroups"][0]
|
|
assert tg[ac.PENDING_KEY] == []
|
|
assert not (tg.get("area_context") or {}).get("local_knowledge")
|