diff --git a/requirements-testing.txt b/requirements-testing.txt index c19271674..ee46b430d 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -17,6 +17,8 @@ ruff == 0.14.* moto == 5.* jsondiff == 2.* pyinstrument == 5.* +pytest-qt == 4.* +PySide6-essentials >= 6.6, < 6.11 # MCP (Model Context Protocol) library for MCP unit tests mcp >= 1.13.0; python_version >= '3.10' # OpenJD model library for job template parsing in mock backend diff --git a/src/deadline/client/ui/cli_job_submitter.py b/src/deadline/client/ui/cli_job_submitter.py index 0577d4fd6..b20168c01 100644 --- a/src/deadline/client/ui/cli_job_submitter.py +++ b/src/deadline/client/ui/cli_job_submitter.py @@ -45,7 +45,11 @@ def show_cli_job_submitter(parent: Optional[QWidget] = None, f=Qt.WindowFlags()) if parent is None: # Get the main application window so we can parent ours to it app = QApplication.instance() - parent = [widget for widget in app.topLevelWidgets() if isinstance(widget, QMainWindow)][0] # type: ignore[union-attr] + if app: + main_windows = [ + widget for widget in app.topLevelWidgets() if isinstance(widget, QMainWindow) + ] + parent = main_windows[0] if main_windows else None def on_create_job_bundle_callback( widget: SubmitJobToDeadlineDialog, diff --git a/src/deadline/client/ui/dialogs/deadline_config_dialog.py b/src/deadline/client/ui/dialogs/deadline_config_dialog.py index 33015f079..eb9a0e4c8 100644 --- a/src/deadline/client/ui/dialogs/deadline_config_dialog.py +++ b/src/deadline/client/ui/dialogs/deadline_config_dialog.py @@ -10,8 +10,7 @@ __all__ = ["DeadlineConfigDialog"] -import sys -import threading +import os from configparser import ConfigParser from logging import getLogger, root from typing import Callable, Dict, List, Optional @@ -42,12 +41,10 @@ QScrollArea, ) -import os - from ... import api from ..deadline_authentication_status import DeadlineAuthenticationStatus from ...config import config_file, get_setting_default, str2bool -from .._utils import CancelationFlag, block_signals, tr +from .._utils import block_signals, tr from ..widgets import DirectoryPickerWidget from ..widgets.deadline_authentication_status_widget import DeadlineAuthenticationStatusWidget from .deadline_login_dialog import DeadlineLoginDialog @@ -132,9 +129,6 @@ def _build_ui(self): ) self.layout.addWidget(self.auth_status_box) self.deadline_authentication_status.deadline_config_changed.connect(self.config_box.refresh) - self.deadline_authentication_status.api_availability_changed.connect( - self.on_auth_status_update - ) # We only use a Close button, not OK/Cancel, because we live update the settings. self.button_box = QDialogButtonBox( @@ -150,9 +144,6 @@ def _build_ui(self): self.auth_status_box.login_clicked.connect(self.on_login) self.layout.addWidget(self.button_box) - # Refresh the lists so queue/farm show the description instead of the ID - self.config_box.refresh_lists() - @property def changes_were_applied(self) -> bool: return self.config_box.changes_were_applied @@ -185,14 +176,6 @@ def on_refresh(self): # Update the auth status with the refreshed config self.deadline_authentication_status.set_config(self.config_box.config) - def on_auth_status_update(self): - # If the AWS Deadline Cloud API is authorized successfully for the AWS profile - # in the config dialog, refresh the farm/queue lists - if self.deadline_authentication_status.api_availability and config_file.get_setting( - "defaults.aws_profile_name", self.deadline_authentication_status.config - ) == config_file.get_setting("defaults.aws_profile_name", self.config_box.config): - self.config_box.refresh_lists() - class DeadlineScrollArea(QScrollArea): def __init__(self, parent: Optional[QWidget] = None): @@ -210,12 +193,6 @@ class DeadlineWorkstationConfigWidget(QWidget): # Signal for when the GUI is refreshed refreshed = Signal() - # Emitted when an async refresh_queues_list thread completes, - # provides (aws_profile_name, farm_id, [(queue_id, queue_name), ...]) - _queue_list_update = Signal(str, str, list) - # Emitted when an async refresh_storage_profiles_name_list thread completes, - # provides (aws_profile_name, farm_id, queue_id, [storage_profile_id, ...]) - _storage_profile_list_update = Signal(str, str, list) # This signal is sent when any background refresh thread catches an exception, # provides (operation_name, BaseException) _background_exception = Signal(str, BaseException) @@ -320,33 +297,9 @@ def _build_profile_settings_ui(self, group, layout): layout.addRow(job_history_dir_label, self.job_history_dir_edit) self.job_history_dir_edit.path_changed.connect(self.job_history_dir_changed) - self.default_farm_box = DeadlineFarmListComboBox(parent=group) - default_farm_box_label = self.labels["defaults.farm_id"] = QLabel(tr("Default farm")) - self.default_farm_box.box.currentIndexChanged.connect(self.default_farm_changed) - self.default_farm_box.background_exception.connect(self.handle_background_exception) - layout.addRow(default_farm_box_label, self.default_farm_box) - def _build_farm_settings_ui(self, group, layout): layout.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow) - self.default_queue_box = DeadlineQueueListComboBox(parent=group) - default_queue_box_label = self.labels["defaults.queue_id"] = QLabel(tr("Default queue")) - self.default_queue_box.box.currentIndexChanged.connect(self.default_queue_changed) - self.default_queue_box.background_exception.connect(self.handle_background_exception) - layout.addRow(default_queue_box_label, self.default_queue_box) - - self.default_storage_profile_box = DeadlineStorageProfileNameListComboBox(parent=group) - default_storage_profile_box_label = self.labels["settings.storage_profile_id"] = QLabel( - tr("Default storage profile") - ) - self.default_storage_profile_box.box.currentIndexChanged.connect( - self.default_storage_profile_name_changed - ) - self.default_storage_profile_box.background_exception.connect( - self.handle_background_exception - ) - layout.addRow(default_storage_profile_box_label, self.default_storage_profile_box) - item_name_copied = JobAttachmentsFileSystem.COPIED.value item_name_virtual = JobAttachmentsFileSystem.VIRTUAL.value job_attachments_file_system_tooltip = ( @@ -716,12 +669,6 @@ def _fill_aws_profiles_box(self): with block_signals(self.aws_profiles_box): self.aws_profiles_box.addItems(list(self.aws_profile_names)) - def refresh_lists(self): - if api.check_deadline_api_available(): - self.default_farm_box.refresh_list() - self.default_queue_box.refresh_list() - self.default_storage_profile_box.refresh_list() - def refresh(self): """ Refreshes all the configuration UI elements from the current config. @@ -731,9 +678,6 @@ def refresh(self): self.config.read_dict(config_file.read_config()) for setting_name, value in self.changes.items(): config_file.set_setting(setting_name, value, self.config) - self.default_farm_box.set_config(self.config) - self.default_queue_box.set_config(self.config) - self.default_storage_profile_box.set_config(self.config) with block_signals(self.aws_profiles_box): aws_profile_name = config_file.get_setting( @@ -757,14 +701,9 @@ def refresh(self): ) self.job_history_dir_edit.setText(job_history_dir) - self.default_farm_box.refresh_selected_id() - for refresh_callback in self._refresh_callbacks: refresh_callback() - self.default_queue_box.refresh_selected_id() - self.default_storage_profile_box.refresh_selected_id() - # Put an orange box around the labels for any settings that are changed for setting_name, label in self.labels.items(): if setting_name in self.changes: @@ -781,11 +720,6 @@ def apply(self) -> bool: Returns True if the settings were applied, False otherwise. """ - # We need to retrieve here as changing Queue's won't update. - self.changes["settings.storage_profile_id"] = ( - self.default_storage_profile_box.box.currentData() - ) - for setting_name, value in self.changes.items(): if value.startswith(NOT_VALID_MARKER): QMessageBox.warning( # type: ignore[call-arg] @@ -818,9 +752,6 @@ def apply(self) -> bool: def aws_profile_changed(self, value): self.changes["defaults.aws_profile_name"] = value - self.default_farm_box.clear_list() - self.default_queue_box.clear_list() - self.default_storage_profile_box.clear_list() self.refresh() def job_history_dir_changed(self): @@ -832,23 +763,6 @@ def job_history_dir_changed(self): self.changes["settings.job_history_dir"] = job_history_dir self.refresh() - def default_farm_changed(self, index): - self.changes["defaults.farm_id"] = self.default_farm_box.box.itemData(index) - self.refresh() - self.default_queue_box.refresh_list() - self.default_storage_profile_box.refresh_list() - - def default_queue_changed(self, index): - self.changes["defaults.queue_id"] = self.default_queue_box.box.itemData(index) - self.refresh() - self.default_storage_profile_box.refresh_list() - - def default_storage_profile_name_changed(self, index): - self.changes["settings.storage_profile_id"] = self.default_storage_profile_box.box.itemData( - index - ) - self.refresh() - def _on_add_known_path(self): """Handle adding a new known path""" path = QFileDialog.getExistingDirectory( @@ -920,224 +834,3 @@ def _on_edit_known_path(self): self.changes["settings.known_asset_paths"] = os.pathsep.join(current_paths) self.refresh() - - -class _DeadlineResourceListComboBox(QWidget): - """ - A ComboBox for selecting an AWS Deadline Cloud Id, with a refresh button. - - The caller should connect the `background_exception` signal, e.g. - to show a message box, and should call `set_config` whenever there is - a change to the AWS Deadline Cloud config object. - - Args: - resource_name (str): The resource name for the list, like "Farm", - "Queue", "Fleet". - """ - - # Emitted when the background refresh thread catches an exception, - # provides (operation_name, BaseException) - background_exception = Signal(str, BaseException) - - # Emitted when an async refresh_farms_list thread completes, - # provides (refresh_id, [(farm_id, farm_name), ...]) - _list_update = Signal(int, list) - - def __init__(self, resource_name, setting_name, parent: Optional[QWidget] = None): - super().__init__(parent) - - self.__refresh_thread = None - self.__refresh_id = 0 - self.canceled = CancelationFlag() - self.destroyed.connect(self.canceled.set_canceled) - - self.resource_name = resource_name - self.setting_name = setting_name - - self._build_ui() - - def _build_ui(self): - self.box = QComboBox(parent=self) - layout = QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self.box, stretch=1) - - self.refresh_button = QPushButton("") - layout.addWidget(self.refresh_button) - self.refresh_button.setIcon(QApplication.style().standardIcon(QStyle.SP_BrowserReload)) - self.refresh_button.setFixedSize(QSize(22, 22)) # Make the button square - self.refresh_button.clicked.connect(self.refresh_list) - self._list_update.connect(self.handle_list_update) - self.background_exception.connect(self.handle_background_exception) - - def handle_background_exception(self, e): - with block_signals(self.box): - self.box.clear() - self.refresh_selected_id() - - def count(self) -> int: - """Returns the number of items in the combobox""" - return self.box.count() - - def set_config(self, config: ConfigParser) -> None: - """Updates the AWS Deadline Cloud config object the control uses.""" - self.config = config - - def clear_list(self): - """ - Fully clears the list. The caller needs to call either - `refresh_list` or `refresh_selected_id` at a later point - to finish it. - """ - with block_signals(self.box): - self.box.clear() - - def refresh_list(self): - """ - Starts a background thread to refresh the resource list. - """ - config = self.config - selected_id = config_file.get_setting(self.setting_name, config=config) - # Reset to a list of just the currently configured id during refresh - with block_signals(self.box): - self.box.clear() - self.box.addItem("", userData=selected_id) - - self.__refresh_id += 1 - self.__refresh_thread = threading.Thread( - target=self._refresh_thread_function, - name=f"AWS Deadline Cloud refresh {self.resource_name} thread", - args=(self.__refresh_id, config), - ) - self.__refresh_thread.start() - - def handle_list_update(self, refresh_id, items_list): - # Apply the refresh if it's still for the latest call - if refresh_id == self.__refresh_id: - with block_signals(self.box): - self.box.clear() - for name, id in items_list: - self.box.addItem(name, userData=id) - - self.refresh_selected_id() - - def refresh_selected_id(self): - """Refreshes the selected id from the config object""" - selected_id = config_file.get_setting(self.setting_name, config=self.config) - # Restore the selected Id, inserting a new item if - # it doesn't exist in the list. - with block_signals(self.box): - index = self.box.findData(selected_id) - if index >= 0: - self.box.setCurrentIndex(index) - elif selected_id: - # User has a configured ID but it's not in the list. This happens when - # the user has permission to use a resource (e.g., queue) but lacks - # permission to list resources (e.g., ListFarms). Show the raw ID so - # they can still see their configured resource. - self.box.insertItem(0, selected_id, userData=selected_id) - self.box.setCurrentIndex(0) - else: - # No ID selected - index = self.box.findText("") - if index >= 0: - self.box.setCurrentIndex(index) - else: - self.box.insertItem(0, "", userData="") - self.box.setCurrentIndex(0) - - def _refresh_thread_function(self, refresh_id: int, config: Optional[ConfigParser] = None): - """ - This function gets started in a background thread to refresh the list. - """ - try: - resources = self.list_resources(config=config) - if not self.canceled: - self._list_update.emit(refresh_id, resources) - except BaseException as e: - if not self.canceled and refresh_id == self.__refresh_id: - self.background_exception.emit(f"Refresh {self.resource_name}s list", e) - - -class DeadlineFarmListComboBox(_DeadlineResourceListComboBox): - def __init__(self, parent: Optional[QWidget] = None): - super().__init__(resource_name="Farm", setting_name="defaults.farm_id", parent=parent) - - def list_resources(self, config: Optional[ConfigParser]): - response = api.list_farms(config=config) - return sorted( - [(item["displayName"], item["farmId"]) for item in response["farms"]], - key=lambda item: (item[0].casefold(), item[1]), - ) - - -class DeadlineQueueListComboBox(_DeadlineResourceListComboBox): - def __init__(self, parent: Optional[QWidget] = None): - super().__init__(resource_name="Queue", setting_name="defaults.queue_id", parent=parent) - - def list_resources(self, config: Optional[ConfigParser]): - default_farm_id = config_file.get_setting("defaults.farm_id", config=config) - if default_farm_id: - response = api.list_queues(config=config, farmId=default_farm_id) - return sorted( - [(item["displayName"], item["queueId"]) for item in response["queues"]], - key=lambda item: (item[0].casefold(), item[1]), - ) - else: - return [] - - -class DeadlineStorageProfileNameListComboBox(_DeadlineResourceListComboBox): - WINDOWS_OS = "windows" - MAC_OS = "macos" - LINUX_OS = "linux" - - def __init__(self, parent: Optional[QWidget] = None): - super().__init__( - resource_name="Storage profile", - setting_name="settings.storage_profile_id", - parent=parent, - ) - - def list_resources(self, config: Optional[ConfigParser]): - default_farm_id = config_file.get_setting("defaults.farm_id", config=config) - default_queue_id = config_file.get_setting("defaults.queue_id", config=config) - if default_farm_id and default_queue_id: - response = api.list_storage_profiles_for_queue( - config=config, farmId=default_farm_id, queueId=default_queue_id - ) - storage_profiles = response.get("storageProfiles", []) - # add a "" option since its possible to select nothing for this type - # of resource - storage_profiles.append( - { - "storageProfileId": "", - "displayName": "", - "osFamily": self._get_current_os(), - } - ) - return sorted( - [ - (item["displayName"], item["storageProfileId"]) - for item in storage_profiles - if self._get_current_os() == item["osFamily"].lower() - ], - key=lambda item: (item[0].casefold(), item[1]), - ) - else: - return [] - - def _get_current_os(self) -> str: - """ - Get a string specifying what the OS is, following the format the Deadline storage profile API expects. - """ - if sys.platform.startswith("linux"): - return self.LINUX_OS - - if sys.platform.startswith("darwin"): - return self.MAC_OS - - if sys.platform.startswith("win"): - return self.WINDOWS_OS - - return "Unknown" diff --git a/src/deadline/client/ui/dialogs/deadline_login_dialog.py b/src/deadline/client/ui/dialogs/deadline_login_dialog.py index c46ed4d3f..08c7786d7 100644 --- a/src/deadline/client/ui/dialogs/deadline_login_dialog.py +++ b/src/deadline/client/ui/dialogs/deadline_login_dialog.py @@ -180,8 +180,10 @@ def on_button_clicked(self, button): # Tell the login thread to cancel, then wait for it. self.canceled = True if self.__login_thread: + app = QApplication.instance() while self.__login_thread.is_alive(): - QApplication.instance().processEvents() # type: ignore[union-attr] + if app: + app.processEvents() def exec_(self) -> bool: """ diff --git a/src/deadline/client/ui/dialogs/submit_job_progress_dialog.py b/src/deadline/client/ui/dialogs/submit_job_progress_dialog.py index 14d9ac1f0..96ce73107 100644 --- a/src/deadline/client/ui/dialogs/submit_job_progress_dialog.py +++ b/src/deadline/client/ui/dialogs/submit_job_progress_dialog.py @@ -127,7 +127,7 @@ class SubmitJobProgressDialog(QDialog): # This signal is sent when the background thread succeeds. submission_thread_succeeded = Signal(str) - progress_window_closed = Signal(None) + progress_window_closed = Signal() job_id: Optional[str] = None @@ -325,8 +325,10 @@ def closeEvent(self, event: QCloseEvent) -> None: logger.info("Canceling submission...") self.status_label.setText(tr("Canceling submission...")) if self.__submission_thread is not None: + app = QApplication.instance() while self.__submission_thread.is_alive(): - QApplication.instance().processEvents() # type: ignore[union-attr] + if app: + app.processEvents() super().closeEvent(event) def exec_(self) -> Optional[str]: # type: ignore[override] diff --git a/src/deadline/client/ui/dialogs/submit_job_to_deadline_dialog.py b/src/deadline/client/ui/dialogs/submit_job_to_deadline_dialog.py index dd566b3d9..29eb19eae 100644 --- a/src/deadline/client/ui/dialogs/submit_job_to_deadline_dialog.py +++ b/src/deadline/client/ui/dialogs/submit_job_to_deadline_dialog.py @@ -113,7 +113,7 @@ def __init__( attachments: AssetReferences, on_create_job_bundle_callback: OnCreateJobBundleCallback, parent: Optional[QWidget] = None, - f: Qt.WindowFlags = Qt.WindowFlags(), + f=Qt.WindowFlags(), show_host_requirements_tab: bool = False, host_requirements: Optional[HostRequirements] = None, submitter_info: Optional[SubmitterInfo] = None, @@ -225,6 +225,13 @@ def _build_ui( self.deadline_authentication_status.api_availability_changed.connect( self.refresh_deadline_settings ) + # Note: we intentionally do NOT connect deadline_config_changed here. + # Farm/queue/storage profile changes in DeadlineCloudSettingsWidget call + # set_setting() which writes to disk, triggering the QFileSystemWatcher. + # If we connected deadline_config_changed → refresh_deadline_settings, + # every selection change would trigger the full refresh cascade twice + # (once synchronously via _notify_parent_refresh, once asynchronously + # via the file watcher). The synchronous path is sufficient. # Refresh the submit button enable state once queue parameter status changes self.shared_job_settings.valid_parameters.connect(self._set_submit_button_state) @@ -554,7 +561,9 @@ def on_submit(self): job_progress_dialog.progress_window_closed.connect(self._close_event_receiver) job_progress_dialog.setModal(True) job_progress_dialog.show() - QApplication.instance().processEvents() # type: ignore[union-attr] + app = QApplication.instance() + if app: + app.processEvents() # Submit the job try: diff --git a/src/deadline/client/ui/job_bundle_submitter.py b/src/deadline/client/ui/job_bundle_submitter.py index b2cdc60f2..4834b9a1d 100644 --- a/src/deadline/client/ui/job_bundle_submitter.py +++ b/src/deadline/client/ui/job_bundle_submitter.py @@ -152,8 +152,8 @@ def show_job_bundle_submitter( app = QApplication.instance() main_windows = [ widget - for widget in app.topLevelWidgets() - if isinstance(widget, QMainWindow) # type: ignore[union-attr] + for widget in (app.topLevelWidgets() if app else []) + if isinstance(widget, QMainWindow) ] if main_windows: parent = main_windows[0] diff --git a/src/deadline/client/ui/translations/locales/de_DE.json b/src/deadline/client/ui/translations/locales/de_DE.json index 3acc0c960..70bc9ce67 100644 --- a/src/deadline/client/ui/translations/locales/de_DE.json +++ b/src/deadline/client/ui/translations/locales/de_DE.json @@ -36,6 +36,7 @@ "Default farm": "Standard-Farm", "Default queue": "Standard-Warteschlange", "Default storage profile": "Standard-Speicherprofil", + "Storage profile": "Speicherprofil", "Delete": "Löschen", "Description": "Beschreibung", "Do not ask again": "Nicht erneut fragen", diff --git a/src/deadline/client/ui/translations/locales/en_US.json b/src/deadline/client/ui/translations/locales/en_US.json index 97bed802e..f000e31b3 100644 --- a/src/deadline/client/ui/translations/locales/en_US.json +++ b/src/deadline/client/ui/translations/locales/en_US.json @@ -36,6 +36,7 @@ "Default farm": "Default farm", "Default queue": "Default queue", "Default storage profile": "Default storage profile", + "Storage profile": "Storage profile", "Delete": "Delete", "Description": "Description", "Do not ask again": "Do not ask again", diff --git a/src/deadline/client/ui/translations/locales/es_ES.json b/src/deadline/client/ui/translations/locales/es_ES.json index ec7aefb04..c5e05a665 100644 --- a/src/deadline/client/ui/translations/locales/es_ES.json +++ b/src/deadline/client/ui/translations/locales/es_ES.json @@ -36,6 +36,7 @@ "Default farm": "Granja predeterminada", "Default queue": "Cola predeterminada", "Default storage profile": "Perfil de almacenamiento predeterminado", + "Storage profile": "Perfil de almacenamiento", "Delete": "Eliminar", "Description": "Descripción", "Do not ask again": "No volver a preguntar", diff --git a/src/deadline/client/ui/translations/locales/fr_FR.json b/src/deadline/client/ui/translations/locales/fr_FR.json index e8edb1cfb..01931f97a 100644 --- a/src/deadline/client/ui/translations/locales/fr_FR.json +++ b/src/deadline/client/ui/translations/locales/fr_FR.json @@ -36,6 +36,7 @@ "Default farm": "Ferme par défaut", "Default queue": "File d'attente par défaut", "Default storage profile": "Profil de stockage par défaut", + "Storage profile": "Profil de stockage", "Delete": "Supprimer", "Description": "Description", "Do not ask again": "Ne plus demander", diff --git a/src/deadline/client/ui/translations/locales/id_ID.json b/src/deadline/client/ui/translations/locales/id_ID.json index 4b3c1e41a..1407338d5 100644 --- a/src/deadline/client/ui/translations/locales/id_ID.json +++ b/src/deadline/client/ui/translations/locales/id_ID.json @@ -36,6 +36,7 @@ "Default farm": "Peternakan default", "Default queue": "Antrian default", "Default storage profile": "Profil penyimpanan default", + "Storage profile": "Profil penyimpanan", "Delete": "Hapus", "Description": "Deskripsi", "Do not ask again": "Jangan tanya lagi", diff --git a/src/deadline/client/ui/translations/locales/it_IT.json b/src/deadline/client/ui/translations/locales/it_IT.json index efed438f1..8178f87bb 100644 --- a/src/deadline/client/ui/translations/locales/it_IT.json +++ b/src/deadline/client/ui/translations/locales/it_IT.json @@ -36,6 +36,7 @@ "Default farm": "Farm predefinita", "Default queue": "Coda predefinita", "Default storage profile": "Profilo di archiviazione predefinito", + "Storage profile": "Profilo di archiviazione", "Delete": "Elimina", "Description": "Descrizione", "Do not ask again": "Non chiedere più", diff --git a/src/deadline/client/ui/translations/locales/ja_JP.json b/src/deadline/client/ui/translations/locales/ja_JP.json index b018acf58..27e66a9c1 100644 --- a/src/deadline/client/ui/translations/locales/ja_JP.json +++ b/src/deadline/client/ui/translations/locales/ja_JP.json @@ -36,6 +36,7 @@ "Default farm": "デフォルトファーム", "Default queue": "デフォルトキュー", "Default storage profile": "デフォルトストレージプロファイル", + "Storage profile": "ストレージプロファイル", "Delete": "削除", "Description": "説明", "Do not ask again": "今後表示しない", diff --git a/src/deadline/client/ui/translations/locales/ko_KR.json b/src/deadline/client/ui/translations/locales/ko_KR.json index 95844d2d6..cfaca2b44 100644 --- a/src/deadline/client/ui/translations/locales/ko_KR.json +++ b/src/deadline/client/ui/translations/locales/ko_KR.json @@ -36,6 +36,7 @@ "Default farm": "기본 팜", "Default queue": "기본 대기열", "Default storage profile": "기본 스토리지 프로필", + "Storage profile": "스토리지 프로필", "Delete": "삭제", "Description": "설명", "Do not ask again": "다시 묻지 않음", diff --git a/src/deadline/client/ui/translations/locales/pt_BR.json b/src/deadline/client/ui/translations/locales/pt_BR.json index 5d8637de4..c6066880a 100644 --- a/src/deadline/client/ui/translations/locales/pt_BR.json +++ b/src/deadline/client/ui/translations/locales/pt_BR.json @@ -36,6 +36,7 @@ "Default farm": "Fazenda padrão", "Default queue": "Fila padrão", "Default storage profile": "Perfil de armazenamento padrão", + "Storage profile": "Perfil de armazenamento", "Delete": "Excluir", "Description": "Descrição", "Do not ask again": "Não perguntar novamente", diff --git a/src/deadline/client/ui/translations/locales/tr_TR.json b/src/deadline/client/ui/translations/locales/tr_TR.json index 7c981364e..cf8c1abd2 100644 --- a/src/deadline/client/ui/translations/locales/tr_TR.json +++ b/src/deadline/client/ui/translations/locales/tr_TR.json @@ -36,6 +36,7 @@ "Default farm": "Varsayılan farm", "Default queue": "Varsayılan kuyruk", "Default storage profile": "Varsayılan depolama profili", + "Storage profile": "Depolama profili", "Delete": "Sil", "Description": "Açıklama", "Do not ask again": "Bir daha sorma", diff --git a/src/deadline/client/ui/translations/locales/zh_CN.json b/src/deadline/client/ui/translations/locales/zh_CN.json index b65800c6e..d0b8c805a 100644 --- a/src/deadline/client/ui/translations/locales/zh_CN.json +++ b/src/deadline/client/ui/translations/locales/zh_CN.json @@ -36,6 +36,7 @@ "Default farm": "默认服务器农场", "Default queue": "默认队列", "Default storage profile": "默认存储配置文件", + "Storage profile": "存储配置文件", "Delete": "删除", "Description": "描述", "Do not ask again": "不再询问", diff --git a/src/deadline/client/ui/translations/locales/zh_TW.json b/src/deadline/client/ui/translations/locales/zh_TW.json index bfac49f9c..84a1155ef 100644 --- a/src/deadline/client/ui/translations/locales/zh_TW.json +++ b/src/deadline/client/ui/translations/locales/zh_TW.json @@ -36,6 +36,7 @@ "Default farm": "預設伺服器陣列", "Default queue": "預設佇列", "Default storage profile": "預設儲存設定檔", + "Storage profile": "儲存設定檔", "Delete": "刪除", "Description": "描述", "Do not ask again": "不要再詢問", diff --git a/src/deadline/client/ui/widgets/__init__.py b/src/deadline/client/ui/widgets/__init__.py index d32d6f432..ae8b06358 100644 --- a/src/deadline/client/ui/widgets/__init__.py +++ b/src/deadline/client/ui/widgets/__init__.py @@ -38,7 +38,7 @@ from .openjd_parameters_widget import OpenJDParametersWidget from .path_widgets import DirectoryPickerWidget, InputFilePickerWidget, OutputFilePickerWidget from .shared_job_settings_tab import ( - DeadlineCloudSettingsWidget, SharedJobSettingsWidget, SharedJobPropertiesWidget, + DeadlineCloudSettingsWidget, ) diff --git a/src/deadline/client/ui/widgets/deadline_authentication_status_widget.py b/src/deadline/client/ui/widgets/deadline_authentication_status_widget.py index 613a3deb8..27e4f7c98 100644 --- a/src/deadline/client/ui/widgets/deadline_authentication_status_widget.py +++ b/src/deadline/client/ui/widgets/deadline_authentication_status_widget.py @@ -44,8 +44,9 @@ def showEvent(self, event): """ Override showEvent to position the menu to the right of the parent button. """ - if self.parent(): - parent_top_right = self.parent().mapToGlobal(self.parent().rect().topRight()) + parent_widget = self.parent() + if isinstance(parent_widget, QWidget): + parent_top_right = parent_widget.mapToGlobal(parent_widget.rect().topRight()) self.move(parent_top_right.x(), parent_top_right.y()) super().showEvent(event) @@ -403,4 +404,4 @@ def _apply_ui_state(self, config: AuthenticationStateConfig) -> None: if any(action.isVisible() for action in self._auth_menu.actions()): self._profile_button.setMenu(self._auth_menu) else: - self._profile_button.setMenu(None) + self._profile_button.setMenu(None) # type: ignore[arg-type] diff --git a/src/deadline/client/ui/widgets/deadline_cloud_resource_combo_boxes.py b/src/deadline/client/ui/widgets/deadline_cloud_resource_combo_boxes.py new file mode 100644 index 000000000..d31f5da55 --- /dev/null +++ b/src/deadline/client/ui/widgets/deadline_cloud_resource_combo_boxes.py @@ -0,0 +1,250 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +""" +Combo box widgets for selecting AWS Deadline Cloud resources (farms, queues, storage profiles). +These are used by both the Settings Dialog and the Submit Dialog. +""" + +__all__ = [ + "DeadlineFarmListComboBox", + "DeadlineQueueListComboBox", + "DeadlineStorageProfileNameListComboBox", +] + +import sys +import threading +from configparser import ConfigParser +from typing import Optional + +from qtpy.QtCore import QSize, Signal +from qtpy.QtWidgets import ( # type: ignore + QApplication, + QComboBox, + QHBoxLayout, + QPushButton, + QStyle, + QWidget, +) + +from ... import api +from ...config import config_file +from .._utils import CancelationFlag, block_signals + + +class _DeadlineResourceListComboBox(QWidget): + """ + A ComboBox for selecting an AWS Deadline Cloud Id, with a refresh button. + + The caller should connect the `background_exception` signal, e.g. + to show a message box, and should call `set_config` whenever there is + a change to the AWS Deadline Cloud config object. + + Args: + resource_name (str): The resource name for the list, like "Farm", + "Queue", "Fleet". + """ + + # Emitted when the background refresh thread catches an exception, + # provides (operation_name, BaseException) + background_exception = Signal(str, BaseException) + + # Emitted when an async refresh list thread completes, + # provides (refresh_id, [(resource_id, resource_name), ...]) + list_update = Signal(int, list) + + def __init__(self, resource_name, setting_name, parent: Optional[QWidget] = None): + super().__init__(parent) + + self.__refresh_thread = None + self.__refresh_id = 0 + self.canceled = CancelationFlag() + self.destroyed.connect(self.canceled.set_canceled) + + self.resource_name = resource_name + self.setting_name = setting_name + + self._build_ui() + + def _build_ui(self): + self.box = QComboBox(parent=self) + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.box, stretch=1) + + self.refresh_button = QPushButton("") + layout.addWidget(self.refresh_button) + self.refresh_button.setIcon(QApplication.style().standardIcon(QStyle.SP_BrowserReload)) + self.refresh_button.setFixedSize(QSize(22, 22)) # Make the button square + self.refresh_button.clicked.connect(self.refresh_list) + self.list_update.connect(self.handle_list_update) + self.background_exception.connect(self.handle_background_exception) + + def handle_background_exception(self, e): + with block_signals(self.box): + self.box.clear() + self.refresh_selected_id() + + def count(self) -> int: + """Returns the number of items in the combobox""" + return self.box.count() + + def set_config(self, config: ConfigParser) -> None: + """Updates the AWS Deadline Cloud config object the control uses.""" + self.config = config + + def clear_list(self): + """ + Fully clears the list. The caller needs to call either + `refresh_list` or `refresh_selected_id` at a later point + to finish it. + """ + with block_signals(self.box): + self.box.clear() + + def refresh_list(self): + """ + Starts a background thread to refresh the resource list. + """ + config = self.config + selected_id = config_file.get_setting(self.setting_name, config=config) + # Reset to a list of just the currently configured id during refresh + with block_signals(self.box): + self.box.clear() + self.box.addItem("", userData=selected_id) + + self.__refresh_id += 1 + self.__refresh_thread = threading.Thread( + target=self._refresh_thread_function, + name=f"AWS Deadline Cloud refresh {self.resource_name} thread", + args=(self.__refresh_id, config), + ) + self.__refresh_thread.start() + + def handle_list_update(self, refresh_id, items_list): + # Apply the refresh if it's still for the latest call + if refresh_id == self.__refresh_id: + with block_signals(self.box): + self.box.clear() + for name, id in items_list: + self.box.addItem(name, userData=id) + + self.refresh_selected_id() + + def refresh_selected_id(self): + """Refreshes the selected id from the config object""" + selected_id = config_file.get_setting(self.setting_name, config=self.config) + # Restore the selected Id, inserting a new item if + # it doesn't exist in the list. + with block_signals(self.box): + index = self.box.findData(selected_id) + if index >= 0: + self.box.setCurrentIndex(index) + elif selected_id: + # User has a configured ID but it's not in the list (e.g., lacks + # permission to list resources). Show the raw ID so they can see + # what's configured. + self.box.insertItem(0, selected_id, userData=selected_id) + self.box.setCurrentIndex(0) + else: + # No ID configured - show "" + index = self.box.findText("") + if index >= 0: + self.box.setCurrentIndex(index) + else: + self.box.insertItem(0, "", userData="") + self.box.setCurrentIndex(0) + + def _refresh_thread_function(self, refresh_id: int, config: Optional[ConfigParser] = None): + """ + This function gets started in a background thread to refresh the list. + """ + try: + resources = self.list_resources(config=config) + if not self.canceled: + self.list_update.emit(refresh_id, resources) + except BaseException as e: + if not self.canceled and refresh_id == self.__refresh_id: + self.background_exception.emit(f"Refresh {self.resource_name}s list", e) + + +class DeadlineFarmListComboBox(_DeadlineResourceListComboBox): + def __init__(self, parent: Optional[QWidget] = None): + super().__init__(resource_name="Farm", setting_name="defaults.farm_id", parent=parent) + + def list_resources(self, config: Optional[ConfigParser]): + response = api.list_farms(config=config) + return sorted( + [(item["displayName"], item["farmId"]) for item in response["farms"]], + key=lambda item: (item[0].casefold(), item[1]), + ) + + +class DeadlineQueueListComboBox(_DeadlineResourceListComboBox): + def __init__(self, parent: Optional[QWidget] = None): + super().__init__(resource_name="Queue", setting_name="defaults.queue_id", parent=parent) + + def list_resources(self, config: Optional[ConfigParser]): + default_farm_id = config_file.get_setting("defaults.farm_id", config=config) + if default_farm_id: + response = api.list_queues(config=config, farmId=default_farm_id) + return sorted( + [(item["displayName"], item["queueId"]) for item in response["queues"]], + key=lambda item: (item[0].casefold(), item[1]), + ) + else: + return [] + + +class DeadlineStorageProfileNameListComboBox(_DeadlineResourceListComboBox): + WINDOWS_OS = "windows" + MAC_OS = "macos" + LINUX_OS = "linux" + + def __init__(self, parent: Optional[QWidget] = None): + super().__init__( + resource_name="Storage profile", + setting_name="settings.storage_profile_id", + parent=parent, + ) + + def list_resources(self, config: Optional[ConfigParser]): + default_farm_id = config_file.get_setting("defaults.farm_id", config=config) + default_queue_id = config_file.get_setting("defaults.queue_id", config=config) + if default_farm_id and default_queue_id: + response = api.list_storage_profiles_for_queue( + config=config, farmId=default_farm_id, queueId=default_queue_id + ) + storage_profiles = response.get("storageProfiles", []) + # add a "" option since its possible to select nothing for this type + # of resource + storage_profiles.append( + { + "storageProfileId": "", + "displayName": "", + "osFamily": self._get_current_os(), + } + ) + return sorted( + [ + (item["displayName"], item["storageProfileId"]) + for item in storage_profiles + if self._get_current_os() == item["osFamily"].lower() + ], + key=lambda item: (item[0].casefold(), item[1]), + ) + else: + return [] + + def _get_current_os(self) -> str: + """ + Get a string specifying what the OS is, following the format the Deadline storage profile API expects. + """ + if sys.platform.startswith("linux"): + return self.LINUX_OS + + if sys.platform.startswith("darwin"): + return self.MAC_OS + + if sys.platform.startswith("win"): + return self.WINDOWS_OS + + return "Unknown" diff --git a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py index 93cba618e..6192c736e 100644 --- a/src/deadline/client/ui/widgets/job_bundle_settings_tab.py +++ b/src/deadline/client/ui/widgets/job_bundle_settings_tab.py @@ -46,7 +46,7 @@ class JobBundleSettingsWidget(QWidget): def __init__(self, initial_settings: JobBundleSettings, parent: Optional[QWidget] = None): super().__init__(parent=parent) - self.parent = parent + self._parent_widget = parent self.param_layout = QVBoxLayout() @@ -64,7 +64,10 @@ def refresh_ui(self, settings: JobBundleSettings): # Clear the layout for i in reversed(range(self.param_layout.count())): item = self.param_layout.takeAt(i) - item.widget().deleteLater() + if item is not None: + widget = item.widget() + if widget is not None: + widget.deleteLater() self.parameters_widget = OpenJDParametersWidget( parameter_definitions=settings.parameters, parent=self @@ -114,8 +117,8 @@ def on_load_bundle(self): logger.warning(msg) return - if self.parent and hasattr(self.parent, "refresh"): - self.parent.refresh( + if self._parent_widget is not None and hasattr(self._parent_widget, "refresh"): + self._parent_widget.refresh( job_settings=job_settings, auto_detected_attachments=asset_references, attachments=None, diff --git a/src/deadline/client/ui/widgets/openjd_parameters_widget.py b/src/deadline/client/ui/widgets/openjd_parameters_widget.py index 33112f2a7..bd3e81c39 100644 --- a/src/deadline/client/ui/widgets/openjd_parameters_widget.py +++ b/src/deadline/client/ui/widgets/openjd_parameters_widget.py @@ -90,10 +90,15 @@ def rebuild_ui( if isinstance(layout, QVBoxLayout): for index in reversed(range(layout.count())): child = layout.takeAt(index) - if child.widget(): - child.widget().deleteLater() - elif child.layout(): - child.layout().deleteLater() + if child is None: + continue + widget = child.widget() + if widget is not None: + widget.deleteLater() + else: + child_layout = child.layout() + if child_layout is not None: + child_layout.deleteLater() else: layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -160,7 +165,9 @@ def rebuild_ui( group_layout = _JobTemplateGroupLayout(self, group_label) group_layout.setObjectName(group_label) layout.addWidget(group_layout) - group_layout.layout().addWidget(control) # type: ignore[union-attr] + group_inner_layout = group_layout.layout() + if group_inner_layout: + group_inner_layout.addWidget(control) else: layout.addWidget(control) diff --git a/src/deadline/client/ui/widgets/shared_job_settings_tab.py b/src/deadline/client/ui/widgets/shared_job_settings_tab.py index f43365823..d56679584 100644 --- a/src/deadline/client/ui/widgets/shared_job_settings_tab.py +++ b/src/deadline/client/ui/widgets/shared_job_settings_tab.py @@ -6,29 +6,32 @@ from __future__ import annotations -import sys import threading -from typing import Any, Dict, Optional +from typing import Any, Optional from qtpy.QtCore import Signal # type: ignore from qtpy.QtWidgets import ( # type: ignore QComboBox, QFormLayout, QGroupBox, - QHBoxLayout, QLabel, QLineEdit, + QMessageBox, QRadioButton, QSpinBox, QVBoxLayout, QWidget, ) -from ... import api -from ...config import get_setting -from .._utils import CancelationFlag, tr +from ...config import get_setting, set_setting, config_file +from .._utils import CancelationFlag, block_signals, tr from .openjd_parameters_widget import OpenJDParametersWidget from ...api import get_queue_parameter_definitions +from .deadline_cloud_resource_combo_boxes import ( + DeadlineFarmListComboBox, + DeadlineQueueListComboBox, + DeadlineStorageProfileNameListComboBox, +) class SharedJobSettingsWidget(QWidget): # pylint: disable=too-few-public-methods @@ -131,24 +134,25 @@ def refresh_ui(self, job_settings: Any, load_new_bundle: bool = False): def refresh_queue_parameters(self, load_new_bundle: bool = False): """ - If the default queue id or job bundle has changed, refresh the queue parameters. + If the default farm id, queue id, or job bundle has changed, refresh the queue parameters. """ farm_id = get_setting("defaults.farm_id") queue_id = get_setting("defaults.queue_id") if not farm_id or not queue_id: self.queue_parameters_box.rebuild_ui(async_loading_state="") return # If the user has not selected a farm or queue ID, don't try to load + farm_or_queue_changed = farm_id != self.farm_id or queue_id != self.queue_id if ( self.queue_parameters_box.async_loading_state - or queue_id != self.queue_id + or farm_or_queue_changed or load_new_bundle ): self.queue_parameters_box.rebuild_ui( async_loading_state="Reloading Queue Environments..." ) - # Join the thread if the queue id or job bundle has changed and the thread is running + # Join the thread if the farm, queue id, or job bundle has changed and the thread is running if ( - (queue_id != self.queue_id or load_new_bundle) + (farm_or_queue_changed or load_new_bundle) and self.__refresh_queue_parameters_thread and self.__refresh_queue_parameters_thread.is_alive() ): @@ -473,228 +477,176 @@ def update_settings(self, settings): class DeadlineCloudSettingsWidget(QGroupBox): """ - UI component for the Deadline Cloud settings. + UI component for the Deadline Cloud settings (farm, queue, storage profile). + Used in the Submit Dialog's "Shared job settings" tab. """ def __init__(self, *, parent: Optional[QWidget] = None): super().__init__(tr("Deadline Cloud settings"), parent=parent) - self.deadline_settings: Dict[str, Any] = {"counter": -1} self.layout = QFormLayout(self) self.layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow) self._build_ui() - def _set_enabled_with_label(self, prop_name: str, enabled: bool): - """Sets the enabled status of a control and its label""" - getattr(self, prop_name).setEnabled(enabled) - getattr(self, prop_name + "_label").setEnabled(enabled) - def _build_ui(self): """ Build the UI for the Deadline settings """ self.farm_box_label = QLabel(tr("Farm")) - self.farm_box = DeadlineFarmDisplay() + self.farm_box = DeadlineFarmListComboBox(parent=self) self.layout.addRow(self.farm_box_label, self.farm_box) self.queue_box_label = QLabel(tr("Queue")) - self.queue_box = DeadlineQueueDisplay() + self.queue_box = DeadlineQueueListComboBox(parent=self) self.layout.addRow(self.queue_box_label, self.queue_box) - def refresh_setting_controls(self, deadline_authorized): - """ - Refreshes the controls for UI items that depend on the AWS Deadline Cloud API - for their values. + self.storage_profile_box_label = QLabel(tr("Storage profile")) + self.storage_profile_box = DeadlineStorageProfileNameListComboBox(parent=self) + self.layout.addRow(self.storage_profile_box_label, self.storage_profile_box) - Args: - deadline_authorized (bool): Should be the result of a call to - api.check_deadline_available, for example from - an AWS Deadline Cloud Status Widget. - """ - self.farm_box.refresh(deadline_authorized) - self.queue_box.refresh(deadline_authorized) + # Hide storage profile by default - only show when queue has storage profiles + self._set_storage_profile_visible(False) + # Connect signals for farm, queue, and storage profile changes + self.farm_box.box.currentIndexChanged.connect(self._on_farm_changed) + self.queue_box.box.currentIndexChanged.connect(self._on_queue_changed) + self.storage_profile_box.box.currentIndexChanged.connect(self._on_storage_profile_changed) -class _DeadlineNamedResourceDisplay(QWidget): - """ - A Label for displaying an AWS Deadline Cloud resource, that starts displaying - it as the Id, but does an async call to AWS Deadline Cloud to convert it - to the name. + # Connect to storage profile combo box model to detect when list changes + self.storage_profile_box.box.model().rowsInserted.connect( + self._update_storage_profile_visibility + ) + self.storage_profile_box.box.model().rowsRemoved.connect( + self._update_storage_profile_visibility + ) + self.storage_profile_box.box.model().modelReset.connect( + self._update_storage_profile_visibility + ) + # Also connect to the list_update signal to handle updates after async refresh + # Args (refresh_id, items_list) not needed - just trigger visibility update + self.storage_profile_box.list_update.connect( + lambda *args: self._update_storage_profile_visibility() + ) - Args: - resource_name (str): The resource name for the list, like "Farm", - "Queue", "Fleet". - setting_name (str): The setting name for the item. - """ + # Initialize with current config + config = config_file.read_config() + self.farm_box.set_config(config) + self.queue_box.set_config(config) + self.storage_profile_box.set_config(config) + + # Connect background exception signals to show errors to the user + self.farm_box.background_exception.connect(self._handle_background_exception) + self.queue_box.background_exception.connect(self._handle_background_exception) + self.storage_profile_box.background_exception.connect(self._handle_background_exception) + + def _handle_background_exception(self, title, e): + """Show a warning dialog when a background refresh thread encounters an error.""" + QMessageBox.warning(self, title, f"Encountered an error:\n{e}") # type: ignore[call-arg] + + def _set_storage_profile_visible(self, visible: bool): + """Show or hide the storage profile selector""" + self.storage_profile_box_label.setVisible(visible) + self.storage_profile_box.setVisible(visible) + + def _update_storage_profile_visibility(self): + """Update storage profile visibility based on available profiles""" + box = self.storage_profile_box.box + has_real_profiles = any( + box.itemData(i) not in (None, "") + and box.itemText(i) not in ("", "") + for i in range(box.count()) + ) + self._set_storage_profile_visible(has_real_profiles) + + def _update_all_box_configs(self): + """Re-read config and update all combo boxes.""" + config = config_file.read_config() + self.farm_box.set_config(config) + self.queue_box.set_config(config) + self.storage_profile_box.set_config(config) + + def _on_farm_changed(self, index: int): + """Handle farm selection change in Submit Dialog""" + if index < 0: + return - # Emitted when the background refresh thread catches an exception, - # provides (operation_name, BaseException) - background_exception = Signal(str, BaseException) + farm_id = self.farm_box.box.itemData(index) + if farm_id is None: + return - # Emitted when an async refresh_item thread completes, - # provides (refresh_id, id, name, description) - _item_update = Signal(int, str, str, str) + set_setting("defaults.farm_id", farm_id) + self._update_all_box_configs() + # Don't call refresh_list() here — _notify_parent_refresh triggers + # refresh_deadline_settings which calls refresh_setting_controls, + # and that already refreshes all lists when authorized. + self._notify_parent_refresh() - def __init__(self, *, resource_name, setting_name, parent: Optional[QWidget] = None): - super().__init__(parent=parent) + def _on_queue_changed(self, index: int): + """Handle queue selection change in Submit Dialog""" + if index < 0: + return - self.__refresh_thread = None - self.__refresh_id = 0 - self.canceled = CancelationFlag() - self.destroyed.connect(self.canceled.set_canceled) + queue_id = self.queue_box.box.itemData(index) + if queue_id is None: + return - self.resource_name = resource_name - self.setting_name = setting_name - self.item_id = get_setting(self.setting_name) - self.item_name = "" - self.item_description = "" + set_setting("defaults.queue_id", queue_id) + self._update_all_box_configs() + # Don't call refresh_list() here — same reason as _on_farm_changed. + self._notify_parent_refresh() - self._build_ui() + def _on_storage_profile_changed(self, index: int): + """Handle storage profile selection change in Submit Dialog""" + if index < 0: + return - self.label.setText(self.item_display_name()) + # Get the selected storage profile ID from the combo box + storage_profile_id = self.storage_profile_box.box.itemData(index) + + # Update config immediately + set_setting("settings.storage_profile_id", storage_profile_id if storage_profile_id else "") + + def _find_parent_with_attr(self, attr_name: str) -> Optional[QWidget]: + """Find first parent widget with the given attribute.""" + parent: Optional[QWidget] = self.parent() # type: ignore[assignment] + while parent is not None: + if hasattr(parent, attr_name): + return parent + parent = parent.parent() # type: ignore[assignment] + return None + + def _notify_parent_refresh(self): + """Helper to notify parent widgets to refresh after config changes""" + # Find and call refresh_deadline_settings on parent chain. + # refresh_deadline_settings already calls refresh_queue_parameters + # internally, so we don't call it separately here. + parent = self._find_parent_with_attr("refresh_deadline_settings") + if parent: + parent.refresh_deadline_settings() - def _build_ui(self): - self.label = QLabel(parent=self) - layout = QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self.label) - self._item_update.connect(self.handle_item_update) - self.background_exception.connect(self.handle_background_exception) - - def handle_background_exception(self, e): - self.label.setText(self.item_id) - self.label.setToolTip("") - - def item_display_name(self): - """Returns the text to display the item name as""" - return self.item_name or self.item_id or "" - - def refresh(self, deadline_authorized): + def refresh_setting_controls(self, deadline_authorized): """ - Starts a background thread to refresh the item name. + Refreshes the controls for UI items that depend on the AWS Deadline Cloud API + for their values. Args: deadline_authorized (bool): Should be the result of a call to api.check_deadline_available, for example from an AWS Deadline Cloud Status Widget. """ - resource_id = get_setting(self.setting_name) - if resource_id != self.item_id or not self.item_name: - self.item_id = resource_id - self.item_name = "" - self.item_description = "" - display_name = self.item_display_name() - # Only call the AWS Deadline Cloud API if we've confirmed access - if deadline_authorized: - display_name = " - " + display_name - - self.__refresh_id += 1 - self.__refresh_thread = threading.Thread( - target=self._refresh_thread_function, - name=f"AWS Deadline Cloud refresh {self.resource_name} item thread", - args=(self.__refresh_id,), - ) - self.__refresh_thread.start() - - self.label.setText(display_name) - self.label.setToolTip(self.item_description) - else: - self.label.setText(self.item_display_name()) + self._update_all_box_configs() - def handle_item_update(self, refresh_id, id, name, description): - # Apply the refresh if it's still for the latest call - if refresh_id == self.__refresh_id: - self.item_id = id - self.item_name = name - self.item_description = description - self.label.setText(self.item_display_name()) - self.label.setToolTip(self.item_description) - - def _refresh_thread_function(self, refresh_id: int): - """ - This function gets started in a background thread to refresh the list. - """ - try: - item = self.get_item() - if not self.canceled: - self._item_update.emit(refresh_id, *item) - except BaseException as e: - if not self.canceled: - self.background_exception.emit(f"Refresh {self.resource_name} item", e) - - -class DeadlineFarmDisplay(_DeadlineNamedResourceDisplay): - def __init__(self, *, parent: Optional[QWidget] = None): - super().__init__(resource_name="Farm", setting_name="defaults.farm_id", parent=parent) - - def get_item(self): - farm_id = get_setting(self.setting_name) - if farm_id: - deadline = api.get_boto3_client("deadline") - response = deadline.get_farm(farmId=farm_id) - return (response["farmId"], response["displayName"], response["description"]) - else: - return ("", "", "") - - -class DeadlineQueueDisplay(_DeadlineNamedResourceDisplay): - def __init__(self, *, parent: Optional[QWidget] = None): - super().__init__(resource_name="Queue", setting_name="defaults.queue_id", parent=parent) - - def get_item(self): - farm_id = get_setting("defaults.farm_id") - queue_id = get_setting(self.setting_name) - if farm_id and queue_id: - deadline = api.get_boto3_client("deadline") - response = deadline.get_queue(farmId=farm_id, queueId=queue_id) - return (response["queueId"], response["displayName"], response["description"]) - else: - return ("", "", "") - - -class DeadlineStorageProfileNameDisplay(_DeadlineNamedResourceDisplay): - WINDOWS_OS = "Windows" - MAC_OS = "Macos" - LINUX_OS = "Linux" - - def __init__(self, *, parent: Optional[QWidget] = None): - super().__init__( - resource_name="Storage profile name", - setting_name="settings.storage_profile_id", - parent=parent, - ) - - def get_item(self): - farm_id = get_setting("defaults.farm_id") - queue_id = get_setting("defaults.queue_id") - storage_profile_id = get_setting(self.setting_name) - - if farm_id and queue_id and storage_profile_id: - deadline = api.get_boto3_client("deadline") - response = deadline.list_storage_profiles_for_queue(farmId=farm_id, queueId=queue_id) - farm_storage_profiles = response.get("storageProfiles", {}) - - if farm_storage_profiles: - storage_profile = [ - (item["storageProfileId"], item["displayName"], item["osFamily"]) - for item in farm_storage_profiles - if storage_profile_id == item["storageProfileId"] - ] - return storage_profile[0] - - return ("", "", "") - - def _get_default_storage_profile_name(self) -> str: - """ - Get a string specifying what the OS is, following the format the Deadline storage profile API expects. - """ - if sys.platform.startswith("linux"): - return self.LINUX_OS - - if sys.platform.startswith("darwin"): - return self.MAC_OS - - if sys.platform.startswith("win"): - return self.WINDOWS_OS - - return "" + # Use block_signals to prevent currentIndexChanged from firing during + # programmatic updates, which would spuriously write to config. + with block_signals(self.farm_box.box), block_signals(self.queue_box.box), block_signals( + self.storage_profile_box.box + ): + self.farm_box.refresh_selected_id() + self.queue_box.refresh_selected_id() + self.storage_profile_box.refresh_selected_id() + + # Refresh lists if authorized (refresh_list already uses block_signals internally) + if deadline_authorized: + self.farm_box.refresh_list() + self.queue_box.refresh_list() + self.storage_profile_box.refresh_list() diff --git a/test/squish/suite_deadline_gui/config.xml b/test/squish/suite_deadline_gui/config.xml index 326b954a5..2c3c3c99f 100644 --- a/test/squish/suite_deadline_gui/config.xml +++ b/test/squish/suite_deadline_gui/config.xml @@ -1,5 +1,4 @@ - - + diff --git a/test/squish/suite_deadline_gui/envvars b/test/squish/suite_deadline_gui/envvars index f1c12beac..63243ccd5 100644 --- a/test/squish/suite_deadline_gui/envvars +++ b/test/squish/suite_deadline_gui/envvars @@ -1,4 +1,2 @@ -LD_LIBRARY_PATH=/home/user/.local/lib/python3.9/site-packages/PySide6/Qt/lib/ -PATH=C:\Users\user\AppData\Local\Programs\Python\Python310\Lib\site-packages\PySide6\ SQUISH_NO_CAPTURE_OUTPUT=1 -DYLD_LIBRARY_PATH=/Users/user/Library/Python/3.9/lib/python/site-packages/PySide6/Qt/lib/ \ No newline at end of file +QT_API=pyside6 diff --git a/test/squish/suite_deadline_gui/shared/scripts/choose_jobbundledir_helpers.py b/test/squish/suite_deadline_gui/shared/scripts/choose_jobbundledir_helpers.py index 64bd029ee..9d7fb2250 100644 --- a/test/squish/suite_deadline_gui/shared/scripts/choose_jobbundledir_helpers.py +++ b/test/squish/suite_deadline_gui/shared/scripts/choose_jobbundledir_helpers.py @@ -74,17 +74,17 @@ def load_different_job_bundle(): str( squish.waitForObjectExists(gui_submitter_locators.load_different_job_bundle_button).text ), - "Load a different job bundle", - "Expect Load a different job bundle button to contain correct text.", + "Load Bundle", + "Expect Load Bundle button to contain correct text.", ) - # verify load a different job bundle button is enabled + # verify load bundle button is enabled test.compare( squish.waitForObjectExists(gui_submitter_locators.load_different_job_bundle_button).enabled, True, - "Expect Load a different job bundle button to be enabled.", + "Expect Load Bundle button to be enabled.", ) - # click on load a different job bundle button - test.log("Hitting `Load a different job bundle` button.") + # click on load bundle button + test.log("Hitting `Load Bundle` button.") squish.clickButton( squish.waitForObject(gui_submitter_locators.load_different_job_bundle_button) ) diff --git a/test/squish/suite_deadline_gui/shared/scripts/config.py b/test/squish/suite_deadline_gui/shared/scripts/config.py index 9385bee64..b8ba2e016 100644 --- a/test/squish/suite_deadline_gui/shared/scripts/config.py +++ b/test/squish/suite_deadline_gui/shared/scripts/config.py @@ -7,6 +7,10 @@ # get user's home directory home_dir = str(Path.home()) +# derive repo root from this file's location +# config.py is at: /test/squish/suite_deadline_gui/shared/scripts/config.py +_repo_root = str(Path(__file__).resolve().parents[5]) + # get python version for Deadline executable file path python_version = f"Python{sys.version_info.major}{sys.version_info.minor}" # Deadline executable file path on Windows @@ -45,10 +49,8 @@ # tst_verify_gui_submitter_bundles test suite: # Simple UI with Job Attachments (simple_ui_with_ja) -simple_ui_with_ja = ( - f"{home_dir}/deadline-cloud/test/squish/deadline_gui_test_samples/simple_ui_with_ja" -) +simple_ui_with_ja = f"{_repo_root}/test/squish/deadline_gui_test_samples/simple_ui_with_ja" simple_ui_with_ja_name = "Simple UI with Job Attachments" # Simple UI - No Job Attachments (simple_ui_no_ja) -simple_ui_no_ja = f"{home_dir}/deadline-cloud/test/squish/deadline_gui_test_samples/simple_ui_no_ja" +simple_ui_no_ja = f"{_repo_root}/test/squish/deadline_gui_test_samples/simple_ui_no_ja" simple_ui_no_ja_name = "Simple UI - No Job Attachments" diff --git a/test/squish/suite_deadline_gui/shared/scripts/gui_submitter_helpers.py b/test/squish/suite_deadline_gui/shared/scripts/gui_submitter_helpers.py index fb7e49e49..740b910a9 100644 --- a/test/squish/suite_deadline_gui/shared/scripts/gui_submitter_helpers.py +++ b/test/squish/suite_deadline_gui/shared/scripts/gui_submitter_helpers.py @@ -3,6 +3,7 @@ # mypy: disable-error-code="attr-defined" import gui_submitter_locators +import platform import squish import test @@ -51,3 +52,165 @@ def verify_shared_job_settings( job_name, "Expect correct job bundle job name to be displayed by default.", ) + + +def _wait_for_combo_box_value(locator, expected_value: str, timeout_ms: int = 5000): + """Wait for a combo box to show the expected value (handles async refresh).""" + import time + + start_time = time.time() + timeout_sec = timeout_ms / 1000.0 + while time.time() - start_time < timeout_sec: + try: + current_text = str(squish.waitForObjectExists(locator).currentText) + if current_text == expected_value: + return True + if current_text != "": + # Value stabilized but doesn't match - fail fast + return False + except Exception: + pass + time.sleep(0.1) + return False + + +def set_farm_name(farm_name: str): + """Set the farm in the Submit Dialog's Deadline Cloud settings.""" + # Ensure we're on the Shared job settings tab + navigate_shared_job_settings() + # Open farm dropdown menu + squish.mouseClick( + squish.waitForObject(gui_submitter_locators.deadline_cloud_settings_farm_dropdown) + ) + test.log("Opened farm name drop down menu in Submit Dialog.") + test.compare( + squish.waitForObjectExists( + gui_submitter_locators.farm_name_dropdown_locator(farm_name) + ).text, + farm_name, + "Expect farm name to be present in drop down.", + ) + # Select the farm + squish.mouseClick( + squish.waitForObjectItem( + gui_submitter_locators.deadline_cloud_settings_farm_dropdown, farm_name + ) + ) + test.log(f"Selected farm name: {farm_name}") + # Wait for async refresh to complete + test.verify( + _wait_for_combo_box_value( + gui_submitter_locators.deadline_cloud_settings_farm_dropdown, farm_name + ), + f"Farm combo box should show '{farm_name}' after async refresh", + ) + + +def set_queue_name(queue_name: str): + """Set the queue in the Submit Dialog's Deadline Cloud settings.""" + # Ensure we're on the Shared job settings tab + navigate_shared_job_settings() + # Open queue dropdown menu + squish.mouseClick( + squish.waitForObject(gui_submitter_locators.deadline_cloud_settings_queue_dropdown) + ) + test.log("Opened queue name drop down menu in Submit Dialog.") + test.compare( + squish.waitForObjectExists( + gui_submitter_locators.queue_name_dropdown_locator(queue_name) + ).text, + queue_name, + "Expect queue name to be present in drop down.", + ) + # Select the queue + squish.mouseClick( + squish.waitForObjectItem( + gui_submitter_locators.deadline_cloud_settings_queue_dropdown, queue_name + ) + ) + test.log(f"Selected queue name: {queue_name}") + # Wait for async refresh to complete + test.verify( + _wait_for_combo_box_value( + gui_submitter_locators.deadline_cloud_settings_queue_dropdown, queue_name + ), + f"Queue combo box should show '{queue_name}' after async refresh", + ) + + +def set_storage_profile(storage_profile: str): + """Set the storage profile in the Submit Dialog's Deadline Cloud settings.""" + # Ensure we're on the Shared job settings tab + navigate_shared_job_settings() + # Open storage profile dropdown menu + squish.mouseClick( + squish.waitForObject( + gui_submitter_locators.deadline_cloud_settings_storage_profile_dropdown + ) + ) + test.log("Opened storage profile drop down menu in Submit Dialog.") + test.compare( + squish.waitForObjectExists( + gui_submitter_locators.storage_profile_dropdown_locator(storage_profile) + ).text, + storage_profile, + "Expect storage profile to be present in drop down.", + ) + # Select the storage profile + squish.mouseClick( + squish.waitForObjectItem( + gui_submitter_locators.deadline_cloud_settings_storage_profile_dropdown, + storage_profile, + ) + ) + test.log(f"Selected storage profile: {storage_profile}") + # Wait for async refresh to complete + test.verify( + _wait_for_combo_box_value( + gui_submitter_locators.deadline_cloud_settings_storage_profile_dropdown, storage_profile + ), + f"Storage profile combo box should show '{storage_profile}' after async refresh", + ) + + +def set_and_verify_os_storage_profile( + linux_storage_profile: str, windows_storage_profile: str, macos_storage_profile: str +): + """Set and verify storage profile based on OS platform being tested.""" + if platform.system() == "Linux": + test.log("Detected test running on Linux OS") + set_storage_profile(linux_storage_profile) + test.compare( + str( + squish.waitForObjectExists( + gui_submitter_locators.deadline_cloud_settings_storage_profile_dropdown + ).currentText + ), + linux_storage_profile, + "Expect selected storage profile to be set.", + ) + elif platform.system() == "Windows": + test.log("Detected test running on Windows OS") + set_storage_profile(windows_storage_profile) + test.compare( + str( + squish.waitForObjectExists( + gui_submitter_locators.deadline_cloud_settings_storage_profile_dropdown + ).currentText + ), + windows_storage_profile, + "Expect selected storage profile to be set.", + ) + elif platform.system() == "Darwin": + test.log("Detected test running on macOS") + set_storage_profile(macos_storage_profile) + test.compare( + str( + squish.waitForObjectExists( + gui_submitter_locators.deadline_cloud_settings_storage_profile_dropdown + ).currentText + ), + macos_storage_profile, + "Expect selected storage profile to be set.", + ) + test.log("Selected and verified storage profile based on OS platform being tested.") diff --git a/test/squish/suite_deadline_gui/shared/scripts/gui_submitter_locators.py b/test/squish/suite_deadline_gui/shared/scripts/gui_submitter_locators.py index 68d48b91c..d180daba9 100644 --- a/test/squish/suite_deadline_gui/shared/scripts/gui_submitter_locators.py +++ b/test/squish/suite_deadline_gui/shared/scripts/gui_submitter_locators.py @@ -5,7 +5,7 @@ "type": "SubmitJobToDeadlineDialog", "unnamed": 1, "visible": 1, - "windowTitle": "Submit to AWS Deadline Cloud", + "windowTitle": "Deadline Cloud JobBundle Submitter", } # Settings button settings_button = { @@ -90,19 +90,26 @@ "unnamed": 1, "visible": 1, } -# Deadline Cloud Squish Farm text element -deadline_cloud_settings_farm_name = { +# Farm combo box +deadline_cloud_settings_farm_dropdown = { "container": deadline_cloud_settings_widget, - "text": "Deadline Cloud Squish Farm", - "type": "QLabel", + "type": "QComboBox", "unnamed": 1, "visible": 1, } -# Squish Automation Queue text element -deadline_cloud_settings_queue_name = { +# Queue combo box +deadline_cloud_settings_queue_dropdown = { "container": deadline_cloud_settings_widget, - "text": "Squish Automation Queue", - "type": "QLabel", + "occurrence": 2, + "type": "QComboBox", + "unnamed": 1, + "visible": 1, +} +# Storage profile combo box +deadline_cloud_settings_storage_profile_dropdown = { + "container": deadline_cloud_settings_widget, + "occurrence": 3, + "type": "QComboBox", "unnamed": 1, "visible": 1, } @@ -222,13 +229,13 @@ "unnamed": 1, "visible": 1, } -# load different job bundle button in AWS Submitter dialogue (job-specific settings tab) +# load different job bundle button in AWS Submitter dialogue (in button box at bottom) load_different_job_bundle_button = { - "container": properties_only_widget, - "text": "Load a different job bundle", + "text": "Load Bundle", "type": "QPushButton", "unnamed": 1, "visible": 1, + "window": aws_submitter_dialogue, } # job history directory default filepath input job_hist_dir_dropdown = { @@ -239,21 +246,25 @@ } -def deadlinecloud_farmname_locator(farm_name): +def farm_name_dropdown_locator(farm_name): return { - "container": deadline_cloud_settings_widget, + "container": deadline_cloud_settings_farm_dropdown, "text": farm_name, - "type": "QLabel", - "unnamed": 1, - "visible": 1, + "type": "QModelIndex", } -def deadlinecloud_queuename_locator(queue_name): +def queue_name_dropdown_locator(queue_name): return { - "container": deadline_cloud_settings_widget, + "container": deadline_cloud_settings_queue_dropdown, "text": queue_name, - "type": "QLabel", - "unnamed": 1, - "visible": 1, + "type": "QModelIndex", + } + + +def storage_profile_dropdown_locator(storage_profile): + return { + "container": deadline_cloud_settings_storage_profile_dropdown, + "text": storage_profile, + "type": "QModelIndex", } diff --git a/test/squish/suite_deadline_gui/shared/scripts/names.py b/test/squish/suite_deadline_gui/shared/scripts/names.py index 2a65a122e..0b38191cc 100644 --- a/test/squish/suite_deadline_gui/shared/scripts/names.py +++ b/test/squish/suite_deadline_gui/shared/scripts/names.py @@ -142,23 +142,12 @@ "visible": 1, "window": aWS_Deadline_Cloud_workstation_configuration_DeadlineConfigDialog, } -profile_settings_QComboBox = { - "container": aWS_Deadline_Cloud_workstation_configuration_Profile_settings_QGroupBox, - "type": "QComboBox", - "unnamed": 1, - "visible": 1, -} profile_settings_QLineEdit = { "container": aWS_Deadline_Cloud_workstation_configuration_Profile_settings_QGroupBox, "type": "QLineEdit", "unnamed": 1, "visible": 1, } -deadline_Cloud_Squish_Farm_QModelIndex = { - "container": profile_settings_QComboBox, - "text": "Deadline Cloud Squish Farm", - "type": "QModelIndex", -} profile_settings_QPushButton = { "container": aWS_Deadline_Cloud_workstation_configuration_Profile_settings_QGroupBox, "text": "...", @@ -181,36 +170,6 @@ "visible": 1, "window": aWS_Deadline_Cloud_workstation_configuration_DeadlineConfigDialog, } -farm_settings_QComboBox = { - "container": aWS_Deadline_Cloud_workstation_configuration_Farm_settings_QGroupBox, - "type": "QComboBox", - "unnamed": 1, - "visible": 1, -} -farm_settings_QComboBox_2 = { - "container": aWS_Deadline_Cloud_workstation_configuration_Farm_settings_QGroupBox, - "occurrence": 2, - "type": "QComboBox", - "unnamed": 1, - "visible": 1, -} -farm_settings_QComboBox_3 = { - "container": aWS_Deadline_Cloud_workstation_configuration_Farm_settings_QGroupBox, - "occurrence": 3, - "type": "QComboBox", - "unnamed": 1, - "visible": 1, -} -squish_Automation_Queue_QModelIndex = { - "container": farm_settings_QComboBox, - "text": "Squish Automation Queue", - "type": "QModelIndex", -} -squish_Storage_Profile_QModelIndex = { - "container": farm_settings_QComboBox_2, - "text": "Squish Storage Profile", - "type": "QModelIndex", -} farm_settings_Job_attachments_filesystem_options_QLabel = { "container": aWS_Deadline_Cloud_workstation_configuration_Farm_settings_QGroupBox, "text": "Job attachments filesystem options", @@ -331,7 +290,7 @@ "type": "SubmitJobToDeadlineDialog", "unnamed": 1, "visible": 1, - "windowTitle": "Submit to AWS Deadline Cloud", + "windowTitle": "Deadline Cloud JobBundle Submitter", } submit_to_AWS_Deadline_Cloud_Settings_QPushButton = { "text": "Settings...", @@ -418,20 +377,6 @@ "unnamed": 1, "visible": 1, } -deadline_Cloud_settings_Deadline_Cloud_Squish_Farm_QLabel = { - "container": deadline_Cloud_settings_DeadlineCloudSettingsWidget, - "text": "Deadline Cloud Squish Farm", - "type": "QLabel", - "unnamed": 1, - "visible": 1, -} -deadline_Cloud_settings_Squish_Automation_Queue_QLabel = { - "container": deadline_Cloud_settings_DeadlineCloudSettingsWidget, - "text": "Squish Automation Queue", - "type": "QLabel", - "unnamed": 1, - "visible": 1, -} queue_Environment_Conda_JobTemplateGroupLayout = { "container": qt_tabwidget_stackedwidget_QScrollArea, "name": "Queue Environment: Conda", @@ -566,11 +511,11 @@ "visible": 1, } load_a_different_job_bundle_QPushButton = { - "container": qt_tabwidget_stackedwidget_QScrollArea, - "text": "Load a different job bundle", + "text": "Load Bundle", "type": "QPushButton", "unnamed": 1, "visible": 1, + "window": submit_to_AWS_Deadline_Cloud_SubmitJobToDeadlineDialog, } lookInCombo_QComboBox = { "container": qt_tabwidget_stackedwidget_QScrollArea, diff --git a/test/squish/suite_deadline_gui/shared/scripts/workstation_config_helpers.py b/test/squish/suite_deadline_gui/shared/scripts/workstation_config_helpers.py index 324d039e0..7fbece268 100644 --- a/test/squish/suite_deadline_gui/shared/scripts/workstation_config_helpers.py +++ b/test/squish/suite_deadline_gui/shared/scripts/workstation_config_helpers.py @@ -87,138 +87,6 @@ def hit_apply_button(): test.log("Settings have been applied.") -def set_farm_name(farm_name: str): - # open Default farm drop down menu - squish.mouseClick( - squish.waitForObject(workstation_config_locators.profilesettings_defaultfarm_dropdown) - ) - test.log("Opened farm name drop down menu.") - test.compare( - squish.waitForObjectExists(workstation_config_locators.farm_name_locator(farm_name)).text, - farm_name, - "Expect farm name to be present in drop down.", - ) - # select Default farm - squish.mouseClick( - squish.waitForObjectItem( - workstation_config_locators.profilesettings_defaultfarm_dropdown, farm_name - ) - ) - test.log("Selected farm name.") - - -def set_queue_name(queue_name: str): - # open Default queue drop down menu - squish.mouseClick( - squish.waitForObject(workstation_config_locators.farmsettings_defaultqueue_dropdown) - ) - test.log("Opened queue name drop down menu.") - test.compare( - squish.waitForObjectExists(workstation_config_locators.queue_name_locator(queue_name)).text, - queue_name, - "Expect queue name to be present in drop down.", - ) - # select Default queue - squish.mouseClick( - squish.waitForObjectItem( - workstation_config_locators.farmsettings_defaultqueue_dropdown, queue_name - ) - ) - test.log("Selected queue name.") - - -def set_and_verify_os_storage_profile( - linux_storage_profile: str, windows_storage_profile: str, macos_storage_profile: str -): - # open Default storage profile drop down menu - squish.mouseClick( - squish.waitForObject( - workstation_config_locators.farmsettings_defaultstorageprofile_dropdown - ) - ) - test.log("Opened storage profile drop down menu.") - # select storage profile based on OS platform being tested - if platform.system() == "Linux": - test.log("Detected test running on Linux OS") - test.compare( - squish.waitForObjectExists( - workstation_config_locators.storage_profile_locator(linux_storage_profile) - ).text, - linux_storage_profile, - "Expect Linux Storage Profile to be present in drop down.", - ) - # select Linux Storage Profile - squish.mouseClick( - squish.waitForObjectItem( - workstation_config_locators.farmsettings_defaultstorageprofile_dropdown, - linux_storage_profile, - ) - ) - # verify correct storage profile name is set - test.compare( - str( - squish.waitForObjectExists( - workstation_config_locators.farmsettings_defaultstorageprofile_dropdown - ).currentText - ), - linux_storage_profile, - "Expect selected storage profile to be set.", - ) - elif platform.system() == "Windows": - test.log("Detected test running on Windows OS") - test.compare( - squish.waitForObjectExists( - workstation_config_locators.storage_profile_locator(windows_storage_profile) - ).text, - windows_storage_profile, - "Expect Windows Storage Profile to be present in drop down.", - ) - # select Windows Storage Profile - squish.mouseClick( - squish.waitForObjectItem( - workstation_config_locators.farmsettings_defaultstorageprofile_dropdown, - windows_storage_profile, - ) - ) - # verify correct storage profile name is set - test.compare( - str( - squish.waitForObjectExists( - workstation_config_locators.farmsettings_defaultstorageprofile_dropdown - ).currentText - ), - windows_storage_profile, - "Expect selected storage profile to be set.", - ) - elif platform.system() == "Darwin": - test.log("Detected test running on macOS") - test.compare( - squish.waitForObjectExists( - workstation_config_locators.storage_profile_locator(macos_storage_profile) - ).text, - macos_storage_profile, - "Expect macOS Storage Profile to be present in drop down.", - ) - # select macOS Storage Profile - squish.mouseClick( - squish.waitForObjectItem( - workstation_config_locators.farmsettings_defaultstorageprofile_dropdown, - macos_storage_profile, - ) - ) - # verify correct storage profile name is set - test.compare( - str( - squish.waitForObjectExists( - workstation_config_locators.farmsettings_defaultstorageprofile_dropdown - ).currentText - ), - macos_storage_profile, - "Expect selected storage profile to be set.", - ) - test.log("Selected storage profile based on OS platform being tested.") - - def set_job_attachments_filesystem_options(job_attachments: str): # open Job attachments filesystem options drop down menu squish.mouseClick( diff --git a/test/squish/suite_deadline_gui/shared/scripts/workstation_config_locators.py b/test/squish/suite_deadline_gui/shared/scripts/workstation_config_locators.py index fedea6846..c5d472ce5 100644 --- a/test/squish/suite_deadline_gui/shared/scripts/workstation_config_locators.py +++ b/test/squish/suite_deadline_gui/shared/scripts/workstation_config_locators.py @@ -61,9 +61,10 @@ "visible": 1, "window": deadline_config_dialog, } -profilesettings_defaultfarm_dropdown = { +profilesettings_jobhistdir_label = { "container": deadlinedialog_profilesettings_box, - "type": "QComboBox", + "text": "Job history directory", + "type": "QLabel", "unnamed": 1, "visible": 1, } @@ -73,12 +74,6 @@ "unnamed": 1, "visible": 1, } -# Deadline Cloud Squish Farm element -deadlinecloudsquish_defaultfarm_index = { - "container": profilesettings_defaultfarm_dropdown, - "text": "Deadline Cloud Squish Farm", - "type": "QModelIndex", -} # choose job history directory file browser open_job_hist_dir_button = { @@ -105,38 +100,12 @@ "visible": 1, "window": deadline_config_dialog, } -farmsettings_defaultqueue_dropdown = { - "container": deadlinedialog_farmsettings_box, - "type": "QComboBox", - "unnamed": 1, - "visible": 1, -} -farmsettings_defaultstorageprofile_dropdown = { - "container": deadlinedialog_farmsettings_box, - "occurrence": 2, - "type": "QComboBox", - "unnamed": 1, - "visible": 1, -} farmsettings_jobattachmentsoptions_dropdown = { "container": deadlinedialog_farmsettings_box, - "occurrence": 3, "type": "QComboBox", "unnamed": 1, "visible": 1, } -# Squish Automation Queue element -squishautomationqueue_defaultqueue_index = { - "container": farmsettings_defaultqueue_dropdown, - "text": "Squish Automation Queue", - "type": "QModelIndex", -} -# Squish Storage Profile element -squishstorageprofile_defaultstorageprofile_index = { - "container": farmsettings_defaultstorageprofile_dropdown, - "text": "Squish Storage Profile", - "type": "QModelIndex", -} jobattachments_filesystemoptions_text_label = { "container": deadlinedialog_farmsettings_box, "text": "Job attachments filesystem options", @@ -225,27 +194,3 @@ def profile_name_locator(profile_name): "text": profile_name, "type": "QModelIndex", } - - -def farm_name_locator(farm_name): - return { - "container": profilesettings_defaultfarm_dropdown, - "text": farm_name, - "type": "QModelIndex", - } - - -def queue_name_locator(queue_name): - return { - "container": farmsettings_defaultqueue_dropdown, - "text": queue_name, - "type": "QModelIndex", - } - - -def storage_profile_locator(storage_profile): - return { - "container": farmsettings_defaultstorageprofile_dropdown, - "text": storage_profile, - "type": "QModelIndex", - } diff --git a/test/squish/suite_deadline_gui/suite.conf b/test/squish/suite_deadline_gui/suite.conf index 3e981abf9..a7f774235 100644 --- a/test/squish/suite_deadline_gui/suite.conf +++ b/test/squish/suite_deadline_gui/suite.conf @@ -1,8 +1,9 @@ +AUT=deadline ENVVARS=envvars HOOK_SUB_PROCESSES=true IMPLICITAUTSTART=0 LANGUAGE=Python OBJECTMAPSTYLE=script -TEST_CASES=tst_verify_settings_dialogue tst_verify_gui_submitter_bundles +TEST_CASES=tst_verify_settings_dialogue tst_verify_gui_submitter_bundles tst_verify_submitter_deadline_cloud_settings VERSION=3 WRAPPERS=Qt diff --git a/test/squish/suite_deadline_gui/tst_verify_gui_submitter_bundles/test.py b/test/squish/suite_deadline_gui/tst_verify_gui_submitter_bundles/test.py index 7e059d259..f953aaa7d 100644 --- a/test/squish/suite_deadline_gui/tst_verify_gui_submitter_bundles/test.py +++ b/test/squish/suite_deadline_gui/tst_verify_gui_submitter_bundles/test.py @@ -37,7 +37,7 @@ def main(): # verify GUI Submitter dialogue opens test.compare( str(squish.waitForObjectExists(gui_submitter_locators.aws_submitter_dialogue).windowTitle), - "Submit to AWS Deadline Cloud", + "Deadline Cloud JobBundle Submitter", "Expect AWS Deadline Cloud Submitter window title to be present.", ) test.compare( diff --git a/test/squish/suite_deadline_gui/tst_verify_settings_dialogue/test.py b/test/squish/suite_deadline_gui/tst_verify_settings_dialogue/test.py index 96f03d776..1eef6091b 100644 --- a/test/squish/suite_deadline_gui/tst_verify_settings_dialogue/test.py +++ b/test/squish/suite_deadline_gui/tst_verify_settings_dialogue/test.py @@ -41,34 +41,6 @@ def main(): jobhistory_dir_helpers.verify_job_hist_dir_text_input(config.custom_job_hist_dir) # verify custom job history folder is created/exists in user's system jobhistory_dir_helpers.verify_directory_exists(config.custom_job_hist_dir) - # set farm name - workstation_config_helpers.set_farm_name(config.farm_name) - # verify correct farm name is set - test.compare( - str( - squish.waitForObjectExists( - workstation_config_locators.profilesettings_defaultfarm_dropdown - ).currentText - ), - config.farm_name, - "Expect selected farm name to be set.", - ) - # set queue name - workstation_config_helpers.set_queue_name(config.queue_name) - # verify correct queue name is set - test.compare( - str( - squish.waitForObjectExists( - workstation_config_locators.farmsettings_defaultqueue_dropdown - ).currentText - ), - config.queue_name, - "Expect selected queue name to be set.", - ) - # set and verify storage profile based on OS platform being tested - workstation_config_helpers.set_and_verify_os_storage_profile( - config.storage_profile_linux, config.storage_profile_windows, config.storage_profile_macos - ) # set job attachments filesystem options workstation_config_helpers.set_job_attachments_filesystem_options(config.job_attachments) # verify job attachments filesystem options is set to 'COPIED' diff --git a/test/squish/suite_deadline_gui/tst_verify_submitter_deadline_cloud_settings/test.py b/test/squish/suite_deadline_gui/tst_verify_submitter_deadline_cloud_settings/test.py new file mode 100644 index 000000000..b518c1da8 --- /dev/null +++ b/test/squish/suite_deadline_gui/tst_verify_submitter_deadline_cloud_settings/test.py @@ -0,0 +1,131 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# -*- coding: utf-8 -*- +# mypy: disable-error-code="attr-defined" + +import config +import choose_jobbundledir_helpers +import choose_jobbundledir_locators +import gui_submitter_helpers +import gui_submitter_locators +import squish +import test + + +def init(): + # launch Choose Job Bundle GUI Submitter based on OS platform being tested + choose_jobbundledir_helpers.detect_platform_and_launch_jobbundle_guisubmitter() + # verify Choose job bundle directory is open + test.compare( + str( + squish.waitForObjectExists( + choose_jobbundledir_locators.choose_job_bundle_dir + ).windowTitle + ), + "Choose job bundle directory", + "Expect Choose job bundle directory window title to be present.", + ) + test.compare( + squish.waitForObjectExists(choose_jobbundledir_locators.choose_job_bundle_dir).visible, + True, + "Expect Choose job bundle directory to be open.", + ) + # select a job bundle to open the Submit Dialog + choose_jobbundledir_helpers.select_jobbundle(config.simple_ui_with_ja) + # verify GUI Submitter dialogue opens + test.compare( + str(squish.waitForObjectExists(gui_submitter_locators.aws_submitter_dialogue).windowTitle), + "Deadline Cloud JobBundle Submitter", + "Expect AWS Deadline Cloud Submitter window title to be present.", + ) + test.compare( + squish.waitForObjectExists(gui_submitter_locators.aws_submitter_dialogue).visible, + True, + "Expect AWS Deadline Cloud Submitter to be open.", + ) + + +def main(): + # Navigate to Shared job settings tab where Deadline Cloud settings live + gui_submitter_helpers.navigate_shared_job_settings() + + # --- Test farm selection and verify queue list refreshes --- + test.log("Testing farm selection in Submit Dialog") + gui_submitter_helpers.set_farm_name(config.farm_name) + # verify correct farm name is set + test.compare( + str( + squish.waitForObjectExists( + gui_submitter_locators.deadline_cloud_settings_farm_dropdown + ).currentText + ), + config.farm_name, + "Expect selected farm name to be set.", + ) + + # --- Test queue selection (verifies queue list refreshed after farm change) --- + test.log( + "Testing queue selection in Submit Dialog (verifies queue list refreshed after farm change)" + ) + gui_submitter_helpers.set_queue_name(config.queue_name) + # verify correct queue name is set + test.compare( + str( + squish.waitForObjectExists( + gui_submitter_locators.deadline_cloud_settings_queue_dropdown + ).currentText + ), + config.queue_name, + "Expect selected queue name to be set.", + ) + + # --- Test storage profile selection (verifies storage profile list refreshed after queue change) --- + test.log( + "Testing storage profile selection in Submit Dialog (verifies list refreshed after queue change)" + ) + gui_submitter_helpers.set_and_verify_os_storage_profile( + config.storage_profile_linux, + config.storage_profile_windows, + config.storage_profile_macos, + ) + + # --- Verify cascade: change farm again and confirm queue/storage profile update --- + test.log("Testing cascade: re-selecting farm to verify queue and storage profile lists refresh") + # Re-select the same farm to trigger a refresh of dependent lists + gui_submitter_helpers.set_farm_name(config.farm_name) + test.compare( + str( + squish.waitForObjectExists( + gui_submitter_locators.deadline_cloud_settings_farm_dropdown + ).currentText + ), + config.farm_name, + "Expect farm name still set after re-selection.", + ) + # After farm re-selection, queue list should have refreshed - verify we can still select the queue + gui_submitter_helpers.set_queue_name(config.queue_name) + test.compare( + str( + squish.waitForObjectExists( + gui_submitter_locators.deadline_cloud_settings_queue_dropdown + ).currentText + ), + config.queue_name, + "Expect queue name set after farm re-selection (queue list refreshed).", + ) + # After queue re-selection, storage profile list should have refreshed + gui_submitter_helpers.set_and_verify_os_storage_profile( + config.storage_profile_linux, + config.storage_profile_windows, + config.storage_profile_macos, + ) + + test.log( + "All Deadline Cloud settings (farm, queue, storage profile) and refresh cascade verified in Submit Dialog." + ) + + +def cleanup(): + test.log("Closing AWS Submitter dialogue by sending QCloseEvent to 'x' button.") + squish.sendEvent( + "QCloseEvent", squish.waitForObject(gui_submitter_locators.aws_submitter_dialogue) + ) diff --git a/test/unit/deadline_client/ui/dialogs/test_deadline_config_dialog.py b/test/unit/deadline_client/ui/dialogs/test_deadline_config_dialog.py index 3bc6e3bb6..1dee8b956 100644 --- a/test/unit/deadline_client/ui/dialogs/test_deadline_config_dialog.py +++ b/test/unit/deadline_client/ui/dialogs/test_deadline_config_dialog.py @@ -6,15 +6,17 @@ import pytest try: - from deadline.client.ui.dialogs.deadline_config_dialog import _DeadlineResourceListComboBox + from deadline.client.ui.widgets.deadline_cloud_resource_combo_boxes import ( + _DeadlineResourceListComboBox, + ) except ImportError: - pytest.importorskip("deadline.client.ui.dialogs.deadline_config_dialog") + pytest.importorskip("deadline.client.ui.widgets.deadline_cloud_resource_combo_boxes") class TestDeadlineResourceListComboBox: """Tests for _DeadlineResourceListComboBox.refresh_selected_id()""" - @patch("deadline.client.ui.dialogs.deadline_config_dialog.config_file") + @patch("deadline.client.ui.widgets.deadline_cloud_resource_combo_boxes.config_file") def test_shows_id_when_not_in_list(self, mock_config_file, qtbot): """ When user has a configured ID but lacks permission to list resources, @@ -34,7 +36,7 @@ def test_shows_id_when_not_in_list(self, mock_config_file, qtbot): assert widget.box.currentText() == "farm-abc123" assert widget.box.currentData() == "farm-abc123" - @patch("deadline.client.ui.dialogs.deadline_config_dialog.config_file") + @patch("deadline.client.ui.widgets.deadline_cloud_resource_combo_boxes.config_file") def test_shows_none_selected_when_no_id_configured(self, mock_config_file, qtbot): """When no ID is configured, should show ''.""" mock_config_file.get_setting.return_value = "" diff --git a/test/unit/deadline_client/ui/dialogs/test_help_dialog.py b/test/unit/deadline_client/ui/dialogs/test_help_dialog.py index 4e0c12012..c24f281de 100644 --- a/test/unit/deadline_client/ui/dialogs/test_help_dialog.py +++ b/test/unit/deadline_client/ui/dialogs/test_help_dialog.py @@ -221,9 +221,12 @@ def test_hard_coded_documentation_links_display(self, qtbot): # Get the layout and find all QLabel widgets layout = help_dialog.layout() + assert layout is not None labels = [] for i in range(layout.count()): - widget = layout.itemAt(i).widget() + layout_item = layout.itemAt(i) + assert layout_item is not None + widget = layout_item.widget() if isinstance(widget, QLabel): labels.append(widget) diff --git a/test/unit/deadline_client/ui/dialogs/test_submit_job_to_deadline_dialog.py b/test/unit/deadline_client/ui/dialogs/test_submit_job_to_deadline_dialog.py index 3819e554c..2fe26b1fc 100644 --- a/test/unit/deadline_client/ui/dialogs/test_submit_job_to_deadline_dialog.py +++ b/test/unit/deadline_client/ui/dialogs/test_submit_job_to_deadline_dialog.py @@ -14,12 +14,16 @@ @pytest.fixture def mock_job_settings_widget(): - """Create a mock job settings widget type.""" - widget = MagicMock() - widget.return_value = MagicMock() - widget.return_value.parameter_changed = MagicMock() - widget.return_value.parameter_changed.connect = MagicMock() - return widget + """Create a mock job settings widget type that returns a real QWidget.""" + from qtpy.QtWidgets import QWidget + + class MockJobSettingsWidget(QWidget): + def __init__(self, initial_settings=None, parent=None): + super().__init__(parent) + self.parameter_changed = MagicMock() + self.parameter_changed.connect = MagicMock() + + return MockJobSettingsWidget @patch("deadline.client.ui.dialogs.submit_job_to_deadline_dialog.DeadlineAuthenticationStatus") diff --git a/test/unit/deadline_client/ui/widgets/test_host_requirements_tab.py b/test/unit/deadline_client/ui/widgets/test_host_requirements_tab.py index 6001fcf09..4c216f997 100644 --- a/test/unit/deadline_client/ui/widgets/test_host_requirements_tab.py +++ b/test/unit/deadline_client/ui/widgets/test_host_requirements_tab.py @@ -59,7 +59,9 @@ def test_input_in_hardware_requirements_widget_should_be_integer_within_range(qt def test_name_in_custom_amount_widget_should_be_truncated(qtbot): - widget = CustomAmountWidget(MagicMock(), 1) + parent = CustomRequirementsWidget() + qtbot.addWidget(parent) + widget = CustomAmountWidget(MagicMock(), 1, parent) qtbot.addWidget(widget) invalid_str = "a" * (AMOUNT_NAME_MAX_LENGTH + 1) @@ -68,7 +70,9 @@ def test_name_in_custom_amount_widget_should_be_truncated(qtbot): def test_name_in_custom_amount_widget_should_not_allow_invalid_chars(qtbot): - widget = CustomAmountWidget(MagicMock(), 1) + parent = CustomRequirementsWidget() + qtbot.addWidget(parent) + widget = CustomAmountWidget(MagicMock(), 1, parent) qtbot.addWidget(widget) invalid_str = "" @@ -77,7 +81,9 @@ def test_name_in_custom_amount_widget_should_not_allow_invalid_chars(qtbot): def test_name_in_custom_amount_widget_should_allow_identifiers(qtbot): - widget = CustomAmountWidget(MagicMock(), 1) + parent = CustomRequirementsWidget() + qtbot.addWidget(parent) + widget = CustomAmountWidget(MagicMock(), 1, parent) qtbot.addWidget(widget) valid_identifier = "a" + (".a" * math.floor((AMOUNT_NAME_MAX_LENGTH - 1) / 2)) @@ -86,7 +92,9 @@ def test_name_in_custom_amount_widget_should_allow_identifiers(qtbot): def test_name_in_custom_amount_widget_does_not_allow_invalid_identifiers(qtbot): - widget = CustomAmountWidget(MagicMock(), 1) + parent = CustomRequirementsWidget() + qtbot.addWidget(parent) + widget = CustomAmountWidget(MagicMock(), 1, parent) qtbot.addWidget(widget) valid_identifier = "a" @@ -97,7 +105,9 @@ def test_name_in_custom_amount_widget_does_not_allow_invalid_identifiers(qtbot): def test_name_in_custom_amount_widget_should_not_allow_missing_identifiers(qtbot): - widget = CustomAmountWidget(MagicMock(), 1) + parent = CustomRequirementsWidget() + qtbot.addWidget(parent) + widget = CustomAmountWidget(MagicMock(), 1, parent) qtbot.addWidget(widget) missing_identifier = "a..a" @@ -106,7 +116,9 @@ def test_name_in_custom_amount_widget_should_not_allow_missing_identifiers(qtbot def test_name_in_custom_amount_widget_should_not_allow_reserved_first_identifier(qtbot): - widget = CustomAmountWidget(MagicMock(), 1) + parent = CustomRequirementsWidget() + qtbot.addWidget(parent) + widget = CustomAmountWidget(MagicMock(), 1, parent) qtbot.addWidget(widget) for reserved_identifier in RESERVED_FIRST_IDENTIFIERS: @@ -122,7 +134,9 @@ def test_name_in_custom_amount_widget_should_not_allow_reserved_first_identifier def test_value_in_custom_amount_widget_should_be_integer_within_range(qtbot): - widget = CustomAmountWidget(MagicMock(), 1) + parent = CustomRequirementsWidget() + qtbot.addWidget(parent) + widget = CustomAmountWidget(MagicMock(), 1, parent) qtbot.addWidget(widget) assert widget.min_spin_box.min == 0 @@ -235,7 +249,9 @@ def test_name_in_custom_attribute_widget_should_not_allow_reserved_first_identif def test_custom_amount_widget_includes_zero_minimum(qtbot): - widget = CustomAmountWidget(MagicMock(), 1) + parent = CustomRequirementsWidget() + qtbot.addWidget(parent) + widget = CustomAmountWidget(MagicMock(), 1, parent) qtbot.addWidget(widget) widget.name_line_edit.setText("test.amount") @@ -250,7 +266,9 @@ def test_custom_amount_widget_includes_zero_minimum(qtbot): def test_custom_amount_widget_includes_zero_maximum(qtbot): - widget = CustomAmountWidget(MagicMock(), 1) + parent = CustomRequirementsWidget() + qtbot.addWidget(parent) + widget = CustomAmountWidget(MagicMock(), 1, parent) qtbot.addWidget(widget) widget.name_line_edit.setText("test.amount") @@ -265,7 +283,9 @@ def test_custom_amount_widget_includes_zero_maximum(qtbot): def test_custom_amount_widget_includes_zero_only_minimum(qtbot): - widget = CustomAmountWidget(MagicMock(), 1) + parent = CustomRequirementsWidget() + qtbot.addWidget(parent) + widget = CustomAmountWidget(MagicMock(), 1, parent) qtbot.addWidget(widget) widget.name_line_edit.setText("test.amount") @@ -279,7 +299,9 @@ def test_custom_amount_widget_includes_zero_only_minimum(qtbot): def test_custom_amount_widget_includes_zero_only_maximum(qtbot): - widget = CustomAmountWidget(MagicMock(), 1) + parent = CustomRequirementsWidget() + qtbot.addWidget(parent) + widget = CustomAmountWidget(MagicMock(), 1, parent) qtbot.addWidget(widget) widget.name_line_edit.setText("test.amount") diff --git a/test/unit/deadline_client/ui/widgets/test_shared_job_settings_tab.py b/test/unit/deadline_client/ui/widgets/test_shared_job_settings_tab.py index 477503dc9..7244b3872 100644 --- a/test/unit/deadline_client/ui/widgets/test_shared_job_settings_tab.py +++ b/test/unit/deadline_client/ui/widgets/test_shared_job_settings_tab.py @@ -1,9 +1,14 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. import pytest +from typing import Generator +from unittest.mock import patch, MagicMock try: - from deadline.client.ui.widgets.shared_job_settings_tab import SharedJobSettingsWidget + from deadline.client.ui.widgets.shared_job_settings_tab import ( + SharedJobSettingsWidget, + DeadlineCloudSettingsWidget, + ) from deadline.client.ui.dataclasses import JobBundleSettings except ImportError: # The tests in this file should be skipped if Qt UI related modules cannot be loaded @@ -80,3 +85,365 @@ def test_max_worker_count_should_be_integer_within_range( ): shared_job_settings_tab.shared_job_properties_box.max_worker_count_box.setValue(-1) assert shared_job_settings_tab.shared_job_properties_box.max_worker_count_box.value() == 1 + + +# Tests for DeadlineCloudSettingsWidget farm/queue combo boxes + + +MOCK_CONFIG_PATH = "deadline.client.ui.widgets.shared_job_settings_tab.config_file.read_config" +MOCK_SET_SETTING_PATH = "deadline.client.ui.widgets.shared_job_settings_tab.set_setting" +MOCK_COMBO_BOX_GET_SETTING_PATH = ( + "deadline.client.ui.widgets.deadline_cloud_resource_combo_boxes.config_file.get_setting" +) + + +@pytest.fixture(scope="function") +def deadline_cloud_settings_widget(qtbot) -> Generator[DeadlineCloudSettingsWidget, None, None]: + """Fixture for DeadlineCloudSettingsWidget with mocked config.""" + with patch(MOCK_CONFIG_PATH) as mock_config, patch( + MOCK_COMBO_BOX_GET_SETTING_PATH + ) as mock_get_setting: + mock_config.return_value = MagicMock() + mock_get_setting.return_value = "" # Return empty string for all get_setting calls + widget = DeadlineCloudSettingsWidget() + qtbot.addWidget(widget) + yield widget + + +def test_farm_selection_updates_config(deadline_cloud_settings_widget: DeadlineCloudSettingsWidget): + """Test that selecting a farm updates the config setting.""" + widget = deadline_cloud_settings_widget + test_farm_id = "farm-test123" + + # Add two items so selecting the second one triggers currentIndexChanged + widget.farm_box.box.addItem("Default Farm", "farm-default") + widget.farm_box.box.addItem("Test Farm", test_farm_id) + + with patch(MOCK_SET_SETTING_PATH) as mock_set_setting: + widget.farm_box.box.setCurrentIndex(1) + mock_set_setting.assert_called_with("defaults.farm_id", test_farm_id) + + +def test_queue_selection_updates_config( + deadline_cloud_settings_widget: DeadlineCloudSettingsWidget, +): + """Test that selecting a queue updates the config setting.""" + widget = deadline_cloud_settings_widget + test_queue_id = "queue-test456" + + widget.queue_box.box.addItem("Default Queue", "queue-default") + widget.queue_box.box.addItem("Test Queue", test_queue_id) + + with patch(MOCK_SET_SETTING_PATH) as mock_set_setting: + widget.queue_box.box.setCurrentIndex(1) + mock_set_setting.assert_called_with("defaults.queue_id", test_queue_id) + + +def test_farm_change_triggers_parent_refresh( + deadline_cloud_settings_widget: DeadlineCloudSettingsWidget, +): + """Test that changing farm triggers _notify_parent_refresh (which cascades to refresh lists).""" + widget = deadline_cloud_settings_widget + + widget.farm_box.box.addItem("Default Farm", "farm-default") + widget.farm_box.box.addItem("Test Farm", "farm-test789") + + with patch.object(widget, "_notify_parent_refresh") as mock_notify, patch( + MOCK_SET_SETTING_PATH + ): + widget.farm_box.box.setCurrentIndex(1) + mock_notify.assert_called_once() + + +def test_refresh_setting_controls_updates_combo_boxes( + deadline_cloud_settings_widget: DeadlineCloudSettingsWidget, +): + """Test that refresh_setting_controls updates all combo boxes.""" + widget = deadline_cloud_settings_widget + + with patch.object(widget.farm_box, "set_config") as mock_farm_config, patch.object( + widget.queue_box, "set_config" + ) as mock_queue_config, patch.object( + widget.storage_profile_box, "set_config" + ) as mock_sp_config, patch.object( + widget.farm_box, "refresh_selected_id" + ) as mock_farm_refresh, patch.object( + widget.queue_box, "refresh_selected_id" + ) as mock_queue_refresh, patch.object( + widget.storage_profile_box, "refresh_selected_id" + ) as mock_sp_refresh, patch.object( + widget.farm_box, "refresh_list" + ) as mock_farm_list, patch.object( + widget.queue_box, "refresh_list" + ) as mock_queue_list, patch.object(widget.storage_profile_box, "refresh_list") as mock_sp_list: + widget.refresh_setting_controls(deadline_authorized=True) + + mock_farm_config.assert_called_once() + mock_queue_config.assert_called_once() + mock_sp_config.assert_called_once() + mock_farm_refresh.assert_called_once() + mock_queue_refresh.assert_called_once() + mock_sp_refresh.assert_called_once() + mock_farm_list.assert_called_once() + mock_queue_list.assert_called_once() + mock_sp_list.assert_called_once() + + +def test_refresh_setting_controls_skips_list_refresh_when_unauthorized( + deadline_cloud_settings_widget: DeadlineCloudSettingsWidget, +): + """Test that refresh_setting_controls skips list refresh when not authorized.""" + widget = deadline_cloud_settings_widget + + with patch.object(widget.farm_box, "set_config"), patch.object( + widget.queue_box, "set_config" + ), patch.object(widget.storage_profile_box, "set_config"), patch.object( + widget.farm_box, "refresh_selected_id" + ), patch.object(widget.queue_box, "refresh_selected_id"), patch.object( + widget.storage_profile_box, "refresh_selected_id" + ), patch.object(widget.farm_box, "refresh_list") as mock_farm_list, patch.object( + widget.queue_box, "refresh_list" + ) as mock_queue_list, patch.object(widget.storage_profile_box, "refresh_list") as mock_sp_list: + widget.refresh_setting_controls(deadline_authorized=False) + + mock_farm_list.assert_not_called() + mock_queue_list.assert_not_called() + mock_sp_list.assert_not_called() + + +def test_storage_profile_hidden_by_default( + deadline_cloud_settings_widget: DeadlineCloudSettingsWidget, +): + """Test that storage profile selector is hidden by default.""" + widget = deadline_cloud_settings_widget + + assert not widget.storage_profile_box.isVisibleTo(widget) + assert not widget.storage_profile_box_label.isVisibleTo(widget) + + +def test_storage_profile_shown_when_profiles_available( + deadline_cloud_settings_widget: DeadlineCloudSettingsWidget, +): + """Test that storage profile selector is shown when profiles are available.""" + widget = deadline_cloud_settings_widget + + widget.storage_profile_box.box.addItem("Placeholder", "") + widget.storage_profile_box.box.addItem("Test Profile", "sp-test123") + + # Use isVisibleTo(parent) since the widget isn't shown in a window during tests + assert widget.storage_profile_box.isVisibleTo(widget) + assert widget.storage_profile_box_label.isVisibleTo(widget) + + +def test_storage_profile_shown_when_none_selected_sorts_first( + deadline_cloud_settings_widget: DeadlineCloudSettingsWidget, +): + """Test that storage profile is visible even when sorts to first position. + + This tests the list_update signal path which is used during async refresh. + The list_update signal handler populates the combo box with block_signals, + so model signals don't fire - we need to explicitly trigger visibility update. + """ + widget = deadline_cloud_settings_widget + + items_list = [ + ("", ""), + ("Profile A", "sp-profile-a"), + ("Profile B", "sp-profile-b"), + ] + + # Emit with refresh_id=0 to match the widget's initial __refresh_id + widget.storage_profile_box.list_update.emit(0, items_list) + + assert widget.storage_profile_box.isVisibleTo(widget) + assert widget.storage_profile_box_label.isVisibleTo(widget) + + +def test_storage_profile_selection_updates_config( + deadline_cloud_settings_widget: DeadlineCloudSettingsWidget, +): + """Test that selecting a storage profile updates the config setting.""" + widget = deadline_cloud_settings_widget + test_profile_id = "sp-test123" + + widget.storage_profile_box.box.addItem("Default Profile", "sp-default") + widget.storage_profile_box.box.addItem("Test Profile", test_profile_id) + + with patch(MOCK_SET_SETTING_PATH) as mock_set_setting: + widget.storage_profile_box.box.setCurrentIndex(1) + mock_set_setting.assert_called_with("settings.storage_profile_id", test_profile_id) + + +def test_queue_change_triggers_parent_refresh( + deadline_cloud_settings_widget: DeadlineCloudSettingsWidget, +): + """Test that changing queue triggers _notify_parent_refresh (which cascades to refresh lists).""" + widget = deadline_cloud_settings_widget + + widget.queue_box.box.addItem("Default Queue", "queue-default") + widget.queue_box.box.addItem("Test Queue", "queue-test789") + + with patch.object(widget, "_notify_parent_refresh") as mock_notify, patch( + MOCK_SET_SETTING_PATH + ): + widget.queue_box.box.setCurrentIndex(1) + mock_notify.assert_called_once() + + +# Tests for SharedJobSettingsWidget.refresh_queue_parameters + + +def test_refresh_queue_parameters_triggers_on_farm_change( + shared_job_settings_tab: SharedJobSettingsWidget, +): + widget = shared_job_settings_tab + + # Set initial farm and queue IDs + widget.farm_id = "farm-initial" + widget.queue_id = "queue-initial" + + with patch( + "deadline.client.ui.widgets.shared_job_settings_tab.get_setting" + ) as mock_get_setting: + # Simulate farm change (different farm_id, same queue_id) + mock_get_setting.side_effect = lambda key: { + "defaults.farm_id": "farm-new", + "defaults.queue_id": "queue-initial", + }.get(key) + + with patch.object(widget.queue_parameters_box, "rebuild_ui") as mock_rebuild, patch.object( + widget, "_start_load_queue_parameters_thread" + ) as mock_start_thread: + widget.refresh_queue_parameters() + + mock_rebuild.assert_called_once_with( + async_loading_state="Reloading Queue Environments..." + ) + mock_start_thread.assert_called_once() + + +def test_refresh_queue_parameters_triggers_on_queue_change( + shared_job_settings_tab: SharedJobSettingsWidget, +): + widget = shared_job_settings_tab + + # Set initial farm and queue IDs + widget.farm_id = "farm-initial" + widget.queue_id = "queue-initial" + + with patch( + "deadline.client.ui.widgets.shared_job_settings_tab.get_setting" + ) as mock_get_setting: + # Simulate queue change (same farm_id, different queue_id) + mock_get_setting.side_effect = lambda key: { + "defaults.farm_id": "farm-initial", + "defaults.queue_id": "queue-new", + }.get(key) + + with patch.object(widget.queue_parameters_box, "rebuild_ui") as mock_rebuild, patch.object( + widget, "_start_load_queue_parameters_thread" + ) as mock_start_thread: + widget.refresh_queue_parameters() + + mock_rebuild.assert_called_once_with( + async_loading_state="Reloading Queue Environments..." + ) + mock_start_thread.assert_called_once() + + +def test_refresh_queue_parameters_no_refresh_when_unchanged( + shared_job_settings_tab: SharedJobSettingsWidget, +): + widget = shared_job_settings_tab + + # Set initial farm and queue IDs + widget.farm_id = "farm-same" + widget.queue_id = "queue-same" + + # Clear the async loading state so it doesn't trigger refresh + widget.queue_parameters_box.async_loading_state = "" + + with patch( + "deadline.client.ui.widgets.shared_job_settings_tab.get_setting" + ) as mock_get_setting: + # Same farm and queue IDs + mock_get_setting.side_effect = lambda key: { + "defaults.farm_id": "farm-same", + "defaults.queue_id": "queue-same", + }.get(key) + + with patch.object(widget.queue_parameters_box, "rebuild_ui") as mock_rebuild, patch.object( + widget, "_start_load_queue_parameters_thread" + ) as mock_start_thread: + widget.refresh_queue_parameters() + + mock_rebuild.assert_not_called() + mock_start_thread.assert_not_called() + + +# Tests verifying refactored module locations and public API surface + + +def test_combo_boxes_importable_from_new_module(): + """Verify combo box classes are importable from their new dedicated module.""" + from deadline.client.ui.widgets.deadline_cloud_resource_combo_boxes import ( + DeadlineFarmListComboBox, + DeadlineQueueListComboBox, + DeadlineStorageProfileNameListComboBox, + ) + + assert DeadlineFarmListComboBox is not None + assert DeadlineQueueListComboBox is not None + assert DeadlineStorageProfileNameListComboBox is not None + + +def test_deadline_cloud_settings_widget_importable_from_widgets_package(): + """Verify DeadlineCloudSettingsWidget is importable from the public widgets package.""" + from deadline.client.ui.widgets import DeadlineCloudSettingsWidget as WidgetFromPackage + from deadline.client.ui.widgets.shared_job_settings_tab import ( + DeadlineCloudSettingsWidget as WidgetFromModule, + ) + + assert WidgetFromPackage is WidgetFromModule + + +def test_farm_change_propagates_config_to_all_boxes( + deadline_cloud_settings_widget: DeadlineCloudSettingsWidget, +): + """Test that changing farm propagates updated config to farm, queue, and storage profile boxes.""" + widget = deadline_cloud_settings_widget + + widget.farm_box.box.addItem("Default Farm", "farm-default") + widget.farm_box.box.addItem("Test Farm", "farm-test") + + with patch(MOCK_SET_SETTING_PATH), patch.object( + widget.farm_box, "set_config" + ) as mock_farm_cfg, patch.object( + widget.queue_box, "set_config" + ) as mock_queue_cfg, patch.object(widget.storage_profile_box, "set_config") as mock_sp_cfg: + widget.farm_box.box.setCurrentIndex(1) + + mock_farm_cfg.assert_called_once() + mock_queue_cfg.assert_called_once() + mock_sp_cfg.assert_called_once() + + +def test_queue_change_propagates_config_to_all_boxes( + deadline_cloud_settings_widget: DeadlineCloudSettingsWidget, +): + """Test that changing queue propagates updated config to farm, queue, and storage profile boxes.""" + widget = deadline_cloud_settings_widget + + widget.queue_box.box.addItem("Default Queue", "queue-default") + widget.queue_box.box.addItem("Test Queue", "queue-test") + + with patch(MOCK_SET_SETTING_PATH), patch.object( + widget.farm_box, "set_config" + ) as mock_farm_cfg, patch.object( + widget.queue_box, "set_config" + ) as mock_queue_cfg, patch.object(widget.storage_profile_box, "set_config") as mock_sp_cfg: + widget.queue_box.box.setCurrentIndex(1) + + mock_farm_cfg.assert_called_once() + mock_queue_cfg.assert_called_once() + mock_sp_cfg.assert_called_once()