@@ -748,3 +748,142 @@ async def test_refresh_map_info_prefers_map_info_names_and_adds_missing_rooms(
748748 assert sorted_rooms [4 ].segment_id == 20
749749 assert sorted_rooms [4 ].name == "Office from rooms_trait"
750750 assert sorted_rooms [4 ].iot_id == "9999001"
751+
752+
753+ async def test_home_trait_listener_notifications (
754+ home_trait : HomeTrait ,
755+ mock_rpc_channel : AsyncMock ,
756+ mock_mqtt_rpc_channel : AsyncMock ,
757+ mock_map_rpc_channel : AsyncMock ,
758+ device_cache : DeviceCache ,
759+ web_api_client : AsyncMock ,
760+ ) -> None :
761+ """Test that listener callbacks are called when home discovery/updates occur."""
762+ mock_callback = MagicMock ()
763+ unsub = home_trait .add_update_listener (mock_callback )
764+ # Callback should NOT be executed immediately on subscription since home_map_info is None
765+ assert mock_callback .call_count == 0
766+
767+ # 1. Test notification on empty cache discovery
768+ mock_rpc_channel .send_command .side_effect = [
769+ UPDATED_STATUS_MAP_123 ,
770+ ROOM_MAPPING_DATA_MAP_123 ,
771+ UPDATED_STATUS_MAP_0 ,
772+ ROOM_MAPPING_DATA_MAP_0 ,
773+ ]
774+ mock_mqtt_rpc_channel .send_command .side_effect = [
775+ MULTI_MAP_LIST_DATA ,
776+ {},
777+ {},
778+ ]
779+ mock_map_rpc_channel .send_command .side_effect = [
780+ MAP_BYTES_RESPONSE_2 ,
781+ MAP_BYTES_RESPONSE_1 ,
782+ ]
783+ web_api_client .get_rooms .return_value = [
784+ HomeDataRoom (id = 2362048 , name = "Example room 1" ),
785+ HomeDataRoom (id = 2362044 , name = "Example room 2" ),
786+ ]
787+
788+ await home_trait .discover_home ()
789+ # Mock callback should have been called during discovery
790+ assert mock_callback .call_count > 0
791+ mock_callback .reset_mock ()
792+
793+ # 2. Test that registering a new listener now (when cache is populated) executes immediately
794+ mock_callback_immediate = MagicMock ()
795+ unsub_immediate = home_trait .add_update_listener (mock_callback_immediate )
796+ assert mock_callback_immediate .call_count == 1
797+ unsub_immediate ()
798+
799+ # 3. Test notification on cached discovery
800+ # Re-run discover_home (which skips API calls and loads from cache)
801+ await home_trait .discover_home ()
802+ assert mock_callback .call_count == 1
803+ mock_callback .reset_mock ()
804+
805+ # 4. Test notification on refresh
806+ mock_rpc_channel .send_command .side_effect = [
807+ ROOM_MAPPING_DATA_MAP_0 ,
808+ ]
809+ mock_mqtt_rpc_channel .send_command .side_effect = [
810+ MULTI_MAP_LIST_DATA ,
811+ ]
812+ mock_map_rpc_channel .send_command .side_effect = [
813+ MAP_BYTES_RESPONSE_1 ,
814+ ]
815+ await home_trait .refresh ()
816+ assert mock_callback .call_count > 0
817+
818+ # Unsubscribe and verify it is no longer called
819+ mock_callback .reset_mock ()
820+ unsub ()
821+ mock_rpc_channel .send_command .side_effect = [
822+ ROOM_MAPPING_DATA_MAP_0 ,
823+ ]
824+ mock_mqtt_rpc_channel .send_command .side_effect = [
825+ MULTI_MAP_LIST_DATA ,
826+ ]
827+ mock_map_rpc_channel .send_command .side_effect = [
828+ MAP_BYTES_RESPONSE_1 ,
829+ ]
830+ await home_trait .refresh ()
831+ assert mock_callback .call_count == 0
832+
833+
834+ async def test_home_trait_map_eviction (
835+ home_trait : HomeTrait ,
836+ mock_rpc_channel : AsyncMock ,
837+ mock_mqtt_rpc_channel : AsyncMock ,
838+ mock_map_rpc_channel : AsyncMock ,
839+ device_cache : DeviceCache ,
840+ web_api_client : AsyncMock ,
841+ ) -> None :
842+ """Test that maps deleted from the device are evicted from cache during refresh."""
843+ # Pre-populate cache with maps 0 and 123
844+ device_cache_data = DeviceCacheData (
845+ home_map_info = {
846+ 0 : CombinedMapInfo (map_flag = 0 , name = "Ground Floor" , rooms = []),
847+ 123 : CombinedMapInfo (map_flag = 123 , name = "Second Floor" , rooms = []),
848+ },
849+ home_map_content_base64 = {
850+ 0 : base64 .b64encode (MAP_BYTES_RESPONSE_1 ).decode ("utf-8" ),
851+ 123 : base64 .b64encode (MAP_BYTES_RESPONSE_2 ).decode ("utf-8" ),
852+ },
853+ )
854+ await device_cache .set (device_cache_data )
855+ await home_trait .discover_home ()
856+
857+ assert home_trait .home_map_info is not None
858+ assert home_trait .home_map_content is not None
859+ assert len (home_trait .home_map_info ) == 2
860+ assert len (home_trait .home_map_content ) == 2
861+
862+ # Set up listener callback
863+ mock_callback = MagicMock ()
864+ home_trait .add_update_listener (mock_callback )
865+ assert mock_callback .call_count == 1
866+ mock_callback .reset_mock ()
867+
868+ # Mock maps_trait.refresh so that only map 0 is returned (map 123 deleted)
869+ mock_mqtt_rpc_channel .send_command .side_effect = [
870+ MULTI_MAP_LIST_SINGLE_MAP_DATA , # Only has map 0
871+ ]
872+ mock_rpc_channel .send_command .side_effect = [
873+ ROOM_MAPPING_DATA_MAP_0 ,
874+ ]
875+ mock_map_rpc_channel .send_command .side_effect = [
876+ MAP_BYTES_RESPONSE_1 ,
877+ ]
878+
879+ await home_trait .refresh ()
880+
881+ # Verify map 123 is excluded from memory cache
882+ assert home_trait .home_map_info is not None
883+ assert home_trait .home_map_content is not None
884+ assert 123 not in home_trait .home_map_info
885+ assert 123 not in home_trait .home_map_content
886+ assert 0 in home_trait .home_map_info
887+
888+ # Verify listener notified
889+ assert mock_callback .call_count > 0
0 commit comments