"""BUG-727 / BUG-728: foreground VedAstro snapshot cache, cancel, and on-demand western packet.""" from __future__ import annotations import json import threading import time from concurrent.futures import ThreadPoolExecutor from pathlib import Path import pytest from scripts import jyotish_api_server from scripts import vedastro_foreground as foreground from scripts.jyotish_api_server import JyotishAPIHandler from scripts.vedastro_snapshot_cache import ( lookup_snapshot, snapshot_cache_dir, snapshot_cache_key, snapshot_cache_path, store_snapshot, ) def _handler() -> JyotishAPIHandler: return JyotishAPIHandler.__new__(JyotishAPIHandler) def _verified_gateway(*, request_id: str, reference_date: str) -> dict: return { "scope": "vedastro_gateway_run", "status": "official_verified", "official_closure_state": "official_verified", "official_raw_response": { "request_id": request_id, "natal": {"sun": "Leo", "moon": "Taurus", "ascendant": "Cancer"}, "reference_date": reference_date, }, } def _fake_chart() -> dict: return { "success": True, "birth_info": {"date": "1997-08-08", "time": "05:00", "tz": 8}, "ascendant": {"lon": 92.0, "sign": "Cancer", "sign_idx": 3}, "planets": {}, "dasha": {"periods": [{"lord": "Sun", "start": "2026-01-01", "end": "2027-01-01"}]}, "modules": { "varga_full": {"D9": {}, "D10": {}}, "arudha_padas": {"A10": {}, "UL": {}}, "narayana_dasha": {"periods": []}, "ashtakavarga": {"sav": []}, "kp_cusps": {"houses": []}, }, "special_lagnas": {"precision": "sunrise_correct"}, } def _stub_local(monkeypatch, handler: JyotishAPIHandler, gateway) -> None: monkeypatch.setattr(handler, "_compute_chart", lambda body: _fake_chart()) monkeypatch.setattr( handler, "_compute_rectification_gate", lambda body: { "success": True, "summary": {"recommended_events": [], "warned": [], "disabled": []}, }, ) monkeypatch.setattr( handler, "_compute_thematic_report", lambda body: {"success": True, "endpoint": "thematic_report", "themes": {}}, ) monkeypatch.setattr(handler, "_compute_vedastro_gateway_run", gateway) def _body(*, reference_date: str, extra: dict | None = None) -> dict: payload = { "entry_mode": "direct_chart", "question": "请直接排盘并重点看事业", "theme": ["career"], "year": 1997, "month": 8, "day": 8, "hour": 5, "minute": 0, "lat": 36.420487, "lon": 114.209936, "tz": 8, "reference_date": reference_date, "today": reference_date, "current_date": reference_date, "western_mode": False, "defer_optional_external_evidence": True, } if extra: payload.update(extra) return payload @pytest.fixture def snapshot_cache(tmp_path, monkeypatch): cache_dir = tmp_path / "vedastro_snapshot_cache" monkeypatch.setenv("JYOTISH_VEDASTRO_SNAPSHOT_CACHE_DIR", str(cache_dir)) return cache_dir def test_snapshot_cache_dir_is_not_api_chart_cache_dir(snapshot_cache) -> None: chart_dir = jyotish_api_server._api_chart_cache_dir() snap_dir = snapshot_cache_dir() assert snap_dir.resolve() != chart_dir.resolve() assert snap_dir.name == "vedastro_snapshot_cache" assert chart_dir.name == "api_chart_cache" assert snapshot_cache_key is not jyotish_api_server._api_chart_cache_key def test_snapshot_cache_key_differs_from_api_chart_cache_key() -> None: body = _body(reference_date="2026-09-15") snap_key = snapshot_cache_key(body, reference_date="2026-09-15") chart_key = jyotish_api_server._api_chart_cache_key( jyotish_api_server._build_api_chart_cache_payload(body) ) assert snap_key != chart_key assert len(snap_key) == 64 assert "1997" not in snap_key assert "36.420487" not in snap_key assert snapshot_cache_path(snap_key).name == f"{snap_key}.json" def test_cache_file_strips_identity_and_keeps_hash_filename(snapshot_cache) -> None: body = _body(reference_date="2026-09-15") packet = _verified_gateway(request_id="keep-me", reference_date="2026-09-15") packet["name"] = "Secret Person" packet["email"] = "user@example.com" packet["user_id"] = "usr_123" stored = store_snapshot(body, packet, reference_date="2026-09-15") assert stored is not None path = snapshot_cache_path(stored["cache_key"]) text = path.read_text(encoding="utf-8") assert "Secret Person" not in text assert "user@example.com" not in text assert "usr_123" not in text assert "keep-me" in text assert path.name.endswith(".json") assert "1997" not in path.name def test_second_same_day_consultation_skips_gateway_and_urlopen(snapshot_cache, monkeypatch) -> None: handler = _handler() calls = {"gateway": 0, "urlopen": 0} def counting_gateway(body): calls["gateway"] += 1 return _verified_gateway(request_id="same-day", reference_date=body.get("reference_date")) def counting_urlopen(*_args, **_kwargs): calls["urlopen"] += 1 raise AssertionError("urlopen must not run when the gateway is stubbed") _stub_local(monkeypatch, handler, counting_gateway) monkeypatch.setattr("urllib.request.urlopen", counting_urlopen) body = _body(reference_date="2026-09-15") first = handler._compute_consultation_workflow(body) second = handler._compute_consultation_workflow(body) assert calls["gateway"] == 1 assert calls["urlopen"] == 0 assert first["vedastro_gateway"] == second["vedastro_gateway"] assert first["vedastro_snapshot_cache"]["freshness"] == "miss" assert second["vedastro_snapshot_cache"]["freshness"] == "fresh" def test_next_day_serves_stale_and_refreshes_without_waiting(snapshot_cache, monkeypatch) -> None: handler = _handler() yesterday = "2026-09-14" today = "2026-09-15" store_snapshot( _body(reference_date=yesterday), _verified_gateway(request_id="yesterday", reference_date=yesterday), reference_date=yesterday, ) release = threading.Event() calls = {"gateway": 0} def slow_today(body): calls["gateway"] += 1 release.wait(timeout=2.0) return _verified_gateway(request_id="today", reference_date=today) _stub_local(monkeypatch, handler, slow_today) started = time.monotonic() result = handler._compute_consultation_workflow(_body(reference_date=today)) elapsed = time.monotonic() - started release.set() assert elapsed < 1.0 assert result["vedastro_snapshot_cache"]["freshness"] == "stale" assert result["vedastro_snapshot_cache"]["reference_date"] == yesterday assert result["vedastro_gateway"]["snapshot_reference_date"] == yesterday assert result["vedastro_gateway"]["official_closure_state"] == "official_verified" assert result["vedastro_gateway"]["official_raw_response"]["request_id"] == "yesterday" deadline = time.monotonic() + 2.0 while calls["gateway"] < 1 and time.monotonic() < deadline: time.sleep(0.01) assert calls["gateway"] >= 1 def test_daily_starlanguage_does_not_eat_yesterday_cache(snapshot_cache, monkeypatch) -> None: handler = _handler() yesterday = "2026-09-14" today = "2026-09-15" store_snapshot( _body(reference_date=yesterday), _verified_gateway(request_id="yesterday", reference_date=yesterday), reference_date=yesterday, ) calls = {"gateway": 0} def today_gateway(body): calls["gateway"] += 1 return _verified_gateway(request_id="today-live", reference_date=today) _stub_local(monkeypatch, handler, today_gateway) result = handler._compute_consultation_workflow( _body(reference_date=today, extra={"entrypoint": "daily_starlanguage"}) ) assert calls["gateway"] == 1 assert result["vedastro_snapshot_cache"]["freshness"] == "miss" assert result["vedastro_gateway"]["official_raw_response"]["request_id"] == "today-live" assert lookup_snapshot( _body(reference_date=today, extra={"entrypoint": "daily_starlanguage"}), today=today, )["freshness"] == "fresh" def test_nth_plus_one_wait_stays_within_join_and_queued_work_is_cancelled( snapshot_cache, monkeypatch ) -> None: join = 0.2 monkeypatch.setenv("JYOTISH_FOREGROUND_VEDASTRO_JOIN_SECONDS", str(join)) monkeypatch.setenv("JYOTISH_FOREGROUND_VEDASTRO_BUDGET_SECONDS", "8") handler = _handler() started: list[int] = [] lock = threading.Lock() release = threading.Event() def slow_gateway(body): with lock: started.append(1) release.wait(timeout=8) return _verified_gateway(request_id="slow", reference_date="2026-09-15") _stub_local(monkeypatch, handler, slow_gateway) workers = foreground._FOREGROUND_VEDASTRO_WORKERS body = _body(reference_date="2026-09-15") elapsed: list[float] = [] def run_one() -> None: t0 = time.monotonic() result = handler._compute_consultation_workflow(body) elapsed.append(time.monotonic() - t0) assert result["vedastro_gateway"]["official_closure_reason"] == "foreground_optional_evidence_timeout" try: with ThreadPoolExecutor(max_workers=workers + 3) as pool: futs = [pool.submit(run_one) for _ in range(workers + 3)] for fut in futs: fut.result(timeout=8) assert max(elapsed) <= join + 2.5 assert len(started) <= workers finally: release.set() def test_join_budget_workers_declared_together_and_budget_respects_ratio(monkeypatch) -> None: source = Path("scripts/vedastro_foreground.py").read_text(encoding="utf-8") join_idx = source.index("FOREGROUND_VEDASTRO_JOIN_SECONDS_DEFAULT") ratio_idx = source.index("FOREGROUND_VEDASTRO_BUDGET_JOIN_RATIO") workers_idx = source.index("FOREGROUND_VEDASTRO_WORKERS_DEFAULT") span = max(join_idx, ratio_idx, workers_idx) - min(join_idx, ratio_idx, workers_idx) assert span < 400 assert "change together" in source monkeypatch.delenv("JYOTISH_FOREGROUND_VEDASTRO_JOIN_SECONDS", raising=False) monkeypatch.delenv("JYOTISH_FOREGROUND_VEDASTRO_BUDGET_SECONDS", raising=False) join = foreground._foreground_vedastro_join_seconds() budget = foreground._foreground_vedastro_budget_seconds() assert budget <= foreground.FOREGROUND_VEDASTRO_BUDGET_JOIN_RATIO * join + 1e-9 monkeypatch.setenv("JYOTISH_FOREGROUND_VEDASTRO_JOIN_SECONDS", "1.5") monkeypatch.setenv("JYOTISH_FOREGROUND_VEDASTRO_BUDGET_SECONDS", "8") assert foreground._foreground_vedastro_budget_seconds() <= 3.0 + 1e-9 def test_default_consultation_omits_western_packet_and_schema_required_keys_remain( snapshot_cache, monkeypatch ) -> None: handler = _handler() _stub_local( monkeypatch, handler, lambda body: _verified_gateway(request_id="omit-western", reference_date="2026-09-15"), ) omitted = handler._compute_consultation_workflow(_body(reference_date="2026-09-15")) included = handler._compute_consultation_workflow( _body(reference_date="2026-09-15", extra={"include_western_evidence_packet": True}) ) assert "western_evidence_packet" not in omitted assert "western_spectrum" in omitted["consumer_context"] assert omitted["success"] is True assert isinstance(omitted["chart"], dict) assert isinstance(omitted["routing"], dict) assert isinstance(omitted["consumer_context"], dict) assert "western_evidence_packet" in included workflow_ts = Path("frontend/src/mastra/consultation-workflow.ts").read_text(encoding="utf-8") schema = workflow_ts.split("export const consultationWorkflowResponseSchema", 1)[1] assert "success: z.boolean()" in schema assert "chart: z.record(z.unknown())" in schema assert "routing: z.record(z.unknown())" in schema assert "consumer_context:" in schema assert ").passthrough()" in schema omitted_chars = len(json.dumps(omitted, ensure_ascii=False)) included_chars = len(json.dumps(included, ensure_ascii=False)) assert included_chars >= omitted_chars def test_western_oracle_payload_still_returns_packet(snapshot_cache, monkeypatch) -> None: handler = _handler() _stub_local( monkeypatch, handler, lambda body: _verified_gateway(request_id="oracle", reference_date="2026-09-15"), ) result = handler._compute_consultation_workflow( _body( reference_date="2026-09-15", extra={ "western_oracle_payload": { "source_engine": "kerykeion_external_json", "natal": {"ascendant": "Virgo", "mc": "Gemini"}, } }, ) ) assert result["western_evidence_packet"]["source_engine"] == "kerykeion_external_json"