Skip to content
This repository was archived by the owner on May 12, 2026. It is now read-only.

Commit be825d6

Browse files
committed
test: Add comprehensive unit tests for core modules
Add new test files for core classes: - tests/core/test_config_loader.py: 11 test cases for ConfigMerger - Deep merge functionality (basic and nested) - Config file loading (valid, invalid, missing) - Device config loading with merge logic - tests/core/test_shell.py: 17 test cases for ShellRunner - Platform detection and binary path resolution - Command execution (list and string formats) - Error handling and callback support - Environment variable and working directory support - tests/core/test_base_modifier.py: 10 test cases for BaseModifier - File and directory recursive finding - Multiple match handling - Non-existent path handling
1 parent ecbdde7 commit be825d6

3 files changed

Lines changed: 390 additions & 0 deletions

File tree

tests/core/test_base_modifier.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""Unit tests for BaseModifier class."""
2+
3+
from pathlib import Path
4+
from unittest.mock import Mock
5+
6+
import pytest
7+
8+
from src.core.modifiers.base_modifier import BaseModifier
9+
10+
11+
class TestBaseModifier:
12+
"""Test cases for BaseModifier class."""
13+
14+
@pytest.fixture
15+
def modifier(self):
16+
"""Create a BaseModifier instance with mock context."""
17+
context = Mock()
18+
context.logger = Mock()
19+
return BaseModifier(context, "TestModifier")
20+
21+
def test_init(self, modifier):
22+
"""Test BaseModifier initialization."""
23+
assert modifier.name == "TestModifier"
24+
assert modifier.ctx is not None
25+
assert modifier.logger is not None
26+
27+
def test_find_file_recursive_found(self, modifier, tmp_path):
28+
"""Test finding a file that exists."""
29+
# Create nested directory structure
30+
nested = tmp_path / "a" / "b" / "c"
31+
nested.mkdir(parents=True)
32+
target_file = nested / "target.txt"
33+
target_file.write_text("content")
34+
35+
result = modifier._find_file_recursive(tmp_path, "target.txt")
36+
assert result == target_file
37+
38+
def test_find_file_recursive_not_found(self, modifier, tmp_path):
39+
"""Test finding a file that doesn't exist."""
40+
result = modifier._find_file_recursive(tmp_path, "nonexistent.txt")
41+
assert result is None
42+
43+
def test_find_file_recursive_in_root(self, modifier, tmp_path):
44+
"""Test finding a file in root directory."""
45+
target_file = tmp_path / "root_file.txt"
46+
target_file.write_text("content")
47+
48+
result = modifier._find_file_recursive(tmp_path, "root_file.txt")
49+
assert result == target_file
50+
51+
def test_find_file_recursive_multiple_matches(self, modifier, tmp_path):
52+
"""Test finding returns first match when multiple files exist."""
53+
(tmp_path / "file1.txt").write_text("content")
54+
nested = tmp_path / "subdir"
55+
nested.mkdir()
56+
(nested / "file1.txt").write_text("content2")
57+
58+
result = modifier._find_file_recursive(tmp_path, "file1.txt")
59+
assert result is not None
60+
assert result.name == "file1.txt"
61+
62+
def test_find_file_recursive_nonexistent_dir(self, modifier):
63+
"""Test finding in non-existent directory returns None."""
64+
result = modifier._find_file_recursive(Path("/nonexistent/path"), "file.txt")
65+
assert result is None
66+
67+
def test_find_dir_recursive_found(self, modifier, tmp_path):
68+
"""Test finding a directory that exists."""
69+
nested = tmp_path / "level1" / "level2" / "target"
70+
nested.mkdir(parents=True)
71+
72+
result = modifier._find_dir_recursive(tmp_path, "target")
73+
assert result == nested
74+
75+
def test_find_dir_recursive_not_found(self, modifier, tmp_path):
76+
"""Test finding a directory that doesn't exist."""
77+
result = modifier._find_dir_recursive(tmp_path, "nonexistent_dir")
78+
assert result is None
79+
80+
def test_find_dir_recursive_exact_name_match(self, modifier, tmp_path):
81+
"""Test that directory name must match exactly."""
82+
# Create dir with similar but different name
83+
(tmp_path / "target_dir").mkdir()
84+
(tmp_path / "target").mkdir()
85+
86+
result = modifier._find_dir_recursive(tmp_path, "target")
87+
assert result is not None
88+
assert result.name == "target"
89+
90+
def test_find_dir_recursive_prefers_deeper_match(self, modifier, tmp_path):
91+
"""Test finding directories at different levels."""
92+
(tmp_path / "target").mkdir()
93+
deep = tmp_path / "a" / "b" / "target"
94+
deep.mkdir(parents=True)
95+
96+
result = modifier._find_dir_recursive(tmp_path, "target")
97+
# Should find the first one encountered by rglob
98+
assert result is not None
99+
assert result.name == "target"

tests/core/test_config_loader.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""Unit tests for ConfigMerger class."""
2+
3+
import json
4+
import logging
5+
from pathlib import Path
6+
7+
import pytest
8+
9+
from src.core.config_loader import ConfigMerger, load_device_config
10+
11+
12+
class TestConfigMerger:
13+
"""Test cases for ConfigMerger class."""
14+
15+
@pytest.fixture
16+
def merger(self, tmp_path):
17+
"""Create a ConfigMerger instance with temporary directories."""
18+
logger = logging.getLogger("test")
19+
return ConfigMerger(logger)
20+
21+
def test_deep_merge_basic(self, merger):
22+
"""Test basic deep merge functionality."""
23+
base = {"a": 1, "b": {"c": 2, "d": 3}}
24+
override = {"b": {"c": 10}, "e": 5}
25+
result = merger.deep_merge(base, override)
26+
27+
assert result["a"] == 1
28+
assert result["b"]["c"] == 10
29+
assert result["b"]["d"] == 3
30+
assert result["e"] == 5
31+
32+
def test_deep_merge_nested(self, merger):
33+
"""Test deep merge with nested dictionaries."""
34+
base = {"level1": {"level2": {"value": "original"}}}
35+
override = {"level1": {"level2": {"value": "overridden"}}}
36+
result = merger.deep_merge(base, override)
37+
assert result["level1"]["level2"]["value"] == "overridden"
38+
39+
def test_deep_merge_skips_metadata(self, merger):
40+
"""Test that metadata keys (starting with _) are skipped."""
41+
base = {"a": 1}
42+
override = {"_comment": "This should be ignored", "a": 2}
43+
result = merger.deep_merge(base, override)
44+
45+
assert "_comment" not in result
46+
assert result["a"] == 2
47+
48+
def test_load_config_file_not_found(self, merger, tmp_path):
49+
"""Test loading a non-existent config file."""
50+
config_path = tmp_path / "nonexistent.json"
51+
result = merger.load_config(config_path)
52+
assert result == {}
53+
54+
def test_load_config_valid_json(self, merger, tmp_path):
55+
"""Test loading a valid JSON config file."""
56+
config_path = tmp_path / "config.json"
57+
config_data = {"key": "value", "nested": {"inner": "data"}}
58+
59+
with open(config_path, "w") as f:
60+
json.dump(config_data, f)
61+
62+
result = merger.load_config(config_path)
63+
assert result == config_data
64+
65+
def test_load_config_invalid_json(self, merger, tmp_path, caplog):
66+
"""Test loading an invalid JSON file logs error."""
67+
config_path = tmp_path / "invalid.json"
68+
config_path.write_text("not valid json")
69+
70+
with caplog.at_level(logging.ERROR):
71+
result = merger.load_config(config_path)
72+
73+
assert result == {}
74+
assert "Failed to parse" in caplog.text
75+
76+
def test_load_config_io_error(self, merger, tmp_path, caplog):
77+
"""Test handling IO errors during config loading."""
78+
# Create a directory with the same name to cause read error
79+
config_path = tmp_path / "config.json"
80+
config_path.mkdir()
81+
82+
with caplog.at_level(logging.ERROR):
83+
result = merger.load_config(config_path)
84+
85+
assert result == {}
86+
87+
88+
class TestLoadDeviceConfig:
89+
"""Test cases for load_device_config function."""
90+
91+
def test_load_device_config_nonexistent_device(self, tmp_path, monkeypatch, caplog):
92+
"""Test loading config for a non-existent device."""
93+
monkeypatch.chdir(tmp_path)
94+
95+
# Create devices directory structure
96+
devices_dir = tmp_path / "devices"
97+
devices_dir.mkdir()
98+
(devices_dir / "common").mkdir()
99+
(devices_dir / "common" / "config.json").write_text('{"common_key": "value"}')
100+
101+
with caplog.at_level(logging.INFO):
102+
result = load_device_config("nonexistent")
103+
104+
assert "common_key" in result
105+
106+
def test_load_device_config_merges_configs(self, tmp_path, monkeypatch):
107+
"""Test that device config properly merges with common config."""
108+
monkeypatch.chdir(tmp_path)
109+
110+
devices_dir = tmp_path / "devices"
111+
common_dir = devices_dir / "common"
112+
device_dir = devices_dir / "testdevice"
113+
common_dir.mkdir(parents=True)
114+
device_dir.mkdir(parents=True)
115+
116+
# Write common config
117+
with open(common_dir / "config.json", "w") as f:
118+
json.dump({"wild_boost": {"enable": False}, "pack": {"type": "payload"}}, f)
119+
120+
# Write device config
121+
with open(device_dir / "config.json", "w") as f:
122+
json.dump({"wild_boost": {"enable": True}}, f)
123+
124+
result = load_device_config("testdevice")
125+
126+
# Device should override common
127+
assert result["wild_boost"]["enable"] is True
128+
# Common values should be preserved
129+
assert result["pack"]["type"] == "payload"

tests/core/test_shell.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""Unit tests for ShellRunner class."""
2+
3+
import subprocess
4+
from pathlib import Path
5+
from unittest.mock import Mock, patch
6+
7+
import pytest
8+
9+
from src.utils.shell import ShellRunner
10+
11+
12+
class TestShellRunner:
13+
"""Test cases for ShellRunner class."""
14+
15+
@pytest.fixture
16+
def runner(self):
17+
"""Create a ShellRunner instance."""
18+
return ShellRunner()
19+
20+
def test_init_sets_platform(self, runner):
21+
"""Test that initialization sets platform attributes."""
22+
assert runner.os_name in ["linux", "darwin", "windows"]
23+
assert runner.arch in ["x86_64", "aarch64"]
24+
25+
def test_get_binary_path_platform_specific(self, runner, tmp_path, monkeypatch):
26+
"""Test finding binary in platform-specific directory."""
27+
# Setup mock binary directory
28+
runner.bin_dir = tmp_path / "bin"
29+
tool_path = runner.bin_dir / "testtool"
30+
runner.bin_dir.mkdir()
31+
tool_path.touch()
32+
33+
result = runner.get_binary_path("testtool")
34+
assert result == tool_path
35+
36+
def test_get_binary_path_otatools(self, runner, tmp_path, monkeypatch):
37+
"""Test finding binary in otatools directory."""
38+
runner.otatools_bin = tmp_path / "otatools" / "bin"
39+
runner.otatools_bin.mkdir(parents=True)
40+
tool_path = runner.otatools_bin / "otatool"
41+
tool_path.touch()
42+
43+
result = runner.get_binary_path("otatool")
44+
assert result == tool_path
45+
46+
def test_get_binary_path_fallback(self, runner):
47+
"""Test fallback to command name when binary not found."""
48+
result = runner.get_binary_path("nonexistent_tool_12345")
49+
assert result == Path("nonexistent_tool_12345")
50+
51+
@patch("subprocess.run")
52+
def test_run_with_list_command(self, mock_run, runner):
53+
"""Test running a command as a list."""
54+
mock_run.return_value = subprocess.CompletedProcess(
55+
args=["echo", "test"], returncode=0, stdout="test\n", stderr=""
56+
)
57+
58+
result = runner.run(["echo", "test"])
59+
60+
mock_run.assert_called_once()
61+
assert result.returncode == 0
62+
63+
@patch("subprocess.run")
64+
def test_run_with_string_command(self, mock_run, runner):
65+
"""Test running a command as a string."""
66+
mock_run.return_value = subprocess.CompletedProcess(
67+
args="echo test", returncode=0, stdout="test\n", stderr=""
68+
)
69+
70+
result = runner.run("echo test", shell=True)
71+
72+
mock_run.assert_called_once()
73+
74+
@patch("subprocess.run")
75+
def test_run_raises_on_failure(self, mock_run, runner):
76+
"""Test that run raises exception on command failure."""
77+
mock_run.side_effect = subprocess.CalledProcessError(
78+
returncode=1, cmd=["false"], output="", stderr="error message"
79+
)
80+
81+
with pytest.raises(subprocess.CalledProcessError):
82+
runner.run(["false"])
83+
84+
@patch("subprocess.run")
85+
def test_run_check_false_no_raise(self, mock_run, runner):
86+
"""Test that check=False doesn't raise on failure."""
87+
mock_run.return_value = subprocess.CompletedProcess(
88+
args=["false"], returncode=1, stdout="", stderr=""
89+
)
90+
91+
result = runner.run(["false"], check=False)
92+
assert result.returncode == 1
93+
94+
@patch("subprocess.run")
95+
def test_run_with_cwd(self, mock_run, runner, tmp_path):
96+
"""Test running command with specific working directory."""
97+
mock_run.return_value = subprocess.CompletedProcess(
98+
args=["pwd"], returncode=0, stdout=str(tmp_path) + "\n", stderr=""
99+
)
100+
101+
runner.run(["pwd"], cwd=tmp_path)
102+
103+
call_kwargs = mock_run.call_args[1]
104+
assert call_kwargs["cwd"] == tmp_path
105+
106+
@patch("subprocess.run")
107+
def test_run_with_env(self, mock_run, runner):
108+
"""Test running command with custom environment variables."""
109+
mock_run.return_value = subprocess.CompletedProcess(
110+
args=["env"], returncode=0, stdout="", stderr=""
111+
)
112+
113+
runner.run(["env"], env={"TEST_VAR": "test_value"})
114+
115+
call_kwargs = mock_run.call_args[1]
116+
assert "TEST_VAR" in call_kwargs["env"]
117+
assert call_kwargs["env"]["TEST_VAR"] == "test_value"
118+
119+
@patch("subprocess.Popen")
120+
def test_run_with_logger(self, mock_popen, runner, caplog):
121+
"""Test running command with logger streams output."""
122+
mock_process = Mock()
123+
mock_process.stdout = iter(["line1\n", "line2\n"])
124+
mock_process.wait.return_value = 0
125+
mock_popen.return_value = mock_process
126+
127+
logger = Mock()
128+
129+
runner.run(["test"], logger=logger)
130+
131+
mock_popen.assert_called_once()
132+
assert logger.info.called
133+
134+
@patch("subprocess.Popen")
135+
def test_run_with_callback(self, mock_popen, runner):
136+
"""Test running command with output callback."""
137+
mock_process = Mock()
138+
mock_process.stdout = iter(["line1\n", "line2\n"])
139+
mock_process.wait.return_value = 0
140+
mock_popen.return_value = mock_process
141+
142+
callback_lines = []
143+
runner.run(["test"], on_line=callback_lines.append)
144+
145+
assert len(callback_lines) == 2
146+
assert "line1" in callback_lines
147+
assert "line2" in callback_lines
148+
149+
def test_run_java_jar(self, runner):
150+
"""Test helper for running Java JAR files."""
151+
with patch.object(runner, "run") as mock_run:
152+
mock_run.return_value = subprocess.CompletedProcess(
153+
args=["java", "-jar", "test.jar", "arg1"], returncode=0, stdout="", stderr=""
154+
)
155+
156+
result = runner.run_java_jar("test.jar", ["arg1"])
157+
158+
mock_run.assert_called_once()
159+
call_args = mock_run.call_args[0][0]
160+
assert call_args[0] == "java"
161+
assert call_args[1] == "-jar"
162+
assert "arg1" in call_args

0 commit comments

Comments
 (0)