from __future__ import annotations import pytest from scripts.consultation_domain_registry import CANONICAL_DOMAINS from scripts.consultation_plan_contract import ( ConsultationPlanContractError, apply_plan_precision_boundary, declared_workflow_route, plan_route_contract, validate_consultation_plan_contract, ) def plan_body(route: str, **overrides): """Build the metadata the product runtime sends for one declared domain.""" contract = plan_route_contract(route) assert contract is not None body = { "plan_version": "consultation-plan-v2", "strict_workflow_route": route, "required_layers": list(contract.required_layers), "claim_boundary": contract.claim_boundary, "plan_depth": "standard", "requested_domains": list(contract.requested_domains), "timing_horizon": "next_12_months" if route in {"timing", "annual"} else None, "precision_boundary": "server_evidence_required", "required_evidence_categories": list(contract.required_evidence_categories), } body.update(overrides) return body 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", "domain", "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", "domain", "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", "timing", "validation"], None, ), ( "education", ["D1", "D24", "5th/9th houses", "Mercury/Jupiter", "Dasha"], "learning_pattern_and_broad_timing_only", ["natal_foundation", "domain", "timing", "validation"], None, ), ( "migration", ["D1", "D4", "D12", "4th/12th houses", "Dasha", "Narayana"], "migration_and_home_direction_broad_window_only", ["natal_foundation", "domain", "timing", "validation"], None, ), ( "family", ["D1", "D7", "D12", "4th/5th/9th houses", "Dasha"], "family_pattern_not_deterministic_event_claim", ["natal_foundation", "domain", "timing", "validation"], None, ), ( "annual", ["D1", "Annual chart boundary", "Dasha", "Transit", "Tajika candidate"], "annual_report_broad_periods_only", ["natal_foundation", "domain", "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_declared_workflow_route_is_only_read_from_a_complete_allowlisted_plan(): assert declared_workflow_route({"question": "事业如何"}) is None assert declared_workflow_route(timing_body()) == "timing" assert declared_workflow_route(plan_body("wealth")) == "wealth" with pytest.raises(ConsultationPlanContractError, match="incomplete consultation plan metadata"): declared_workflow_route({"strict_workflow_route": "wealth"}) with pytest.raises(ConsultationPlanContractError, match="version"): declared_workflow_route(timing_body(plan_version="consultation-plan-v1")) with pytest.raises(ConsultationPlanContractError, match="unsupported consultation workflow route"): declared_workflow_route(timing_body(strict_workflow_route="free_script")) @pytest.mark.parametrize("domain", CANONICAL_DOMAINS) def test_plan_route_allowlist_covers_every_canonical_domain(domain: str): contract = plan_route_contract(domain) assert contract is not None assert contract.themes == (domain,) assert domain in contract.resolved_routes assert plan_route_contract("free_script") is None def _consultation_body(**overrides): body = { "dry_run": True, "entry_mode": "direct_chart", "year": 1990, "month": 1, "day": 1, "hour": 12, "minute": 0, "lat": 25, "lon": 121, "tz": 8, } body.update(overrides) return body def test_declared_route_executes_even_when_question_text_names_another_domain(): """One question, several domains: text routing can only ever agree with one of them.""" from scripts.jyotish_api_server import JyotishAPIHandler handler = JyotishAPIHandler.__new__(JyotishAPIHandler) question = "我的事业和财运接下来会怎么走,两者之间该怎么取舍" wealth = handler._compute_consultation_workflow(_consultation_body( **plan_body("wealth"), question=question, theme=["wealth"], )) career = handler._compute_consultation_workflow(_consultation_body( **plan_body("career"), question=question, theme=["career"], )) assert wealth["routing"]["question_type"] == "wealth" assert wealth["routing"]["primary_theme"] == "wealth" assert "D2" in wealth["routing"]["focus_techniques"] assert wealth["consultation_plan_contract"]["strict_workflow_route"] == "wealth" assert career["routing"]["primary_theme"] == "career" def test_declared_route_cannot_smuggle_a_route_off_the_server_allowlist(): from scripts.jyotish_api_server import BadRequest, JyotishAPIHandler handler = JyotishAPIHandler.__new__(JyotishAPIHandler) with pytest.raises(BadRequest, match="unsupported consultation workflow route"): handler._compute_consultation_workflow(_consultation_body( **plan_body("wealth", strict_workflow_route="free_script"), question="财运如何", theme=["wealth"], )) with pytest.raises(BadRequest, match="theme mismatch"): handler._compute_consultation_workflow(_consultation_body( **plan_body("wealth"), question="财运如何", theme=["career"], )) with pytest.raises(BadRequest, match="required layers mismatch"): handler._compute_consultation_workflow(_consultation_body( **plan_body("wealth", required_layers=["D1"]), question="财运如何", theme=["wealth"], )) 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"})