From 8505a4dfaf6a7a3b3c002599b8394446132734e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Dec 2025 07:52:33 -1000 Subject: [PATCH] dry up tests --- tests/integration/conftest.py | 3 + tests/integration/entity_utils.py | 144 ++++++++++++++++++ .../test_object_id_api_verification.py | 57 +------ ...t_object_id_friendly_name_no_mac_suffix.py | 49 ++---- .../test_object_id_no_friendly_name.py | 69 +-------- 5 files changed, 174 insertions(+), 148 deletions(-) create mode 100644 tests/integration/entity_utils.py diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 965363972f..50e8d4122b 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -51,6 +51,9 @@ if platform.system() == "Windows": import pty # not available on Windows +# Register assert rewrite for entity_utils so assertions have proper error messages +pytest.register_assert_rewrite("tests.integration.entity_utils") + def _get_platformio_env(cache_dir: Path) -> dict[str, str]: """Get environment variables for PlatformIO with shared cache.""" diff --git a/tests/integration/entity_utils.py b/tests/integration/entity_utils.py new file mode 100644 index 0000000000..f0164341e3 --- /dev/null +++ b/tests/integration/entity_utils.py @@ -0,0 +1,144 @@ +"""Utilities for computing entity object_id in integration tests. + +This module contains the algorithm that aioesphomeapi will use to compute +object_id client-side from API data. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case + +if TYPE_CHECKING: + from aioesphomeapi import DeviceInfo, EntityInfo + + +def compute_object_id(name: str) -> str: + """Compute object_id from name using snake_case + sanitize.""" + return sanitize(snake_case(name)) + + +def infer_name_add_mac_suffix(device_info: DeviceInfo) -> bool: + """Infer name_add_mac_suffix from device name ending with MAC suffix.""" + mac_suffix = device_info.mac_address.replace(":", "")[-6:].lower() + return device_info.name.endswith(f"-{mac_suffix}") + + +def compute_entity_object_id( + entity: EntityInfo, + device_info: DeviceInfo, + device_id_to_name: dict[int, str], +) -> str: + """Compute expected object_id for an entity using the algorithm from PR summary. + + This is the algorithm that aioesphomeapi will use to compute object_id + client-side from API data. + + Args: + entity: The entity to compute object_id for + device_info: Device info from the API + device_id_to_name: Mapping of device_id to device name for sub-devices + + Returns: + The computed object_id string + """ + name_add_mac_suffix = infer_name_add_mac_suffix(device_info) + + if entity.name: + # Named entity: use entity name + name_for_id = entity.name + elif entity.device_id != 0: + # Empty name on sub-device: use sub-device name + name_for_id = device_id_to_name[entity.device_id] + elif name_add_mac_suffix: + # Empty name on main device with MAC suffix: use friendly_name directly + # (even if empty - this is bug-for-bug compatibility) + name_for_id = device_info.friendly_name + elif device_info.friendly_name: + # Empty name on main device with friendly_name set: use it + name_for_id = device_info.friendly_name + else: + # Empty name on main device, no friendly_name: use device name + name_for_id = device_info.name + + return compute_object_id(name_for_id) + + +def compute_entity_hash( + entity: EntityInfo, + device_info: DeviceInfo, + device_id_to_name: dict[int, str], +) -> int: + """Compute expected object_id hash for an entity. + + Args: + entity: The entity to compute hash for + device_info: Device info from the API + device_id_to_name: Mapping of device_id to device name for sub-devices + + Returns: + The computed FNV-1 hash + """ + name_add_mac_suffix = infer_name_add_mac_suffix(device_info) + + if entity.name: + name_for_id = entity.name + elif entity.device_id != 0: + name_for_id = device_id_to_name[entity.device_id] + elif name_add_mac_suffix or device_info.friendly_name: + name_for_id = device_info.friendly_name + else: + name_for_id = device_info.name + + return fnv1_hash_object_id(name_for_id) + + +def verify_entity_object_id( + entity: EntityInfo, + device_info: DeviceInfo, + device_id_to_name: dict[int, str], +) -> None: + """Verify an entity's object_id and hash match the expected values. + + Args: + entity: The entity to verify + device_info: Device info from the API + device_id_to_name: Mapping of device_id to device name for sub-devices + + Raises: + AssertionError: If object_id or hash doesn't match expected value + """ + expected_object_id = compute_entity_object_id( + entity, device_info, device_id_to_name + ) + assert entity.object_id == expected_object_id, ( + f"object_id mismatch for entity '{entity.name}': " + f"expected '{expected_object_id}', got '{entity.object_id}'" + ) + + expected_hash = compute_entity_hash(entity, device_info, device_id_to_name) + assert entity.key == expected_hash, ( + f"hash mismatch for entity '{entity.name}': " + f"expected {expected_hash:#x}, got {entity.key:#x}" + ) + + +def verify_all_entities( + entities: list[EntityInfo], + device_info: DeviceInfo, +) -> None: + """Verify all entities have correct object_id and hash values. + + Args: + entities: List of entities to verify + device_info: Device info from the API + + Raises: + AssertionError: If any entity's object_id or hash doesn't match + """ + # Build device_id -> name lookup from sub-devices + device_id_to_name = {d.device_id: d.name for d in device_info.devices} + + for entity in entities: + verify_entity_object_id(entity, device_info, device_id_to_name) diff --git a/tests/integration/test_object_id_api_verification.py b/tests/integration/test_object_id_api_verification.py index 58862bd234..c8603e0682 100644 --- a/tests/integration/test_object_id_api_verification.py +++ b/tests/integration/test_object_id_api_verification.py @@ -25,8 +25,9 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case +from esphome.helpers import fnv1_hash_object_id +from .entity_utils import compute_object_id, verify_all_entities from .types import APIClientConnectedFactory, RunCompiledFunction # Host platform default MAC: 98:35:69:ab:f6:79 -> suffix "abf679" @@ -62,11 +63,6 @@ SUB_DEVICE_EMPTY_NAME_ENTITIES = [ ] -def compute_expected_object_id(name: str) -> str: - """Compute expected object_id from name using Python helpers.""" - return sanitize(snake_case(name)) - - @pytest.mark.asyncio async def test_object_id_api_verification( yaml_config: str, @@ -120,7 +116,7 @@ async def test_object_id_api_verification( ) # Verify Python computation matches - computed = compute_expected_object_id(entity_name) + computed = compute_object_id(entity_name) assert computed == expected_object_id, ( f"Entity '{entity_name}': Python computation mismatch. " f"Computed '{computed}', expected '{expected_object_id}'" @@ -160,7 +156,7 @@ async def test_object_id_api_verification( ) expected_name = device_id_to_name[entity.device_id] - expected_object_id = compute_expected_object_id(expected_name) + expected_object_id = compute_object_id(expected_name) assert entity.object_id == expected_object_id, ( f"Empty-name entity (device_id={entity.device_id}): object_id mismatch. " f"API: '{entity.object_id}', expected: '{expected_object_id}' " @@ -174,44 +170,7 @@ async def test_object_id_api_verification( f"API key: {entity.key:#x}, expected: {expected_hash:#x}" ) - # === Test 3: Verify ALL entities can have object_id computed from API data === - # This uses the algorithm from the PR summary that aioesphomeapi will use. - # Infer name_add_mac_suffix from device name ending with MAC suffix. - mac_suffix = device_info.mac_address.replace(":", "")[-6:].lower() - name_add_mac_suffix = device_info.name.endswith(f"-{mac_suffix}") - - for entity in entities: - if entity.name: - # Named entity: use entity name - name_for_id = entity.name - elif entity.device_id != 0: - # Empty name on sub-device: use sub-device name - name_for_id = device_id_to_name[entity.device_id] - elif name_add_mac_suffix: - # Empty name on main device with MAC suffix: use friendly_name directly - # (even if empty - this is bug-for-bug compatibility) - name_for_id = device_info.friendly_name - elif device_info.friendly_name: - # Empty name on main device with friendly_name set: use it - name_for_id = device_info.friendly_name - else: - # Empty name on main device, no friendly_name: use device name - name_for_id = device_info.name - - # Compute object_id from the appropriate name - computed_object_id = compute_expected_object_id(name_for_id) - - # Verify it matches what the API returned - assert entity.object_id == computed_object_id, ( - f"Entity (name='{entity.name}', device_id={entity.device_id}): " - f"object_id cannot be computed. " - f"API: '{entity.object_id}', Computed from '{name_for_id}': '{computed_object_id}'" - ) - - # Verify hash can also be computed - computed_hash = fnv1_hash_object_id(name_for_id) - assert entity.key == computed_hash, ( - f"Entity (name='{entity.name}', device_id={entity.device_id}): " - f"hash cannot be computed. " - f"API key: {entity.key:#x}, Computed: {computed_hash:#x}" - ) + # === Test 3: Verify ALL entities using the algorithm from entity_utils === + # This uses the algorithm that aioesphomeapi will use to compute object_id + # client-side from API data. + verify_all_entities(entities, device_info) diff --git a/tests/integration/test_object_id_friendly_name_no_mac_suffix.py b/tests/integration/test_object_id_friendly_name_no_mac_suffix.py index b8d198f9d0..7199a2b371 100644 --- a/tests/integration/test_object_id_friendly_name_no_mac_suffix.py +++ b/tests/integration/test_object_id_friendly_name_no_mac_suffix.py @@ -11,16 +11,16 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case +from esphome.helpers import fnv1_hash_object_id +from .entity_utils import ( + compute_object_id, + infer_name_add_mac_suffix, + verify_all_entities, +) from .types import APIClientConnectedFactory, RunCompiledFunction -def compute_expected_object_id(name: str) -> str: - """Compute expected object_id from name using Python helpers.""" - return sanitize(snake_case(name)) - - @pytest.mark.asyncio async def test_object_id_friendly_name_no_mac_suffix( yaml_config: str, @@ -54,7 +54,7 @@ async def test_object_id_friendly_name_no_mac_suffix( entity = empty_name_entities[0] # Should use friendly_name for object_id (Branch 4) - expected_object_id = compute_expected_object_id("My Friendly Device") + expected_object_id = compute_object_id("My Friendly Device") assert expected_object_id == "my_friendly_device" # Verify our expectation assert entity.object_id == expected_object_id, ( f"Expected object_id '{expected_object_id}' from friendly_name, " @@ -72,35 +72,10 @@ async def test_object_id_friendly_name_no_mac_suffix( assert len(named_entities) == 1 assert named_entities[0].object_id == "temperature" - # Verify the full algorithm from PR summary works for ALL entities - # Infer name_add_mac_suffix from device name ending with MAC suffix. - mac_suffix = device_info.mac_address.replace(":", "")[-6:].lower() - name_add_mac_suffix = device_info.name.endswith(f"-{mac_suffix}") - # Verify our inference: no MAC suffix in this test - assert not name_add_mac_suffix, "Device name should NOT have MAC suffix" + assert not infer_name_add_mac_suffix(device_info), ( + "Device name should NOT have MAC suffix" + ) - for entity in entities: - if entity.name: - name_for_id = entity.name - elif name_add_mac_suffix: - # MAC suffix enabled: use friendly_name directly (even if empty) - name_for_id = device_info.friendly_name - elif device_info.friendly_name: - # Branch 4: No MAC suffix, but friendly_name is set - name_for_id = device_info.friendly_name - else: - # No MAC suffix, no friendly_name: use device name - name_for_id = device_info.name - - computed_object_id = compute_expected_object_id(name_for_id) - assert entity.object_id == computed_object_id, ( - f"Algorithm failed for entity '{entity.name}': " - f"expected '{computed_object_id}', got '{entity.object_id}'" - ) - - computed_hash = fnv1_hash_object_id(name_for_id) - assert entity.key == computed_hash, ( - f"Algorithm hash failed for entity '{entity.name}': " - f"expected {computed_hash:#x}, got {entity.key:#x}" - ) + # Verify the full algorithm from entity_utils works for ALL entities + verify_all_entities(entities, device_info) diff --git a/tests/integration/test_object_id_no_friendly_name.py b/tests/integration/test_object_id_no_friendly_name.py index 1a60a787ed..b548f02fde 100644 --- a/tests/integration/test_object_id_no_friendly_name.py +++ b/tests/integration/test_object_id_no_friendly_name.py @@ -17,8 +17,9 @@ from __future__ import annotations import pytest -from esphome.helpers import fnv1_hash_object_id, sanitize, snake_case +from esphome.helpers import fnv1_hash_object_id +from .entity_utils import compute_object_id, verify_all_entities from .types import APIClientConnectedFactory, RunCompiledFunction # Host platform default MAC: 98:35:69:ab:f6:79 -> suffix "abf679" @@ -28,11 +29,6 @@ MAC_SUFFIX = "abf679" FNV1_OFFSET_BASIS = 2166136261 -def compute_expected_object_id(name: str) -> str: - """Compute expected object_id from name using Python helpers.""" - return sanitize(snake_case(name)) - - @pytest.mark.asyncio async def test_object_id_no_friendly_name_with_mac_suffix( yaml_config: str, @@ -85,33 +81,8 @@ async def test_object_id_no_friendly_name_with_mac_suffix( assert len(named_entities) == 1 assert named_entities[0].object_id == "temperature" - # Verify the full algorithm from PR summary works for ALL entities - # Infer name_add_mac_suffix from device name ending with MAC suffix. - mac_suffix = device_info.mac_address.replace(":", "")[-6:].lower() - name_add_mac_suffix = device_info.name.endswith(f"-{mac_suffix}") - - for entity in entities: - if entity.name: - name_for_id = entity.name - elif name_add_mac_suffix: - # MAC suffix enabled: use friendly_name directly (even if empty) - name_for_id = device_info.friendly_name - elif device_info.friendly_name: - name_for_id = device_info.friendly_name - else: - name_for_id = device_info.name - - computed_object_id = compute_expected_object_id(name_for_id) - assert entity.object_id == computed_object_id, ( - f"Algorithm failed for entity '{entity.name}': " - f"expected '{computed_object_id}', got '{entity.object_id}'" - ) - - computed_hash = fnv1_hash_object_id(name_for_id) - assert entity.key == computed_hash, ( - f"Algorithm hash failed for entity '{entity.name}': " - f"expected {computed_hash:#x}, got {entity.key:#x}" - ) + # Verify the full algorithm from entity_utils works for ALL entities + verify_all_entities(entities, device_info) @pytest.mark.asyncio @@ -148,7 +119,7 @@ async def test_object_id_no_friendly_name_no_mac_suffix( entity = empty_name_entities[0] # OLD behavior: object_id was computed from device name - expected_object_id = compute_expected_object_id("test-device") + expected_object_id = compute_object_id("test-device") assert entity.object_id == expected_object_id, ( f"Expected object_id '{expected_object_id}' from device name, " f"got '{entity.object_id}'" @@ -165,31 +136,5 @@ async def test_object_id_no_friendly_name_no_mac_suffix( assert len(named_entities) == 1 assert named_entities[0].object_id == "temperature" - # Verify the full algorithm from PR summary works for ALL entities - # Infer name_add_mac_suffix from device name ending with MAC suffix. - mac_suffix = device_info.mac_address.replace(":", "")[-6:].lower() - name_add_mac_suffix = device_info.name.endswith(f"-{mac_suffix}") - - for entity in entities: - if entity.name: - name_for_id = entity.name - elif name_add_mac_suffix: - # MAC suffix enabled: use friendly_name directly (even if empty) - name_for_id = device_info.friendly_name - elif device_info.friendly_name: - name_for_id = device_info.friendly_name - else: - # No MAC suffix, no friendly_name: use device name - name_for_id = device_info.name - - computed_object_id = compute_expected_object_id(name_for_id) - assert entity.object_id == computed_object_id, ( - f"Algorithm failed for entity '{entity.name}': " - f"expected '{computed_object_id}', got '{entity.object_id}'" - ) - - computed_hash = fnv1_hash_object_id(name_for_id) - assert entity.key == computed_hash, ( - f"Algorithm hash failed for entity '{entity.name}': " - f"expected {computed_hash:#x}, got {entity.key:#x}" - ) + # Verify the full algorithm from entity_utils works for ALL entities + verify_all_entities(entities, device_info)