mirror of
https://github.com/esphome/esphome.git
synced 2025-09-01 19:02:18 +01:00
Speed up clang-tidy CI by 80%+ with incremental checking (#9396)
This commit is contained in:
0
tests/script/__init__.py
Normal file
0
tests/script/__init__.py
Normal file
359
tests/script/test_clang_tidy_hash.py
Normal file
359
tests/script/test_clang_tidy_hash.py
Normal file
@@ -0,0 +1,359 @@
|
||||
"""Unit tests for script/clang_tidy_hash.py module."""
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Add the script directory to Python path so we can import clang_tidy_hash
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "script"))
|
||||
|
||||
import clang_tidy_hash # noqa: E402
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("file_content", "expected"),
|
||||
[
|
||||
(
|
||||
"clang-tidy==18.1.5 # via -r requirements_dev.in\n",
|
||||
"clang-tidy==18.1.5 # via -r requirements_dev.in",
|
||||
),
|
||||
(
|
||||
"other-package==1.0\nclang-tidy==17.0.0\nmore-packages==2.0\n",
|
||||
"clang-tidy==17.0.0",
|
||||
),
|
||||
(
|
||||
"# comment\nclang-tidy==16.0.0 # some comment\n",
|
||||
"clang-tidy==16.0.0 # some comment",
|
||||
),
|
||||
("no-clang-tidy-here==1.0\n", "clang-tidy version not found"),
|
||||
],
|
||||
)
|
||||
def test_get_clang_tidy_version_from_requirements(
|
||||
file_content: str, expected: str
|
||||
) -> None:
|
||||
"""Test extracting clang-tidy version from various file formats."""
|
||||
# Mock read_file_lines to return our test content
|
||||
with patch("clang_tidy_hash.read_file_lines") as mock_read:
|
||||
mock_read.return_value = file_content.splitlines(keepends=True)
|
||||
|
||||
result = clang_tidy_hash.get_clang_tidy_version_from_requirements()
|
||||
|
||||
assert result == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("platformio_content", "expected_flags"),
|
||||
[
|
||||
(
|
||||
"[env:esp32]\n"
|
||||
"platform = espressif32\n"
|
||||
"\n"
|
||||
"[flags:clangtidy]\n"
|
||||
"build_flags = -Wall\n"
|
||||
"extra_flags = -Wextra\n"
|
||||
"\n"
|
||||
"[env:esp8266]\n",
|
||||
"build_flags = -Wall\nextra_flags = -Wextra",
|
||||
),
|
||||
(
|
||||
"[flags:clangtidy]\n# Comment line\nbuild_flags = -O2\n\n[next_section]\n",
|
||||
"build_flags = -O2",
|
||||
),
|
||||
(
|
||||
"[flags:clangtidy]\nflag_c = -std=c99\nflag_b = -Wall\nflag_a = -O2\n",
|
||||
"flag_a = -O2\nflag_b = -Wall\nflag_c = -std=c99", # Sorted
|
||||
),
|
||||
(
|
||||
"[env:esp32]\nplatform = espressif32\n", # No clangtidy section
|
||||
"",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_extract_platformio_flags(platformio_content: str, expected_flags: str) -> None:
|
||||
"""Test extracting clang-tidy flags from platformio.ini."""
|
||||
# Mock read_file_lines to return our test content
|
||||
with patch("clang_tidy_hash.read_file_lines") as mock_read:
|
||||
mock_read.return_value = platformio_content.splitlines(keepends=True)
|
||||
|
||||
result = clang_tidy_hash.extract_platformio_flags()
|
||||
|
||||
assert result == expected_flags
|
||||
|
||||
|
||||
def test_calculate_clang_tidy_hash() -> None:
|
||||
"""Test calculating hash from all configuration sources."""
|
||||
clang_tidy_content = b"Checks: '-*,readability-*'\n"
|
||||
requirements_version = "clang-tidy==18.1.5"
|
||||
pio_flags = "build_flags = -Wall"
|
||||
|
||||
# Expected hash calculation
|
||||
expected_hasher = hashlib.sha256()
|
||||
expected_hasher.update(clang_tidy_content)
|
||||
expected_hasher.update(requirements_version.encode())
|
||||
expected_hasher.update(pio_flags.encode())
|
||||
expected_hash = expected_hasher.hexdigest()
|
||||
|
||||
# Mock the dependencies
|
||||
with (
|
||||
patch("clang_tidy_hash.read_file_bytes", return_value=clang_tidy_content),
|
||||
patch(
|
||||
"clang_tidy_hash.get_clang_tidy_version_from_requirements",
|
||||
return_value=requirements_version,
|
||||
),
|
||||
patch("clang_tidy_hash.extract_platformio_flags", return_value=pio_flags),
|
||||
):
|
||||
result = clang_tidy_hash.calculate_clang_tidy_hash()
|
||||
|
||||
assert result == expected_hash
|
||||
|
||||
|
||||
def test_read_stored_hash_exists(tmp_path: Path) -> None:
|
||||
"""Test reading hash when file exists."""
|
||||
stored_hash = "abc123def456"
|
||||
hash_file = tmp_path / ".clang-tidy.hash"
|
||||
hash_file.write_text(f"{stored_hash}\n")
|
||||
|
||||
with (
|
||||
patch("clang_tidy_hash.Path") as mock_path_class,
|
||||
patch("clang_tidy_hash.read_file_lines", return_value=[f"{stored_hash}\n"]),
|
||||
):
|
||||
# Mock the path calculation and exists check
|
||||
mock_hash_file = Mock()
|
||||
mock_hash_file.exists.return_value = True
|
||||
mock_path_class.return_value.parent.parent.__truediv__.return_value = (
|
||||
mock_hash_file
|
||||
)
|
||||
|
||||
result = clang_tidy_hash.read_stored_hash()
|
||||
|
||||
assert result == stored_hash
|
||||
|
||||
|
||||
def test_read_stored_hash_not_exists() -> None:
|
||||
"""Test reading hash when file doesn't exist."""
|
||||
with patch("clang_tidy_hash.Path") as mock_path_class:
|
||||
# Mock the path calculation and exists check
|
||||
mock_hash_file = Mock()
|
||||
mock_hash_file.exists.return_value = False
|
||||
mock_path_class.return_value.parent.parent.__truediv__.return_value = (
|
||||
mock_hash_file
|
||||
)
|
||||
|
||||
result = clang_tidy_hash.read_stored_hash()
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_write_hash() -> None:
|
||||
"""Test writing hash to file."""
|
||||
hash_value = "abc123def456"
|
||||
|
||||
with patch("clang_tidy_hash.write_file_content") as mock_write:
|
||||
clang_tidy_hash.write_hash(hash_value)
|
||||
|
||||
# Verify write_file_content was called with correct parameters
|
||||
mock_write.assert_called_once()
|
||||
args = mock_write.call_args[0]
|
||||
assert str(args[0]).endswith(".clang-tidy.hash")
|
||||
assert args[1] == hash_value
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("args", "current_hash", "stored_hash", "expected_exit"),
|
||||
[
|
||||
(["--check"], "abc123", "abc123", 1), # Hashes match, no scan needed
|
||||
(["--check"], "abc123", "def456", 0), # Hashes differ, scan needed
|
||||
(["--check"], "abc123", None, 0), # No stored hash, scan needed
|
||||
],
|
||||
)
|
||||
def test_main_check_mode(
|
||||
args: list[str], current_hash: str, stored_hash: str | None, expected_exit: int
|
||||
) -> None:
|
||||
"""Test main function in check mode."""
|
||||
with (
|
||||
patch("sys.argv", ["clang_tidy_hash.py"] + args),
|
||||
patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash),
|
||||
patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash),
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
):
|
||||
clang_tidy_hash.main()
|
||||
|
||||
assert exc_info.value.code == expected_exit
|
||||
|
||||
|
||||
def test_main_update_mode(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Test main function in update mode."""
|
||||
current_hash = "abc123"
|
||||
|
||||
with (
|
||||
patch("sys.argv", ["clang_tidy_hash.py", "--update"]),
|
||||
patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash),
|
||||
patch("clang_tidy_hash.write_hash") as mock_write,
|
||||
):
|
||||
clang_tidy_hash.main()
|
||||
|
||||
mock_write.assert_called_once_with(current_hash)
|
||||
captured = capsys.readouterr()
|
||||
assert f"Hash updated: {current_hash}" in captured.out
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("current_hash", "stored_hash"),
|
||||
[
|
||||
("abc123", "def456"), # Hash changed, should update
|
||||
("abc123", None), # No stored hash, should update
|
||||
],
|
||||
)
|
||||
def test_main_update_if_changed_mode_update(
|
||||
current_hash: str, stored_hash: str | None, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Test main function in update-if-changed mode when update is needed."""
|
||||
with (
|
||||
patch("sys.argv", ["clang_tidy_hash.py", "--update-if-changed"]),
|
||||
patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash),
|
||||
patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash),
|
||||
patch("clang_tidy_hash.write_hash") as mock_write,
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
):
|
||||
clang_tidy_hash.main()
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
mock_write.assert_called_once_with(current_hash)
|
||||
captured = capsys.readouterr()
|
||||
assert "Clang-tidy hash updated" in captured.out
|
||||
|
||||
|
||||
def test_main_update_if_changed_mode_no_update(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""Test main function in update-if-changed mode when no update is needed."""
|
||||
current_hash = "abc123"
|
||||
stored_hash = "abc123"
|
||||
|
||||
with (
|
||||
patch("sys.argv", ["clang_tidy_hash.py", "--update-if-changed"]),
|
||||
patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash),
|
||||
patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash),
|
||||
patch("clang_tidy_hash.write_hash") as mock_write,
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
):
|
||||
clang_tidy_hash.main()
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
mock_write.assert_not_called()
|
||||
captured = capsys.readouterr()
|
||||
assert "Clang-tidy hash unchanged" in captured.out
|
||||
|
||||
|
||||
def test_main_verify_mode_success(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Test main function in verify mode when verification passes."""
|
||||
current_hash = "abc123"
|
||||
stored_hash = "abc123"
|
||||
|
||||
with (
|
||||
patch("sys.argv", ["clang_tidy_hash.py", "--verify"]),
|
||||
patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash),
|
||||
patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash),
|
||||
):
|
||||
clang_tidy_hash.main()
|
||||
captured = capsys.readouterr()
|
||||
assert "Hash verification passed" in captured.out
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("current_hash", "stored_hash"),
|
||||
[
|
||||
("abc123", "def456"), # Hashes differ, verification fails
|
||||
("abc123", None), # No stored hash, verification fails
|
||||
],
|
||||
)
|
||||
def test_main_verify_mode_failure(
|
||||
current_hash: str, stored_hash: str | None, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Test main function in verify mode when verification fails."""
|
||||
with (
|
||||
patch("sys.argv", ["clang_tidy_hash.py", "--verify"]),
|
||||
patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash),
|
||||
patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash),
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
):
|
||||
clang_tidy_hash.main()
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "ERROR: Clang-tidy configuration has changed" in captured.out
|
||||
|
||||
|
||||
def test_main_default_mode(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Test main function in default mode (no arguments)."""
|
||||
current_hash = "abc123"
|
||||
stored_hash = "def456"
|
||||
|
||||
with (
|
||||
patch("sys.argv", ["clang_tidy_hash.py"]),
|
||||
patch("clang_tidy_hash.calculate_clang_tidy_hash", return_value=current_hash),
|
||||
patch("clang_tidy_hash.read_stored_hash", return_value=stored_hash),
|
||||
):
|
||||
clang_tidy_hash.main()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert f"Current hash: {current_hash}" in captured.out
|
||||
assert f"Stored hash: {stored_hash}" in captured.out
|
||||
assert "Match: False" in captured.out
|
||||
|
||||
|
||||
def test_read_file_lines(tmp_path: Path) -> None:
|
||||
"""Test read_file_lines helper function."""
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_content = "line1\nline2\nline3\n"
|
||||
test_file.write_text(test_content)
|
||||
|
||||
result = clang_tidy_hash.read_file_lines(test_file)
|
||||
|
||||
assert result == ["line1\n", "line2\n", "line3\n"]
|
||||
|
||||
|
||||
def test_read_file_bytes(tmp_path: Path) -> None:
|
||||
"""Test read_file_bytes helper function."""
|
||||
test_file = tmp_path / "test.bin"
|
||||
test_content = b"binary content\x00\xff"
|
||||
test_file.write_bytes(test_content)
|
||||
|
||||
result = clang_tidy_hash.read_file_bytes(test_file)
|
||||
|
||||
assert result == test_content
|
||||
|
||||
|
||||
def test_write_file_content(tmp_path: Path) -> None:
|
||||
"""Test write_file_content helper function."""
|
||||
test_file = tmp_path / "test.txt"
|
||||
test_content = "test content"
|
||||
|
||||
clang_tidy_hash.write_file_content(test_file, test_content)
|
||||
|
||||
assert test_file.read_text() == test_content
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("line", "expected"),
|
||||
[
|
||||
("clang-tidy==18.1.5", ("clang-tidy", "clang-tidy==18.1.5")),
|
||||
(
|
||||
"clang-tidy==18.1.5 # comment",
|
||||
("clang-tidy", "clang-tidy==18.1.5 # comment"),
|
||||
),
|
||||
("some-package>=1.0,<2.0", ("some-package", "some-package>=1.0,<2.0")),
|
||||
("pkg_with-dashes==1.0", ("pkg_with-dashes", "pkg_with-dashes==1.0")),
|
||||
("# just a comment", None),
|
||||
("", None),
|
||||
(" ", None),
|
||||
("invalid line without version", None),
|
||||
],
|
||||
)
|
||||
def test_parse_requirement_line(line: str, expected: tuple[str, str] | None) -> None:
|
||||
"""Test parsing individual requirement lines."""
|
||||
result = clang_tidy_hash.parse_requirement_line(line)
|
||||
assert result == expected
|
812
tests/script/test_helpers.py
Normal file
812
tests/script/test_helpers.py
Normal file
@@ -0,0 +1,812 @@
|
||||
"""Unit tests for script/helpers.py module."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
# Add the script directory to Python path so we can import helpers
|
||||
sys.path.insert(
|
||||
0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "script"))
|
||||
)
|
||||
|
||||
import helpers # noqa: E402
|
||||
|
||||
changed_files = helpers.changed_files
|
||||
filter_changed = helpers.filter_changed
|
||||
get_changed_components = helpers.get_changed_components
|
||||
_get_changed_files_from_command = helpers._get_changed_files_from_command
|
||||
_get_pr_number_from_github_env = helpers._get_pr_number_from_github_env
|
||||
_get_changed_files_github_actions = helpers._get_changed_files_github_actions
|
||||
_filter_changed_ci = helpers._filter_changed_ci
|
||||
_filter_changed_local = helpers._filter_changed_local
|
||||
build_all_include = helpers.build_all_include
|
||||
print_file_list = helpers.print_file_list
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("github_ref", "expected_pr_number"),
|
||||
[
|
||||
("refs/pull/1234/merge", "1234"),
|
||||
("refs/pull/5678/head", "5678"),
|
||||
("refs/pull/999/merge", "999"),
|
||||
("refs/heads/main", None),
|
||||
("", None),
|
||||
],
|
||||
)
|
||||
def test_get_pr_number_from_github_env_ref(
|
||||
monkeypatch: MonkeyPatch, github_ref: str, expected_pr_number: str | None
|
||||
) -> None:
|
||||
"""Test extracting PR number from GITHUB_REF."""
|
||||
monkeypatch.setenv("GITHUB_REF", github_ref)
|
||||
# Make sure GITHUB_EVENT_PATH is not set
|
||||
monkeypatch.delenv("GITHUB_EVENT_PATH", raising=False)
|
||||
|
||||
result = _get_pr_number_from_github_env()
|
||||
|
||||
assert result == expected_pr_number
|
||||
|
||||
|
||||
def test_get_pr_number_from_github_env_event_file(
|
||||
monkeypatch: MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""Test extracting PR number from GitHub event file."""
|
||||
# No PR number in ref
|
||||
monkeypatch.setenv("GITHUB_REF", "refs/heads/feature-branch")
|
||||
|
||||
event_file = tmp_path / "event.json"
|
||||
event_data = {"pull_request": {"number": 5678}}
|
||||
event_file.write_text(json.dumps(event_data))
|
||||
monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_file))
|
||||
|
||||
result = _get_pr_number_from_github_env()
|
||||
|
||||
assert result == "5678"
|
||||
|
||||
|
||||
def test_get_pr_number_from_github_env_no_pr(
|
||||
monkeypatch: MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""Test when no PR number is available."""
|
||||
monkeypatch.setenv("GITHUB_REF", "refs/heads/main")
|
||||
|
||||
event_file = tmp_path / "event.json"
|
||||
event_data = {"push": {"head_commit": {"id": "abc123"}}}
|
||||
event_file.write_text(json.dumps(event_data))
|
||||
monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_file))
|
||||
|
||||
result = _get_pr_number_from_github_env()
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("github_ref", "expected_pr_number"),
|
||||
[
|
||||
("refs/pull/1234/merge", "1234"),
|
||||
("refs/pull/5678/head", "5678"),
|
||||
("refs/pull/999/merge", "999"),
|
||||
],
|
||||
)
|
||||
def test_github_actions_pull_request_with_pr_number_in_ref(
|
||||
monkeypatch: MonkeyPatch, github_ref: str, expected_pr_number: str
|
||||
) -> None:
|
||||
"""Test PR detection via GITHUB_REF."""
|
||||
monkeypatch.setenv("GITHUB_ACTIONS", "true")
|
||||
monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request")
|
||||
monkeypatch.setenv("GITHUB_REF", github_ref)
|
||||
|
||||
expected_files = ["file1.py", "file2.cpp"]
|
||||
|
||||
with patch("helpers._get_changed_files_from_command") as mock_get:
|
||||
mock_get.return_value = expected_files
|
||||
|
||||
result = changed_files()
|
||||
|
||||
mock_get.assert_called_once_with(
|
||||
["gh", "pr", "diff", expected_pr_number, "--name-only"]
|
||||
)
|
||||
assert result == expected_files
|
||||
|
||||
|
||||
def test_github_actions_pull_request_with_event_file(
|
||||
monkeypatch: MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""Test PR detection via GitHub event file."""
|
||||
monkeypatch.setenv("GITHUB_ACTIONS", "true")
|
||||
monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request")
|
||||
monkeypatch.setenv("GITHUB_REF", "refs/heads/feature-branch")
|
||||
|
||||
event_file = tmp_path / "event.json"
|
||||
event_data = {"pull_request": {"number": 5678}}
|
||||
event_file.write_text(json.dumps(event_data))
|
||||
monkeypatch.setenv("GITHUB_EVENT_PATH", str(event_file))
|
||||
|
||||
expected_files = ["file1.py", "file2.cpp"]
|
||||
|
||||
with patch("helpers._get_changed_files_from_command") as mock_get:
|
||||
mock_get.return_value = expected_files
|
||||
|
||||
result = changed_files()
|
||||
|
||||
mock_get.assert_called_once_with(["gh", "pr", "diff", "5678", "--name-only"])
|
||||
assert result == expected_files
|
||||
|
||||
|
||||
def test_github_actions_push_event(monkeypatch: MonkeyPatch) -> None:
|
||||
"""Test push event handling."""
|
||||
monkeypatch.setenv("GITHUB_ACTIONS", "true")
|
||||
monkeypatch.setenv("GITHUB_EVENT_NAME", "push")
|
||||
|
||||
expected_files = ["file1.py", "file2.cpp"]
|
||||
|
||||
with patch("helpers._get_changed_files_from_command") as mock_get:
|
||||
mock_get.return_value = expected_files
|
||||
|
||||
result = changed_files()
|
||||
|
||||
mock_get.assert_called_once_with(["git", "diff", "HEAD~1..HEAD", "--name-only"])
|
||||
assert result == expected_files
|
||||
|
||||
|
||||
def test_get_changed_files_github_actions_pull_request(
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test _get_changed_files_github_actions for pull request event."""
|
||||
monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request")
|
||||
|
||||
expected_files = ["file1.py", "file2.cpp"]
|
||||
|
||||
with (
|
||||
patch("helpers._get_pr_number_from_github_env", return_value="1234"),
|
||||
patch("helpers._get_changed_files_from_command") as mock_get,
|
||||
):
|
||||
mock_get.return_value = expected_files
|
||||
|
||||
result = _get_changed_files_github_actions()
|
||||
|
||||
mock_get.assert_called_once_with(["gh", "pr", "diff", "1234", "--name-only"])
|
||||
assert result == expected_files
|
||||
|
||||
|
||||
def test_get_changed_files_github_actions_pull_request_no_pr_number(
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test _get_changed_files_github_actions when no PR number is found."""
|
||||
monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request")
|
||||
|
||||
with patch("helpers._get_pr_number_from_github_env", return_value=None):
|
||||
result = _get_changed_files_github_actions()
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_get_changed_files_github_actions_push(monkeypatch: MonkeyPatch) -> None:
|
||||
"""Test _get_changed_files_github_actions for push event."""
|
||||
monkeypatch.setenv("GITHUB_EVENT_NAME", "push")
|
||||
|
||||
expected_files = ["file1.py", "file2.cpp"]
|
||||
|
||||
with patch("helpers._get_changed_files_from_command") as mock_get:
|
||||
mock_get.return_value = expected_files
|
||||
|
||||
result = _get_changed_files_github_actions()
|
||||
|
||||
mock_get.assert_called_once_with(["git", "diff", "HEAD~1..HEAD", "--name-only"])
|
||||
assert result == expected_files
|
||||
|
||||
|
||||
def test_get_changed_files_github_actions_push_fallback(
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test _get_changed_files_github_actions fallback for push event."""
|
||||
monkeypatch.setenv("GITHUB_EVENT_NAME", "push")
|
||||
|
||||
with patch("helpers._get_changed_files_from_command") as mock_get:
|
||||
mock_get.side_effect = Exception("Failed")
|
||||
|
||||
result = _get_changed_files_github_actions()
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_get_changed_files_github_actions_other_event(monkeypatch: MonkeyPatch) -> None:
|
||||
"""Test _get_changed_files_github_actions for other event types."""
|
||||
monkeypatch.setenv("GITHUB_EVENT_NAME", "workflow_dispatch")
|
||||
|
||||
result = _get_changed_files_github_actions()
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_github_actions_push_event_fallback(monkeypatch: MonkeyPatch) -> None:
|
||||
"""Test push event fallback to git merge-base."""
|
||||
monkeypatch.setenv("GITHUB_ACTIONS", "true")
|
||||
monkeypatch.setenv("GITHUB_EVENT_NAME", "push")
|
||||
|
||||
expected_files = ["file1.py", "file2.cpp"]
|
||||
|
||||
with (
|
||||
patch("helpers._get_changed_files_from_command") as mock_get,
|
||||
patch("helpers.get_output") as mock_output,
|
||||
):
|
||||
# First call fails, triggering fallback
|
||||
mock_get.side_effect = [
|
||||
Exception("Failed"),
|
||||
expected_files,
|
||||
]
|
||||
|
||||
mock_output.side_effect = [
|
||||
"origin\nupstream\n", # git remote
|
||||
"abc123\n", # merge base
|
||||
]
|
||||
|
||||
result = changed_files()
|
||||
|
||||
assert mock_get.call_count == 2
|
||||
assert result == expected_files
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("branch", "merge_base"),
|
||||
[
|
||||
(None, "abc123"), # Default branch (dev)
|
||||
("release", "def456"),
|
||||
("beta", "ghi789"),
|
||||
],
|
||||
)
|
||||
def test_local_development_branches(
|
||||
monkeypatch: MonkeyPatch, branch: str | None, merge_base: str
|
||||
) -> None:
|
||||
"""Test local development with different branches."""
|
||||
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
|
||||
|
||||
expected_files = ["file1.py", "file2.cpp"]
|
||||
|
||||
with (
|
||||
patch("helpers.get_output") as mock_output,
|
||||
patch("helpers._get_changed_files_from_command") as mock_get,
|
||||
):
|
||||
if branch is None:
|
||||
# For default branch, helpers.get_output is called twice (git remote and merge-base)
|
||||
mock_output.side_effect = [
|
||||
"origin\nupstream\n", # git remote
|
||||
f"{merge_base}\n", # merge base for upstream/dev
|
||||
]
|
||||
else:
|
||||
# For custom branch, may need more calls if trying multiple remotes
|
||||
mock_output.side_effect = [
|
||||
"origin\nupstream\n", # git remote
|
||||
Exception("not found"), # upstream/{branch} may fail
|
||||
f"{merge_base}\n", # merge base for origin/{branch}
|
||||
]
|
||||
|
||||
mock_get.return_value = expected_files
|
||||
|
||||
result = changed_files(branch)
|
||||
|
||||
mock_get.assert_called_once_with(["git", "diff", merge_base, "--name-only"])
|
||||
assert result == expected_files
|
||||
|
||||
|
||||
def test_local_development_no_remotes_configured(monkeypatch: MonkeyPatch) -> None:
|
||||
"""Test error when no git remotes are configured."""
|
||||
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
|
||||
|
||||
with patch("helpers.get_output") as mock_output:
|
||||
# The function calls get_output multiple times:
|
||||
# 1. First to get list of remotes: git remote
|
||||
# 2. Then for each remote it tries: git merge-base
|
||||
# We simulate having some remotes but all merge-base attempts fail
|
||||
def side_effect_func(*args):
|
||||
if args == ("git", "remote"):
|
||||
return "origin\nupstream\n"
|
||||
else:
|
||||
# All merge-base attempts fail
|
||||
raise Exception("Command failed")
|
||||
|
||||
mock_output.side_effect = side_effect_func
|
||||
|
||||
with pytest.raises(ValueError, match="Git not configured"):
|
||||
changed_files()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("stdout", "expected"),
|
||||
[
|
||||
("file1.py\nfile2.cpp\n\n", ["file1.py", "file2.cpp"]),
|
||||
("\n\n", []),
|
||||
("single.py\n", ["single.py"]),
|
||||
(
|
||||
"path/to/file.cpp\nanother/path.h\n",
|
||||
["another/path.h", "path/to/file.cpp"],
|
||||
), # Sorted
|
||||
],
|
||||
)
|
||||
def test_get_changed_files_from_command_successful(
|
||||
stdout: str, expected: list[str]
|
||||
) -> None:
|
||||
"""Test successful command execution with various outputs."""
|
||||
mock_result = Mock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = stdout
|
||||
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
result = _get_changed_files_from_command(["git", "diff"])
|
||||
|
||||
# Normalize paths to forward slashes for comparison
|
||||
# since os.path.relpath returns OS-specific separators
|
||||
normalized_result = [f.replace(os.sep, "/") for f in result]
|
||||
assert normalized_result == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("returncode", "stderr"),
|
||||
[
|
||||
(1, "Error: command failed"),
|
||||
(128, "fatal: not a git repository"),
|
||||
(2, "Unknown error"),
|
||||
],
|
||||
)
|
||||
def test_get_changed_files_from_command_failed(returncode: int, stderr: str) -> None:
|
||||
"""Test command failure handling."""
|
||||
mock_result = Mock()
|
||||
mock_result.returncode = returncode
|
||||
mock_result.stderr = stderr
|
||||
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
_get_changed_files_from_command(["git", "diff"])
|
||||
assert "Command failed" in str(exc_info.value)
|
||||
assert stderr in str(exc_info.value)
|
||||
|
||||
|
||||
def test_get_changed_files_from_command_relative_paths() -> None:
|
||||
"""Test that paths are made relative to current directory."""
|
||||
mock_result = Mock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = "/some/project/file1.py\n/some/project/sub/file2.cpp\n"
|
||||
|
||||
with (
|
||||
patch("subprocess.run", return_value=mock_result),
|
||||
patch(
|
||||
"os.path.relpath", side_effect=["file1.py", "sub/file2.cpp"]
|
||||
) as mock_relpath,
|
||||
patch("os.getcwd", return_value="/some/project"),
|
||||
):
|
||||
result = _get_changed_files_from_command(["git", "diff"])
|
||||
|
||||
# Check relpath was called with correct arguments
|
||||
assert mock_relpath.call_count == 2
|
||||
assert result == ["file1.py", "sub/file2.cpp"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"changed_files_list",
|
||||
[
|
||||
["esphome/core/component.h", "esphome/components/wifi/wifi.cpp"],
|
||||
["esphome/core/helpers.cpp"],
|
||||
["esphome/core/application.h", "esphome/core/defines.h"],
|
||||
],
|
||||
)
|
||||
def test_get_changed_components_core_files_trigger_full_scan(
|
||||
changed_files_list: list[str],
|
||||
) -> None:
|
||||
"""Test that core file changes trigger full scan without calling subprocess."""
|
||||
with patch("helpers.changed_files") as mock_changed:
|
||||
mock_changed.return_value = changed_files_list
|
||||
|
||||
# Should return None without calling subprocess
|
||||
result = get_changed_components()
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("changed_files_list", "expected"),
|
||||
[
|
||||
# Only component files changed
|
||||
(
|
||||
["esphome/components/wifi/wifi.cpp", "esphome/components/api/api.cpp"],
|
||||
["wifi", "api"],
|
||||
),
|
||||
# Non-component files only
|
||||
(["README.md", "script/clang-tidy"], []),
|
||||
# Single component
|
||||
(["esphome/components/mqtt/mqtt_client.cpp"], ["mqtt"]),
|
||||
],
|
||||
)
|
||||
def test_get_changed_components_returns_component_list(
|
||||
changed_files_list: list[str], expected: list[str]
|
||||
) -> None:
|
||||
"""Test component detection returns correct component list."""
|
||||
with patch("helpers.changed_files") as mock_changed:
|
||||
mock_changed.return_value = changed_files_list
|
||||
|
||||
mock_result = Mock()
|
||||
mock_result.stdout = "\n".join(expected) + "\n" if expected else "\n"
|
||||
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
result = get_changed_components()
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_get_changed_components_script_failure() -> None:
|
||||
"""Test fallback to full scan when script fails."""
|
||||
with patch("helpers.changed_files") as mock_changed:
|
||||
mock_changed.return_value = ["esphome/components/wifi/wifi_component.cpp"]
|
||||
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.side_effect = subprocess.CalledProcessError(1, "cmd")
|
||||
|
||||
result = get_changed_components()
|
||||
|
||||
assert result is None # None means full scan
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("components", "all_files", "expected_files"),
|
||||
[
|
||||
# Core files changed (full scan)
|
||||
(
|
||||
None,
|
||||
["esphome/components/wifi/wifi.cpp", "esphome/core/helpers.cpp"],
|
||||
["esphome/components/wifi/wifi.cpp", "esphome/core/helpers.cpp"],
|
||||
),
|
||||
# Specific components
|
||||
(
|
||||
["wifi", "api"],
|
||||
[
|
||||
"esphome/components/wifi/wifi.cpp",
|
||||
"esphome/components/wifi/wifi.h",
|
||||
"esphome/components/api/api.cpp",
|
||||
"esphome/components/mqtt/mqtt.cpp",
|
||||
],
|
||||
[
|
||||
"esphome/components/wifi/wifi.cpp",
|
||||
"esphome/components/wifi/wifi.h",
|
||||
"esphome/components/api/api.cpp",
|
||||
],
|
||||
),
|
||||
# No components changed
|
||||
(
|
||||
[],
|
||||
["esphome/components/wifi/wifi.cpp", "script/clang-tidy"],
|
||||
["script/clang-tidy"], # Only non-component changed files
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_filter_changed_ci_mode(
|
||||
monkeypatch: MonkeyPatch,
|
||||
components: list[str] | None,
|
||||
all_files: list[str],
|
||||
expected_files: list[str],
|
||||
) -> None:
|
||||
"""Test filter_changed in CI mode with different component scenarios."""
|
||||
monkeypatch.setenv("GITHUB_ACTIONS", "true")
|
||||
|
||||
with patch("helpers.get_changed_components") as mock_components:
|
||||
mock_components.return_value = components
|
||||
|
||||
if components == []:
|
||||
# No components changed scenario needs changed_files mock
|
||||
with patch("helpers.changed_files") as mock_changed:
|
||||
mock_changed.return_value = ["script/clang-tidy", "README.md"]
|
||||
result = filter_changed(all_files)
|
||||
else:
|
||||
result = filter_changed(all_files)
|
||||
|
||||
assert set(result) == set(expected_files)
|
||||
|
||||
|
||||
def test_filter_changed_local_mode(monkeypatch: MonkeyPatch) -> None:
|
||||
"""Test filter_changed in local mode filters files directly."""
|
||||
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
|
||||
|
||||
all_files = [
|
||||
"esphome/components/wifi/wifi.cpp",
|
||||
"esphome/components/api/api.cpp",
|
||||
"esphome/core/helpers.cpp",
|
||||
]
|
||||
|
||||
with patch("helpers.changed_files") as mock_changed:
|
||||
mock_changed.return_value = [
|
||||
"esphome/components/wifi/wifi.cpp",
|
||||
"esphome/core/helpers.cpp",
|
||||
]
|
||||
|
||||
result = filter_changed(all_files)
|
||||
|
||||
# Should only include files that actually changed
|
||||
expected = ["esphome/components/wifi/wifi.cpp", "esphome/core/helpers.cpp"]
|
||||
assert set(result) == set(expected)
|
||||
|
||||
|
||||
def test_filter_changed_component_path_parsing(monkeypatch: MonkeyPatch) -> None:
|
||||
"""Test correct parsing of component paths."""
|
||||
monkeypatch.setenv("GITHUB_ACTIONS", "true")
|
||||
|
||||
all_files = [
|
||||
"esphome/components/wifi/wifi_component.cpp",
|
||||
"esphome/components/wifi_info/wifi_info_text_sensor.cpp", # Different component
|
||||
"esphome/components/api/api_server.cpp",
|
||||
"esphome/components/api/custom_api_device.h",
|
||||
]
|
||||
|
||||
with patch("helpers.get_changed_components") as mock_components:
|
||||
mock_components.return_value = ["wifi"] # Only wifi, not wifi_info
|
||||
|
||||
result = filter_changed(all_files)
|
||||
|
||||
# Should only include files from wifi component, not wifi_info
|
||||
expected = ["esphome/components/wifi/wifi_component.cpp"]
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_filter_changed_prints_output(
|
||||
monkeypatch: MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Test that appropriate messages are printed."""
|
||||
monkeypatch.setenv("GITHUB_ACTIONS", "true")
|
||||
|
||||
all_files = ["esphome/components/wifi/wifi_component.cpp"]
|
||||
|
||||
with patch("helpers.get_changed_components") as mock_components:
|
||||
mock_components.return_value = ["wifi"]
|
||||
|
||||
filter_changed(all_files)
|
||||
|
||||
# Check that output was produced (not checking exact messages)
|
||||
captured = capsys.readouterr()
|
||||
assert len(captured.out) > 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("files", "expected_empty"),
|
||||
[
|
||||
([], True),
|
||||
(["file.cpp"], False),
|
||||
],
|
||||
ids=["empty_files", "non_empty_files"],
|
||||
)
|
||||
def test_filter_changed_empty_file_handling(
|
||||
monkeypatch: MonkeyPatch, files: list[str], expected_empty: bool
|
||||
) -> None:
|
||||
"""Test handling of empty file lists."""
|
||||
monkeypatch.setenv("GITHUB_ACTIONS", "true")
|
||||
|
||||
with patch("helpers.get_changed_components") as mock_components:
|
||||
mock_components.return_value = ["wifi"]
|
||||
|
||||
result = filter_changed(files)
|
||||
|
||||
# Both cases should be empty:
|
||||
# - Empty files list -> empty result
|
||||
# - file.cpp doesn't match esphome/components/wifi/* pattern -> filtered out
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
def test_filter_changed_ci_full_scan() -> None:
|
||||
"""Test _filter_changed_ci when core files changed (full scan)."""
|
||||
all_files = ["esphome/components/wifi/wifi.cpp", "esphome/core/helpers.cpp"]
|
||||
|
||||
with patch("helpers.get_changed_components", return_value=None):
|
||||
result = _filter_changed_ci(all_files)
|
||||
|
||||
# Should return all files for full scan
|
||||
assert result == all_files
|
||||
|
||||
|
||||
def test_filter_changed_ci_no_components_changed() -> None:
|
||||
"""Test _filter_changed_ci when no components changed."""
|
||||
all_files = ["esphome/components/wifi/wifi.cpp", "script/clang-tidy", "README.md"]
|
||||
|
||||
with (
|
||||
patch("helpers.get_changed_components", return_value=[]),
|
||||
patch("helpers.changed_files", return_value=["script/clang-tidy", "README.md"]),
|
||||
):
|
||||
result = _filter_changed_ci(all_files)
|
||||
|
||||
# Should only include non-component files that changed
|
||||
assert set(result) == {"script/clang-tidy", "README.md"}
|
||||
|
||||
|
||||
def test_filter_changed_ci_specific_components() -> None:
|
||||
"""Test _filter_changed_ci with specific components changed."""
|
||||
all_files = [
|
||||
"esphome/components/wifi/wifi.cpp",
|
||||
"esphome/components/wifi/wifi.h",
|
||||
"esphome/components/api/api.cpp",
|
||||
"esphome/components/mqtt/mqtt.cpp",
|
||||
]
|
||||
|
||||
with patch("helpers.get_changed_components", return_value=["wifi", "api"]):
|
||||
result = _filter_changed_ci(all_files)
|
||||
|
||||
# Should include all files from wifi and api components
|
||||
expected = [
|
||||
"esphome/components/wifi/wifi.cpp",
|
||||
"esphome/components/wifi/wifi.h",
|
||||
"esphome/components/api/api.cpp",
|
||||
]
|
||||
assert set(result) == set(expected)
|
||||
|
||||
|
||||
def test_filter_changed_local() -> None:
|
||||
"""Test _filter_changed_local filters based on git changes."""
|
||||
all_files = [
|
||||
"esphome/components/wifi/wifi.cpp",
|
||||
"esphome/components/api/api.cpp",
|
||||
"esphome/core/helpers.cpp",
|
||||
]
|
||||
|
||||
with patch("helpers.changed_files") as mock_changed:
|
||||
mock_changed.return_value = [
|
||||
"esphome/components/wifi/wifi.cpp",
|
||||
"esphome/core/helpers.cpp",
|
||||
]
|
||||
|
||||
result = _filter_changed_local(all_files)
|
||||
|
||||
# Should only include files that actually changed
|
||||
expected = ["esphome/components/wifi/wifi.cpp", "esphome/core/helpers.cpp"]
|
||||
assert set(result) == set(expected)
|
||||
|
||||
|
||||
def test_build_all_include_with_git(tmp_path: Path) -> None:
|
||||
"""Test build_all_include using git ls-files."""
|
||||
# Mock git output
|
||||
git_output = "esphome/core/component.h\nesphome/components/wifi/wifi.h\nesphome/components/api/api.h\n"
|
||||
|
||||
mock_proc = Mock()
|
||||
mock_proc.returncode = 0
|
||||
mock_proc.stdout = git_output
|
||||
|
||||
with (
|
||||
patch("subprocess.run", return_value=mock_proc),
|
||||
patch("helpers.temp_header_file", str(tmp_path / "all-include.cpp")),
|
||||
):
|
||||
build_all_include()
|
||||
|
||||
# Check the generated file
|
||||
include_file = tmp_path / "all-include.cpp"
|
||||
assert include_file.exists()
|
||||
|
||||
content = include_file.read_text()
|
||||
expected_lines = [
|
||||
'#include "esphome/components/api/api.h"',
|
||||
'#include "esphome/components/wifi/wifi.h"',
|
||||
'#include "esphome/core/component.h"',
|
||||
"", # Empty line at end
|
||||
]
|
||||
assert content == "\n".join(expected_lines)
|
||||
|
||||
|
||||
def test_build_all_include_empty_output(tmp_path: Path) -> None:
|
||||
"""Test build_all_include with empty git output."""
|
||||
# Mock git returning empty output
|
||||
mock_proc = Mock()
|
||||
mock_proc.returncode = 0
|
||||
mock_proc.stdout = ""
|
||||
|
||||
with (
|
||||
patch("subprocess.run", return_value=mock_proc),
|
||||
patch("helpers.temp_header_file", str(tmp_path / "all-include.cpp")),
|
||||
):
|
||||
build_all_include()
|
||||
|
||||
# Check the generated file
|
||||
include_file = tmp_path / "all-include.cpp"
|
||||
assert include_file.exists()
|
||||
|
||||
content = include_file.read_text()
|
||||
# When git output is empty, the list comprehension filters out empty strings,
|
||||
# then we append "" to get [""], which joins to just ""
|
||||
assert content == ""
|
||||
|
||||
|
||||
def test_build_all_include_creates_directory(tmp_path: Path) -> None:
|
||||
"""Test that build_all_include creates the temp directory if needed."""
|
||||
# Use a subdirectory that doesn't exist
|
||||
temp_file = tmp_path / "subdir" / "all-include.cpp"
|
||||
|
||||
mock_proc = Mock()
|
||||
mock_proc.returncode = 0
|
||||
mock_proc.stdout = "esphome/core/test.h\n"
|
||||
|
||||
with (
|
||||
patch("subprocess.run", return_value=mock_proc),
|
||||
patch("helpers.temp_header_file", str(temp_file)),
|
||||
):
|
||||
build_all_include()
|
||||
|
||||
# Check that directory was created
|
||||
assert temp_file.parent.exists()
|
||||
assert temp_file.exists()
|
||||
|
||||
|
||||
def test_print_file_list_empty(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Test printing an empty file list."""
|
||||
print_file_list([], "Test Files:")
|
||||
captured = capsys.readouterr()
|
||||
|
||||
assert "Test Files:" in captured.out
|
||||
assert "No files to check!" in captured.out
|
||||
|
||||
|
||||
def test_print_file_list_small(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Test printing a small list of files (less than max_files)."""
|
||||
files = ["file1.cpp", "file2.cpp", "file3.cpp"]
|
||||
print_file_list(files, "Test Files:", max_files=20)
|
||||
captured = capsys.readouterr()
|
||||
|
||||
assert "Test Files:" in captured.out
|
||||
assert " file1.cpp" in captured.out
|
||||
assert " file2.cpp" in captured.out
|
||||
assert " file3.cpp" in captured.out
|
||||
assert "... and" not in captured.out
|
||||
|
||||
|
||||
def test_print_file_list_exact_max_files(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Test printing exactly max_files number of files."""
|
||||
files = [f"file{i}.cpp" for i in range(20)]
|
||||
print_file_list(files, "Test Files:", max_files=20)
|
||||
captured = capsys.readouterr()
|
||||
|
||||
# All files should be shown
|
||||
for i in range(20):
|
||||
assert f" file{i}.cpp" in captured.out
|
||||
assert "... and" not in captured.out
|
||||
|
||||
|
||||
def test_print_file_list_large(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Test printing a large list of files (more than max_files)."""
|
||||
files = [f"file{i:03d}.cpp" for i in range(50)]
|
||||
print_file_list(files, "Test Files:", max_files=20)
|
||||
captured = capsys.readouterr()
|
||||
|
||||
assert "Test Files:" in captured.out
|
||||
# First 10 files should be shown (sorted)
|
||||
for i in range(10):
|
||||
assert f" file{i:03d}.cpp" in captured.out
|
||||
# Files 10-49 should not be shown
|
||||
assert " file010.cpp" not in captured.out
|
||||
assert " file049.cpp" not in captured.out
|
||||
# Should show count of remaining files
|
||||
assert "... and 40 more files" in captured.out
|
||||
|
||||
|
||||
def test_print_file_list_unsorted(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Test that files are sorted before printing."""
|
||||
files = ["z_file.cpp", "a_file.cpp", "m_file.cpp"]
|
||||
print_file_list(files, "Test Files:", max_files=20)
|
||||
captured = capsys.readouterr()
|
||||
|
||||
lines = captured.out.strip().split("\n")
|
||||
# Check order in output
|
||||
assert lines[1] == " a_file.cpp"
|
||||
assert lines[2] == " m_file.cpp"
|
||||
assert lines[3] == " z_file.cpp"
|
||||
|
||||
|
||||
def test_print_file_list_custom_max_files(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Test with custom max_files parameter."""
|
||||
files = [f"file{i}.cpp" for i in range(15)]
|
||||
print_file_list(files, "Test Files:", max_files=10)
|
||||
captured = capsys.readouterr()
|
||||
|
||||
# Should truncate after 10 files
|
||||
assert "... and 5 more files" in captured.out
|
||||
|
||||
|
||||
def test_print_file_list_default_title(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Test with default title."""
|
||||
print_file_list(["test.cpp"])
|
||||
captured = capsys.readouterr()
|
||||
|
||||
assert "Files:" in captured.out
|
||||
assert " test.cpp" in captured.out
|
Reference in New Issue
Block a user