🚧 Add environment maneuverability

This commit is contained in:
Jonas Bengtsson
2026-10-04 22:48:10 +02:00
parent 68d3c42d79
commit 647c209e89
15 changed files with 850 additions and 46 deletions
+185
View File
@@ -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):