Skip to content

Commit 5b99638

Browse files
committed
feat: add unit tests for CLI, GPUAnalyzer, and human-readable utilities
1 parent ca04547 commit 5b99638

4 files changed

Lines changed: 342 additions & 0 deletions

File tree

tests/test_cli.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import unittest
2+
from unittest.mock import MagicMock, patch
3+
4+
from click.testing import CliRunner
5+
6+
from canirun.cli import main
7+
from canirun.enum import COMPATIBILITY
8+
9+
10+
class TestCli(unittest.TestCase):
11+
"""Test suite for the CLI module."""
12+
13+
def setUp(self) -> None:
14+
"""Sets up the test environment."""
15+
self.runner = CliRunner()
16+
17+
@patch("canirun.cli.ModelAnalyzer")
18+
def test_cli_success(self, MockAnalyzer: MagicMock) -> None:
19+
"""Tests the CLI with a successful model analysis."""
20+
# Setup mock
21+
instance = MockAnalyzer.return_value
22+
instance.fetch_model_data.return_value = {"some": "data"}
23+
instance.specs = {
24+
"ram": 16 * 1024**3,
25+
"vram": 8 * 1024**3,
26+
"name": "Test GPU",
27+
"is_mac": False,
28+
}
29+
instance.calculate.return_value = [
30+
{
31+
"quant": "FP16",
32+
"total_ram": 14 * 1024**3,
33+
"kv_cache": 1 * 1024**3,
34+
"status": COMPATIBILITY.FULL,
35+
}
36+
]
37+
38+
# Run CLI
39+
result = self.runner.invoke(main, ["test-model"])
40+
41+
# Verify
42+
self.assertEqual(result.exit_code, 0)
43+
self.assertIn("ANALYSIS REPORT: test-model", result.output)
44+
self.assertIn("FP16", result.output)
45+
self.assertIn("✅ GPU", result.output)
46+
47+
@patch("canirun.cli.ModelAnalyzer")
48+
def test_cli_fetch_error(self, MockAnalyzer: MagicMock) -> None:
49+
"""Tests the CLI when model data fetch fails."""
50+
instance = MockAnalyzer.return_value
51+
instance.fetch_model_data.return_value = None
52+
53+
result = self.runner.invoke(main, ["bad-model"])
54+
55+
self.assertEqual(result.exit_code, 0)
56+
self.assertIn("Error: Could not fetch data", result.output)
57+
58+
@patch("canirun.cli.ModelAnalyzer")
59+
def test_cli_verbose(self, MockAnalyzer: MagicMock) -> None:
60+
"""Tests the CLI with verbose flag."""
61+
instance = MockAnalyzer.return_value
62+
instance.fetch_model_data.return_value = {"some": "data"}
63+
instance.specs = {"ram": 0, "vram": 0, "name": "Test", "is_mac": False}
64+
instance.calculate.return_value = [
65+
{
66+
"quant": "FP16",
67+
"total_ram": 100,
68+
"kv_cache": 10,
69+
"status": COMPATIBILITY.FULL,
70+
}
71+
]
72+
73+
result = self.runner.invoke(main, ["test-model", "--verbose"])
74+
75+
self.assertEqual(result.exit_code, 0)
76+
# Verify ModelAnalyzer was initialized with verbose=True
77+
MockAnalyzer.assert_called_with("test-model", verbose=True, hf_token=None)
78+
79+
@patch("canirun.cli.ModelAnalyzer")
80+
def test_cli_with_token(self, MockAnalyzer: MagicMock) -> None:
81+
"""Tests the CLI with an HF token."""
82+
instance = MockAnalyzer.return_value
83+
instance.fetch_model_data.return_value = {"some": "data"}
84+
instance.specs = {"ram": 0, "vram": 0, "name": "Test", "is_mac": False}
85+
instance.calculate.return_value = [
86+
{
87+
"quant": "FP16",
88+
"total_ram": 100,
89+
"kv_cache": 10,
90+
"status": COMPATIBILITY.FULL,
91+
}
92+
]
93+
94+
result = self.runner.invoke(main, ["test-model", "--hf-token", "hf_123"])
95+
96+
self.assertEqual(result.exit_code, 0)
97+
MockAnalyzer.assert_called_with("test-model", verbose=False, hf_token="hf_123")
98+
99+
100+
if __name__ == "__main__":
101+
unittest.main()

tests/test_gpu.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import sys
2+
import unittest
3+
from unittest.mock import MagicMock, patch
4+
5+
from canirun.gpu import GPUAnalyzer
6+
7+
8+
class TestGPUAnalyzer(unittest.TestCase):
9+
"""Test suite for the GPUAnalyzer class."""
10+
11+
@patch("canirun.gpu.shutil.which")
12+
@patch("subprocess.run")
13+
def test_gpu_detection_linux_success(
14+
self,
15+
mock_run: MagicMock,
16+
mock_which: MagicMock,
17+
) -> None:
18+
"""Tests successful GPU detection on Linux."""
19+
# Simulate Linux environment
20+
with patch("sys.platform", "linux"):
21+
mock_which.return_value = "/usr/bin/nvidia-smi"
22+
23+
# Mock successful subprocess output
24+
mock_run.return_value.stdout = "0, NVIDIA GeForce RTX 3080, 10240"
25+
mock_run.return_value.returncode = 0
26+
27+
analyzer = GPUAnalyzer(verbose=True)
28+
29+
self.assertTrue(analyzer.is_gpu_available())
30+
self.assertEqual(analyzer.gpu_count, 1)
31+
self.assertEqual(analyzer.device_name, "NVIDIA GeForce RTX 3080")
32+
self.assertEqual(analyzer.get_device_name(0), "NVIDIA GeForce RTX 3080")
33+
34+
# 10240 MiB -> bytes
35+
expected_vram = 10240 * 1024 * 1024
36+
self.assertEqual(analyzer.vram, expected_vram)
37+
self.assertEqual(analyzer.get_vram(0), expected_vram)
38+
39+
# Check subprocess call
40+
mock_run.assert_called_once()
41+
args, _ = mock_run.call_args
42+
self.assertEqual(args[0][0], "nvidia-smi")
43+
44+
@patch("canirun.gpu.shutil.which")
45+
@patch("subprocess.run")
46+
def test_gpu_detection_windows_success(
47+
self,
48+
mock_run: MagicMock,
49+
mock_which: MagicMock,
50+
) -> None:
51+
"""Tests successful GPU detection on Windows."""
52+
# Simulate Windows environment
53+
with patch("sys.platform", "win32"):
54+
nvidia_path = "C:\\Program Files\\NVIDIA Corporation\\NVSMI\\nvidia-smi.exe"
55+
mock_which.return_value = nvidia_path
56+
57+
mock_run.return_value.stdout = "0, NVIDIA GeForce RTX 4090, 24576"
58+
mock_run.return_value.returncode = 0
59+
60+
analyzer = GPUAnalyzer(verbose=False)
61+
62+
self.assertTrue(analyzer.is_gpu_available())
63+
self.assertEqual(analyzer.device_name, "NVIDIA GeForce RTX 4090")
64+
65+
# 24576 MiB -> bytes
66+
expected_vram = 24576 * 1024 * 1024
67+
self.assertEqual(analyzer.vram, expected_vram)
68+
self.assertEqual(analyzer.get_vram(0), expected_vram)
69+
70+
# Check subprocess call used full path or found executable
71+
mock_run.assert_called_once()
72+
args, _ = mock_run.call_args
73+
self.assertEqual(args[0][0], nvidia_path)
74+
75+
@patch("canirun.gpu.shutil.which")
76+
@patch("subprocess.run")
77+
def test_gpu_not_found(self, mock_run: MagicMock, mock_which: MagicMock) -> None:
78+
"""Tests behavior when no GPU is detected (nvidia-smi fails or missing)."""
79+
# if nvidia-smi missing
80+
mock_which.return_value = None
81+
# On windows, it tries default path even if which returns None,
82+
# so we also need subprocess to fail or return empty
83+
mock_run.side_effect = FileNotFoundError("No file")
84+
85+
analyzer = GPUAnalyzer()
86+
self.assertFalse(analyzer.is_gpu_available())
87+
self.assertEqual(analyzer.gpu_count, 0)
88+
self.assertEqual(analyzer.device_name, "CPU")
89+
self.assertEqual(analyzer.vram, 0)
90+
self.assertEqual(analyzer.get_device_name(0), "CPU")
91+
self.assertEqual(analyzer.get_vram(0), 0)
92+
93+
@patch("canirun.gpu.shutil.which")
94+
@patch("subprocess.run")
95+
def test_multiple_gpus(self, mock_run: MagicMock, mock_which: MagicMock) -> None:
96+
"""Tests detection of multiple GPUs."""
97+
mock_which.return_value = "nvidia-smi"
98+
output = "0, GPU A, 8000\n" "1, GPU B, 12000"
99+
mock_run.return_value.stdout = output
100+
mock_run.return_value.returncode = 0
101+
102+
analyzer = GPUAnalyzer()
103+
104+
self.assertEqual(analyzer.gpu_count, 2)
105+
self.assertEqual(analyzer.get_device_name(0), "GPU A")
106+
self.assertEqual(analyzer.get_device_name(1), "GPU B")
107+
108+
# Test out of bounds
109+
self.assertEqual(
110+
analyzer.get_device_name(99), "GPU A"
111+
) # Should warn and return first
112+
self.assertEqual(analyzer.get_vram(99), 8000 * 1024 * 1024)
113+
114+
@patch("subprocess.run")
115+
def test_windows_default_path(self, mock_run: MagicMock) -> None:
116+
"""Tests fallback to default Windows path if shutil.which fails."""
117+
with (
118+
patch("sys.platform", "win32"),
119+
patch("canirun.gpu.shutil.which", return_value=None),
120+
patch("os.environ.get", return_value="C:"),
121+
):
122+
mock_run.return_value.stdout = "0, GPU Default, 4000"
123+
mock_run.return_value.returncode = 0
124+
125+
analyzer = GPUAnalyzer()
126+
127+
self.assertTrue(analyzer.is_gpu_available())
128+
self.assertEqual(analyzer.device_name, "GPU Default")
129+
130+
# Verify it tried the hardcoded path
131+
args, _ = mock_run.call_args
132+
cmd = args[0]
133+
self.assertTrue("Program Files" in cmd[0])
134+
self.assertTrue("nvidia-smi.exe" in cmd[0])
135+
136+
137+
if __name__ == "__main__":
138+
unittest.main()

