[tests] Fail component test merge on conflicting duplicate IDs (#16849)

This commit is contained in:
J. Nick Koston
2026-06-08 12:49:25 -05:00
committed by GitHub
parent 54c73bf1bc
commit 36e043debb
125 changed files with 836 additions and 372 deletions

View File

@@ -0,0 +1,114 @@
"""Unit tests for script/ci_check_duplicate_test_ids.py.
These lock in that the guard stays consistent with the actual config merge: it
prefixes substitutions the same way and delegates the conflict decision to
``merge_component_configs.deduplicate_by_id``.
"""
from pathlib import Path
import sys
import pytest
sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve()))
import ci_check_duplicate_test_ids as checker # noqa: E402
def _write_component(tests_dir: Path, name: str, body: str) -> None:
comp = tests_dir / name
comp.mkdir(parents=True)
(comp / "test.esp32-idf.yaml").write_text(body)
@pytest.fixture
def tests_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
monkeypatch.setattr(checker, "TESTS_DIR", tmp_path)
return tmp_path
def test_substitution_only_difference_is_a_conflict(tests_dir: Path) -> None:
"""Raw-identical items that differ only by a substitution still conflict.
This is the class that the first version missed (and broke CI): the merge
prefixes ``${pin}`` per component, so the two become ``${a_pin}`` and
``${b_pin}`` and collide.
"""
shared = "sensor:\n - platform: adc\n id: shared\n pin: ${pin}\n"
_write_component(tests_dir, "comp_a", shared)
_write_component(tests_dir, "comp_b", shared)
result = checker.scan()
assert any("shared" in line for line in result.conflicts), result.conflicts
def test_identical_substitution_free_items_do_not_conflict(tests_dir: Path) -> None:
same = "sensor:\n - platform: template\n id: shared\n name: Fixed\n"
_write_component(tests_dir, "comp_a", same)
_write_component(tests_dir, "comp_b", same)
assert checker.scan().conflicts == []
def test_unique_ids_do_not_conflict(tests_dir: Path) -> None:
_write_component(
tests_dir,
"comp_a",
"sensor:\n - platform: adc\n id: comp_a_sensor\n pin: ${pin}\n",
)
_write_component(
tests_dir,
"comp_b",
"sensor:\n - platform: adc\n id: comp_b_sensor\n pin: ${pin}\n",
)
assert checker.scan().conflicts == []
def test_same_list_key_under_different_paths_is_not_compared(tests_dir: Path) -> None:
"""Ids sharing a list key name but under different parent paths don't conflict.
The merge only concatenates lists at the same path, so ``foo.shared`` and
``bar.shared`` are never compared against each other.
"""
_write_component(
tests_dir, "comp_a", "foo:\n shared:\n - id: dup\n v: 1\n"
)
_write_component(
tests_dir, "comp_b", "bar:\n shared:\n - id: dup\n v: 2\n"
)
assert checker.scan().conflicts == []
def test_int_and_string_ids_are_distinct(tests_dir: Path) -> None:
"""``5`` and ``"5"`` are different ids, exactly as deduplicate_by_id treats them."""
_write_component(tests_dir, "comp_a", "sensor:\n - platform: t\n id: 5\n")
_write_component(tests_dir, "comp_b", 'sensor:\n - platform: t\n id: "5"\n')
assert checker.scan().conflicts == []
def test_unparseable_fixture_is_reported_and_fails(tests_dir: Path) -> None:
"""A fixture that cannot be parsed is surfaced and fails the run, not skipped."""
_write_component(tests_dir, "broken", "foo: [unbalanced\n")
result = checker.scan()
assert result.conflicts == []
assert any("broken" in path for path in result.parse_errors)
# The run as a whole must not pass when a covered fixture was not scanned.
assert checker.main() == 1
def test_allowlisted_singleton_is_not_a_conflict(tests_dir: Path) -> None:
"""Ids in INTENTIONALLY_SHARED_IDS may differ across components."""
_write_component(
tests_dir, "comp_a", "time:\n - platform: sntp\n id: sntp_time\n"
)
_write_component(
tests_dir,
"comp_b",
"time:\n - platform: sntp\n id: sntp_time\n servers: [a.example]\n",
)
assert checker.scan().conflicts == []
def test_empty_scan_fails(tests_dir: Path) -> None:
"""A scan that covers zero fixtures is a false green and must fail."""
result = checker.scan()
assert result.components_scanned == 0
assert checker.main() == 1

View File

@@ -0,0 +1,101 @@
"""Unit tests for script/merge_component_configs.py deduplication."""
from pathlib import Path
import sys
import pytest
# Add the script directory to Python path so we can import the module
sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve()))
import merge_component_configs # noqa: E402
deduplicate_by_id = merge_component_configs.deduplicate_by_id
def test_identical_duplicate_ids_collapse() -> None:
"""Two identical items sharing an id collapse to one without error."""
data = {
"sensor": [
{"id": "shared", "platform": "template", "name": "A"},
{"id": "shared", "platform": "template", "name": "A"},
]
}
result = deduplicate_by_id(data)
assert result["sensor"] == [{"id": "shared", "platform": "template", "name": "A"}]
def test_conflicting_duplicate_ids_raise() -> None:
"""Two different items sharing an id is a hard error naming the id."""
data = {
"sensor": [
{"id": "dup", "platform": "template", "name": "A"},
{"id": "dup", "platform": "template", "name": "B"},
]
}
with pytest.raises(ValueError, match="dup"):
deduplicate_by_id(data)
def test_intentionally_shared_id_does_not_raise() -> None:
"""An allowlisted (section, id) may differ across components and collapse."""
section, id_ = "time", "sntp_time"
assert (section, id_) in merge_component_configs.INTENTIONALLY_SHARED_IDS
data = {
section: [
{"id": id_, "platform": "sntp"},
{"id": id_, "platform": "sntp", "servers": ["a"]},
]
}
result = deduplicate_by_id(data)
# First occurrence wins, no error raised
assert result[section] == [{"id": id_, "platform": "sntp"}]
def test_allowlisted_id_in_other_section_still_raises() -> None:
"""The allowlist is keyed on (section, id): the same id elsewhere conflicts."""
data = {
"sensor": [
{"id": "sntp_time", "platform": "a"},
{"id": "sntp_time", "platform": "b"},
]
}
with pytest.raises(ValueError, match="sntp_time"):
deduplicate_by_id(data)
def test_items_without_id_are_preserved() -> None:
"""Items lacking an id are passed through untouched."""
data = {"binary_sensor": [{"platform": "gpio"}, {"platform": "gpio"}]}
result = deduplicate_by_id(data)
assert result["binary_sensor"] == [{"platform": "gpio"}, {"platform": "gpio"}]
def test_comparison_is_type_sensitive() -> None:
"""Comparison matches the merge exactly: 5 and "5" are a conflict.
The duplicate-id CI guard reuses this function, so a looser (e.g. string
normalized) comparison would let the guard disagree with the build.
"""
data = {
"sensor": [
{"id": "dup", "platform": "adc", "pin": 5},
{"id": "dup", "platform": "adc", "pin": "5"},
]
}
with pytest.raises(ValueError, match="dup"):
deduplicate_by_id(data)
def test_nested_lists_are_checked() -> None:
"""Conflicts nested inside dict values are also detected."""
data = {
"wrapper": {
"sensor": [
{"id": "dup", "value": 1},
{"id": "dup", "value": 2},
]
}
}
with pytest.raises(ValueError, match="dup"):
deduplicate_by_id(data)