From 2f93ba0d71bf5ed722b59c7930aef988985b91ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 Mar 2026 15:21:11 -1000 Subject: [PATCH] Add integration test for deprecated FanTraits compat layer --- .../legacy_fan_component/__init__.py | 1 + .../legacy_fan_component/fan/__init__.py | 19 +++++ .../legacy_fan_component/fan/legacy_fan.h | 49 ++++++++++++ .../fixtures/legacy_fan_compat.yaml | 21 +++++ tests/integration/test_legacy_fan_compat.py | 79 +++++++++++++++++++ 5 files changed, 169 insertions(+) create mode 100644 tests/integration/fixtures/external_components/legacy_fan_component/__init__.py create mode 100644 tests/integration/fixtures/external_components/legacy_fan_component/fan/__init__.py create mode 100644 tests/integration/fixtures/external_components/legacy_fan_component/fan/legacy_fan.h create mode 100644 tests/integration/fixtures/legacy_fan_compat.yaml create mode 100644 tests/integration/test_legacy_fan_compat.py diff --git a/tests/integration/fixtures/external_components/legacy_fan_component/__init__.py b/tests/integration/fixtures/external_components/legacy_fan_component/__init__.py new file mode 100644 index 0000000000..ceee477522 --- /dev/null +++ b/tests/integration/fixtures/external_components/legacy_fan_component/__init__.py @@ -0,0 +1 @@ +"""Legacy fan component — tests deprecated FanTraits setters backward compat.""" diff --git a/tests/integration/fixtures/external_components/legacy_fan_component/fan/__init__.py b/tests/integration/fixtures/external_components/legacy_fan_component/fan/__init__.py new file mode 100644 index 0000000000..72655dec95 --- /dev/null +++ b/tests/integration/fixtures/external_components/legacy_fan_component/fan/__init__.py @@ -0,0 +1,19 @@ +"""Legacy fan platform that uses deprecated FanTraits setters.""" + +import esphome.codegen as cg +from esphome.components import fan +import esphome.config_validation as cv + +legacy_fan_ns = cg.esphome_ns.namespace("legacy_fan_test") +LegacyFan = legacy_fan_ns.class_("LegacyFan", fan.Fan, cg.Component) + +CONFIG_SCHEMA = fan.FAN_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(LegacyFan), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + var = await fan.new_fan(config) + await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/legacy_fan_component/fan/legacy_fan.h b/tests/integration/fixtures/external_components/legacy_fan_component/fan/legacy_fan.h new file mode 100644 index 0000000000..3f8937905e --- /dev/null +++ b/tests/integration/fixtures/external_components/legacy_fan_component/fan/legacy_fan.h @@ -0,0 +1,49 @@ +#pragma once + +#include "esphome/components/fan/fan.h" +#include "esphome/core/component.h" + +namespace esphome { +namespace legacy_fan_test { + +/// Test fan that uses the DEPRECATED FanTraits setters for preset modes. +/// This validates backward compatibility for external components that haven't migrated. +class LegacyFan : public fan::Fan, public Component { + public: + void setup() override { + auto restore = this->restore_state_(); + if (restore.has_value()) { + restore->apply(*this); + } + this->publish_state(); + } + + float get_setup_priority() const override { return setup_priority::LATE; } + + fan::FanTraits get_traits() override { + auto traits = fan::FanTraits(false, true, false, 3); + + // DEPRECATED API: setting preset modes directly on FanTraits. +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + traits.set_supported_preset_modes({"Turbo", "Silent", "Eco"}); +#pragma GCC diagnostic pop + + return traits; + } + + protected: + void control(const fan::FanCall &call) override { + if (call.get_state().has_value()) { + this->state = *call.get_state(); + } + if (call.get_speed().has_value()) { + this->speed = *call.get_speed(); + } + this->apply_preset_mode_(call); + this->publish_state(); + } +}; + +} // namespace legacy_fan_test +} // namespace esphome diff --git a/tests/integration/fixtures/legacy_fan_compat.yaml b/tests/integration/fixtures/legacy_fan_compat.yaml new file mode 100644 index 0000000000..d510f8adea --- /dev/null +++ b/tests/integration/fixtures/legacy_fan_compat.yaml @@ -0,0 +1,21 @@ +esphome: + name: legacy-fan-compat + platformio_options: + build_flags: + - "-DUSE_HOST" + +host: +api: +logger: + level: DEBUG + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + components: [legacy_fan_component] + +fan: + - platform: legacy_fan_component + name: "Legacy Fan" + id: legacy_fan diff --git a/tests/integration/test_legacy_fan_compat.py b/tests/integration/test_legacy_fan_compat.py new file mode 100644 index 0000000000..4ac8585e5a --- /dev/null +++ b/tests/integration/test_legacy_fan_compat.py @@ -0,0 +1,79 @@ +"""Integration test for backward compatibility of deprecated FanTraits setters. + +Verifies that external components using the old traits.set_supported_preset_modes() +API still work correctly during the deprecation period (removed in 2026.11.0). +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import FanInfo, FanState +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_legacy_fan_compat( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that deprecated FanTraits preset mode setters still work end-to-end.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + async with run_compiled(yaml_config), api_client_connected() as client: + entities, services = await client.list_entities_services() + + fan_infos = [e for e in entities if isinstance(e, FanInfo)] + assert len(fan_infos) == 1, f"Expected 1 fan entity, got {len(fan_infos)}" + + test_fan = fan_infos[0] + + # Verify preset modes set via deprecated FanTraits setter are exposed + assert set(test_fan.supported_preset_modes) == { + "Turbo", + "Silent", + "Eco", + }, ( + f"Expected preset modes {{Turbo, Silent, Eco}}, " + f"got {test_fan.supported_preset_modes}" + ) + + # Verify speed support + assert test_fan.supports_speed is True + assert test_fan.supported_speed_count == 3 + + # Subscribe and wait for initial states + states: dict[int, FanState] = {} + state_event = asyncio.Event() + + def on_state(state: FanState) -> None: + if isinstance(state, FanState): + states[state.key] = state + state_event.set() + + client.subscribe_states(on_state) + + # Wait for initial state + await asyncio.wait_for(state_event.wait(), timeout=5.0) + + # Turn on fan with preset mode (tests find_preset_mode_ compat path) + state_event.clear() + client.fan_command( + key=test_fan.key, + state=True, + preset_mode="Turbo", + ) + await asyncio.wait_for(state_event.wait(), timeout=5.0) + + fan_state = states[test_fan.key] + assert fan_state.state is True + assert fan_state.preset_mode == "Turbo"