Files
PolyWeather/tests/test_observation_collector.py
T
2026-06-09 22:36:23 +08:00

384 lines
12 KiB
Python

from concurrent.futures import ThreadPoolExecutor, TimeoutError
import sqlite3
import threading
import time
def test_observation_source_gate_shares_inflight_and_cooldown(monkeypatch):
from src.data_collection.observation_source_gate import (
reset_observation_source_gate_for_tests,
run_observation_source,
)
monkeypatch.setenv("POLYWEATHER_OBSERVATION_SOURCE_DB_LOCK_ENABLED", "false")
reset_observation_source_gate_for_tests()
calls = 0
def fetcher():
nonlocal calls
calls += 1
time.sleep(0.05)
return {"temp": 23.4}
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [
executor.submit(run_observation_source, "amsc_awos", "qingdao", 180, fetcher),
executor.submit(run_observation_source, "amsc_awos", "qingdao", 180, fetcher),
]
results = [future.result(timeout=2) for future in futures]
assert results == [{"temp": 23.4}, {"temp": 23.4}]
assert calls == 1
cached = run_observation_source("amsc_awos", "qingdao", 180, fetcher)
assert cached == {"temp": 23.4}
assert calls == 1
def test_observation_source_gate_respects_failure_cooldown(monkeypatch):
from src.data_collection.observation_source_gate import (
reset_observation_source_gate_for_tests,
run_observation_source,
)
monkeypatch.setenv("POLYWEATHER_OBSERVATION_SOURCE_DB_LOCK_ENABLED", "false")
reset_observation_source_gate_for_tests()
calls = 0
def fetcher():
nonlocal calls
calls += 1
raise RuntimeError("upstream down")
try:
run_observation_source(
"cowin_obs",
"hong kong",
60,
fetcher,
failure_cooldown_sec=60,
)
except RuntimeError:
pass
skipped = run_observation_source(
"cowin_obs",
"hong kong",
60,
fetcher,
failure_cooldown_sec=60,
)
assert skipped is None
assert calls == 1
def test_observation_collector_profiles_match_source_cadence():
from web.observation_collector_service import build_observation_source_profiles
from web.realtime_patch_schema import SOURCE_CADENCE_SECONDS
profiles = {profile.source: profile for profile in build_observation_source_profiles()}
assert profiles["amsc_awos"].interval_sec == 180
assert profiles["amos"].interval_sec == 60
assert profiles["madis_hfmetar"].interval_sec == 300
assert profiles["cowin_obs"].interval_sec == 60
assert profiles["hko_obs"].interval_sec == 600
assert "qingdao" in profiles["amsc_awos"].cities
assert {"seoul", "busan"}.issubset(set(profiles["amos"].cities))
assert "new york" in profiles["madis_hfmetar"].cities
assert "hong kong" in profiles["cowin_obs"].cities
assert {"hong kong", "shenzhen"}.issubset(set(profiles["hko_obs"].cities))
assert SOURCE_CADENCE_SECONDS["amsc_awos"] == 180
def test_observation_collector_run_due_once_refreshes_panel_cache():
from web.observation_collector_service import (
ObservationCollector,
ObservationSourceProfile,
)
calls = []
refreshed = []
class FakeWeather:
def _uses_fahrenheit(self, city):
return False
def _attach_china_amsc_awos_data(self, results, city, use_fahrenheit):
calls.append((city, use_fahrenheit))
results["amos"] = {"source": "amsc_awos", "temp_c": 24.0}
collector = ObservationCollector(
weather=FakeWeather(),
profiles=[
ObservationSourceProfile(
source="amsc_awos",
cities=("qingdao",),
interval_sec=180,
)
],
cache_refresher=lambda city: refreshed.append(city),
async_cache_refresh=False,
)
assert collector.run_due_once(now_ts=1000.0) == 1
assert calls == [("qingdao", False)]
assert refreshed == ["qingdao"]
assert collector.run_due_once(now_ts=1100.0) == 0
assert calls == [("qingdao", False)]
assert collector.run_due_once(now_ts=1180.0) == 1
assert calls == [("qingdao", False), ("qingdao", False)]
assert refreshed == ["qingdao", "qingdao"]
def test_observation_collector_cache_refresh_does_not_block_source_polling():
from web.observation_collector_service import (
ObservationCollector,
ObservationSourceProfile,
)
calls = []
refresh_started = threading.Event()
release_refresh = threading.Event()
refreshed = []
class FakeWeather:
def _uses_fahrenheit(self, city):
return False
def _attach_china_amsc_awos_data(self, results, city, use_fahrenheit):
calls.append(city)
results["amsc_awos"] = {"source": "amsc_awos", "temp_c": 24.0}
def slow_cache_refresher(city):
refresh_started.set()
release_refresh.wait(timeout=2)
refreshed.append(city)
collector = ObservationCollector(
weather=FakeWeather(),
profiles=[
ObservationSourceProfile(
source="amsc_awos",
cities=("qingdao", "beijing"),
interval_sec=180,
)
],
cache_refresher=slow_cache_refresher,
)
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(collector.run_due_once, now_ts=1000.0)
assert refresh_started.wait(timeout=1)
try:
try:
result = future.result(timeout=0.1)
except TimeoutError:
result = None
finally:
release_refresh.set()
assert result == 2
assert calls == ["qingdao", "beijing"]
deadline = time.time() + 2
while set(refreshed) != {"qingdao", "beijing"} and time.time() < deadline:
time.sleep(0.01)
assert set(refreshed) == {"qingdao", "beijing"}
collector.close()
def test_observation_collector_records_source_status_to_runtime_state(tmp_path):
from src.database.runtime_state import ObservationCollectorStatusRepository, RuntimeStateDB
from web.observation_collector_service import (
ObservationCollector,
ObservationSourceProfile,
)
class FakeWeather:
def _uses_fahrenheit(self, city):
return False
def _attach_china_amsc_awos_data(self, results, city, use_fahrenheit):
results["amsc_awos"] = {"source": "amsc_awos", "temp_c": 22.8}
db = RuntimeStateDB(str(tmp_path / "polyweather.db"))
status_repo = ObservationCollectorStatusRepository(db)
collector = ObservationCollector(
weather=FakeWeather(),
profiles=[ObservationSourceProfile("amsc_awos", ("beijing",), 180)],
status_recorder=status_repo,
)
assert collector.run_due_once(now_ts=1000.0) == 1
payload = status_repo.load_snapshot(now_ts=1001.0)
assert payload["total_entries"] == 1
assert payload["status_counts"] == {"ok": 1}
entry = payload["entries"][0]
assert entry["source"] == "amsc_awos"
assert entry["city"] == "beijing"
assert entry["interval_sec"] == 180
assert entry["failure_count"] == 0
assert entry["last_error"] is None
assert entry["last_success_at"] is not None
assert entry["last_failure_at"] is None
assert entry["last_latency_ms"] is not None
assert entry["next_due_ts"] == 1180.0
assert entry["in_cooldown"] is False
assert entry["status"] == "ok"
source = payload["sources"][0]
assert source["source"] == "amsc_awos"
assert source["city_count"] == 1
assert source["failure_count"] == 0
assert source["avg_latency_ms"] is not None
assert source["status_counts"] == {"ok": 1}
def test_observation_collector_records_failure_and_cooldown(tmp_path):
from src.database.runtime_state import ObservationCollectorStatusRepository, RuntimeStateDB
from web.observation_collector_service import (
ObservationCollector,
ObservationSourceProfile,
)
class FakeWeather:
def _uses_fahrenheit(self, city):
return False
def _attach_china_amsc_awos_data(self, results, city, use_fahrenheit):
raise RuntimeError("upstream timeout")
db = RuntimeStateDB(str(tmp_path / "polyweather.db"))
status_repo = ObservationCollectorStatusRepository(db)
collector = ObservationCollector(
weather=FakeWeather(),
profiles=[ObservationSourceProfile("amsc_awos", ("seoul",), 180)],
status_recorder=status_repo,
)
assert collector.run_due_once(now_ts=2000.0) == 0
payload = status_repo.load_snapshot(now_ts=2010.0)
assert payload["total_entries"] == 1
assert payload["status_counts"] == {"cooldown": 1}
entry = payload["entries"][0]
assert entry["source"] == "amsc_awos"
assert entry["city"] == "seoul"
assert entry["failure_count"] == 1
assert entry["last_success_at"] is None
assert entry["last_failure_at"] is not None
assert entry["last_error"] == "upstream timeout"
assert entry["next_due_ts"] == 2180.0
assert entry["in_cooldown"] is True
assert entry["status"] == "cooldown"
source = payload["sources"][0]
assert source["failure_count"] == 1
assert source["cooldown_count"] == 1
def test_ops_observation_collector_status_returns_runtime_snapshot(monkeypatch, tmp_path):
from src.database.runtime_state import ObservationCollectorStatusRepository, RuntimeStateDB
from web.services import ops_api
db = RuntimeStateDB(str(tmp_path / "polyweather.db"))
status_repo = ObservationCollectorStatusRepository(db)
now = time.time()
status_repo.record_result(
source="cowin_obs",
city="hong kong",
interval_sec=60,
due_ts=now,
started_ts=now,
completed_ts=now + 0.25,
ok=True,
)
monkeypatch.setattr(
ops_api.legacy_routes,
"_require_ops_admin",
lambda request: {"email": "ops@example.com"},
)
monkeypatch.setattr(ops_api, "ObservationCollectorStatusRepository", lambda: status_repo)
payload = ops_api.get_ops_observation_collector_status(object(), limit=10)
assert payload["total_entries"] == 1
assert payload["entries"][0]["source"] == "cowin_obs"
assert payload["entries"][0]["city"] == "hong kong"
assert payload["sources"][0]["source"] == "cowin_obs"
assert payload["status_counts"] == {"ok": 1}
def test_observation_collector_worker_entrypoint_exists():
from web import observation_collector_worker
assert callable(observation_collector_worker.main)
def test_ephemeral_observation_log_writes_skip_sqlite_lock(monkeypatch, tmp_path):
from src.database.db_manager import DBManager
db = DBManager(str(tmp_path / "polyweather.db"))
def locked_connection():
raise sqlite3.OperationalError("database is locked")
monkeypatch.setattr(db, "_get_connection", locked_connection)
db.append_airport_obs(
icao="ZSQD",
city="qingdao",
temp_c=24.0,
obs_time="2026-06-08T04:00:00Z",
)
db.append_runway_obs(
icao="ZSQD",
city="qingdao",
runway="17/35",
target_runway_max=24.0,
otime_utc="2026-06-08T04:00:00Z",
)
def test_airport_obs_batch_writes_share_one_sqlite_transaction(monkeypatch, tmp_path):
from src.database.db_manager import DBManager
db = DBManager(str(tmp_path / "polyweather-airport-obs-batch.db"))
original_get_connection = db._get_connection
connection_calls = {"count": 0}
def counting_connection():
connection_calls["count"] += 1
return original_get_connection()
monkeypatch.setattr(db, "_get_connection", counting_connection)
db.append_airport_obs_batch(
[
{
"icao": "ZBAA",
"city": "beijing",
"temp_c": 24.0,
"obs_time": "2026-06-08T04:00:00Z",
},
{
"icao": "ZBAD",
"city": "beijing",
"temp_c": 25.0,
"obs_time": "2026-06-08T04:00:00Z",
},
]
)
assert connection_calls["count"] == 1
assert [row["temp_c"] for row in db.get_airport_obs_recent("ZBAA", minutes=180)] == [24.0]
assert [row["temp_c"] for row in db.get_airport_obs_recent("ZBAD", minutes=180)] == [25.0]