Productize jyotish app release surface

This commit is contained in:
732642856
2026-06-24 13:37:33 +08:00
parent 23b8b6e5cf
commit 494e387018
71 changed files with 28658 additions and 709 deletions
+133 -1
View File
@@ -27,8 +27,140 @@
"modules.dasha_sandhi",
"modules.shadbala",
"modules.ashtakavarga",
"modules.validation"
"modules.validation",
"modules.remedies",
"modules.dasa_convergence",
"modules.transit_multi_reference"
],
"quality_gates": {
"min_modules": 45,
"non_empty_paths": [
"modules.shadbala.planets",
"modules.ashtakavarga.house_scores",
"modules.remedies.evidence_chain",
"modules.dasa_convergence.top_convergent_domains",
"modules.transit_multi_reference.references"
],
"invariants": {
"validation_valid": true,
"ashtakavarga_sav_total": 337,
"min_remedies_evidence": 1,
"dasha_current_covers_reference": true,
"dasa_convergence_non_empty": true,
"transit_has_references": true
},
"boundaries": {
"forbidden_absolute_terms": [
"一定会",
"必然",
"保证"
]
}
},
"max_errors": 0
},
{
"id": "ux_regression_delhi_1988",
"description": "A real-use regression case for India-local time, Dasha coverage, remedies evidence, transit references and Ashtakavarga invariants.",
"input": {
"year": 1988,
"month": 5,
"day": 14,
"hour": 6,
"minute": 30,
"lat": 28.6139,
"lon": 77.209,
"tz": 5.5,
"today": "2026-06-22"
},
"expected_output_paths": [
"modules.chart",
"modules.dasha",
"modules.varga_full.D9_Navamsa",
"modules.shadbala",
"modules.ashtakavarga",
"modules.validation",
"modules.remedies",
"modules.dasa_convergence",
"modules.transit_multi_reference"
],
"quality_gates": {
"min_modules": 45,
"non_empty_paths": [
"modules.shadbala.planets",
"modules.ashtakavarga.house_scores",
"modules.remedies.evidence_chain",
"modules.dasa_convergence.top_convergent_domains",
"modules.transit_multi_reference.references"
],
"invariants": {
"validation_valid": true,
"ashtakavarga_sav_total": 337,
"min_remedies_evidence": 1,
"dasha_current_covers_reference": true,
"dasa_convergence_non_empty": true,
"transit_has_references": true
},
"boundaries": {
"forbidden_absolute_terms": [
"一定会",
"必然",
"保证"
]
}
},
"max_errors": 0
},
{
"id": "ux_regression_newyork_2000",
"description": "A western-timezone regression case to catch timezone, Dasha-current, evidence and invariant drift.",
"input": {
"year": 2000,
"month": 7,
"day": 4,
"hour": 21,
"minute": 15,
"lat": 40.7128,
"lon": -74.006,
"tz": -4,
"today": "2026-06-22"
},
"expected_output_paths": [
"modules.chart",
"modules.dasha",
"modules.varga_full.D9_Navamsa",
"modules.shadbala",
"modules.ashtakavarga",
"modules.validation",
"modules.remedies",
"modules.dasa_convergence",
"modules.transit_multi_reference"
],
"quality_gates": {
"min_modules": 45,
"non_empty_paths": [
"modules.shadbala.planets",
"modules.ashtakavarga.house_scores",
"modules.remedies.evidence_chain",
"modules.dasa_convergence.top_convergent_domains",
"modules.transit_multi_reference.references"
],
"invariants": {
"validation_valid": true,
"ashtakavarga_sav_total": 337,
"min_remedies_evidence": 1,
"dasha_current_covers_reference": true,
"dasa_convergence_non_empty": true,
"transit_has_references": true
},
"boundaries": {
"forbidden_absolute_terms": [
"一定会",
"必然",
"保证"
]
}
},
"max_errors": 0
}
]
File diff suppressed because it is too large Load Diff
+299
View File
@@ -0,0 +1,299 @@
#!/usr/bin/env python3
"""Runtime smoke check for the Jyotish web app and local API."""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
import time
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
APP = ROOT / "jyotish-app"
API_SERVER = ROOT / "scripts" / "jyotish_api_server.py"
TMP = Path(os.environ.get("TMPDIR", "/private/tmp"))
def run(cmd: list[str], *, cwd: Path = ROOT, timeout: int = 12, check: bool = True) -> subprocess.CompletedProcess[str]:
return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout, check=check)
def curl_text(url: str, *, timeout: int = 12) -> str:
return run(["curl", "-sS", url], timeout=timeout).stdout
def curl_status(url: str, *, timeout: int = 4) -> tuple[bool, str]:
completed = run(
["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", url],
timeout=timeout,
check=False,
)
code = completed.stdout.strip()
return completed.returncode == 0 and code.startswith(("2", "3", "4")), completed.stderr.strip() or code
def curl_json(url: str, *, timeout: int = 12) -> dict[str, Any]:
return json.loads(curl_text(url, timeout=timeout))
def curl_post_json(url: str, payload: dict[str, Any], *, timeout: int = 15) -> dict[str, Any]:
completed = run(
[
"curl",
"-sS",
"-X",
"POST",
"-H",
"Content-Type: application/json",
"--data-binary",
json.dumps(payload),
url,
],
timeout=timeout,
)
return json.loads(completed.stdout)
def wait_for_url(url: str, *, timeout: float = 12.0, logs: list[Path] | None = None) -> None:
deadline = time.time() + timeout
last = ""
while time.time() < deadline:
ok, last = curl_status(url)
if ok:
return
time.sleep(0.25)
log_text = ""
for log in logs or []:
if log.exists():
log_text += f"\n--- {log} ---\n{log.read_text(encoding='utf-8', errors='replace')[-4000:]}"
raise RuntimeError(f"Timed out waiting for {url}: {last}{log_text}")
def start_process(cmd: list[str], cwd: Path, log_path: Path) -> subprocess.Popen[str]:
log_path.parent.mkdir(parents=True, exist_ok=True)
log = log_path.open("w", encoding="utf-8")
return subprocess.Popen(cmd, cwd=cwd, stdout=log, stderr=subprocess.STDOUT, text=True)
def stop_process(process: subprocess.Popen[str] | None) -> None:
if process is None:
return
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
def sample_birth_payload() -> dict[str, Any]:
return {
"year": 1990,
"month": 1,
"day": 1,
"hour": 12,
"minute": 0,
"lat": 39.9,
"lon": 116.4,
"tz": 8,
}
def assert_report_artifact_smoke(api_base: str) -> dict[str, Any]:
result = curl_post_json(
f"{api_base}/api/report_artifact",
{
"format": "html",
"name": "runtime smoke report",
"html": "<!doctype html><html><body><h1>Runtime Smoke</h1></body></html>",
},
timeout=20,
)
if not result.get("success") or result.get("endpoint") != "report_artifact":
raise AssertionError(f"report_artifact_smoke failed: {result}")
if not result.get("html_base64") or not result.get("html_filename", "").endswith(".html"):
raise AssertionError(f"report_artifact_smoke missing fallback artifact: {result}")
delivery = result.get("delivery") or {}
expected_fields = {
"artifact_status": "html_ready",
"primary_artifact": "html",
"download_filename": result.get("html_filename"),
"download_mime": "text/html;charset=utf-8",
}
for field, expected in expected_fields.items():
if result.get(field) != expected:
raise AssertionError(f"report_artifact_smoke bad {field}: {result}")
if delivery.get("artifact_status") != result.get("artifact_status"):
raise AssertionError(f"report_artifact_smoke delivery status mismatch: {result}")
if delivery.get("filename") != result.get("download_filename"):
raise AssertionError(f"report_artifact_smoke delivery filename mismatch: {result}")
if not result.get("user_message") or not result.get("next_action"):
raise AssertionError(f"report_artifact_smoke missing user guidance: {result}")
return {
"success": True,
"html_filename": result.get("html_filename"),
"format": result.get("format"),
"artifact_status": result.get("artifact_status"),
"download_filename": result.get("download_filename"),
}
def assert_ai_bridge_policy_smoke(web_base: str) -> dict[str, Any]:
bridge = curl_text(f"{web_base}/api-bridge.js")
ai_chat = curl_text(f"{web_base}/ai-chat.js")
i18n = curl_text(f"{web_base}/i18n.js")
required = [
"AI_BROWSER_KEY_DISABLED",
"不要把 OpenAI API key 放进浏览器",
"aiKeyPolicy: 'server_side_only'",
]
missing = [token for token in required if token not in bridge]
unsafe = [
"YINDUZHANXING_AI_KEY",
"apiKey: AI_KEY",
"Authorization': 'Bearer ' + AI_KEY",
]
chat_required = [
"buildAISetupGuidance",
"OPENAI_API_KEY",
"/api/chat",
"不要把 OpenAI API key 放进浏览器",
]
chat_text = ai_chat + "\n" + i18n
chat_missing = [token for token in chat_required if token not in chat_text]
chat_unsafe = [
"jyotish_ai_endpoint",
"Custom endpoint failed",
"在浏览器控制台输入",
]
leaked = [token for token in unsafe if token in bridge] + [token for token in chat_unsafe if token in chat_text]
if missing or leaked:
raise AssertionError(f"ai_bridge_policy_smoke failed: missing={missing}, chat_missing={chat_missing}, leaked={leaked}")
if chat_missing:
raise AssertionError(f"ai_chat_policy_smoke failed: chat_missing={chat_missing}")
return {
"success": True,
"policy": "AI_BROWSER_KEY_DISABLED",
"chat_policy": "server_side_only",
}
def assert_contract(web_base: str, api_base: str) -> dict[str, Any]:
html = curl_text(f"{web_base}/")
for token in ['id="birth-form"', 'id="tab-kp"', "/main.js", "/api-bridge.js"]:
if token not in html:
raise AssertionError(f"frontend missing token: {token}")
health = curl_json(f"{api_base}/api/health")
if health.get("status") != "ok":
raise AssertionError(f"bad health: {health}")
for module in ["Remedies", "Ashtakavarga", "Kakshya", "CaseValidation"]:
if module not in health.get("modules", ""):
raise AssertionError(f"health missing module: {module}")
audit = curl_json(f"{api_base}/api/capability_audit", timeout=20)
registry_count = audit["registry"]["technique_count"]
if audit["productization"]["summary"]["productized"] != registry_count:
raise AssertionError("not all registry techniques are productized")
if audit["ux_productization"]["summary"]["excellent"] != registry_count:
raise AssertionError("not all registry techniques are excellent UX")
if audit["priority_gaps"]:
raise AssertionError(f"priority gaps remain: {audit['priority_gaps']}")
chart = curl_post_json(f"{api_base}/api/chart", sample_birth_payload(), timeout=20)
if not chart.get("success"):
raise AssertionError(f"chart failed: {chart}")
if not chart.get("remedies", {}).get("evidence_chain"):
raise AssertionError("chart remedies evidence_chain is empty")
payload = {"planets": chart["planets"], "ascendant": chart["ascendant"]}
ashtakavarga = curl_post_json(f"{api_base}/api/ashtakavarga", payload, timeout=20)
for path in [("summary", "strongest_houses"), ("pav_summary", "top_planets"), ("sodhita_summary", "top_signs")]:
cur: Any = ashtakavarga
for part in path:
cur = cur.get(part) if isinstance(cur, dict) else None
if not cur:
raise AssertionError(f"ashtakavarga missing {'.'.join(path)}")
kp = curl_post_json(f"{api_base}/api/kp", payload, timeout=20)
if not kp or "error" in kp:
raise AssertionError(f"kp failed: {kp}")
report_artifact_smoke = assert_report_artifact_smoke(api_base)
ai_bridge_policy_smoke = assert_ai_bridge_policy_smoke(web_base)
return {
"valid": True,
"web_base": web_base,
"api_base": api_base,
"registry_count": registry_count,
"ux": audit["ux_productization"]["summary"],
"productization": audit["productization"]["summary"],
"chart_remedy_evidence": len(chart["remedies"]["evidence_chain"]),
"ashtakavarga_sav_total": ashtakavarga["summary"]["sav_total"],
"report_artifact_smoke": report_artifact_smoke,
"ai_bridge_policy_smoke": ai_bridge_policy_smoke,
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run Jyotish frontend/API runtime smoke")
parser.add_argument("--web-base", default="http://127.0.0.1:5173")
parser.add_argument("--api-base", default="http://127.0.0.1:5200")
parser.add_argument("--start-if-needed", action="store_true", help="Start local Vite/API servers if defaults are not already running")
parser.add_argument("--api-port", type=int, default=5200)
parser.add_argument("--web-port", type=int, default=5173)
return parser.parse_args()
def main() -> int:
args = parse_args()
api_process: subprocess.Popen[str] | None = None
web_process: subprocess.Popen[str] | None = None
web_base = args.web_base.rstrip("/")
api_base = args.api_base.rstrip("/")
try:
api_ok, _ = curl_status(f"{api_base}/api/health")
web_ok, _ = curl_status(f"{web_base}/")
if args.start_if_needed and (not api_ok or not web_ok):
web_base = f"http://127.0.0.1:{args.web_port}"
api_base = f"http://127.0.0.1:{args.api_port}"
api_log = TMP / f"jyotish-runtime-api-{args.api_port}.log"
web_log = TMP / f"jyotish-runtime-web-{args.web_port}.log"
if not api_ok:
api_process = start_process(
[sys.executable, str(API_SERVER), "--port", str(args.api_port), "--allow-origin", web_base],
ROOT,
api_log,
)
try:
wait_for_url(f"{api_base}/api/health", logs=[api_log])
except RuntimeError as exc:
if "PermissionError" in str(exc) and "socket.bind" in str(exc):
print(json.dumps({"valid": False, "skipped": True, "reason": "sandbox disallows local listening servers"}, ensure_ascii=False, indent=2))
return 0
raise
if not web_ok:
web_process = start_process(
["npm", "run", "dev", "--", "--host", "127.0.0.1", "--port", str(args.web_port)],
APP,
web_log,
)
wait_for_url(f"{web_base}/", logs=[web_log])
result = assert_contract(web_base, api_base)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
finally:
stop_process(web_process)
stop_process(api_process)
if __name__ == "__main__":
raise SystemExit(main())
+91
View File
@@ -9,6 +9,7 @@ import json
import os
import subprocess
import sys
from datetime import date
from typing import Any, Dict, List
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -25,6 +26,92 @@ def get_path(data: Dict[str, Any], dotted_path: str) -> Any:
return cur
def is_non_empty(value: Any) -> bool:
return value is not None and value != {} and value != []
def parse_date(value: str) -> date:
return date.fromisoformat(str(value)[:10])
def current_period_covers_reference(period: Dict[str, Any], reference_date: str) -> bool:
if not isinstance(period, dict):
return False
start = period.get("start") or period.get("start_date")
end = period.get("end") or period.get("end_date")
if not start or not end:
return False
ref = parse_date(reference_date)
return parse_date(start) <= ref <= parse_date(end)
def check_quality_gates(data: Dict[str, Any], case: Dict[str, Any]) -> List[str]:
gates = case.get("quality_gates", {})
failures: List[str] = []
modules = data.get("modules", {})
if not isinstance(modules, dict):
return ["modules must be an object"]
min_modules = gates.get("min_modules")
if min_modules is not None and len(modules) < min_modules:
failures.append(f"module count too low: {len(modules)} < {min_modules}")
for path in gates.get("non_empty_paths", []):
try:
value = get_path(data, path)
if not is_non_empty(value):
failures.append(f"empty quality path: {path}")
except KeyError:
failures.append(f"missing quality path: {path}")
invariants = gates.get("invariants", {})
if invariants.get("validation_valid") and modules.get("validation", {}).get("valid") is not True:
failures.append("validation.valid is not true")
expected_sav = invariants.get("ashtakavarga_sav_total")
if expected_sav is not None:
sav_total = modules.get("ashtakavarga", {}).get("sav", {}).get("total")
if sav_total != expected_sav:
failures.append(f"ashtakavarga SAV total mismatch: {sav_total} != {expected_sav}")
min_remedy_evidence = invariants.get("min_remedies_evidence")
if min_remedy_evidence is not None:
evidence = modules.get("remedies", {}).get("evidence_chain", [])
if len(evidence) < min_remedy_evidence:
failures.append(f"remedies evidence too thin: {len(evidence)} < {min_remedy_evidence}")
if invariants.get("dasha_current_covers_reference"):
reference_date = case.get("input", {}).get("today")
current = modules.get("dasha", {}).get("current_dasha", {})
if not reference_date or not current_period_covers_reference(current, reference_date):
failures.append("current dasha does not cover reference date")
if invariants.get("dasa_convergence_non_empty"):
domains = modules.get("dasa_convergence", {}).get("top_convergent_domains", [])
if not domains:
failures.append("dasa convergence has no top domains")
if invariants.get("transit_has_references"):
refs = modules.get("transit_multi_reference", {}).get("references", {})
if len(refs) < 3:
failures.append(f"transit references too thin: {len(refs)} < 3")
boundaries = gates.get("boundaries", {})
forbidden_terms = boundaries.get("forbidden_absolute_terms", [])
if forbidden_terms:
text = json.dumps({
"summary": data.get("summary"),
"warnings": data.get("warnings"),
"remedies": modules.get("remedies"),
"dasa_convergence": modules.get("dasa_convergence"),
}, ensure_ascii=False)
for term in forbidden_terms:
if term and term in text:
failures.append(f"forbidden absolute term found: {term}")
return failures
def run_full_reading(py: str, case: Dict[str, Any]) -> Dict[str, Any]:
inp = case["input"]
cmd = [
@@ -67,6 +154,10 @@ def run_cases(py: str, cases_path: str) -> Dict[str, Any]:
except KeyError:
case_result["passed"] = False
case_result["failures"].append(f"missing output path: {path}")
quality_failures = check_quality_gates(data, case)
if quality_failures:
case_result["passed"] = False
case_result["failures"].extend(quality_failures)
except Exception as exc:
case_result["passed"] = False
case_result["failures"].append(str(exc))
File diff suppressed because it is too large Load Diff
+72 -17
View File
@@ -3,29 +3,47 @@
from __future__ import annotations
from ashtakavarga import ALL_SOURCES, BAV_TOTALS, EXPECTED_SAV_TOTAL, SEVEN_PLANETS, SIGNS, calc_ashtakavarga
from hypothesis import given, settings
from hypothesis import strategies as st
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = ROOT / "scripts"
if str(SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCRIPTS))
from ashtakavarga import (
ALL_SOURCES,
BAV_TOTALS,
EXPECTED_SAV_TOTAL,
SEVEN_PLANETS,
SIGNS,
calc_ashtakavarga,
calc_prastara_av,
calc_yoga_pinda,
)
@st.composite
def planet_signs(draw: st.DrawFn) -> dict[str, dict[str, str]]:
return {planet: {"sign": draw(st.sampled_from(SIGNS))} for planet in SEVEN_PLANETS}
def _planet_signs(offset: int = 0, step: int = 1) -> dict[str, dict[str, str]]:
return {planet: {"sign": SIGNS[(offset + index * step) % 12]} for index, planet in enumerate(SEVEN_PLANETS)}
@settings(deadline=None)
@given(planet_signs(), st.integers(min_value=0, max_value=11))
def test_ashtakavarga_totals_are_position_independent(planets: dict[str, dict[str, str]], asc_sign_idx: int) -> None:
result = calc_ashtakavarga(planets, asc_sign_idx)
def test_ashtakavarga_totals_are_position_independent() -> None:
samples = [
(_planet_signs(offset=0, step=1), 0),
(_planet_signs(offset=2, step=3), 4),
(_planet_signs(offset=7, step=5), 11),
]
for planets, asc_sign_idx in samples:
result = calc_ashtakavarga(planets, asc_sign_idx)
assert result["sav"]["total"] == EXPECTED_SAV_TOTAL
assert result["sav"]["valid"] is True
assert result["sav"]["full_total_with_lagna"] == EXPECTED_SAV_TOTAL + BAV_TOTALS["Lagna"]
assert result["all_bav_valid"] is True
assert result["sav"]["total"] == EXPECTED_SAV_TOTAL
assert result["sav"]["valid"] is True
assert result["sav"]["full_total_with_lagna"] == EXPECTED_SAV_TOTAL + BAV_TOTALS["Lagna"]
assert result["all_bav_valid"] is True
for planet in ALL_SOURCES:
assert result["bav"][planet]["total"] == BAV_TOTALS[planet]
assert result["bav"][planet]["valid"] is True
for planet in ALL_SOURCES:
assert result["bav"][planet]["total"] == BAV_TOTALS[planet]
assert result["bav"][planet]["valid"] is True
def test_ashtakavarga_output_contract() -> None:
@@ -37,3 +55,40 @@ def test_ashtakavarga_output_contract() -> None:
assert set(result["sav"]["scores"].keys()) == set(SIGNS)
assert len(result["house_scores"]) == 12
assert len(result["house_scores_full"]) == 12
def test_prastara_matrix_reconstructs_each_planets_bav() -> None:
planets = {planet: {"sign": SIGNS[(index * 2) % 12]} for index, planet in enumerate(SEVEN_PLANETS)}
result = calc_ashtakavarga(planets, asc_sign_idx=3)
pav = calc_prastara_av(planets, asc_sign_idx=3)
assert pav["all_valid"] is True
assert pav["matrix_shape"] == {"planets": 7, "signs": 12, "sources": 8}
for planet in SEVEN_PLANETS:
reconstructed = [0] * 12
for source in ALL_SOURCES:
for index, value in enumerate(pav["pav"][planet][source]):
reconstructed[index] += value
assert reconstructed == result["bav"][planet]["bindus"]
def test_yoga_pinda_is_first_class_contract() -> None:
planets = {planet: {"sign": SIGNS[(index * 3) % 12]} for index, planet in enumerate(SEVEN_PLANETS)}
result = calc_ashtakavarga(planets, asc_sign_idx=5)
yoga_pinda = calc_yoga_pinda(result["bav"], planets, asc_sign_idx=5)
assert yoga_pinda["method"] == "Yoga Pinda"
assert yoga_pinda["all_valid"] is True
assert yoga_pinda["summary"]["strongest_planet"] in SEVEN_PLANETS
assert yoga_pinda["summary"]["total_yoga_pinda"] == sum(
item["yoga_pinda"] for item in yoga_pinda["planets"].values()
)
for planet in SEVEN_PLANETS:
row = yoga_pinda["planets"][planet]
legacy = result["shodhya_pinda"][planet]
assert row["rashi_pinda"] == legacy["rashi_pinda"]
assert row["graha_pinda"] == legacy["graha_pinda"]
assert row["yoga_pinda"] == legacy["total_pinda"]
assert row["bindu_at_own_sign"] == legacy["bindu_at_own_sign"]
+34
View File
@@ -8,8 +8,11 @@ import subprocess
import sys
from pathlib import Path
from tests.run_golden_cases import run_cases
ROOT = Path(__file__).resolve().parents[1]
ENGINE = ROOT / "scripts" / "jyotish_engine.py"
GOLDEN_CASES = ROOT / "tests" / "golden" / "golden_cases.json"
BASE_BIRTH_ARGS = [
"--year", "1990",
"--month", "1",
@@ -58,3 +61,34 @@ def test_ashtakavarga_cli_keeps_sav_invariant() -> None:
def test_audit_capabilities_cli_validates_registry() -> None:
result = run_engine("audit-capabilities", "--mode", "validate")
assert result.get("valid") is True
def test_full_reading_golden_cases_cover_user_ready_output() -> None:
result = run_cases(sys.executable, str(GOLDEN_CASES))
assert result["valid"] is True, result
assert result["total"] >= 3
def test_yoga_logic_validation_tolerates_algorithmic_yogas_without_rule_id() -> None:
sys.path.insert(0, str(ROOT / "scripts"))
import validate_logic_v2
results = [
{"rule_id": "bvr_vosi_precise", "name": "Vosi Yoga"},
{"id": "bvr_sunaphaa_precise", "name": "Sunapha Yoga"},
{"name": "Algorithmic Solar Yoga"},
]
assert validate_logic_v2.extract_skill_rule_ids(results) == {
"bvr_vosi_precise",
"bvr_sunaphaa_precise",
}
def test_yoga_logic_validation_import_does_not_shadow_project_modules() -> None:
before = list(sys.path)
sys.path.insert(0, str(ROOT / "scripts"))
import validate_logic_v2 # noqa: F401
assert not sys.path[0].endswith(".workbuddy/skills/jyotish-vedic-astrology/scripts")
sys.path[:] = before
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Tests for deep_varga_avastha.py — Sayanadi/Shayanadi + D24/D30/D60 templates."""
from __future__ import annotations
import os
import sys
SCRIPTS = os.path.join(os.path.dirname(__file__), '..', 'scripts')
if SCRIPTS not in sys.path:
sys.path.insert(0, SCRIPTS)
from deep_varga_avastha import build_deep_varga_avastha_report
def _sample_lons() -> dict:
return {
'Sun': 80.0,
'Moon': 123.0,
'Mars': 210.0,
'Mercury': 75.0,
'Jupiter': 15.0,
'Venus': 102.0,
'Saturn': 330.0,
}
def test_deep_varga_avastha_report_exposes_avastha_and_deep_templates() -> None:
result = build_deep_varga_avastha_report(_sample_lons(), asc_lon=92.0)
assert result['method'] == 'Sayanadi/Shayanadi Avastha + D24/D30/D60 Deep Templates'
assert result['summary']['headline']
assert result['avastha_summary']['dominant_states']
assert result['avastha_summary']['planet_states']['Moon']['Shayanadi']['state']
assert {'D24', 'D30', 'D60'} <= set(result['deep_varga_templates'])
d24 = result['deep_varga_templates']['D24']
assert d24['theme'] == 'education_learning'
assert d24['template_cards']
assert d24['next_action']
d30 = result['deep_varga_templates']['D30']
assert d30['theme'] == 'risk_crisis'
assert 'risk_flags' in d30
d60 = result['deep_varga_templates']['D60']
assert d60['theme'] == 'karma_root'
assert d60['template_cards']
File diff suppressed because it is too large Load Diff
+22 -1
View File
@@ -11,7 +11,7 @@ if SCRIPTS not in sys.path:
from jaimini import (
calc_chara_karaka_7, calc_chara_karaka_8,
calc_arudha_pada_for_house, calc_arudha_padas, calc_upapada,
calc_graha_padas, calc_karakamsha, calc_special_lagnas,
calc_graha_padas, calc_karakamsha, calc_special_lagnas, calc_special_lagnas_precise,
calc_chara_dasha, calc_chara_dasha_with_antardasha,
KARAKA_7, KARAKA_8, SIGNS, SIGN_LORDS,
)
@@ -169,6 +169,27 @@ class TestSpecialLagnas:
result = calc_special_lagnas(0, 10, 30)
assert 'note' in result
def test_precise_special_lagnas_use_sunrise_reference(self):
result = calc_special_lagnas_precise(
0, 1990, 6, 15, 10, 30, lat=28.6, lon=77.2, tz_offset=5.5
)
assert result['capability_status'] == 'covered'
assert result['precision'] == 'sunrise_correct'
assert 'sunrise_local_time' in result
assert result['ghatis_elapsed_from_sunrise'] > 0
for key in ['HL', 'GL', 'VL']:
assert result[key]['sign'] in SIGNS
assert result[key]['lord'] in SIGN_LORDS.values()
def test_precise_special_lagnas_are_time_sensitive(self):
morning = calc_special_lagnas_precise(
0, 1990, 6, 15, 10, 0, lat=28.6, lon=77.2, tz_offset=5.5
)
later = calc_special_lagnas_precise(
0, 1990, 6, 15, 11, 0, lat=28.6, lon=77.2, tz_offset=5.5
)
assert later['ghatis_elapsed_from_sunrise'] > morning['ghatis_elapsed_from_sunrise']
# ── Graha Padas Tests ──────────────────────────────────────────────
+136 -1
View File
@@ -11,7 +11,9 @@ if SCRIPTS not in sys.path:
from muhurta import (
calc_tithi, calc_nakshatra_from_lon, calc_yoga, calc_karana,
calc_vara, calc_hora, calc_abhijit_muhurta, calc_panchanga,
check_activity_muhurta, muhurta_full_report,
check_activity_muhurta, muhurta_full_report, calc_daytime_inauspicious_periods,
panchanga_range_report, muhurta_range_search, calc_sunrise_sunset_local, calc_panchanga_end_times,
classify_vrata_tags, classify_panchanga_condition_tags, calc_choghadiya_windows, calc_hora_windows,
TITHI_NAMES, TITHI_QUALITY, NAKSHATRAS, NAKSHATRA_TYPE,
YOGA_NAMES, YOGA_QUALITY, KARANA_NAMES, KARANA_QUALITY,
VARA_LORDS, ACTIVITY_RULES,
@@ -197,6 +199,26 @@ class TestAbhijitMuhurta:
assert 'abhijit_end_jd' in result
class TestDaytimeInauspiciousPeriods:
def test_monday_rahu_kala_uses_second_segment(self):
result = calc_daytime_inauspicious_periods(1, '06:00', '18:00')
assert result['rahu_kala']['segment'] == 2
assert result['rahu_kala']['start'] == '07:30'
assert result['rahu_kala']['end'] == '09:00'
def test_gulika_and_yamaganda_present(self):
result = calc_daytime_inauspicious_periods(4, '06:00', '18:00')
assert result['policy'] == 'daytime_8_segments_local_clock'
assert 'yamaganda' in result
assert 'gulika' in result
def test_location_aware_sunrise_sunset(self):
result = calc_sunrise_sunset_local(2026, 6, 22, 28.6, 77.2, 5.5)
assert result['policy'] in {'swisseph_rise_trans', 'location_aware_solar_event_approximation'}
assert ':' in result['sunrise']
assert ':' in result['sunset']
# ── Panchanga Complete Tests ────────────────────────────────────────
class TestPanchangaComplete:
@@ -220,6 +242,95 @@ class TestPanchangaComplete:
result = calc_panchanga(45.0, 120.0, 4, 6.0)
assert isinstance(result['warnings'], list)
def test_panchanga_range_report_includes_inauspicious_periods(self):
result = panchanga_range_report('2026-06-22', '2026-06-24')
assert result['day_count'] == 3
assert result['days'][0]['query_date'] == '2026-06-22'
assert result['days'][0]['inauspicious_periods']['rahu_kala']['label'] == 'Rahu Kala'
assert result['calculation_policy']['inauspicious_periods']
def test_panchanga_range_report_can_use_location_aware_solar_times(self):
result = panchanga_range_report('2026-06-22', '2026-06-22', lat=28.6, lon=77.2, tz=5.5)
assert result['location']['lat'] == 28.6
assert result['calculation_policy']['sunrise_sunset'] in {
'SwissEph rise_trans',
'location-aware solar approximation',
'mixed SwissEph rise_trans with approximation fallback',
}
assert 'solar_times' in result['days'][0]
assert 'end_times' in result['days'][0]
assert 'vrata_tags' in result['days'][0]
assert 'condition_tags' in result['days'][0]
assert 'festival_details' in result['days'][0]
assert 'search_summary' in result
assert 'condition_counts' in result['search_summary']
assert 'choghadiya' in result['days'][0]
assert 'hora_windows' in result['days'][0]
def test_panchanga_range_report_includes_month_grid_for_single_month(self):
result = panchanga_range_report('2026-06-01', '2026-06-30', activity='business', lat=28.6, lon=77.2, tz=5.5)
assert result['month_grid']
cells = [cell for week in result['month_grid'] for cell in week if cell]
assert len(cells) == 30
assert cells[0]['date'] == '2026-06-01'
assert 'best_activities' in cells[0]
assert 'vrata_tags' in cells[0]
assert 'condition_tags' in cells[0]
def test_festival_details_explain_candidate_basis(self):
result = panchanga_range_report('2026-06-01', '2026-06-30', lat=28.6, lon=77.2, tz=5.5)
details = [detail for day in result['days'] for detail in day.get('festival_details', [])]
assert details
assert all('basis' in detail for detail in details)
assert any('confirmation_note' in detail for detail in details)
assert result['search_summary']['query_examples']
def test_panchanga_end_times_are_available_with_swisseph(self):
result = calc_panchanga_end_times(2026, 6, 22, 5.5, 11.4)
assert result is not None
assert result['policy'] == 'swisseph_boundary_bisection'
assert 'tithi' in result
assert 'ends_at' in result['tithi']
def test_basic_vrata_tags(self):
assert classify_vrata_tags({'tithi_num': 11, 'paksha': 'Shukla', 'name': 'Ekadashi'})[0]['key'] == 'ekadashi'
assert classify_vrata_tags({'tithi_num': 28, 'paksha': 'Krishna', 'name': 'Trayodashi'})[0]['key'] == 'pradosham'
assert classify_vrata_tags({'tithi_num': 30, 'paksha': 'Krishna', 'name': 'Amavasya'})[0]['key'] == 'amavasya'
def test_richer_vrata_tags_use_nakshatra_and_vara(self):
tags = classify_vrata_tags(
{'tithi_num': 13, 'paksha': 'Shukla', 'name': 'Trayodashi'},
{'nakshatra': 'Pushya'},
{'vara': 'Thursday'},
)
keys = {tag['key'] for tag in tags}
assert {'pradosham', 'pushya_nakshatra', 'guru_pushya'} <= keys
def test_vrata_tags_mark_masa_dependent_festivals_as_candidates(self):
tags = classify_vrata_tags({'tithi_num': 3, 'paksha': 'Shukla', 'name': 'Tritiya'})
assert any(tag['key'] == 'akshaya_tritiya_candidate' and tag['type'] == 'festival_candidate' for tag in tags)
def test_panchanga_condition_tags_are_searchable(self):
report = {
'panchanga': {'warnings': ['avoid']},
'summary': {'best_activities': ['business'], 'warnings': ['avoid']},
'vrata_tags': [{'key': 'ekadashi', 'type': 'fasting'}],
'activity_checks': {'business': {'verdict': '大吉(Excellent)'}},
'choghadiya': {'day': [{'quality': 'auspicious'}], 'night': []},
}
keys = {tag['key'] for tag in classify_panchanga_condition_tags(report, activity='business')}
assert {'has_vrata', 'spiritual_practice', 'auspicious_activity', 'avoid_new_start', 'good_choghadiya', 'selected_activity_good'} <= keys
def test_choghadiya_and_hora_windows(self):
choghadiya = calc_choghadiya_windows(1, '06:00', '18:00')
assert choghadiya['day'][0]['name'] == 'Amrit'
assert len(choghadiya['day']) == 8
assert len(choghadiya['night']) == 8
hora = calc_hora_windows(1, '06:00', '18:00')
assert hora['day'][0]['lord'] == 'Moon'
assert len(hora['day']) == 12
assert len(hora['night']) == 12
# ── Activity Muhurta Tests ─────────────────────────────────────────
@@ -275,3 +386,27 @@ class TestMuhurtaFullReport:
def test_query_date_preserved(self):
result = muhurta_full_report(45.0, 120.0, 4, 6.0, '2026-06-04')
assert result['query_date'] == '2026-06-04'
class TestMuhurtaRangeSearch:
def test_returns_ranked_activity_windows_for_date_range(self):
result = muhurta_range_search(
'2026-06-22',
'2026-06-28',
activity='business',
limit=3,
sunrise='06:00',
sunset='18:00',
)
assert result['mode'] == 'muhurta_date_range_solver'
assert result['activity'] == 'business'
assert result['date_range'] == {'start': '2026-06-22', 'end': '2026-06-28'}
assert result['candidate_count'] <= 3
assert result['best_windows']
assert result['rejected_dates']
assert result['constraints']['avoid_inauspicious_periods'] is True
first = result['best_windows'][0]
assert {'date', 'score', 'quality', 'activity_verdict', 'recommended_windows', 'evidence'} <= set(first)
assert first['recommended_windows']
assert first['evidence']['panchanga']
+33 -1
View File
@@ -12,7 +12,7 @@ if SCRIPTS not in sys.path:
from tajika import (
calc_muntha, calc_year_lord, calc_mudda_dasha,
calc_tri_pataka, calc_tajika_yogas, detect_tajika_yogas,
detect_vedha, calc_all_sahams, _is_faster,
detect_vedha, calc_all_sahams, calc_tajika_strength_layers, _is_faster,
SIGNS, SIGN_LORDS,
)
@@ -105,6 +105,38 @@ class TestTriPataka:
assert 'year_lord' in result
# ── Harsha / Panchavargiya Bala Tests ─────────────────────────────
class TestTajikaStrengthLayers:
def test_harsha_and_panchavargiya_strength_layers(self):
planet_lons = {
'Sun': 10.0,
'Moon': 45.0,
'Mars': 92.0,
'Mercury': 152.0,
'Jupiter': 244.0,
'Venus': 330.0,
'Saturn': 300.0,
}
result = calc_tajika_strength_layers(planet_lons, asc_lon=15.0, year_lord='Jupiter')
assert result['method'] == 'Tajika Harsha/Panchavargiya Bala'
assert result['available_planets'] == 7
assert 'harsha_bala' in result
assert 'panchavargiya_bala' in result
assert 'combined_strength' in result
assert result['summary']['strongest_planets']
assert result['summary']['weakest_planets']
assert result['summary']['next_action']
for planet in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn'):
assert planet in result['harsha_bala']
assert planet in result['panchavargiya_bala']
assert {'score', 'max_score', 'grade', 'components'} <= set(result['combined_strength'][planet])
assert result['combined_strength'][planet]['max_score'] > 0
# ── Tajika Yogas Tests ─────────────────────────────────────────────
class TestTajikaYogas: