Productize jyotish app release surface
This commit is contained in:
@@ -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
@@ -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())
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user