Skip to content

Commit 083c94e

Browse files
jgamblinclaude
andcommitted
test: add missing coverage for Docker parsing, CLI commands, config integration, severity
Added comprehensive tests closing key coverage gaps: 1. Docker _parse_reclaimed_space: GB/MB/KB/B parsing, empty/no-match cases 2. Docker config flags: stopped containers, all flags disabled 3. CLI report command: dry-run with nonexistent config 4. CLI config command: displays 'not found, using defaults' 5. Config integration: verifies config values reach module constructors 6. Severity enum: from_str for all levels, str conversion 7. worst_severity: empty list, single/mixed findings Coverage increased from 79% to 81% (187 tests passing). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f1b8af8 commit 083c94e

4 files changed

Lines changed: 198 additions & 0 deletions

File tree

tests/modules/test_docker.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,89 @@ def test_audit_returns_empty(docker_module):
8080
assert isinstance(result, AuditResult)
8181
assert result.status == "pass"
8282
assert result.findings == []
83+
84+
85+
def test_parse_reclaimed_space_gb():
86+
"""Test parsing GB from docker prune output."""
87+
output = "Total reclaimed space: 1.5GB"
88+
result = DockerModule._parse_reclaimed_space(output)
89+
assert result == 1610612736 # 1.5 * 1024^3
90+
91+
92+
def test_parse_reclaimed_space_mb():
93+
"""Test parsing MB from docker prune output."""
94+
output = "Total reclaimed space: 500MB"
95+
result = DockerModule._parse_reclaimed_space(output)
96+
assert result == 524288000 # 500 * 1024^2
97+
98+
99+
def test_parse_reclaimed_space_kb():
100+
"""Test parsing KB from docker prune output."""
101+
output = "Total reclaimed space: 1024KB"
102+
result = DockerModule._parse_reclaimed_space(output)
103+
assert result == 1048576 # 1024 * 1024
104+
105+
106+
def test_parse_reclaimed_space_bytes():
107+
"""Test parsing bytes from docker prune output."""
108+
output = "Total reclaimed space: 100B"
109+
result = DockerModule._parse_reclaimed_space(output)
110+
assert result == 100
111+
112+
113+
def test_parse_reclaimed_space_empty():
114+
"""Test parsing empty string returns 0."""
115+
result = DockerModule._parse_reclaimed_space("")
116+
assert result == 0
117+
118+
119+
def test_parse_reclaimed_space_no_match():
120+
"""Test parsing random text returns 0."""
121+
output = "Some random docker output without space info"
122+
result = DockerModule._parse_reclaimed_space(output)
123+
assert result == 0
124+
125+
126+
def test_parse_reclaimed_space_with_surrounding_text():
127+
"""Test parsing with other text before/after the reclaimed line."""
128+
output = """Deleted Images:
129+
untagged: sha256:abc123
130+
untagged: sha256:def456
131+
132+
Total reclaimed space: 2.5GB
133+
134+
Some other output here"""
135+
result = DockerModule._parse_reclaimed_space(output)
136+
assert result == 2684354560 # 2.5 * 1024^3
137+
138+
139+
def test_scan_docker_stopped_containers():
140+
"""Test scan reports stopped containers when enabled."""
141+
docker_module = DockerModule(remove_stopped_containers=True)
142+
with (
143+
patch.object(docker_module, "_is_docker_running", return_value=True),
144+
patch.object(docker_module, "_get_dangling_images", return_value=[]),
145+
patch.object(docker_module, "_get_unused_volumes", return_value=[]),
146+
patch.object(docker_module, "_get_stopped_containers", return_value=["c1", "c2"]),
147+
):
148+
result = docker_module.scan()
149+
150+
assert isinstance(result, ScanResult)
151+
assert len(result.items) == 1
152+
assert "2 stopped containers" in result.items[0]
153+
154+
155+
def test_clean_all_flags_disabled():
156+
"""Test clean does nothing when all flags are False."""
157+
docker_module = DockerModule(
158+
remove_dangling_images=False,
159+
remove_unused_volumes=False,
160+
remove_stopped_containers=False,
161+
)
162+
with patch.object(docker_module, "_is_docker_running", return_value=True):
163+
result = docker_module.clean()
164+
165+
assert isinstance(result, CleanResult)
166+
assert result.items_cleaned == []
167+
assert result.bytes_reclaimed == 0
168+
assert result.errors == []

tests/test_base.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
Finding,
66
Module,
77
ScanResult,
8+
Severity,
9+
worst_severity,
810
)
911

1012

@@ -85,3 +87,68 @@ def test_module_subclass():
8587
assert mod.category == "dev"
8688
scan = mod.scan()
8789
assert len(scan.items) == 1
90+
91+
92+
def test_severity_from_str_pass():
93+
"""Test Severity.from_str for pass."""
94+
assert Severity.from_str("pass") == Severity.PASS
95+
96+
97+
def test_severity_from_str_info():
98+
"""Test Severity.from_str for info."""
99+
assert Severity.from_str("info") == Severity.INFO
100+
101+
102+
def test_severity_from_str_warn():
103+
"""Test Severity.from_str for warn."""
104+
assert Severity.from_str("warn") == Severity.WARN
105+
106+
107+
def test_severity_from_str_fail():
108+
"""Test Severity.from_str for fail."""
109+
assert Severity.from_str("fail") == Severity.FAIL
110+
111+
112+
def test_severity_str_conversion():
113+
"""Test str(Severity) returns lowercase name."""
114+
assert str(Severity.PASS) == "pass"
115+
assert str(Severity.INFO) == "info"
116+
assert str(Severity.WARN) == "warn"
117+
assert str(Severity.FAIL) == "fail"
118+
119+
120+
def test_worst_severity_empty_list():
121+
"""Test worst_severity returns 'pass' for empty list."""
122+
result = worst_severity([])
123+
assert result == "pass"
124+
125+
126+
def test_worst_severity_single_finding():
127+
"""Test worst_severity with a single finding."""
128+
findings = [Finding(severity="warn", title="Test", detail="Detail")]
129+
result = worst_severity(findings)
130+
assert result == "warn"
131+
132+
133+
def test_worst_severity_mixed_findings():
134+
"""Test worst_severity returns the worst severity from mixed findings."""
135+
findings = [
136+
Finding(severity="pass", title="Good", detail="All good"),
137+
Finding(severity="info", title="Info", detail="FYI"),
138+
Finding(severity="warn", title="Warning", detail="Be careful"),
139+
Finding(severity="info", title="Info2", detail="Another FYI"),
140+
]
141+
result = worst_severity(findings)
142+
assert result == "warn"
143+
144+
145+
def test_worst_severity_with_fail():
146+
"""Test worst_severity returns 'fail' when any finding is fail."""
147+
findings = [
148+
Finding(severity="pass", title="Good", detail="All good"),
149+
Finding(severity="warn", title="Warning", detail="Be careful"),
150+
Finding(severity="fail", title="Critical", detail="Very bad"),
151+
Finding(severity="info", title="Info", detail="FYI"),
152+
]
153+
result = worst_severity(findings)
154+
assert result == "fail"

tests/test_cli.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,21 @@ def test_cli_log_no_previous_run():
5353
result = runner.invoke(main, ["log", "--log-dir", "/nonexistent"])
5454
assert result.exit_code == 0
5555
assert "No previous run" in result.output or "not found" in result.output.lower()
56+
57+
58+
def test_cli_report_dry_run_nonexistent_config():
59+
"""Test report command with dry-run and nonexistent config."""
60+
runner = CliRunner()
61+
result = runner.invoke(main, ["report", "--dry-run", "--config", "/nonexistent/path.yml"])
62+
assert result.exit_code == 0
63+
# Should show dry-run preview output
64+
output_lower = result.output.lower()
65+
assert "dry run" in output_lower or "preview" in output_lower or "no config" in output_lower
66+
67+
68+
def test_cli_config_command():
69+
"""Test config command shows not found message for defaults."""
70+
runner = CliRunner()
71+
result = runner.invoke(main, ["config"])
72+
assert result.exit_code == 0
73+
assert "not found, using defaults" in result.output

tests/test_config.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,30 @@ def test_generate_default_config():
7878
assert "categories:" in content
7979
assert "homebrew:" in content
8080
assert "wifi:" in content
81+
82+
83+
def test_config_integration_pipeline():
84+
"""Test full pipeline: write config, load it, get modules, verify module config.
85+
86+
This verifies that config values actually reach module constructors.
87+
"""
88+
from macos_maid.modules import get_all_modules
89+
from macos_maid.modules.homebrew import HomebrewModule
90+
91+
with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f:
92+
yaml.dump({"homebrew": {"update": True}}, f)
93+
f.flush()
94+
config = load_config(Path(f.name))
95+
96+
# Get all modules with this config
97+
modules = get_all_modules(config)
98+
99+
# Find the HomebrewModule instance
100+
homebrew_module = None
101+
for mod in modules:
102+
if isinstance(mod, HomebrewModule):
103+
homebrew_module = mod
104+
break
105+
106+
assert homebrew_module is not None
107+
assert homebrew_module.update is True

0 commit comments

Comments
 (0)