22
33from __future__ import annotations
44
5+ from collections import defaultdict
56from dataclasses import dataclass
6- from typing import Iterable , Optional
7+ from typing import Dict , Iterable , List , Optional , Tuple
78
89from app .domain import Binding , BindingPlan , ControlProfile , ValidationIssue , ValidationReport
910
@@ -41,7 +42,7 @@ def plan_diff(
4142 desired_keys = {binding .key : binding for binding in desired_bindings }
4243
4344 for key in current_keys .keys () - desired_keys .keys ():
44- plan .record_remove (key )
45+ plan .record_remove (current_keys [ key ] )
4546
4647 for key , binding in desired_keys .items ():
4748 if key not in current_keys :
@@ -60,4 +61,82 @@ def validate_plan(self, plan: BindingPlan) -> ValidationReport:
6061 message = "No binding changes detected." ,
6162 )
6263 )
64+ return report
65+
66+ slot_key = self ._make_slot_key
67+
68+ occupancy : Dict [Tuple [str , str , str ], List [Binding ]] = defaultdict (list )
69+ if self .context .default_profile is not None :
70+ for binding in self .context .default_profile .iter_bindings ():
71+ occupancy [slot_key (binding )].append (binding )
72+
73+ for binding in plan .to_remove :
74+ key = slot_key (binding )
75+ if key not in occupancy :
76+ continue
77+ remaining = [existing for existing in occupancy [key ] if existing .key != binding .key ]
78+ if remaining :
79+ occupancy [key ] = remaining
80+ else :
81+ occupancy .pop (key )
82+
83+ additions_by_slot : Dict [Tuple [str , str , str ], List [Binding ]] = defaultdict (list )
84+ for binding in plan .to_add :
85+ additions_by_slot [slot_key (binding )].append (binding )
86+
87+ for key , bindings in additions_by_slot .items ():
88+ slot_desc = f"{ key [0 ]} :{ key [2 ]} "
89+ actions_list = ", " .join (sorted ({binding .action .name for binding in bindings }))
90+
91+ if len (bindings ) > 1 :
92+ modifier_values = {binding .modifier for binding in bindings }
93+ if len (modifier_values ) > 1 :
94+ report .add (
95+ ValidationIssue (
96+ level = "error" ,
97+ message = (
98+ f"Modifier conflict: slot { slot_desc } receives both modifier and "
99+ f"non-modifier bindings ({ actions_list } )."
100+ ),
101+ slot = bindings [0 ].slot ,
102+ )
103+ )
104+ else :
105+ report .add (
106+ ValidationIssue (
107+ level = "error" ,
108+ message = (
109+ f"Duplicate slot assignment: slot { slot_desc } receives multiple "
110+ f"bindings ({ actions_list } )."
111+ ),
112+ slot = bindings [0 ].slot ,
113+ )
114+ )
115+
116+ existing_bindings = occupancy .get (key , [])
117+ if existing_bindings :
118+ existing_actions = ", " .join (
119+ sorted ({binding .action .name for binding in existing_bindings })
120+ )
121+ existing_modifiers = {binding .modifier for binding in existing_bindings }
122+ addition_modifiers = {binding .modifier for binding in bindings }
123+ if existing_modifiers ^ addition_modifiers :
124+ reason = "modifier conflict"
125+ else :
126+ reason = "slot already mapped"
127+ report .add (
128+ ValidationIssue (
129+ level = "error" ,
130+ message = (
131+ f"{ reason .capitalize ()} : slot { slot_desc } currently mapped to { existing_actions } ; "
132+ f"cannot add { actions_list } ."
133+ ),
134+ slot = bindings [0 ].slot ,
135+ )
136+ )
137+
63138 return report
139+
140+ @staticmethod
141+ def _make_slot_key (binding : Binding ) -> Tuple [str , str , str ]:
142+ return binding .slot .device_uid , binding .slot .side , binding .slot .slot_id
0 commit comments