🚧 Add environment maneuverability
This commit is contained in:
@@ -3,6 +3,9 @@ diagnostics — exercised in-process via httpx ASGITransport (no socket)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
@@ -191,6 +194,188 @@ class TestReadOnlyMode:
|
||||
r = await c.post("/api/diagnostics/reset-stats")
|
||||
assert r.status_code == 403
|
||||
|
||||
async def test_readonly_blocks_env_control(self, app):
|
||||
ro = build_webui(app, user="admin", password="simu", readonly=True)
|
||||
transport = httpx.ASGITransport(app=ro)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
await c.post("/login", json={"user": "admin", "password": "simu"})
|
||||
r = await c.get("/api/env")
|
||||
assert r.status_code == 200 # status is a read
|
||||
assert r.json()["state"] == "none"
|
||||
r = await c.post("/api/env/start",
|
||||
json={"ref": "envs.pump_station_env:PumpStationEnv"})
|
||||
assert r.status_code == 403
|
||||
r = await c.post("/api/env/stop")
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
class TestEnvControl:
|
||||
"""FR-10.2.15 runtime env lifecycle + FR-10.2.17 hot reload."""
|
||||
|
||||
REF = "envs.pump_station_env:PumpStationEnv"
|
||||
|
||||
async def test_env_status_none(self, client):
|
||||
r = await client.get("/api/env")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"ref": None, "state": "none", "reason": None,
|
||||
"sim_time_ms": None, "tick_ms": None}
|
||||
|
||||
async def test_start_stop_cycle(self, client, app):
|
||||
r = await client.post("/api/env/start", json={"ref": self.REF})
|
||||
assert r.status_code == 200, r.text
|
||||
st = r.json()
|
||||
assert st["state"] == "running"
|
||||
assert st["ref"] == self.REF
|
||||
assert app.runner.env is not None
|
||||
# let it tick a little, then check status shows sim time
|
||||
await asyncio.sleep(0.05)
|
||||
r = await client.get("/api/env")
|
||||
assert r.json()["sim_time_ms"] is not None
|
||||
# stop -> none
|
||||
r = await client.post("/api/env/stop")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["state"] == "none"
|
||||
assert app.runner.env is None
|
||||
|
||||
async def test_start_refused_while_loaded(self, client):
|
||||
await client.post("/api/env/start", json={"ref": self.REF})
|
||||
r = await client.post("/api/env/start", json={"ref": self.REF})
|
||||
assert r.status_code == 409
|
||||
assert "stop" in r.json()["detail"]
|
||||
await client.post("/api/env/stop")
|
||||
|
||||
async def test_start_requires_ref(self, client):
|
||||
r = await client.post("/api/env/start", json={})
|
||||
assert r.status_code == 400
|
||||
|
||||
async def test_pause_resume(self, client, app):
|
||||
await client.post("/api/env/start", json={"ref": self.REF})
|
||||
# let it tick so sim time advances
|
||||
await asyncio.sleep(0.05)
|
||||
t0 = (await client.get("/api/env")).json()["sim_time_ms"]
|
||||
assert t0 > 0
|
||||
r = await client.post("/api/env/pause")
|
||||
assert r.json()["state"] == "paused"
|
||||
t1 = (await client.get("/api/env")).json()["sim_time_ms"]
|
||||
await asyncio.sleep(0.05)
|
||||
t2 = (await client.get("/api/env")).json()["sim_time_ms"]
|
||||
assert t2 == t1 # frozen while paused
|
||||
r = await client.post("/api/env/resume")
|
||||
assert r.json()["state"] == "running"
|
||||
await asyncio.sleep(0.05)
|
||||
t3 = (await client.get("/api/env")).json()["sim_time_ms"]
|
||||
assert t3 > t2
|
||||
# double-pause / resume-without-pause are refused
|
||||
await client.post("/api/env/pause")
|
||||
r = await client.post("/api/env/pause")
|
||||
assert r.status_code == 409
|
||||
r = await client.post("/api/env/stop")
|
||||
assert r.status_code == 200
|
||||
r = await client.post("/api/env/resume")
|
||||
assert r.status_code == 409
|
||||
r = await client.post("/api/env/pause")
|
||||
assert r.status_code == 409 # nothing loaded
|
||||
|
||||
async def test_reset_returns_to_initial_state(self, client):
|
||||
await client.post("/api/env/start", json={"ref": self.REF})
|
||||
await asyncio.sleep(0.05)
|
||||
st = (await client.get("/api/env")).json()
|
||||
assert st["sim_time_ms"] > 0
|
||||
r = await client.post("/api/env/reset")
|
||||
assert r.status_code == 200
|
||||
st = (await client.get("/api/env")).json()
|
||||
assert st["sim_time_ms"] == 0.0 # deterministic fresh episode
|
||||
await client.post("/api/env/stop")
|
||||
|
||||
async def test_restart_fresh_instance(self, client, app):
|
||||
await client.post("/api/env/start", json={"ref": self.REF})
|
||||
first = app.runner.env
|
||||
await asyncio.sleep(0.02)
|
||||
r = await client.post("/api/env/restart")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["state"] == "running"
|
||||
assert app.runner.env is not first # a NEW instance
|
||||
assert app.runner.env.sim_time_ms < first.sim_time_ms
|
||||
await client.post("/api/env/stop")
|
||||
|
||||
async def test_is_done_ends_ticker(self, client, app):
|
||||
# an env that is done after 30 ms of sim time (TICK_MS 10 -> 3 ticks)
|
||||
ref = "envs.test_done_env:PumpStationEnv"
|
||||
app.runner.env = None
|
||||
r = await client.post("/api/env/start", json={"ref": ref})
|
||||
assert r.status_code == 200
|
||||
await asyncio.sleep(0.15) # let the paced ticker finish
|
||||
st = (await client.get("/api/env")).json()
|
||||
assert st["state"] == "done"
|
||||
assert st["reason"] == "is_done()"
|
||||
assert app.runner.env is not None # instance kept for inspection
|
||||
# pause on done -> refused (restart instead)
|
||||
r = await client.post("/api/env/pause")
|
||||
assert r.status_code == 409
|
||||
# reset brings it back to running
|
||||
r = await client.post("/api/env/reset")
|
||||
assert r.json()["state"] == "running"
|
||||
await client.post("/api/env/stop")
|
||||
|
||||
async def test_module_save_discards_env(self, client, app):
|
||||
await client.post("/api/env/start", json={"ref": self.REF})
|
||||
assert app.runner.env is not None
|
||||
r = await client.put("/api/config/modules", json={
|
||||
"modules": ["750-430", "750-530"], "persist": False,
|
||||
})
|
||||
assert r.status_code == 200
|
||||
# FR-10.2.15: the rebuild discards the env (old-layout bindings)
|
||||
assert app.runner.env is None
|
||||
assert (await client.get("/api/env")).json()["state"] == "none"
|
||||
# and the new runner points at the NEW node
|
||||
assert app.runner.node is app.node
|
||||
|
||||
async def test_start_unknown_env_is_409(self, client):
|
||||
r = await client.post("/api/env/start",
|
||||
json={"ref": "envs.no_such_env:Env"})
|
||||
assert r.status_code == 409
|
||||
assert "cannot import" in r.json()["detail"]
|
||||
|
||||
async def test_hot_reload_after_source_edit(self, client, app, tmp_path,
|
||||
monkeypatch):
|
||||
"""FR-10.2.17: start -> edit source on disk -> restart runs the
|
||||
NEW code (sys.modules cache invalidated)."""
|
||||
pkg = tmp_path / "reloadpkg"
|
||||
pkg.mkdir()
|
||||
(pkg / "__init__.py").write_text("", encoding="utf-8")
|
||||
# v1: level rises
|
||||
(pkg / "reload_env.py").write_text(
|
||||
"from wago_sim.scenario.plantenv import PlantEnv\n"
|
||||
"class ReloadEnv(PlantEnv):\n"
|
||||
" def model(self, state, dt_s):\n"
|
||||
" level = float(state.by_code('AI0.0'))\n"
|
||||
" return {'AI0.0': level + 1.0}\n"
|
||||
"Env = ReloadEnv\n", encoding="utf-8")
|
||||
sys.path.insert(0, str(tmp_path))
|
||||
try:
|
||||
r = await client.post("/api/env/start",
|
||||
json={"ref": "reloadpkg.reload_env:ReloadEnv"})
|
||||
assert r.status_code == 200, r.text
|
||||
await asyncio.sleep(0.03)
|
||||
v1 = app.datastore.get_input_word(0)
|
||||
assert v1 > 0
|
||||
# v2: level DRAINS instead — same class name
|
||||
(pkg / "reload_env.py").write_text(
|
||||
"from wago_sim.scenario.plantenv import PlantEnv\n"
|
||||
"class ReloadEnv(PlantEnv):\n"
|
||||
" def model(self, state, dt_s):\n"
|
||||
" level = float(state.by_code('AI0.0'))\n"
|
||||
" return {'AI0.0': max(0.0, level - 1.0)}\n"
|
||||
"Env = ReloadEnv\n", encoding="utf-8")
|
||||
r = await client.post("/api/env/restart")
|
||||
assert r.status_code == 200
|
||||
app.datastore.set_input_word(0, 100)
|
||||
await asyncio.sleep(0.03)
|
||||
assert app.datastore.get_input_word(0) < 100 # NEW code ran
|
||||
await client.post("/api/env/stop")
|
||||
finally:
|
||||
sys.path.remove(str(tmp_path))
|
||||
|
||||
|
||||
class TestCouplerConfig:
|
||||
async def test_get_coupler_config(self, client, app):
|
||||
|
||||
Reference in New Issue
Block a user