674 lines
24 KiB
Python
674 lines
24 KiB
Python
from __future__ import annotations
|
|
|
|
import copy
|
|
import threading
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from server.agent_core.async_jobs import JobCancelled, JobQueue
|
|
from server.aps_domain.cp_rhs import run_cp_rhs_resolve
|
|
from server.engines.base import EngineParams
|
|
from server.engines.cp_engine import CpSatEngine, optimize_line_assignment
|
|
from server.engines.solver_process import (
|
|
PROTOCOL_VERSION,
|
|
SolverProcessError,
|
|
_request_digest,
|
|
_validate_result_semantics,
|
|
run_cp_rhs_diagnostic,
|
|
)
|
|
from server.engines.solver_worker import _validate_request
|
|
from server.state.seed import empty_world
|
|
from tests.auth_provider import install_test_auth
|
|
from tests.golden.test_cp_cumulative import _two_order_ids, _wire_cumulative_world
|
|
from tests.golden.test_cp_marginal import _align_shift_calendar, _tight_two_order_world
|
|
|
|
|
|
def _entries_and_params(world: dict) -> tuple[list[dict], EngineParams]:
|
|
params = EngineParams(
|
|
orderIds=[], engineType="CP", strategyTemplate="COMPREHENSIVE",
|
|
planningHorizonDays=14, startDate="2026-08-03", timeLimitSeconds=4,
|
|
)
|
|
entries, _, _ = CpSatEngine().collect_and_order(world, params)
|
|
return entries, params
|
|
|
|
|
|
def _cumulative_world() -> dict:
|
|
world = _wire_cumulative_world(team=True, tooling=True, team_cap=1, tooling_cap=1)
|
|
_align_shift_calendar(world, "2026-08-03")
|
|
keep = set(_two_order_ids(world))
|
|
world["salesOrders"] = [order for order in world["salesOrders"] if order["id"] in keep]
|
|
for order in world["salesOrders"]:
|
|
order["deliveryDate"] = "2026-08-03"
|
|
return world
|
|
|
|
|
|
def test_real_due_allowance_is_exact_positive_rhs_finite_difference():
|
|
world = _tight_two_order_world()
|
|
routing_id = next(
|
|
row["id"] for row in world["routings"]
|
|
if row["productId"] == 1 and row["isDefault"]
|
|
)
|
|
world["routingSteps"] = [
|
|
row for row in world["routingSteps"]
|
|
if row["routingId"] == routing_id and row["sequenceNo"] == 1
|
|
]
|
|
before = copy.deepcopy(world)
|
|
report = run_cp_rhs_resolve(
|
|
world, start_date="2026-08-03",
|
|
parameter_ids=["C8_due_date_allowance"],
|
|
increments={"C8_due_date_allowance": 60},
|
|
planning_horizon_days=3,
|
|
time_limit_seconds=8,
|
|
)
|
|
assert world == before
|
|
assert report["status"] == "completed"
|
|
assert report["evaluations"] == 2
|
|
assert report["baseline"]["operation"] == "diagnose_rhs_baseline"
|
|
row = report["rows"][0]
|
|
assert row["status"] == "available"
|
|
assert row["baselineRhs"] == 0
|
|
assert row["perturbedRhs"] == 60
|
|
assert row["objectiveImprovement"] > 0
|
|
assert row["objectiveImprovementPerIncrementUnit"] == pytest.approx(
|
|
row["objectiveImprovement"] / 60
|
|
)
|
|
assert row["perturbed"]["operation"] == "diagnose_rhs_perturbation"
|
|
assert row["costStatus"] == "not_configured"
|
|
assert row["costRate"] is None
|
|
assert row["costRateUnit"] is None
|
|
assert row["costCurrency"] is None
|
|
assert row["estimatedCost"] is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("parameter_id", "resource_kind"),
|
|
[
|
|
("C12_team_capacity", "team"),
|
|
("C12_tooling_capacity", "tooling"),
|
|
],
|
|
)
|
|
def test_real_specific_cumulative_capacity_increment_changes_only_target_rhs(
|
|
parameter_id: str, resource_kind: str,
|
|
):
|
|
report = run_cp_rhs_resolve(
|
|
_cumulative_world(), start_date="2026-08-03",
|
|
parameter_ids=[parameter_id], increments={parameter_id: 1},
|
|
planning_horizon_days=3,
|
|
time_limit_seconds=8,
|
|
)
|
|
assert report["status"] == "completed"
|
|
row = report["rows"][0]
|
|
assert row["resourceKind"] == resource_kind
|
|
assert row["resourceId"] == 1
|
|
assert row["baselineRhs"] == 1
|
|
assert row["perturbedRhs"] == 2
|
|
assert row["status"] == "available"
|
|
assert row["objectiveImprovement"] >= 0
|
|
|
|
|
|
def test_explicit_zero_site_rate_is_configured_and_never_netted_with_objective():
|
|
report = run_cp_rhs_resolve(
|
|
_tight_two_order_world(), start_date="2026-08-03",
|
|
parameter_ids=["C8_due_date_allowance"],
|
|
increments={"C8_due_date_allowance": 30},
|
|
cost_rates={"C8_due_date_allowance": 0.0}, currency="cny",
|
|
)
|
|
row = report["rows"][0]
|
|
assert row["costStatus"] == "configured"
|
|
assert row["costRate"] == 0.0
|
|
assert row["costCurrency"] == "CNY"
|
|
assert row["estimatedCost"] == 0.0
|
|
assert "netValue" not in row
|
|
assert report["costCalibration"]["objectiveAndCostAreNotNettable"] is True
|
|
|
|
|
|
def test_inactive_cumulative_parameter_does_not_spawn_variant_solve():
|
|
world = _tight_two_order_world()
|
|
for workstation in world["workstations"]:
|
|
workstation.pop("teamId", None)
|
|
world["teams"] = []
|
|
report = run_cp_rhs_resolve(
|
|
world, start_date="2026-08-03", parameter_ids=["C12_team_capacity"],
|
|
)
|
|
assert report["evaluations"] == 1
|
|
assert report["rows"][0]["status"] == "inactive"
|
|
assert report["rows"][0]["resourceId"] is None
|
|
|
|
|
|
def test_inactive_cumulative_parameter_does_not_estimate_configured_cost():
|
|
world = _tight_two_order_world()
|
|
for workstation in world["workstations"]:
|
|
workstation.pop("teamId", None)
|
|
world["teams"] = []
|
|
report = run_cp_rhs_resolve(
|
|
world, start_date="2026-08-03", parameter_ids=["C12_team_capacity"],
|
|
cost_rates={"C12_team_capacity": 25.0},
|
|
)
|
|
row = report["rows"][0]
|
|
assert row["status"] == "inactive"
|
|
assert row["costStatus"] == "configured_inactive"
|
|
assert row["costRate"] == 25.0
|
|
assert row["estimatedCost"] is None
|
|
|
|
|
|
def test_no_demand_is_explicit_and_does_not_spawn_solver():
|
|
report = run_cp_rhs_resolve(
|
|
empty_world(), start_date="2026-08-03",
|
|
parameter_ids=["C8_due_date_allowance"],
|
|
)
|
|
assert report["status"] == "no_demand"
|
|
assert report["evaluations"] == 0
|
|
assert report["rows"] == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"kwargs",
|
|
[
|
|
{"parameter_ids": []},
|
|
{"parameter_ids": ["UNKNOWN"]},
|
|
{
|
|
"parameter_ids": ["C8_due_date_allowance"],
|
|
"increments": {"C8_due_date_allowance": True},
|
|
},
|
|
{
|
|
"parameter_ids": ["C12_team_capacity"],
|
|
"increments": {"C12_team_capacity": 101},
|
|
},
|
|
{
|
|
"parameter_ids": ["C8_due_date_allowance"],
|
|
"cost_rates": {"C12_team_capacity": 1.0},
|
|
},
|
|
{
|
|
"parameter_ids": ["C8_due_date_allowance"],
|
|
"cost_rates": {"C8_due_date_allowance": float("nan")},
|
|
},
|
|
],
|
|
)
|
|
def test_domain_rejects_invalid_rhs_or_cost_configuration(kwargs):
|
|
with pytest.raises(ValueError):
|
|
run_cp_rhs_resolve(_tight_two_order_world(), start_date="2026-08-03", **kwargs)
|
|
|
|
|
|
def _worker_request(operation: str, rhs_perturbation=None) -> dict:
|
|
body = {
|
|
"protocolVersion": PROTOCOL_VERSION,
|
|
"operation": operation,
|
|
"world": {},
|
|
"entries": [],
|
|
"params": EngineParams(engineType="CP").model_dump(mode="json"),
|
|
"warmStart": None,
|
|
"pipelineLabel": "TEST-RHS",
|
|
"rhsPerturbation": rhs_perturbation,
|
|
"invocationId": "a" * 32,
|
|
}
|
|
return {**body, "requestId": _request_digest(body)}
|
|
|
|
|
|
def test_worker_accepts_strict_rhs_baseline_and_increment_operations():
|
|
baseline = _validate_request(_worker_request("diagnose_rhs_baseline"))
|
|
assert baseline[6] == "diagnose_rhs_baseline"
|
|
assert baseline[8] is None
|
|
assert baseline[-1] is True
|
|
perturbation = {
|
|
"parameterId": "C12_team_capacity", "resourceId": 1, "increment": 1,
|
|
}
|
|
increment = _validate_request(
|
|
_worker_request("diagnose_rhs_perturbation", perturbation)
|
|
)
|
|
assert increment[8] == perturbation
|
|
assert increment[-1] is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"payload",
|
|
[
|
|
_worker_request("diagnose_rhs_perturbation"),
|
|
_worker_request(
|
|
"diagnose_rhs_baseline",
|
|
{"parameterId": "C8_due_date_allowance", "increment": 60},
|
|
),
|
|
_worker_request(
|
|
"diagnose_rhs_perturbation",
|
|
{"parameterId": "C12_team_capacity", "resourceId": 1, "increment": True},
|
|
),
|
|
_worker_request(
|
|
"diagnose_rhs_perturbation",
|
|
{
|
|
"parameterId": "C8_due_date_allowance", "increment": 60,
|
|
"unexpected": True,
|
|
},
|
|
),
|
|
],
|
|
)
|
|
def test_worker_rejects_invalid_rhs_operation_schema(payload):
|
|
with pytest.raises((TypeError, ValueError)):
|
|
_validate_request(payload)
|
|
|
|
|
|
def test_real_rhs_protocol_uses_random_invocation_and_fixed_solver_seed():
|
|
world = _tight_two_order_world()
|
|
entries, params = _entries_and_params(world)
|
|
_, first = run_cp_rhs_diagnostic(
|
|
world, entries, params, pipeline_label="RHS-BASELINE",
|
|
)
|
|
_, second = run_cp_rhs_diagnostic(
|
|
world, entries, params, pipeline_label="RHS-BASELINE",
|
|
)
|
|
assert first["objective"] == second["objective"]
|
|
assert first["solverProcess"]["requestId"] != second["solverProcess"]["requestId"]
|
|
assert first["solverProcess"]["invocationId"] != second["solverProcess"]["invocationId"]
|
|
assert first["numSearchWorkers"] == 1
|
|
assert first["randomSeed"] == 0
|
|
assert first["rhsDiagnosticMode"] is True
|
|
assert first["rhsPerturbation"] is None
|
|
|
|
|
|
def test_empty_rhs_baseline_protocol_returns_bound_trivial_state():
|
|
ordered, meta = run_cp_rhs_diagnostic(
|
|
{}, [], EngineParams(engineType="CP"), pipeline_label="RHS-EMPTY",
|
|
)
|
|
assert ordered == []
|
|
assert meta["status"] == "TRIVIAL"
|
|
assert meta["rhsParameterState"]["dueDateEntryCount"] == 0
|
|
assert meta["solverProcess"]["operation"] == "diagnose_rhs_baseline"
|
|
|
|
|
|
def test_production_optimizer_rejects_rhs_perturbation():
|
|
world = _tight_two_order_world()
|
|
entries, params = _entries_and_params(world)
|
|
with pytest.raises(ValueError, match="禁止 RHS"):
|
|
optimize_line_assignment(
|
|
world, entries, params, pipeline_label="PRODUCTION",
|
|
rhs_perturbation={"parameterId": "C8_due_date_allowance", "increment": 60},
|
|
)
|
|
|
|
|
|
def test_response_semantics_rejects_rhs_echo_or_state_drift():
|
|
meta = {
|
|
"pipeline": "TEST-RHS",
|
|
"status": "INFEASIBLE",
|
|
"relaxedConstraintIds": [],
|
|
"diagnosticMode": True,
|
|
"rhsDiagnosticMode": True,
|
|
"rhsPerturbation": None,
|
|
"assumptionConstraints": ["C2_no_overlap"],
|
|
"activeAssumptionConstraints": ["C2_no_overlap"],
|
|
"enforcedAssumptionConstraints": ["C2_no_overlap"],
|
|
"constraintInstanceCounts": {"C2_no_overlap": 1},
|
|
"rhsParameterState": {
|
|
"dueDateAllowanceMinutes": 0,
|
|
"dueDateEntryCount": 1,
|
|
"lineDailyCapacities": [],
|
|
"teamCapacities": [],
|
|
"toolingCapacities": [{"resourceId": 1, "capacity": 0, "intervalCount": 2}],
|
|
},
|
|
}
|
|
with pytest.raises(SolverProcessError, match="toolingCapacities"):
|
|
_validate_result_semantics(
|
|
[{"orderNo": "SO-1"}], meta,
|
|
expected_entries=[{"orderNo": "SO-1"}], pipeline_label="TEST-RHS",
|
|
expected_relaxed_constraint_ids=[], expected_diagnostic_mode=True,
|
|
expected_rhs_diagnostic_mode=True, expected_rhs_perturbation=None,
|
|
)
|
|
|
|
|
|
def _fake_rhs_meta(
|
|
*, status: str, objective: float | None, bound: float | None,
|
|
due_allowance: int,
|
|
) -> dict:
|
|
return {
|
|
"status": status,
|
|
"objective": objective,
|
|
"bestBound": bound,
|
|
"gap": None if objective is None else round(abs(objective - bound) / abs(objective), 6),
|
|
"assumptionConstraints": ["C2_no_overlap"],
|
|
"activeAssumptionConstraints": ["C2_no_overlap"],
|
|
"enforcedAssumptionConstraints": ["C2_no_overlap"],
|
|
"relaxedConstraintIds": [],
|
|
"constraintInstanceCounts": {"C2_no_overlap": 1},
|
|
"diagnosticMode": True,
|
|
"rhsDiagnosticMode": True,
|
|
"rhsPerturbation": (
|
|
None if due_allowance == 0
|
|
else {"parameterId": "C8_due_date_allowance", "increment": due_allowance}
|
|
),
|
|
"rhsParameterState": {
|
|
"dueDateAllowanceMinutes": due_allowance,
|
|
"dueDateEntryCount": 2,
|
|
"lineDailyCapacities": [],
|
|
"teamCapacities": [],
|
|
"toolingCapacities": [],
|
|
},
|
|
"numSearchWorkers": 1,
|
|
"randomSeed": 0,
|
|
"cumulative": {"resources": []},
|
|
"solverProcess": {
|
|
"requestId": "req", "invocationId": "inv",
|
|
"operation": "diagnose_rhs_baseline" if due_allowance == 0 else "diagnose_rhs_perturbation",
|
|
"runtimeIdentity": {"safe": True},
|
|
},
|
|
}
|
|
|
|
|
|
def test_feasible_rhs_comparison_reports_bounds_not_incumbent_value(monkeypatch):
|
|
from server.aps_domain import cp_rhs
|
|
|
|
def fake_run(*_args, rhs_perturbation=None, **_kwargs):
|
|
if rhs_perturbation is None:
|
|
return [], _fake_rhs_meta(
|
|
status="FEASIBLE", objective=100.0, bound=80.0, due_allowance=0,
|
|
)
|
|
return [], _fake_rhs_meta(
|
|
status="FEASIBLE", objective=70.0, bound=50.0, due_allowance=60,
|
|
)
|
|
|
|
monkeypatch.setattr(cp_rhs, "run_cp_rhs_diagnostic", fake_run)
|
|
report = run_cp_rhs_resolve(
|
|
_tight_two_order_world(), start_date="2026-08-03",
|
|
parameter_ids=["C8_due_date_allowance"],
|
|
)
|
|
row = report["rows"][0]
|
|
assert row["status"] == "bounded"
|
|
assert row["objectiveImprovement"] is None
|
|
assert "incumbentDifference" not in row
|
|
assert row["improvementLowerBound"] == 10.0
|
|
assert row["improvementUpperBound"] == 50.0
|
|
|
|
|
|
def test_rhs_monotonicity_violation_suppresses_report(monkeypatch):
|
|
from server.aps_domain import cp_rhs
|
|
|
|
def fake_run(*_args, rhs_perturbation=None, **_kwargs):
|
|
objective = 100.0 if rhs_perturbation is None else 110.0
|
|
due = 0 if rhs_perturbation is None else 60
|
|
return [], _fake_rhs_meta(
|
|
status="OPTIMAL", objective=objective, bound=objective, due_allowance=due,
|
|
)
|
|
|
|
monkeypatch.setattr(cp_rhs, "run_cp_rhs_diagnostic", fake_run)
|
|
report = run_cp_rhs_resolve(
|
|
_tight_two_order_world(), start_date="2026-08-03",
|
|
parameter_ids=["C8_due_date_allowance"],
|
|
)
|
|
assert report["status"] == "monotonicity_violation"
|
|
assert report["rows"][0]["status"] == "monotonicity_violation"
|
|
assert report["rows"][0]["objectiveImprovement"] is None
|
|
|
|
|
|
def test_c8_cannot_change_feasibility(monkeypatch):
|
|
from server.aps_domain import cp_rhs
|
|
|
|
def fake_run(*_args, rhs_perturbation=None, **_kwargs):
|
|
if rhs_perturbation is None:
|
|
return [], _fake_rhs_meta(
|
|
status="INFEASIBLE", objective=None, bound=None, due_allowance=0,
|
|
)
|
|
return [], _fake_rhs_meta(
|
|
status="OPTIMAL", objective=50.0, bound=50.0, due_allowance=60,
|
|
)
|
|
|
|
monkeypatch.setattr(cp_rhs, "run_cp_rhs_diagnostic", fake_run)
|
|
report = run_cp_rhs_resolve(
|
|
_tight_two_order_world(), start_date="2026-08-03",
|
|
parameter_ids=["C8_due_date_allowance"],
|
|
)
|
|
assert report["status"] == "monotonicity_violation"
|
|
assert "仅改变延期目标" in report["rows"][0]["reason"]
|
|
|
|
|
|
def test_rhs_resource_interval_topology_drift_is_solver_error(monkeypatch):
|
|
from server.aps_domain import cp_rhs
|
|
|
|
def fake_run(*_args, rhs_perturbation=None, **_kwargs):
|
|
due = 0 if rhs_perturbation is None else 60
|
|
meta = _fake_rhs_meta(
|
|
status="OPTIMAL", objective=100.0, bound=100.0, due_allowance=due,
|
|
)
|
|
meta["rhsParameterState"]["teamCapacities"] = [{
|
|
"resourceId": 1,
|
|
"capacity": 1,
|
|
"intervalCount": 2 if rhs_perturbation is None else 3,
|
|
}]
|
|
return [], meta
|
|
|
|
monkeypatch.setattr(cp_rhs, "run_cp_rhs_diagnostic", fake_run)
|
|
report = run_cp_rhs_resolve(
|
|
_tight_two_order_world(), start_date="2026-08-03",
|
|
parameter_ids=["C8_due_date_allowance"],
|
|
)
|
|
assert report["status"] == "partial"
|
|
assert report["rows"][0]["status"] == "solver_error"
|
|
assert "模型拓扑" in report["rows"][0]["reason"]
|
|
|
|
|
|
def test_inconsistent_feasible_interval_is_solver_error(monkeypatch):
|
|
from server.aps_domain import cp_rhs
|
|
|
|
def fake_run(*_args, rhs_perturbation=None, **_kwargs):
|
|
if rhs_perturbation is None:
|
|
return [], _fake_rhs_meta(
|
|
status="FEASIBLE", objective=100.0, bound=90.0, due_allowance=0,
|
|
)
|
|
return [], _fake_rhs_meta(
|
|
status="FEASIBLE", objective=70.0, bound=95.0, due_allowance=60,
|
|
)
|
|
|
|
monkeypatch.setattr(cp_rhs, "run_cp_rhs_diagnostic", fake_run)
|
|
report = run_cp_rhs_resolve(
|
|
_tight_two_order_world(), start_date="2026-08-03",
|
|
parameter_ids=["C8_due_date_allowance"],
|
|
)
|
|
row = report["rows"][0]
|
|
assert report["status"] == "partial"
|
|
assert row["status"] == "solver_error"
|
|
assert row["objectiveImprovement"] is None
|
|
|
|
|
|
def test_cancellation_is_checked_after_baseline():
|
|
checks = 0
|
|
|
|
def cancel_after_baseline():
|
|
nonlocal checks
|
|
checks += 1
|
|
if checks == 2:
|
|
raise JobCancelled()
|
|
|
|
with pytest.raises(JobCancelled):
|
|
run_cp_rhs_resolve(
|
|
_tight_two_order_world(), start_date="2026-08-03",
|
|
parameter_ids=["C8_due_date_allowance"],
|
|
cancel_check=cancel_after_baseline,
|
|
)
|
|
assert checks == 2
|
|
|
|
|
|
class _Store:
|
|
def __init__(self) -> None:
|
|
self.data = _tight_two_order_world()
|
|
|
|
|
|
class _Projects:
|
|
def active_world_key(self) -> str:
|
|
return "project-cp-rhs"
|
|
|
|
def require_active_write(self) -> None:
|
|
return None
|
|
|
|
|
|
@pytest.fixture
|
|
def cp_rhs_client(monkeypatch):
|
|
import server.gateway.app as gateway
|
|
from server.agent_core import async_jobs
|
|
from server.state import projects
|
|
|
|
install_test_auth(monkeypatch, "tenant-cp-rhs")
|
|
monkeypatch.setattr(gateway, "get_store", lambda: _Store())
|
|
monkeypatch.setattr(projects, "get_project_store", lambda: _Projects())
|
|
queue = JobQueue()
|
|
monkeypatch.setattr(async_jobs, "_queue", queue)
|
|
client = TestClient(gateway.create_app())
|
|
assert client.post(
|
|
"/api/auth/login", json={"username": "planner", "password": "test"},
|
|
).status_code == 200
|
|
return client, queue
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"params",
|
|
[
|
|
{"startDate": "2026-08-03", "track": "flex"},
|
|
{"startDate": "2026-08-03", "unknown": True},
|
|
{"startDate": "2026-08-03", "parameterIds": []},
|
|
{"startDate": "2026-08-03", "parameterIds": ["UNKNOWN"]},
|
|
{
|
|
"startDate": "2026-08-03",
|
|
"parameterIds": ["C8_due_date_allowance"],
|
|
"increments": {"C8_due_date_allowance": True},
|
|
},
|
|
{
|
|
"startDate": "2026-08-03",
|
|
"parameterIds": ["C12_team_capacity"],
|
|
"increments": {"C12_team_capacity": 101},
|
|
},
|
|
{
|
|
"startDate": "2026-08-03",
|
|
"parameterIds": ["C8_due_date_allowance"],
|
|
"costRates": {"C8_due_date_allowance": True},
|
|
},
|
|
{
|
|
"startDate": "2026-08-03",
|
|
"parameterIds": ["C8_due_date_allowance"],
|
|
"costRates": {"C12_team_capacity": 1},
|
|
},
|
|
{"startDate": "2026-08-03", "currency": "CN"},
|
|
{"startDate": "20260803"},
|
|
{"startDate": "2026-08-03", "planningHorizonDays": 0},
|
|
{"startDate": "2026-08-03", "timeLimitSeconds": "4"},
|
|
],
|
|
)
|
|
def test_gateway_rejects_invalid_cp_rhs_before_submit(cp_rhs_client, params):
|
|
client, queue = cp_rhs_client
|
|
response = client.post(
|
|
"/api/jobs", json={"kind": "cp-rhs.recompute", "params": params},
|
|
)
|
|
assert response.status_code == 422, response.text
|
|
assert queue.stats()["total"] == 0
|
|
|
|
|
|
def test_gateway_cp_rhs_submit_uses_authenticated_scope_and_normalized_params(
|
|
monkeypatch, cp_rhs_client,
|
|
):
|
|
from server.aps_domain import cp_rhs
|
|
|
|
captured = {}
|
|
|
|
def fast_report(_world, **options):
|
|
captured.update(options)
|
|
options["cancel_check"]()
|
|
return {"method": "cp-rhs-one-parameter-at-a-time-resolve.v1", "rows": []}
|
|
|
|
monkeypatch.setattr(cp_rhs, "run_cp_rhs_resolve", fast_report)
|
|
client, queue = cp_rhs_client
|
|
response = client.post("/api/jobs", json={
|
|
"kind": "cp-rhs.recompute",
|
|
"actor": "forged-actor",
|
|
"params": {
|
|
"track": "fixed", "startDate": "2026-08-03",
|
|
"parameterIds": ["C8_due_date_allowance"],
|
|
"increments": {"C8_due_date_allowance": 30},
|
|
"costRates": {"C8_due_date_allowance": 0},
|
|
"currency": "cny",
|
|
},
|
|
})
|
|
assert response.status_code == 200, response.text
|
|
record = queue.wait(response.json()["jobId"], timeout=10)
|
|
assert record["status"] == "done"
|
|
assert record["actor"] == "planner"
|
|
assert record["tenant_uuid"] == "tenant-cp-rhs"
|
|
assert record["project_id"] == "project-cp-rhs"
|
|
assert captured["parameter_ids"] == ["C8_due_date_allowance"]
|
|
assert captured["increments"] == {"C8_due_date_allowance": 30}
|
|
assert captured["cost_rates"] == {"C8_due_date_allowance": 0}
|
|
assert captured["currency"] == "CNY"
|
|
|
|
import server.gateway.app as gateway
|
|
|
|
other = TestClient(gateway.create_app())
|
|
assert other.post(
|
|
"/api/auth/login", json={"username": "collaborator", "password": "test"},
|
|
).status_code == 200
|
|
assert other.get("/api/jobs").json()["jobs"] == []
|
|
assert "error" in other.get(f"/api/jobs/{record['job_id']}").json()
|
|
cancelled = other.post(f"/api/jobs/{record['job_id']}/cancel").json()
|
|
assert cancelled["cancelled"] is False
|
|
|
|
|
|
def test_gateway_real_cp_rhs_submit_poll_preserves_unconfigured_cost_nulls(cp_rhs_client):
|
|
client, queue = cp_rhs_client
|
|
response = client.post("/api/jobs", json={
|
|
"kind": "cp-rhs.recompute",
|
|
"params": {
|
|
"track": "fixed", "startDate": "2026-08-03",
|
|
"parameterIds": ["C8_due_date_allowance"],
|
|
"increments": {"C8_due_date_allowance": 60},
|
|
"timeLimitSeconds": 4,
|
|
},
|
|
})
|
|
assert response.status_code == 200, response.text
|
|
record = queue.wait(response.json()["jobId"], timeout=20)
|
|
assert record["status"] == "done"
|
|
result = record["result"]
|
|
assert result["status"] == "completed"
|
|
assert result["evaluations"] == 2
|
|
row = result["rows"][0]
|
|
assert row["costStatus"] == "not_configured"
|
|
assert row["costRate"] is None
|
|
assert row["costRateUnit"] is None
|
|
assert row["costCurrency"] is None
|
|
assert row["estimatedCost"] is None
|
|
|
|
|
|
def test_gateway_other_actor_cannot_read_or_cancel_running_cp_rhs(
|
|
monkeypatch, cp_rhs_client,
|
|
):
|
|
import server.gateway.app as gateway
|
|
from server.aps_domain import cp_rhs
|
|
|
|
started = threading.Event()
|
|
release = threading.Event()
|
|
|
|
def blocking_report(_world, **options):
|
|
started.set()
|
|
while not release.wait(0.01):
|
|
options["cancel_check"]()
|
|
return {"method": "cp-rhs-one-parameter-at-a-time-resolve.v1", "rows": []}
|
|
|
|
monkeypatch.setattr(cp_rhs, "run_cp_rhs_resolve", blocking_report)
|
|
planner, queue = cp_rhs_client
|
|
response = planner.post("/api/jobs", json={
|
|
"kind": "cp-rhs.recompute",
|
|
"params": {
|
|
"startDate": "2026-08-03",
|
|
"parameterIds": ["C8_due_date_allowance"],
|
|
},
|
|
})
|
|
assert response.status_code == 200, response.text
|
|
job_id = response.json()["jobId"]
|
|
assert started.wait(5)
|
|
|
|
other = TestClient(gateway.create_app())
|
|
assert other.post(
|
|
"/api/auth/login", json={"username": "collaborator", "password": "test"},
|
|
).status_code == 200
|
|
assert other.get("/api/jobs").json()["jobs"] == []
|
|
assert "error" in other.get(f"/api/jobs/{job_id}").json()
|
|
denied = other.post(f"/api/jobs/{job_id}/cancel").json()
|
|
assert denied["cancelled"] is False
|
|
own = planner.get(f"/api/jobs/{job_id}").json()["job"]
|
|
assert own["status"] == "running"
|
|
assert own["cancel_requested"] is False
|
|
|
|
release.set()
|
|
assert queue.wait(job_id, timeout=10)["status"] == "done"
|