diff --git a/.gitea/workflows/run-mucapy.yml b/.gitea/workflows/run-mucapy.yml index 9e87f15..dc1507e 100644 --- a/.gitea/workflows/run-mucapy.yml +++ b/.gitea/workflows/run-mucapy.yml @@ -24,6 +24,9 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt + - name: Run tests + run: make test + - name: Install PyInstaller run: pip install pyinstaller diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..082b194 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "makefile.configureOnOpen": false +} \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..776dca4 --- /dev/null +++ b/Makefile @@ -0,0 +1,2 @@ +test: + PYTHONPATH=. pytest tests diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..62581a8 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,152 @@ +import os +import sys +import json +import tempfile +import shutil +import unittest +from unittest.mock import patch, MagicMock + +import numpy as np + +# Import the classes to test +from mucapy.main import Config, CameraThread, MultiCamYOLODetector + +class TestConfig(unittest.TestCase): + def setUp(self): + # Create a temporary directory for config + self.temp_dir = tempfile.mkdtemp() + self.config_file = os.path.join(self.temp_dir, 'config.json') + self.expanduser_patcher = patch('os.path.expanduser', return_value=self.temp_dir) + self.makedirs_patcher = patch('os.makedirs', return_value=None) + self.expanduser_patcher.start() + self.makedirs_patcher.start() + # Patch environment variables that may affect config path + self.env_patches = [] + for var in ['APPDATA', 'USERPROFILE']: + self.env_patches.append(patch.dict(os.environ, {var: self.temp_dir})) + self.env_patches[-1].start() + self.config = Config() + self.config.config_file = self.config_file + + def tearDown(self): + self.expanduser_patcher.stop() + self.makedirs_patcher.stop() + for p in self.env_patches: + p.stop() + shutil.rmtree(self.temp_dir) + + def test_save_and_load_setting(self): + self.config.save_setting('test_key', 'test_value') + self.assertEqual(self.config.load_setting('test_key'), 'test_value') + # Reload config to check persistence + self.config.settings['test_key'] = None + self.config.load_config() + self.assertEqual(self.config.load_setting('test_key'), 'test_value') + + def test_default_settings(self): + self.assertIn('network_cameras', self.config.settings) + self.assertIn('last_model_dir', self.config.settings) + + def test_load_nonexistent_key(self): + self.assertIsNone(self.config.load_setting('nonexistent_key')) + self.assertEqual(self.config.load_setting('nonexistent_key', default=123), 123) + + def test_save_invalid_json(self): + # Simulate corrupted config file + with open(self.config_file, 'w') as f: + f.write("{invalid json") + # Should not raise, should print error and keep defaults + self.config.load_config() + self.assertIn('network_cameras', self.config.settings) + +class TestCameraThread(unittest.TestCase): + def setUp(self): + self.thread = CameraThread(0, {'url': 'http://192.168.1.2:4747'}) + + @patch('mucapy.main.CameraThread.validate_url') + def test_validate_url(self, mock_validate_url): + mock_validate_url.side_effect = lambda url: 'http://192.168.1.2:4747/video' if ':4747' in url else url + url = '192.168.1.2:4747' + validated = self.thread.validate_url(url) + self.assertEqual(validated, 'http://192.168.1.2:4747/video') + url2 = 'http://example.com/stream' + validated2 = self.thread.validate_url(url2) + self.assertEqual(validated2, url2) + + def test_construct_camera_url_no_auth(self): + info = {'url': 'http://example.com/stream'} + url = self.thread.construct_camera_url(info) + self.assertEqual(url, 'http://example.com/stream') + + def test_construct_camera_url_with_auth(self): + info = {'url': 'http://example.com/stream', 'username': 'user', 'password': 'pass'} + url = self.thread.construct_camera_url(info) + self.assertTrue(url.startswith('http://user:pass@')) + + def test_construct_camera_url_invalid_url(self): + # Should handle invalid URL gracefully + info = {'url': '!!!not_a_url'} + url = self.thread.construct_camera_url(info) + self.assertIsInstance(url, str) # Should still return a string, possibly normalized + + def test_construct_camera_url_non_dict(self): + url = self.thread.construct_camera_url('http://example.com/stream') + self.assertEqual(url, 'http://example.com/stream') + +class TestMultiCamYOLODetector(unittest.TestCase): + def setUp(self): + self.detector = MultiCamYOLODetector() + + def test_add_and_remove_network_camera(self): + self.detector.add_network_camera('testcam', 'http://test') + self.assertIn('testcam', self.detector.network_cameras) + self.detector.remove_network_camera('testcam') + self.assertNotIn('testcam', self.detector.network_cameras) + + @patch('os.listdir', side_effect=FileNotFoundError) + def test_load_yolo_model_dir_not_found(self, mock_listdir): + result = self.detector.load_yolo_model('/nonexistent') + self.assertFalse(result) + + @patch('os.listdir', return_value=['model.weights', 'model.cfg', 'model.names']) + @patch('cv2.dnn.readNet', side_effect=Exception("Failed to load net")) + def test_load_yolo_model_readnet_exception(self, mock_readnet, mock_listdir): + result = self.detector.load_yolo_model('/some/dir') + self.assertFalse(result) + + @patch('os.path.exists', return_value=False) + @patch('cv2.dnn.readNet', return_value=MagicMock()) + @patch('os.listdir', return_value=[]) + def test_load_yolo_model_no_files(self, mock_listdir, mock_readnet, mock_exists): + result = self.detector.load_yolo_model('/nonexistent') + self.assertFalse(result) + + @patch('cv2.VideoCapture') + def test_scan_for_cameras(self, mock_vc): + # Simulate one camera available + mock_instance = MagicMock() + mock_instance.isOpened.return_value = True + mock_vc.return_value = mock_instance + cams = self.detector.scan_for_cameras(max_to_check=1) + self.assertTrue(any(c.isdigit() for c in cams)) + + @patch('cv2.VideoCapture') + def test_scan_for_cameras_none_available(self, mock_vc): + # Simulate no cameras available + mock_instance = MagicMock() + mock_instance.isOpened.return_value = False + mock_vc.return_value = mock_instance + cams = self.detector.scan_for_cameras(max_to_check=1) + self.assertFalse(any(c.isdigit() for c in cams)) + + def test_connect_cameras_empty(self): + # Should not fail if given empty list + result = self.detector.connect_cameras([]) + self.assertFalse(result) + + def test_disconnect_cameras_noop(self): + # Should not raise if no cameras connected + self.detector.disconnect_cameras() # Should not raise + +if __name__ == '__main__': + unittest.main() \ No newline at end of file