Spaces:
Running
Running
File size: 14,968 Bytes
b7c46f4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 | """
Unit tests for the intel store (Phase 0 + 1, docs/blueprint/intelligence-system.md):
geometry, gazetteer matching and text resolution, Layer 1 freshness, handoff
receipts, the calendar, resumable weather backfill, category mapping, and the
news β events hook. Everything runs on in-memory SQLite with synthetic data β
no network, and no dependency on the gitignored raw downloads.
"""
from datetime import date, datetime, timedelta, timezone
import pytest
from intel import db, handoff
from intel.freshness import record_attempt, report, source_status
from intel.geo import PolygonIndex, centroid, contains, haversine_km
from intel.ingest import calendar as cal
from intel.ingest import weather
from intel.locations import match_boundaries, normalise, resolve
from intel.subsector_map import subsector_for
def square(x0, y0, x1, y1):
return {"type": "Polygon", "coordinates": [[[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]]}
@pytest.fixture
def con():
c = db.connect(":memory:")
db.init(c)
yield c
c.close()
def add_location(con, lid, level, name, parent=None, lat=0.0, lon=0.0, aliases=()):
con.execute("insert into locations (id, level, name, parent_id, lat, lon, source) values (?, ?, ?, ?, ?, ?, 'test')",
(lid, level, name, parent, lat, lon))
for a in (name, *aliases):
con.execute("insert into location_aliases (alias, location_id, origin) values (?, ?, 'test')", (normalise(a), lid))
@pytest.fixture
def gazetteer(con):
add_location(con, "ng", "country", "Nigeria")
add_location(con, "ng.lagos", "state", "Lagos", "ng")
add_location(con, "ng.oyo", "state", "Oyo", "ng")
add_location(con, "ng.bauchi", "state", "Bauchi", "ng")
add_location(con, "ng.lagos.surulere", "lga", "Surulere", "ng.lagos")
add_location(con, "ng.oyo.surulere", "lga", "Surulere", "ng.oyo")
add_location(con, "ng.lagos.eti-osa", "lga", "Eti-Osa", "ng.lagos", aliases=("Ajah", "Lekki"))
add_location(con, "ng.bauchi.bauchi", "lga", "Bauchi", "ng.bauchi")
con.commit()
return con
# βββ geometry βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def test_centroid_of_square():
assert centroid(square(0, 0, 2, 2)) == pytest.approx((1.0, 1.0))
def test_centroid_respects_holes():
g = {"type": "Polygon", "coordinates": [
[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]],
[[2, 0], [4, 0], [4, 4], [2, 4], [2, 0]], # right half removed
]}
assert centroid(g)[0] == pytest.approx(1.0)
def test_contains_and_index():
idx = PolygonIndex([("a", square(0, 0, 1, 1)), ("b", square(1, 0, 2, 1))])
assert idx.lookup(0.5, 0.5) == "a"
assert idx.lookup(1.5, 0.5) == "b"
assert idx.lookup(5, 5) is None
assert not contains(square(0, 0, 1, 1), 1.5, 0.5)
def test_haversine_one_degree_at_equator():
assert haversine_km(0, 0, 1, 0) == pytest.approx(111.2, abs=0.2)
# βββ gazetteer ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _feature(name, geom):
return {"properties": {"shapeName": name}, "geometry": geom}
def test_match_boundaries_exact_fuzzy_and_state_placement():
states = [{"name": "Lagos", "lgas": ["Ifako-Ijaiye", "Surulere"]}, {"name": "Oyo", "lgas": ["Surulere"]}]
adm1 = [_feature("Lagos", square(0, 0, 2, 1)), _feature("Oyo", square(0, 1, 2, 2))]
adm2 = [
_feature("Ifako/Ijaye", square(0, 0, 1, 1)), # fuzzy
_feature("Surulere", square(1, 0, 2, 1)), # exact, in Lagos by position
_feature("Surulere", square(0, 1, 2, 2)), # same name, in Oyo by position
]
matched, problems = match_boundaries(states, adm1, adm2)
assert problems == []
assert matched[("Lagos", "Ifako-Ijaiye")]["properties"]["shapeName"] == "Ifako/Ijaye"
assert centroid(matched[("Oyo", "Surulere")]["geometry"])[1] == pytest.approx(1.5)
def test_match_boundaries_reports_rather_than_guesses():
states = [{"name": "Lagos", "lgas": ["Agege"]}]
adm1 = [_feature("Lagos", square(0, 0, 1, 1))]
adm2 = [_feature("Completely Different", square(0, 0, 1, 1))]
_, problems = match_boundaries(states, adm1, adm2)
assert any("Agege" in p for p in problems)
def test_resolve_alias_to_lga(gazetteer):
assert resolve(gazetteer, "Flooding in Ajah after heavy rain") == "ng.lagos.eti-osa"
def test_resolve_shared_lga_name_needs_state_context(gazetteer):
assert resolve(gazetteer, "Traders in Surulere protest") is None
assert resolve(gazetteer, "Surulere, Lagos market shut") == "ng.lagos.surulere"
def test_resolve_state_beats_same_named_lga(gazetteer):
assert resolve(gazetteer, "Bauchi governor signs levy") == "ng.bauchi"
def test_resolve_no_mention(gazetteer):
assert resolve(gazetteer, "Celebrity wedding photos") is None
assert resolve(gazetteer, None) is None
def test_resolve_word_boundaries(gazetteer):
# "Oyo" must not match inside another word.
assert resolve(gazetteer, "Coyotes spotted") is None
# βββ Layer 1: freshness βββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _source(con, sid):
return con.execute("select * from sources where id = ?", (sid,)).fetchone()
def test_never_fetched_source_is_never(con):
assert source_status(_source(con, "calendar"), datetime.now(timezone.utc))["status"] == "never"
def test_fresh_then_stale_on_fetch_clock(con):
record_attempt(con, "open_meteo_forecast", ok=True, latest_data_period=date.today().isoformat())
now = datetime.now(timezone.utc)
assert source_status(_source(con, "open_meteo_forecast"), now)["status"] == "fresh"
assert source_status(_source(con, "open_meteo_forecast"), now + timedelta(days=2))["status"] == "late"
assert source_status(_source(con, "open_meteo_forecast"), now + timedelta(days=4))["status"] == "stale"
def test_data_clock_catches_old_data_behind_a_fresh_fetch(con):
# Fetched just now, but the newest data is a month old on a daily source.
old = (date.today() - timedelta(days=30)).isoformat()
record_attempt(con, "open_meteo_forecast", ok=True, latest_data_period=old)
s = source_status(_source(con, "open_meteo_forecast"), datetime.now(timezone.utc))
assert s["status"] == "stale"
assert "newest data" in s["reason"]
def test_archive_latency_is_allowed(con):
# The archive legitimately trails by ~a week; 6-day-old data is on time.
record_attempt(con, "open_meteo_archive", ok=True, latest_data_period=(date.today() - timedelta(days=6)).isoformat())
assert source_status(_source(con, "open_meteo_archive"), datetime.now(timezone.utc))["status"] == "fresh"
def test_failure_after_success_is_failing(con):
record_attempt(con, "open_meteo_forecast", ok=True, latest_data_period=date.today().isoformat())
record_attempt(con, "open_meteo_forecast", ok=False, error="timeout")
s = source_status(_source(con, "open_meteo_forecast"), datetime.now(timezone.utc))
assert s["status"] == "failing" and "timeout" in s["reason"]
# βββ handoffs βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def test_handoff_receipt_once(con):
hid = handoff.produce(con, "r1", handoff.DATA_STREAM, handoff.STALE_AWARENESS, {"ok": True})
assert [h["id"] for h in handoff.pending(con, handoff.STALE_AWARENESS)] == [hid]
assert handoff.receive(con, hid, "test") is True
assert handoff.receive(con, hid, "test") is False
assert handoff.pending(con, handoff.STALE_AWARENESS) == []
def test_unreceived_handoff_to_active_layer_goes_overdue(con):
handoff.produce(con, "r1", handoff.DATA_STREAM, handoff.STALE_AWARENESS, {})
later = datetime.now(timezone.utc) + timedelta(days=2)
assert len(report(con, later)["handoffs_overdue"]) == 1
def test_handoff_to_unbuilt_layer_is_queued_not_overdue(con):
handoff.produce(con, "r1", handoff.DATA_STREAM, handoff.INTERFACE, {})
r = report(con, datetime.now(timezone.utc) + timedelta(days=30))
assert r["handoffs_overdue"] == [] and r["handoffs_queued_for_unbuilt_layers"] == 1
# βββ calendar βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@pytest.mark.parametrize("year,expected", [(2024, date(2024, 3, 31)), (2025, date(2025, 4, 20)), (2026, date(2026, 4, 5))])
def test_easter(year, expected):
assert cal.easter(year) == expected
def test_democracy_day_moved_in_2019():
dd = lambda y: next(e["starts_on"] for e in cal.events_for_year(y) if e["key"] == "democracy_day") # noqa: E731
assert dd(2018) == date(2018, 5, 29)
assert dd(2019) == date(2019, 6, 12)
def test_islamic_holidays_are_estimates():
events = {e["key"]: e for e in cal.events_for_year(2025)}
assert events["eid_al_fitr"]["starts_on"] == date(2025, 3, 30)
assert events["eid_al_fitr"]["is_estimate"] and not events["christmas"]["is_estimate"]
def test_calendar_ingest_is_idempotent(gazetteer):
first = cal.ingest(gazetteer, 2024, 2025)["events"]
cal.ingest(gazetteer, 2024, 2025)
assert gazetteer.execute("select count(*) from events where source_id = 'calendar'").fetchone()[0] <= first
# βββ weather backfill βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def fake_fetch(calls):
def fetch(url, params):
calls.append(params)
a, b = date.fromisoformat(params.get("start_date") or date.today().isoformat()), \
date.fromisoformat(params.get("end_date") or (date.today() + timedelta(days=params.get("forecast_days", 1) - 1)).isoformat())
days = [(a + timedelta(days=i)).isoformat() for i in range((b - a).days + 1)]
return {"daily": {"time": days, **{v: [1.0] * len(days) for v in weather.VARIABLES}}}
return fetch
def test_call_weight():
assert weather.call_weight(14) == 1
assert weather.call_weight(365) == pytest.approx(365 / 14)
def test_backfill_stops_at_budget_and_resumes(gazetteer):
start = date(date.today().year - 3, 1, 1)
calls = []
first = weather.backfill_history(gazetteer, start=start, budget=60, fetch=fake_fetch(calls), sleep=lambda s: None)
assert first["stopped"] == "run budget reached" and first["year_chunks_remaining"] > 0
weather.backfill_history(gazetteer, start=start, budget=10_000, fetch=fake_fetch(calls), sleep=lambda s: None)
assert weather._missing_years(gazetteer, "ng.lagos", start, date.today() - timedelta(days=weather.ARCHIVE_LAG_DAYS)) == []
# A third run has nothing to fetch.
n = len(calls)
weather.backfill_history(gazetteer, start=start, budget=10_000, fetch=fake_fetch(calls), sleep=lambda s: None)
assert len(calls) == n
def test_backfill_resumes_current_year_from_last_day(gazetteer):
end = date.today() - timedelta(days=weather.ARCHIVE_LAG_DAYS)
start = date(end.year, 1, 1)
weather.backfill_history(gazetteer, start=start, fetch=fake_fetch([]), sleep=lambda s: None)
gazetteer.execute("delete from observations where period_start > ?", ((end - timedelta(days=3)).isoformat(),))
spans = weather._missing_years(gazetteer, "ng.lagos", start, end)
assert spans == [(end - timedelta(days=2), end)]
def test_quota_429_is_partial_success_not_error(gazetteer):
def fetch(url, params):
raise weather.QuotaExhausted("daily limit")
out = weather.backfill_history(gazetteer, start=date(date.today().year, 1, 1), fetch=fetch, sleep=lambda s: None)
assert "quota" in out["stopped"]
def test_forecast_rows_are_marked_and_versioned(gazetteer):
weather.fetch_forecast(gazetteer, days=3, fetch=fake_fetch([]))
rows = gazetteer.execute("select distinct is_forecast, issued_at from observations").fetchall()
assert [tuple(r) for r in rows] == [(1, date.today().isoformat())]
# βββ places category mapping ββββββββββββββββββββββββββββββββββββββββββββββββ
def test_subsector_prefers_detailed_taxonomy():
assert subsector_for("personal_or_beauty_service", "barber") == "hair_services"
assert subsector_for("personal_or_beauty_service", "makeup_artist") == "skincare_cosmetics"
assert subsector_for("restaurant", None) == "catering_food_service"
def test_context_places_have_no_subsector():
assert subsector_for("christian_place_of_worship", None) is None
assert subsector_for(None, None) is None
def test_mapped_subsectors_are_real_ids():
from news.business_types import VALID_IDS
from intel.subsector_map import BASIC_TO_SUBSECTOR, TAXONOMY_TO_SUBSECTOR
assert set(BASIC_TO_SUBSECTOR.values()) | set(TAXONOMY_TO_SUBSECTOR.values()) <= set(VALID_IDS)
# βββ news β events ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def test_record_news_item_is_noop_when_store_off(monkeypatch):
from intel.events import record_news_item
monkeypatch.delenv("INTEL_DB_PATH", raising=False)
assert record_news_item({"url_hash": "x", "title": "t"}) is False
def test_record_news_item_writes_located_event(monkeypatch, tmp_path):
from intel.events import record_news_item
path = tmp_path / "intel.db"
monkeypatch.setenv("INTEL_DB_PATH", str(path))
c = db.connect()
db.init(c)
add_location(c, "ng", "country", "Nigeria")
add_location(c, "ng.lagos", "state", "Lagos", "ng")
add_location(c, "ng.lagos.eti-osa", "lga", "Eti-Osa", "ng.lagos", aliases=("Lekki",))
c.commit()
item = {"url_hash": "abc", "title": "Lekki traders face new levy", "category": "policy", "location": "Lekki",
"published_at": "2026-10-01T09:00:00+00:00", "raw_text": "..."}
assert record_news_item(item) is True
assert record_news_item(item) is True # idempotent upsert
rows = c.execute("select family, location_id, starts_on from events where source_id = 'news_pipeline'").fetchall()
assert [tuple(r) for r in rows] == [("policy", "ng.lagos.eti-osa", "2026-10-01")]
c.close()
def test_record_news_item_never_raises(monkeypatch, tmp_path):
from intel.events import record_news_item
monkeypatch.setenv("INTEL_DB_PATH", str(tmp_path / "missing-schema.db"))
assert record_news_item({"url_hash": "x", "title": "t"}) is False
|