mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-31 23:21:54 +00:00 
			
		
		
		
	Merge branch 'wifi_sta_fixed' into integration
This commit is contained in:
		| @@ -1 +1 @@ | ||||
| d7693a1e996cacd4a3d1c9a16336799c2a8cc3db02e4e74084151ce964581248 | ||||
| 3d46b63015d761c85ca9cb77ab79a389509e5776701fb22aed16e7b79d432c0c | ||||
|   | ||||
							
								
								
									
										7
									
								
								.github/workflows/auto-label-pr.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.github/workflows/auto-label-pr.yml
									
									
									
									
										vendored
									
									
								
							| @@ -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); | ||||
|  | ||||
|   | ||||
							
								
								
									
										31
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										31
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							| @@ -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 | ||||
|   | ||||
							
								
								
									
										1
									
								
								.github/workflows/status-check-labels.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/workflows/status-check-labels.yml
									
									
									
									
										vendored
									
									
								
							| @@ -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 | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -107,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( | ||||
|         { | ||||
| @@ -123,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, | ||||
| ) | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -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. | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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(); | ||||
|   | ||||
| @@ -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}); | ||||
|   | ||||
| @@ -378,14 +378,18 @@ async def to_code(config): | ||||
|     # Track if any network uses Enterprise authentication | ||||
|     has_eap = False | ||||
|  | ||||
|     def add_sta(ap, network): | ||||
|         ip_config = network.get(CONF_MANUAL_IP, config.get(CONF_MANUAL_IP)) | ||||
|         cg.add(var.add_sta(wifi_network(network, ap, ip_config))) | ||||
|  | ||||
|     for network in config.get(CONF_NETWORKS, []): | ||||
|     # Build all WiFiAP objects | ||||
|     networks = config.get(CONF_NETWORKS, []) | ||||
|     if networks: | ||||
|         wifi_aps = [] | ||||
|         for network in networks: | ||||
|             if CONF_EAP in network: | ||||
|                 has_eap = True | ||||
|         cg.with_local_variable(network[CONF_ID], WiFiAP(), add_sta, network) | ||||
|             ip_config = network.get(CONF_MANUAL_IP, config.get(CONF_MANUAL_IP)) | ||||
|             wifi_aps.append(wifi_network(network, WiFiAP(), ip_config)) | ||||
|  | ||||
|         # Set all WiFi networks at once | ||||
|         cg.add(var.set_stas(wifi_aps)) | ||||
|  | ||||
|     if CONF_AP in config: | ||||
|         conf = config[CONF_AP] | ||||
|   | ||||
| @@ -330,11 +330,8 @@ float WiFiComponent::get_loop_priority() const { | ||||
|   return 10.0f;  // before other loop components | ||||
| } | ||||
|  | ||||
| void WiFiComponent::add_sta(const WiFiAP &ap) { this->sta_.push_back(ap); } | ||||
| void WiFiComponent::set_sta(const WiFiAP &ap) { | ||||
|   this->clear_sta(); | ||||
|   this->add_sta(ap); | ||||
| } | ||||
| void WiFiComponent::set_stas(const std::initializer_list<WiFiAP> &aps) { this->sta_ = aps; } | ||||
| void WiFiComponent::set_sta(const WiFiAP &ap) { this->set_stas({ap}); } | ||||
| void WiFiComponent::clear_sta() { this->sta_.clear(); } | ||||
| void WiFiComponent::save_wifi_sta(const std::string &ssid, const std::string &password) { | ||||
|   SavedWifiSettings save{};  // zero-initialized - all bytes set to \0, guaranteeing null termination | ||||
|   | ||||
| @@ -219,7 +219,7 @@ class WiFiComponent : public Component { | ||||
|  | ||||
|   void set_sta(const WiFiAP &ap); | ||||
|   WiFiAP get_sta() { return this->selected_ap_; } | ||||
|   void add_sta(const WiFiAP &ap); | ||||
|   void set_stas(const std::initializer_list<WiFiAP> &aps); | ||||
|   void clear_sta(); | ||||
|  | ||||
| #ifdef USE_WIFI_AP | ||||
| @@ -393,7 +393,7 @@ class WiFiComponent : public Component { | ||||
| #endif | ||||
|  | ||||
|   std::string use_address_; | ||||
|   std::vector<WiFiAP> sta_; | ||||
|   FixedVector<WiFiAP> sta_; | ||||
|   std::vector<WiFiSTAPriority> sta_priorities_; | ||||
|   wifi_scan_vector_t<WiFiScanResult> scan_result_; | ||||
|   WiFiAP selected_ap_; | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -197,12 +197,8 @@ template<typename T> class FixedVector { | ||||
|     size_ = 0; | ||||
|   } | ||||
|  | ||||
|  public: | ||||
|   FixedVector() = default; | ||||
|  | ||||
|   /// Constructor from initializer list - allocates exact size needed | ||||
|   /// This enables brace initialization: FixedVector<int> v = {1, 2, 3}; | ||||
|   FixedVector(std::initializer_list<T> init_list) { | ||||
|   // Helper to assign from initializer list (shared by constructor and assignment operator) | ||||
|   void assign_from_initializer_list_(std::initializer_list<T> init_list) { | ||||
|     init(init_list.size()); | ||||
|     size_t idx = 0; | ||||
|     for (const auto &item : init_list) { | ||||
| @@ -212,6 +208,13 @@ template<typename T> class FixedVector { | ||||
|     size_ = init_list.size(); | ||||
|   } | ||||
|  | ||||
|  public: | ||||
|   FixedVector() = default; | ||||
|  | ||||
|   /// Constructor from initializer list - allocates exact size needed | ||||
|   /// This enables brace initialization: FixedVector<int> v = {1, 2, 3}; | ||||
|   FixedVector(std::initializer_list<T> init_list) { assign_from_initializer_list_(init_list); } | ||||
|  | ||||
|   ~FixedVector() { cleanup_(); } | ||||
|  | ||||
|   // Disable copy operations (avoid accidental expensive copies) | ||||
| @@ -237,6 +240,15 @@ template<typename T> class FixedVector { | ||||
|     return *this; | ||||
|   } | ||||
|  | ||||
|   /// Assignment from initializer list - avoids temporary and move overhead | ||||
|   /// This enables: FixedVector<int> v; v = {1, 2, 3}; | ||||
|   FixedVector &operator=(std::initializer_list<T> init_list) { | ||||
|     cleanup_(); | ||||
|     reset_(); | ||||
|     assign_from_initializer_list_(init_list); | ||||
|     return *this; | ||||
|   } | ||||
|  | ||||
|   // Allocate capacity - can be called multiple times to reinit | ||||
|   void init(size_t n) { | ||||
|     cleanup_(); | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
							
								
								
									
										172
									
								
								script/cpp_unit_test.py
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										172
									
								
								script/cpp_unit_test.py
									
									
									
									
									
										Executable file
									
								
							| @@ -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() | ||||
| @@ -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,9 +146,8 @@ 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/"): | ||||
|     if core_changed(files): | ||||
|         # If any core files changed, run integration tests | ||||
|         return True | ||||
|  | ||||
|     # Check if any integration test files changed | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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())), | ||||
|   | ||||
| @@ -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/<component>/*.cpp): | ||||
|        - Adds the component to the affected list | ||||
|        - Only that component needs to be tested | ||||
|  | ||||
|     2. Component C++ files changed (esphome/components/<component>/*): | ||||
|        - 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) | ||||
|   | ||||
| @@ -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): | ||||
|   | ||||
							
								
								
									
										5
									
								
								tests/components/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								tests/components/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -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 | ||||
							
								
								
									
										32
									
								
								tests/components/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								tests/components/README.md
									
									
									
									
									
										Normal file
									
								
							| @@ -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. | ||||
							
								
								
									
										26
									
								
								tests/components/main.cpp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								tests/components/main.cpp
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | ||||
| #include <gtest/gtest.h> | ||||
|  | ||||
| /* | ||||
| 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() {} | ||||
							
								
								
									
										37
									
								
								tests/components/uart/common.h
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								tests/components/uart/common.h
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | ||||
| #pragma once | ||||
| #include <vector> | ||||
| #include <cstdint> | ||||
| #include <cstring> | ||||
| #include <gmock/gmock.h> | ||||
| #include <gtest/gtest.h> | ||||
| #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<uint8_t> 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 | ||||
							
								
								
									
										73
									
								
								tests/components/uart/uart_component.cpp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								tests/components/uart/uart_component.cpp
									
									
									
									
									
										Normal file
									
								
							| @@ -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<uint8_t> 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<uint8_t> 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 | ||||
							
								
								
									
										108
									
								
								tests/components/uart/uart_device.cpp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								tests/components/uart/uart_device.cpp
									
									
									
									
									
										Normal file
									
								
							| @@ -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<uint8_t> data = {4, 5, 6}; | ||||
|   dev.write_array(data); | ||||
|   EXPECT_EQ(mock.written_data, data); | ||||
| } | ||||
|  | ||||
| TEST(UARTDeviceTest, WriteArrayStdArray) { | ||||
|   MockUARTComponent mock; | ||||
|   UARTDevice dev(&mock); | ||||
|   std::array<uint8_t, 4> 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 | ||||
| @@ -12,5 +12,8 @@ esphome: | ||||
|             - logger.log: "Failed to connect to WiFi!" | ||||
|  | ||||
| wifi: | ||||
|   ssid: MySSID | ||||
|   networks: | ||||
|     - ssid: MySSID | ||||
|       password: password1 | ||||
|     - ssid: MySSID2 | ||||
|       password: password2 | ||||
|   | ||||
| @@ -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( | ||||
|   | ||||
		Reference in New Issue
	
	Block a user