mirror of
https://github.com/esphome/esphome.git
synced 2026-09-24 05:24:14 +00:00
Merge remote-tracking branch 'origin/dev' into jesserockz-2026-627
# Conflicts: # script/git-hooks/post-checkout
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
"""Unit tests for the ESP_LOG-needs-braces lint rule in script/ci-custom.py.
|
||||
|
||||
The rule flags an if/else/for/while whose only body is an unbraced ESP_LOG*() call (which becomes an
|
||||
empty statement -- and a -Wempty-body warning -- once the log level compiles the macro out). These
|
||||
tests pin the comment/string/raw-string masker, the accepted control-statement shapes, and the
|
||||
NOLINT escape hatch at both placements a contributor would try.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
SCRIPT_DIR = (Path(__file__).parent / ".." / ".." / "script").resolve()
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
_spec = importlib.util.spec_from_file_location("ci_custom", SCRIPT_DIR / "ci-custom.py")
|
||||
ci_custom = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(ci_custom)
|
||||
|
||||
mask = ci_custom._mask_cpp_comments_strings
|
||||
|
||||
|
||||
def _lint(content: str) -> list:
|
||||
return ci_custom.lint_esp_log_needs_braces("test.cpp", content)
|
||||
|
||||
|
||||
# --- masker ---
|
||||
|
||||
|
||||
def test_mask_preserves_length_newlines_and_real_parens() -> None:
|
||||
src = 'foo("bar") + baz();\nqux();\n'
|
||||
masked = mask(src)
|
||||
assert len(masked) == len(src)
|
||||
assert masked.count("\n") == src.count("\n")
|
||||
assert masked.count("(") == src.count("(") # real parens survive for balancing
|
||||
|
||||
|
||||
def test_mask_blanks_line_and_block_comments() -> None:
|
||||
assert "ESP_LOGD" not in mask("a; // if (x) ESP_LOGD(t);\n")
|
||||
assert "ESP_LOGD" not in mask("a; /* if (x) ESP_LOGD(t); */ b;\n")
|
||||
|
||||
|
||||
def test_mask_blanks_string_literals() -> None:
|
||||
assert "if" not in mask('x = "if (y) ESP_LOGD";\n')
|
||||
|
||||
|
||||
def test_mask_handles_raw_string_without_desync() -> None:
|
||||
# A raw string full of quotes/parens must be consumed as one unit; code after it stays intact.
|
||||
src = 's.print(R"(<a href="x">)");\nreturn;\n'
|
||||
masked = mask(src)
|
||||
assert "href" not in masked
|
||||
assert "return;" in masked # not swallowed by a desynced string scan
|
||||
|
||||
|
||||
# --- rule: flags real violations ---
|
||||
|
||||
|
||||
def test_flags_unbraced_if_next_line() -> None:
|
||||
assert _lint("if (x)\n ESP_LOGD(t);\n")
|
||||
|
||||
|
||||
def test_flags_unbraced_same_line() -> None:
|
||||
assert _lint("if (x) ESP_LOGW(t);\n")
|
||||
|
||||
|
||||
def test_flags_c_style_for() -> None:
|
||||
assert _lint("for (int i = 0; i < n; i++)\n ESP_LOGD(t, i);\n")
|
||||
|
||||
|
||||
def test_flags_range_for_and_else() -> None:
|
||||
assert _lint("for (auto &x : v)\n ESP_LOGCONFIG(t);\n")
|
||||
assert _lint("else\n ESP_LOGE(t);\n")
|
||||
|
||||
|
||||
def test_flags_for_header_with_nested_call() -> None:
|
||||
assert _lint("for (auto it = v.begin(); it != v.end(); ++it)\n ESP_LOGD(t);\n")
|
||||
|
||||
|
||||
def test_for_header_does_not_reach_into_a_later_statement() -> None:
|
||||
# The 'for' header is bounded to its own statement, so it cannot swallow the loop body and latch
|
||||
# onto a later ')'. Without that, the '#if' line below is reported as an unbraced body even though
|
||||
# the '#' preprocessor check should skip it.
|
||||
assert not _lint(
|
||||
"for (int i = 0; i < n; i++)\n arr[i] = 0;\n#if defined(USE_X)\n ESP_LOGD(t);\n#endif\n"
|
||||
)
|
||||
|
||||
|
||||
def test_violation_after_a_for_loop_is_reported_at_its_own_line() -> None:
|
||||
errors = _lint(
|
||||
"for (int i = 0; i < n; i++)\n sum += a[i];\nif (verbose)\n ESP_LOGD(t, sum);\n"
|
||||
)
|
||||
lines = [line for line, _col, _msg in errors]
|
||||
assert lines == [3] # the 'if', not the 'for' on line 1
|
||||
|
||||
|
||||
def test_flags_lowercase_esph_log_family() -> None:
|
||||
# core/log.h defines esph_log_*() alongside ESP_LOG*(); both expand to nothing below their level.
|
||||
assert _lint('if (x)\n esph_log_config(t, "m");\n')
|
||||
assert _lint('if (err != ESP_OK)\n esph_log_e(t, "m");\n')
|
||||
|
||||
|
||||
def test_digit_separator_does_not_disable_the_rest_of_the_file() -> None:
|
||||
# A "'" digit separator must not be read as a char-literal opener, which blanked everything after.
|
||||
assert _lint("uint32_t x = 1'000;\nif (y)\n ESP_LOGD(t);\n")
|
||||
|
||||
|
||||
def test_mask_still_blanks_real_char_literals() -> None:
|
||||
assert "ESP_LOGD" not in mask("char c = '\"'; // if (x) ESP_LOGD(t);\n")
|
||||
assert not _lint("char sep = ';';\nif (x) {\n ESP_LOGD(t);\n}\n")
|
||||
|
||||
|
||||
def test_flags_multiline_log_body() -> None:
|
||||
assert _lint('if (x)\n ESP_LOGD(t, "%d %d",\n a, b);\n')
|
||||
|
||||
|
||||
def test_raw_string_before_violation_still_caught() -> None:
|
||||
# Regression for the masker desyncing on a raw string and disabling the check for the rest.
|
||||
assert _lint('s.print(R"(<a href="x">)");\nif (y)\n ESP_LOGD(t);\n')
|
||||
|
||||
|
||||
# --- rule: ignores non-violations ---
|
||||
|
||||
|
||||
def test_ignores_braced_body() -> None:
|
||||
assert not _lint("if (x) {\n ESP_LOGD(t);\n}\n")
|
||||
|
||||
|
||||
def test_ignores_commented_out_code() -> None:
|
||||
assert not _lint("// if (x) ESP_LOGD(t);\n")
|
||||
|
||||
|
||||
def test_ignores_preprocessor_else() -> None:
|
||||
assert not _lint("#else\n ESP_LOGCONFIG(t);\n#endif\n")
|
||||
|
||||
|
||||
def test_ignores_non_log_body() -> None:
|
||||
assert not _lint("if (x)\n return false;\n")
|
||||
|
||||
|
||||
# --- NOLINT escape hatch, both placements ---
|
||||
|
||||
|
||||
def test_nolint_at_end_of_log_line_suppresses() -> None:
|
||||
assert not _lint("if (x)\n ESP_LOGD(t); // NOLINT\n")
|
||||
|
||||
|
||||
def test_nolint_on_control_line_suppresses() -> None:
|
||||
assert not _lint("if (x) // NOLINT\n ESP_LOGD(t);\n")
|
||||
@@ -81,3 +81,40 @@ def test_read_file_bytes(tmp_path: Path) -> None:
|
||||
result = clang_tidy_hash.read_file_bytes(test_file)
|
||||
|
||||
assert result == test_content
|
||||
|
||||
|
||||
def test_calculate_idedata_cache_hash_changes_with_infra_code(tmp_path: Path) -> None:
|
||||
_populate(tmp_path)
|
||||
infra = tmp_path / "esphome" / "espidf" / "clang_tidy.py"
|
||||
infra.parent.mkdir(parents=True)
|
||||
infra.write_text("a")
|
||||
before = clang_tidy_hash.calculate_idedata_cache_hash(repo_root=tmp_path)
|
||||
assert before == clang_tidy_hash.calculate_idedata_cache_hash(repo_root=tmp_path)
|
||||
infra.write_text("b")
|
||||
assert clang_tidy_hash.calculate_idedata_cache_hash(repo_root=tmp_path) != before
|
||||
|
||||
|
||||
def test_calculate_idedata_cache_hash_includes_listed_files(tmp_path: Path) -> None:
|
||||
_populate(tmp_path)
|
||||
before = clang_tidy_hash.calculate_idedata_cache_hash(repo_root=tmp_path)
|
||||
listed = tmp_path / "esphome" / "platformio" / "library.py"
|
||||
listed.parent.mkdir(parents=True)
|
||||
listed.write_text("x")
|
||||
assert clang_tidy_hash.calculate_idedata_cache_hash(repo_root=tmp_path) != before
|
||||
|
||||
|
||||
def test_idedata_cache_hash_only_widens_for_esp32(tmp_path: Path) -> None:
|
||||
_populate(tmp_path)
|
||||
infra = tmp_path / "esphome" / "espidf" / "clang_tidy.py"
|
||||
infra.parent.mkdir(parents=True)
|
||||
infra.write_text("a")
|
||||
esp32_before = clang_tidy_hash.idedata_cache_hash("esp32-idf-tidy", tmp_path)
|
||||
other_before = clang_tidy_hash.idedata_cache_hash("esp8266-arduino-tidy", tmp_path)
|
||||
infra.write_text("b")
|
||||
assert (
|
||||
clang_tidy_hash.idedata_cache_hash("esp32-idf-tidy", tmp_path) != esp32_before
|
||||
)
|
||||
assert (
|
||||
clang_tidy_hash.idedata_cache_hash("esp8266-arduino-tidy", tmp_path)
|
||||
== other_before
|
||||
)
|
||||
|
||||
@@ -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 == []
|
||||
@@ -552,6 +555,13 @@ def test_determine_integration_tests(
|
||||
assert run_all is True
|
||||
assert test_files == []
|
||||
|
||||
# Dependency pins and the session init fixture trigger run_all
|
||||
for trigger in sorted(determine_jobs.INTEGRATION_TESTS_TRIGGER_FILES):
|
||||
with patch.object(determine_jobs, "changed_files", return_value=[trigger]):
|
||||
run_all, test_files = determine_jobs.determine_integration_tests()
|
||||
assert run_all is True
|
||||
assert test_files == []
|
||||
|
||||
# Python files directly in esphome/ do NOT trigger tests
|
||||
with patch.object(
|
||||
determine_jobs, "changed_files", return_value=["esphome/config.py"]
|
||||
@@ -3139,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"]]
|
||||
|
||||
@@ -35,8 +35,8 @@ def _load_script():
|
||||
def test_spec_key_collapses_destinations() -> None:
|
||||
"""Two specs delivering one package share a directory and one key."""
|
||||
mod = _load_script()
|
||||
assert mod.spec_key("esphome/noise-c @ 0.1.21") == "noise-c"
|
||||
assert mod.spec_key("esphome/noise-c@0.1.21") == "noise-c"
|
||||
assert mod.spec_key("esphome/noise-c @ 0.1.24") == "noise-c"
|
||||
assert mod.spec_key("esphome/noise-c@0.1.24") == "noise-c"
|
||||
assert mod.spec_key("ESP32Async/AsyncTCP @ ^3.4.10") == mod.spec_key(
|
||||
"esp32async/asynctcp @ 3.5.0"
|
||||
)
|
||||
@@ -54,23 +54,23 @@ def test_parse_specs_and_cli_args(tmp_path: Path) -> None:
|
||||
"[env:a]\n"
|
||||
"platform = fake/platform@1\n"
|
||||
"lib_deps =\n"
|
||||
" esphome/noise-c @ 0.1.21\n"
|
||||
" esphome/noise-c @ 0.1.24\n"
|
||||
" ${common.lib_deps}\n"
|
||||
" internal_lib\n"
|
||||
"[env:b]\n"
|
||||
"lib_deps =\n"
|
||||
" esphome/noise-c @ 0.1.21\n"
|
||||
" esphome/noise-c @ 0.1.24\n"
|
||||
)
|
||||
mod = _load_script()
|
||||
args = Namespace(libraries=True, platforms=True, tools=False)
|
||||
libs, platforms, tools = mod.parse_specs(str(ini), args)
|
||||
# exact-string duplicates collapse; distinct version pins survive
|
||||
assert libs == ["esphome/noise-c @ 0.1.21"]
|
||||
assert libs == ["esphome/noise-c @ 0.1.24"]
|
||||
assert platforms == ["fake/platform@1"]
|
||||
assert tools == []
|
||||
assert mod.build_cli_args(libs, platforms, tools) == [
|
||||
"-l",
|
||||
"esphome/noise-c @ 0.1.21",
|
||||
"esphome/noise-c @ 0.1.24",
|
||||
"-p",
|
||||
"fake/platform@1",
|
||||
]
|
||||
@@ -162,13 +162,13 @@ def test_parallel_install_behavior(tmp_path: Path) -> None:
|
||||
mod.parallel_install(
|
||||
cls,
|
||||
[
|
||||
"esphome/noise-c @ 0.1.21",
|
||||
"esphome/noise-c @ 0.1.21",
|
||||
"esphome/noise-c @ 0.1.24",
|
||||
"esphome/noise-c @ 0.1.24",
|
||||
"esphome/already @ 1.0",
|
||||
"https://x/framework.tar.xz",
|
||||
],
|
||||
)
|
||||
assert cls.calls == ["esphome/noise-c @ 0.1.21"]
|
||||
assert cls.calls == ["esphome/noise-c @ 0.1.24"]
|
||||
assert cls.lock_events == ["lock", "unlock"]
|
||||
|
||||
|
||||
@@ -205,7 +205,7 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None:
|
||||
mod = _load_script()
|
||||
cls = _reset_fake(str(tmp_path))
|
||||
cls.deps = {
|
||||
"esphome/noise-c @ 0.1.21": [
|
||||
"esphome/noise-c @ 0.1.24": [
|
||||
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
|
||||
{"name": "SPI"},
|
||||
],
|
||||
@@ -213,12 +213,12 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None:
|
||||
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
|
||||
],
|
||||
}
|
||||
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.21", "esphome/wg @ 1.0"])
|
||||
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24", "esphome/wg @ 1.0"])
|
||||
assert len(cls.calls) == 3 # the shared dep installs exactly once
|
||||
assert {mod.spec_key(c) for c in cls.calls} == {"noise-c", "wg", "libsodium"}
|
||||
# Wave-1 strings carry no compatibility; the dependency wave does
|
||||
compats = dict(cls.compat_calls)
|
||||
assert compats["esphome/noise-c @ 0.1.21"] is None
|
||||
assert compats["esphome/noise-c @ 0.1.24"] is None
|
||||
dep_compat = next(v for k, v in cls.compat_calls if "libsodium" in k)
|
||||
assert dep_compat is not None # mirrors pio's install_dependency
|
||||
|
||||
@@ -229,11 +229,11 @@ def test_dependency_wave_excludes_url_specs(tmp_path: Path) -> None:
|
||||
mod = _load_script()
|
||||
cls = _reset_fake(str(tmp_path))
|
||||
cls.deps = {
|
||||
"esphome/noise-c @ 0.1.21": [
|
||||
"esphome/noise-c @ 0.1.24": [
|
||||
{"name": "vendored", "version": "https://github.com/x/y.git"},
|
||||
],
|
||||
}
|
||||
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.21"])
|
||||
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24"])
|
||||
assert {mod.spec_key(c) for c in cls.calls} == {"noise-c"}
|
||||
|
||||
|
||||
@@ -348,13 +348,13 @@ def test_warm_store_still_walks_dependencies(tmp_path: Path) -> None:
|
||||
"""Already-installed top-level packages still feed the dependency
|
||||
wave; a warm store can be missing a transitive dep."""
|
||||
mod = _load_script()
|
||||
cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.21"})
|
||||
cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.24"})
|
||||
cls.deps = {
|
||||
"esphome/noise-c @ 0.1.21": [
|
||||
"esphome/noise-c @ 0.1.24": [
|
||||
{"owner": "esphome", "name": "libsodium", "version": "^1.0"},
|
||||
],
|
||||
}
|
||||
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.21"])
|
||||
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24"])
|
||||
assert [mod.spec_key(c) for c in cls.calls] == ["libsodium"]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Unit tests for script/sync_dependency_versions.py."""
|
||||
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import yamlrocks
|
||||
|
||||
sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve()))
|
||||
|
||||
import sync_dependency_versions as sync_mod # noqa: E402
|
||||
|
||||
PRECOMMIT = """\
|
||||
# See https://pre-commit.com for more information
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.1.0
|
||||
hooks:
|
||||
- id: ruff
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: 7.0.0
|
||||
hooks:
|
||||
- id: flake8
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.0.0
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
- repo: https://github.com/pre-commit/mirrors-clang-format
|
||||
rev: v13.0.1
|
||||
hooks:
|
||||
- id: clang-format
|
||||
- repo: https://github.com/adrienverge/yamllint.git
|
||||
rev: v1.0.0
|
||||
hooks:
|
||||
- id: yamllint
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: pylint
|
||||
"""
|
||||
|
||||
REQ_TEST = """\
|
||||
pylint==4.0.8
|
||||
flake8==7.1.0
|
||||
ruff==0.2.0 # comment
|
||||
pyupgrade==3.0.0
|
||||
"""
|
||||
|
||||
REQ_DEV = """\
|
||||
clang-format==13.0.1
|
||||
yamllint==1.0.0
|
||||
"""
|
||||
|
||||
RUFF_REPO = "https://github.com/astral-sh/ruff-pre-commit"
|
||||
DUPLICATE_RUFF_BLOCK = f" - repo: {RUFF_REPO}\n rev: v0.3.0\n hooks: []\n"
|
||||
|
||||
EXPECTED_DRIFT = ["ruff: 0.1.0 -> 0.2.0", "flake8: 7.0.0 -> 7.1.0"]
|
||||
EXPECTED_PRECOMMIT = PRECOMMIT.replace("rev: v0.1.0", "rev: v0.2.0").replace(
|
||||
"rev: 7.0.0", "rev: 7.1.0"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def root(tmp_path: Path) -> Path:
|
||||
"""A fake checkout where ruff (v-prefixed) and flake8 (bare) have drifted."""
|
||||
(tmp_path / ".pre-commit-config.yaml").write_text(PRECOMMIT)
|
||||
(tmp_path / "requirements_test.txt").write_text(REQ_TEST)
|
||||
(tmp_path / "requirements_dev.txt").write_text(REQ_DEV)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _load(text: str) -> object:
|
||||
return yamlrocks.loads(text.encode(), option=yamlrocks.OPT_ROUND_TRIP)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("requirements", "expected"),
|
||||
[
|
||||
("prek==0.5.1 # comment\n", "0.5.1"),
|
||||
("Prek==0.5.1\n", "0.5.1"),
|
||||
("other==1.0\nprek==0.5.1\n", "0.5.1"),
|
||||
("prek>=0.5.1\n", None),
|
||||
("prek-extra==0.5.1\n", None),
|
||||
("", None),
|
||||
],
|
||||
)
|
||||
def test_read_requirement_version(requirements: str, expected: str | None) -> None:
|
||||
assert sync_mod.read_requirement_version(requirements, "prek") == expected
|
||||
|
||||
|
||||
def test_find_repo_entry() -> None:
|
||||
entry = sync_mod.find_repo_entry(_load(PRECOMMIT), RUFF_REPO)
|
||||
assert entry["rev"] == "v0.1.0"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "message"),
|
||||
[
|
||||
("hooks: []\n", "missing key 'repos'"),
|
||||
("repos:\n - rev: 1.0.0\n", "missing key 'repo'"),
|
||||
(PRECOMMIT + DUPLICATE_RUFF_BLOCK, "found 2"),
|
||||
("repos:\n - repo: other\n rev: 1.0.0\n", "found 0"),
|
||||
],
|
||||
)
|
||||
def test_find_repo_entry_errors(text: str, message: str) -> None:
|
||||
with pytest.raises(sync_mod.SyncError, match=message):
|
||||
sync_mod.find_repo_entry(_load(text), RUFF_REPO)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("rev", "expected"),
|
||||
[("v0.1.0", ("v", "0.1.0")), ("7.0.0", ("", "7.0.0")), ("'1.0'", ("", "1.0"))],
|
||||
)
|
||||
def test_current_rev(rev: str, expected: tuple[str, str]) -> None:
|
||||
doc = _load(f"repos:\n - repo: {RUFF_REPO}\n rev: {rev}\n")
|
||||
assert sync_mod.current_rev(doc["repos"][0], RUFF_REPO) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("block", "message"),
|
||||
[(" hooks: []\n", "has no rev"), (" rev: 1.0\n", "not a string: 1.0")],
|
||||
)
|
||||
def test_current_rev_errors(block: str, message: str) -> None:
|
||||
doc = _load(f"repos:\n - repo: {RUFF_REPO}\n{block}")
|
||||
with pytest.raises(sync_mod.SyncError, match=message):
|
||||
sync_mod.current_rev(doc["repos"][0], RUFF_REPO)
|
||||
|
||||
|
||||
def test_sync_reports_without_writing(root: Path) -> None:
|
||||
assert sync_mod.sync(root, write=False) == EXPECTED_DRIFT
|
||||
assert (root / ".pre-commit-config.yaml").read_text() == PRECOMMIT
|
||||
|
||||
|
||||
def test_sync_writes_keeps_layout_and_is_idempotent(root: Path) -> None:
|
||||
assert sync_mod.sync(root, write=True) == EXPECTED_DRIFT
|
||||
assert (root / ".pre-commit-config.yaml").read_text() == EXPECTED_PRECOMMIT
|
||||
assert sync_mod.sync(root, write=True) == []
|
||||
|
||||
|
||||
def test_sync_does_not_touch_a_config_that_matches(root: Path) -> None:
|
||||
(root / ".pre-commit-config.yaml").write_text(EXPECTED_PRECOMMIT)
|
||||
before = (root / ".pre-commit-config.yaml").stat().st_mtime_ns
|
||||
assert sync_mod.sync(root, write=True) == []
|
||||
assert (root / ".pre-commit-config.yaml").stat().st_mtime_ns == before
|
||||
|
||||
|
||||
def test_sync_missing_requirement_pin(root: Path) -> None:
|
||||
(root / "requirements_dev.txt").write_text("")
|
||||
with pytest.raises(sync_mod.SyncError, match="no 'clang-format==' pin"):
|
||||
sync_mod.sync(root, write=True)
|
||||
|
||||
|
||||
def test_sync_propagates_config_errors(root: Path) -> None:
|
||||
(root / ".pre-commit-config.yaml").write_text(PRECOMMIT + DUPLICATE_RUFF_BLOCK)
|
||||
with pytest.raises(sync_mod.SyncError, match="found 2"):
|
||||
sync_mod.sync(root, write=True)
|
||||
|
||||
|
||||
def test_main_check_reports_drift(
|
||||
root: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
assert sync_mod.main(["--check", "--root", str(root)]) == 1
|
||||
assert capsys.readouterr().out.splitlines() == EXPECTED_DRIFT
|
||||
assert (root / ".pre-commit-config.yaml").read_text() == PRECOMMIT
|
||||
|
||||
|
||||
def test_main_writes_then_check_is_clean(
|
||||
root: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
assert sync_mod.main(["--root", str(root)]) == 0
|
||||
assert capsys.readouterr().out.splitlines() == EXPECTED_DRIFT
|
||||
assert sync_mod.main(["--check", "--root", str(root)]) == 0
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
|
||||
def test_main_reports_sync_error(
|
||||
root: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
(root / "requirements_dev.txt").write_text("")
|
||||
assert sync_mod.main(["--root", str(root)]) == 1
|
||||
assert (
|
||||
"error: requirements_dev.txt: no 'clang-format==' pin"
|
||||
in capsys.readouterr().err
|
||||
)
|
||||
|
||||
|
||||
def test_main_defaults_to_repo_root(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
def fake_sync(root: Path, *, write: bool) -> list[str]:
|
||||
seen["root"] = root
|
||||
seen["write"] = write
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(sync_mod, "sync", fake_sync)
|
||||
assert sync_mod.main([]) == 0
|
||||
assert seen == {"root": sync_mod.REPO_ROOT, "write": True}
|
||||
|
||||
|
||||
def test_repository_is_in_sync() -> None:
|
||||
"""The real checkout must match; a failure here means a rev has drifted.
|
||||
|
||||
Also proves every SYNC_TARGETS entry still resolves in the real files.
|
||||
"""
|
||||
assert sync_mod.sync(sync_mod.REPO_ROOT, write=False) == []
|
||||
|
||||
|
||||
def test_cli_entry_point(root: Path) -> None:
|
||||
"""Run the script the way the workflow does, as a subprocess."""
|
||||
script = Path(sync_mod.__file__)
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(script), "--check", "--root", str(root)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 1
|
||||
assert result.stdout.splitlines() == EXPECTED_DRIFT
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user