diff --git a/tests/test_canonical_temperature.py b/tests/test_canonical_temperature.py index 09c4f7cf..871cec29 100644 --- a/tests/test_canonical_temperature.py +++ b/tests/test_canonical_temperature.py @@ -4,30 +4,6 @@ import asyncio from types import SimpleNamespace -def _sample_panel_payload() -> dict: - return { - "name": "shanghai", - "temp_symbol": "°C", - "updated_at": "2026-06-14T01:02:03+00:00", - "current": { - "temp": 31.2, - "source_code": "amsc_awos", - "settlement_source": "amsc_awos", - "settlement_source_label": "AMSC AWOS runway-point air temperature", - "station_code": "ZSPD", - "station_name": "Shanghai Pudong", - "observed_at": "2026-06-14T01:01:00+00:00", - "obs_time": "09:01", - "freshness": { - "freshness_status": "fresh", - "age_sec": 63, - "observed_at": "2026-06-14T01:01:00+00:00", - "observed_at_local": "09:01", - }, - }, - } - - def test_canonical_temperature_roles_understand_adapter_source_names(): from web.services.canonical_temperature import build_canonical_temperature @@ -80,33 +56,80 @@ def test_db_manager_stores_canonical_temperature_latest(tmp_path): assert row["payload"]["observed_at"] == "2026-06-14T01:01:00+00:00" -def test_refresh_city_panel_cache_persists_canonical_temperature(monkeypatch): +def test_refresh_city_panel_cache_reads_canonical_without_sync_fetch(monkeypatch): import web.services.city_runtime as city_runtime - writes = {} + enqueued = [] class FakeDB: - def set_city_cache(self, kind, city, payload, **_kwargs): - writes["city_cache"] = (kind, city, payload) + def get_city_cache(self, kind, city): + assert kind == "panel" + assert city == "shanghai" + return None - def set_canonical_temperature(self, city, payload): - writes["canonical"] = (city, payload) + def get_canonical_temperature(self, city): + assert city == "shanghai" + return { + "payload": { + "city": "shanghai", + "value": 31.2, + "temp_symbol": "°C", + "source": "amsc_awos", + "source_label": "AMSC AWOS runway-point air temperature", + "source_role": "settlement_proxy", + "observed_at": "2026-06-14T01:01:00+00:00", + "observed_at_local": "09:01", + "freshness_sec": 63, + "freshness_status": "fresh", + "fetched_at": "2026-06-14T01:02:03+00:00", + "confidence": 0.92, + "explanation": "AMSC AWOS runway-point air temperature updated 63s ago.", + } + } + + def enqueue_observation_refresh_request(self, **kwargs): + enqueued.append(kwargs) + return True + + def fail_analyze(*_args, **_kwargs): + raise AssertionError("business cache refresh must not call _analyze") monkeypatch.setattr(city_runtime, "_CACHE_DB", FakeDB()) - monkeypatch.setattr( - city_runtime, - "_analyze", - lambda city, force_refresh=False, detail_mode="panel": _sample_panel_payload(), - ) + if hasattr(city_runtime, "_analyze"): + monkeypatch.setattr(city_runtime, "_analyze", fail_analyze) - payload = city_runtime._refresh_city_panel_cache("shanghai", allow_external_fetch=True) + payload = city_runtime._refresh_city_panel_cache("shanghai") assert payload["canonical_temperature"]["value"] == 31.2 assert payload["canonical_temperature"]["source"] == "amsc_awos" assert payload["canonical_temperature"]["freshness_sec"] == 63 assert payload["canonical_temperature"]["source_role"] == "settlement_proxy" - assert writes["canonical"][0] == "shanghai" - assert writes["canonical"][1]["observed_at"] == "2026-06-14T01:01:00+00:00" + assert enqueued == [ + { + "city": "shanghai", + "kind": "panel", + "priority": "high", + "reason": "canonical_fallback", + } + ] + + +def test_city_runtime_refresh_helpers_have_no_sync_fetch_escape_hatch(): + import inspect + + import web.services.city_runtime as city_runtime + + for name in ( + "_refresh_city_summary_cache", + "_refresh_city_panel_cache", + "_refresh_city_nearby_cache", + "_refresh_city_market_cache", + "_refresh_city_full_cache", + ): + signature = inspect.signature(getattr(city_runtime, name)) + assert "allow_external_fetch" not in signature.parameters + assert not hasattr(city_runtime, "_analyze") + assert not hasattr(city_runtime, "_analyze_summary") def test_refresh_city_panel_cache_defaults_to_queue_without_sync_analyze(monkeypatch): @@ -128,11 +151,7 @@ def test_refresh_city_panel_cache_defaults_to_queue_without_sync_analyze(monkeyp enqueued.append(kwargs) return True - def fail_analyze(*_args, **_kwargs): - raise AssertionError("business cache refresh must not call _analyze by default") - monkeypatch.setattr(city_runtime, "_CACHE_DB", FakeDB()) - monkeypatch.setattr(city_runtime, "_analyze", fail_analyze) payload = city_runtime._refresh_city_panel_cache("shanghai") diff --git a/web/services/city_runtime.py b/web/services/city_runtime.py index a95c18f4..2ab98220 100644 --- a/web/services/city_runtime.py +++ b/web/services/city_runtime.py @@ -23,17 +23,12 @@ from src.data_collection.city_registry import ALIASES from src.data_collection.city_time import get_city_utc_offset_seconds # noqa: F401 - compatibility export for transitional routers from src.utils.refresh_policy import OBSERVATION_REFRESH_SEC, SCAN_ROWS_REFRESH_SEC from web.analysis_service import ( - _analyze, - _analyze_summary, _build_city_chart_detail_payload, # noqa: F401 - compatibility export for chart detail batches _build_city_detail_payload, # noqa: F401 - compatibility export for tests and transitional routers _build_city_market_scan_payload, - _build_city_summary_payload, ) from web.services.canonical_temperature import ( - attach_canonical_temperature, build_city_weather_from_canonical, - store_canonical_temperature_from_payload, ) from web.scan_terminal_service import build_scan_terminal_payload # noqa: F401 - compatibility export for tests and transitional routers from web.core import ( @@ -277,17 +272,6 @@ def _refresh_market_scan_payload_from_cached_analysis( return payload.get("market_scan_payload") or {} -def _attach_and_store_canonical_temperature(city: str, payload: dict) -> dict: - if not isinstance(payload, dict): - return payload - attach_canonical_temperature(payload, city=city) - try: - store_canonical_temperature_from_payload(_CACHE_DB, city, payload) - except Exception as exc: - logger.debug("canonical temperature store skipped city={}: {}", city, exc) - return payload - - def _enqueue_city_observation_refresh(city: str, kind: str, *, reason: str) -> bool: try: enqueue = getattr(_CACHE_DB, "enqueue_observation_refresh_request", None) @@ -428,116 +412,24 @@ def _queued_city_cache_payload(city: str, kind: str, *, force_refresh: bool = Fa return _initializing_city_payload(normalized, detail_depth=kind) -def _refresh_city_summary_cache( - city: str, - force_refresh: bool = False, - *, - allow_external_fetch: bool = False, -) -> dict: - if not allow_external_fetch: - return _queued_city_cache_payload(city, "summary", force_refresh=force_refresh) - - data = _analyze_summary(city, force_refresh=force_refresh) - _attach_and_store_canonical_temperature(city, data) - payload = _build_city_summary_payload(data) - if data.get("canonical_temperature"): - payload["canonical_temperature"] = data["canonical_temperature"] - _CACHE_DB.set_city_cache( - "summary", - city, - payload, - version="v1", - source_fingerprint=f"{city}:summary", - ) - return payload +def _refresh_city_summary_cache(city: str, force_refresh: bool = False) -> dict: + return _queued_city_cache_payload(city, "summary", force_refresh=force_refresh) -def _refresh_city_panel_cache( - city: str, - force_refresh: bool = False, - *, - allow_external_fetch: bool = False, -) -> dict: - if not allow_external_fetch: - return _queued_city_cache_payload(city, "panel", force_refresh=force_refresh) - - payload = _analyze(city, force_refresh=force_refresh, detail_mode="panel") - _attach_and_store_canonical_temperature(city, payload) - _CACHE_DB.set_city_cache( - "panel", - city, - payload, - version="v1", - source_fingerprint=f"{city}:panel", - ) - return payload +def _refresh_city_panel_cache(city: str, force_refresh: bool = False) -> dict: + return _queued_city_cache_payload(city, "panel", force_refresh=force_refresh) -def _refresh_city_nearby_cache( - city: str, - force_refresh: bool = False, - *, - allow_external_fetch: bool = False, -) -> dict: - if not allow_external_fetch: - return _queued_city_cache_payload(city, "nearby", force_refresh=force_refresh) - - payload = _analyze(city, force_refresh=force_refresh, detail_mode="nearby") - _attach_and_store_canonical_temperature(city, payload) - _CACHE_DB.set_city_cache( - "nearby", - city, - payload, - version="v1", - source_fingerprint=f"{city}:nearby", - ) - return payload +def _refresh_city_nearby_cache(city: str, force_refresh: bool = False) -> dict: + return _queued_city_cache_payload(city, "nearby", force_refresh=force_refresh) -def _refresh_city_market_cache( - city: str, - force_refresh: bool = False, - *, - allow_external_fetch: bool = False, -) -> dict: - if not allow_external_fetch: - return _queued_city_cache_payload(city, "market", force_refresh=force_refresh) - - payload = _analyze(city, force_refresh=force_refresh, detail_mode="market") - _attach_and_store_canonical_temperature(city, payload) - now_ts = time.time() - payload["market_analysis_cached_at"] = datetime.now().isoformat() - payload["market_analysis_cached_at_ts"] = now_ts - _attach_market_scan_payload(payload) - _CACHE_DB.set_city_cache( - "market", - city, - payload, - version="v1", - source_fingerprint=f"{city}:market", - ) - return payload +def _refresh_city_market_cache(city: str, force_refresh: bool = False) -> dict: + return _queued_city_cache_payload(city, "market", force_refresh=force_refresh) -def _refresh_city_full_cache( - city: str, - force_refresh: bool = False, - *, - allow_external_fetch: bool = False, -) -> dict: - if not allow_external_fetch: - return _queued_city_cache_payload(city, "full", force_refresh=force_refresh) - - payload = _analyze(city, force_refresh=force_refresh, detail_mode="full") - _attach_and_store_canonical_temperature(city, payload) - _CACHE_DB.set_city_cache( - "full", - city, - payload, - version="v1", - source_fingerprint=f"{city}:full", - ) - return payload +def _refresh_city_full_cache(city: str, force_refresh: bool = False) -> dict: + return _queued_city_cache_payload(city, "full", force_refresh=force_refresh) def _overlay_wunderground_current(payload: dict, wunderground: dict) -> dict: