"""Issue #64: skip useless report subprocesses and observe the heavy-compute gate.""" from __future__ import annotations import json import logging from pathlib import Path import pytest from scripts import api_heavy_compute_gate as gate from scripts.annual_pyjhora_replay import ( _run_isolated_annual_replay, _run_isolated_tajaka_year_lord, reset_jhora_import_cache, ) from scripts.jyotish_engine import ( _oracle_progress_snapshot, reset_oracle_progress_snapshot_cache, ) from scripts.tajika import ( _resolve_external_pyjhora_python, reset_external_pyjhora_python_cache, ) ROOT = Path(__file__).resolve().parents[1] FIXTURES = ROOT / "tests" / "fixtures" / "api-chart-subprocess-gate" class _Place: name = "Delhi" latitude = 28.6139 longitude = 77.209 timezone = 5.5 elevation = 0 _PROFILE = {"ayanamsa": "LAHIRI", "node_mode": "mean"} def _without_hash(payload: dict) -> dict: body = dict(payload) body.pop("request_hash", None) return body def test_no_jhora_tajaka_skips_subprocess_and_matches_golden(monkeypatch) -> None: reset_jhora_import_cache() monkeypatch.setattr("scripts.annual_pyjhora_replay._jhora_importable", lambda: False) def _forbid_subprocess(*_args, **_kwargs): raise AssertionError("subprocess.run must not run when jhora cannot be imported") monkeypatch.setattr("scripts.annual_pyjhora_replay.subprocess.run", _forbid_subprocess) result = _run_isolated_tajaka_year_lord(_PROFILE, 2447893.0, _Place(), 30) golden = json.loads((FIXTURES / "tajaka-no-jhora.json").read_text(encoding="utf-8")) assert result["request_hash"] assert _without_hash(result) == golden def test_no_jhora_annual_skips_subprocess_and_matches_golden(monkeypatch) -> None: reset_jhora_import_cache() monkeypatch.setattr("scripts.annual_pyjhora_replay._jhora_importable", lambda: False) def _forbid_subprocess(*_args, **_kwargs): raise AssertionError("subprocess.run must not run when jhora cannot be imported") monkeypatch.setattr("scripts.annual_pyjhora_replay.subprocess.run", _forbid_subprocess) result = _run_isolated_annual_replay(_PROFILE, 2447893.0, _Place(), 30) golden = json.loads((FIXTURES / "annual-no-jhora.json").read_text(encoding="utf-8")) assert result["request_hash"] assert _without_hash(result) == golden def test_jhora_available_still_uses_subprocess(monkeypatch) -> None: # 原值:_jhora_importable 为真时两个隔离函数各起一次子进程(calls == 2) # 新值:产品调用不传 research 时,即使可导入也不起子进程、返回 golden;传 research=True 时仍起子进程(calls == 2) # 原因:#93 红线 1:装包后旧回放不得进年运章 reset_jhora_import_cache() monkeypatch.setattr("scripts.annual_pyjhora_replay._jhora_importable", lambda: True) calls = {"n": 0} class _Completed: returncode = 0 stdout = json.dumps({"status": "partial_verified", "settings": {}}) def _fake_run(*_args, **_kwargs): calls["n"] += 1 return _Completed() def _forbid_subprocess(*_args, **_kwargs): raise AssertionError("product replay must not spawn a child") monkeypatch.setattr("scripts.annual_pyjhora_replay.subprocess.run", _forbid_subprocess) tajaka = _run_isolated_tajaka_year_lord(_PROFILE, 2447893.0, _Place(), 30) annual = _run_isolated_annual_replay(_PROFILE, 2447893.0, _Place(), 30) assert calls["n"] == 0 tajaka_golden = json.loads((FIXTURES / "tajaka-no-jhora.json").read_text(encoding="utf-8")) annual_golden = json.loads((FIXTURES / "annual-no-jhora.json").read_text(encoding="utf-8")) assert _without_hash(tajaka) == tajaka_golden assert _without_hash(annual) == annual_golden monkeypatch.setattr("scripts.annual_pyjhora_replay.subprocess.run", _fake_run) _run_isolated_tajaka_year_lord(_PROFILE, 2447893.0, _Place(), 30, research=True) _run_isolated_annual_replay(_PROFILE, 2447893.0, _Place(), 30, research=True) assert calls["n"] == 2 def test_product_replay_calls_a_four_arg_stand_in(monkeypatch) -> None: """The 200-chart sweep replaces the runners with four-argument functions.""" from scripts.annual_pyjhora_replay import ( _no_jhora_annual_replay, _no_jhora_tajaka_replay, replay_pyjhora_annual, ) calls = [] def blocked(profile, birth_julian_day, place, age): calls.append(age) settings = {"ayanamsa": "LAHIRI", "node_mode": "mean"} result = _no_jhora_tajaka_replay(settings) if len(calls) == 1 else _no_jhora_annual_replay(settings) result["request_hash"] = "stand-in" return result monkeypatch.setattr("scripts.annual_pyjhora_replay._run_isolated_tajaka_year_lord", blocked) monkeypatch.setattr("scripts.annual_pyjhora_replay._run_isolated_annual_replay", blocked) result = replay_pyjhora_annual(_PROFILE, birth_julian_day=2447893.0, place=_Place(), age=30) assert calls == [30, 30] assert result["status"] == "blocked" def test_pyjhora_interpreter_lookup_is_cached(monkeypatch) -> None: reset_external_pyjhora_python_cache() calls = {"n": 0} def _missing(): calls["n"] += 1 return None monkeypatch.setattr("scripts.tajika._lookup_external_pyjhora_python", _missing) assert _resolve_external_pyjhora_python() is None assert _resolve_external_pyjhora_python() is None assert calls["n"] == 1 def test_oracle_progress_snapshot_reuses_until_the_file_changes(monkeypatch) -> None: reset_oracle_progress_snapshot_cache() calls = {"n": 0} real_run = __import__("subprocess").run def _counting_run(*args, **kwargs): calls["n"] += 1 return real_run(*args, **kwargs) monkeypatch.setattr("subprocess.run", _counting_run) first = _oracle_progress_snapshot() after_first = calls["n"] assert after_first >= 1 second = _oracle_progress_snapshot() assert calls["n"] == after_first assert second == first oracle = ROOT / "references" / "oracle" / "dasha_shadbala_oracle_cases.json" stat = oracle.stat() later = stat.st_mtime + 5 try: oracle.touch() import os os.utime(oracle, (later, later)) third = _oracle_progress_snapshot() assert calls["n"] > after_first assert third["scope"] == first["scope"] finally: os_utime = __import__("os").utime os_utime(oracle, (stat.st_atime, stat.st_mtime)) reset_oracle_progress_snapshot_cache() def test_chart_overview_gate_is_off_by_default(monkeypatch) -> None: monkeypatch.delenv(gate.ENV_GATE_CHART_OVERVIEW, raising=False) gate.reset_heavy_compute_gate(limit=1) held = gate.acquire_heavy_compute_slot("/api/high_rigor_workflow") assert held is not None assert gate.acquire_heavy_compute_slot_for_request("/api/chart", {"year": 1990}) is None gate.release_heavy_compute_slot(held) assert gate.heavy_compute_status()["in_use"] == 0 gate.reset_heavy_compute_gate() def test_chart_overview_gate_only_sync_unskipped_requests(monkeypatch) -> None: monkeypatch.setenv(gate.ENV_GATE_CHART_OVERVIEW, "1") # 原值:reset(limit=1) 后第二次立刻 HeavyComputeBusy # 新值:wait_seconds=0 # 原因:默认改为短排队;本测试仍锁「名额被占时同步排盘被拒绝」,等待行为不在这里 gate.reset_heavy_compute_gate(limit=1, wait_seconds=0) body = {"year": 1990, "lat": 28.6, "lon": 77.2} slot = gate.acquire_heavy_compute_slot_for_request("/api/chart", body) assert slot is not None assert gate.heavy_compute_status() == {"limit": 1, "in_use": 1} with pytest.raises(gate.HeavyComputeBusy): gate.acquire_heavy_compute_slot_for_request("/api/chart", {"year": 1991}) gate.release_heavy_compute_slot(slot) assert gate.heavy_compute_status()["in_use"] == 0 assert gate.acquire_heavy_compute_slot_for_request( "/api/chart", {"skip_vedastro_main_entry_overview": True} ) is None assert gate.acquire_heavy_compute_slot_for_request("/api/chart", {"async": True}) is None assert gate.acquire_heavy_compute_slot_for_request("/api/chart", {"enqueue": 1}) is None assert "/api/chart" not in gate.HEAVY_COMPUTE_PATHS gate.reset_heavy_compute_gate() def test_busy_log_names_holders_and_not_the_body(caplog) -> None: # 原值:reset(limit=1) 后拒绝日志立刻出现 # 新值:wait_seconds=0 # 原因:默认会先等 8 秒;本测试仍锁日志只有路径、没有请求正文 gate.reset_heavy_compute_gate(limit=1, wait_seconds=0) held = gate.acquire_heavy_compute_slot("/api/professional_report_reference") assert held is not None secret_body = {"birth_date": "1990-01-01", "name": "must-not-appear"} with caplog.at_level(logging.WARNING, logger="jyotish.heavy_compute"): with pytest.raises(gate.HeavyComputeBusy): gate.acquire_heavy_compute_slot_for_request("/api/professional_report_reference", secret_body) text = caplog.text assert "/api/professional_report_reference" in text assert "1990-01-01" not in text assert "must-not-appear" not in text gate.release_heavy_compute_slot(held) assert gate.heavy_compute_status()["in_use"] == 0 gate.reset_heavy_compute_gate() def test_warm_consultation_still_skips_the_slot() -> None: gate.reset_heavy_compute_gate(limit=1) assert gate.acquire_heavy_compute_slot_for_request( "/api/consultation_workflow", {"consult_card_warm_only": True} ) is None assert gate.heavy_compute_status()["in_use"] == 0 gate.reset_heavy_compute_gate()