From 33f77a6fb90b6cf4890dcf0ea3ca260b12889906 Mon Sep 17 00:00:00 2001 From: Andy Choquette <78888816+andychoquette@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:43:51 -0700 Subject: [PATCH] test: add pytest-qt GUI tests as open-source alternative to Squish Add pytest-qt tests that replace the Squish tst_verify_settings_dialogue and tst_verify_gui_submitter_bundles test suites. These tests use MockDeadlineBackend to provide fake API responses in-process, requiring no AWS credentials, no Squish license, and no external infrastructure. New files: - test/gui/conftest.py: shared fixture for MockDeadlineBackend - test/gui/test_settings_dialogue.py: 14 tests for the config dialog - test/gui/test_gui_submitter_bundles.py: 6 tests for the GUI submitter Changes: - mock_deadline_backend.py: add ListFarms, ListQueues, ListStorageProfilesForQueue APIs and storage_profiles storage - hatch.toml: add [envs.gui] with pytest-qt and PySide6 deps - code_quality.yml: add GUITests job (Linux/macOS/Windows matrix) Run locally with: hatch run gui:test Signed-off-by: Andy Choquette Signed-off-by: Andy Choquette <78888816+andychoquette@users.noreply.github.com> --- .github/workflows/code_quality.yml | 28 ++ DEVELOPMENT.md | 18 +- hatch.toml | 10 + test/gui/conftest.py | 29 ++ test/gui/test_gui_submitter_bundles.py | 154 ++++++++ test/gui/test_settings_dialogue.py | 347 ++++++++++++++++++ .../deadline_client/mock_deadline_backend.py | 68 ++++ 7 files changed, 652 insertions(+), 2 deletions(-) create mode 100644 test/gui/conftest.py create mode 100644 test/gui/test_gui_submitter_bundles.py create mode 100644 test/gui/test_settings_dialogue.py diff --git a/.github/workflows/code_quality.yml b/.github/workflows/code_quality.yml index 6b36cf398..b1d6dde88 100644 --- a/.github/workflows/code_quality.yml +++ b/.github/workflows/code_quality.yml @@ -54,3 +54,31 @@ jobs: python-version: '3.13' - run: pip install hatch - run: hatch run attributions:check + + GUITests: + name: GUI Tests (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + with: + ref: ${{inputs.tag}} + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - name: Install Qt dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq libegl1 libdbus-1-3 libxkbcommon-x11-0 \ + libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 \ + libxcb-render-util0 libxcb-xinerama0 libxcb-xinput0 libxcb-xfixes0 \ + x11-utils libxcb-cursor0 libopengl0 + - run: pip install hatch + - name: Run GUI tests + uses: coactions/setup-xvfb@v1 + with: + run: hatch run gui:test diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e7727b2da..860d551fc 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -78,7 +78,7 @@ process along the lines of the following as a starting point: Iteratively improve your implementation until all unit tests pass. (See [Unit tests](#unit-tests)) 3. Add integration tests for your changes if applicable. Ensure that all integration tests pass. Iteratively improve your implementation until all integration and unit tests pass. (See [Integration tests](#integration-tests)) -4. Add Squish GUI tests for your changes if applicable. Ensure that all Squish GUI tests pass. (See [Squish GUI tests](#squish-tests)) +4. Add GUI tests (`hatch run gui:test`) or Squish GUI tests for your changes if applicable. Ensure that all GUI tests pass. (See [GUI tests](#gui-tests-pytest-qt) and [Squish GUI tests](#squish-tests)) Once you are satisfied with your code, and all relevant tests pass, then run `hatch run fmt` to fix up the formatting of your code and post your pull request. @@ -113,7 +113,9 @@ The tests for this package have three forms: without requiring an AWS account. 2. Integration tests - Tests that ensure that the implementation behaves as expected when run in a real environment. Ensuring that code properly interacts as expected with a real Amazon S3 bucket, for instance. -3. Squish GUI Submitter tests - Tests that verify the Deadline GUI using Squish automated framework. Squish tests require a license. +3. GUI tests - Tests that verify the Deadline GUI widgets and dialogs using [pytest-qt](https://pytest-qt.readthedocs.io/). + These use MockDeadlineBackend for API responses and require no AWS account or Squish license. +4. Squish GUI Submitter tests - Tests that verify the Deadline GUI using Squish automated framework. Squish tests require a license. ### Writing Tests @@ -208,6 +210,18 @@ Notes: define the `AWS_ENDPOINT_URL_DEADLINE` environment variable to the non-production endpoint URL. For example, production endpoints look like: `export AWS_ENDPOINT_URL_DEADLINE="https://deadline.$AWS_DEFAULT_REGION.amazonaws.com"` +### GUI Tests (pytest-qt) + +GUI tests are located under the `test/gui` directory. They use [pytest-qt](https://pytest-qt.readthedocs.io/) to test Qt widgets and dialogs in-process, with `MockDeadlineBackend` providing fake API responses. No AWS credentials or Squish license required. + +#### Running GUI Tests + +```sh +hatch run gui:test +``` + +These tests run automatically in CI on Linux, macOS, and Windows as part of the Code Quality workflow. + ### Squish GUI Submitter Tests Squish GUI tests are located under the `test/squish` directory of this repository. New tests can be added for the Deadline GUI when necessary (ie: new functionality is introduced and a test can be added for coverage, or existing functionality is modified). When changes are made, Squish automated tests should be run to ensure changes are not breaking Deadline CLI and GUI functionality. diff --git a/hatch.toml b/hatch.toml index 76cca53fc..e0afecaef 100644 --- a/hatch.toml +++ b/hatch.toml @@ -16,6 +16,16 @@ check-imports = "lint-imports" [[envs.all.matrix]] python = ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] +[envs.gui] +extra-dependencies = [ + "pytest", + "pytest-qt == 4.*", + "PySide6-Essentials >= 6.6, < 6.9", +] + +[envs.gui.scripts] +test = "pytest --no-cov -vvv {args:test/gui}" + [envs.integ] pre-install-commands = [ "pip install -r requirements-integ-testing.txt", diff --git a/test/gui/conftest.py b/test/gui/conftest.py new file mode 100644 index 000000000..b244533ab --- /dev/null +++ b/test/gui/conftest.py @@ -0,0 +1,29 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +"""Shared fixtures for pytest-qt GUI tests.""" + +import importlib.util +import os + +import pytest + +# Load MockDeadlineBackend by file path to avoid sys.path pollution +# (the test/unit/deadline_client/dataclasses/ package would shadow stdlib). +_mock_path = os.path.join( + os.path.dirname(__file__), + "..", + "unit", + "deadline_client", + "mock_deadline_backend.py", +) +_spec = importlib.util.spec_from_file_location("mock_deadline_backend", os.path.abspath(_mock_path)) +assert _spec and _spec.loader +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) +MockDeadlineBackend = _mod.MockDeadlineBackend + + +@pytest.fixture +def mock_deadline_backend(): + """Provide a fresh MockDeadlineBackend instance.""" + return MockDeadlineBackend(validate_params=False) diff --git a/test/gui/test_gui_submitter_bundles.py b/test/gui/test_gui_submitter_bundles.py new file mode 100644 index 000000000..08d05b35a --- /dev/null +++ b/test/gui/test_gui_submitter_bundles.py @@ -0,0 +1,154 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +""" +pytest-qt proof-of-concept: GUI submitter bundle tests. + +This is a pytest-qt equivalent of the Squish tst_verify_gui_submitter_bundles test, +verifying that the Submit to AWS Deadline Cloud dialog correctly loads job bundles +and displays their settings. + +Run with: + hatch run gui:test +""" + +import os +from configparser import ConfigParser +from unittest.mock import MagicMock, PropertyMock, patch + +import pytest + +try: + from qtpy.QtWidgets import QWidget + from deadline.client.ui.dataclasses import JobBundleSettings + from deadline.client.ui.dialogs.submit_job_to_deadline_dialog import ( + SubmitJobToDeadlineDialog, + ) + from deadline.client.job_bundle.submission import AssetReferences +except ImportError: + pytest.skip("GUI dependencies not available", allow_module_level=True) + + +# Paths to the sample job bundles shipped with the Squish tests +_SAMPLES_DIR = os.path.join(os.path.dirname(__file__), "..", "squish", "deadline_gui_test_samples") +SIMPLE_UI_WITH_JA = os.path.join(_SAMPLES_DIR, "simple_ui_with_ja") +SIMPLE_UI_NO_JA = os.path.join(_SAMPLES_DIR, "simple_ui_no_ja") + + +class MockJobSettingsWidget(QWidget): + """A minimal job settings widget for testing.""" + + def __init__(self, initial_settings=None, parent=None): + super().__init__(parent) + self.initial_settings = initial_settings + self.parameter_changed = MagicMock() + self.parameter_changed.connect = MagicMock() + + def update_settings(self, settings): + pass + + +@pytest.fixture +def mock_auth_status(): + """Mock DeadlineAuthenticationStatus to prevent real API calls.""" + mock_instance = MagicMock() + type(mock_instance).api_availability = PropertyMock(return_value=None) + type(mock_instance).creds_source = PropertyMock(return_value=None) + type(mock_instance).auth_status = PropertyMock(return_value=None) + mock_instance.config = ConfigParser() + mock_instance.api_availability_changed = MagicMock() + mock_instance.api_availability_changed.connect = MagicMock() + mock_instance.creds_source_changed = MagicMock() + mock_instance.creds_source_changed.connect = MagicMock() + mock_instance.auth_status_changed = MagicMock() + mock_instance.auth_status_changed.connect = MagicMock() + + import deadline.client.ui.deadline_authentication_status as auth_module + + auth_module._deadline_authentication_status = mock_instance + yield mock_instance + auth_module._deadline_authentication_status = None + + +def _create_dialog(qtbot, mock_auth_status, *, name, bundle_dir): + """Helper to create a SubmitJobToDeadlineDialog with a given bundle.""" + with patch( + "deadline.client.ui.widgets.deadline_authentication_status_widget" + ".DeadlineAuthenticationStatus.getInstance", + return_value=mock_auth_status, + ), patch( + "deadline.client.ui.dialogs.submit_job_to_deadline_dialog" + ".DeadlineAuthenticationStatus.getInstance", + return_value=mock_auth_status, + ): + settings = JobBundleSettings( + browse_enabled=True, + input_job_bundle_dir=bundle_dir, + name=name, + ) + dialog = SubmitJobToDeadlineDialog( + job_setup_widget_type=MockJobSettingsWidget, + initial_job_settings=settings, + initial_shared_parameter_values={}, + auto_detected_attachments=AssetReferences(), + attachments=AssetReferences(), + on_create_job_bundle_callback=MagicMock(), + ) + qtbot.addWidget(dialog) + dialog.show() + return dialog + + +@pytest.fixture +def submitter_dialog(qtbot, mock_auth_status): + """Create a SubmitJobToDeadlineDialog loaded with the simple_ui_with_ja bundle.""" + return _create_dialog( + qtbot, + mock_auth_status, + name="Simple UI with Job Attachments", + bundle_dir=SIMPLE_UI_WITH_JA, + ) + + +class TestGuiSubmitterBundles: + """ + pytest-qt equivalent of Squish tst_verify_gui_submitter_bundles. + """ + + def test_submitter_dialog_opens(self, submitter_dialog): + """Verify the submitter dialog opens with correct title.""" + assert submitter_dialog.isVisible() + assert submitter_dialog.windowTitle() == "Submit to AWS Deadline Cloud" + + def test_shared_job_settings_tab_exists(self, submitter_dialog): + """Verify the Shared job settings tab is present.""" + tabs = submitter_dialog.tabs + tab_names = [tabs.tabText(i) for i in range(tabs.count())] + assert "Shared job settings" in tab_names + + def test_job_specific_settings_tab_exists(self, submitter_dialog): + """Verify the Job-specific settings tab is present.""" + tabs = submitter_dialog.tabs + tab_names = [tabs.tabText(i) for i in range(tabs.count())] + assert "Job-specific settings" in tab_names + + def test_job_name_matches_bundle_with_ja(self, submitter_dialog): + """Verify the job name matches the simple_ui_with_ja bundle.""" + props = submitter_dialog.shared_job_settings.shared_job_properties_box + assert props.sub_name_edit.text() == "Simple UI with Job Attachments" + + def test_load_bundle_button_exists(self, submitter_dialog): + """Verify the 'Load Bundle' button exists when browse is enabled.""" + assert hasattr(submitter_dialog, "load_bundle_button") + assert submitter_dialog.load_bundle_button.text() == "Load Bundle" + assert submitter_dialog.load_bundle_button.isEnabled() + + def test_job_name_matches_bundle_no_ja(self, qtbot, mock_auth_status): + """Verify the job name matches the simple_ui_no_ja bundle.""" + dialog = _create_dialog( + qtbot, + mock_auth_status, + name="Simple UI - No Job Attachments", + bundle_dir=SIMPLE_UI_NO_JA, + ) + props = dialog.shared_job_settings.shared_job_properties_box + assert props.sub_name_edit.text() == "Simple UI - No Job Attachments" diff --git a/test/gui/test_settings_dialogue.py b/test/gui/test_settings_dialogue.py new file mode 100644 index 000000000..5c3021cb7 --- /dev/null +++ b/test/gui/test_settings_dialogue.py @@ -0,0 +1,347 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +""" +pytest-qt proof-of-concept: settings dialogue tests. + +This is a pytest-qt equivalent of the Squish tst_verify_settings_dialogue test, +demonstrating that we can replace Squish GUI tests with open-source tooling +that runs in our existing CI pipeline with no commercial license required. + +Verifies the Deadline Workstation Config dialog: + - Dialog opens and is visible + - AWS profile dropdown populated + - Farm/queue/storage profile dropdowns populated from MockDeadlineBackend + - Job attachments filesystem options and tooltips + - Checkboxes are checkable + - Conflict resolution and log level dropdowns work + - Job history directory widget is functional + - Ok/Cancel/Apply buttons exist + +Run with: + hatch run gui:test +""" + +import contextlib +import sys +from configparser import ConfigParser +from unittest.mock import MagicMock, patch + +import pytest + +try: + from deadline.client.ui.dialogs.deadline_config_dialog import ( + DeadlineConfigDialog, + DeadlineWorkstationConfigWidget, + ) + from deadline.client.ui.controllers._deadline_controller import DeadlineUIController + from deadline.client.ui.controllers._thread_pool import DeadlineThreadPool + from deadline.client import api + from qtpy.QtWidgets import QApplication as QApplication +except ImportError: + pytest.skip("GUI dependencies not available", allow_module_level=True) + + +@pytest.fixture(autouse=True) +def _reset_singletons(): + """Reset UI singletons before/after each test.""" + DeadlineUIController.resetInstance() + DeadlineThreadPool.reset() + yield + DeadlineUIController.resetInstance() + DeadlineThreadPool.shutdown(wait_for_done=True, timeout_ms=2000) + DeadlineThreadPool.reset() + + +@pytest.fixture +def mock_backend(mock_deadline_backend): + """Seed a MockDeadlineBackend with the resources the Squish tests expect.""" + backend = mock_deadline_backend + + farm = backend.create_farm( + displayName="Deadline Cloud Squish Farm", + description="Squish Automation Test Framework", + ) + farm_id = farm["farmId"] + + queue = backend.create_queue(farmId=farm_id, displayName="Squish Automation Queue") + queue_id = queue["queueId"] + + for name, os_family in [ + ("Linux Storage Profile", "LINUX"), + ("Windows Storage Profile", "WINDOWS"), + ("macOS Storage Profile", "MACOS"), + ]: + backend.create_storage_profile( + farmId=farm_id, queueId=queue_id, displayName=name, osFamily=os_family + ) + + return backend, farm_id, queue_id + + +@pytest.fixture +def deadline_config(mock_backend, tmp_path): + """Create a deadline config pointing at the mock backend's seeded resources.""" + _, farm_id, queue_id = mock_backend + config = ConfigParser() + config["defaults"] = { + "aws_profile_name": "(default)", + "farm_id": farm_id, + "queue_id": queue_id, + } + config["settings"] = { + "storage_profile_id": "", + "job_history_dir": str(tmp_path / "job_history"), + "auto_accept": "false", + "conflict_resolution": "NOT_SELECTED", + "log_level": "WARNING", + } + config["telemetry"] = {"opt_out": "false"} + return config + + +@pytest.fixture +def mock_api(mock_backend): + """Patch API functions and auth checks to use MockDeadlineBackend.""" + backend, _, _ = mock_backend + deadline_mock = MagicMock() + backend.set_mock_methods(deadline_mock) + + with contextlib.ExitStack() as stack: + stack.enter_context( + patch( + "deadline.client.api.list_farms", + side_effect=lambda **kw: deadline_mock.list_farms( + **{k: v for k, v in kw.items() if k != "config"} + ), + ) + ) + stack.enter_context( + patch( + "deadline.client.api.list_queues", + side_effect=lambda **kw: deadline_mock.list_queues( + **{k: v for k, v in kw.items() if k != "config"} + ), + ) + ) + stack.enter_context( + patch( + "deadline.client.api.list_storage_profiles_for_queue", + side_effect=lambda **kw: deadline_mock.list_storage_profiles_for_queue( + **{k: v for k, v in kw.items() if k != "config"} + ), + ) + ) + stack.enter_context( + patch( + "deadline.client.api._session.get_user_and_identity_store_id", + return_value=(None, None), + ) + ) + stack.enter_context(patch("deadline.client.api._session.get_boto3_session")) + stack.enter_context( + patch( + "deadline.client.api.check_authentication_status", + return_value=api.AwsAuthenticationStatus.AUTHENTICATED, + ) + ) + stack.enter_context( + patch("deadline.client.api.check_deadline_api_available", return_value=True) + ) + stack.enter_context( + patch( + "deadline.client.api.get_credentials_source", + return_value=api.AwsCredentialsSource.HOST_PROVIDED, + ) + ) + mock_cf = stack.enter_context( + patch("deadline.client.ui.deadline_authentication_status.config_file") + ) + mock_dialog_cf = stack.enter_context( + patch("deadline.client.ui.dialogs.deadline_config_dialog.config_file") + ) + mock_boto_session = stack.enter_context(patch("boto3.Session")) + mock_cf.read_config.return_value = {} + mock_cf.get_setting.return_value = "(default)" + mock_dialog_cf.read_config.return_value = {} + mock_dialog_cf.get_setting.side_effect = lambda name, config=None: { + "defaults.aws_profile_name": "(default)", + }.get(name, "") + mock_dialog_cf.set_setting = lambda name, value, config: None + + session_instance = MagicMock() + session_instance._session.full_config = {"profiles": {"default": {}, "test-profile": {}}} + mock_boto_session.return_value = session_instance + + yield deadline_mock + + +@pytest.fixture +def config_widget(qtbot, mock_api, deadline_config): + """Create the DeadlineWorkstationConfigWidget with mocked backend.""" + widget = DeadlineWorkstationConfigWidget() + qtbot.addWidget(widget) + widget.config = deadline_config + widget.show() + return widget + + +class TestSettingsDialogue: + """ + pytest-qt equivalent of Squish tst_verify_settings_dialogue. + + Each test method corresponds to a verification from the original Squish test. + """ + + def test_dialog_opens(self, qtbot, mock_api, deadline_config): + """Verify the config dialog can be created and is visible.""" + dialog = DeadlineConfigDialog() + qtbot.addWidget(dialog) + dialog.show() + + assert dialog.isVisible() + assert dialog.windowTitle() == "AWS Deadline Cloud workstation configuration" + + def test_aws_profile_dropdown_populated(self, config_widget): + """Verify AWS profile dropdown contains expected profiles.""" + combo = config_widget.aws_profiles_box + items = [combo.itemText(i) for i in range(combo.count())] + + assert "(default)" in items + + def test_auto_accept_checkbox_is_checkable(self, config_widget): + """Verify auto accept prompt defaults checkbox is checkable.""" + assert config_widget.auto_accept.isCheckable() + + def test_telemetry_opt_out_checkbox_is_checkable(self, config_widget): + """Verify telemetry opt out checkbox is checkable.""" + assert config_widget.telemetry_opt_out.isCheckable() + + def test_conflict_resolution_dropdown(self, qtbot, config_widget): + """Verify conflict resolution option can be set.""" + from qtpy.QtWidgets import QComboBox + + combos = config_widget.general_settings_group.findChildren(QComboBox) + combo = next(c for c in combos if c.findText("NOT_SELECTED") >= 0) + items = [combo.itemText(i) for i in range(combo.count())] + + assert "NOT_SELECTED" in items + assert "CREATE_COPY" in items + assert "OVERWRITE" in items + assert "SKIP" in items + + def test_log_level_dropdown(self, qtbot, config_widget): + """Verify logging level dropdown contains expected levels.""" + from qtpy.QtWidgets import QComboBox + + combos = config_widget.general_settings_group.findChildren(QComboBox) + combo = next(c for c in combos if c.findText("WARNING") >= 0) + items = [combo.itemText(i) for i in range(combo.count())] + + assert "WARNING" in items + assert "DEBUG" in items + assert "INFO" in items + assert "ERROR" in items + + def test_log_level_can_be_changed(self, qtbot, config_widget): + """Verify logging level can be changed to WARNING.""" + from qtpy.QtWidgets import QComboBox + + combos = config_widget.general_settings_group.findChildren(QComboBox) + combo = next(c for c in combos if c.findText("WARNING") >= 0) + combo.setCurrentText("WARNING") + + assert combo.currentText() == "WARNING" + + def test_job_attachments_filesystem_options(self, config_widget): + """Verify job attachments filesystem options dropdown has COPIED and VIRTUAL.""" + from qtpy.QtWidgets import QComboBox + + combos = config_widget.farm_settings_group.findChildren(QComboBox) + combo = next(c for c in combos if c.findText("COPIED") >= 0) + items = [combo.itemText(i) for i in range(combo.count())] + + assert "COPIED" in items + assert "VIRTUAL" in items + + def test_job_attachments_copied_tooltip(self, config_widget): + """Verify COPIED option has correct tooltip.""" + from qtpy.QtWidgets import QComboBox + + combos = config_widget.farm_settings_group.findChildren(QComboBox) + combo = next(c for c in combos if c.findText("COPIED") >= 0) + combo.setCurrentText("COPIED") + + assert combo.toolTip() == ( + "When selected, the worker downloads all job attachments to disk " + "before rendering begins." + ) + + def test_job_history_dir_editable(self, qtbot, config_widget, tmp_path): + """Verify job history directory widget exists and accepts input.""" + edit = config_widget.job_history_dir_edit + assert edit is not None + assert edit.directory_edit.isEnabled() + + def test_farm_dropdown_populated_from_backend(self, qtbot, config_widget): + """Verify farm dropdown gets populated when list is refreshed.""" + controller = DeadlineUIController.getInstance() + combo = config_widget.default_farm_box.box + + with qtbot.waitSignal(controller.farms_updated, timeout=5000): + controller.refresh_farms() + + QApplication.processEvents() + + items = [combo.itemText(i) for i in range(combo.count())] + assert "Deadline Cloud Squish Farm" in items + + def test_queue_dropdown_populated_from_backend(self, qtbot, config_widget, mock_backend): + """Verify queue dropdown gets populated for a given farm.""" + _, farm_id, _ = mock_backend + controller = DeadlineUIController.getInstance() + + with qtbot.waitSignal(controller.queues_updated, timeout=5000): + controller.refresh_queues(farm_id=farm_id) + + QApplication.processEvents() + + queue_combo = config_widget.default_queue_box.box + items = [queue_combo.itemText(i) for i in range(queue_combo.count())] + assert "Squish Automation Queue" in items + + def test_storage_profile_dropdown_populated_from_backend( + self, qtbot, config_widget, mock_backend + ): + """Verify storage profile dropdown gets populated for a given farm+queue.""" + _, farm_id, queue_id = mock_backend + controller = DeadlineUIController.getInstance() + + with qtbot.waitSignal(controller.storage_profiles_updated, timeout=5000): + controller.refresh_storage_profiles(farm_id=farm_id, queue_id=queue_id) + + QApplication.processEvents() + + sp_combo = config_widget.default_storage_profile_box.box + items = [sp_combo.itemText(i) for i in range(sp_combo.count())] + + if sys.platform.startswith("linux"): + assert "Linux Storage Profile" in items + elif sys.platform.startswith("darwin"): + assert "macOS Storage Profile" in items + elif sys.platform.startswith("win"): + assert "Windows Storage Profile" in items + + def test_ok_cancel_apply_buttons_exist(self, qtbot, mock_api, deadline_config): + """Verify the dialog has Ok, Cancel, and Apply buttons.""" + from qtpy.QtWidgets import QDialogButtonBox + + dialog = DeadlineConfigDialog() + qtbot.addWidget(dialog) + + ok_btn = dialog.button_box.button(QDialogButtonBox.StandardButton.Ok) # type: ignore[attr-defined] + cancel_btn = dialog.button_box.button(QDialogButtonBox.StandardButton.Cancel) # type: ignore[attr-defined] + apply_btn = dialog.button_box.button(QDialogButtonBox.StandardButton.Apply) # type: ignore[attr-defined] + + assert ok_btn is not None + assert cancel_btn is not None + assert apply_btn is not None diff --git a/test/unit/deadline_client/mock_deadline_backend.py b/test/unit/deadline_client/mock_deadline_backend.py index ced608cb4..b9547da49 100644 --- a/test/unit/deadline_client/mock_deadline_backend.py +++ b/test/unit/deadline_client/mock_deadline_backend.py @@ -83,6 +83,7 @@ def __init__(self, validate_params: bool = True): self.tasks: dict[tuple, dict] = {} self.sessions: dict[tuple, dict] = {} self.session_actions: dict[tuple, dict] = {} + self.storage_profiles: dict[tuple, dict] = {} # (farmId, queueId, storageProfileId) self._job_environments: dict[str, list[str]] = {} # job_id -> [env_name, ...] self._step_environments: dict[tuple, list[str]] = {} # (job_id, step_id) -> [env_name, ...] self._id_counter = 0 @@ -236,6 +237,16 @@ def get_farm(self, *, farmId: str) -> dict: raise _resource_not_found("farm", farmId, "GetFarm") return self.farms[farmId] + @route("GET", "/farms", "ListFarms") + def list_farms(self, *, maxResults: int = 100, nextToken: str | None = None, **kwargs) -> dict: + params: dict = {"maxResults": maxResults} + if nextToken is not None: + params["nextToken"] = nextToken + params.update(kwargs) + self._validate("ListFarms", params) + farms = [dict(f) for f in self.farms.values()] + return {"farms": farms} + # ========== Queue APIs ========== @route("POST", "/farms/{farmId}/queues", "CreateQueue") @@ -263,6 +274,18 @@ def get_queue(self, *, farmId: str, queueId: str) -> dict: raise _resource_not_found("queue", queueId, "GetQueue") return self.queues[key] + @route("GET", "/farms/{farmId}/queues", "ListQueues") + def list_queues( + self, *, farmId: str, maxResults: int = 100, nextToken: str | None = None, **kwargs + ) -> dict: + params: dict = {"farmId": farmId, "maxResults": maxResults} + if nextToken is not None: + params["nextToken"] = nextToken + params.update(kwargs) + self._validate("ListQueues", params) + queues = [dict(q) for k, q in self.queues.items() if k[0] == farmId] + return {"queues": queues} + @route("GET", "/farms/{farmId}/queues/{queueId}/environments", "ListQueueEnvironments") def list_queue_environments( self, *, farmId: str, queueId: str, nextToken: str | None = None, **kwargs @@ -276,6 +299,46 @@ def list_queue_environments( raise _resource_not_found("queue", queueId, "ListQueueEnvironments") return {"environments": []} + # ========== Storage Profile APIs ========== + + def create_storage_profile( + self, *, farmId: str, queueId: str, displayName: str, osFamily: str, **kwargs + ) -> dict: + """Test helper to seed a storage profile (no HTTP route needed).""" + sp_id = self._gen_id("sp") + self.storage_profiles[(farmId, queueId, sp_id)] = { + "storageProfileId": sp_id, + "displayName": displayName, + "osFamily": osFamily, + } + return {"storageProfileId": sp_id} + + @route( + "GET", + "/farms/{farmId}/queues/{queueId}/storage-profiles", + "ListStorageProfilesForQueue", + ) + def list_storage_profiles_for_queue( + self, + *, + farmId: str, + queueId: str, + maxResults: int = 100, + nextToken: str | None = None, + **kwargs, + ) -> dict: + params: dict = {"farmId": farmId, "queueId": queueId, "maxResults": maxResults} + if nextToken is not None: + params["nextToken"] = nextToken + params.update(kwargs) + self._validate("ListStorageProfilesForQueue", params) + profiles = [ + dict(sp) + for k, sp in self.storage_profiles.items() + if k[0] == farmId and k[1] == queueId + ] + return {"storageProfiles": profiles} + @route("GET", "/farms/{farmId}/queues/{queueId}/user-roles", "AssumeQueueRoleForUser") def assume_queue_role_for_user(self, *, farmId: str, queueId: str) -> dict: self._validate("AssumeQueueRoleForUser", {"farmId": farmId, "queueId": queueId}) @@ -759,8 +822,13 @@ def set_mock_methods(self, deadline_mock: MagicMock) -> None: """Wire this backend to a MagicMock for use with deadline_mock fixture.""" deadline_mock.create_farm.side_effect = self.create_farm deadline_mock.get_farm.side_effect = self.get_farm + deadline_mock.list_farms.side_effect = self.list_farms deadline_mock.create_queue.side_effect = self.create_queue deadline_mock.get_queue.side_effect = self.get_queue + deadline_mock.list_queues.side_effect = self.list_queues + deadline_mock.list_storage_profiles_for_queue.side_effect = ( + self.list_storage_profiles_for_queue + ) deadline_mock.create_job.side_effect = self.create_job deadline_mock.get_job.side_effect = self.get_job deadline_mock.get_step.side_effect = self.get_step