tests/test_logic.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,78 @@ def test_fetch_model_data_auth_error(
7373
)
7474
self.assertTrue(found_tip, f"Tip not found in logs: {cm.output}")
7575

76+
@patch("canirun.logic.GPUAnalyzer")
77+
@patch("psutil.virtual_memory")
78+
def test_get_specs_with_gpu(self, mock_vm: Any, MockGPU: Any) -> None:
79+
"""Tests that _get_specs correctly prioritizes GPU over CPU/Mac."""
80+
# Setup RAM
81+
mock_vm.return_value.total = 32 * 1024**3
82+
83+
# Setup GPU
84+
mock_gpu_instance = MockGPU.return_value
85+
mock_gpu_instance.is_gpu_available.return_value = True
86+
mock_gpu_instance.vram = 24 * 1024**3
87+
mock_gpu_instance.device_name = "NVIDIA RTX 3090"
88+
89+
# Initialize analyzer
90+
analyzer = ModelAnalyzer("test-model", verbose=False)
91+
92+
self.assertEqual(analyzer.specs["vram"], 24 * 1024**3)
93+
self.assertEqual(analyzer.specs["name"], "NVIDIA RTX 3090")
94+
self.assertFalse(analyzer.specs["is_mac"])
95+
96+
@patch("canirun.logic.GPUAnalyzer")
97+
@patch("platform.machine")
98+
@patch("platform.system")
99+
@patch("psutil.virtual_memory")
100+
def test_get_specs_mac_silicon(
101+
self, mock_vm: Any, mock_system: Any, mock_machine: Any, MockGPU: Any
102+
) -> None:
103+
"""Tests that _get_specs correctly detects Apple Silicon."""
104+
# Setup RAM
105+
mock_vm.return_value.total = 16 * 1024**3
106+
107+
# Setup Mac Environment
108+
mock_system.return_value = "Darwin"
109+
mock_machine.return_value = "arm64"
110+
111+
# Setup GPU (No discrete GPU)
112+
mock_gpu_instance = MockGPU.return_value
113+
mock_gpu_instance.is_gpu_available.return_value = False
114+
115+
analyzer = ModelAnalyzer("test-model", verbose=False)
116+
117+
self.assertTrue(analyzer.specs["is_mac"])
118+
self.assertEqual(analyzer.specs["name"], "Apple Silicon (Unified Memory)")
119+
# Check VRAM calculation (75% of RAM)
120+
expected_vram = 16 * 1024**3 * 0.75
121+
self.assertEqual(analyzer.specs["vram"], expected_vram)
122+
123+
def test_calculate_fallback_params(self) -> None:
124+
"""Tests calculation fallback when params_billions is 0."""
125+
# Data with 0 params but architecture details
126+
data = {
127+
"params_billions": 0,
128+
"hidden_size": 1024,
129+
"num_hidden_layers": 10,
130+
"num_attention_heads": 8,
131+
"num_key_value_heads": 8,
132+
"vocab_size": 1000,
133+
}
134+
135+
results = self.analyzer.calculate(data, ctx=1024)
136+
self.assertTrue(len(results) > 0)
137+
138+
# Verify params were calculated
139+
# block = 12 * 10 * 1024^2 = 120 * 1,048,576 = 125M
140+
# embed = 1000 * 1024 = 1M
141+
# Total = 126M params -> 0.126B
142+
# FP16 size = 0.25 GB
143+
144+
res_fp16 = results[0]
145+
self.assertEqual(res_fp16["quant"], "FP16")
146+
self.assertGreater(res_fp16["total_ram"], 0)
147+
76148

