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