Merge branch 'esp8266-native-build-surgery' into esp8266-native-toolchain-plumbing

This commit is contained in:
J. Nick Koston
2026-08-24 14:38:59 -05:00
38 changed files with 857 additions and 120 deletions
@@ -1,19 +1,27 @@
"""Schema-level config validation for custom_pdu and the deprecated custom_command alias.
"""Config validation for custom_pdu and the deprecated custom_command alias.
custom_command took a raw frame with a leading device address byte; custom_pdu takes the PDU only.
The old key is still accepted at the schema level and auto-migrated later in final validate (which a
bare-schema test can't reach), so these tests only cover what the schema itself enforces: the two keys
are mutually exclusive, and custom_pdu takes byte-sized values.
Most of these tests cover what the schema itself enforces (the two keys are mutually exclusive, and
custom_pdu takes byte-sized values). The last two reach the final-validate step that a bare-schema
test cannot: a write-coded custom_pdu polled continuously is rejected there.
"""
import pytest
from voluptuous import Invalid, MultipleInvalid
from esphome.components.modbus_controller import ModbusItemBaseSchema
from esphome.components.modbus_controller import (
ModbusItemBaseSchema,
validate_custom_pdu_item,
)
from esphome.components.modbus_controller.const import (
CONF_CUSTOM_COMMAND,
CONF_CUSTOM_PDU,
CONF_MODBUS_CONTROLLER_ID,
)
from esphome.config import Config
from esphome.const import CONF_ADDRESS, CONF_CONTINUOUS, CONF_ID
from esphome.core import ID
import esphome.final_validate as fv
def test_custom_command_accepted_at_schema_level() -> None:
@@ -45,3 +53,48 @@ def test_custom_pdu_rejects_non_byte_values() -> None:
"""PDU entries are bytes; a word-sized value is a sign the old raw format is being used."""
with pytest.raises((Invalid, MultipleInvalid)):
ModbusItemBaseSchema({CONF_CUSTOM_PDU: [0x0103, 0x002A]})
def _controller_full_config(*, continuous: bool) -> Config:
"""A minimal full-config graph with one modbus_controller declaring id 'ctl', enough for the
final-validate to resolve the controller (and its continuous flag) from an item's
modbus_controller_id."""
ctl_id = ID("ctl", is_declaration=True)
config = Config()
config["modbus_controller"] = [
{CONF_ID: ctl_id, CONF_ADDRESS: 1, CONF_CONTINUOUS: continuous}
]
config.declare_ids.append((ctl_id, ["modbus_controller", 0, CONF_ID]))
return config
@pytest.fixture
def reset_full_config():
token = fv.full_config.set(Config())
yield
fv.full_config.reset(token)
def test_continuous_write_custom_pdu_rejected(reset_full_config) -> None:
"""A write-coded custom_pdu (0x17 = read/write-multiple) under a continuous controller is
rejected at final validate: the hub would strip continuous from the mutating code and warn on
every update."""
fv.full_config.set(_controller_full_config(continuous=True))
with pytest.raises(Invalid, match="can't be polled continuously"):
validate_custom_pdu_item(
{
CONF_MODBUS_CONTROLLER_ID: ID("ctl"),
CONF_CUSTOM_PDU: [0x17, 0x00, 0x03, 0x00, 0x01],
}
)
def test_continuous_read_custom_pdu_allowed(reset_full_config) -> None:
"""A read-coded custom_pdu (0x03) under a continuous controller is fine - only writes stream."""
fv.full_config.set(_controller_full_config(continuous=True))
validate_custom_pdu_item(
{
CONF_MODBUS_CONTROLLER_ID: ID("ctl"),
CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01],
}
)
+1
View File
@@ -4,4 +4,5 @@ media_source:
- platform: audio_http
id: audio_http_source
buffer_size: 100000
persistent_ring_buffer: true
task_stack_in_psram: true
@@ -15,11 +15,10 @@ struct MitsubishiCN105ClimateTestContext {
TEST(MitsubishiCN105ClimateTests, CelsiusTemperatureMappingAndTraitsMatchExpectedValues) {
MitsubishiCN105ClimateTestContext context;
const auto mapping = TemperatureMapping();
for (int temperature = 16; temperature <= 31; ++temperature) {
EXPECT_EQ(mapping.to_mitsubishi(temperature), temperature);
EXPECT_EQ(mapping.from_mitsubishi(temperature), temperature);
EXPECT_EQ(context.component.get_temperature_mapping().to_mitsubishi(temperature), temperature);
EXPECT_EQ(context.component.get_temperature_mapping().from_mitsubishi(temperature), temperature);
}
const auto traits = context.sut.traits();
@@ -32,8 +31,6 @@ TEST(MitsubishiCN105ClimateTests, CelsiusTemperatureMappingAndTraitsMatchExpecte
TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingAndTraitsMatchExpectedValues) {
MitsubishiCN105ClimateTestContext context;
auto mapping = TemperatureMapping();
mapping.set_use_fahrenheit(true);
context.component.set_use_fahrenheit(true);
const std::array cases{
@@ -46,8 +43,8 @@ TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingAndTraitsMatchExpe
};
for (const auto &[fahrenheit, mitsubishi_celsius] : cases) {
EXPECT_FLOAT_EQ(mapping.to_mitsubishi(fahrenheit), mitsubishi_celsius);
EXPECT_FLOAT_EQ(mapping.from_mitsubishi(mitsubishi_celsius), fahrenheit);
EXPECT_FLOAT_EQ(context.component.get_temperature_mapping().to_mitsubishi(fahrenheit), mitsubishi_celsius);
EXPECT_FLOAT_EQ(context.component.get_temperature_mapping().from_mitsubishi(mitsubishi_celsius), fahrenheit);
}
const auto traits = context.sut.traits();
EXPECT_EQ(traits.get_temperature_unit(), TemperatureUnit::FAHRENHEIT);
@@ -42,11 +42,17 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
// All bytes from UART should be consumed
EXPECT_TRUE(ctx.uart.rx.empty());
// After successful connect we request status, first settings (0x02)
// Defer the first settings request (0x02) until the next update.
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::DEFERRED_STATUS_REQUEST);
EXPECT_TRUE(ctx.uart.tx.empty());
ctx.sut.set_current_time(201);
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS);
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x42, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7B));
EXPECT_EQ(ctx.sut.operation_start_ms_, 200);
EXPECT_EQ(ctx.sut.operation_start_ms_, 201);
// Clear TX bytes.
ctx.uart.tx.clear();
@@ -75,15 +81,24 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
EXPECT_EQ(ctx.sut.status().vane_mode, MitsubishiCN105::VaneMode::POSITION_4);
EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::SWING);
// Now fetch telemetry (0x03)
// Defer the telemetry request (0x03) until the next update.
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::DEFERRED_STATUS_REQUEST);
EXPECT_TRUE(ctx.uart.tx.empty());
ctx.sut.set_current_time(301);
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS);
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x42, 0x01, 0x30, 0x10, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7A));
EXPECT_EQ(ctx.sut.operation_start_ms_, 300);
EXPECT_EQ(ctx.sut.operation_start_ms_, 301);
// Clear TX bytes.
ctx.uart.tx.clear();
// Queue a setting while waiting for telemetry.
ctx.sut.set_power(true);
// Telemetry response
ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x03, 0x00, 0x00, 0x0B, 0x00, 0x00,
0xAA, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xA5});
@@ -103,6 +118,13 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
EXPECT_TRUE(ctx.uart.tx.empty());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
EXPECT_EQ(ctx.sut.operation_start_ms_, 400);
// Apply the pending setting on the next update, outside RX processing.
ctx.sut.set_current_time(401);
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS);
EXPECT_FALSE(ctx.uart.tx.empty());
EXPECT_EQ(ctx.sut.operation_start_ms_, 401);
}
TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) {
@@ -469,6 +491,36 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) {
EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0);
}
TEST(MitsubishiCN105Tests, PendingSettingsTakePriorityOverDueTelemetry) {
MitsubishiCN105TestsContext ctx;
ctx.sut.status_.target_temperature = 24.0f;
ctx.sut.status_.room_temperature = 21.0f;
ASSERT_TRUE(ctx.sut.is_status_initialized());
ctx.sut.state_ = TestableMitsubishiCN105::State::STATUS_UPDATED;
ctx.sut.set_state(TestableMitsubishiCN105::State::SCHEDULE_NEXT_STATUS_UPDATE);
ctx.sut.set_current_time(1000);
ASSERT_FALSE(ctx.sut.update());
ASSERT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS);
ctx.uart.tx.clear();
ctx.sut.set_power(true);
ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x08, 0x07,
0x00, 0x04, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3C});
ctx.sut.set_current_time(1001);
ASSERT_TRUE(ctx.sut.update());
EXPECT_TRUE(ctx.uart.tx.empty());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
ctx.sut.set_current_time(1002);
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS);
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x01, 0x00, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7B));
}
TEST(MitsubishiCN105Tests, SetAndClearRemoteRoomTemp) {
MitsubishiCN105TestsContext ctx;
@@ -2,6 +2,7 @@ modbus_controller:
- id: modbus_controller1
address: 0x2
modbus_id: modbus_bus
continuous: true
on_online:
then:
logger.log: "Module Online"
@@ -1,2 +1,9 @@
preferences:
id: prefs_syncer
flash_write_interval: 20s
esphome:
on_boot:
then:
- component.suspend: prefs_syncer
- component.resume: prefs_syncer
@@ -0,0 +1,41 @@
esphome:
name: test_suspend_resume_device
host:
logger:
level: DEBUG
api:
preferences:
id: prefs_syncer
flash_write_interval: 1s
button:
- platform: template
name: "Save Preference"
on_press:
- lambda: |-
// save() only updates the in-memory map; only sync() persists it to disk.
ESPPreferenceObject pref = global_preferences->make_preference<uint32_t>(0xBEEF);
uint32_t value = 123;
if (pref.save(&value)) {
ESP_LOGI("test", "Preference saved in memory");
} else {
ESP_LOGE("test", "Preference save failed");
}
- platform: template
name: "Suspend Syncer"
on_press:
- component.suspend: prefs_syncer
- lambda: |-
ESP_LOGI("test", "Syncer suspended");
- platform: template
name: "Resume Syncer"
on_press:
- component.resume: prefs_syncer
- lambda: |-
ESP_LOGI("test", "Syncer resumed");
@@ -0,0 +1,115 @@
esphome:
name: uart-mock-modbus-continuous
host:
api:
logger:
level: VERBOSE
# When set, the mock server stops forwarding its replies to the controller, so the controller sees
# timeouts - used by the recovery test to drive a live continuous poll offline and back.
globals:
- id: silence_server
type: bool
initial_value: "false"
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- if:
condition:
lambda: "return !id(silence_server);"
then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
# Short timeout so the recovery test drives the poll offline quickly; when the server answers,
# replies arrive within turnaround_time, so this does not slow the streaming path.
send_wait_time: 100ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
id: modbus_controller_1
# A long update_interval means that without continuous polling only the boot poll would run in the
# test window. continuous: true re-queues the read after each success, so it streams as fast as the
# bus allows.
update_interval: 30s
continuous: true
# One retry so a silenced device trips offline fast (initial send + 1 retry, each 100ms).
max_cmd_retries: 1
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
# Each read returns the next counter value, so every poll publishes a distinct state the test can
# count (proving the read actually ran, not just that the state changed once).
- address: 0x01
value_type: U_WORD
read_lambda: |-
static uint16_t counter = 0;
return counter++;
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "continuous_reg"
address: 0x01
register_type: holding
value_type: U_WORD
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# Trigger the first poll deterministically. PollingComponent's first update() would otherwise land
# somewhere in the 30s update_interval; once this one read completes, continuous re-queuing takes over.
on_press:
- lambda: "id(modbus_controller_1)->update();"
switch:
# Toggles whether the mock server forwards its replies. On = silence (controller sees timeouts);
# off = answer again. The recovery test uses it to drive a live continuous poll offline and back.
- platform: template
name: "Silence Server"
id: silence_server_switch
optimistic: true
turn_on_action:
- lambda: "id(silence_server) = true;"
turn_off_action:
- lambda: "id(silence_server) = false;"
@@ -0,0 +1,137 @@
"""Test that suspending/resuming the preferences IntervalSyncer actually stops/starts flash writes."""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable
from pathlib import Path
import re
from typing import Any
from aioesphomeapi import ButtonInfo, EntityInfo
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
DEVICE_NAME = "test_suspend_resume_device"
def find_entity_by_name(
entities: list[EntityInfo], entity_type: type, name: str
) -> Any:
"""Helper to find an entity by type and name."""
return next(
(e for e in entities if isinstance(e, entity_type) and e.name == name), None
)
async def _wait_for(
awaitable: Awaitable[Any], message: str, timeout: float = 5.0
) -> None:
"""Await a future or coroutine, failing the test with a clear message on timeout."""
try:
await asyncio.wait_for(awaitable, timeout=timeout)
except TimeoutError:
pytest.fail(message)
async def _poll_until_exists(path: Path) -> None:
"""Poll for a file to appear, rather than guessing a sleep duration."""
while not path.exists():
await asyncio.sleep(0.05)
@pytest.fixture(autouse=True)
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> Path:
"""Keep host preferences per-test so this test never touches the real
~/.esphome/prefs and never races other tests over ESPHOME_PREFDIR."""
prefdir = tmp_path / "prefs"
monkeypatch.setenv("ESPHOME_PREFDIR", str(prefdir))
return prefdir / f"{DEVICE_NAME}.prefs"
@pytest.mark.asyncio
async def test_host_preferences_suspend_resume(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
isolated_preferences: Path,
) -> None:
"""Test that a running syncer flushes, a suspended one doesn't, and resume restores flushing."""
pref_file = isolated_preferences
loop = asyncio.get_running_loop()
saved_in_memory = loop.create_future()
syncer_suspended = loop.create_future()
syncer_resumed = loop.create_future()
save_pattern = re.compile(r"Preference saved in memory")
suspend_pattern = re.compile(r"Syncer suspended")
resume_pattern = re.compile(r"Syncer resumed")
def check_output(line: str) -> None:
if save_pattern.search(line) and not saved_in_memory.done():
saved_in_memory.set_result(True)
if suspend_pattern.search(line) and not syncer_suspended.done():
syncer_suspended.set_result(True)
if resume_pattern.search(line) and not syncer_resumed.done():
syncer_resumed.set_result(True)
async with (
run_compiled(yaml_config, line_callback=check_output),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
save_button = find_entity_by_name(entities, ButtonInfo, "Save Preference")
suspend_button = find_entity_by_name(entities, ButtonInfo, "Suspend Syncer")
resume_button = find_entity_by_name(entities, ButtonInfo, "Resume Syncer")
assert save_button is not None, "Save Preference button not found"
assert suspend_button is not None, "Suspend Syncer button not found"
assert resume_button is not None, "Resume Syncer button not found"
# --- Positive control: a running syncer flushes to disk. Without this,
# the suspend assertion below could pass for the wrong reason (e.g. wrong prefs path). ---
client.button_command(save_button.key)
await _wait_for(
saved_in_memory, "Preference was not saved to memory within timeout"
)
await _wait_for(
_poll_until_exists(pref_file),
"Running syncer never flushed to disk; positive control failed",
timeout=10.0,
)
saved_in_memory = loop.create_future()
# --- Suspend: a suspended syncer must not flush. ---
client.button_command(suspend_button.key)
await _wait_for(
syncer_suspended, "Syncer suspend command was not processed within timeout"
)
# Delete only after suspend is confirmed: the poller is now stopped, so
# nothing can recreate the file before the negative assertion below.
pref_file.unlink()
client.button_command(save_button.key)
await _wait_for(
saved_in_memory, "Preference was not saved to memory within timeout"
)
# Wait well past flash_write_interval (1s): a running syncer would
# have flushed to disk by now, a suspended one must not have. This is a
# negative assertion (proving absence), so a fixed sleep is unavoidable here.
await asyncio.sleep(1.5)
assert not pref_file.exists(), (
"Suspended syncer flushed to disk; component.suspend did not stop the poller"
)
# --- Resume: flushing must restart. ---
client.button_command(resume_button.key)
await _wait_for(
syncer_resumed, "Syncer resume command was not processed within timeout"
)
await _wait_for(
_poll_until_exists(pref_file),
"Resumed syncer never flushed to disk; component.resume did not restart the poller",
timeout=10.0,
)
@@ -736,6 +736,68 @@ async def test_uart_mock_modbus_custom_pdu(
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.asyncio
async def test_uart_mock_modbus_continuous(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that `continuous: true` polls faster than the update_interval.
The controller's update_interval is 30s, so without continuous polling only the boot poll would
run during the short test window. With continuous the read is re-queued after each success, filling
idle bus time, so many reads arrive. The server returns an incrementing counter, so every read is a
distinct published state the tracker can count. (Bus warnings are not asserted here: continuous
polling deliberately saturates the bus, so the occasional timing hiccup is expected and off-topic;
the other tests cover clean operation at normal poll rates.)
"""
tracker = SensorTracker(["continuous_reg"])
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
# setup_and_start_scenario presses the Start Scenario button, whose on_press triggers the
# controller's first update(). With continuous that one read re-queues and streams; without it
# the next poll would not run until the 30s update_interval elapses.
entities = await tracker.setup_and_start_scenario(client)
# Count reads over a window far shorter than the update_interval. Absent continuous polling we
# would see ~1 (the triggered poll); continuous re-queues, so the bus fills with reads.
await asyncio.sleep(3.0)
reads = len(tracker.sensor_states["continuous_reg"])
assert reads >= 5, (
"expected many continuous reads within the window (update_interval is 30s, so absent "
f"continuous polling we would see ~1), got {reads}"
)
# Recovery path: a live continuous poll that starts failing goes offline, and the next update()
# re-arms it once the device answers again. Silence the server so the poll's reads time out; with
# max_cmd_retries=1 and send_wait_time=100ms the device trips offline quickly and streaming stops.
silence = find_entity(entities, "silence_server", SwitchInfo)
assert silence is not None, "Silence Server switch not found"
start = find_entity(entities, "start_scenario", ButtonInfo)
assert start is not None, "Start Scenario button not found"
client.switch_command(silence.key, True)
await asyncio.sleep(1.0) # let the poll fail and the device trip offline
plateau = len(tracker.sensor_states["continuous_reg"])
await asyncio.sleep(1.0) # offline: no polls should land
assert len(tracker.sensor_states["continuous_reg"]) == plateau, (
"reads kept arriving after the server was silenced - the failed continuous poll did not stop"
)
# Answer again and trigger update(): the offline probe recovers the device and the continuous
# poll re-arms, so streaming resumes.
client.switch_command(silence.key, False)
client.button_command(start.key)
await asyncio.sleep(3.0)
resumed = len(tracker.sensor_states["continuous_reg"]) - plateau
assert resumed >= 5, (
f"continuous polling did not resume after the device recovered (got {resumed} new reads)"
)
@pytest.mark.asyncio
async def test_uart_mock_modbus_offline(
yaml_config: str,
+50
View File
@@ -1214,6 +1214,56 @@ def test_count_changed_cpp_files_with_branch() -> None:
mock_changed.assert_called_once_with("release")
@pytest.mark.parametrize(
("changed_files", "expected"),
[
# Core C++ change runs everything
(["esphome/core/helpers.cpp"], (True, [])),
# Core Python change runs everything too
(["esphome/core/config.py"], (True, [])),
# Component C++ change: component plus dependents with C++ tests
(["esphome/components/time/posix_tz.cpp"], (False, ["sntp", "time"])),
# Component Python change shapes the host build (defines, source
# filters), so it must trigger the same tests as a C++ change
(["esphome/components/time/__init__.py"], (False, ["sntp", "time"])),
# Nothing to build when no selected component has C++ tests
(["esphome/components/homeassistant/__init__.py"], (False, [])),
# Test manifest override changes only that component
(["tests/components/time/__init__.py"], (False, ["time"])),
# Test source change only that component
(["tests/components/time/posix_tz.cpp"], (False, ["time"])),
# pytest files and YAML build tests do not affect the test binary
(["tests/components/socket/conftest.py"], (False, [])),
(["tests/components/time/test.esp32-idf.yaml"], (False, [])),
(["README.md", "script/helpers.py"], (False, [])),
([], (False, [])),
],
)
def test_determine_cpp_unit_tests(
changed_files: list[str],
expected: tuple[bool, list[str]],
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test which C++ unit tests a set of changed files selects."""
tests_dir = tmp_path / "tests" / "components"
for component in ("time", "sntp"):
(tests_dir / component).mkdir(parents=True)
(tests_dir / component / f"{component}.cpp").write_text("")
(tests_dir / "homeassistant").mkdir()
(tests_dir / "socket").mkdir()
monkeypatch.setattr(helpers, "root_path", str(tmp_path))
with (
patch.object(determine_jobs, "changed_files", return_value=changed_files),
patch.object(
helpers,
"create_components_graph",
return_value={"time": ["homeassistant", "sntp"]},
),
):
assert determine_jobs.determine_cpp_unit_tests() == expected
def test_main_filters_components_without_tests(
mock_determine_integration_tests: Mock,
mock_should_run_clang_tidy: Mock,
+89
View File
@@ -2031,3 +2031,92 @@ def test_get_changed_files_from_command_gh_failure_keeps_stderr() -> None:
pytest.raises(Exception, match="maximum number of changed files"),
):
_get_changed_files_from_command(["gh", "pr", "diff", "123", "--name-only"])
@pytest.mark.parametrize(
("file_path", "expected"),
[
("esphome/components/time/posix_tz.cpp", True),
("esphome/components/time/posix_tz.h", True),
("esphome/components/time/__init__.py", True),
("esphome/components/sntp/time.py", True),
("tests/components/time/posix_tz.cpp", True),
("tests/components/time/__init__.py", True),
# Platform override: tests/components/<component>/<domain>/__init__.py
("tests/components/template/sensor/__init__.py", True),
# pytest-only files do not shape the C++ test binary
("tests/components/socket/conftest.py", False),
("tests/components/socket/test_socket.py", False),
("tests/components/time/test.esp32-idf.yaml", False),
("esphome/core/time.cpp", False),
("esphome/config.py", False),
("script/helpers.py", False),
("README.md", False),
],
)
def test_filter_cpp_unit_test_files(file_path: str, expected: bool) -> None:
"""Test which changed files can affect a component's C++ unit test build."""
assert helpers.filter_cpp_unit_test_files(file_path) is expected
@pytest.fixture
def cpp_unit_test_tree(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Fake repo root where time, sntp and api have C++ unit tests.
homeassistant depends on time but has no C++ tests, so it must be
dropped from the selection; socket has only pytest files.
"""
tests_dir = tmp_path / "tests" / "components"
for component in ("time", "sntp", "api"):
(tests_dir / component).mkdir(parents=True)
(tests_dir / component / f"{component}.cpp").write_text("")
(tests_dir / "homeassistant").mkdir()
(tests_dir / "homeassistant" / "__init__.py").write_text("")
(tests_dir / "socket").mkdir()
(tests_dir / "socket" / "conftest.py").write_text("")
monkeypatch.setattr(helpers, "root_path", str(tmp_path))
monkeypatch.setattr(
helpers,
"create_components_graph",
lambda: {"time": ["homeassistant", "sntp"]},
)
return tmp_path
@pytest.mark.parametrize(
("files", "expected"),
[
# Component changes expand to dependents with C++ tests
(["esphome/components/time/posix_tz.cpp"], ["sntp", "time"]),
(["esphome/components/time/__init__.py"], ["sntp", "time"]),
# Dependent without C++ tests is dropped
(["esphome/components/homeassistant/__init__.py"], []),
# Test changes select only that component
(["tests/components/time/posix_tz.cpp"], ["time"]),
(["tests/components/time/__init__.py"], ["time"]),
(["tests/components/homeassistant/__init__.py"], []),
(["tests/components/socket/conftest.py"], []),
(["tests/components/time/test.esp32-idf.yaml"], []),
(
["esphome/components/time/__init__.py", "tests/components/api/api.cpp"],
["api", "sntp", "time"],
),
([], []),
],
)
@pytest.mark.usefixtures("cpp_unit_test_tree")
def test_get_cpp_changed_components(files: list[str], expected: list[str]) -> None:
"""Test that C++ and Python component changes select the right unit tests."""
assert helpers.get_cpp_changed_components(files) == expected
def test_get_cpp_changed_components_independent_of_cwd(
cpp_unit_test_tree: Path,
tmp_path_factory: pytest.TempPathFactory,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test directories resolve against root_path, not the current directory."""
monkeypatch.chdir(tmp_path_factory.mktemp("elsewhere"))
assert helpers.get_cpp_changed_components(
["tests/components/time/__init__.py"]
) == ["time"]