Files
nickol-knx-mcp/tests/test_policy.py
T
Nikolay1andClaude Opus 4.8 e9105d45aa feat(P5): contextual domain classification — DPT is one signal, not the verdict
A 1-bit switch is domain-agnostic, yet DPT 1.001 defaulted to 'lighting', so an
"AC on/off" was classified lighting and tripped downstream checks. The domain is
now a combination: explicit name > strong domain-encoding DPT > main-group name;
a bare 1-bit GA with no signal is honestly 'unknown', never a guessed lighting.
An explicit name contradicting a STRONG DPT yields 'unknown' (a genuine conflict,
shown by explain_ga as contested), not a silent pick. Word-boundary matching so
"ac" no longer fires inside "terrace"; name terms broadened (EN+RU HVAC incl.
ac/a-c/air-conditioner, RGB/colour lighting).

check_policy taxonomy-outlier now flags only misplaced ACTUATORS (lighting/
shutter/hvac); sensors, scene links, thresholds and timeout params are the
cross-cutting GAs they are. HA generator still emits a switch for a now-HVAC
simple on/off, so no device is dropped.

Verified on 3 real projects: every AC on/off is HVAC; outlier noise fell demo
11->2, Minsk 31->6, Razdory 200->57. tests/test_domain_classifier.py (13/13 green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 10:44:04 +02:00

86 lines
3.8 KiB
Python

"""Test the Project Policy Profile checker (check_policy / load_policy).
Two modes:
* inferred (no profile) — taxonomy is derived from the project's own majority
per main group; a GA that deviates from its main group's own domain is flagged
as ``policy_taxonomy_outlier`` (never against an external "standard").
* declared (profile) — the profile's main-group taxonomy is authoritative;
a misfiled GA is flagged as ``policy_domain_mismatch``. Changing the profile
changes what is flagged.
"""
import os
import tempfile
from nickol_knx_mcp.project import build_loaded_from_raw
from nickol_knx_mcp.policy import check_policy, load_policy
def _ga(addr, name, dmain, dsub):
return {"name": name, "identifier": f"GA-{addr}", "raw_address": 0, "address": addr,
"project_uid": None, "dpt": {"main": dmain, "sub": dsub}, "data_secure": False,
"communication_object_ids": [], "description": "", "comment": ""}
def _project():
# main 1: three lighting (1.001) + ONE shutter (1.008) -> the shutter is the deviant
# main 2: three shutter (1.008) -> consistent
gas = {
"1/0/1": _ga("1/0/1", "Kitchen light A", 1, 1),
"1/0/2": _ga("1/0/2", "Kitchen light B", 1, 1),
"1/0/3": _ga("1/0/3", "Kitchen light C", 1, 1),
"1/0/4": _ga("1/0/4", "Odd blind here", 1, 8), # shutter DPT in the lighting main
"2/0/1": _ga("2/0/1", "Zone one", 1, 8),
"2/0/2": _ga("2/0/2", "Zone two", 1, 8),
"2/0/3": _ga("2/0/3", "Zone three", 1, 8),
}
raw = {
"info": {"project_id": "P-1", "name": "PolicyTest", "group_address_style": "ThreeLevel",
"schema_version": "21"},
"group_addresses": gas, "communication_objects": {}, "devices": {},
"functions": {}, "topology": {}, "group_ranges": {},
}
return build_loaded_from_raw(raw, "policy_test.knxproj")
def main():
p = _project()
# 1. inferred (no profile): main 1 is 3/4 lighting -> the shutter GA 1/0/4 is an outlier
r = load_policy()
assert r["_source"] == "default"
res = check_policy(p, r)
assert res["taxonomy_source"] == "inferred from the project", res["taxonomy_source"]
codes = {(f["code"], f["address"]) for f in res["findings"]}
assert ("policy_taxonomy_outlier", "1/0/4") in codes, res["findings"]
# the consistent shutter main (2) produces no outlier
assert not any(f["address"].startswith("2/") for f in res["findings"]), res["findings"]
# 2. declared profile via YAML: main 1 = lighting, main 2 = shutter -> same deviant, but as
# an authoritative domain mismatch
prof = ("name: my-policy\nmain_groups:\n 1: [lighting]\n 2: [shutter]\n")
fd, path = tempfile.mkstemp(suffix=".yaml"); os.close(fd)
open(path, "w").write(prof)
pol = load_policy(path)
assert pol["_source"] == "profile" and pol["main_groups"][1] == ["lighting"]
res2 = check_policy(p, pol)
assert res2["taxonomy_source"] == "declared profile"
codes2 = {(f["code"], f["address"]) for f in res2["findings"]}
assert ("policy_domain_mismatch", "1/0/4") in codes2, res2["findings"]
# 3. change the profile -> main 1 = shutter now -> the THREE lighting GAs become mismatches
prof2 = ("name: alt\nmain_groups:\n 1: [shutter]\n 2: [shutter]\n")
open(path, "w").write(prof2)
pol2 = load_policy(path)
res3 = check_policy(p, pol2)
bad = {f["address"] for f in res3["findings"] if f["code"] == "policy_domain_mismatch"}
assert {"1/0/1", "1/0/2", "1/0/3"} <= bad, bad
assert "1/0/4" not in bad # shutter GA now conforms
os.unlink(path)
print("test_policy: OK — inferred taxonomy flags the deviant GA vs the project's own "
"majority; a declared profile is authoritative and changing it changes the findings.")
if __name__ == "__main__":
main()