824 lines
38 KiB
Python
824 lines
38 KiB
Python
"""Web UI API tests (spec §10): auth, node layout, values, manipulation,
|
|
diagnostics — exercised in-process via httpx ASGITransport (no socket)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import sys
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from wago_sim.webui.app import build_webui
|
|
|
|
|
|
@pytest.fixture
|
|
def webui(app):
|
|
"""FastAPI app bound to the simulator app (default credentials)."""
|
|
return build_webui(app, user="admin", password="simu")
|
|
|
|
|
|
@pytest.fixture
|
|
async def client(webui):
|
|
"""Async httpx client with a valid session (default credentials)."""
|
|
transport = httpx.ASGITransport(app=webui)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
|
r = await c.post("/login", json={"user": "admin", "password": "simu"})
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["ok"] is True
|
|
yield c
|
|
|
|
|
|
@pytest.fixture
|
|
async def anon_client(webui):
|
|
"""Async httpx client WITHOUT login."""
|
|
transport = httpx.ASGITransport(app=webui)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
|
yield c
|
|
|
|
|
|
class TestAuth:
|
|
async def test_login_page_served(self, anon_client):
|
|
r = await anon_client.get("/login")
|
|
assert r.status_code == 200
|
|
assert "WAGO" in r.text
|
|
|
|
async def test_root_redirects_when_anonymous(self, anon_client):
|
|
r = await anon_client.get("/", follow_redirects=False)
|
|
assert r.status_code == 303
|
|
assert r.headers["location"] == "/login"
|
|
|
|
async def test_api_requires_session(self, anon_client):
|
|
r = await anon_client.get("/api/node")
|
|
assert r.status_code == 401
|
|
|
|
async def test_login_wrong_password(self, anon_client):
|
|
r = await anon_client.post("/login", json={"user": "admin", "password": "wrong"})
|
|
assert r.status_code == 401
|
|
assert r.json()["ok"] is False
|
|
|
|
async def test_logout_invalidates_session(self, client):
|
|
r = await client.post("/api/auth/logout")
|
|
assert r.status_code == 200
|
|
r = await client.get("/api/node")
|
|
assert r.status_code == 401
|
|
|
|
async def test_login_lockout_after_failures(self, anon_client, monkeypatch):
|
|
import wago_sim.webui.auth as auth_mod
|
|
|
|
# reset the module-level throttle so suite order does not matter
|
|
monkeypatch.setattr(auth_mod, "_LOGIN_ATTEMPTS", {})
|
|
for _ in range(5):
|
|
r = await anon_client.post("/login", json={"user": "admin", "password": "nope"})
|
|
assert r.status_code == 401
|
|
# 6th attempt with CORRECT password is locked out too
|
|
r = await anon_client.post("/login", json={"user": "admin", "password": "simu"})
|
|
assert r.status_code == 401
|
|
|
|
|
|
class TestNodeAPI:
|
|
async def test_node_layout(self, client):
|
|
r = await client.get("/api/node")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["coupler"] == "750-362"
|
|
articles = [m["article"] for m in data["modules"]]
|
|
assert "750-430" in articles # DI_8 from pump_modules
|
|
# module-based addressing: channel 0 -> address like DI0.0
|
|
di_mod = next(m for m in data["modules"] if m["article"] == "750-430")
|
|
assert di_mod["channels"][0]["address"] == "DI0.0"
|
|
assert "modbus" in di_mod["channels"][0]
|
|
# image sizes from the layout are reported
|
|
assert data["image_bits"]["digital_in"] == 24 # DI_8 + DI_16
|
|
|
|
async def test_values_snapshot(self, client):
|
|
r = await client.get("/api/values")
|
|
assert r.status_code == 200
|
|
values = r.json()["values"]
|
|
kinds = {v["kind"] for v in values}
|
|
assert kinds == {"di", "do", "ai", "ao"}
|
|
|
|
|
|
class TestManipulation:
|
|
async def test_toggle_di_via_api(self, client, datastore):
|
|
r = await client.post("/api/channels/di/0/0", json={"action": "toggle"})
|
|
assert r.status_code == 200
|
|
assert r.json()["value"] is True
|
|
# the internal memory handler shows the same state (channel 1)
|
|
assert datastore.digital_input_channel(1) is True
|
|
|
|
async def test_toggle_do_forces_output_image(self, client, datastore):
|
|
# DO_8 sits at module slot 2 in the pump fixture
|
|
r = await client.post("/api/channels/do/0/0", json={"action": "toggle"})
|
|
assert r.status_code == 200
|
|
# DO channel 1 is forced ON in the OUTPUT image (master-side)
|
|
assert datastore.digital_output_channel(1) is True
|
|
# ... and the +0x0200 read-back mirror reflects it
|
|
bits = datastore.read_bits(0x0200, 1)
|
|
assert bits == [True]
|
|
|
|
async def test_ai_value_set(self, client, datastore):
|
|
# AI_2 sits at module slot 4 in the pump fixture (DI_8, DI_16,
|
|
# DO_8, DO_16, AI_2, AO_2); its first word is input word 0
|
|
r = await client.post("/api/channels/ai/0/0", json={"value": 200})
|
|
assert r.status_code == 200
|
|
assert r.json()["value"] == 200
|
|
assert datastore.get_input_word(0) == 200
|
|
|
|
async def test_ao_force_writes_output_image(self, client, datastore):
|
|
r = await client.post("/api/channels/ao/0/0", json={"value": 1234})
|
|
assert r.status_code == 200
|
|
# written at the output image word 0 -> read back at +0x0200
|
|
assert datastore.read_words(0x0200, 1) == [1234]
|
|
|
|
async def test_unknown_channel_404(self, client):
|
|
r = await client.post("/api/channels/di/99/0", json={"action": "toggle"})
|
|
assert r.status_code == 404
|
|
|
|
async def test_kind_mismatch_400(self, client):
|
|
# per-type numbering (v1.5): DO module 0 is the DO_8 — asking
|
|
# for a DO channel beyond its 8 channels is out of range (404);
|
|
# DO module 2 does not exist (only DO0, DO1)
|
|
r = await client.post("/api/channels/do/2/0", json={"action": "toggle"})
|
|
assert r.status_code == 404
|
|
r = await client.post("/api/channels/do/0/9", json={"action": "toggle"})
|
|
assert r.status_code in (400, 404) # channel out of module range
|
|
|
|
|
|
class TestDiagnostics:
|
|
async def test_diagnostics_payload(self, client):
|
|
r = await client.get("/api/diagnostics")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["watchdog"]["status"] in (0, 1, 2)
|
|
assert "mod_tcp_message_counter" in data["statistics"]
|
|
assert "led_error" in data
|
|
|
|
async def test_inject_and_clear_led_fault(self, client, app):
|
|
r = await client.post("/api/diagnostics/inject-fault",
|
|
json={"fault": "led", "code": 5, "argument": 2})
|
|
assert r.status_code == 200
|
|
assert app.registers.read_config(0x1020, 1) == [5]
|
|
assert app.registers.read_config(0x1021, 1) == [2]
|
|
r = await client.post("/api/diagnostics/clear-fault", json={"fault": "led"})
|
|
assert r.status_code == 200
|
|
assert app.registers.read_config(0x1020, 1) == [0]
|
|
|
|
async def test_inject_module_diagnosis(self, client, app):
|
|
r = await client.post("/api/diagnostics/inject-fault",
|
|
json={"fault": "module_diagnosis", "module": 2,
|
|
"channel": 1, "diagnosis": 0x0001})
|
|
assert r.status_code == 200
|
|
assert app.registers.read_config(0x1050, 3) == [2, 1, 0x0001]
|
|
|
|
async def test_reset_stats(self, client, app):
|
|
app.stats.bad_address += 3
|
|
r = await client.post("/api/diagnostics/reset-stats")
|
|
assert r.status_code == 200
|
|
assert app.stats.bad_address == 0
|
|
|
|
|
|
class TestReadOnlyMode:
|
|
async def test_readonly_blocks_writes(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:
|
|
r = await c.post("/login", json={"user": "admin", "password": "simu"})
|
|
assert r.status_code == 200
|
|
assert r.json()["readonly"] is True
|
|
r = await c.get("/api/node")
|
|
assert r.status_code == 200
|
|
r = await c.post("/api/channels/di/0/0", json={"action": "toggle"})
|
|
assert r.status_code == 403
|
|
assert "read-only" in r.json()["detail"]
|
|
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):
|
|
r = await client.get("/api/config/coupler")
|
|
assert r.status_code == 200
|
|
cfg = r.json()
|
|
# defaults: 100 units = 10 s watchdog, full masks, no delay
|
|
assert cfg["watchdog"]["time_units"] == 100
|
|
assert cfg["watchdog"]["mask_fc_1_16"] == 0xFFFF
|
|
assert cfg["watchdog"]["type"] == 0
|
|
assert cfg["response_delay_ms"] == 0
|
|
assert cfg["idle_timeout_units"] == 600
|
|
assert len(cfg["mac_id"]) == 3
|
|
# image sizes for the pump fixture (AO_2 out / AI_2 in analog,
|
|
# DO 24 bits out / DI 24 bits in — 0x1022..0x1025 per manual §11.2.5.5)
|
|
assert cfg["image_bits"]["analog_in"] == 32
|
|
assert cfg["image_bits"]["analog_out"] == 32
|
|
assert cfg["image_bits"]["digital_in"] == 24
|
|
assert cfg["image_bits"]["digital_out"] == 24
|
|
|
|
async def test_put_coupler_config_roundtrip(self, client, app):
|
|
r = await client.put("/api/config/coupler", json={
|
|
"watchdog": {"time_units": 200, "close_on_timeout": True},
|
|
"response_delay_ms": 25,
|
|
"idle_timeout_units": 10,
|
|
})
|
|
assert r.status_code == 200
|
|
assert r.json()["applied"] == 4
|
|
# written through the internal register bank
|
|
assert app.registers.read_config(0x1000, 1) == [200]
|
|
assert app.registers.read_config(0x1037, 1) == [25]
|
|
assert app.registers.read_config(0x1030, 1) == [10]
|
|
assert app.registers.read_config(0x1009, 1) == [1]
|
|
|
|
async def test_watchdog_refusal_while_running(self, client, app):
|
|
# start the watchdog (trigger write, like the manual's flow)
|
|
app.watchdog.write(0x1003, 0x0001)
|
|
assert app.watchdog.status == 1
|
|
r = await client.put("/api/config/coupler", json={
|
|
"watchdog": {"time_units": 50},
|
|
})
|
|
assert r.status_code == 400
|
|
assert "cannot change time while running" in r.json()["detail"]
|
|
|
|
async def test_put_coupler_config_readonly_403(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.put("/api/config/coupler", json={"response_delay_ms": 1})
|
|
assert r.status_code == 403
|
|
|
|
|
|
class TestModuleConfig:
|
|
async def test_get_module_config(self, client, app):
|
|
r = await client.get("/api/config/modules")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert [m["article"] for m in data["modules"]] == [
|
|
"750-430", "750-1405", "750-530", "750-1516", "750-467", "750-554",
|
|
]
|
|
articles = {c["article"] for c in data["catalog"]}
|
|
assert "750-461" in articles # catalog has all 13 articles
|
|
assert data["max_modules"] == 64
|
|
assert data["node_config_path"] is None # fixture built without a path
|
|
|
|
async def test_rebuild_updates_layout_and_registers(self, client, app):
|
|
# swap in a bigger DI module list
|
|
r = await client.put("/api/config/modules", json={
|
|
"modules": ["750-1405", "750-1405", "750-530"], "persist": False,
|
|
})
|
|
assert r.status_code == 200
|
|
assert r.json()["image_bits"]["digital_in"] == 32
|
|
# /api/node reflects the new layout
|
|
r = await client.get("/api/node")
|
|
assert [m["article"] for m in r.json()["modules"]] == [
|
|
"750-1405", "750-1405", "750-530",
|
|
]
|
|
# register bank updated: 0x1025 digital-IN bits = 32 (two DI_16)
|
|
assert app.registers.read_config(0x1025, 1) == [32]
|
|
# 0x1024 digital-OUT bits = 8 (one DO_8)
|
|
assert app.registers.read_config(0x1024, 1) == [8]
|
|
# module description 0x2030 contains the new articles
|
|
# (2 chars/word, high byte first — reverse of _string_to_words)
|
|
words = app.registers.read_config(0x2030, 65)
|
|
text = "".join(
|
|
chr(b) for w in words for b in ((w >> 8) & 0xFF, w & 0xFF)
|
|
)
|
|
assert "750-1405" in text
|
|
# values were cleared by the rebuild (KBUS-reset semantics)
|
|
assert all(w == 0 for w in app.datastore.output_words)
|
|
|
|
async def test_rebuild_rejected_invalid(self, client):
|
|
r = await client.put("/api/config/modules", json={"modules": ["750-9999"]})
|
|
assert r.status_code == 400
|
|
assert "750-9999" in r.json()["detail"]
|
|
r = await client.put("/api/config/modules", json={"modules": []})
|
|
assert r.status_code == 400
|
|
r = await client.put("/api/config/modules", json={"modules": ["750-430"] * 65})
|
|
assert r.status_code == 400
|
|
assert "max 64" in r.json()["detail"]
|
|
|
|
async def test_rebuild_persists_yaml(self, client, app, tmp_path):
|
|
cfg_file = tmp_path / "node.yaml"
|
|
app.node_config_path = str(cfg_file)
|
|
r = await client.put("/api/config/modules", json={
|
|
"modules": ["750-430", "750-530"], "persist": True,
|
|
})
|
|
assert r.status_code == 200
|
|
assert r.json()["saved_to"] == str(cfg_file)
|
|
text = cfg_file.read_text(encoding="utf-8")
|
|
assert 'coupler: 750-362' in text
|
|
assert '"750-430"' in text and '"750-530"' in text
|
|
# and it loads back through the normal loader
|
|
from wago_sim.scenario.dsl import load_yaml, load_node_modules
|
|
modules = load_node_modules(load_yaml(cfg_file))
|
|
assert [m.article for m in modules] == ["750-430", "750-530"]
|
|
|
|
async def test_modbus_still_serves_after_rebuild(self, client, app):
|
|
"""Modbus connections survive a rebuild; new image answers."""
|
|
r = await client.put("/api/config/modules", json={
|
|
"modules": ["750-530", "750-554"], "persist": False,
|
|
})
|
|
assert r.status_code == 200
|
|
# write an output via the internal dispatch and read the mirror
|
|
app.datastore.write_words(0x0000, [0x1234])
|
|
assert app.datastore.read_words(0x0200, 1) == [0x1234]
|
|
|
|
|
|
class TestChannelNames:
|
|
async def test_names_in_layout_and_values(self, client, app):
|
|
r = await client.put("/api/config/channel-names", json={
|
|
"channel_names": {"DI0.0": "start_button", "AI0.0": "tank_level"},
|
|
"persist": False,
|
|
})
|
|
assert r.status_code == 200
|
|
# layout carries the name on the right channel
|
|
r = await client.get("/api/node")
|
|
di0 = next(c for m in r.json()["modules"] if m["index"] == 0
|
|
for c in m["channels"] if c["address"] == "DI0.0")
|
|
assert di0["channel_name"] == "start_button"
|
|
# values snapshot carries it too
|
|
r = await client.get("/api/values")
|
|
entry = next(v for v in r.json()["values"]
|
|
if (v["kind"], v["module"], v["channel"]) == ("ai", 0, 0))
|
|
assert entry["name"] == "tank_level"
|
|
|
|
async def test_names_persist_to_yaml(self, client, app, tmp_path):
|
|
cfg_file = tmp_path / "node.yaml"
|
|
app.node_config_path = str(cfg_file)
|
|
r = await client.put("/api/config/channel-names", json={
|
|
"channel_names": {"DO0.0": "pump_relay"}, "persist": True,
|
|
})
|
|
assert r.status_code == 200
|
|
text = cfg_file.read_text(encoding="utf-8")
|
|
assert "channel_names:" in text
|
|
assert "DO0.0: pump_relay" in text
|
|
# and it loads back
|
|
from wago_sim.scenario.dsl import load_yaml, load_channel_names
|
|
names = load_channel_names(load_yaml(cfg_file))
|
|
assert names == {"DO0.0": "pump_relay"}
|
|
|
|
async def test_invalid_code_rejected(self, client):
|
|
r = await client.put("/api/config/channel-names", json={
|
|
"channel_names": {"XX0.0": "nope"}, "persist": False,
|
|
})
|
|
assert r.status_code == 400
|
|
assert "invalid I/O code" in r.json()["detail"]
|
|
|
|
async def test_names_survive_module_save(self, client, app, tmp_path):
|
|
"""Module SAVE CONFIG must not wipe channel names (they persist)."""
|
|
app.node_config_path = str(tmp_path / "node.yaml")
|
|
await client.put("/api/config/channel-names", json={
|
|
"channel_names": {"DI0.0": "start_button"}, "persist": True,
|
|
})
|
|
r = await client.put("/api/config/modules", json={
|
|
"modules": ["750-430", "750-530"], "persist": True,
|
|
})
|
|
assert r.status_code == 200
|
|
assert app.node.channel_names.get("DI0.0") == "start_button"
|
|
# YAML contains both sections
|
|
text = (tmp_path / "node.yaml").read_text(encoding="utf-8")
|
|
assert "DI0.0: start_button" in text
|
|
|
|
async def test_readonly_blocks_rename(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.put("/api/config/channel-names", json={"channel_names": {}})
|
|
assert r.status_code == 403
|
|
|
|
|
|
class TestConfigExportImport:
|
|
async def test_export_shape(self, client):
|
|
r = await client.get("/api/config/export")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["format"] == "wago-sim-config"
|
|
assert data["version"] == 1
|
|
assert len(data["modules"]) == 6
|
|
assert "coupler_settings" in data
|
|
assert "channel_names" in data
|
|
|
|
async def test_export_import_roundtrip(self, client, app, tmp_path):
|
|
# configure something recognizable
|
|
await client.put("/api/config/channel-names", json={
|
|
"channel_names": {"DI0.0": "start_button"}, "persist": False,
|
|
})
|
|
await client.put("/api/config/coupler", json={"response_delay_ms": 33})
|
|
exported = (await client.get("/api/config/export")).json()
|
|
|
|
app.node_config_path = str(tmp_path / "node.yaml")
|
|
r = await client.post("/api/config/import", json=exported)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["ok"] is True
|
|
assert "750-430" in body["modules"]
|
|
# names and coupler settings applied
|
|
assert app.node.channel_names.get("DI0.0") == "start_button"
|
|
assert app.registers.read_config(0x1037, 1) == [33]
|
|
# persisted to YAML
|
|
text = (tmp_path / "node.yaml").read_text(encoding="utf-8")
|
|
assert "DI0.0: start_button" in text
|
|
|
|
async def test_import_rejects_bad_payload(self, client, app):
|
|
# wrong format marker
|
|
r = await client.post("/api/config/import", json={"format": "nope"})
|
|
assert r.status_code == 400
|
|
# unknown article: nothing applied
|
|
before = [m.article for m in app.node.modules]
|
|
r = await client.post("/api/config/import", json={
|
|
"format": "wago-sim-config", "modules": ["750-9999"],
|
|
"channel_names": {}, "coupler_settings": {},
|
|
})
|
|
assert r.status_code == 400
|
|
assert [m.article for m in app.node.modules] == before # untouched
|
|
|
|
async def test_import_drops_stale_name_codes(self, client, app):
|
|
# names for channels that don't exist on the imported layout
|
|
r = await client.post("/api/config/import", json={
|
|
"format": "wago-sim-config",
|
|
"modules": ["750-530"],
|
|
"channel_names": {"DO0.0": "pump", "DI0.0": "stale_name"},
|
|
"coupler_settings": {},
|
|
"persist": False,
|
|
})
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["dropped_codes"] == ["DI0.0"]
|
|
assert app.node.channel_names == {"DO0.0": "pump"}
|
|
|
|
async def test_readonly_blocks_import_export(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/config/export")
|
|
assert r.status_code == 200 # export is a read
|
|
r = await c.post("/api/config/import", json={"format": "wago-sim-config",
|
|
"modules": ["750-430"]})
|
|
assert r.status_code == 403
|
|
|
|
|
|
class TestChannelRanges:
|
|
async def test_default_range_in_layout(self, client):
|
|
r = await client.get("/api/node")
|
|
ai = next(c for m in r.json()["modules"] if m["index"] == 4
|
|
for c in m["channels"] if c["kind"] == "ai")
|
|
assert ai["range"] == {"min": 0, "max": 4095}
|
|
# digital channels carry no range
|
|
di = next(c for m in r.json()["modules"] if m["index"] == 0
|
|
for c in m["channels"] if c["kind"] == "di")
|
|
assert di["range"] is None
|
|
|
|
async def test_put_ranges_and_layout(self, client, app):
|
|
r = await client.put("/api/config/channel-ranges", json={
|
|
"channel_ranges": {"AI0.0": {"min": 0, "max": 32000},
|
|
"AO0.0": {"min": 400, "max": 2000}},
|
|
"persist": False,
|
|
})
|
|
assert r.status_code == 200
|
|
# layout reflects the configured range
|
|
r = await client.get("/api/node")
|
|
ai = next(c for m in r.json()["modules"] if m["index"] == 4
|
|
for c in m["channels"] if c["kind"] == "ai" and c["address"] == "AI0.0")
|
|
assert ai["range"] == {"min": 0, "max": 32000}
|
|
assert app.node.channel_ranges["AO0.0"] == (400, 2000)
|
|
|
|
async def test_default_range_kept_in_dense_store(self, client, app):
|
|
# the store is DENSE (FR-10.2.14): a default-equal entry is
|
|
# materialized like any other — no sparse-override dropping
|
|
r = await client.put("/api/config/channel-ranges", json={
|
|
"channel_ranges": {"AI0.0": {"min": 0, "max": 4095}}, # == default
|
|
"persist": False,
|
|
})
|
|
assert r.status_code == 200
|
|
assert app.node.channel_ranges["AI0.0"] == (0, 4095)
|
|
# every analog channel of the layout is present
|
|
analog = {c for c in app.node.channel_ranges}
|
|
assert {"AI0.0", "AI0.1", "AO0.0", "AO0.1"} <= analog
|
|
# and digital codes never are
|
|
assert not any(c.startswith(("DI", "DO")) for c in analog)
|
|
|
|
async def test_dense_store_pruned_on_module_save(self, client, app, tmp_path):
|
|
app.node_config_path = str(tmp_path / "node.yaml")
|
|
await client.put("/api/config/channel-ranges", json={
|
|
"channel_ranges": {"AI0.0": {"min": 0, "max": 32000}}, "persist": False,
|
|
})
|
|
r = await client.put("/api/config/modules", json={
|
|
"modules": ["750-430", "750-530"], "persist": False,
|
|
})
|
|
assert r.status_code == 200
|
|
# no analog modules left -> the range store is empty (pruned)
|
|
assert app.node.channel_ranges == {}
|
|
|
|
async def test_range_validation(self, client):
|
|
base = {"persist": False}
|
|
r = await client.put("/api/config/channel-ranges", json={
|
|
"channel_ranges": {"XX4.0": {"min": 0, "max": 100}}, **base})
|
|
assert r.status_code == 400 # bad code
|
|
r = await client.put("/api/config/channel-ranges", json={
|
|
"channel_ranges": {"AI0.0": {"min": 100, "max": 50}}, **base})
|
|
assert r.status_code == 400 # min >= max
|
|
r = await client.put("/api/config/channel-ranges", json={
|
|
"channel_ranges": {"AI0.0": {"min": 0, "max": 70000}}, **base})
|
|
assert r.status_code == 400 # > 65535
|
|
r = await client.put("/api/config/channel-ranges", json={
|
|
"channel_ranges": {"AI0.0": {"min": 0}}, **base})
|
|
assert r.status_code == 400 # missing max
|
|
|
|
async def test_negative_range_accepted(self, client, app):
|
|
r = await client.put("/api/config/channel-ranges", json={
|
|
"channel_ranges": {"AI0.0": {"min": -32000, "max": 32000}},
|
|
"persist": False,
|
|
})
|
|
assert r.status_code == 200
|
|
assert app.node.channel_ranges["AI0.0"] == (-32000, 32000)
|
|
# layout exposes the signed range
|
|
r = await client.get("/api/node")
|
|
ai = next(c for m in r.json()["modules"] if m["index"] == 4
|
|
for c in m["channels"] if c["address"] == "AI0.0")
|
|
assert ai["range"] == {"min": -32000, "max": 32000}
|
|
|
|
async def test_range_floor_is_minus_65534(self, client):
|
|
r = await client.put("/api/config/channel-ranges", json={
|
|
"channel_ranges": {"AI0.0": {"min": -65535, "max": 0}}, "persist": False})
|
|
assert r.status_code == 400 # below -65534
|
|
|
|
async def test_signed_value_display_and_write(self, client, app, datastore):
|
|
# channel with a negative-min range -> signed two's-complement view
|
|
await client.put("/api/config/channel-ranges", json={
|
|
"channel_ranges": {"AI0.0": {"min": -32000, "max": 32000}},
|
|
"persist": False})
|
|
r = await client.post("/api/channels/ai/0/0", json={"value": -2000})
|
|
assert r.status_code == 200
|
|
# raw image holds the two's-complement word
|
|
assert datastore.get_input_word(0) == (-2000) & 0xFFFF
|
|
# values API shows the signed number again
|
|
r = await client.get("/api/values")
|
|
entry = next(v for v in r.json()["values"]
|
|
if (v["kind"], v["module"], v["channel"]) == ("ai", 0, 0))
|
|
assert entry["value"] == -2000
|
|
|
|
async def test_negative_ao_force(self, client, datastore):
|
|
r = await client.post("/api/channels/ao/0/0", json={"value": -3000})
|
|
assert r.status_code == 200
|
|
assert datastore.get_output_word(0) == (-3000) & 0xFFFF
|
|
# unsigned AO (default range) still displays raw
|
|
r = await client.get("/api/values")
|
|
entry = next(v for v in r.json()["values"]
|
|
if (v["kind"], v["module"], v["channel"]) == ("ao", 0, 0))
|
|
assert entry["value"] == (-3000) & 0xFFFF
|
|
|
|
async def test_value_bounds_rejected(self, client):
|
|
r = await client.post("/api/channels/ai/0/0", json={"value": -65535})
|
|
assert r.status_code == 400
|
|
r = await client.post("/api/channels/ai/0/0", json={"value": 65536})
|
|
assert r.status_code == 400
|
|
r = await client.post("/api/channels/ai/0/0", json={"value": "banana"})
|
|
assert r.status_code == 400
|
|
|
|
async def test_ranges_persist_to_yaml(self, client, app, tmp_path):
|
|
cfg_file = tmp_path / "node.yaml"
|
|
app.node_config_path = str(cfg_file)
|
|
r = await client.put("/api/config/channel-ranges", json={
|
|
"channel_ranges": {"AI0.0": {"min": 0, "max": 32000}}, "persist": True,
|
|
})
|
|
assert r.status_code == 200
|
|
text = cfg_file.read_text(encoding="utf-8")
|
|
assert "channel_ranges:" in text
|
|
assert "min: 0, max: 32000" in text
|
|
# loads back
|
|
from wago_sim.scenario.dsl import load_yaml, load_channel_ranges
|
|
ranges = load_channel_ranges(load_yaml(cfg_file))
|
|
assert ranges == {"AI0.0": (0, 32000)}
|
|
|
|
async def test_ranges_yaml_lean_defaults(self, client, app, tmp_path):
|
|
# dense store, lean YAML: default-equal entries are NOT written
|
|
cfg_file = tmp_path / "node.yaml"
|
|
app.node_config_path = str(cfg_file)
|
|
r = await client.put("/api/config/channel-ranges", json={
|
|
"channel_ranges": {"AI0.0": {"min": 0, "max": 4095},
|
|
"AO0.0": {"min": 0, "max": 32000}},
|
|
"persist": True,
|
|
})
|
|
assert r.status_code == 200
|
|
text = cfg_file.read_text(encoding="utf-8")
|
|
assert "AO0.0" in text and "AI0.0" not in text
|
|
assert app.node.channel_ranges["AI0.0"] == (0, 4095) # store dense
|
|
|
|
async def test_ranges_survive_module_save(self, client, app, tmp_path):
|
|
app.node_config_path = str(tmp_path / "node.yaml")
|
|
await client.put("/api/config/channel-ranges", json={
|
|
"channel_ranges": {"AI0.0": {"min": 0, "max": 32000}}, "persist": True,
|
|
})
|
|
r = await client.put("/api/config/modules", json={
|
|
"modules": ["750-430", "750-467"], "persist": True,
|
|
})
|
|
assert r.status_code == 200
|
|
# AI_2 is at slot 1 now; the AI0.0 range became stale -> carried
|
|
# over but no longer matches a channel; the store keeps it (names
|
|
# semantics differ: stale name codes are dropped on import only)
|
|
text = (tmp_path / "node.yaml").read_text(encoding="utf-8")
|
|
assert "channel_ranges:" in text
|
|
|
|
async def test_export_import_carries_ranges(self, client, app, tmp_path):
|
|
await client.put("/api/config/channel-ranges", json={
|
|
"channel_ranges": {"AI0.0": {"min": 600, "max": 2000}}, "persist": False,
|
|
})
|
|
exported = (await client.get("/api/config/export")).json()
|
|
assert exported["channel_ranges"]["AI0.0"] == {"min": 600, "max": 2000}
|
|
app.node_config_path = str(tmp_path / "node.yaml")
|
|
r = await client.post("/api/config/import", json=exported)
|
|
assert r.status_code == 200
|
|
assert app.node.channel_ranges["AI0.0"] == (600, 2000)
|
|
text = (tmp_path / "node.yaml").read_text(encoding="utf-8")
|
|
assert "min: 600, max: 2000" in text
|
|
|
|
async def test_readonly_blocks_ranges(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.put("/api/config/channel-ranges", json={"channel_ranges": {}})
|
|
assert r.status_code == 403 |