166 lines
6.1 KiB
Python
166 lines
6.1 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=["career"],
|
|
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=["career"],
|
|
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=["career"],
|
|
route_packet={"question_type": "career"},
|
|
)
|
|
|
|
|
|
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=["career"],
|
|
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": ["career"],
|
|
"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"})
|