diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..039de5b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install system deps + run: sudo apt-get update && sudo apt-get install -y libxcb-cursor0 libxcb-xinerama0 blender + + - name: Install Python deps + run: | + pip install -r requirements.txt + pip install pytest flake8 pylint + + - name: Test + run: python -m pytest tests/ --ignore=tests/job_creation_tests.py -v + + - name: Lint — bugs (flake8) + # Fails on syntax errors only. Ignored: ~100+ pre-existing + # style issues (E/W), star-import false positives (F403/F405), + # unused imports (F401), unused vars (F841), f-string debt (F541). + run: flake8 --select=E9 --exclude=src/engines/aerender,build,dist,.git,__pycache__,venv --max-line-length=127 --max-complexity=35 + + - name: Lint — style (flake8) + # Reports style issues but never fails the build. + # TODO: resolve ~100+ pre-existing style issues + run: flake8 --exit-zero --exclude=src/engines/aerender,build,dist,.git,__pycache__,venv --max-line-length=127 --max-complexity=35 + + - name: Lint (pylint) + # Reports all issues but never fails the build. + # TODO: resolve pre-existing debt (current score 7.73/10) + run: pylint src/ --max-line-length=120 --ignore-paths=^src/engines/aerender/ --fail-under=0 diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml deleted file mode 100644 index aab256a..0000000 --- a/.github/workflows/python-app.yml +++ /dev/null @@ -1,40 +0,0 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python - -name: Python application - -on: - push: - branches: [ "master" ] - pull_request: - branches: [ "master" ] - -permissions: - contents: read - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.10 - uses: actions/setup-python@v3 - with: - python-version: "3.10" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install flake8 pytest - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - continue-on-error: false - # - name: Test with pytest - # run: | - # pytest diff --git a/pytest.ini b/pytest.ini index aa74c9a..664bf19 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,2 +1,4 @@ [pytest] -norecursedirs = src/engines/aerender .git build dist *.egg venv .venv env .env __pycache__ .pytest_cache \ No newline at end of file +norecursedirs = src/engines/aerender .git build dist *.egg venv .venv env .env __pycache__ .pytest_cache +testpaths = tests +python_files = test_*.py job_creation_tests.py \ No newline at end of file diff --git a/src/distributed_job_manager.py b/src/distributed_job_manager.py index e817261..c9198b2 100644 --- a/src/distributed_job_manager.py +++ b/src/distributed_job_manager.py @@ -270,7 +270,7 @@ class DistributedJobManager: @staticmethod def find_available_servers(engine_name: str, system_os=None): - from api.api_server import API_VERSION + from src.api.api_server import API_VERSION found_available_servers = [] for hostname in ZeroconfServer.found_hostnames(): host_properties = ZeroconfServer.get_hostname_properties(hostname) diff --git a/src/engines/engine_manager.py b/src/engines/engine_manager.py index cd1e6ba..f177ea0 100644 --- a/src/engines/engine_manager.py +++ b/src/engines/engine_manager.py @@ -80,9 +80,10 @@ class EngineManager: binary_name = result_dict['engine'].lower() eng = self.engine_class_with_name(result_dict['engine']) - binary_name = eng.binary_names.get(result_dict['system_os'], binary_name) + if eng: + binary_name = eng.binary_names.get(result_dict['system_os'], binary_name) - search_root = self.engines_path / directory + search_root = Path(self.engines_path) / directory match = next((p for p in search_root.rglob(binary_name) if p.is_file()), None) path = str(match) if match else None result_dict['path'] = path diff --git a/src/utilities/misc_helper.py b/src/utilities/misc_helper.py index bd7faef..870738f 100644 --- a/src/utilities/misc_helper.py +++ b/src/utilities/misc_helper.py @@ -225,35 +225,35 @@ def get_gpu_info() -> List[Dict[str, Any]]: """Get GPU info on Windows""" try: result = subprocess.run( - ['wmic', 'path', 'win32_videocontroller', 'get', 'name,AdapterRAM', '/format:list'], + ['wmic', 'path', 'win32_videocontroller', 'get', 'name,AdapterRAM', '/format:list'], capture_output=True, text=True, timeout=5 ) - + # Virtual adapters to exclude virtual_adapters = [ 'virtual', 'rdp', 'hyper-v', 'microsoft basic', 'basic display', 'vga compatible', 'dummy', 'nvfbc', 'nvencode' ] - + gpus = [] current_gpu = None - + for line in result.stdout.strip().split('\n'): line = line.strip() if not line: continue - + if line.startswith('Name='): if current_gpu and current_gpu.get('name'): gpus.append(current_gpu) gpu_name = line.replace('Name=', '').strip() - + # Skip virtual adapters if any(virtual in gpu_name.lower() for virtual in virtual_adapters): current_gpu = None else: current_gpu = {'name': gpu_name, 'memory': 'Integrated'} - + elif line.startswith('AdapterRAM=') and current_gpu: vram_bytes_str = line.replace('AdapterRAM=', '').strip() if vram_bytes_str and vram_bytes_str != '0': @@ -262,10 +262,10 @@ def get_gpu_info() -> List[Dict[str, Any]]: current_gpu['memory'] = round(vram_gb, 2) except: pass - + if current_gpu and current_gpu.get('name'): gpus.append(current_gpu) - + return gpus if gpus else [{'name': 'Unknown GPU', 'memory': 'Unknown'}] except Exception as e: logger.error(f"Failed to get Windows GPU info: {e}") @@ -281,7 +281,7 @@ def get_gpu_info() -> List[Dict[str, Any]]: result = subprocess.run(['system_profiler', 'SPDisplaysDataType', '-detailLevel', 'mini', '-json'], capture_output=True, text=True, timeout=5) data = json.loads(result.stdout) - + gpus = [] displays = data.get('SPDisplaysDataType', []) for display in displays: @@ -294,7 +294,7 @@ def get_gpu_info() -> List[Dict[str, Any]]: except Exception as e: print(f"Failed to get macOS GPU info: {e}") return [{'name': 'Unknown GPU', 'memory': 'Unknown'}] - + def get_linux_gpu_info(): gpus = [] try: @@ -316,7 +316,7 @@ def get_gpu_info() -> List[Dict[str, Any]]: vendor = "AMD" elif "intel" in name.lower(): vendor = "Intel" - + gpus.append({ "name": name, "vendor": vendor, @@ -332,7 +332,7 @@ def get_gpu_info() -> List[Dict[str, Any]]: return gpus system = platform.system() - + if system == 'Darwin': # macOS return get_macos_gpu_info() elif system == 'Windows': @@ -340,55 +340,56 @@ def get_gpu_info() -> List[Dict[str, Any]]: else: # Assume Linux or other return get_linux_gpu_info() + COMMON_RESOLUTIONS = { - # SD - "SD_480p": (640, 480), - "NTSC_DVD": (720, 480), - "PAL_DVD": (720, 576), + # SD + "SD_480p": (640, 480), + "NTSC_DVD": (720, 480), + "PAL_DVD": (720, 576), - # HD - "HD_720p": (1280, 720), - "HD_900p": (1600, 900), - "HD_1080p": (1920, 1080), + # HD + "HD_720p": (1280, 720), + "HD_900p": (1600, 900), + "HD_1080p": (1920, 1080), - # Cinema / Film - "2K_DCI": (2048, 1080), - "4K_DCI": (4096, 2160), + # Cinema / Film + "2K_DCI": (2048, 1080), + "4K_DCI": (4096, 2160), - # UHD / Consumer - "UHD_4K": (3840, 2160), - "UHD_5K": (5120, 2880), - "UHD_8K": (7680, 4320), + # UHD / Consumer + "UHD_4K": (3840, 2160), + "UHD_5K": (5120, 2880), + "UHD_8K": (7680, 4320), - # Ultrawide / Aspect Variants - "UW_1080p": (2560, 1080), - "UW_1440p": (3440, 1440), - "UW_5K": (5120, 2160), + # Ultrawide / Aspect Variants + "UW_1080p": (2560, 1080), + "UW_1440p": (3440, 1440), + "UW_5K": (5120, 2160), - # Mobile / Social - "VERTICAL_1080x1920": (1080, 1920), - "SQUARE_1080": (1080, 1080), + # Mobile / Social + "VERTICAL_1080x1920": (1080, 1920), + "SQUARE_1080": (1080, 1080), - # Classic / Legacy - "VGA": (640, 480), - "SVGA": (800, 600), - "XGA": (1024, 768), - "WXGA": (1280, 800), + # Classic / Legacy + "VGA": (640, 480), + "SVGA": (800, 600), + "XGA": (1024, 768), + "WXGA": (1280, 800), } COMMON_FRAME_RATES = { - "23.976 (NTSC Film)": 23.976, - "24 (Cinema)": 24.0, - "25 (PAL)": 25.0, - "29.97 (NTSC)": 29.97, - "30": 30.0, - "48 (HFR Film)": 48.0, - "50 (PAL HFR)": 50.0, - "59.94": 59.94, - "60": 60.0, - "72": 72.0, - "90 (VR)": 90.0, - "120": 120.0, - "144 (Gaming)": 144.0, - "240 (HFR)": 240.0, -} \ No newline at end of file + "23.976 (NTSC Film)": 23.976, + "24 (Cinema)": 24.0, + "25 (PAL)": 25.0, + "29.97 (NTSC)": 29.97, + "30": 30.0, + "48 (HFR Film)": 48.0, + "50 (PAL HFR)": 50.0, + "59.94": 59.94, + "60": 60.0, + "72": 72.0, + "90 (VR)": 90.0, + "120": 120.0, + "144 (Gaming)": 144.0, + "240 (HFR)": 240.0, +} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1f4192c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,134 @@ +from unittest.mock import patch +from pathlib import Path +import pytest + +from src.render_queue import RenderQueue +from src.engines.engine_manager import EngineManager +from src.api.preview_manager import PreviewManager +from src.utilities.config import Config +from src.distributed_job_manager import DistributedJobManager +from src.utilities.zeroconf_server import ZeroconfServer + + +# --------------------------------------------------------------------------- +# Global pubsub patch – real pubsub can fire callbacks across tests. +# Each service module does `from pubsub import pub` at import time, so we +# must patch each module-level reference individually. +# --------------------------------------------------------------------------- +_PUBSUB_TARGETS = [ + 'pubsub.pub', + 'src.render_queue.pub', + 'src.distributed_job_manager.pub', + 'src.utilities.zeroconf_server.pub', + 'src.engines.core.base_worker.pub', +] + + +@pytest.fixture(autouse=True) +def _patch_pubsub(): + mocks = {} + patchers = [] + for target in _PUBSUB_TARGETS: + p = patch(target) + patchers.append(p) + mocks[target] = p.start() + yield mocks.get('pubsub.pub') + for p in reversed(patchers): + p.stop() + + +# --------------------------------------------------------------------------- +# Temp directory for file-system-dependent tests +# --------------------------------------------------------------------------- +@pytest.fixture +def tmp_workspace(tmp_path: Path) -> Path: + ws = tmp_path / 'zordon_test' + ws.mkdir() + return ws + + +# --------------------------------------------------------------------------- +# Config fixture +# --------------------------------------------------------------------------- +@pytest.fixture +def config_instance(tmp_workspace: Path) -> Config: + orig = Config._default_instance + cfg = Config() + cfg.upload_folder = str(tmp_workspace / 'uploads') + Config._default_instance = cfg + Config._sync_class() + yield cfg + Config._default_instance = orig + Config._sync_class() + + +# --------------------------------------------------------------------------- +# EngineManager fixture +# --------------------------------------------------------------------------- +@pytest.fixture +def engine_manager_instance(tmp_workspace: Path) -> EngineManager: + orig = EngineManager._default_instance + em = EngineManager() + em.engines_path = str(tmp_workspace / 'engines') + EngineManager._default_instance = em + EngineManager._sync_class() + yield em + EngineManager._default_instance = orig + EngineManager._sync_class() + + +# --------------------------------------------------------------------------- +# RenderQueue fixture +# --------------------------------------------------------------------------- +@pytest.fixture +def render_queue_instance() -> RenderQueue: + orig = RenderQueue._default_instance + rq = RenderQueue() + RenderQueue._default_instance = rq + RenderQueue._sync_class() + yield rq + RenderQueue._default_instance = orig + RenderQueue._sync_class() + + +# --------------------------------------------------------------------------- +# DistributedJobManager fixture +# --------------------------------------------------------------------------- +@pytest.fixture +def distributed_job_manager_instance() -> DistributedJobManager: + orig = DistributedJobManager._default_instance + djm = DistributedJobManager() + DistributedJobManager._default_instance = djm + DistributedJobManager._sync_class() + yield djm + DistributedJobManager._default_instance = orig + DistributedJobManager._sync_class() + + +# --------------------------------------------------------------------------- +# PreviewManager fixture +# --------------------------------------------------------------------------- +@pytest.fixture +def preview_manager_instance(tmp_workspace: Path) -> PreviewManager: + orig = PreviewManager._default_instance + pm = PreviewManager() + pm.storage_path = str(tmp_workspace / 'previews') + PreviewManager._default_instance = pm + PreviewManager._sync_class() + yield pm + PreviewManager._default_instance = orig + PreviewManager._sync_class() + + +# --------------------------------------------------------------------------- +# ZeroconfServer fixture +# --------------------------------------------------------------------------- +@pytest.fixture +def zeroconf_server_instance() -> ZeroconfServer: + orig = ZeroconfServer._default_instance + zs = ZeroconfServer() + ZeroconfServer._default_instance = zs + ZeroconfServer._sync_class() + yield zs + ZeroconfServer._default_instance = orig + ZeroconfServer._sync_class() diff --git a/tests/job_creation_tests.py b/tests/job_creation_tests.py new file mode 100644 index 0000000..d0dfc79 --- /dev/null +++ b/tests/job_creation_tests.py @@ -0,0 +1,85 @@ +import logging +import os +import time +import unittest + +from src.api.server_proxy import RenderServerProxy + +logger = logging.getLogger(__name__) + +SERVER_HOST = os.environ.get('ZORDON_TEST_HOST', '127.0.0.1') +SERVER_PORT = os.environ.get('ZORDON_TEST_PORT', '8080') + + +@unittest.skipIf(os.environ.get('ZORDON_SKIP_INTEGRATION'), + 'set ZORDON_SKIP_INTEGRATION to skip integration tests') +class SubmissionTestCase(unittest.TestCase): + """Integration tests requiring a running Zordon server. + + Start the server: python server.py + Run tests: ZORDON_TEST_HOST=127.0.0.1 python -m pytest tests/job_creation_tests.py + + Override host/port via ZORDON_TEST_HOST and ZORDON_TEST_PORT env vars. + """ + + render_server = None + test_job_id = None + + @classmethod + def setUpClass(cls): + cls.render_server = RenderServerProxy(SERVER_HOST, SERVER_PORT) + + @classmethod + def tearDownClass(cls): + cls.render_server = None + + def test_connection_ok(self): + self.assertTrue(self.render_server.is_online(), + msg=f'Server not reachable at {SERVER_HOST}:{SERVER_PORT}') + + def test_submit_job(self): + sample_file_path = os.path.join(os.path.dirname(__file__), 'resources', + 'batman_sample.blend') + self.assertTrue(os.path.exists(sample_file_path), + msg=f'Test file not found: {sample_file_path}') + + sample_job = { + 'name': 'sample_blender_job', + 'renderer': 'blender', + 'start_frame': 1, + 'end_frame': 5, + 'args': {'engine': 'CYCLES'}, + 'enable_split_jobs': False, + } + + response = self.render_server.post_job_to_server( + file_path=sample_file_path, job_list=[sample_job]) + self.assertIsNotNone(response, msg='No response from server') + self.assertTrue(response.ok, msg=f'Server returned {response.status_code}') + + logger.info('Submitted to server ok!') + job_data = response.json() + self.__class__.test_job_id = job_data[0]['id'] + + def test_wait_for_job_to_complete(self): + if not self.__class__.test_job_id: + self.skipTest('No job was submitted in test_submit_job') + + file_count = 0 + + while True: + update_response = self.render_server.get_job_info(self.__class__.test_job_id) + if update_response: + print(f"Status: {update_response['status']}") + + if update_response['status'] not in [ + 'not_started', 'running', 'waiting_for_subjobs', 'configuring' + ]: + self.assertEqual(update_response['status'], 'completed', + msg=f"Job ended with status: {update_response['status']}") + break + + if update_response['file_count'] > file_count: + file_count = update_response['file_count'] + print(f"File count is now {file_count}") + time.sleep(1) diff --git a/tests/resources/batman_sample.blend b/tests/resources/batman_sample.blend new file mode 100644 index 0000000..f7e98c0 Binary files /dev/null and b/tests/resources/batman_sample.blend differ diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..d6bf023 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,172 @@ +from pathlib import Path +from unittest.mock import patch + +from src.utilities.config import Config, _CONFIG_ATTRS + + +class TestConfigDefaults: + """Default attribute values.""" + + def test_default_upload_folder(self): + assert Config.upload_folder == '~/zordon-uploads/' + + def test_default_port(self): + assert Config.port_number == 8080 + + def test_default_server_log_level(self): + assert Config.server_log_level == 'debug' + + def test_default_enable_split_jobs(self): + assert Config.enable_split_jobs is True + + def test_default_worker_timeout(self): + assert Config.worker_process_timeout == 120 + + +class TestConfigInstance: + """Instance creation and attribute initialisation.""" + + def test_init_copies_class_attrs(self): + cfg = Config() + assert cfg.upload_folder == '~/zordon-uploads/' + assert cfg.port_number == 8080 + + def test_init_has_all_attrs(self): + cfg = Config() + for attr in _CONFIG_ATTRS: + assert hasattr(cfg, attr), f'missing attr: {attr}' + + +class TestConfigLoad: + """Loading configuration from YAML.""" + + def test_load_sets_attributes(self, tmp_path): + config_file = tmp_path / 'config.yaml' + config_file.write_text('port_number: 9090\nserver_log_level: info\n') + + cfg = Config() + cfg.load(config_file) + + assert cfg.port_number == 9090 + assert cfg.server_log_level == 'info' + + def test_load_expands_upload_folder(self, tmp_path): + config_file = tmp_path / 'config.yaml' + config_file.write_text('upload_folder: ~/custom-uploads/\n') + + cfg = Config() + cfg.load(config_file) + + # expanduser strips trailing slash + assert cfg.upload_folder.endswith('/custom-uploads') + + def test_load_ignores_unknown_keys(self, tmp_path): + config_file = tmp_path / 'config.yaml' + config_file.write_text('nonexistent_key: value\nport_number: 7070\n') + + cfg = Config() + cfg.load(config_file) + + assert cfg.port_number == 7070 + + def test_load_raises_on_missing_file(self): + cfg = Config() + try: + cfg.load(Path('/nonexistent/path.yaml')) + assert False, 'expected FileNotFoundError' + except FileNotFoundError: + pass + + +class TestConfigSyncClass: + """_sync_class propagates instance attrs to class level.""" + + def test_sync_class_propagates_attrs(self): + orig = Config._default_instance + try: + cfg = Config() + cfg.port_number = 7777 + Config._default_instance = cfg + + Config._sync_class() + + assert Config.port_number == 7777 + finally: + Config._default_instance = orig + Config._sync_class() + + def test_sync_class_noop_when_no_instance(self): + orig = Config._default_instance + try: + Config._default_instance = None + Config._sync_class() + finally: + Config._default_instance = orig + Config._sync_class() + + +class TestConfigLoadConfig: + """Classmethod load_config — full bootstrap.""" + + def test_load_config_sets_default_instance(self, tmp_path): + orig = Config._default_instance + try: + config_file = tmp_path / 'config.yaml' + config_file.write_text('port_number: 9999\n') + + Config._default_instance = None + Config.load_config(config_file) + + assert Config._default_instance is not None + assert Config.port_number == 9999 + finally: + Config._default_instance = orig + Config._sync_class() + + +class TestConfigDir: + """config_dir() returns OS-specific path.""" + + @patch('src.utilities.config.current_system_os') + def test_config_dir_macos(self, mock_os): + mock_os.return_value = 'macos' + result = Config.config_dir() + assert 'Library/Application Support/Zordon' in str(result) + + @patch('src.utilities.config.current_system_os') + def test_config_dir_windows(self, mock_os): + mock_os.return_value = 'windows' + with patch.dict('os.environ', {'APPDATA': 'C:\\Users\\Test\\AppData\\Roaming'}): + result = Config.config_dir() + assert 'Zordon' in str(result) + + @patch('src.utilities.config.current_system_os') + def test_config_dir_linux(self, mock_os): + mock_os.return_value = 'linux' + result = Config.config_dir() + assert '.config/Zordon' in str(result) + + +class TestSetupConfigDir: + """setup_config_dir creates dir and copies template.""" + + @patch('src.utilities.config.copy_directory_contents') + @patch('src.utilities.config.os.makedirs') + @patch('src.utilities.config.os.path.exists') + def test_creates_dir_when_missing(self, mock_exists, mock_makedirs, mock_copy): + mock_exists.return_value = False + + Config.setup_config_dir() + + mock_makedirs.assert_called_once() + + @patch('src.utilities.config.copy_directory_contents') + @patch('src.utilities.config.os.makedirs') + @patch('src.utilities.config.os.path.exists') + def test_skips_when_dir_exists(self, mock_exists, mock_makedirs, mock_copy): + mock_exists.return_value = True + + Config.setup_config_dir() + + mock_makedirs.assert_not_called() + mock_copy.assert_not_called() diff --git a/tests/test_distributed_job_manager.py b/tests/test_distributed_job_manager.py new file mode 100644 index 0000000..e9bbf5c --- /dev/null +++ b/tests/test_distributed_job_manager.py @@ -0,0 +1,138 @@ +from unittest.mock import MagicMock, patch + +import src.distributed_job_manager as djm_module +from src.distributed_job_manager import DistributedJobManager +from src.utilities.status_utils import RenderStatus + + +class TestSubscribeToListener: + """PubSub subscription.""" + + def test_subscribes_to_status_change(self, distributed_job_manager_instance): + distributed_job_manager_instance._subscribe_to_listener() + + # Check via the module-level reference (the one _subscribe_to_listener uses) + djm_module.pub.subscribe.assert_any_call( + distributed_job_manager_instance._local_job_status_changed, + 'status_change', + ) + djm_module.pub.subscribe.assert_any_call( + distributed_job_manager_instance._local_job_frame_complete, + 'frame_complete', + ) + + +class TestCreateRenderJob: + """Creating a render job.""" + + @patch('src.distributed_job_manager.os.makedirs') + @patch('src.distributed_job_manager.EngineManager.create_worker') + def test_creates_worker_and_adds_to_queue( + self, mock_create_worker, mock_makedirs, distributed_job_manager_instance, + config_instance, tmp_path, + ): + worker = MagicMock() + worker.total_frames = 10 + worker.parent = None + worker.id = 'job-1' + mock_create_worker.return_value = worker + + project_path = tmp_path / 'test_project.blend' + project_path.write_text('fake') + + attrs = { + 'engine_name': 'blender', + 'args': {'engine': 'CYCLES'}, + 'name': 'Test Job', + 'start_frame': 1, + 'end_frame': 10, + 'priority': 2, + 'enable_split_jobs': False, + } + + with patch('src.distributed_job_manager.RenderQueue.add_to_render_queue') as mock_add: + with patch('src.distributed_job_manager.PreviewManager.update_previews_for_job'): + result = DistributedJobManager.create_render_job(attrs, project_path) + + assert result == worker + assert worker.status == RenderStatus.NOT_STARTED + mock_add.assert_called_once_with(worker, force_start=False) + + @patch('src.distributed_job_manager.os.makedirs') + @patch('src.distributed_job_manager.EngineManager.create_worker') + def test_split_jobs_enabled_calls_split_async( + self, mock_create_worker, mock_makedirs, distributed_job_manager_instance, + config_instance, tmp_path, + ): + worker = MagicMock() + worker.total_frames = 10 + worker.parent = None + mock_create_worker.return_value = worker + + project_path = tmp_path / 'test_project.blend' + project_path.write_text('fake') + + attrs = { + 'engine_name': 'blender', + 'args': {}, + 'name': 'Split Job', + 'start_frame': 1, + 'end_frame': 10, + 'priority': 2, + 'enable_split_jobs': True, + } + + # The forwarder passes system_os=None by default + with patch.object(distributed_job_manager_instance, '_split_into_subjobs_async') as mock_split: + with patch('src.distributed_job_manager.RenderQueue.add_to_render_queue'): + with patch('src.distributed_job_manager.PreviewManager.update_previews_for_job'): + DistributedJobManager.create_render_job(attrs, project_path) + + mock_split.assert_called_once_with(worker, attrs, project_path, None) + + +class TestHandleSubjobUpdate: + """Processing subjob update notifications.""" + + def test_updates_child_info(self, distributed_job_manager_instance): + parent_job = MagicMock() + parent_job.children = {} + + subjob_data = { + 'id': 'sub-1', + 'hostname': 'worker-1', + 'status': 'completed', + 'percent_complete': 1.0, + 'file_count': 5, + } + + with patch('src.distributed_job_manager.download_missing_frames_from_subjob', + return_value=True): + DistributedJobManager.handle_subjob_update_notification(parent_job, subjob_data) + + assert 'sub-1@worker-1' in parent_job.children + assert parent_job.children['sub-1@worker-1']['download_status'] == 'completed' + + +class TestFindAvailableServers: + """Discovering remote servers.""" + + @patch('src.distributed_job_manager.ZeroconfServer.found_hostnames') + @patch('src.distributed_job_manager.ZeroconfServer.get_hostname_properties') + @patch('src.distributed_job_manager.RenderServerProxy') + def test_finds_matching_server( + self, mock_proxy_class, mock_get_props, mock_found_hostnames, + ): + mock_found_hostnames.return_value = ['server-1.local'] + mock_get_props.return_value = {'api_version': '0.1', 'system_os': 'macos'} + + mock_proxy = MagicMock() + mock_proxy.is_engine_available.return_value = { + 'available': True, + 'hostname': 'server-1.local', + } + mock_proxy_class.return_value = mock_proxy + + result = DistributedJobManager.find_available_servers('blender') + assert len(result) == 1 + assert result[0]['hostname'] == 'server-1.local' diff --git a/tests/test_engine_manager.py b/tests/test_engine_manager.py new file mode 100644 index 0000000..7f2a72e --- /dev/null +++ b/tests/test_engine_manager.py @@ -0,0 +1,239 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from src.engines.engine_manager import EngineManager, EngineDownloadWorker + + +class TestEngineManagerSyncClass: + """_sync_class propagates instance attrs to class level.""" + + def test_sync_class_propagates_engines_path(self, engine_manager_instance): + assert EngineManager.engines_path == engine_manager_instance.engines_path + + def test_sync_class_noop_when_no_instance(self): + orig = EngineManager._default_instance + try: + EngineManager._default_instance = None + EngineManager.engines_path = 'original' + EngineManager._sync_class() + assert EngineManager.engines_path == 'original' + finally: + EngineManager._default_instance = orig + EngineManager._sync_class() + + def test_supported_engines_returns_list(self): + engines = EngineManager.supported_engines() + assert len(engines) >= 2 + + +class TestEngineClassMapping: + """Mapping file extensions and names to engine classes.""" + + def test_engine_class_with_name_finds_blender(self, engine_manager_instance): + cls = EngineManager.engine_class_with_name('blender') + assert cls is not None + assert cls.__name__ == 'Blender' + + def test_engine_class_with_name_finds_ffmpeg(self, engine_manager_instance): + cls = EngineManager.engine_class_with_name('ffmpeg') + assert cls is not None + assert cls.__name__ == 'FFMPEG' + + def test_engine_class_with_name_returns_none_for_unknown(self, engine_manager_instance): + cls = EngineManager.engine_class_with_name('nonexistent') + assert cls is None + + def test_engine_class_with_name_case_insensitive(self, engine_manager_instance): + cls = EngineManager.engine_class_with_name('BLENDER') + assert cls is not None + assert cls.__name__ == 'Blender' + + def test_engine_class_for_project_path_no_engines_path(self, engine_manager_instance): + engine_manager_instance.engines_path = None + with pytest.raises(FileNotFoundError): + EngineManager.engine_class_for_project_path('test.blend') + + +class TestGetInstalledEngineData: + """Parsing directory listings for managed engines.""" + + def test_get_installed_engine_data_no_path(self, engine_manager_instance): + engine_manager_instance.engines_path = None + with pytest.raises(FileNotFoundError): + EngineManager.get_installed_engine_data() + + def test_get_installed_engine_data_empty_dir(self, engine_manager_instance, tmp_path): + engine_manager_instance.engines_path = str(tmp_path / 'engines') + result = EngineManager.get_installed_engine_data(ignore_system=True) + assert result == [] + + @patch('src.engines.engine_manager.os.listdir') + @patch('src.engines.engine_manager.os.path.isdir') + def test_parse_managed_engine_directory( + self, mock_isdir, mock_listdir, engine_manager_instance, tmp_path, + ): + engine_manager_instance.engines_path = str(tmp_path / 'engines') + mock_listdir.return_value = ['blender-3.6.0-macos-arm64'] + mock_isdir.return_value = True + + result = EngineManager.get_installed_engine_data(ignore_system=True) + + assert len(result) == 1 + assert result[0]['engine'] == 'blender' + assert result[0]['version'] == '3.6.0' + assert result[0]['system_os'] == 'macos' + assert result[0]['cpu'] == 'arm64' + assert result[0]['type'] == 'managed' + + @patch('src.engines.engine_manager.os.listdir') + @patch('src.engines.engine_manager.os.path.isdir') + def test_filter_by_engine_name( + self, mock_isdir, mock_listdir, engine_manager_instance, tmp_path, + ): + engine_manager_instance.engines_path = str(tmp_path / 'engines') + mock_listdir.return_value = ['blender-3.6.0-macos-arm64', 'ffmpeg-6.0-macos-arm64'] + mock_isdir.return_value = True + + blender_only = EngineManager.get_installed_engine_data( + filter_name='blender', ignore_system=True) + assert len(blender_only) == 1 + assert blender_only[0]['engine'] == 'blender' + + +class TestNewestInstalledEngineData: + """Filtering by system and CPU.""" + + @patch('src.engines.engine_manager.current_system_os') + @patch('src.engines.engine_manager.current_system_cpu') + def test_newest_filters_by_system_and_cpu( + self, mock_cpu, mock_os, engine_manager_instance, tmp_path, + ): + mock_os.return_value = 'macos' + mock_cpu.return_value = 'arm64' + + engine_manager_instance.engines_path = str(tmp_path / 'engines') + dirs = [ + 'blender-3.6.0-macos-arm64', + 'blender-4.0.0-linux-x86_64', + 'blender-4.1.0-macos-arm64', + ] + + with (patch('src.engines.engine_manager.os.listdir', return_value=dirs), + patch('src.engines.engine_manager.os.path.isdir', return_value=True)): + result = EngineManager.newest_installed_engine_data( + 'blender', ignore_system=True) + + assert result['version'] == '4.1.0' + assert result['system_os'] == 'macos' + assert result['cpu'] == 'arm64' + + def test_newest_returns_empty_on_no_match(self, engine_manager_instance, tmp_path): + engine_manager_instance.engines_path = str(tmp_path / 'engines') + + result = EngineManager.newest_installed_engine_data('nonexistent') + assert result == [] + + +class TestIsVersionInstalled: + """Checking whether a specific version is installed.""" + + @patch('src.engines.engine_manager.current_system_os') + @patch('src.engines.engine_manager.current_system_cpu') + def test_finds_matching_version( + self, mock_cpu, mock_os, engine_manager_instance, tmp_path, + ): + mock_os.return_value = 'macos' + mock_cpu.return_value = 'arm64' + engine_manager_instance.engines_path = str(tmp_path / 'engines') + + dirs = ['blender-3.6.0-macos-arm64', 'blender-4.1.0-macos-arm64'] + + with (patch('src.engines.engine_manager.os.listdir', return_value=dirs), + patch('src.engines.engine_manager.os.path.isdir', return_value=True)): + result = EngineManager.is_version_installed('blender', '3.6.0') + + assert result is not False + + @patch('src.engines.engine_manager.current_system_os') + @patch('src.engines.engine_manager.current_system_cpu') + def test_returns_false_on_no_match( + self, mock_cpu, mock_os, engine_manager_instance, tmp_path, + ): + mock_os.return_value = 'macos' + mock_cpu.return_value = 'arm64' + engine_manager_instance.engines_path = str(tmp_path / 'engines') + + result = EngineManager.is_version_installed('blender', '99.0.0') + assert result is False + + +class TestDeleteEngineDownload: + """Deleting a managed engine directory.""" + + @patch('src.engines.engine_manager.shutil.rmtree') + @patch('src.engines.engine_manager.current_system_os', return_value='macos') + @patch('src.engines.engine_manager.current_system_cpu', return_value='arm64') + def test_delete_managed_engine( + self, mock_cpu, mock_os, mock_rmtree, engine_manager_instance, tmp_path, + ): + engine_manager_instance.engines_path = str(tmp_path / 'engines') + engines_dir = tmp_path / 'engines' / 'blender-3.6.0-macos-arm64' + engines_dir.mkdir(parents=True) + (engines_dir / 'Blender').write_text('fake binary') + result = EngineManager.delete_engine_download('blender', '3.6.0') + assert result is True + mock_rmtree.assert_called_once_with(str(engines_dir), ignore_errors=False) + + def test_delete_nonexistent_engine(self, engine_manager_instance, tmp_path): + engine_manager_instance.engines_path = str(tmp_path / 'engines') + + result = EngineManager.delete_engine_download('nonexistent', '1.0.0') + assert result is False + + +class TestActiveDownloads: + """Background download tracking.""" + + def test_active_downloads_empty_initially(self, engine_manager_instance): + assert EngineManager.active_downloads() == [] + + def test_download_tasks_tracked(self, engine_manager_instance): + task = MagicMock(spec=EngineDownloadWorker) + task.is_alive.return_value = True + task.name = 'blender-4.0.0-macos-arm64' + engine_manager_instance.download_tasks.append(task) + + active = EngineManager.active_downloads() + assert len(active) == 1 + + +class TestCreateWorker: + """Creating worker instances for render jobs.""" + + def test_create_worker_raises_when_no_engines_installed(self, engine_manager_instance, tmp_path): + engine_manager_instance.engines_path = str(tmp_path / 'engines') + + with patch.object(engine_manager_instance, '_get_installed_engine_data', return_value=[]): + with pytest.raises(FileNotFoundError, match='Cannot find any installed'): + EngineManager.create_worker( + 'blender', + input_path=tmp_path / 'test.blend', + output_path=tmp_path / 'output', + ) + + def test_create_worker_raises_for_unknown_engine(self, engine_manager_instance): + with pytest.raises(AttributeError): + EngineManager.create_worker( + 'nonexistent', + input_path='/tmp/test.blend', + output_path='/tmp/output', + ) + + +class TestDownloadableEngines: + """Engines with a downloader.""" + + def test_downloadable_engines_returns_list(self, engine_manager_instance): + engines = EngineManager.downloadable_engines() + assert isinstance(engines, list) diff --git a/tests/test_preview_manager.py b/tests/test_preview_manager.py new file mode 100644 index 0000000..2564a5c --- /dev/null +++ b/tests/test_preview_manager.py @@ -0,0 +1,169 @@ +from unittest.mock import MagicMock, patch + +from src.api.preview_manager import PreviewManager + + +def make_mock_job(job_id='test-job-1', input_path='/tmp/test.blend', + file_list=None, **kwargs): + job = MagicMock() + job.id = job_id + job.input_path = input_path + job.file_list.return_value = file_list or [] + for k, v in kwargs.items(): + setattr(job, k, v) + return job + + +class TestPreviewManagerDefaults: + """Default state.""" + + def test_storage_path_none_initially(self, preview_manager_instance): + assert preview_manager_instance.storage_path is not None + + def test_running_jobs_empty_initially(self, preview_manager_instance): + assert preview_manager_instance._running_jobs == {} + + +class TestGeneratePreviewWorker: + """Core preview generation logic.""" + + def test_skips_when_no_supported_files(self, preview_manager_instance, tmp_path): + preview_manager_instance.storage_path = str(tmp_path) + job = make_mock_job(file_list=[str(tmp_path / 'output.txt')]) + + with patch.object(preview_manager_instance, '_generate_job_preview_worker') as mock_gen: + preview_manager_instance._update_previews_for_job(job) + mock_gen.assert_called_once_with(job, False) + + def test_uses_input_path_when_no_file_list(self, preview_manager_instance, tmp_path): + """When file_list is empty, falls back to input_path.""" + job = make_mock_job(input_path=str(tmp_path / 'output.mp4')) + + with patch.object(preview_manager_instance, '_generate_job_preview_worker') as mock_gen: + preview_manager_instance._update_previews_for_job(job) + mock_gen.assert_called_once() + + def test_generate_preview_checks_existing_files(self, preview_manager_instance, tmp_path): + preview_manager_instance.storage_path = str(tmp_path) + job = make_mock_job(input_path=str(tmp_path / 'test.jpg')) + + with patch('src.api.preview_manager.save_first_frame') as mock_save: + with patch('src.api.preview_manager.os.path.exists', return_value=False): + preview_manager_instance._generate_job_preview_worker(job) + + # No file_list → falls back to input_path → label is "input" + expected_img = str(tmp_path / f'{job.id}-input-480.jpg') + mock_save.assert_called_once_with( + source_path=job.input_path, + dest_path=expected_img, + max_width=480, + ) + + +class TestUpdatePreviewsForJob: + """Dispatch of preview generation.""" + + def test_starts_new_thread(self, preview_manager_instance, tmp_path): + preview_manager_instance.storage_path = str(tmp_path) + job = make_mock_job(file_list=[str(tmp_path / 'test.mp4')]) + + with patch('src.api.preview_manager.threading.Thread') as mock_thread: + thread_instance = MagicMock() + mock_thread.return_value = thread_instance + + preview_manager_instance._update_previews_for_job(job) + + mock_thread.assert_called_once() + thread_instance.start.assert_called_once() + assert preview_manager_instance._running_jobs[job.id] == thread_instance + + def test_reuses_existing_thread(self, preview_manager_instance, tmp_path): + preview_manager_instance.storage_path = str(tmp_path) + job = make_mock_job() + + existing_thread = MagicMock() + existing_thread.is_alive.return_value = True + preview_manager_instance._running_jobs[job.id] = existing_thread + + with patch('src.api.preview_manager.threading.Thread') as mock_thread: + preview_manager_instance._update_previews_for_job(job) + mock_thread.assert_not_called() + + def test_join_when_wait_requested(self, preview_manager_instance, tmp_path): + preview_manager_instance.storage_path = str(tmp_path) + job = make_mock_job() + + with patch('src.api.preview_manager.threading.Thread') as mock_thread: + thread_instance = MagicMock() + mock_thread.return_value = thread_instance + + preview_manager_instance._update_previews_for_job( + job, wait_until_completion=True, timeout=30, + ) + thread_instance.join.assert_called_once_with(timeout=30) + + +class TestGetPreviewsForJob: + """Reading preview files.""" + + def test_returns_empty_when_no_previews(self, preview_manager_instance, tmp_path): + preview_manager_instance.storage_path = str(tmp_path) + job = make_mock_job() + + result = preview_manager_instance._get_previews_for_job(job) + assert result == {} + + def test_returns_preview_files(self, preview_manager_instance, tmp_path): + preview_manager_instance.storage_path = str(tmp_path) + job = make_mock_job(job_id='abc') + + # Create a fake preview file + (tmp_path / 'abc-output-480.jpg').write_text('preview') + + result = preview_manager_instance._get_previews_for_job(job) + assert 'output' in result + assert len(result['output']) == 1 + assert result['output'][0]['kind'] == 'image' + + +class TestDeletePreviewsForJob: + """Cleaning up preview files.""" + + def test_deletes_existing_previews(self, preview_manager_instance, tmp_path): + preview_manager_instance.storage_path = str(tmp_path) + job = make_mock_job(job_id='abc') + + preview_file = tmp_path / 'abc-output-480.jpg' + preview_file.write_text('preview') + + preview_manager_instance._delete_previews_for_job(job) + assert not preview_file.exists() + + def test_no_error_when_no_previews(self, preview_manager_instance, tmp_path): + preview_manager_instance.storage_path = str(tmp_path) + job = make_mock_job() + preview_manager_instance._delete_previews_for_job(job) + + +class TestForwarders: + """Classmethod forwarders delegate to instance.""" + + def test_update_previews_for_job_forwarder(self, preview_manager_instance): + job = make_mock_job() + with patch.object(preview_manager_instance, '_update_previews_for_job') as mock_method: + PreviewManager.update_previews_for_job(job) + mock_method.assert_called_once_with(job, False, False, None) + + def test_get_previews_for_job_forwarder(self, preview_manager_instance): + job = make_mock_job() + with patch.object(preview_manager_instance, '_get_previews_for_job', + return_value={'input': []}) as mock_method: + result = PreviewManager.get_previews_for_job(job) + assert result == {'input': []} + mock_method.assert_called_once_with(job) + + def test_delete_previews_for_job_forwarder(self, preview_manager_instance): + job = make_mock_job() + with patch.object(preview_manager_instance, '_delete_previews_for_job') as mock_method: + PreviewManager.delete_previews_for_job(job) + mock_method.assert_called_once_with(job) diff --git a/tests/test_render_queue.py b/tests/test_render_queue.py new file mode 100644 index 0000000..26d8760 --- /dev/null +++ b/tests/test_render_queue.py @@ -0,0 +1,307 @@ +from unittest.mock import MagicMock, patch +from datetime import datetime + +import pytest + +from src.render_queue import RenderQueue, JobNotFoundError +from src.utilities.status_utils import RenderStatus + + +def make_mock_job(job_id='test-1', status=RenderStatus.NOT_STARTED, + engine_name='blender', priority=2, **kwargs): + job = MagicMock() + job.id = job_id + job.status = status + job.engine_name = engine_name + job.priority = priority + job.name = kwargs.get('name', 'Test Job') + job.scheduled_start = kwargs.get('scheduled_start', None) + for k, v in kwargs.items(): + setattr(job, k, v) + return job + + +class TestRenderQueueDefaults: + """Default state.""" + + def test_init_sets_empty_queue(self, render_queue_instance): + assert render_queue_instance.job_queue == [] + + def test_init_sets_max_instances(self, render_queue_instance): + assert render_queue_instance.maximum_renderer_instances == {'blender': 1, 'aerender': 1, 'ffmpeg': 4} + + def test_init_is_not_running(self, render_queue_instance): + assert render_queue_instance.is_running is False + + +class TestAllJobs: + """Returning all jobs.""" + + def test_all_jobs_empty_initially(self, render_queue_instance): + assert RenderQueue.all_jobs() == [] + + def test_all_jobs_returns_job_list(self, render_queue_instance): + job = make_mock_job() + render_queue_instance.job_queue.append(job) + assert len(RenderQueue.all_jobs()) == 1 + assert RenderQueue.all_jobs()[0] == job + + +class TestJobsWithStatus: + """Filtering jobs by status.""" + + def test_jobs_with_status_returns_matching(self, render_queue_instance): + running = make_mock_job('job-1', RenderStatus.RUNNING) + pending = make_mock_job('job-2', RenderStatus.NOT_STARTED) + render_queue_instance.job_queue.extend([running, pending]) + + result = RenderQueue.jobs_with_status(RenderStatus.RUNNING) + assert len(result) == 1 + assert result[0].id == 'job-1' + + def test_jobs_with_status_empty_when_no_match(self, render_queue_instance): + job = make_mock_job('job-1', RenderStatus.COMPLETED) + render_queue_instance.job_queue.append(job) + + result = RenderQueue.jobs_with_status(RenderStatus.RUNNING) + assert result == [] + + def test_jobs_with_status_sorts_by_priority(self, render_queue_instance): + high = make_mock_job('job-1', RenderStatus.NOT_STARTED, priority=1) + low = make_mock_job('job-2', RenderStatus.NOT_STARTED, priority=5) + render_queue_instance.job_queue.extend([low, high]) + + result = RenderQueue.jobs_with_status(RenderStatus.NOT_STARTED, priority_sorted=True) + assert result[0].id == 'job-1' + + +class TestRunningAndPending: + """Convenience methods for running/pending.""" + + def test_running_jobs(self, render_queue_instance): + running = make_mock_job('job-1', RenderStatus.RUNNING) + pending = make_mock_job('job-2', RenderStatus.NOT_STARTED) + render_queue_instance.job_queue.extend([running, pending]) + + result = RenderQueue.running_jobs() + assert len(result) == 1 + assert result[0].id == 'job-1' + + def test_pending_jobs_includes_not_started_and_scheduled(self, render_queue_instance): + ns = make_mock_job('job-1', RenderStatus.NOT_STARTED) + sched = make_mock_job('job-2', RenderStatus.SCHEDULED) + running = make_mock_job('job-3', RenderStatus.RUNNING) + render_queue_instance.job_queue.extend([ns, sched, running]) + + result = RenderQueue.pending_jobs() + assert len(result) == 2 + + +class TestJobWithId: + """Lookup by job ID.""" + + def test_finds_job(self, render_queue_instance): + job = make_mock_job('abc-123') + render_queue_instance.job_queue.append(job) + + found = RenderQueue.job_with_id('abc-123') + assert found == job + + def test_raises_when_not_found(self, render_queue_instance): + with pytest.raises(JobNotFoundError, match='abc-123'): + RenderQueue.job_with_id('abc-123') + + def test_returns_none_when_not_found_with_none_ok(self, render_queue_instance): + result = RenderQueue.job_with_id('abc-123', none_ok=True) + assert result is None + + +class TestJobCounts: + """Counting jobs by status.""" + + def test_job_counts_returns_all_statuses(self, render_queue_instance): + render_queue_instance.job_queue.extend([ + make_mock_job('j1', RenderStatus.RUNNING), + make_mock_job('j2', RenderStatus.COMPLETED), + make_mock_job('j3', RenderStatus.NOT_STARTED), + ]) + + counts = RenderQueue.job_counts() + assert counts[RenderStatus.RUNNING.value] == 1 + assert counts[RenderStatus.COMPLETED.value] == 1 + assert counts[RenderStatus.NOT_STARTED.value] == 1 + assert counts[RenderStatus.ERROR.value] == 0 + assert counts[RenderStatus.CANCELLED.value] == 0 + + +class TestIsAvailableForJob: + """Renderer availability checks.""" + + def test_available_when_no_running_jobs(self, render_queue_instance): + assert RenderQueue.is_available_for_job('blender') is True + + def test_not_available_when_maxed_out(self, render_queue_instance): + render_queue_instance.job_queue.append( + make_mock_job('j1', RenderStatus.RUNNING, engine_name='blender') + ) + assert RenderQueue.is_available_for_job('blender') is False + + def test_available_for_different_renderer(self, render_queue_instance): + render_queue_instance.job_queue.append( + make_mock_job('j1', RenderStatus.RUNNING, engine_name='blender') + ) + assert RenderQueue.is_available_for_job('ffmpeg') is True + + def test_blocked_by_higher_priority(self, render_queue_instance): + render_queue_instance.job_queue.append( + make_mock_job('j1', RenderStatus.RUNNING, engine_name='blender', priority=0) + ) + assert RenderQueue.is_available_for_job('blender', priority=2) is False + + def test_ffmpeg_allows_multiple_instances(self, render_queue_instance): + for i in range(4): + render_queue_instance.job_queue.append( + make_mock_job(f'j{i}', RenderStatus.RUNNING, engine_name='ffmpeg') + ) + assert RenderQueue.is_available_for_job('ffmpeg') is False + + +class TestAddToRenderQueue: + """Adding jobs.""" + + def test_add_job_appends_to_queue(self, render_queue_instance): + render_queue_instance.session = MagicMock() + job = make_mock_job() + RenderQueue.add_to_render_queue(job) + + assert job in render_queue_instance.job_queue + + def test_add_job_saves_state(self, render_queue_instance): + render_queue_instance.session = MagicMock() + + job = make_mock_job() + RenderQueue.add_to_render_queue(job) + + render_queue_instance.session.add.assert_called_once_with(job) + + def test_add_job_force_start_calls_start(self, render_queue_instance): + render_queue_instance.session = MagicMock() + job = make_mock_job(status=RenderStatus.NOT_STARTED) + with patch.object(render_queue_instance, '_start_job') as mock_start: + RenderQueue.add_to_render_queue(job, force_start=True) + mock_start.assert_called_once_with(job) + + def test_add_job_force_start_skips_completed(self, render_queue_instance): + render_queue_instance.session = MagicMock() + job = make_mock_job(status=RenderStatus.COMPLETED) + with patch.object(render_queue_instance, '_start_job') as mock_start: + RenderQueue.add_to_render_queue(job, force_start=True) + mock_start.assert_not_called() + + def test_add_job_evaluates_queue_when_running(self, render_queue_instance): + render_queue_instance.session = MagicMock() + render_queue_instance.is_running = True + job = make_mock_job() + + with patch.object(render_queue_instance, '_evaluate_queue') as mock_eval: + RenderQueue.add_to_render_queue(job) + mock_eval.assert_called_once() + + +class TestStartCancelDelete: + """Job lifecycle.""" + + def test_start_job_calls_job_start(self, render_queue_instance): + job = make_mock_job() + with patch.object(render_queue_instance, '_save_state'): + RenderQueue.start_job(job) + job.start.assert_called_once() + + def test_cancel_job_calls_job_stop(self, render_queue_instance): + job = make_mock_job() + job.stop.return_value = None + job.status = RenderStatus.CANCELLED + + result = RenderQueue.cancel_job(job) + assert result is True + job.stop.assert_called_once() + + def test_delete_job_removes_from_queue(self, render_queue_instance): + job = make_mock_job() + render_queue_instance.job_queue.append(job) + render_queue_instance.session = MagicMock() + + result = RenderQueue.delete_job(job) + assert result is True + assert job not in render_queue_instance.job_queue + render_queue_instance.session.delete.assert_called_once_with(job) + + +class TestStartStop: + """Starting and stopping the queue evaluation loop.""" + + def test_start_sets_running_and_evaluates(self, render_queue_instance): + with patch.object(render_queue_instance, '_evaluate_queue') as mock_eval: + RenderQueue.start() + assert render_queue_instance.is_running is True + mock_eval.assert_called_once() + + def test_stop_clears_running(self, render_queue_instance): + render_queue_instance.is_running = True + RenderQueue.stop() + assert render_queue_instance.is_running is False + + +class TestEvaluateQueue: + """Queue evaluation dispatches jobs.""" + + def test_evaluate_starts_not_started_jobs(self, render_queue_instance): + job = make_mock_job() + render_queue_instance.job_queue.append(job) + + with patch.object(render_queue_instance, '_start_job') as mock_start: + with patch.object(render_queue_instance, '_save_state'): + RenderQueue.evaluate_queue() + mock_start.assert_called_once_with(job) + + def test_evaluate_respects_max_instances(self, render_queue_instance): + # One already running, only 1 slot for blender + render_queue_instance.job_queue.append( + make_mock_job('running-1', RenderStatus.RUNNING, engine_name='blender') + ) + waiting = make_mock_job('waiting-1', RenderStatus.NOT_STARTED, engine_name='blender') + render_queue_instance.job_queue.append(waiting) + + with patch.object(render_queue_instance, '_start_job') as mock_start: + with patch.object(render_queue_instance, '_save_state'): + RenderQueue.evaluate_queue() + mock_start.assert_not_called() + + def test_evaluate_starts_scheduled_jobs(self, render_queue_instance): + past = datetime(2020, 1, 1) + job = make_mock_job(status=RenderStatus.SCHEDULED, scheduled_start=past) + render_queue_instance.job_queue.append(job) + + with patch.object(render_queue_instance, '_start_job') as mock_start: + with patch.object(render_queue_instance, '_save_state'): + RenderQueue.evaluate_queue() + mock_start.assert_called_once_with(job) + + +class TestClearHistory: + """Clearing completed/cancelled/error jobs.""" + + def test_clear_history_removes_finished_jobs(self, render_queue_instance): + render_queue_instance.session = MagicMock() + jobs = [ + make_mock_job('c', RenderStatus.COMPLETED), + make_mock_job('e', RenderStatus.ERROR), + make_mock_job('x', RenderStatus.CANCELLED), + make_mock_job('r', RenderStatus.RUNNING), + ] + render_queue_instance.job_queue.extend(jobs) + + RenderQueue.clear_history() + + assert len(render_queue_instance.job_queue) == 1 + assert render_queue_instance.job_queue[0].id == 'r' diff --git a/tests/test_zeroconf_server.py b/tests/test_zeroconf_server.py new file mode 100644 index 0000000..ae57276 --- /dev/null +++ b/tests/test_zeroconf_server.py @@ -0,0 +1,159 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from src.utilities.zeroconf_server import ZeroconfServer + + +class TestConfigure: + """Configuring service parameters.""" + + def test_configure_sets_attributes(self, zeroconf_server_instance): + with patch('socket.gethostbyname', return_value='192.168.1.1'): + ZeroconfServer.configure('_zordon._tcp.local.', 'test-server', 8080) + + assert zeroconf_server_instance.service_type == '_zordon._tcp.local.' + assert zeroconf_server_instance.server_name == 'test-server' + assert zeroconf_server_instance.server_port == 8080 + + def test_configure_calls_sync_class(self, zeroconf_server_instance): + with patch('socket.gethostbyname', return_value='192.168.1.1'): + ZeroconfServer.configure('_zordon._tcp.local.', 'test-server', 8080) + + assert ZeroconfServer.service_type == '_zordon._tcp.local.' + assert ZeroconfServer.server_port == 8080 + + def test_configure_stops_on_gaierror(self, zeroconf_server_instance): + import socket + with patch('socket.gethostbyname', side_effect=socket.gaierror): + with patch.object(zeroconf_server_instance, '_stop') as mock_stop: + ZeroconfServer.configure('_zordon._tcp.local.', 'test-server', 8080) + mock_stop.assert_called_once() + + +class TestSyncClass: + """_sync_class propagates instance attrs to class level.""" + + def test_sync_class_propagates_all_attrs(self, zeroconf_server_instance): + zeroconf_server_instance.service_type = '_test._tcp.' + zeroconf_server_instance.server_name = 'foo' + zeroconf_server_instance.server_port = 9999 + zeroconf_server_instance.server_ip = '10.0.0.1' + zeroconf_server_instance.properties = {'key': 'val'} + + ZeroconfServer._sync_class() + + assert ZeroconfServer.service_type == '_test._tcp.' + assert ZeroconfServer.server_name == 'foo' + assert ZeroconfServer.server_port == 9999 + assert ZeroconfServer.server_ip == '10.0.0.1' + assert ZeroconfServer.properties == {'key': 'val'} + + +class TestStartStop: + """Service lifecycle.""" + + def test_start_raises_without_configure(self, zeroconf_server_instance): + with pytest.raises(RuntimeError, match='configure'): + ZeroconfServer.start() + + @patch('src.utilities.zeroconf_server.ServiceBrowser') + def test_start_listen_only_skips_register(self, mock_browser, zeroconf_server_instance): + with patch('socket.gethostbyname', return_value='192.168.1.1'): + ZeroconfServer.configure('_zordon._tcp.local.', 'test', 8080) + + with patch.object(zeroconf_server_instance, '_register_service') as mock_register: + ZeroconfServer.start(listen_only=True) + mock_register.assert_not_called() + + @patch('src.utilities.zeroconf_server.ServiceBrowser') + def test_start_registers_service(self, mock_browser, zeroconf_server_instance): + with patch('socket.gethostbyname', return_value='192.168.1.1'): + ZeroconfServer.configure('_zordon._tcp.local.', 'test', 8080) + + with patch.object(zeroconf_server_instance, '_register_service') as mock_register: + ZeroconfServer.start(listen_only=False) + mock_register.assert_called_once() + + def test_stop_unregisters_and_closes(self, zeroconf_server_instance): + zeroconf_server_instance.service_info = MagicMock() + + with patch.object(zeroconf_server_instance, '_unregister_service') as mock_unreg: + with patch.object(zeroconf_server_instance.zeroconf, 'close') as mock_close: + ZeroconfServer.stop() + mock_unreg.assert_called_once() + mock_close.assert_called_once() + + +class TestRegisterService: + """Service registration with Zeroconf.""" + + @patch('src.utilities.zeroconf_server.ServiceInfo') + @patch('socket.gethostbyname') + def test_registers_service_info(self, mock_gethostbyname, mock_service_info, + zeroconf_server_instance): + mock_gethostbyname.return_value = '192.168.1.1' + zeroconf_server_instance.service_type = '_zordon._tcp.local.' + zeroconf_server_instance.server_name = 'test' + zeroconf_server_instance.server_port = 8080 + zeroconf_server_instance.properties = {} + + # Replace real Zeroconf with a mock so we don't actually register + zeroconf_server_instance.zeroconf = MagicMock() + + mock_info = MagicMock() + mock_service_info.return_value = mock_info + + with patch('socket.inet_aton', return_value=b'\xc0\xa8\x01\x01'): + zeroconf_server_instance._register_service() + + zeroconf_server_instance.zeroconf.register_service.assert_called_once_with(mock_info) + assert zeroconf_server_instance.service_info == mock_info + + +class TestFoundHostnames: + """Discovery cache.""" + + def test_found_hostnames_empty_initially(self, zeroconf_server_instance): + result = zeroconf_server_instance._found_hostnames() + assert result == [] + + @patch('socket.gethostname', return_value='my-machine') + def test_hostnames_sorted_with_local_first(self, mock_hostname, zeroconf_server_instance): + zeroconf_server_instance.client_cache = { + 'other-machine': MagicMock(), + 'my-machine': MagicMock(), + } + + result = zeroconf_server_instance._found_hostnames() + # sort_key returns False (0) for local → sorted first + assert result[0] == 'my-machine' + + def test_get_hostname_properties_returns_decoded(self, zeroconf_server_instance): + info = MagicMock() + info.properties = {b'key': b'value', b'num': b'42'} + zeroconf_server_instance.client_cache['server-1'] = info + + result = zeroconf_server_instance._get_hostname_properties('server-1') + assert result == {'key': 'value', 'num': '42'} + + def test_get_hostname_properties_returns_empty_for_unknown(self, zeroconf_server_instance): + result = zeroconf_server_instance._get_hostname_properties('unknown') + assert result == {} + + +class TestForwarders: + """Classmethod forwarders delegate to instance.""" + + def test_found_hostnames_forwarder(self, zeroconf_server_instance): + zeroconf_server_instance.client_cache['svr'] = MagicMock() + result = ZeroconfServer.found_hostnames() + assert 'svr' in result + + def test_get_hostname_properties_forwarder(self, zeroconf_server_instance): + info = MagicMock() + info.properties = {} + zeroconf_server_instance.client_cache['svr'] = info + + result = ZeroconfServer.get_hostname_properties('svr') + assert result == {}