mirror of
https://github.com/blw1138/Zordon.git
synced 2026-06-09 13:39:24 -05:00
Add Unit Tests (#132)
* Add tests and new github workflow * Add new unit tests * Add Github CI workflow * Workflow fix * Add pytest install to workflow file * More CI / test updates * More test cleanup * Whitespace cleanup and link complexity override * More whitespace cleanup * Make lint less strict * More lint tweaks
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
|
||||||
+3
-1
@@ -1,2 +1,4 @@
|
|||||||
[pytest]
|
[pytest]
|
||||||
norecursedirs = src/engines/aerender .git build dist *.egg venv .venv env .env __pycache__ .pytest_cache
|
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
|
||||||
@@ -270,7 +270,7 @@ class DistributedJobManager:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def find_available_servers(engine_name: str, system_os=None):
|
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 = []
|
found_available_servers = []
|
||||||
for hostname in ZeroconfServer.found_hostnames():
|
for hostname in ZeroconfServer.found_hostnames():
|
||||||
host_properties = ZeroconfServer.get_hostname_properties(hostname)
|
host_properties = ZeroconfServer.get_hostname_properties(hostname)
|
||||||
|
|||||||
@@ -80,9 +80,10 @@ class EngineManager:
|
|||||||
|
|
||||||
binary_name = result_dict['engine'].lower()
|
binary_name = result_dict['engine'].lower()
|
||||||
eng = self.engine_class_with_name(result_dict['engine'])
|
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)
|
match = next((p for p in search_root.rglob(binary_name) if p.is_file()), None)
|
||||||
path = str(match) if match else None
|
path = str(match) if match else None
|
||||||
result_dict['path'] = path
|
result_dict['path'] = path
|
||||||
|
|||||||
@@ -225,35 +225,35 @@ def get_gpu_info() -> List[Dict[str, Any]]:
|
|||||||
"""Get GPU info on Windows"""
|
"""Get GPU info on Windows"""
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
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
|
capture_output=True, text=True, timeout=5
|
||||||
)
|
)
|
||||||
|
|
||||||
# Virtual adapters to exclude
|
# Virtual adapters to exclude
|
||||||
virtual_adapters = [
|
virtual_adapters = [
|
||||||
'virtual', 'rdp', 'hyper-v', 'microsoft basic', 'basic display',
|
'virtual', 'rdp', 'hyper-v', 'microsoft basic', 'basic display',
|
||||||
'vga compatible', 'dummy', 'nvfbc', 'nvencode'
|
'vga compatible', 'dummy', 'nvfbc', 'nvencode'
|
||||||
]
|
]
|
||||||
|
|
||||||
gpus = []
|
gpus = []
|
||||||
current_gpu = None
|
current_gpu = None
|
||||||
|
|
||||||
for line in result.stdout.strip().split('\n'):
|
for line in result.stdout.strip().split('\n'):
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if not line:
|
if not line:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if line.startswith('Name='):
|
if line.startswith('Name='):
|
||||||
if current_gpu and current_gpu.get('name'):
|
if current_gpu and current_gpu.get('name'):
|
||||||
gpus.append(current_gpu)
|
gpus.append(current_gpu)
|
||||||
gpu_name = line.replace('Name=', '').strip()
|
gpu_name = line.replace('Name=', '').strip()
|
||||||
|
|
||||||
# Skip virtual adapters
|
# Skip virtual adapters
|
||||||
if any(virtual in gpu_name.lower() for virtual in virtual_adapters):
|
if any(virtual in gpu_name.lower() for virtual in virtual_adapters):
|
||||||
current_gpu = None
|
current_gpu = None
|
||||||
else:
|
else:
|
||||||
current_gpu = {'name': gpu_name, 'memory': 'Integrated'}
|
current_gpu = {'name': gpu_name, 'memory': 'Integrated'}
|
||||||
|
|
||||||
elif line.startswith('AdapterRAM=') and current_gpu:
|
elif line.startswith('AdapterRAM=') and current_gpu:
|
||||||
vram_bytes_str = line.replace('AdapterRAM=', '').strip()
|
vram_bytes_str = line.replace('AdapterRAM=', '').strip()
|
||||||
if vram_bytes_str and vram_bytes_str != '0':
|
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)
|
current_gpu['memory'] = round(vram_gb, 2)
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if current_gpu and current_gpu.get('name'):
|
if current_gpu and current_gpu.get('name'):
|
||||||
gpus.append(current_gpu)
|
gpus.append(current_gpu)
|
||||||
|
|
||||||
return gpus if gpus else [{'name': 'Unknown GPU', 'memory': 'Unknown'}]
|
return gpus if gpus else [{'name': 'Unknown GPU', 'memory': 'Unknown'}]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get Windows GPU info: {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'],
|
result = subprocess.run(['system_profiler', 'SPDisplaysDataType', '-detailLevel', 'mini', '-json'],
|
||||||
capture_output=True, text=True, timeout=5)
|
capture_output=True, text=True, timeout=5)
|
||||||
data = json.loads(result.stdout)
|
data = json.loads(result.stdout)
|
||||||
|
|
||||||
gpus = []
|
gpus = []
|
||||||
displays = data.get('SPDisplaysDataType', [])
|
displays = data.get('SPDisplaysDataType', [])
|
||||||
for display in displays:
|
for display in displays:
|
||||||
@@ -294,7 +294,7 @@ def get_gpu_info() -> List[Dict[str, Any]]:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to get macOS GPU info: {e}")
|
print(f"Failed to get macOS GPU info: {e}")
|
||||||
return [{'name': 'Unknown GPU', 'memory': 'Unknown'}]
|
return [{'name': 'Unknown GPU', 'memory': 'Unknown'}]
|
||||||
|
|
||||||
def get_linux_gpu_info():
|
def get_linux_gpu_info():
|
||||||
gpus = []
|
gpus = []
|
||||||
try:
|
try:
|
||||||
@@ -316,7 +316,7 @@ def get_gpu_info() -> List[Dict[str, Any]]:
|
|||||||
vendor = "AMD"
|
vendor = "AMD"
|
||||||
elif "intel" in name.lower():
|
elif "intel" in name.lower():
|
||||||
vendor = "Intel"
|
vendor = "Intel"
|
||||||
|
|
||||||
gpus.append({
|
gpus.append({
|
||||||
"name": name,
|
"name": name,
|
||||||
"vendor": vendor,
|
"vendor": vendor,
|
||||||
@@ -332,7 +332,7 @@ def get_gpu_info() -> List[Dict[str, Any]]:
|
|||||||
return gpus
|
return gpus
|
||||||
|
|
||||||
system = platform.system()
|
system = platform.system()
|
||||||
|
|
||||||
if system == 'Darwin': # macOS
|
if system == 'Darwin': # macOS
|
||||||
return get_macos_gpu_info()
|
return get_macos_gpu_info()
|
||||||
elif system == 'Windows':
|
elif system == 'Windows':
|
||||||
@@ -340,55 +340,56 @@ def get_gpu_info() -> List[Dict[str, Any]]:
|
|||||||
else: # Assume Linux or other
|
else: # Assume Linux or other
|
||||||
return get_linux_gpu_info()
|
return get_linux_gpu_info()
|
||||||
|
|
||||||
|
|
||||||
COMMON_RESOLUTIONS = {
|
COMMON_RESOLUTIONS = {
|
||||||
# SD
|
# SD
|
||||||
"SD_480p": (640, 480),
|
"SD_480p": (640, 480),
|
||||||
"NTSC_DVD": (720, 480),
|
"NTSC_DVD": (720, 480),
|
||||||
"PAL_DVD": (720, 576),
|
"PAL_DVD": (720, 576),
|
||||||
|
|
||||||
# HD
|
# HD
|
||||||
"HD_720p": (1280, 720),
|
"HD_720p": (1280, 720),
|
||||||
"HD_900p": (1600, 900),
|
"HD_900p": (1600, 900),
|
||||||
"HD_1080p": (1920, 1080),
|
"HD_1080p": (1920, 1080),
|
||||||
|
|
||||||
# Cinema / Film
|
# Cinema / Film
|
||||||
"2K_DCI": (2048, 1080),
|
"2K_DCI": (2048, 1080),
|
||||||
"4K_DCI": (4096, 2160),
|
"4K_DCI": (4096, 2160),
|
||||||
|
|
||||||
# UHD / Consumer
|
# UHD / Consumer
|
||||||
"UHD_4K": (3840, 2160),
|
"UHD_4K": (3840, 2160),
|
||||||
"UHD_5K": (5120, 2880),
|
"UHD_5K": (5120, 2880),
|
||||||
"UHD_8K": (7680, 4320),
|
"UHD_8K": (7680, 4320),
|
||||||
|
|
||||||
# Ultrawide / Aspect Variants
|
# Ultrawide / Aspect Variants
|
||||||
"UW_1080p": (2560, 1080),
|
"UW_1080p": (2560, 1080),
|
||||||
"UW_1440p": (3440, 1440),
|
"UW_1440p": (3440, 1440),
|
||||||
"UW_5K": (5120, 2160),
|
"UW_5K": (5120, 2160),
|
||||||
|
|
||||||
# Mobile / Social
|
# Mobile / Social
|
||||||
"VERTICAL_1080x1920": (1080, 1920),
|
"VERTICAL_1080x1920": (1080, 1920),
|
||||||
"SQUARE_1080": (1080, 1080),
|
"SQUARE_1080": (1080, 1080),
|
||||||
|
|
||||||
# Classic / Legacy
|
# Classic / Legacy
|
||||||
"VGA": (640, 480),
|
"VGA": (640, 480),
|
||||||
"SVGA": (800, 600),
|
"SVGA": (800, 600),
|
||||||
"XGA": (1024, 768),
|
"XGA": (1024, 768),
|
||||||
"WXGA": (1280, 800),
|
"WXGA": (1280, 800),
|
||||||
}
|
}
|
||||||
|
|
||||||
COMMON_FRAME_RATES = {
|
COMMON_FRAME_RATES = {
|
||||||
"23.976 (NTSC Film)": 23.976,
|
"23.976 (NTSC Film)": 23.976,
|
||||||
"24 (Cinema)": 24.0,
|
"24 (Cinema)": 24.0,
|
||||||
"25 (PAL)": 25.0,
|
"25 (PAL)": 25.0,
|
||||||
"29.97 (NTSC)": 29.97,
|
"29.97 (NTSC)": 29.97,
|
||||||
"30": 30.0,
|
"30": 30.0,
|
||||||
"48 (HFR Film)": 48.0,
|
"48 (HFR Film)": 48.0,
|
||||||
"50 (PAL HFR)": 50.0,
|
"50 (PAL HFR)": 50.0,
|
||||||
"59.94": 59.94,
|
"59.94": 59.94,
|
||||||
"60": 60.0,
|
"60": 60.0,
|
||||||
"72": 72.0,
|
"72": 72.0,
|
||||||
"90 (VR)": 90.0,
|
"90 (VR)": 90.0,
|
||||||
"120": 120.0,
|
"120": 120.0,
|
||||||
"144 (Gaming)": 144.0,
|
"144 (Gaming)": 144.0,
|
||||||
"240 (HFR)": 240.0,
|
"240 (HFR)": 240.0,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
Binary file not shown.
@@ -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()
|
||||||
@@ -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'
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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'
|
||||||
@@ -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 == {}
|
||||||
Reference in New Issue
Block a user