Add selectable value face sources

Issue: #378
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-29 22:20:18 +03:00
parent ad9c634901
commit 591f8f6ac9
51 changed files with 1415 additions and 562 deletions
+62
View File
@@ -1511,6 +1511,27 @@ def test_issue_90_space_merge_remaps_internal_badge_marker_link(tmp_path: Path)
assert details["dropped_marker_links"] == 0
def test_issue_378_space_merge_remaps_internal_value_face_marker_link(tmp_path: Path) -> None:
document = _document(tmp_path, "space")
markers = document["payload"]["config"]["markers"]
markers[0]["name"] = "Controller"
markers[0]["value_source"] = {
"kind": "derived_marker_state", "ref": "marker:dumb",
}
markers.append({
"id": "dumb", "binding": "virtual", "space": "ground",
"room_id": "living", "name": "Dumb lamp", "is_light": True,
})
merged, _layout, details = build_space_merge(
document, {"spaces": [], "markers": [], "settings": {}}, {}, "skip",
)
imported = [m for m in merged["markers"] if m.get("space") == details["space_id"]]
by_name = {m.get("name"): m for m in imported}
assert by_name["Controller"]["value_source"]["ref"] == \
"marker:" + by_name["Dumb lamp"]["id"]
assert details["dropped_marker_links"] == 0
def test_space_export_drops_marker_links_outside_selection(tmp_path: Path) -> None:
config = _config()
config["markers"][0]["controls"] = ["marker:outside", "light.keep"]
@@ -1549,6 +1570,47 @@ def test_issue_90_space_export_disables_badge_link_outside_selection(tmp_path: P
assert document["transfer"]["dropped_marker_links"] == 1
def test_issue_378_space_export_drops_value_face_link_outside_selection(tmp_path: Path) -> None:
config = _config()
config["markers"][0]["value_source"] = {
"kind": "derived_marker_state", "ref": "marker:outside",
}
config["markers"].append({
"id": "outside", "binding": "virtual", "space": "other",
"name": "Other lamp", "is_light": True,
})
document, _ = create_export(
SimpleNamespace(instance_id="instance-a"), {"config": config},
{"layout": {"lamp": {"s": "ground", "x": 0.5, "y": 0.5}}},
kind="space", space_id="ground", card_version="1.61.0", config_root=tmp_path,
)
marker = document["payload"]["config"]["markers"][0]
assert "value_source" not in marker
assert document["transfer"]["dropped_marker_links"] == 1
def test_issue_378_virtualized_duplicate_drops_value_face_link(tmp_path: Path) -> None:
document = _document(tmp_path, "space")
controller = document["payload"]["config"]["markers"][0]
controller["name"] = "Controller"
controller["value_source"] = {
"kind": "derived_marker_state", "ref": "marker:dumb",
}
document["payload"]["config"]["markers"].append({
"id": "dumb", "binding": "virtual", "space": "ground", "is_light": True,
})
current = {
"spaces": [], "markers": [{"id": "existing", "binding": controller["binding"]}],
}
merged, _layout, details = build_space_merge(document, current, {}, "virtual")
imported_controller = next(
marker for marker in merged["markers"]
if marker.get("name") == controller.get("name") and marker.get("binding") == "virtual"
)
assert "value_source" not in imported_controller
assert details["dropped_marker_links"] == 1
def test_space_merge_drops_link_when_target_is_virtualized_duplicate(tmp_path: Path) -> None:
document = _document(tmp_path, "space")
controller = document["payload"]["config"]["markers"][0]
+72
View File
@@ -2400,3 +2400,75 @@ def test_issue_90_marker_value_badge_reference_uses_canonical_ref():
with pytest.raises(v.MarkerControlError) as invalid:
v.validate_marker_value_badges(config, {"markers": []})
assert invalid.value.code == "invalid_value_badge_source"
def test_issue_378_value_face_source_is_lossless_and_strict_only_when_changed():
source = {
"kind": "entity_attribute", "entity_id": "cover.awning",
"attribute": "current_position", "future_source": {"kept": True},
}
config = {"markers": [{
"id": "awning", "binding": "entity:cover.awning", "display": "value",
"value_source": source,
}]}
v.validate_marker_value_badges(config, {"markers": []})
stored = v.MARKER_SCHEMA(config["markers"][0])
assert stored["value_source"]["future_source"] == {"kept": True}
future = {"markers": [{
"id": "future", "binding": "entity:sensor.future", "display": "value",
"value_source": {"kind": "future_source", "payload": {"kept": True}},
}]}
v.validate_marker_value_badges(future, future)
with pytest.raises(v.MarkerControlError) as changed:
v.validate_marker_value_badges(future, {"markers": []})
assert changed.value.code == "invalid_value_source"
with pytest.raises(v.MarkerControlError) as imported:
v.validate_marker_value_badges(future, validate_all=True)
assert imported.value.code == "invalid_value_source"
invalid_attr = {"markers": [{
"id": "bad", "binding": "entity:sensor.x", "display": "value",
"value_source": {
"kind": "entity_attribute", "entity_id": "sensor.x",
"attribute": "entity_picture",
},
}]}
with pytest.raises(v.MarkerControlError) as attribute:
v.validate_marker_value_badges(invalid_attr, {"markers": []})
assert attribute.value.code == "invalid_value_source_attribute"
def test_issue_378_value_face_marker_reference_and_id_rename_are_delta_safe():
config = {"markers": [
{"id": "lamp", "binding": "virtual", "is_light": True},
{"id": "controller", "binding": "device:controller", "display": "value",
"value_source": {"kind": "derived_marker_state", "ref": "marker:lamp"}},
]}
v.validate_marker_value_badges(config, {"markers": []})
missing = {"markers": [
config["markers"][0],
{**config["markers"][1], "value_source": {
"kind": "derived_marker_state", "ref": "marker:gone",
}},
]}
with pytest.raises(v.MarkerControlError) as absent:
v.validate_marker_value_badges(missing, config)
assert absent.value.code == "value_source_marker_missing"
not_light = {"markers": [
{**config["markers"][0], "is_light": False}, config["markers"][1],
]}
with pytest.raises(v.MarkerControlError) as target:
v.validate_marker_value_badges(not_light, {"markers": []})
assert target.value.code == "value_source_marker_not_light"
# Stable binding identifies a renamed marker, so dormant unchanged source data
# remains old data instead of becoming a newly-invalid write.
future_old = {"markers": [{
"id": "old", "binding": "device:controller", "display": "value",
"value_source": {"kind": "future_source", "payload": 1},
}]}
future_renamed = {"markers": [{**future_old["markers"][0], "id": "new"}]}
v.validate_marker_value_badges(future_renamed, future_old)