Files
nickol-knx-mcp/tests/test_room_library.py
T
Nikolay1andClaude Opus 4.8 bf7eba8898 room-library: Theben-benchmark corrections (dogfood on 5 official ETS examples)
Ran KNX Association-ecosystem vendor (Theben) 5 official ETS example .knxproj
through our QA. Findings folded back into the product:

- repair.py _infer_dpt: relative dimming (Brighter/darker, heller/dunkler,
  светлее/темнее) now infers 3.007 DPT_Control_Dimming instead of falling to
  1.001; explicit '% / value' stays 5.001; scene checked before the broad
  value branch. DPTs verified against XKNX/xknx via deepwiki.
- room_library.py: new reusable blocks distilled from vendor patterns —
  shutter_venetian (slat + slat-status + wind/frost safety-lock), climate_floor
  modulating actuating-value + status, ventilation (airing stage + CO2 +
  humidity), lighting_dali, multisensor_air, central scene/all-off/all-on.
- project.py classifier: airing/Lüftung/проветривание -> hvac (was unknown).
- analyze.py: safety_input_no_status INFO exemption for write-only wind/frost
  lock inputs (Theben central wind-safety pattern).
- SCHEMA.md: new function rows; automation_intents implementation: knx|external
  with safety_related => knx rule; DPT-tolerance note (missing-DPT hard error
  applies to generated output; ETS4 object-derived DPT on import is legitimate).
- templates: ventilation slot (bathroom/kitchen), opt-in venetian + wind/frost
  safety intent (living/bedroom), new house-level central.yaml.
- tests: +test_theben_corrections.py (10); golden bedroom 26->28 GA (climate
  actuating-value). pytest 20 passed (pre-existing unrelated test_protocol
  collection error untouched). senior-code-audit: APPROVED.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 11:21:18 +02:00

