"""Tests for the OpenEnv adapter layer (HospitalOpenEnv + app:app). These exercise the OpenEnv contract directly without spinning up a real HTTP server, so they're fast and deterministic. """ from __future__ import annotations import pytest # Skip the whole module if openenv-core isn't installed (minimal install). pytest.importorskip("openenv.core") pytest.importorskip("fastapi") from fastapi.testclient import TestClient # noqa: E402 from hospital_env import ( # noqa: E402 HospitalAction, HospitalObservation, HospitalOpenEnv, HospitalState, ) # --------------------------------------------------------------------- # HospitalOpenEnv unit tests # --------------------------------------------------------------------- def test_openenv_reset_returns_observation() -> None: env = HospitalOpenEnv() obs = env.reset(seed=0) assert isinstance(obs, HospitalObservation) assert obs.done is False assert obs.reward is None assert len(obs.bed_occupancy) == 20 assert len(obs.icu_occupancy) == 5 assert len(obs.ventilator_status) == 3 assert len(obs.action_mask) == 39 assert obs.time_step == 0 def test_openenv_step_advances_time() -> None: env = HospitalOpenEnv() env.reset(seed=0) obs = env.step(HospitalAction(action=0)) assert isinstance(obs, HospitalObservation) assert obs.time_step == 1 assert isinstance(obs.reward, float) def test_openenv_state_property() -> None: env = HospitalOpenEnv() env.reset(seed=0, episode_id="test-ep") for _ in range(10): env.step(HospitalAction(action=0)) state = env.state assert isinstance(state, HospitalState) assert state.episode_id == "test-ep" assert state.step_count == 10 assert state.queue_len >= 0 assert state.scenario is None # default config def test_openenv_metadata() -> None: env = HospitalOpenEnv() md = env.get_metadata() assert "Hospital" in md.name assert md.version is not None assert len(md.description) > 30 def test_openenv_scenario_dispatch() -> None: env = HospitalOpenEnv(scenario="surge") obs = env.reset(seed=0) assert obs.time_step == 0 state = env.state assert state.scenario == "surge" def test_openenv_unknown_scenario_raises() -> None: with pytest.raises(ValueError, match="Unknown scenario"): HospitalOpenEnv(scenario="not_a_real_scenario") def test_openenv_close_is_idempotent() -> None: env = HospitalOpenEnv() env.reset(seed=0) env.close() env.close() # second call must not raise # --------------------------------------------------------------------- # FastAPI app smoke tests (in-process via TestClient) # --------------------------------------------------------------------- @pytest.fixture(scope="module") def app_client() -> TestClient: from app import app return TestClient(app) def test_app_health(app_client: TestClient) -> None: r = app_client.get("/health") assert r.status_code == 200 assert r.json()["status"] == "healthy" def test_app_metadata(app_client: TestClient) -> None: r = app_client.get("/metadata") assert r.status_code == 200 md = r.json() assert "Hospital" in md["name"] assert md["version"] == "1.0.0" def test_app_schema_has_action_observation_state(app_client: TestClient) -> None: r = app_client.get("/schema") assert r.status_code == 200 s = r.json() assert "action" in s assert "observation" in s assert "state" in s obs_props = s["observation"].get("properties", {}) for key in ( "bed_occupancy", "icu_occupancy", "ventilator_status", "waiting_queue", "time_step", "stats", "action_mask", ): assert key in obs_props, f"missing observation field: {key}" def test_app_reset_via_http(app_client: TestClient) -> None: r = app_client.post("/reset", json={"seed": 0}) assert r.status_code == 200 body = r.json() obs = body.get("observation", body) assert obs["time_step"] == 0 assert len(obs["action_mask"]) == 39 def test_app_step_via_http(app_client: TestClient) -> None: r = app_client.post("/step", json={"action": {"action": 0}}) assert r.status_code == 200 body = r.json() obs = body.get("observation", body) # /step is stateless on REST: each call constructs a fresh env, so we # only assert the protocol shape, not stateful progression. assert "time_step" in obs assert "action_mask" in obs def test_app_step_rejects_invalid_action_value(app_client: TestClient) -> None: r = app_client.post("/step", json={"action": {"action": 999}}) # Pydantic validation should reject (action <= 38 constraint). assert r.status_code in (400, 422)