Skip to content

Commit 1d12c6c

Browse files
committed
fix(search): align membership projection contracts
1 parent 1eb7bb1 commit 1d12c6c

4 files changed

Lines changed: 57 additions & 15 deletions

File tree

custom_components/ha_mcp_tools/websocket_api.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1588,11 +1588,13 @@ def _normalize_member_entity_ids(attributes: Any) -> list[str] | None:
15881588
if not isinstance(raw, Collection):
15891589
continue
15901590
members: set[str] = set()
1591+
valid = True
15911592
for value in raw:
15921593
if not _is_entity_id(value):
1594+
valid = False
15931595
break
15941596
members.add(value)
1595-
else:
1597+
if valid:
15961598
return sorted(members)
15971599
return None
15981600

src/ha_mcp/tools/tools_search.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -761,9 +761,9 @@ def _normalized_domain_filter(raw: str | None) -> str | None:
761761
_ENRICHMENT_FIELDS: tuple[str, ...] = ("area", "floor", "labels", "aliases")
762762
_MEMBERSHIP_FIELDS: tuple[str, ...] = ("is_group", "member_entity_ids")
763763

764-
# Every field name result_fields= accepts — base record keys plus the opt-in
765-
# enrichment keys. A requested name outside this set is rejected up front with the
766-
# standard validation error rather than silently projecting to empty records.
764+
# Every field name result_fields= accepts — base record keys plus opt-in
765+
# enrichment and membership keys. Unknown names are rejected up front rather
766+
# than silently projecting to empty records.
767767
_ALLOWED_RESULT_FIELDS: frozenset[str] = (
768768
frozenset(_ENTITY_RECORD_KEYS)
769769
| frozenset(_ENRICHMENT_FIELDS)
@@ -1134,12 +1134,10 @@ def _shape_component_search_response(
11341134

11351135
if req.registry_eligible:
11361136
parsed_result_fields = _parse_component_result_fields(req.result_fields)
1137-
# Base record is the six documented keys. result_fields may additionally
1138-
# request enrichment fields (area/floor/labels/aliases) that the component
1139-
# already computed per hit via its registry join — retain exactly those
1140-
# requested keys before the result_fields projection so the enrichment
1141-
# survives it, while a search that requests none still emits the default
1142-
# six-key shape (parity with the legacy path).
1137+
# Base records contain the six documented keys. Requested enrichment
1138+
# fields retain the component's area/floor/labels/aliases registry join;
1139+
# requested membership fields append its opt-in state-derived metadata.
1140+
# With neither class requested, the default six-key shape is unchanged.
11431141
record_keys = (
11441142
*_ENTITY_RECORD_KEYS,
11451143
*_requested_enrichment(parsed_result_fields),
@@ -2472,7 +2470,7 @@ async def _ha_search_entities(
24722470
'E.g. ["entity_id", "state"] returns slim entity records. '
24732471
"None = full records (default). "
24742472
"Base keys: entity_id, friendly_name, domain, state, score, match_type. "
2475-
"Opt-in enrichment/membership keys (computed on request): area, floor, labels, aliases. "
2473+
"Opt-in enrichment/membership keys (computed on request): area, floor, labels, aliases, is_group, member_entity_ids. "
24762474
"An unknown key is rejected."
24772475
),
24782476
),

tests/src/unit/test_component_search_contract.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -591,9 +591,45 @@ def test_component_body_search_types_lockstep() -> None:
591591
@pytest.mark.parametrize(
592592
("attributes", "expected"),
593593
[
594-
({"group_entities": ["light.two", "light.one"]}, ["light.one", "light.two"]),
594+
(
595+
{
596+
"group_entities": ["light.two", "light.one", "light.two"],
597+
"entity_id": ["light.legacy"],
598+
},
599+
["light.one", "light.two"],
600+
),
601+
({"entity_id": ("light.two", "light.one")}, ["light.one", "light.two"]),
595602
({"group_entities": [], "entity_id": ["light.legacy"]}, []),
596603
({"entity_id": "light.reference"}, None),
604+
({"entity_id": b"light.reference"}, None),
605+
({"entity_id": bytearray(b"light.reference")}, None),
606+
({"group_entities": {"light.member": True}}, None),
607+
(
608+
{"group_entities": frozenset({"light.two", "light.one"})},
609+
["light.one", "light.two"],
610+
),
611+
({"entity_id": ["light.Not_Valid"]}, None),
612+
({"entity_id": ["light.not-valid"]}, None),
613+
({"entity_id": ["light.lämp"]}, None),
614+
({"group_entities": ["light.valid", "not-an-entity"]}, None),
615+
(
616+
{
617+
"group_entities": ["light.valid", "not-an-entity"],
618+
"entity_id": ["light.legacy"],
619+
},
620+
["light.legacy"],
621+
),
622+
(
623+
{
624+
"group_entities": ["light.modern"],
625+
"entity_id": ["light.legacy"],
626+
},
627+
["light.modern"],
628+
),
629+
(
630+
{"group_entities": ["light.group", "light.member"]},
631+
["light.group", "light.member"],
632+
),
597633
({"group_members": ["light.not_supported"]}, None),
598634
],
599635
)
@@ -604,7 +640,7 @@ def test_component_body_search_types_lockstep() -> None:
604640
def test_membership_normalizer_artifacts_remain_in_lockstep(
605641
normalizer, attributes, expected
606642
) -> None:
607-
"""Keep server and component normalization aligned across edge cases."""
643+
"""Keep server and component normalization aligned across all edge cases."""
608644
assert normalizer(attributes) == expected
609645

610646

tests/src/unit/test_search_fields_projection.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -839,7 +839,7 @@ async def send_websocket_message(payload):
839839
return client
840840

841841
@pytest.fixture
842-
def mock_smart_tools(self, mock_client):
842+
def real_smart_tools(self, mock_client):
843843
"""Use the real smart-search transform chain for membership redaction."""
844844
smart = SmartSearchTools(mock_client)
845845
smart.deep_search = AsyncMock(
@@ -854,9 +854,15 @@ def mock_smart_tools(self, mock_client):
854854
)
855855
return smart
856856

857+
@pytest.fixture
858+
def search_tool(self, mock_mcp, mock_client, real_smart_tools):
859+
"""Register ha_search with the real smart-search fixture."""
860+
register_search_tools(mock_mcp, mock_client, smart_tools=real_smart_tools)
861+
return self.registered_tools["ha_search"]
862+
857863
@pytest.mark.asyncio
858864
async def test_fuzzy_search_redacts_hidden_member_but_keeps_group(
859-
self, search_tool, mock_smart_tools
865+
self, search_tool
860866
):
861867
result = await search_tool(
862868
query="group",

0 commit comments

Comments
 (0)