248 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Room Template Library — R1 tests.
Covers the plan's required cases:
(a) golden RU/EN — one template gives a stable, exact result;
(b) idempotency — same input -> byte-identical output;
(c) permutation invariance — reordering rooms -> identical addresses;
(d) address exhaustion — a clear error, never a silent overflow;
(e) round-trip — generated house -> load_project -> 0 errors from our linters
(validate_naming / detect_missing_status / detect_dpt_issues / check_policy);
(f) negative-oracle — a hand-written expected function/GA manifest vs the fact.
"""
import hashlib
import tempfile
import os
from nickol_knx_mcp import room_library as rl
from nickol_knx_mcp.project import load_project
from nickol_knx_mcp.analyze import (validate_naming, detect_missing_status,
detect_dpt_issues)
from nickol_knx_mcp.policy import check_policy, load_policy
# --------------------------------------------------------------------------- #
# Golden: a single bedroom (comfort) — exact address -> (name, dpt) in RU and EN.
# Hand-verified structure: 28 GAs = dimmer(5) + 2×switch(2) + 2×shutter(4)
# + climate(9: +actuating-value +its status) + presence(2). The opt-in
# venetian_blind slot has venetian_windows=0 by default, so it emits nothing here.
# --------------------------------------------------------------------------- #
GOLDEN_BEDROOM_RU = {
"1/0/1": ("Спальня основной свет вкл/выкл", "1.001"),
"1/0/2": ("Спальня прикроватный свет 1 вкл/выкл", "1.001"),
"1/0/3": ("Спальня прикроватный свет 2 вкл/выкл", "1.001"),
"1/1/1": ("Спальня основной свет диммирование", "3.007"),
"1/2/1": ("Спальня основной свет яркость", "5.001"),
"1/3/1": ("Спальня основной свет статус", "1.011"),
"1/3/2": ("Спальня прикроватный свет 1 статус", "1.011"),
"1/3/3": ("Спальня прикроватный свет 2 статус", "1.011"),
"1/4/1": ("Спальня основной свет яркость статус", "5.001"),
"2/0/1": ("Спальня окно 1 вверх/вниз", "1.008"),
"2/0/2": ("Спальня окно 2 вверх/вниз", "1.008"),
"2/1/1": ("Спальня окно 1 стоп", "1.010"),
"2/1/2": ("Спальня окно 2 стоп", "1.010"),
"2/2/1": ("Спальня окно 1 позиция", "5.001"),
"2/2/2": ("Спальня окно 2 позиция", "5.001"),
"2/4/1": ("Спальня окно 1 позиция статус", "5.001"),
"2/4/2": ("Спальня окно 2 позиция статус", "5.001"),
"3/0/1": ("Спальня тёплый пол вкл/выкл", "1.001"),
"3/1/1": ("Спальня тёплый пол режим", "20.102"),
"3/2/1": ("Спальня тёплый пол уставка", "9.001"),
"3/4/1": ("Спальня тёплый пол статус", "1.011"),
"3/4/2": ("Спальня тёплый пол уставка статус", "9.001"),
"3/4/3": ("Спальня тёплый пол режим статус", "20.102"),
"3/4/4": ("Спальня тёплый пол уровень клапана статус", "5.001"),
"3/5/1": ("Спальня тёплый пол температура факт", "9.001"),
"3/7/1": ("Спальня тёплый пол уровень клапана", "5.001"),
"4/0/1": ("Спальня датчик присутствие", "1.018"),
"4/1/1": ("Спальня датчик освещённость", "9.004"),
}
GOLDEN_BEDROOM_EN = {
"1/0/1": ("Bedroom main light on/off", "1.001"),
"1/0/2": ("Bedroom bedside light 1 on/off", "1.001"),
"1/0/3": ("Bedroom bedside light 2 on/off", "1.001"),
"1/1/1": ("Bedroom main light dimming", "3.007"),
"1/2/1": ("Bedroom main light brightness", "5.001"),
"1/3/1": ("Bedroom main light status", "1.011"),
"1/3/2": ("Bedroom bedside light 1 status", "1.011"),
"1/3/3": ("Bedroom bedside light 2 status", "1.011"),
"1/4/1": ("Bedroom main light brightness status", "5.001"),
"2/0/1": ("Bedroom window 1 up/down", "1.008"),
"2/0/2": ("Bedroom window 2 up/down", "1.008"),
"2/1/1": ("Bedroom window 1 stop", "1.010"),
"2/1/2": ("Bedroom window 2 stop", "1.010"),
"2/2/1": ("Bedroom window 1 position", "5.001"),
"2/2/2": ("Bedroom window 2 position", "5.001"),
"2/4/1": ("Bedroom window 1 position status", "5.001"),
"2/4/2": ("Bedroom window 2 position status", "5.001"),
"3/0/1": ("Bedroom floor heating on/off", "1.001"),
"3/1/1": ("Bedroom floor heating mode", "20.102"),
"3/2/1": ("Bedroom floor heating setpoint", "9.001"),
"3/4/1": ("Bedroom floor heating status", "1.011"),
"3/4/2": ("Bedroom floor heating setpoint status", "9.001"),
"3/4/3": ("Bedroom floor heating mode status", "20.102"),
"3/4/4": ("Bedroom floor heating actuating value status", "5.001"),
"3/5/1": ("Bedroom floor heating actual temperature", "9.001"),
"3/7/1": ("Bedroom floor heating actuating value", "5.001"),
"4/0/1": ("Bedroom sensor occupancy", "1.018"),
"4/1/1": ("Bedroom sensor illuminance", "9.004"),
}
def _obj_map(house):
return {o.address: (o.name, f"{o.dpt_main}.{o.dpt_sub:03d}") for o in house.objects}
def test_templates_all_valid():
tmpls = rl.load_builtin_templates()
assert set(tmpls) == {"bedroom", "children", "living", "kitchen",
"bathroom", "corridor", "central"}, sorted(tmpls)
for tid, t in tmpls.items():
v = rl.validate_room_template(t)
assert v["ok"], (tid, v["findings"])
def test_golden_ru_en():
tmpls = rl.load_builtin_templates()
room = [{"template": "bedroom", "preset": "comfort"}]
hru = rl.resolve_house(room, tmpls, language="ru")
hen = rl.resolve_house(room, tmpls, language="en")
assert _obj_map(hru) == GOLDEN_BEDROOM_RU
assert _obj_map(hen) == GOLDEN_BEDROOM_EN
# identity is language-neutral: same addresses/DPTs regardless of labels
assert set(_obj_map(hru)) == set(_obj_map(hen))
assert {a: d for a, (_, d) in _obj_map(hru).items()} == \
{a: d for a, (_, d) in _obj_map(hen).items()}
def test_idempotent():
rooms = [{"template": "living", "preset": "comfort"},
{"template": "bedroom", "preset": "comfort"},
{"template": "kitchen", "preset": "basic"}]
a = rl.compose(rooms, language="ru")["ets_xml"]
b = rl.compose(rooms, language="ru")["ets_xml"]
assert hashlib.sha256(a.encode()).digest() == hashlib.sha256(b.encode()).digest()
def test_permutation_invariant():
rooms = [{"template": "living", "preset": "comfort"},
{"template": "bedroom", "preset": "comfort"},
{"template": "kitchen", "preset": "basic"},
{"template": "bedroom", "preset": "basic"}]
a = rl.compose(rooms, language="ru")["ets_xml"]
b = rl.compose(list(reversed(rooms)), language="ru")["ets_xml"]
assert a == b, "reordering rooms must not change the generated project"
def test_exhaustion_is_a_clear_error():
# 300 lighting circuits overflow the 255-sub range of middle 1/0 -> hard error.
try:
rl.compose([{"template": "corridor", "preset": "basic",
"params": {"lighting_circuits": 300}}])
except rl.RoomLibraryError as e:
assert "exhausted" in str(e).lower() and "1/0" in str(e), str(e)
else:
raise AssertionError("address exhaustion must raise, never silently overflow")
def test_round_trip_zero_linter_errors():
"""The composed house, re-read by the STANDARD loader, must pass all four
linters with 0 errors and 0 warnings (only INFO is acceptable)."""
rooms = [{"template": "living", "preset": "comfort"},
{"template": "bedroom", "preset": "comfort"},
{"template": "bedroom", "preset": "basic"},
{"template": "children", "preset": "comfort"},
{"template": "kitchen", "preset": "comfort"},
{"template": "bathroom", "preset": "comfort"},
{"template": "corridor", "preset": "basic"}]
house = rl.resolve_house(rooms, rl.load_builtin_templates(), language="ru")
data = rl.build_knxproj_bytes(house)
tf = tempfile.NamedTemporaryFile(suffix=".knxproj", delete=False)
try:
tf.write(data)
tf.close()
proj = load_project(tf.name)
finally:
os.unlink(tf.name)
assert len(proj.gas) == len(house.objects), (len(proj.gas), len(house.objects))
findings = (validate_naming(proj) + detect_missing_status(proj)
+ detect_dpt_issues(proj)
+ check_policy(proj, load_policy(None))["findings"])
errors = [f for f in findings if f.get("severity") == "error"]
warnings = [f for f in findings if f.get("severity") == "warning"]
assert not errors, [(f["code"], f["address"]) for f in errors]
assert not warnings, [(f["code"], f["address"]) for f in warnings]
def test_negative_oracle_manifest():
"""Hand-written expected function/GA set for corridor@comfort vs the fact."""
house = rl.resolve_house([{"template": "corridor", "preset": "comfort"}],
rl.load_builtin_templates(), language="ru")
expected = {
"1/0/1": ("Коридор свет вкл/выкл", "1.001", "command", "lighting"),
"1/1/1": ("Коридор свет диммирование", "3.007", "command", "lighting"),
"1/2/1": ("Коридор свет яркость", "5.001", "command", "lighting"),
"1/3/1": ("Коридор свет статус", "1.011", "status", "lighting"),
"1/4/1": ("Коридор свет яркость статус", "5.001", "status", "lighting"),
"4/0/1": ("Коридор датчик присутствие", "1.018", "sensor", "sensor"),
"4/1/1": ("Коридор датчик освещённость", "9.004", "sensor", "sensor"),
}
fact = {o.address: (o.name, f"{o.dpt_main}.{o.dpt_sub:03d}", o.kind, o.domain)
for o in house.objects}
assert fact == expected, fact
# manifest totals must agree with the resolved object count
man = rl.build_manifest(house)
assert man["totals"]["group_addresses"] == len(house.objects) == 7
assert man["totals"]["rooms"] == 1
def test_per_slot_preset_override():
"""A slot-level preset override mixes comfort climate with basic lighting."""
tmpls = rl.load_builtin_templates()
house = rl.resolve_house([{"template": "bedroom", "preset": "comfort",
"slot_presets": {"main_light": "basic"}}],
tmpls, language="en")
names = {o.name for o in house.objects}
# main light downgraded to a switch -> no dimming/brightness objects for it
assert "Bedroom main light on/off" in names
assert not any("main light dimming" in n for n in names)
# but the comfort climate slot is still present
assert any("floor heating" in n for n in names)
def test_validate_rejects_bad_template():
bad = {"schema_version": 99, "slot_id": "Bad Name",
"labels": {"ru": "x"}, "parameters": {}, "slots": []}
v = rl.validate_room_template(bad)
assert not v["ok"]
codes = {f["code"] for f in v["findings"]}
assert "schema_version_unsupported" in codes
assert "slot_id_invalid" in codes
assert "label_missing" in codes
assert "slots_missing" in codes
def test_bom_proposal():
house = rl.resolve_house([{"template": "living", "preset": "comfort"}],
rl.load_builtin_templates(), language="ru")
bom = rl.build_bom_proposal(house)
fns = {i["function"]: i for i in bom["items"]}
assert "lighting_dimmer" in fns and fns["lighting_dimmer"]["channels_required"] >= 3
assert "shutter" in fns and "climate_floor" in fns
for item in bom["items"]:
assert item["candidate_recipe"], item
def main():
for name, fn in sorted(globals().items()):
if name.startswith("test_") and callable(fn):
fn()
print(f" {name}: OK")
print("test_room_library: OK — golden RU/EN, idempotent, permutation-invariant, "
"exhaustion-safe, round-trip 0 errors/0 warnings, negative-oracle verified.")
if __name__ == "__main__":
main()