Files
Jyotisha/tests/test_consultation_plan_contract.py
T

232 lines
8.3 KiB
Python

from __future__ import annotations
import pytest
from scripts.consultation_plan_contract import (
ConsultationPlanContractError,
apply_plan_precision_boundary,
validate_consultation_plan_contract,
)
def timing_body(**overrides):
body = {
"plan_version": "consultation-plan-v2",
"strict_workflow_route": "timing",
"required_layers": ["Vimshottari", "Narayana", "Transit", "Varga", "negative holdout gate"],
"claim_boundary": "candidate_day_month_window_only_until_holdout_passes",
"plan_depth": "standard",
"requested_domains": ["timing"],
"timing_horizon": "next_12_months",
"precision_boundary": "precise_timing_blocked",
"required_evidence_categories": ["natal_foundation", "timing", "validation"],
}
body.update(overrides)
return body
def test_versioned_plan_is_validated_against_server_route_allowlist():
contract = validate_consultation_plan_contract(
timing_body(),
themes=["timing"],
route_packet={"question_type": "timing"},
)
assert contract == {
"plan_version": "consultation-plan-v2",
"strict_workflow_route": "timing",
"plan_depth": "standard",
"requested_domains": ["timing"],
"timing_horizon": "next_12_months",
"required_evidence_categories": ["natal_foundation", "timing", "validation"],
"required_layers": ["Vimshottari", "Narayana", "Transit", "Varga", "negative holdout gate"],
"claim_boundary": "candidate_day_month_window_only_until_holdout_passes",
"precision_boundary": "precise_timing_blocked",
"enforcement": "server_allowlist_validated",
}
@pytest.mark.parametrize(
("override", "message"),
[
({"plan_version": "consultation-plan-v1"}, "version"),
({"strict_workflow_route": "free_script"}, "route"),
({"required_layers": ["D1"]}, "required layers"),
({"claim_boundary": "exact_date_allowed"}, "claim boundary"),
({"requested_domains": ["career"]}, "requested domains"),
({"required_evidence_categories": ["natal_foundation"]}, "evidence categories"),
({"plan_depth": "unbounded"}, "depth"),
({"timing_horizon": None}, "requires a horizon"),
({"precision_boundary": "precise_timing_allowed"}, "precision boundary"),
],
)
def test_versioned_plan_rejects_free_form_or_tampered_workflow_control(override, message):
with pytest.raises(ConsultationPlanContractError, match=message):
validate_consultation_plan_contract(
timing_body(**override),
themes=["timing"],
route_packet={"question_type": "timing"},
)
def test_versioned_plan_rejects_theme_and_resolved_route_mismatch():
with pytest.raises(ConsultationPlanContractError, match="theme mismatch"):
validate_consultation_plan_contract(
timing_body(),
themes=["marriage"],
route_packet={"question_type": "timing"},
)
with pytest.raises(ConsultationPlanContractError, match="route mismatch"):
validate_consultation_plan_contract(
timing_body(),
themes=["timing"],
route_packet={"question_type": "career"},
)
@pytest.mark.parametrize(
("route", "required_layers", "claim_boundary", "evidence_categories", "timing_horizon"),
[
(
"health",
["D1", "D6", "D8", "6th/8th houses", "Dasha", "non-medical boundary"],
"wellbeing_pressure_patterns_not_medical_diagnosis",
["natal_foundation", "domain"],
None,
),
(
"education",
["D1", "D24", "5th/9th houses", "Mercury/Jupiter", "Dasha"],
"learning_pattern_and_broad_timing_only",
["natal_foundation", "domain"],
None,
),
(
"migration",
["D1", "D4", "D12", "4th/12th houses", "Dasha", "Narayana"],
"migration_and_home_direction_broad_window_only",
["natal_foundation", "domain"],
None,
),
(
"family",
["D1", "D7", "D12", "4th/5th/9th houses", "Dasha"],
"family_pattern_not_deterministic_event_claim",
["natal_foundation", "domain"],
None,
),
(
"annual",
["D1", "Annual chart boundary", "Dasha", "Transit", "Tajika candidate"],
"annual_report_broad_periods_only",
["natal_foundation", "timing", "validation"],
"next_12_months",
),
],
)
def test_expanded_staging_domains_use_canonical_plan_contracts(
route, required_layers, claim_boundary, evidence_categories, timing_horizon,
):
body = {
"plan_version": "consultation-plan-v2",
"strict_workflow_route": route,
"required_layers": required_layers,
"claim_boundary": claim_boundary,
"plan_depth": "standard",
"requested_domains": [route],
"timing_horizon": timing_horizon,
"precision_boundary": "server_evidence_required",
"required_evidence_categories": evidence_categories,
}
contract = validate_consultation_plan_contract(
body,
themes=[route],
route_packet={"question_type": route},
)
assert contract is not None
assert contract["strict_workflow_route"] == route
assert contract["requested_domains"] == [route]
assert contract["enforcement"] == "server_allowlist_validated"
def test_legacy_research_callers_without_plan_metadata_remain_compatible():
assert validate_consultation_plan_contract(
{"question": "事业如何"},
themes=["career"],
route_packet={"question_type": "career"},
) is None
@pytest.mark.parametrize(
"partial_body",
[
{"strict_workflow_route": "timing"},
{"required_layers": ["Vimshottari"]},
{"claim_boundary": "candidate_day_month_window_only_until_holdout_passes"},
{"plan_version": "consultation-plan-v2"},
],
)
def test_partial_plan_metadata_cannot_downgrade_to_legacy(partial_body):
with pytest.raises(ConsultationPlanContractError, match="incomplete consultation plan metadata"):
validate_consultation_plan_contract(
partial_body,
themes=["career"],
route_packet={"question_type": "timing"},
)
@pytest.mark.parametrize("missing_key", list(timing_body()))
def test_versioned_plan_requires_the_complete_metadata_set(missing_key):
body = timing_body()
body.pop(missing_key)
with pytest.raises(ConsultationPlanContractError, match="incomplete consultation plan metadata"):
validate_consultation_plan_contract(
body,
themes=["timing"],
route_packet={"question_type": "timing"},
)
def test_blocked_plan_can_only_restrict_evidence_owned_precision_policy():
context = {
"answer_policy": {
"can_answer_precise_timing": True,
"should_lead_with_limitations": False,
}
}
blocked = apply_plan_precision_boundary(context, {"precision_boundary": "precise_timing_blocked"})
evidence_owned = apply_plan_precision_boundary(context, {"precision_boundary": "server_evidence_required"})
assert blocked["answer_policy"]["can_answer_precise_timing"] is False
assert blocked["answer_policy"]["should_lead_with_limitations"] is True
assert evidence_owned == context
def test_api_dry_run_exposes_validated_plan_contract_and_rejects_tampering():
from scripts.jyotish_api_server import BadRequest, JyotishAPIHandler
handler = JyotishAPIHandler.__new__(JyotishAPIHandler)
body = {
**timing_body(),
"dry_run": True,
"entry_mode": "direct_chart",
"question": "应期与阶段问题:未来哪些阶段值得把握?",
"theme": ["timing"],
"year": 1990,
"month": 1,
"day": 1,
"hour": 12,
"minute": 0,
"lat": 25,
"lon": 121,
"tz": 8,
}
result = handler._compute_consultation_workflow(body)
assert result["routing"]["question_type"] == "timing"
assert result["consultation_plan_contract"]["enforcement"] == "server_allowlist_validated"
with pytest.raises(BadRequest, match="claim boundary mismatch"):
handler._compute_consultation_workflow({**body, "claim_boundary": "exact_date_allowed"})