"""Smoke tests for ``POST /sweep``. The evaluator backend has no artifact dependency; the surrogate backend needs the surrogate-calibration step-4 quantile bundle. Tests that require the bundle skip when it is missing so a contributor without the artifact can still run the evaluator path locally. """ from __future__ import annotations import pytest from fastapi.testclient import TestClient def _payload( design: dict[str, float | int], *, target: str = "range_km", backend: str = "evaluator", x_n: int = 4, y_axis: dict[str, float | int] | None = None, scenario: str = "equatorial_mare_traverse", ) -> dict[str, object]: body: dict[str, object] = { "target": target, "x_axis": { "variable": "wheel_radius_m", "lo": 0.08, "hi": 0.18, "n_points": x_n, }, "base_design": design, "scenario_name": scenario, "backend": backend, } if y_axis is not None: body["y_axis"] = y_axis return body def test_sweep_evaluator_1d_returns_one_value_per_grid_point( client: TestClient, sample_design: dict[str, float | int], ) -> None: response = client.post( "/sweep", json=_payload(sample_design, x_n=4, backend="evaluator") ) assert response.status_code == 200, response.text body = response.json() assert body["target"] == "range_km" assert body["x_variable"] == "wheel_radius_m" assert body["y_variable"] is None assert body["y_values"] is None assert len(body["x_values"]) == 4 assert len(body["z_values"]) == 4 assert all(isinstance(v, (int, float)) for v in body["z_values"]) assert body["backend_used"] == "evaluator" assert body["backend_requested"] == "evaluator" assert body["n_cells"] == 4 assert body["elapsed_ms"] >= 0.0 def test_sweep_evaluator_2d_returns_y_outer_x_inner_matrix( client: TestClient, sample_design: dict[str, float | int], ) -> None: response = client.post( "/sweep", json=_payload( sample_design, x_n=3, backend="evaluator", y_axis={ "variable": "solar_area_m2", "lo": 0.4, "hi": 0.8, "n_points": 2, }, ), ) assert response.status_code == 200, response.text body = response.json() assert body["y_variable"] == "solar_area_m2" assert len(body["y_values"]) == 2 assert len(body["x_values"]) == 3 z = body["z_values"] assert len(z) == 2 # outer = y assert all(len(row) == 3 for row in z) # inner = x assert body["n_cells"] == 6 assert body["backend_used"] == "evaluator" def test_sweep_surrogate_backend_when_artifact_present( client: TestClient, sample_design: dict[str, float | int], surrogate_v7_1_compatible: bool, ) -> None: if not surrogate_v7_1_compatible: pytest.skip( "schema-v7_1 quantile_bundles.joblib not on disk; skipping " "surrogate sweep." ) response = client.post( "/sweep", json=_payload(sample_design, x_n=8, backend="surrogate") ) assert response.status_code == 200, response.text body = response.json() assert body["backend_used"] == "surrogate" assert len(body["z_values"]) == 8 def test_sweep_rejects_unknown_axis_variable( client: TestClient, sample_design: dict[str, float | int], ) -> None: payload = _payload(sample_design) payload["x_axis"]["variable"] = "n_wheels" # excluded from sweepables response = client.post("/sweep", json=payload) # The Pydantic-level check passes the string through, but the # roverdevkit-side guard fires either at request validation # (route) or at SweepSpec.__post_init__; either way we get a 422. assert response.status_code == 422, response.text def test_sweep_evaluator_hard_limit_returns_422( client: TestClient, sample_design: dict[str, float | int], ) -> None: # 200 * 200 = 40k cells on the evaluator path -> trips hard limit. payload = _payload( sample_design, x_n=200, backend="evaluator", y_axis={ "variable": "solar_area_m2", "lo": 0.4, "hi": 0.8, "n_points": 200, }, ) response = client.post("/sweep", json=payload) assert response.status_code == 422, response.text assert "evaluator hard limit" in response.json()["detail"] def test_sweep_unknown_scenario_returns_404( client: TestClient, sample_design: dict[str, float | int], ) -> None: response = client.post( "/sweep", json=_payload(sample_design, scenario="not_a_real_scenario"), ) assert response.status_code == 404, response.text def test_sweep_honours_operational_duty_cycle_override( client: TestClient, sample_design: dict[str, float | int], ) -> None: """SCHEMA_VERSION v7_1: the δ_ops override flows through to the sweep route. Equatorial-mare with continuous sun is duty-bound on ``range_km``: at the family default (δ_ops = 0.30) the rover runs hard; cutting δ_ops to 0.05 should slash range proportionally on every cell (range_km scales linearly with δ_eff until it hits the non-binding traverse-distance cap or the energy throttle). """ base = _payload(sample_design, x_n=4, backend="evaluator", scenario="equatorial_mare_traverse") overridden_low = dict(base) overridden_low["operational_duty_cycle"] = 0.05 overridden_high = dict(base) overridden_high["operational_duty_cycle"] = 0.30 low_resp = client.post("/sweep", json=overridden_low) high_resp = client.post("/sweep", json=overridden_high) assert low_resp.status_code == 200, low_resp.text assert high_resp.status_code == 200, high_resp.text low_z = low_resp.json()["z_values"] high_z = high_resp.json()["z_values"] # Identical designs / scenarios except for δ_ops; the higher # duty cycle must move at least one cell, otherwise the # override silently dropped on the floor. assert low_z != high_z # Sanity-check: the higher-δ_ops grid has every cell ≥ the # lower-δ_ops grid (range monotone in δ_eff). assert all(h >= ll for ll, h in zip(low_z, high_z, strict=True)) def test_sweep_rejects_out_of_bounds_operational_duty_cycle( client: TestClient, sample_design: dict[str, float | int], ) -> None: """SchemaField bounds [0, 0.6] on operational_duty_cycle reject 0.9 → 422.""" payload = _payload(sample_design) payload["operational_duty_cycle"] = 0.9 response = client.post("/sweep", json=payload) assert response.status_code == 422, response.text