diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 2cd4319325..3ade00f0cd 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -d7693a1e996cacd4a3d1c9a16336799c2a8cc3db02e4e74084151ce964581248 +3d46b63015d761c85ca9cb77ab79a389509e5776701fb22aed16e7b79d432c0c diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 1670bd1821..4e2f086f47 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -53,6 +53,7 @@ jobs: 'new-target-platform', 'merging-to-release', 'merging-to-beta', + 'chained-pr', 'core', 'small-pr', 'dashboard', @@ -140,6 +141,8 @@ jobs: labels.add('merging-to-release'); } else if (baseRef === 'beta') { labels.add('merging-to-beta'); + } else if (baseRef !== 'dev') { + labels.add('chained-pr'); } return labels; @@ -528,8 +531,8 @@ jobs: const apiData = await fetchApiData(); const baseRef = context.payload.pull_request.base.ref; - // Early exit for non-dev branches - if (baseRef !== 'dev') { + // Early exit for release and beta branches only + if (baseRef === 'release' || baseRef === 'beta') { const branchLabels = await detectMergeBranch(); const finalLabels = Array.from(branchLabels); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f085aedcc0..cb04f6bf8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -178,6 +178,8 @@ jobs: component-test-count: ${{ steps.determine.outputs.component-test-count }} changed-cpp-file-count: ${{ steps.determine.outputs.changed-cpp-file-count }} memory_impact: ${{ steps.determine.outputs.memory-impact }} + cpp-unit-tests-run-all: ${{ steps.determine.outputs.cpp-unit-tests-run-all }} + cpp-unit-tests-components: ${{ steps.determine.outputs.cpp-unit-tests-components }} steps: - name: Check out code from GitHub uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 @@ -210,6 +212,8 @@ jobs: echo "component-test-count=$(echo "$output" | jq -r '.component_test_count')" >> $GITHUB_OUTPUT echo "changed-cpp-file-count=$(echo "$output" | jq -r '.changed_cpp_file_count')" >> $GITHUB_OUTPUT echo "memory-impact=$(echo "$output" | jq -c '.memory_impact')" >> $GITHUB_OUTPUT + echo "cpp-unit-tests-run-all=$(echo "$output" | jq -r '.cpp_unit_tests_run_all')" >> $GITHUB_OUTPUT + echo "cpp-unit-tests-components=$(echo "$output" | jq -c '.cpp_unit_tests_components')" >> $GITHUB_OUTPUT integration-tests: name: Run integration tests @@ -247,6 +251,33 @@ jobs: . venv/bin/activate pytest -vv --no-cov --tb=native -n auto tests/integration/ + cpp-unit-tests: + name: Run C++ unit tests + runs-on: ubuntu-24.04 + needs: + - common + - determine-jobs + if: github.event_name == 'pull_request' && (needs.determine-jobs.outputs.cpp-unit-tests-run-all == 'true' || needs.determine-jobs.outputs.cpp-unit-tests-components != '[]') + steps: + - name: Check out code from GitHub + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + + - name: Run cpp_unit_test.py + run: | + . venv/bin/activate + if [ "${{ needs.determine-jobs.outputs.cpp-unit-tests-run-all }}" = "true" ]; then + script/cpp_unit_test.py --all + else + ARGS=$(echo '${{ needs.determine-jobs.outputs.cpp-unit-tests-components }}' | jq -r '.[] | @sh' | xargs) + script/cpp_unit_test.py $ARGS + fi + clang-tidy-single: name: ${{ matrix.name }} runs-on: ubuntu-24.04 diff --git a/.github/workflows/status-check-labels.yml b/.github/workflows/status-check-labels.yml index e44fd18132..cca70815b9 100644 --- a/.github/workflows/status-check-labels.yml +++ b/.github/workflows/status-check-labels.yml @@ -14,6 +14,7 @@ jobs: label: - needs-docs - merge-after-release + - chained-pr steps: - name: Check for ${{ matrix.label }} label uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 99a87e06f9..cb6354cc74 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -550,6 +550,32 @@ CONF_ENABLE_LWIP_BRIDGE_INTERFACE = "enable_lwip_bridge_interface" CONF_ENABLE_LWIP_TCPIP_CORE_LOCKING = "enable_lwip_tcpip_core_locking" CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY = "enable_lwip_check_thread_safety" CONF_DISABLE_LIBC_LOCKS_IN_IRAM = "disable_libc_locks_in_iram" +CONF_DISABLE_VFS_SUPPORT_TERMIOS = "disable_vfs_support_termios" +CONF_DISABLE_VFS_SUPPORT_SELECT = "disable_vfs_support_select" +CONF_DISABLE_VFS_SUPPORT_DIR = "disable_vfs_support_dir" + +# VFS requirement tracking +# Components that need VFS features can call require_vfs_select() or require_vfs_dir() +KEY_VFS_SELECT_REQUIRED = "vfs_select_required" +KEY_VFS_DIR_REQUIRED = "vfs_dir_required" + + +def require_vfs_select() -> None: + """Mark that VFS select support is required by a component. + + Call this from components that use esp_vfs_eventfd or other VFS select features. + This prevents CONFIG_VFS_SUPPORT_SELECT from being disabled. + """ + CORE.data[KEY_VFS_SELECT_REQUIRED] = True + + +def require_vfs_dir() -> None: + """Mark that VFS directory support is required by a component. + + Call this from components that use directory functions (opendir, readdir, mkdir, etc.). + This prevents CONFIG_VFS_SUPPORT_DIR from being disabled. + """ + CORE.data[KEY_VFS_DIR_REQUIRED] = True def _validate_idf_component(config: ConfigType) -> ConfigType: @@ -615,6 +641,13 @@ FRAMEWORK_SCHEMA = cv.All( cv.Optional( CONF_DISABLE_LIBC_LOCKS_IN_IRAM, default=True ): cv.boolean, + cv.Optional( + CONF_DISABLE_VFS_SUPPORT_TERMIOS, default=True + ): cv.boolean, + cv.Optional( + CONF_DISABLE_VFS_SUPPORT_SELECT, default=True + ): cv.boolean, + cv.Optional(CONF_DISABLE_VFS_SUPPORT_DIR, default=True): cv.boolean, cv.Optional(CONF_EXECUTE_FROM_PSRAM): cv.boolean, } ), @@ -962,6 +995,43 @@ async def to_code(config): if advanced.get(CONF_DISABLE_LIBC_LOCKS_IN_IRAM, True): add_idf_sdkconfig_option("CONFIG_LIBC_LOCKS_PLACE_IN_IRAM", False) + # Disable VFS support for termios (terminal I/O functions) + # ESPHome doesn't use termios functions on ESP32 (only used in host UART driver). + # Saves approximately 1.8KB of flash when disabled (default). + add_idf_sdkconfig_option( + "CONFIG_VFS_SUPPORT_TERMIOS", + not advanced.get(CONF_DISABLE_VFS_SUPPORT_TERMIOS, True), + ) + + # Disable VFS support for select() with file descriptors + # ESPHome only uses select() with sockets via lwip_select(), which still works. + # VFS select is only needed for UART/eventfd file descriptors. + # Components that need it (e.g., openthread) call require_vfs_select(). + # Saves approximately 2.7KB of flash when disabled (default). + if CORE.data.get(KEY_VFS_SELECT_REQUIRED, False): + # Component requires VFS select - force enable regardless of user setting + add_idf_sdkconfig_option("CONFIG_VFS_SUPPORT_SELECT", True) + else: + # No component needs it - allow user to control (default: disabled) + add_idf_sdkconfig_option( + "CONFIG_VFS_SUPPORT_SELECT", + not advanced.get(CONF_DISABLE_VFS_SUPPORT_SELECT, True), + ) + + # Disable VFS support for directory functions (opendir, readdir, mkdir, etc.) + # ESPHome doesn't use directory functions on ESP32. + # Components that need it (e.g., storage components) call require_vfs_dir(). + # Saves approximately 0.5KB+ of flash when disabled (default). + if CORE.data.get(KEY_VFS_DIR_REQUIRED, False): + # Component requires VFS directory support - force enable regardless of user setting + add_idf_sdkconfig_option("CONFIG_VFS_SUPPORT_DIR", True) + else: + # No component needs it - allow user to control (default: disabled) + add_idf_sdkconfig_option( + "CONFIG_VFS_SUPPORT_DIR", + not advanced.get(CONF_DISABLE_VFS_SUPPORT_DIR, True), + ) + cg.add_platformio_option("board_build.partitions", "partitions.csv") if CONF_PARTITIONS in config: add_extra_build_file( diff --git a/esphome/components/gpio/switch/gpio_switch.cpp b/esphome/components/gpio/switch/gpio_switch.cpp index b67af5e95d..9043a6a493 100644 --- a/esphome/components/gpio/switch/gpio_switch.cpp +++ b/esphome/components/gpio/switch/gpio_switch.cpp @@ -67,7 +67,7 @@ void GPIOSwitch::write_state(bool state) { this->pin_->digital_write(state); this->publish_state(state); } -void GPIOSwitch::set_interlock(const std::vector &interlock) { this->interlock_ = interlock; } +void GPIOSwitch::set_interlock(const std::initializer_list &interlock) { this->interlock_ = interlock; } } // namespace gpio } // namespace esphome diff --git a/esphome/components/gpio/switch/gpio_switch.h b/esphome/components/gpio/switch/gpio_switch.h index 94d49745b5..080decac08 100644 --- a/esphome/components/gpio/switch/gpio_switch.h +++ b/esphome/components/gpio/switch/gpio_switch.h @@ -2,10 +2,9 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/components/switch/switch.h" -#include - namespace esphome { namespace gpio { @@ -19,14 +18,14 @@ class GPIOSwitch : public switch_::Switch, public Component { void setup() override; void dump_config() override; - void set_interlock(const std::vector &interlock); + void set_interlock(const std::initializer_list &interlock); void set_interlock_wait_time(uint32_t interlock_wait_time) { interlock_wait_time_ = interlock_wait_time; } protected: void write_state(bool state) override; GPIOPin *pin_; - std::vector interlock_; + FixedVector interlock_; uint32_t interlock_wait_time_{0}; }; diff --git a/esphome/components/light/esp_color_correction.h b/esphome/components/light/esp_color_correction.h index 979a1acb07..14c065058c 100644 --- a/esphome/components/light/esp_color_correction.h +++ b/esphome/components/light/esp_color_correction.h @@ -17,19 +17,19 @@ class ESPColorCorrection { this->color_correct_blue(color.blue), this->color_correct_white(color.white)); } inline uint8_t color_correct_red(uint8_t red) const ESPHOME_ALWAYS_INLINE { - uint8_t res = esp_scale8(esp_scale8(red, this->max_brightness_.red), this->local_brightness_); + uint8_t res = esp_scale8_twice(red, this->max_brightness_.red, this->local_brightness_); return this->gamma_table_[res]; } inline uint8_t color_correct_green(uint8_t green) const ESPHOME_ALWAYS_INLINE { - uint8_t res = esp_scale8(esp_scale8(green, this->max_brightness_.green), this->local_brightness_); + uint8_t res = esp_scale8_twice(green, this->max_brightness_.green, this->local_brightness_); return this->gamma_table_[res]; } inline uint8_t color_correct_blue(uint8_t blue) const ESPHOME_ALWAYS_INLINE { - uint8_t res = esp_scale8(esp_scale8(blue, this->max_brightness_.blue), this->local_brightness_); + uint8_t res = esp_scale8_twice(blue, this->max_brightness_.blue, this->local_brightness_); return this->gamma_table_[res]; } inline uint8_t color_correct_white(uint8_t white) const ESPHOME_ALWAYS_INLINE { - uint8_t res = esp_scale8(esp_scale8(white, this->max_brightness_.white), this->local_brightness_); + uint8_t res = esp_scale8_twice(white, this->max_brightness_.white, this->local_brightness_); return this->gamma_table_[res]; } inline Color color_uncorrect(Color color) const ESPHOME_ALWAYS_INLINE { diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 3fac497c3d..4865399d02 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -4,6 +4,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32H2, add_idf_sdkconfig_option, only_on_variant, + require_vfs_select, ) from esphome.components.mdns import MDNSComponent, enable_mdns_storage import esphome.config_validation as cv @@ -106,6 +107,14 @@ _CONNECTION_SCHEMA = cv.Schema( } ) + +def _require_vfs_select(config): + """Register VFS select requirement during config validation.""" + # OpenThread uses esp_vfs_eventfd which requires VFS select support + require_vfs_select() + return config + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -122,6 +131,7 @@ CONFIG_SCHEMA = cv.All( cv.has_exactly_one_key(CONF_NETWORK_KEY, CONF_TLV), cv.only_with_esp_idf, only_on_variant(supported=[VARIANT_ESP32C6, VARIANT_ESP32H2]), + _require_vfs_select, ) diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index b2bf2bacf1..7a453dbb50 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -56,6 +56,13 @@ uint32_t ESP8266UartComponent::get_config() { } void ESP8266UartComponent::setup() { + if (this->rx_pin_) { + this->rx_pin_->setup(); + } + if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) { + this->tx_pin_->setup(); + } + // Use Arduino HardwareSerial UARTs if all used pins match the ones // preconfigured by the platform. For example if RX disabled but TX pin // is 1 we still want to use Serial. diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index 7530856b1e..cffa3308eb 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -6,6 +6,9 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/gpio.h" +#include "driver/gpio.h" +#include "soc/gpio_num.h" #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" @@ -104,6 +107,13 @@ void IDFUARTComponent::load_settings(bool dump_config) { return; } + if (this->rx_pin_) { + this->rx_pin_->setup(); + } + if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) { + this->tx_pin_->setup(); + } + int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1; int8_t rx = this->rx_pin_ != nullptr ? this->rx_pin_->get_pin() : -1; int8_t flow_control = this->flow_control_pin_ != nullptr ? this->flow_control_pin_->get_pin() : -1; diff --git a/esphome/components/uart/uart_component_libretiny.cpp b/esphome/components/uart/uart_component_libretiny.cpp index 8a7a301cfe..9c065fe5df 100644 --- a/esphome/components/uart/uart_component_libretiny.cpp +++ b/esphome/components/uart/uart_component_libretiny.cpp @@ -46,6 +46,13 @@ uint16_t LibreTinyUARTComponent::get_config() { } void LibreTinyUARTComponent::setup() { + if (this->rx_pin_) { + this->rx_pin_->setup(); + } + if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) { + this->tx_pin_->setup(); + } + int8_t tx_pin = tx_pin_ == nullptr ? -1 : tx_pin_->get_pin(); int8_t rx_pin = rx_pin_ == nullptr ? -1 : rx_pin_->get_pin(); bool tx_inverted = tx_pin_ != nullptr && tx_pin_->is_inverted(); diff --git a/esphome/components/uart/uart_component_rp2040.cpp b/esphome/components/uart/uart_component_rp2040.cpp index ae3042fb77..c78691653d 100644 --- a/esphome/components/uart/uart_component_rp2040.cpp +++ b/esphome/components/uart/uart_component_rp2040.cpp @@ -52,6 +52,13 @@ uint16_t RP2040UartComponent::get_config() { } void RP2040UartComponent::setup() { + if (this->rx_pin_) { + this->rx_pin_->setup(); + } + if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) { + this->tx_pin_->setup(); + } + uint16_t config = get_config(); constexpr uint32_t valid_tx_uart_0 = __bitset({0, 12, 16, 28}); diff --git a/esphome/core/color.h b/esphome/core/color.h index 5dce58a485..4b0ae5b57a 100644 --- a/esphome/core/color.h +++ b/esphome/core/color.h @@ -14,6 +14,15 @@ inline static constexpr uint8_t esp_scale8(uint8_t i, uint8_t scale) { return (uint16_t(i) * (1 + uint16_t(scale))) / 256; } +/// Scale an 8-bit value by two 8-bit scale factors with improved precision. +/// This is more accurate than calling esp_scale8() twice because it delays +/// truncation until after both multiplications, preserving intermediate precision. +/// For example: esp_scale8_twice(value, max_brightness, local_brightness) +/// gives better results than esp_scale8(esp_scale8(value, max_brightness), local_brightness) +inline static constexpr uint8_t esp_scale8_twice(uint8_t i, uint8_t scale1, uint8_t scale2) { + return (uint32_t(i) * (1 + uint32_t(scale1)) * (1 + uint32_t(scale2))) >> 16; +} + struct Color { union { struct { diff --git a/platformio.ini b/platformio.ini index 6b2a8657bb..94f58f84ab 100644 --- a/platformio.ini +++ b/platformio.ini @@ -46,6 +46,10 @@ lib_deps = ; This is using the repository until a new release is published to PlatformIO https://github.com/Sensirion/arduino-gas-index-algorithm.git#3.2.1 ; Sensirion Gas Index Algorithm Arduino Library lvgl/lvgl@8.4.0 ; lvgl + ; This dependency is used only in unit tests. + ; Must coincide with PLATFORMIO_GOOGLE_TEST_LIB in scripts/cpp_unit_test.py + ; See scripts/cpp_unit_test.py and tests/components/README.md + google/googletest@^1.15.2 build_flags = -DESPHOME_LOG_LEVEL=ESPHOME_LOG_LEVEL_VERY_VERBOSE -std=gnu++20 diff --git a/script/cpp_unit_test.py b/script/cpp_unit_test.py new file mode 100755 index 0000000000..e97b5bd7b0 --- /dev/null +++ b/script/cpp_unit_test.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +import argparse +import hashlib +import os +from pathlib import Path +import subprocess +import sys + +from helpers import get_all_components, get_all_dependencies, root_path + +from esphome.__main__ import command_compile, parse_args +from esphome.config import validate_config +from esphome.core import CORE +from esphome.platformio_api import get_idedata + +# This must coincide with the version in /platformio.ini +PLATFORMIO_GOOGLE_TEST_LIB = "google/googletest@^1.15.2" + +# Path to /tests/components +COMPONENTS_TESTS_DIR: Path = Path(root_path) / "tests" / "components" + + +def hash_components(components: list[str]) -> str: + key = ",".join(components) + return hashlib.sha256(key.encode()).hexdigest()[:16] + + +def filter_components_without_tests(components: list[str]) -> list[str]: + """Filter out components that do not have a corresponding test file. + + This is done by checking if the component's directory contains at + least a .cpp file. + """ + filtered_components: list[str] = [] + for component in components: + test_dir = COMPONENTS_TESTS_DIR / component + if test_dir.is_dir() and any(test_dir.glob("*.cpp")): + filtered_components.append(component) + else: + print( + f"WARNING: No tests found for component '{component}', skipping.", + file=sys.stderr, + ) + return filtered_components + + +def create_test_config(config_name: str, includes: list[str]) -> dict: + """Create ESPHome test configuration for C++ unit tests. + + Args: + config_name: Unique name for this test configuration + includes: List of include folders for the test build + + Returns: + Configuration dict for ESPHome + """ + return { + "esphome": { + "name": config_name, + "friendly_name": "CPP Unit Tests", + "libraries": PLATFORMIO_GOOGLE_TEST_LIB, + "platformio_options": { + "build_type": "debug", + "build_unflags": [ + "-Os", # remove size-opt flag + ], + "build_flags": [ + "-Og", # optimize for debug + ], + "debug_build_flags": [ # only for debug builds + "-g3", # max debug info + "-ggdb3", + ], + }, + "includes": includes, + }, + "host": {}, + "logger": {"level": "DEBUG"}, + } + + +def run_tests(selected_components: list[str]) -> int: + # Skip tests on Windows + if os.name == "nt": + print("Skipping esphome tests on Windows", file=sys.stderr) + return 1 + + # Remove components that do not have tests + components = filter_components_without_tests(selected_components) + + if len(components) == 0: + print( + "No components specified or no tests found for the specified components.", + file=sys.stderr, + ) + return 0 + + components = sorted(components) + + # Obtain possible dependencies for the requested components: + components_with_dependencies = sorted(get_all_dependencies(set(components))) + + # Build a list of include folders, one folder per component containing tests. + # A special replacement main.cpp is located in /tests/components/main.cpp + includes: list[str] = ["main.cpp"] + components + + # Create a unique name for this config based on the actual components being tested + # to maximize cache during testing + config_name: str = "cpptests-" + hash_components(components) + + config = create_test_config(config_name, includes) + + CORE.config_path = COMPONENTS_TESTS_DIR / "dummy.yaml" + CORE.dashboard = None + + # Validate config will expand the above with defaults: + config = validate_config(config, {}) + + # Add all components and dependencies to the base configuration after validation, so their files + # are added to the build. + config.update({key: {} for key in components_with_dependencies}) + + print(f"Testing components: {', '.join(components)}") + CORE.config = config + args = parse_args(["program", "compile", str(CORE.config_path)]) + try: + exit_code: int = command_compile(args, config) + + if exit_code != 0: + print(f"Error compiling unit tests for {', '.join(components)}") + return exit_code + except Exception as e: + print( + f"Error compiling unit tests for {', '.join(components)}. Check path. : {e}" + ) + return 2 + + # After a successful compilation, locate the executable and run it: + idedata = get_idedata(config) + if idedata is None: + print("Cannot find executable") + return 1 + + program_path: str = idedata.raw["prog_path"] + run_cmd: list[str] = [program_path] + run_proc = subprocess.run(run_cmd, check=False) + return run_proc.returncode + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Run C++ unit tests for ESPHome components." + ) + parser.add_argument( + "components", + nargs="*", + help="List of components to test. Use --all to test all known components.", + ) + parser.add_argument("--all", action="store_true", help="Test all known components.") + + args = parser.parse_args() + + if args.all: + components: list[str] = get_all_components() + else: + components: list[str] = args.components + + sys.exit(run_tests(components)) + + +if __name__ == "__main__": + main() diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 9721fd9756..6651553ce7 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -52,13 +52,16 @@ from helpers import ( CPP_FILE_EXTENSIONS, PYTHON_FILE_EXTENSIONS, changed_files, - filter_component_files, + core_changed, + filter_component_and_test_cpp_files, + filter_component_and_test_files, get_all_dependencies, get_changed_components, get_component_from_path, get_component_test_files, get_components_from_integration_fixtures, get_components_with_dependencies, + get_cpp_changed_components, git_ls_files, parse_test_filename, root_path, @@ -143,10 +146,9 @@ def should_run_integration_tests(branch: str | None = None) -> bool: """ files = changed_files(branch) - # Check if any core files changed (esphome/core/*) - for file in files: - if file.startswith("esphome/core/"): - return True + if core_changed(files): + # If any core files changed, run integration tests + return True # Check if any integration test files changed if any("tests/integration" in file for file in files): @@ -283,6 +285,40 @@ def should_run_python_linters(branch: str | None = None) -> bool: return _any_changed_file_endswith(branch, PYTHON_FILE_EXTENSIONS) +def determine_cpp_unit_tests( + branch: str | None = None, +) -> tuple[bool, list[str]]: + """Determine if C++ unit tests should run based on changed files. + + This function is used by the CI workflow to skip C++ unit tests when + no relevant files have changed, saving CI time and resources. + + C++ unit tests will run when any of the following conditions are met: + + 1. Any C++ core source files changed (esphome/core/*), in which case + all cpp unit tests run. + 2. A test file for a component changed, which triggers tests for that + component. + 3. The code for a component changed, which triggers tests for that + component and all components that depend on it. + + Args: + branch: Branch to compare against. If None, uses default. + + Returns: + Tuple of (run_all, components) where: + - run_all: True if all tests should run, False otherwise + - components: List of specific components to test (empty if run_all) + """ + files = changed_files(branch) + if core_changed(files): + return (True, []) + + # Filter to only C++ files + cpp_files = list(filter(filter_component_and_test_cpp_files, files)) + return (False, get_cpp_changed_components(cpp_files)) + + def _any_changed_file_endswith(branch: str | None, extensions: tuple[str, ...]) -> bool: """Check if a changed file ends with any of the specified extensions.""" return any(file.endswith(extensions) for file in changed_files(branch)) @@ -579,7 +615,7 @@ def main() -> None: else: # Get both directly changed and all changed (with dependencies) changed = changed_files(args.branch) - component_files = [f for f in changed if filter_component_files(f)] + component_files = [f for f in changed if filter_component_and_test_files(f)] directly_changed_components = get_components_with_dependencies( component_files, False @@ -646,6 +682,9 @@ def main() -> None: files_to_check_count = 0 # Build output + # Determine which C++ unit tests to run + cpp_run_all, cpp_components = determine_cpp_unit_tests(args.branch) + output: dict[str, Any] = { "integration_tests": run_integration, "clang_tidy": run_clang_tidy, @@ -661,6 +700,8 @@ def main() -> None: "dependency_only_count": len(dependency_only_components), "changed_cpp_file_count": changed_cpp_file_count, "memory_impact": memory_impact, + "cpp_unit_tests_run_all": cpp_run_all, + "cpp_unit_tests_components": cpp_components, } # Output as JSON diff --git a/script/extract_automations.py b/script/extract_automations.py index 943eb7110a..4e650ce25f 100755 --- a/script/extract_automations.py +++ b/script/extract_automations.py @@ -2,19 +2,14 @@ import json -from helpers import git_ls_files +from helpers import get_all_component_files, get_components_with_dependencies from esphome.automation import ACTION_REGISTRY, CONDITION_REGISTRY from esphome.pins import PIN_SCHEMA_REGISTRY -list_components = __import__("list-components") - - if __name__ == "__main__": - files = git_ls_files() - files = filter(list_components.filter_component_files, files) - - components = list_components.get_components(files, True) + files = get_all_component_files() + components = get_components_with_dependencies(files, True) dump = { "actions": sorted(list(ACTION_REGISTRY.keys())), diff --git a/script/helpers.py b/script/helpers.py index 6b2bb2daef..78c11b427e 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -25,12 +25,21 @@ CPP_FILE_EXTENSIONS = (".cpp", ".h", ".hpp", ".cc", ".cxx", ".c", ".tcc") # Python file extensions PYTHON_FILE_EXTENSIONS = (".py", ".pyi") +# Combined C++ and Python file extensions for convenience +CPP_AND_PYTHON_FILE_EXTENSIONS = (*CPP_FILE_EXTENSIONS, *PYTHON_FILE_EXTENSIONS) + # YAML file extensions YAML_FILE_EXTENSIONS = (".yaml", ".yml") # Component path prefix ESPHOME_COMPONENTS_PATH = "esphome/components/" +# Test components path prefix +ESPHOME_TESTS_COMPONENTS_PATH = "tests/components/" + +# Tuple of component and test paths for efficient startswith checks +COMPONENT_AND_TESTS_PATHS = (ESPHOME_COMPONENTS_PATH, ESPHOME_TESTS_COMPONENTS_PATH) + # Base bus components - these ARE the bus implementations and should not # be flagged as needing migration since they are the platform/base components BASE_BUS_COMPONENTS = { @@ -658,17 +667,32 @@ def get_components_from_integration_fixtures() -> set[str]: return components -def filter_component_files(file_path: str) -> bool: - """Check if a file path is a component file. +def filter_component_and_test_files(file_path: str) -> bool: + """Check if a file path is a component or test file. Args: file_path: Path to check Returns: - True if the file is in a component directory + True if the file is in a component or test directory """ - return file_path.startswith("esphome/components/") or file_path.startswith( - "tests/components/" + return file_path.startswith(COMPONENT_AND_TESTS_PATHS) or ( + file_path.startswith(ESPHOME_TESTS_COMPONENTS_PATH) + and file_path.endswith(YAML_FILE_EXTENSIONS) + ) + + +def filter_component_and_test_cpp_files(file_path: str) -> bool: + """Check if a file is a C++ source file in component or test directories. + + Args: + file_path: Path to check + + Returns: + True if the file is a C++ source/header file in component or test directories + """ + return file_path.endswith(CPP_FILE_EXTENSIONS) and file_path.startswith( + COMPONENT_AND_TESTS_PATHS ) @@ -740,7 +764,7 @@ def create_components_graph() -> dict[str, list[str]]: # The root directory of the repo root = Path(__file__).parent.parent - components_dir = root / "esphome" / "components" + components_dir = root / ESPHOME_COMPONENTS_PATH # Fake some directory so that get_component works CORE.config_path = root # Various configuration to capture different outcomes used by `AUTO_LOAD` function. @@ -873,3 +897,81 @@ def get_components_with_dependencies( return sorted(all_changed_components) return sorted(components) + + +def get_all_component_files() -> list[str]: + """Get all component and test files from git. + + Returns: + List of all component and test file paths + """ + files = git_ls_files() + return list(filter(filter_component_and_test_files, files)) + + +def get_all_components() -> list[str]: + """Get all component names. + + This function uses git to find all component files and extracts the component names. + It returns the same list as calling list-components.py without arguments. + + Returns: + List of all component names + """ + return get_components_with_dependencies(get_all_component_files(), False) + + +def core_changed(files: list[str]) -> bool: + """Check if any core C++ or Python files have changed. + + Args: + files: List of file paths to check + + Returns: + True if any core C++ or Python files have changed + """ + return any( + f.startswith("esphome/core/") and f.endswith(CPP_AND_PYTHON_FILE_EXTENSIONS) + for f in files + ) + + +def get_cpp_changed_components(files: list[str]) -> list[str]: + """Get components that have changed C++ files or tests. + + This function analyzes a list of changed files and determines which components + are affected. It handles two scenarios: + + 1. Test files changed (tests/components//*.cpp): + - Adds the component to the affected list + - Only that component needs to be tested + + 2. Component C++ files changed (esphome/components//*): + - Adds the component to the affected list + - Also adds all components that depend on this component (recursively) + - This ensures that changes propagate to dependent components + + Args: + files: List of file paths to analyze (should be C++ files) + + Returns: + Sorted list of component names that need C++ unit tests run + """ + components_graph = create_components_graph() + affected: set[str] = set() + for file in files: + if not file.endswith(CPP_FILE_EXTENSIONS): + continue + if file.startswith(ESPHOME_TESTS_COMPONENTS_PATH): + parts = file.split("/") + if len(parts) >= 4: + component_dir = Path(ESPHOME_TESTS_COMPONENTS_PATH) / parts[2] + if component_dir.is_dir(): + affected.add(parts[2]) + elif file.startswith(ESPHOME_COMPONENTS_PATH): + parts = file.split("/") + if len(parts) >= 4: + component = parts[2] + affected.update(find_children_of_component(components_graph, component)) + affected.add(component) + return sorted(affected) diff --git a/script/list-components.py b/script/list-components.py index d768256c71..31a1609f88 100755 --- a/script/list-components.py +++ b/script/list-components.py @@ -3,18 +3,14 @@ import argparse from helpers import ( changed_files, - filter_component_files, + filter_component_and_test_cpp_files, + filter_component_and_test_files, + get_all_component_files, get_components_with_dependencies, - git_ls_files, + get_cpp_changed_components, ) -def get_all_component_files() -> list[str]: - """Get all component files from git.""" - files = git_ls_files() - return list(filter(filter_component_files, files)) - - def main(): parser = argparse.ArgumentParser() parser.add_argument( @@ -39,16 +35,29 @@ def main(): parser.add_argument( "-b", "--branch", help="Branch to compare changed files against" ) + parser.add_argument( + "--cpp-changed", + action="store_true", + help="List components with changed C++ files", + ) args = parser.parse_args() if args.branch and not ( - args.changed or args.changed_direct or args.changed_with_deps + args.changed + or args.changed_direct + or args.changed_with_deps + or args.cpp_changed ): parser.error( - "--branch requires --changed, --changed-direct, or --changed-with-deps" + "--branch requires --changed, --changed-direct, --changed-with-deps, or --cpp-changed" ) - if args.changed or args.changed_direct or args.changed_with_deps: + if ( + args.changed + or args.changed_direct + or args.changed_with_deps + or args.cpp_changed + ): # When --changed* is passed, only get the changed files changed = changed_files(args.branch) @@ -68,6 +77,11 @@ def main(): # - --changed-with-deps: Used by CI test determination (script/determine-jobs.py) # Returns: Components with code changes + their dependencies (not infrastructure) # Reason: CI needs to test changed components and their dependents + # + # - --cpp-changed: Used by CI to determine if any C++ files changed (script/determine-jobs.py) + # Returns: Only components with changed C++ files + # Reason: Only components with C++ changes need C++ testing + base_test_changed = any( "tests/test_build_components" in file for file in changed ) @@ -80,7 +94,7 @@ def main(): # Only look at changed component files (ignore infrastructure changes) # For --changed-direct: only actual component code changes matter (for isolation) # For --changed-with-deps: only actual component code changes matter (for testing) - files = [f for f in changed if filter_component_files(f)] + files = [f for f in changed if filter_component_and_test_files(f)] else: # Get all component files files = get_all_component_files() @@ -100,6 +114,11 @@ def main(): # Return only directly changed components (without dependencies) for c in get_components_with_dependencies(files, False): print(c) + elif args.cpp_changed: + # Only look at changed cpp files + files = list(filter(filter_component_and_test_cpp_files, changed)) + for c in get_cpp_changed_components(files): + print(c) else: # Return all changed components (with dependencies) - default behavior for c in get_components_with_dependencies(files, args.changed): diff --git a/tests/components/.gitignore b/tests/components/.gitignore new file mode 100644 index 0000000000..d8b4157aef --- /dev/null +++ b/tests/components/.gitignore @@ -0,0 +1,5 @@ +# Gitignore settings for ESPHome +# This is an example and may include too much for your use-case. +# You can modify this file to suit your needs. +/.esphome/ +/secrets.yaml diff --git a/tests/components/README.md b/tests/components/README.md new file mode 100644 index 0000000000..0901f2ef17 --- /dev/null +++ b/tests/components/README.md @@ -0,0 +1,32 @@ +# How to write C++ ESPHome unit tests + +1. Locate the folder with your component or create a new one with the same name as the component. +2. Write the tests. You can add as many `.cpp` and `.h` files as you need to organize your tests. + +**IMPORTANT**: wrap all your testing code in a unique namespace to avoid linker collisions when compiling +testing binaries that combine many components. By convention, this unique namespace is `esphome::component::testing` +(where "component" is the component under test), for example: `esphome::uart::testing`. + + +## Running component unit tests + +(from the repository root) +```bash +./script/cpp_unit_test.py component1 component2 ... +``` + +The above will compile and run the provided components and their tests. + +To run all tests, you can invoke `cpp_unit_test.py` with the special `--all` flag: + +```bash +./script/cpp_unit_test.py --all +``` + +To run a specific test suite, you can provide a Google Test filter: + +```bash +GTEST_FILTER='UART*' ./script/cpp_unit_test.py uart modbus +``` + +The process will return `0` for success or nonzero for failure. In case of failure, the errors will be printed out to the console. diff --git a/tests/components/binary_sensor/common.yaml b/tests/components/binary_sensor/common.yaml index 6965c1feeb..e3fd159b08 100644 --- a/tests/components/binary_sensor/common.yaml +++ b/tests/components/binary_sensor/common.yaml @@ -70,3 +70,69 @@ binary_sensor: - delay: 10s time_off: 200ms time_on: 800ms + + # Test on_multi_click with single click + - platform: template + id: multi_click_single + name: "Multi Click Single" + on_multi_click: + - timing: + - state: true + min_length: 50ms + max_length: 350ms + then: + - logger.log: "Single click detected" + + # Test on_multi_click with double click + - platform: template + id: multi_click_double + name: "Multi Click Double" + on_multi_click: + - timing: + - state: true + min_length: 50ms + max_length: 350ms + - state: false + min_length: 50ms + max_length: 350ms + - state: true + min_length: 50ms + max_length: 350ms + then: + - logger.log: "Double click detected" + + # Test on_multi_click with complex pattern (5 events) + - platform: template + id: multi_click_complex + name: "Multi Click Complex" + on_multi_click: + - timing: + - state: true + min_length: 50ms + max_length: 350ms + - state: false + min_length: 50ms + max_length: 350ms + - state: true + min_length: 50ms + max_length: 350ms + - state: false + min_length: 50ms + max_length: 350ms + - state: true + min_length: 50ms + then: + - logger.log: "Complex pattern detected" + + # Test on_multi_click with custom invalid_cooldown + - platform: template + id: multi_click_cooldown + name: "Multi Click Cooldown" + on_multi_click: + - timing: + - state: true + min_length: 100ms + max_length: 500ms + invalid_cooldown: 2s + then: + - logger.log: "Click with custom cooldown" diff --git a/tests/components/gpio/common.yaml b/tests/components/gpio/common.yaml index 4e237349d9..b8e8fa81e4 100644 --- a/tests/components/gpio/common.yaml +++ b/tests/components/gpio/common.yaml @@ -12,3 +12,20 @@ switch: - platform: gpio pin: ${switch_pin} id: gpio_switch + + - platform: gpio + pin: ${switch_pin_2} + id: gpio_switch_interlock_1 + interlock: [gpio_switch_interlock_2, gpio_switch_interlock_3] + interlock_wait_time: 100ms + + - platform: gpio + pin: ${switch_pin_3} + id: gpio_switch_interlock_2 + interlock: [gpio_switch_interlock_1, gpio_switch_interlock_3] + + - platform: gpio + pin: ${switch_pin_4} + id: gpio_switch_interlock_3 + interlock: [gpio_switch_interlock_1, gpio_switch_interlock_2] + interlock_wait_time: 50ms diff --git a/tests/components/gpio/test.esp32-c3-idf.yaml b/tests/components/gpio/test.esp32-c3-idf.yaml index fc7c9942d0..e9071b4356 100644 --- a/tests/components/gpio/test.esp32-c3-idf.yaml +++ b/tests/components/gpio/test.esp32-c3-idf.yaml @@ -2,5 +2,8 @@ substitutions: binary_sensor_pin: GPIO2 output_pin: GPIO3 switch_pin: GPIO4 + switch_pin_2: GPIO5 + switch_pin_3: GPIO6 + switch_pin_4: GPIO7 <<: !include common.yaml diff --git a/tests/components/gpio/test.esp32-idf.yaml b/tests/components/gpio/test.esp32-idf.yaml index 09f41abb79..862aa533ea 100644 --- a/tests/components/gpio/test.esp32-idf.yaml +++ b/tests/components/gpio/test.esp32-idf.yaml @@ -2,5 +2,8 @@ substitutions: binary_sensor_pin: GPIO12 output_pin: GPIO13 switch_pin: GPIO14 + switch_pin_2: GPIO15 + switch_pin_3: GPIO16 + switch_pin_4: GPIO17 <<: !include common.yaml diff --git a/tests/components/gpio/test.esp8266-ard.yaml b/tests/components/gpio/test.esp8266-ard.yaml index e1660ec47c..e13b4520d1 100644 --- a/tests/components/gpio/test.esp8266-ard.yaml +++ b/tests/components/gpio/test.esp8266-ard.yaml @@ -2,5 +2,8 @@ substitutions: binary_sensor_pin: GPIO0 output_pin: GPIO2 switch_pin: GPIO15 + switch_pin_2: GPIO12 + switch_pin_3: GPIO13 + switch_pin_4: GPIO14 <<: !include common.yaml diff --git a/tests/components/gpio/test.nrf52-adafruit.yaml b/tests/components/gpio/test.nrf52-adafruit.yaml index 912b9537c4..fb3f368e03 100644 --- a/tests/components/gpio/test.nrf52-adafruit.yaml +++ b/tests/components/gpio/test.nrf52-adafruit.yaml @@ -12,3 +12,20 @@ switch: - platform: gpio pin: P1.2 id: gpio_switch + + - platform: gpio + pin: P1.3 + id: gpio_switch_interlock_1 + interlock: [gpio_switch_interlock_2, gpio_switch_interlock_3] + interlock_wait_time: 100ms + + - platform: gpio + pin: P1.4 + id: gpio_switch_interlock_2 + interlock: [gpio_switch_interlock_1, gpio_switch_interlock_3] + + - platform: gpio + pin: P1.5 + id: gpio_switch_interlock_3 + interlock: [gpio_switch_interlock_1, gpio_switch_interlock_2] + interlock_wait_time: 50ms diff --git a/tests/components/gpio/test.nrf52-mcumgr.yaml b/tests/components/gpio/test.nrf52-mcumgr.yaml index 912b9537c4..fb3f368e03 100644 --- a/tests/components/gpio/test.nrf52-mcumgr.yaml +++ b/tests/components/gpio/test.nrf52-mcumgr.yaml @@ -12,3 +12,20 @@ switch: - platform: gpio pin: P1.2 id: gpio_switch + + - platform: gpio + pin: P1.3 + id: gpio_switch_interlock_1 + interlock: [gpio_switch_interlock_2, gpio_switch_interlock_3] + interlock_wait_time: 100ms + + - platform: gpio + pin: P1.4 + id: gpio_switch_interlock_2 + interlock: [gpio_switch_interlock_1, gpio_switch_interlock_3] + + - platform: gpio + pin: P1.5 + id: gpio_switch_interlock_3 + interlock: [gpio_switch_interlock_1, gpio_switch_interlock_2] + interlock_wait_time: 50ms diff --git a/tests/components/gpio/test.rp2040-ard.yaml b/tests/components/gpio/test.rp2040-ard.yaml index fc7c9942d0..e9071b4356 100644 --- a/tests/components/gpio/test.rp2040-ard.yaml +++ b/tests/components/gpio/test.rp2040-ard.yaml @@ -2,5 +2,8 @@ substitutions: binary_sensor_pin: GPIO2 output_pin: GPIO3 switch_pin: GPIO4 + switch_pin_2: GPIO5 + switch_pin_3: GPIO6 + switch_pin_4: GPIO7 <<: !include common.yaml diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index d4f64dcdea..f807014065 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -123,3 +123,43 @@ light: red: 100% green: 50% blue: 50% + # Test StrobeLightEffect with multiple colors + - platform: monochromatic + id: test_strobe_multiple + name: Strobe Multiple Colors + output: test_ledc_1 + effects: + - strobe: + name: Strobe Multi + colors: + - state: true + brightness: 100% + duration: 500ms + - state: false + duration: 250ms + - state: true + brightness: 50% + duration: 500ms + # Test StrobeLightEffect with transition + - platform: rgb + id: test_strobe_transition + name: Strobe With Transition + red: test_ledc_1 + green: test_ledc_2 + blue: test_ledc_3 + effects: + - strobe: + name: Strobe Transition + colors: + - state: true + red: 100% + green: 0% + blue: 0% + duration: 1s + transition_length: 500ms + - state: true + red: 0% + green: 100% + blue: 0% + duration: 1s + transition_length: 500ms diff --git a/tests/components/main.cpp b/tests/components/main.cpp new file mode 100644 index 0000000000..928f0e6059 --- /dev/null +++ b/tests/components/main.cpp @@ -0,0 +1,26 @@ +#include + +/* +This special main.cpp replaces the default one. +It will run all the Google Tests found in all compiled cpp files and then exit with the result +See README.md for more information +*/ + +// Auto generated code by esphome +// ========== AUTO GENERATED INCLUDE BLOCK BEGIN =========== +// ========== AUTO GENERATED INCLUDE BLOCK END ===========" + +void original_setup() { + // This function won't be run. + + // ========== AUTO GENERATED CODE BEGIN =========== + // =========== AUTO GENERATED CODE END ============ +} + +void setup() { + ::testing::InitGoogleTest(); + int exit_code = RUN_ALL_TESTS(); + exit(exit_code); +} + +void loop() {} diff --git a/tests/components/uart/common.h b/tests/components/uart/common.h new file mode 100644 index 0000000000..5597b86410 --- /dev/null +++ b/tests/components/uart/common.h @@ -0,0 +1,37 @@ +#pragma once +#include +#include +#include +#include +#include +#include "esphome/components/uart/uart_component.h" + +namespace esphome::uart::testing { + +using ::testing::_; +using ::testing::Return; +using ::testing::SaveArg; +using ::testing::DoAll; +using ::testing::Invoke; +using ::testing::SetArgPointee; + +// Derive a mock from UARTComponent to test the wrapper implementations. +class MockUARTComponent : public UARTComponent { + public: + using UARTComponent::write_array; + using UARTComponent::write_byte; + + // NOTE: std::vector is used here for test convenience. For production code, + // consider using StaticVector or FixedVector from esphome/core/helpers.h instead. + std::vector written_data; + + void write_array(const uint8_t *data, size_t len) override { written_data.assign(data, data + len); } + + MOCK_METHOD(bool, read_array, (uint8_t * data, size_t len), (override)); + MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override)); + MOCK_METHOD(int, available, (), (override)); + MOCK_METHOD(void, flush, (), (override)); + MOCK_METHOD(void, check_logger_conflict, (), (override)); +}; + +} // namespace esphome::uart::testing diff --git a/tests/components/uart/uart_component.cpp b/tests/components/uart/uart_component.cpp new file mode 100644 index 0000000000..2cab1f62ad --- /dev/null +++ b/tests/components/uart/uart_component.cpp @@ -0,0 +1,73 @@ +#include "common.h" + +namespace esphome::uart::testing { + +TEST(UARTComponentTest, SetGetBaudRate) { + MockUARTComponent mock; + mock.set_baud_rate(38400); + EXPECT_EQ(mock.get_baud_rate(), 38400); +} + +TEST(UARTComponentTest, SetGetStopBits) { + MockUARTComponent mock; + mock.set_stop_bits(2); + EXPECT_EQ(mock.get_stop_bits(), 2); +} + +TEST(UARTComponentTest, SetGetDataBits) { + MockUARTComponent mock; + mock.set_data_bits(7); + EXPECT_EQ(mock.get_data_bits(), 7); +} + +TEST(UARTComponentTest, SetGetParity) { + MockUARTComponent mock; + mock.set_parity(UARTParityOptions::UART_CONFIG_PARITY_EVEN); + EXPECT_EQ(mock.get_parity(), UARTParityOptions::UART_CONFIG_PARITY_EVEN); +} + +TEST(UARTComponentTest, SetGetRxBufferSize) { + MockUARTComponent mock; + mock.set_rx_buffer_size(128); + EXPECT_EQ(mock.get_rx_buffer_size(), 128); +} + +TEST(UARTComponentTest, WriteArrayVector) { + MockUARTComponent mock; + std::vector data = {10, 20, 30}; + mock.write_array(data); + EXPECT_EQ(mock.written_data, data); +} +TEST(UARTComponentTest, WriteByte) { + MockUARTComponent mock; + uint8_t byte = 0x79; + mock.write_byte(byte); + EXPECT_EQ(mock.written_data.size(), 1); + EXPECT_EQ(mock.written_data[0], byte); +} + +TEST(UARTComponentTest, WriteStr) { + MockUARTComponent mock; + const char *str = "Hello"; + std::vector captured; + mock.write_str(str); + EXPECT_EQ(mock.written_data.size(), strlen(str)); + EXPECT_EQ(0, strncmp(str, (const char *) mock.written_data.data(), mock.written_data.size())); +} + +// Tests for wrapper methods forwarding to pure virtual read_array +TEST(UARTComponentTest, ReadByteSuccess) { + MockUARTComponent mock; + uint8_t value = 0; + EXPECT_CALL(mock, read_array(&value, 1)).WillOnce(Return(true)); + EXPECT_TRUE(mock.read_byte(&value)); +} + +TEST(UARTComponentTest, ReadByteFailure) { + MockUARTComponent mock; + uint8_t value = 0xFF; + EXPECT_CALL(mock, read_array(&value, 1)).WillOnce(Return(false)); + EXPECT_FALSE(mock.read_byte(&value)); +} + +} // namespace esphome::uart::testing diff --git a/tests/components/uart/uart_device.cpp b/tests/components/uart/uart_device.cpp new file mode 100644 index 0000000000..c3f1d9078b --- /dev/null +++ b/tests/components/uart/uart_device.cpp @@ -0,0 +1,108 @@ +#include "common.h" +#include "esphome/components/uart/uart.h" + +namespace esphome::uart::testing { + +TEST(UARTDeviceTest, ReadByteSuccess) { + MockUARTComponent mock; + UARTDevice dev(&mock); + uint8_t value = 0; + EXPECT_CALL(mock, read_array(_, 1)).WillOnce(DoAll(SetArgPointee<0>(0x5A), Return(true))); + bool result = dev.read_byte(&value); + EXPECT_TRUE(result); + EXPECT_EQ(value, 0x5A); +} + +TEST(UARTDeviceTest, ReadByteFailure) { + MockUARTComponent mock; + UARTDevice dev(&mock); + uint8_t value = 0xFF; + EXPECT_CALL(mock, read_array(_, 1)).WillOnce(Return(false)); + bool result = dev.read_byte(&value); + EXPECT_FALSE(result); +} + +TEST(UARTDeviceTest, PeekByteSuccess) { + MockUARTComponent mock; + UARTDevice dev(&mock); + uint8_t value = 0; + EXPECT_CALL(mock, peek_byte(_)).WillOnce(DoAll(SetArgPointee<0>(0xA5), Return(true))); + bool result = dev.peek_byte(&value); + EXPECT_TRUE(result); + EXPECT_EQ(value, 0xA5); +} + +TEST(UARTDeviceTest, PeekByteFailure) { + MockUARTComponent mock; + UARTDevice dev(&mock); + uint8_t value = 0; + EXPECT_CALL(mock, peek_byte(_)).WillOnce(Return(false)); + bool result = dev.peek_byte(&value); + EXPECT_FALSE(result); +} + +TEST(UARTDeviceTest, Available) { + MockUARTComponent mock; + UARTDevice dev(&mock); + EXPECT_CALL(mock, available()).WillOnce(Return(5)); + EXPECT_EQ(dev.available(), 5); +} + +TEST(UARTDeviceTest, FlushCallsParent) { + MockUARTComponent mock; + UARTDevice dev(&mock); + EXPECT_CALL(mock, flush()).Times(1); + dev.flush(); +} + +TEST(UARTDeviceTest, WriteByteForwardsToWriteArray) { + MockUARTComponent mock; + UARTDevice dev(&mock); + dev.write_byte(0xAB); + EXPECT_EQ(mock.written_data.size(), 1); + EXPECT_EQ(mock.written_data[0], 0xAB); +} +TEST(UARTDeviceTest, WriteArrayPointer) { + MockUARTComponent mock; + UARTDevice dev(&mock); + uint8_t data[3] = {1, 2, 3}; + dev.write_array(data, 3); + EXPECT_EQ(mock.written_data.size(), 3); + EXPECT_EQ(mock.written_data, std::vector(data, data + 3)); +} + +TEST(UARTDeviceTest, WriteArrayVector) { + MockUARTComponent mock; + UARTDevice dev(&mock); + std::vector data = {4, 5, 6}; + dev.write_array(data); + EXPECT_EQ(mock.written_data, data); +} + +TEST(UARTDeviceTest, WriteArrayStdArray) { + MockUARTComponent mock; + UARTDevice dev(&mock); + std::array data = {7, 8, 9, 10}; + dev.write_array(data); + EXPECT_EQ(mock.written_data.size(), data.size()); + EXPECT_EQ(mock.written_data, std::vector(data.begin(), data.end())); +} + +TEST(UARTDeviceTest, WriteStrForwardsToWriteArray) { + MockUARTComponent mock; + UARTDevice dev(&mock); + const char *str = "ESPHome"; + dev.write_str(str); + EXPECT_EQ(mock.written_data.size(), strlen(str)); + EXPECT_EQ(0, strncmp(str, (const char *) mock.written_data.data(), mock.written_data.size())); +} + +TEST(UARTDeviceTest, WriteStrEmptyString) { + MockUARTComponent mock; + UARTDevice dev(&mock); + const char *str = ""; + dev.write_str(str); + EXPECT_EQ(mock.written_data.size(), 0); +} + +} // namespace esphome::uart::testing diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 35652e0efc..a859b3c24d 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -5,7 +5,6 @@ import importlib.util import json import os from pathlib import Path -import subprocess import sys from unittest.mock import Mock, call, patch @@ -56,9 +55,9 @@ def mock_should_run_python_linters() -> Generator[Mock, None, None]: @pytest.fixture -def mock_subprocess_run() -> Generator[Mock, None, None]: - """Mock subprocess.run for list-components.py calls.""" - with patch.object(determine_jobs.subprocess, "run") as mock: +def mock_determine_cpp_unit_tests() -> Generator[Mock, None, None]: + """Mock determine_cpp_unit_tests from helpers.""" + with patch.object(determine_jobs, "determine_cpp_unit_tests") as mock: yield mock @@ -82,8 +81,8 @@ def test_main_all_tests_should_run( mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, - mock_subprocess_run: Mock, mock_changed_files: Mock, + mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -95,6 +94,7 @@ def test_main_all_tests_should_run( mock_should_run_clang_tidy.return_value = True mock_should_run_clang_format.return_value = True mock_should_run_python_linters.return_value = True + mock_determine_cpp_unit_tests.return_value = (False, ["wifi", "api", "sensor"]) # Mock changed_files to return non-component files (to avoid memory impact) # Memory impact only runs when component C++ files change @@ -114,15 +114,15 @@ def test_main_all_tests_should_run( ), patch.object( determine_jobs, - "filter_component_files", + "filter_component_and_test_files", side_effect=lambda f: f.startswith("esphome/components/"), ), patch.object( determine_jobs, "get_components_with_dependencies", - side_effect=lambda files, deps: ["wifi", "api"] - if not deps - else ["wifi", "api", "sensor"], + side_effect=lambda files, deps: ( + ["wifi", "api"] if not deps else ["wifi", "api", "sensor"] + ), ), ): determine_jobs.main() @@ -150,6 +150,8 @@ def test_main_all_tests_should_run( # memory_impact should be false (no component C++ files changed) assert "memory_impact" in output assert output["memory_impact"]["should_run"] == "false" + assert output["cpp_unit_tests_run_all"] is False + assert output["cpp_unit_tests_components"] == ["wifi", "api", "sensor"] def test_main_no_tests_should_run( @@ -157,8 +159,8 @@ def test_main_no_tests_should_run( mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, - mock_subprocess_run: Mock, mock_changed_files: Mock, + mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -170,6 +172,7 @@ def test_main_no_tests_should_run( mock_should_run_clang_tidy.return_value = False mock_should_run_clang_format.return_value = False mock_should_run_python_linters.return_value = False + mock_determine_cpp_unit_tests.return_value = (False, []) # Mock changed_files to return no component files mock_changed_files.return_value = [] @@ -178,7 +181,9 @@ def test_main_no_tests_should_run( with ( patch("sys.argv", ["determine-jobs.py"]), patch.object(determine_jobs, "get_changed_components", return_value=[]), - patch.object(determine_jobs, "filter_component_files", return_value=False), + patch.object( + determine_jobs, "filter_component_and_test_files", return_value=False + ), patch.object( determine_jobs, "get_components_with_dependencies", return_value=[] ), @@ -202,31 +207,8 @@ def test_main_no_tests_should_run( # memory_impact should be present assert "memory_impact" in output assert output["memory_impact"]["should_run"] == "false" - - -def test_main_list_components_fails( - mock_should_run_integration_tests: Mock, - mock_should_run_clang_tidy: Mock, - mock_should_run_clang_format: Mock, - mock_should_run_python_linters: Mock, - mock_subprocess_run: Mock, - capsys: pytest.CaptureFixture[str], -) -> None: - """Test when list-components.py fails.""" - mock_should_run_integration_tests.return_value = True - mock_should_run_clang_tidy.return_value = True - mock_should_run_clang_format.return_value = True - mock_should_run_python_linters.return_value = True - - # Mock list-components.py failure - mock_subprocess_run.side_effect = subprocess.CalledProcessError(1, "cmd") - - # Run main function with mocked argv - should raise - with ( - patch("sys.argv", ["determine-jobs.py"]), - pytest.raises(subprocess.CalledProcessError), - ): - determine_jobs.main() + assert output["cpp_unit_tests_run_all"] is False + assert output["cpp_unit_tests_components"] == [] def test_main_with_branch_argument( @@ -234,8 +216,8 @@ def test_main_with_branch_argument( mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, - mock_subprocess_run: Mock, mock_changed_files: Mock, + mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -247,6 +229,7 @@ def test_main_with_branch_argument( mock_should_run_clang_tidy.return_value = True mock_should_run_clang_format.return_value = False mock_should_run_python_linters.return_value = True + mock_determine_cpp_unit_tests.return_value = (False, ["mqtt"]) # Mock changed_files to return non-component files (to avoid memory impact) # Memory impact only runs when component C++ files change @@ -258,7 +241,7 @@ def test_main_with_branch_argument( patch.object(determine_jobs, "get_changed_components", return_value=["mqtt"]), patch.object( determine_jobs, - "filter_component_files", + "filter_component_and_test_files", side_effect=lambda f: f.startswith("esphome/components/"), ), patch.object( @@ -296,6 +279,8 @@ def test_main_with_branch_argument( # memory_impact should be false (no component C++ files changed) assert "memory_impact" in output assert output["memory_impact"]["should_run"] == "false" + assert output["cpp_unit_tests_run_all"] is False + assert output["cpp_unit_tests_components"] == ["mqtt"] def test_should_run_integration_tests( @@ -506,7 +491,6 @@ def test_main_filters_components_without_tests( mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, - mock_subprocess_run: Mock, mock_changed_files: Mock, capsys: pytest.CaptureFixture[str], tmp_path: Path, @@ -556,16 +540,17 @@ def test_main_filters_components_without_tests( ), patch.object( determine_jobs, - "filter_component_files", + "filter_component_and_test_files", side_effect=lambda f: f.startswith("esphome/components/"), ), patch.object( determine_jobs, "get_components_with_dependencies", - side_effect=lambda files, deps: ["wifi", "sensor"] - if not deps - else ["wifi", "sensor", "airthings_ble"], + side_effect=lambda files, deps: ( + ["wifi", "sensor"] if not deps else ["wifi", "sensor", "airthings_ble"] + ), ), + patch.object(determine_jobs, "changed_files", return_value=[]), ): # Clear the cache since we're mocking root_path determine_jobs._component_has_tests.cache_clear() @@ -808,7 +793,6 @@ def test_clang_tidy_mode_full_scan( mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, - mock_subprocess_run: Mock, mock_changed_files: Mock, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, @@ -829,7 +813,9 @@ def test_clang_tidy_mode_full_scan( patch("sys.argv", ["determine-jobs.py"]), patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=True), patch.object(determine_jobs, "get_changed_components", return_value=[]), - patch.object(determine_jobs, "filter_component_files", return_value=False), + patch.object( + determine_jobs, "filter_component_and_test_files", return_value=False + ), patch.object( determine_jobs, "get_components_with_dependencies", return_value=[] ), @@ -873,7 +859,6 @@ def test_clang_tidy_mode_targeted_scan( mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, - mock_subprocess_run: Mock, mock_changed_files: Mock, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, @@ -912,7 +897,7 @@ def test_clang_tidy_mode_targeted_scan( patch.object(determine_jobs, "get_changed_components", return_value=components), patch.object( determine_jobs, - "filter_component_files", + "filter_component_and_test_files", side_effect=lambda f: f.startswith("esphome/components/"), ), patch.object(