77149
if __name__ == "__main__":
78150
unittest.main()

tests/test_utils.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import unittest
2+
3+
from canirun.enum import COMPATIBILITY
4+
from canirun.human_readable import get_human_readable_size, get_human_readable_status
5+
6+
7+
class TestHumanReadable(unittest.TestCase):
8+
"""Test suite for human-readable helper functions."""
9+
10+
def test_get_human_readable_size(self) -> None:
11+
"""Tests bytes to human-readable string conversion."""
12+
self.assertEqual(get_human_readable_size(0), "0 B")
13+
self.assertEqual(get_human_readable_size(100), "100.00 B")
14+
self.assertEqual(get_human_readable_size(1024), "1.00 KB")
15+
self.assertEqual(get_human_readable_size(1024**2), "1.00 MB")
16+
self.assertEqual(get_human_readable_size(1024**3), "1.00 GB")
17+
self.assertEqual(get_human_readable_size(1.5 * 1024**3), "1.50 GB")
18+
self.assertEqual(get_human_readable_size(1024**4), "1.00 TB")
19+
self.assertEqual(get_human_readable_size(1024**5), "1.00 PB")
20+
21+
def test_get_human_readable_status(self) -> None:
22+
"""Tests compatibility enum to string conversion."""
23+
self.assertEqual(get_human_readable_status(COMPATIBILITY.FULL), "✅ GPU")
24+
self.assertEqual(
25+
get_human_readable_status(COMPATIBILITY.PARTIAL), "⚠️ CPU/RAM only (Slow)"
26+
)
27+
self.assertEqual(get_human_readable_status(COMPATIBILITY.NONE), "❌ Impossible")
28+
29+
30+
if __name__ == "__main__":
31+
unittest.main()

0 commit comments

Comments
 (0)