🚧 WIP
This commit is contained in:
@@ -346,6 +346,62 @@ class TestPlantEnv:
|
||||
assert "DI0.0" in codes and "DO0.0" in codes
|
||||
assert state.sim_time_ms == 0.0
|
||||
|
||||
def test_observe_carries_display_ranges(self, node):
|
||||
"""FR-8.2.14 observation includes analog display ranges
|
||||
(FR-10.2.14 dense store — defaults materialized)."""
|
||||
env = PlantEnv(node=node)
|
||||
state = env.observe()
|
||||
ai = state.channel("AI0.0")
|
||||
assert ai.range == (0, 4095) # default materialized
|
||||
di = state.channel("DI0.0")
|
||||
assert di.range is None # digital: no range
|
||||
# a configured range is visible in the very same observation
|
||||
node.set_channel_ranges({"AI0.0": (-32000, 32000)})
|
||||
state = env.observe()
|
||||
assert state.channel("AI0.0").range == (-32000, 32000)
|
||||
|
||||
def test_channel_lookup_by_name(self, node):
|
||||
"""PlantState.channel() resolves the configured application
|
||||
name too (FR-10.2.11 names, FR-8.2.16 accessor)."""
|
||||
node.channel_names["AI0.0"] = "tank_level"
|
||||
env = PlantEnv(node=node)
|
||||
state = env.observe()
|
||||
ch = state.channel("tank_level")
|
||||
assert ch.io_code == "AI0.0"
|
||||
assert ch.range == (0, 4095)
|
||||
assert state.channel("AI0.0") is ch # code lookup matches
|
||||
# code takes precedence over name; unknown -> KeyError
|
||||
with pytest.raises(KeyError):
|
||||
state.channel("no_such_channel")
|
||||
# a name colliding with an I/O code resolves to the code
|
||||
node.channel_names["AO0.1"] = "AI0.0"
|
||||
state = env.observe()
|
||||
assert state.channel("AI0.0").io_code == "AI0.0"
|
||||
|
||||
def test_channel_range_accessor(self, node):
|
||||
"""state.channel_range(): analog (min, max) by code or name;
|
||||
digital channels carry no range."""
|
||||
node.channel_names["AI0.0"] = "tank_level"
|
||||
node.set_channel_ranges({"AI0.0": (-32000, 32000)})
|
||||
env = PlantEnv(node=node)
|
||||
state = env.observe()
|
||||
assert state.channel_range("AI0.0") == (-32000, 32000)
|
||||
assert state.channel_range("tank_level") == (-32000, 32000)
|
||||
assert state.channel_range("AO0.0") == (0, 4095) # dense default
|
||||
with pytest.raises(KeyError):
|
||||
state.channel_range("DI0.0") # digital
|
||||
|
||||
def test_node_range_map_is_dense(self, node):
|
||||
"""FR-10.2.14: every analog channel always has an entry."""
|
||||
analog = set(node.channel_ranges)
|
||||
assert {"AI0.0", "AI0.1", "AO0.0", "AO0.1"} <= analog
|
||||
assert all(node.channel_ranges[c] == (0, 4095) for c in analog)
|
||||
# digital codes are pruned; defaults materialized on assign
|
||||
node.set_channel_ranges({"AI0.0": (0, 4095), "DI0.0": (0, 100)})
|
||||
assert "DI0.0" not in node.channel_ranges
|
||||
assert node.channel_ranges["AI0.0"] == (0, 4095)
|
||||
assert "AO0.1" in node.channel_ranges # re-materialized
|
||||
|
||||
def test_apply_writes_input_side(self, node):
|
||||
env = PlantEnv(node=node)
|
||||
env.apply({"DI0.0": True, "AI0.0": 1234})
|
||||
|
||||
+35
-2
@@ -480,13 +480,32 @@ class TestChannelRanges:
|
||||
assert ai["range"] == {"min": 0, "max": 32000}
|
||||
assert app.node.channel_ranges["AO0.0"] == (400, 2000)
|
||||
|
||||
async def test_default_range_dropped_from_store(self, client, app):
|
||||
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 == {} # kept clean
|
||||
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}
|
||||
@@ -569,6 +588,20 @@ class TestChannelRanges:
|
||||
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={
|
||||
|
||||
Reference in New Issue
Block a user