mirror of
https://github.com/esphome/esphome.git
synced 2026-09-11 23:37:34 +00:00
Merge remote-tracking branch 'origin/dev' into api-reverse-connection
This commit is contained in:
@@ -188,6 +188,8 @@ lvgl:
|
||||
dark_mode: true
|
||||
obj:
|
||||
border_width: 1
|
||||
user_1:
|
||||
bg_color: black
|
||||
|
||||
gradients:
|
||||
- id: color_bar
|
||||
@@ -717,6 +719,30 @@ lvgl:
|
||||
id: button_with_text
|
||||
text: Clicked
|
||||
|
||||
# Exercises the LV_STATE_USER_1..USER_4 states: setting them at creation
|
||||
# (both literal and lambda), styling each of them individually, and
|
||||
# setting/clearing them at runtime with lvgl.widget.update.
|
||||
- button:
|
||||
id: user_flags_button
|
||||
text: User flags
|
||||
state:
|
||||
user_1: true
|
||||
user_2: !lambda return true;
|
||||
user_1:
|
||||
bg_color: 0xFF00FF
|
||||
user_2:
|
||||
bg_color: 0x00FFFF
|
||||
user_3:
|
||||
bg_color: 0xFFFF00
|
||||
user_4:
|
||||
bg_color: 0x808080
|
||||
on_click:
|
||||
- lvgl.widget.update:
|
||||
id: user_flags_button
|
||||
state:
|
||||
user_3: true
|
||||
user_4: !lambda return !lv_obj_has_state(id(user_flags_button), LV_STATE_USER_4);
|
||||
|
||||
- button:
|
||||
layout: 2x1
|
||||
id: button_button
|
||||
|
||||
@@ -28,8 +28,21 @@ sensor:
|
||||
accuracy_decimals: 1
|
||||
nox_index:
|
||||
name: NOx Index
|
||||
algorithm_tuning:
|
||||
index_offset: 8
|
||||
learning_time_offset_hours: 6
|
||||
learning_time_gain_hours: 24
|
||||
gating_max_duration_minutes: 900
|
||||
gain_factor: 180
|
||||
voc_index:
|
||||
name: VOC Index
|
||||
algorithm_tuning:
|
||||
index_offset: 120
|
||||
learning_time_offset_hours: 6
|
||||
learning_time_gain_hours: 24
|
||||
gating_max_duration_minutes: 240
|
||||
std_initial: 75
|
||||
gain_factor: 180
|
||||
co2:
|
||||
name: Carbon Dioxide
|
||||
formaldehyde:
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Config-only: partial algorithm_tuning blocks, so the schema defaults fill in the
|
||||
# keys that are left out.
|
||||
packages:
|
||||
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
|
||||
|
||||
sensor:
|
||||
- platform: sen6x
|
||||
id: sen6x_partial_tuning
|
||||
type: SEN65
|
||||
i2c_id: i2c_bus
|
||||
voc_index:
|
||||
name: VOC Index
|
||||
algorithm_tuning:
|
||||
index_offset: 60
|
||||
nox_index:
|
||||
name: NOx Index
|
||||
algorithm_tuning:
|
||||
gain_factor: 45
|
||||
@@ -23,6 +23,7 @@ import pytest_asyncio
|
||||
|
||||
import esphome.config
|
||||
from esphome.core import CORE
|
||||
from esphome.helpers import get_usable_cpu_count
|
||||
from esphome.platformio.toolchain import get_idedata
|
||||
|
||||
from .const import (
|
||||
@@ -67,6 +68,14 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
|
||||
env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps" / worker)
|
||||
# Prevent cache cleaning during integration tests
|
||||
env["ESPHOME_SKIP_CLEAN_BUILD"] = "1"
|
||||
# Cap each compile's -j so several xdist workers do not each spawn a
|
||||
# full-width compiler fan-out on the same machine. An explicit env wins.
|
||||
if "ESPHOME_DEFAULT_COMPILE_PROCESS_LIMIT" not in os.environ:
|
||||
workers = int(os.environ.get("PYTEST_XDIST_WORKER_COUNT", "1"))
|
||||
# Floor of 2 keeps a lone tail compile from running fully serial
|
||||
env["ESPHOME_DEFAULT_COMPILE_PROCESS_LIMIT"] = str(
|
||||
max(2, get_usable_cpu_count() // workers)
|
||||
)
|
||||
# Compile with THIS tree's esphome sources, not wherever the venv's editable
|
||||
# install points (which may be a different git worktree or checkout).
|
||||
repo_root = str(Path(__file__).resolve().parent.parent.parent)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
{
|
||||
"tests/integration/test_action_concurrent_reentry.py": 45.23,
|
||||
"tests/integration/test_addressable_light_transition.py": 74.47,
|
||||
"tests/integration/test_alarm_control_panel_state_transitions.py": 74.1,
|
||||
"tests/integration/test_api_action_metadata.py": 62.1,
|
||||
"tests/integration/test_api_action_responses.py": 71.08,
|
||||
"tests/integration/test_api_action_timeout.py": 21.64,
|
||||
"tests/integration/test_api_conditional_memory.py": 13.72,
|
||||
"tests/integration/test_api_custom_services.py": 24.16,
|
||||
"tests/integration/test_api_get_time_response_timezone.py": 23.48,
|
||||
"tests/integration/test_api_homeassistant.py": 37.87,
|
||||
"tests/integration/test_api_homeassistant_action_no_subscriber.py": 14.38,
|
||||
"tests/integration/test_api_list_entities_backpressure.py": 26.85,
|
||||
"tests/integration/test_api_message_size_batching.py": 33.36,
|
||||
"tests/integration/test_api_reboot_timeout.py": 13.63,
|
||||
"tests/integration/test_api_string_lambda.py": 25.04,
|
||||
"tests/integration/test_api_vv_logging.py": 16.6,
|
||||
"tests/integration/test_api_zero_psk_provisioning.py": 43.14,
|
||||
"tests/integration/test_areas_and_devices.py": 25.98,
|
||||
"tests/integration/test_automation_wait_actions.py": 21.91,
|
||||
"tests/integration/test_automations.py": 42.43,
|
||||
"tests/integration/test_batch_delay_zero_rapid_transitions.py": 16.65,
|
||||
"tests/integration/test_binary_sensor_autorepeat_filter.py": 28.67,
|
||||
"tests/integration/test_binary_sensor_invalidate_state.py": 23.69,
|
||||
"tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 22.99,
|
||||
"tests/integration/test_build_info.py": 24.96,
|
||||
"tests/integration/test_camera_mock.py": 14.47,
|
||||
"tests/integration/test_climate_control_action.py": 31.07,
|
||||
"tests/integration/test_climate_custom_modes.py": 28.59,
|
||||
"tests/integration/test_continuation_actions.py": 14.96,
|
||||
"tests/integration/test_cover_control_action.py": 26.14,
|
||||
"tests/integration/test_crc8_helper.py": 10.92,
|
||||
"tests/integration/test_device_id_in_state.py": 64.97,
|
||||
"tests/integration/test_duplicate_entities.py": 30.81,
|
||||
"tests/integration/test_entity_icon.py": 32.85,
|
||||
"tests/integration/test_fan_turn_on_action.py": 24.91,
|
||||
"tests/integration/test_fnv1_hash_object_id.py": 12.54,
|
||||
"tests/integration/test_fnv1a_hash.py": 21.8,
|
||||
"tests/integration/test_gpio_expander_cache.py": 5.2,
|
||||
"tests/integration/test_host_logger_thread_safety.py": 21.7,
|
||||
"tests/integration/test_host_mode_basic.py": 13.62,
|
||||
"tests/integration/test_host_mode_batch_delay.py": 14.56,
|
||||
"tests/integration/test_host_mode_climate_basic_state.py": 30.95,
|
||||
"tests/integration/test_host_mode_climate_control.py": 29.06,
|
||||
"tests/integration/test_host_mode_empty_string_options.py": 27.22,
|
||||
"tests/integration/test_host_mode_entity_fields.py": 30.95,
|
||||
"tests/integration/test_host_mode_fan_preset.py": 14.44,
|
||||
"tests/integration/test_host_mode_many_entities.py": 54.13,
|
||||
"tests/integration/test_host_mode_many_entities_multiple_connections.py": 32.17,
|
||||
"tests/integration/test_host_mode_noise_encryption.py": 42.77,
|
||||
"tests/integration/test_host_mode_reconnect.py": 4.06,
|
||||
"tests/integration/test_host_mode_sensor.py": 13.47,
|
||||
"tests/integration/test_host_ota.py": 21.4,
|
||||
"tests/integration/test_host_preferences.py": 25.43,
|
||||
"tests/integration/test_host_preferences_suspend_resume.py": 19.2,
|
||||
"tests/integration/test_improv_serial_uart.py": 31.52,
|
||||
"tests/integration/test_large_message_batching.py": 15.64,
|
||||
"tests/integration/test_legacy_area.py": 22.63,
|
||||
"tests/integration/test_legacy_climate_compat.py": 26.13,
|
||||
"tests/integration/test_legacy_fan_compat.py": 24.05,
|
||||
"tests/integration/test_light_automations.py": 30.86,
|
||||
"tests/integration/test_light_binary_effect_off_phase.py": 23.19,
|
||||
"tests/integration/test_light_calls.py": 32.35,
|
||||
"tests/integration/test_light_constant_brightness.py": 29.89,
|
||||
"tests/integration/test_light_control_action.py": 29.06,
|
||||
"tests/integration/test_light_dim_relative_action.py": 29.61,
|
||||
"tests/integration/test_light_effect_zero_brightness.py": 18.68,
|
||||
"tests/integration/test_light_initial_state.py": 24.49,
|
||||
"tests/integration/test_light_toggle_action.py": 26.46,
|
||||
"tests/integration/test_lock_automations.py": 23.28,
|
||||
"tests/integration/test_logger_buffered_recursion_guard.py": 24.29,
|
||||
"tests/integration/test_loop_disable_enable.py": 45.28,
|
||||
"tests/integration/test_loop_interval_decoupling.py": 28.35,
|
||||
"tests/integration/test_loop_interval_default_not_pulled_forward.py": 21.97,
|
||||
"tests/integration/test_micros_to_millis.py": 20.79,
|
||||
"tests/integration/test_multi_click_trigger.py": 26.2,
|
||||
"tests/integration/test_multi_device_preferences.py": 16.87,
|
||||
"tests/integration/test_noise_encryption_key_protection.py": 77.05,
|
||||
"tests/integration/test_object_id_api_verification.py": 73.51,
|
||||
"tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 62.33,
|
||||
"tests/integration/test_object_id_no_friendly_name.py": 43.47,
|
||||
"tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 32.21,
|
||||
"tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 56.86,
|
||||
"tests/integration/test_online_image_bmp.py": 50.9,
|
||||
"tests/integration/test_oversized_payloads.py": 53.2,
|
||||
"tests/integration/test_preference_key_stability.py": 26.09,
|
||||
"tests/integration/test_runtime_stats.py": 18.34,
|
||||
"tests/integration/test_safe_mode_loop_runs.py": 10.07,
|
||||
"tests/integration/test_scheduler_blocking_warning.py": 40.91,
|
||||
"tests/integration/test_scheduler_bulk_cleanup.py": 23.14,
|
||||
"tests/integration/test_scheduler_defer_cancel.py": 24.54,
|
||||
"tests/integration/test_scheduler_defer_cancel_regular.py": 13.48,
|
||||
"tests/integration/test_scheduler_defer_fifo_simple.py": 26.86,
|
||||
"tests/integration/test_scheduler_defer_stress.py": 27.23,
|
||||
"tests/integration/test_scheduler_heap_stress.py": 24.02,
|
||||
"tests/integration/test_scheduler_internal_id_no_collision.py": 24.57,
|
||||
"tests/integration/test_scheduler_interval_reschedule.py": 13.12,
|
||||
"tests/integration/test_scheduler_interval_zero_coerced.py": 22.91,
|
||||
"tests/integration/test_scheduler_null_name.py": 23.46,
|
||||
"tests/integration/test_scheduler_numeric_id_test.py": 24.54,
|
||||
"tests/integration/test_scheduler_pool.py": 25.0,
|
||||
"tests/integration/test_scheduler_rapid_cancellation.py": 14.68,
|
||||
"tests/integration/test_scheduler_recursive_timeout.py": 25.35,
|
||||
"tests/integration/test_scheduler_removed_item_race.py": 26.19,
|
||||
"tests/integration/test_scheduler_self_keyed.py": 23.43,
|
||||
"tests/integration/test_scheduler_simultaneous_callbacks.py": 22.16,
|
||||
"tests/integration/test_scheduler_string_test.py": 15.22,
|
||||
"tests/integration/test_script_array_params.py": 14.67,
|
||||
"tests/integration/test_script_delay_params.py": 15.65,
|
||||
"tests/integration/test_script_queued.py": 24.93,
|
||||
"tests/integration/test_script_queued_idle_loop.py": 5.04,
|
||||
"tests/integration/test_script_wait_on_boot.py": 13.08,
|
||||
"tests/integration/test_select_stringref_trigger.py": 29.6,
|
||||
"tests/integration/test_sensor_filters_delta.py": 28.01,
|
||||
"tests/integration/test_sensor_filters_ring_buffer.py": 25.04,
|
||||
"tests/integration/test_sensor_filters_sliding_window.py": 71.5,
|
||||
"tests/integration/test_sensor_filters_value_list.py": 16.94,
|
||||
"tests/integration/test_sensor_timeout_filter.py": 29.48,
|
||||
"tests/integration/test_socket_wake_gate_tcp.py": 20.36,
|
||||
"tests/integration/test_status_flags.py": 37.42,
|
||||
"tests/integration/test_strftime_to.py": 22.61,
|
||||
"tests/integration/test_syslog.py": 16.34,
|
||||
"tests/integration/test_template_alarm_control_panel_many_sensors.py": 29.81,
|
||||
"tests/integration/test_template_text_save.py": 25.43,
|
||||
"tests/integration/test_text_command.py": 23.34,
|
||||
"tests/integration/test_text_sensor_raw_state.py": 69.57,
|
||||
"tests/integration/test_uart_mock_ld2410.py": 37.95,
|
||||
"tests/integration/test_uart_mock_ld2412.py": 93.22,
|
||||
"tests/integration/test_uart_mock_ld2420.py": 43.24,
|
||||
"tests/integration/test_uart_mock_ld2450.py": 31.75,
|
||||
"tests/integration/test_uart_mock_modbus.py": 667.4,
|
||||
"tests/integration/test_udp.py": 9.38,
|
||||
"tests/integration/test_use_address_runtime.py": 37.05,
|
||||
"tests/integration/test_valve_control_action.py": 24.47,
|
||||
"tests/integration/test_varint_five_byte_device_id.py": 25.03,
|
||||
"tests/integration/test_wait_until_mid_loop_timing.py": 23.73,
|
||||
"tests/integration/test_wait_until_on_boot.py": 9.16,
|
||||
"tests/integration/test_wait_until_ordering.py": 13.3,
|
||||
"tests/integration/test_wait_until_reentrant_restart.py": 25.23,
|
||||
"tests/integration/test_wake_loop_forces_phase_b.py": 23.34,
|
||||
"tests/integration/test_water_heater_template.py": 17.67
|
||||
}
|
||||
@@ -151,9 +151,14 @@ def test_main_all_tests_should_run(
|
||||
patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"_all_integration_test_files",
|
||||
"all_integration_test_files",
|
||||
return_value=fake_test_files,
|
||||
),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"load_integration_durations",
|
||||
return_value=dict.fromkeys(fake_test_files, 200.0),
|
||||
),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"get_changed_components",
|
||||
@@ -189,24 +194,12 @@ def test_main_all_tests_should_run(
|
||||
output = json.loads(captured.out)
|
||||
|
||||
assert output["integration_tests"] is True
|
||||
# run_all=True expands to the full glob and pre-buckets into 3 parts.
|
||||
# Each bucket's `tests` is a JSON list of file paths.
|
||||
assert output["integration_run_all"] is True
|
||||
# run_all=True expands to the full glob; balance and naming are pinned
|
||||
# by the unit tests, main() only needs to round-trip the structure
|
||||
assert isinstance(output["integration_test_buckets"], list)
|
||||
assert len(output["integration_test_buckets"]) == 3
|
||||
assert [b["name"] for b in output["integration_test_buckets"]] == [
|
||||
"1/3",
|
||||
"2/3",
|
||||
"3/3",
|
||||
]
|
||||
for bucket in output["integration_test_buckets"]:
|
||||
assert isinstance(bucket["tests"], list)
|
||||
for path in bucket["tests"]:
|
||||
assert isinstance(path, str)
|
||||
bucket_files = [f for b in output["integration_test_buckets"] for f in b["tests"]]
|
||||
assert bucket_files == fake_test_files
|
||||
# Bucket sizes are balanced (max-min difference at most 1).
|
||||
sizes = [len(b["tests"]) for b in output["integration_test_buckets"]]
|
||||
assert max(sizes) - min(sizes) <= 1
|
||||
assert sorted(bucket_files) == fake_test_files
|
||||
assert output["clang_tidy"] is True
|
||||
assert output["clang_tidy_mode"] in ["nosplit", "split"]
|
||||
assert output["clang_format"] is True
|
||||
@@ -509,14 +502,24 @@ def test_compute_integration_test_buckets_at_threshold_stays_single() -> None:
|
||||
|
||||
|
||||
def test_compute_integration_test_buckets_just_over_threshold_splits() -> None:
|
||||
"""One file over the threshold triggers the 3-bucket fan-out, balanced."""
|
||||
"""One file over the threshold fans out fully when the weights demand it."""
|
||||
n = determine_jobs.INTEGRATION_TESTS_SPLIT_THRESHOLD + 1
|
||||
files = [f"tests/integration/test_{i:02d}.py" for i in range(n)]
|
||||
run, buckets = determine_jobs._compute_integration_test_buckets(False, files)
|
||||
with patch.object(
|
||||
determine_jobs,
|
||||
"load_integration_durations",
|
||||
return_value=dict.fromkeys(files, 200.0),
|
||||
):
|
||||
run, buckets = determine_jobs._compute_integration_test_buckets(False, files)
|
||||
assert run is True
|
||||
assert [b["name"] for b in buckets] == ["1/3", "2/3", "3/3"]
|
||||
union = [path for b in buckets for path in b["tests"]]
|
||||
# threshold+1 files x 200s caps at the maximum bucket count.
|
||||
n_buckets = determine_jobs.INTEGRATION_TESTS_SPLIT_BUCKETS
|
||||
assert [b["name"] for b in buckets] == [
|
||||
f"{i + 1}/{n_buckets}" for i in range(n_buckets)
|
||||
]
|
||||
union = sorted(path for b in buckets for path in b["tests"])
|
||||
assert union == sorted(files)
|
||||
# Equal weights => bucket sizes are balanced (difference at most 1).
|
||||
sizes = [len(b["tests"]) for b in buckets]
|
||||
assert max(sizes) - min(sizes) <= 1
|
||||
|
||||
@@ -526,7 +529,7 @@ def test_compute_integration_test_buckets_run_all_with_empty_glob_disables_run()
|
||||
):
|
||||
"""run_all=True but glob returns no files => run suppressed (otherwise
|
||||
pytest would collect tests outside tests/integration/)."""
|
||||
with patch.object(determine_jobs, "_all_integration_test_files", return_value=[]):
|
||||
with patch.object(determine_jobs, "all_integration_test_files", return_value=[]):
|
||||
run, buckets = determine_jobs._compute_integration_test_buckets(True, [])
|
||||
assert run is False
|
||||
assert buckets == []
|
||||
@@ -3146,3 +3149,86 @@ def test_memory_impact_elf_layouts_are_found(tmp_path: Path) -> None:
|
||||
elf.write_text("")
|
||||
|
||||
assert find_elf_path(build_path) == elf, f"{platform} ELF not found"
|
||||
|
||||
|
||||
def test_compute_integration_test_buckets_no_durations_full_fanout() -> None:
|
||||
"""Without recorded durations the fan-out stays at the maximum."""
|
||||
files = [f"tests/integration/test_{i:03d}.py" for i in range(15)]
|
||||
with patch.object(determine_jobs, "load_integration_durations", return_value={}):
|
||||
run, buckets = determine_jobs._compute_integration_test_buckets(False, files)
|
||||
assert run is True
|
||||
assert len(buckets) == determine_jobs.INTEGRATION_TESTS_SPLIT_BUCKETS
|
||||
assert sorted(f for b in buckets for f in b["tests"]) == files
|
||||
|
||||
|
||||
def test_compute_integration_test_buckets_adaptive_count() -> None:
|
||||
"""A small recorded total weight collapses to one bucket above the threshold."""
|
||||
files = [f"tests/integration/test_{i:03d}.py" for i in range(15)]
|
||||
with patch.object(
|
||||
determine_jobs,
|
||||
"load_integration_durations",
|
||||
return_value=dict.fromkeys(files, 10.0),
|
||||
):
|
||||
run, buckets = determine_jobs._compute_integration_test_buckets(False, files)
|
||||
assert run is True
|
||||
# 15 files x 10s recorded = 150s, under the per-bucket weight target.
|
||||
assert [b["name"] for b in buckets] == ["1/1"]
|
||||
assert buckets[0]["tests"] == files
|
||||
|
||||
|
||||
def test_compute_integration_test_buckets_duration_weighted() -> None:
|
||||
"""Heavy files spread across buckets instead of clustering by sorted name."""
|
||||
files = [f"tests/integration/test_{i:03d}.py" for i in range(12)]
|
||||
durations = dict.fromkeys(files, 10.0)
|
||||
durations[files[0]] = 600.0
|
||||
durations[files[1]] = 600.0
|
||||
with patch.object(
|
||||
determine_jobs, "load_integration_durations", return_value=durations
|
||||
):
|
||||
run, buckets = determine_jobs._compute_integration_test_buckets(False, files)
|
||||
assert run is True
|
||||
assert len(buckets) >= 2
|
||||
heavy_buckets = [b for b in buckets if set(files[:2]) & set(b["tests"])]
|
||||
assert len(heavy_buckets) == 2, "heavy files should land in different buckets"
|
||||
assert sorted(f for b in buckets for f in b["tests"]) == files
|
||||
|
||||
|
||||
def test_load_integration_durations_missing_or_corrupt(tmp_path: Path) -> None:
|
||||
"""Missing or unparsable durations data degrades to an empty mapping."""
|
||||
with patch.object(helpers, "root_path", str(tmp_path)):
|
||||
assert determine_jobs.load_integration_durations() == {}
|
||||
durations_file = tmp_path / helpers.INTEGRATION_TEST_DURATIONS_FILE
|
||||
durations_file.parent.mkdir(parents=True)
|
||||
durations_file.write_text("not json")
|
||||
assert determine_jobs.load_integration_durations() == {}
|
||||
durations_file.write_text('{"tests/integration/test_a.py": 12.5}')
|
||||
assert determine_jobs.load_integration_durations() == {
|
||||
"tests/integration/test_a.py": 12.5
|
||||
}
|
||||
# Non-positive entries are dropped, valid ones survive
|
||||
durations_file.write_text(
|
||||
'{"tests/integration/test_a.py": 12.5, "tests/integration/test_b.py": -1}'
|
||||
)
|
||||
assert determine_jobs.load_integration_durations() == {
|
||||
"tests/integration/test_a.py": 12.5
|
||||
}
|
||||
# One non-numeric entry cannot discard the whole recording
|
||||
durations_file.write_text(
|
||||
'{"tests/integration/test_a.py": 12.5, "tests/integration/test_b.py": null}'
|
||||
)
|
||||
assert determine_jobs.load_integration_durations() == {
|
||||
"tests/integration/test_a.py": 12.5
|
||||
}
|
||||
# A non-dict top level degrades to empty
|
||||
durations_file.write_text("[12.5]")
|
||||
assert determine_jobs.load_integration_durations() == {}
|
||||
|
||||
|
||||
def test_committed_integration_durations_are_sane() -> None:
|
||||
"""The committed recording itself holds positive bounded floats."""
|
||||
raw = json.loads(
|
||||
(Path(helpers.root_path) / helpers.INTEGRATION_TEST_DURATIONS_FILE).read_text()
|
||||
)
|
||||
assert raw, "committed durations file missing or empty"
|
||||
assert all(isinstance(v, (int, float)) and 0 < v < 86400 for v in raw.values())
|
||||
assert all(k.startswith("tests/integration/test_") for k in raw)
|
||||
|
||||
@@ -2120,3 +2120,30 @@ def test_get_cpp_changed_components_independent_of_cwd(
|
||||
assert helpers.get_cpp_changed_components(
|
||||
["tests/components/time/__init__.py"]
|
||||
) == ["time"]
|
||||
|
||||
|
||||
def test_lpt_partition_balances_skewed_weights() -> None:
|
||||
"""Heavy items spread across groups instead of clustering."""
|
||||
items = [f"i{n}" for n in range(6)]
|
||||
weights = {"i0": 100.0, "i1": 90.0, "i2": 10.0, "i3": 10.0, "i4": 5.0, "i5": 5.0}
|
||||
groups = helpers.lpt_partition(items, weights, 2)
|
||||
group_weights = sorted(sum(weights[i] for i in g) for g in groups)
|
||||
# Contiguous split would give 200 vs 20; LPT lands at 110 vs 110
|
||||
assert group_weights == [110.0, 110.0]
|
||||
assert sorted(i for g in groups for i in g) == items
|
||||
|
||||
|
||||
def test_lpt_partition_more_groups_than_items() -> None:
|
||||
"""Surplus groups come back empty; every item still lands somewhere."""
|
||||
items = ["a", "b"]
|
||||
groups = helpers.lpt_partition(items, {"a": 1.0, "b": 1.0}, 4)
|
||||
assert len(groups) == 4
|
||||
assert sorted(i for g in groups for i in g) == items
|
||||
assert sum(not g for g in groups) == 2
|
||||
|
||||
|
||||
def test_lpt_partition_tie_determinism() -> None:
|
||||
"""Equal weights assign in input order, so output is reproducible."""
|
||||
items = [f"i{n}" for n in range(4)]
|
||||
weights = dict.fromkeys(items, 1.0)
|
||||
assert helpers.lpt_partition(items, weights, 2) == [["i0", "i2"], ["i1", "i3"]]
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Unit tests for script/update_integration_test_durations.py."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Add the script directory to Python path so we can import the module
|
||||
script_dir = str((Path(__file__).parent / ".." / ".." / "script").resolve())
|
||||
sys.path.insert(0, script_dir)
|
||||
|
||||
import helpers # noqa: E402
|
||||
import update_integration_test_durations as uitd # noqa: E402
|
||||
|
||||
JUNIT_TEMPLATE = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<testsuites><testsuite>{testcases}</testsuite></testsuites>
|
||||
"""
|
||||
|
||||
KNOWN = {
|
||||
"tests/integration/test_a.py",
|
||||
"tests/integration/test_b.py",
|
||||
}
|
||||
|
||||
|
||||
def _write_junit(path: Path, testcases: str) -> None:
|
||||
path.write_text(JUNIT_TEMPLATE.format(testcases=testcases), encoding="utf-8")
|
||||
|
||||
|
||||
def test_collect_durations_sums_per_file(tmp_path: Path) -> None:
|
||||
"""Testcases from the same module sum."""
|
||||
_write_junit(
|
||||
tmp_path / "a.xml",
|
||||
'<testcase classname="tests.integration.test_a" name="t1" time="1.5"/>'
|
||||
'<testcase classname="tests.integration.test_a" name="t2" time="2.0"/>'
|
||||
'<testcase classname="tests.integration.test_b" name="t1" time="4.0"/>',
|
||||
)
|
||||
assert uitd.collect_durations(tmp_path, KNOWN) == {
|
||||
"tests/integration/test_a.py": 3.5,
|
||||
"tests/integration/test_b.py": 4.0,
|
||||
}
|
||||
|
||||
|
||||
def test_collect_durations_class_based_testcase(tmp_path: Path) -> None:
|
||||
"""A class-based classname still maps to its module file."""
|
||||
_write_junit(
|
||||
tmp_path / "a.xml",
|
||||
'<testcase classname="tests.integration.test_a.TestFoo" name="t" time="2.5"/>',
|
||||
)
|
||||
assert uitd.collect_durations(tmp_path, KNOWN) == {
|
||||
"tests/integration/test_a.py": 2.5
|
||||
}
|
||||
|
||||
|
||||
def test_collect_durations_unknown_module_skipped(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A classname that maps to no known file is skipped with a warning."""
|
||||
_write_junit(
|
||||
tmp_path / "a.xml",
|
||||
'<testcase classname="tests.integration.test_gone" name="t" time="2.5"/>',
|
||||
)
|
||||
assert uitd.collect_durations(tmp_path, KNOWN) == {}
|
||||
assert "test_gone" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_collect_durations_skips_skipped_testcases(tmp_path: Path) -> None:
|
||||
"""Skipped testcases do not record a bogus zero duration."""
|
||||
_write_junit(
|
||||
tmp_path / "a.xml",
|
||||
'<testcase classname="tests.integration.test_a" name="t" time="0">'
|
||||
"<skipped/></testcase>",
|
||||
)
|
||||
assert uitd.collect_durations(tmp_path, KNOWN) == {}
|
||||
|
||||
|
||||
def test_collect_durations_unexpected_classname_aborts(tmp_path: Path) -> None:
|
||||
"""A classname outside tests.integration means the junit layout changed."""
|
||||
_write_junit(
|
||||
tmp_path / "a.xml",
|
||||
'<testcase classname="tests.unit_tests.test_x" name="t" time="9.0"/>',
|
||||
)
|
||||
with pytest.raises(SystemExit):
|
||||
uitd.collect_durations(tmp_path, KNOWN)
|
||||
|
||||
|
||||
def test_collect_durations_empty_dir_aborts(tmp_path: Path) -> None:
|
||||
"""No junit XML at all is a hard error, not an empty recording."""
|
||||
with pytest.raises(SystemExit):
|
||||
uitd.collect_durations(tmp_path, KNOWN)
|
||||
|
||||
|
||||
def test_main_merges_partial_run(tmp_path: Path) -> None:
|
||||
"""A partial run merges over the previous data instead of truncating it."""
|
||||
tests_dir = tmp_path / "tests" / "integration"
|
||||
tests_dir.mkdir(parents=True)
|
||||
for name in ("test_a", "test_b", "test_c"):
|
||||
(tests_dir / f"{name}.py").write_text("", encoding="utf-8")
|
||||
durations_file = tmp_path / helpers.INTEGRATION_TEST_DURATIONS_FILE
|
||||
durations_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"tests/integration/test_a.py": 5.0,
|
||||
"tests/integration/test_b.py": 7.0,
|
||||
"tests/integration/test_gone.py": 9.0,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
junit_dir = tmp_path / "junit"
|
||||
junit_dir.mkdir()
|
||||
_write_junit(
|
||||
junit_dir / "a.xml",
|
||||
'<testcase classname="tests.integration.test_a" name="t" time="6.0"/>',
|
||||
)
|
||||
with (
|
||||
patch.object(helpers, "root_path", str(tmp_path)),
|
||||
patch.object(uitd, "DURATIONS_FILE", durations_file),
|
||||
):
|
||||
# 1 of 3 files covered: refused without --allow-partial
|
||||
with patch.object(sys, "argv", ["uitd", str(junit_dir)]):
|
||||
assert uitd.main() == uitd.EXIT_LOW_COVERAGE
|
||||
with patch.object(sys, "argv", ["uitd", str(junit_dir), "--allow-partial"]):
|
||||
assert uitd.main() == 0
|
||||
# test_a updated, test_b kept, deleted test_gone dropped
|
||||
assert json.loads(durations_file.read_text()) == {
|
||||
"tests/integration/test_a.py": 6.0,
|
||||
"tests/integration/test_b.py": 7.0,
|
||||
}
|
||||
@@ -1417,6 +1417,76 @@ def test_prefetch_installs_cached_archives_without_downloads(
|
||||
assert not (tmp_path / pf._SENTINEL_NAME).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("platform_group", "lib_group", "expected"),
|
||||
[
|
||||
(
|
||||
[("toolchain-x@1", _FakeSpec(name="toolchain-x"))],
|
||||
[],
|
||||
["configure", "install", "configure"],
|
||||
),
|
||||
([], [("noise-c@1.0", _FakeSpec(name="noise-c"))], ["configure", "install"]),
|
||||
],
|
||||
)
|
||||
def test_prefetch_reconfigures_only_after_platform_installs(
|
||||
tmp_path: Path, platform_group: list, lib_group: list, expected: list[str]
|
||||
) -> None:
|
||||
"""Installed platform packages get a second configure pass; libraries do not."""
|
||||
_write_ini(tmp_path, "[env:testenv]\nplatform = fake/p@1\n")
|
||||
order: list[str] = []
|
||||
fake_platform = MagicMock()
|
||||
fake_platform.packages = {}
|
||||
fake_platform.configure_project_packages.side_effect = lambda env, targets: (
|
||||
order.append("configure")
|
||||
)
|
||||
config = _fake_config(
|
||||
tmp_path, {"platform": "fake/p@1", "lib_deps": ["esphome/noise-c@1.0"]}
|
||||
)
|
||||
modules = _pio_modules(tmp_path, fake_platform, MagicMock(), config)
|
||||
with (
|
||||
patch.dict("sys.modules", modules),
|
||||
patch.object(
|
||||
pf,
|
||||
"_registry_jobs",
|
||||
side_effect=[([], 0, platform_group), ([], 0, lib_group)],
|
||||
),
|
||||
patch.object(pf, "_uri_jobs", return_value=([], 0, [])),
|
||||
patch.object(pf, "_preinstall", side_effect=lambda *_: order.append("install")),
|
||||
):
|
||||
pf._prefetch(tmp_path, "testenv")
|
||||
assert order == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"err", [RuntimeError("idf_tools.py failed"), SystemExit("postinstall exited")]
|
||||
)
|
||||
def test_prefetch_settle_failure_warns_and_continues(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture, err: BaseException
|
||||
) -> None:
|
||||
"""A failing second configure pass only costs the speedup."""
|
||||
_write_ini(tmp_path, "[env:testenv]\nplatform = fake/p@1\n")
|
||||
fake_platform = MagicMock()
|
||||
fake_platform.packages = {}
|
||||
fake_platform.configure_project_packages.side_effect = [None, err]
|
||||
config = _fake_config(tmp_path, {"platform": "fake/p@1"})
|
||||
modules = _pio_modules(tmp_path, fake_platform, MagicMock(), config)
|
||||
with (
|
||||
patch.dict("sys.modules", modules),
|
||||
patch.object(
|
||||
pf,
|
||||
"_registry_jobs",
|
||||
side_effect=[
|
||||
([], 0, [("toolchain-x@1", _FakeSpec(name="toolchain-x"))]),
|
||||
([], 0, []),
|
||||
],
|
||||
),
|
||||
patch.object(pf, "_uri_jobs", return_value=([], 0, [])),
|
||||
patch.object(pf, "_preinstall"),
|
||||
):
|
||||
pf._prefetch(tmp_path, "testenv")
|
||||
assert f"Could not settle platform packages: {err}" in caplog.text
|
||||
|
||||
|
||||
def test_preinstall_extracts_in_parallel_under_one_lock(tmp_path: Path) -> None:
|
||||
"""The manager lock wraps the whole batch; per-thread managers share
|
||||
its package dir; one failing install leaves the rest alone."""
|
||||
|
||||
Reference in New Issue
Block a user