From 99dfd4259f16565817907038dedbd78f0e31e7ac Mon Sep 17 00:00:00 2001 From: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:55:34 -0700 Subject: [PATCH 1/6] fix: connect gui-submit --parameter validation to live queue signal bundle gui-submit --parameter hard-crashed with AttributeError before the dialog could show. show_job_bundle_submitter connected the CLI-parameter validation callback to shared_job_settings._queue_parameters_update, a signal that no longer exists: queue-parameter loading moved to DeadlineUIController, which emits queue_parameters_updated(list). The SharedJobSettingsWidget only exposes parameter_changed / valid_parameters, and its child settings widget exposes selection_changed -- none carry the loaded queue parameters. Connect the callback to shared_job_settings._controller.queue_parameters_updated (the signal the widget itself consumes) and update the callback signature to match its list payload (dropping the stale refresh_id arg from the removed Signal(int, list)). Add a GUI regression test that wires the --parameter path with a real SharedJobSettingsWidget (a MagicMock dialog masked the crash) and asserts no AttributeError plus that emitting queue_parameters_updated runs the validator with the emitted parameter list. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com> --- .../client/ui/job_bundle_submitter.py | 11 +- .../gui/test_gui_submitter_cli_parameters.py | 144 ++++++++++++++++++ 2 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 test/unit/deadline_client/ui/gui/test_gui_submitter_cli_parameters.py diff --git a/src/deadline/client/ui/job_bundle_submitter.py b/src/deadline/client/ui/job_bundle_submitter.py index d46d51618..3119b4ef7 100644 --- a/src/deadline/client/ui/job_bundle_submitter.py +++ b/src/deadline/client/ui/job_bundle_submitter.py @@ -411,18 +411,17 @@ def on_create_job_bundle_callback( ) if job_parameters: - # We want to validate the job parameters after the queue parameters are loaded. - # Connect a parameter validation function to the queue parameter loading completion - def validate_parameters_after_queue_load(refresh_id: int, queue_parameters: list): + + def validate_parameters_after_queue_load(queue_parameters: list): """Validate CLI parameters against loaded queue parameters and set parameter values""" if not _validate_and_warn_about_parameters( job_parameters, initial_settings.parameters, queue_parameters, submitter_dialog ): - # User chose to cancel, close the dialog + # User cancelled at the validation warning. submitter_dialog.close() - # Connect to the queue parameters update signal - submitter_dialog.shared_job_settings._queue_parameters_update.connect( + # Validate CLI params once the controller finishes loading queue params. + submitter_dialog.shared_job_settings._controller.queue_parameters_updated.connect( validate_parameters_after_queue_load ) diff --git a/test/unit/deadline_client/ui/gui/test_gui_submitter_cli_parameters.py b/test/unit/deadline_client/ui/gui/test_gui_submitter_cli_parameters.py new file mode 100644 index 000000000..b14d1ea5d --- /dev/null +++ b/test/unit/deadline_client/ui/gui/test_gui_submitter_cli_parameters.py @@ -0,0 +1,144 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +"""GUI test for the ``bundle gui-submit --parameter`` validation wiring. + +Verifies ``show_job_bundle_submitter`` connects the CLI ``--parameter`` validation callback to +the controller's ``queue_parameters_updated`` signal. Uses a real ``SharedJobSettingsWidget`` +(not a MagicMock) so the connection is actually resolved — a mocked dialog would let a broken +connect pass silently. +""" + +import os + +from unittest.mock import MagicMock, patch + +import pytest + +from deadline.client.ui.controllers._deadline_controller import DeadlineUIController +from deadline.client.ui.controllers._thread_pool import DeadlineThreadPool +from deadline.client.ui.dataclasses import JobBundleSettings +from deadline.client.ui.job_bundle_submitter import show_job_bundle_submitter +from deadline.client.ui.widgets.shared_job_settings_tab import SharedJobSettingsWidget + +MODULE = "deadline.client.ui.job_bundle_submitter" + + +@pytest.fixture(autouse=True) +def _reset_singletons(): + """Reset UI singletons before/after each test so the controller is clean.""" + DeadlineUIController.resetInstance() + DeadlineThreadPool.reset() + yield + DeadlineUIController.resetInstance() + DeadlineThreadPool.shutdown(wait_for_done=True, timeout_ms=2000) + DeadlineThreadPool.reset() + + +def _make_bundle(tmp_path): + bundle_dir = str(tmp_path / "bundle") + os.makedirs(bundle_dir) + with open(os.path.join(bundle_dir, "template.yaml"), "w") as f: + f.write("name: Bundle Job\nsteps: []\n") + return bundle_dir + + +class TestGuiSubmitCliParameterValidationWiring: + """The --parameter validation callback is wired to the real queue-parameters signal.""" + + def _run(self, qtbot, tmp_path, *, job_parameters, validate_side_effect, emit_after=None): + """Drive show_job_bundle_submitter with a REAL SharedJobSettingsWidget standing in for + the dialog's shared_job_settings. No farm/queue is configured (fresh_deadline_config), + so the widget does not kick off a background load. + + If ``emit_after`` is provided, the controller's queue_parameters_updated signal is + emitted with it *inside* the patched context so the connected validation callback runs + against the mocked ``_validate_and_warn_about_parameters``. Returns + (dialog, widget, validate_mock).""" + bundle_dir = _make_bundle(tmp_path) + + settings = JobBundleSettings(input_job_bundle_dir=bundle_dir, name="n") + real_widget = SharedJobSettingsWidget( + initial_settings=settings, initial_shared_parameter_values={} + ) + qtbot.addWidget(real_widget) + + class FakeDialog: + def __init__(self, **kwargs): + self.shared_job_settings = real_widget + self.closed = False + + def show(self): + pass + + def close(self): + self.closed = True + + template = {"name": "Bundle Job", "steps": []} + validate_mock = MagicMock(side_effect=validate_side_effect) + + with ( + patch(f"{MODULE}.validate_directory_symlink_containment"), + patch( + f"{MODULE}.read_yaml_or_json_object", + side_effect=lambda _dir, name, *a, **k: template if name == "template" else None, + ), + patch(f"{MODULE}.read_job_bundle_parameters", return_value=[]), + patch(f"{MODULE}.run_pre_gui_hooks", return_value={}), + patch(f"{MODULE}.SubmitJobToDeadlineDialog", side_effect=FakeDialog), + patch(f"{MODULE}.QApplication"), + patch(f"{MODULE}.QMessageBox"), + patch(f"{MODULE}._get_setting", side_effect=lambda name, config=None: "false"), + patch(f"{MODULE}._config_file") as cfg, + patch(f"{MODULE}._validate_and_warn_about_parameters", validate_mock), + ): + cfg.str2bool.side_effect = lambda v: str(v).lower() == "true" + dialog = show_job_bundle_submitter( + input_job_bundle_dir=bundle_dir, job_parameters=job_parameters + ) + # Emitting inside the patched block keeps _validate_and_warn_about_parameters mocked + # when the connected callback fires. + if emit_after is not None: + real_widget._controller.queue_parameters_updated.emit(emit_after) + return dialog, real_widget, validate_mock + + def test_parameter_path_does_not_raise_attribute_error( + self, qtbot, fresh_deadline_config, tmp_path + ): + """Wiring the --parameter path must not raise AttributeError (the C6 hard crash).""" + dialog, _widget, _validate = self._run( + qtbot, + tmp_path, + job_parameters=[{"name": "Foo", "value": "bar"}], + validate_side_effect=lambda *a, **k: True, + ) + assert dialog is not None + + def test_queue_parameters_update_invokes_validator_with_param_list( + self, qtbot, fresh_deadline_config, tmp_path + ): + """Emitting the controller's queue_parameters_updated signal runs the validator with the + emitted queue-parameter list.""" + queue_parameters = [{"name": "CondaChannels", "type": "STRING"}] + _dialog, _widget, validate_mock = self._run( + qtbot, + tmp_path, + job_parameters=[{"name": "Foo", "value": "bar"}], + validate_side_effect=lambda *a, **k: True, + emit_after=queue_parameters, + ) + + validate_mock.assert_called_once() + # Signature: (job_parameters, job_template_parameters, queue_parameters, parent_widget) + assert validate_mock.call_args.args[2] == queue_parameters + + def test_validator_cancel_closes_dialog(self, qtbot, fresh_deadline_config, tmp_path): + """When the validator returns False (user cancels), the dialog is closed.""" + dialog, _widget, _validate = self._run( + qtbot, + tmp_path, + job_parameters=[{"name": "Foo", "value": "bar"}], + validate_side_effect=lambda *a, **k: False, + emit_after=[{"name": "Foo"}], + ) + + assert dialog.closed is True From 29590faebe13ae8c230e0233be97f9cb1f2a03ef Mon Sep 17 00:00:00 2001 From: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:04:41 -0700 Subject: [PATCH 2/6] fix: make gui-submit --parameter validation single-shot and dialog-scoped The controller is a global singleton, so the previous connection had two lifecycle problems flagged in review: - queue_parameters_updated also fires with [] to clear stale state (farm/ queue switch, fetch error, nothing selected), which would spuriously flag queue parameters as unrecognized and could close the dialog. - The connection was never torn down, so the closure over the dialog kept firing after the dialog closed (and accumulated across dialogs). Now the callback ignores clearing ([]) emissions, disconnects itself after the first real load, and is also disconnected on dialog destruction. Adds tests for empty-emission skip, single-shot behavior, and teardown. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com> --- .../client/ui/job_bundle_submitter.py | 23 ++++++- .../gui/test_gui_submitter_cli_parameters.py | 69 +++++++++++++++++-- 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/src/deadline/client/ui/job_bundle_submitter.py b/src/deadline/client/ui/job_bundle_submitter.py index 3119b4ef7..faf3b1594 100644 --- a/src/deadline/client/ui/job_bundle_submitter.py +++ b/src/deadline/client/ui/job_bundle_submitter.py @@ -411,9 +411,26 @@ def on_create_job_bundle_callback( ) if job_parameters: + # The controller is a global singleton that outlives this dialog, and its + # queue_parameters_updated signal also fires with [] to clear stale state + # (farm/queue switch, fetch error, nothing selected). Validate single-shot + # against the first real (non-empty) load, then disconnect so the closure + # over submitter_dialog can't fire against a closed dialog later. + controller = submitter_dialog.shared_job_settings._controller + + def disconnect_validation_callback(): + try: + controller.queue_parameters_updated.disconnect(validate_parameters_after_queue_load) + except (TypeError, RuntimeError): + # Already disconnected (validation ran before the dialog was destroyed). + pass def validate_parameters_after_queue_load(queue_parameters: list): """Validate CLI parameters against loaded queue parameters and set parameter values""" + if not queue_parameters: + # A clearing emission, not a completed load. Keep waiting. + return + disconnect_validation_callback() if not _validate_and_warn_about_parameters( job_parameters, initial_settings.parameters, queue_parameters, submitter_dialog ): @@ -421,9 +438,9 @@ def validate_parameters_after_queue_load(queue_parameters: list): submitter_dialog.close() # Validate CLI params once the controller finishes loading queue params. - submitter_dialog.shared_job_settings._controller.queue_parameters_updated.connect( - validate_parameters_after_queue_load - ) + controller.queue_parameters_updated.connect(validate_parameters_after_queue_load) + # If the dialog goes away before any load completes, tear the connection down. + submitter_dialog.destroyed.connect(disconnect_validation_callback) submitter_dialog.show() return submitter_dialog diff --git a/test/unit/deadline_client/ui/gui/test_gui_submitter_cli_parameters.py b/test/unit/deadline_client/ui/gui/test_gui_submitter_cli_parameters.py index b14d1ea5d..230835369 100644 --- a/test/unit/deadline_client/ui/gui/test_gui_submitter_cli_parameters.py +++ b/test/unit/deadline_client/ui/gui/test_gui_submitter_cli_parameters.py @@ -14,6 +14,8 @@ import pytest +from qtpy.QtCore import QObject # type: ignore + from deadline.client.ui.controllers._deadline_controller import DeadlineUIController from deadline.client.ui.controllers._thread_pool import DeadlineThreadPool from deadline.client.ui.dataclasses import JobBundleSettings @@ -51,8 +53,9 @@ def _run(self, qtbot, tmp_path, *, job_parameters, validate_side_effect, emit_af so the widget does not kick off a background load. If ``emit_after`` is provided, the controller's queue_parameters_updated signal is - emitted with it *inside* the patched context so the connected validation callback runs - against the mocked ``_validate_and_warn_about_parameters``. Returns + emitted *inside* the patched context so the connected validation callback runs + against the mocked ``_validate_and_warn_about_parameters``. It may be a single + queue-parameter list, or a list of such lists to emit in sequence. Returns (dialog, widget, validate_mock).""" bundle_dir = _make_bundle(tmp_path) @@ -62,8 +65,11 @@ def _run(self, qtbot, tmp_path, *, job_parameters, validate_side_effect, emit_af ) qtbot.addWidget(real_widget) - class FakeDialog: + class FakeDialog(QObject): + # QObject supplies the real ``destroyed`` signal the production code + # connects its cleanup to. def __init__(self, **kwargs): + super().__init__() self.shared_job_settings = real_widget self.closed = False @@ -98,7 +104,11 @@ def close(self): # Emitting inside the patched block keeps _validate_and_warn_about_parameters mocked # when the connected callback fires. if emit_after is not None: - real_widget._controller.queue_parameters_updated.emit(emit_after) + emissions = ( + emit_after if emit_after and isinstance(emit_after[0], list) else [emit_after] + ) + for emission in emissions: + real_widget._controller.queue_parameters_updated.emit(emission) return dialog, real_widget, validate_mock def test_parameter_path_does_not_raise_attribute_error( @@ -142,3 +152,54 @@ def test_validator_cancel_closes_dialog(self, qtbot, fresh_deadline_config, tmp_ ) assert dialog.closed is True + + def test_empty_emission_does_not_invoke_validator(self, qtbot, fresh_deadline_config, tmp_path): + """A clearing emission ([]) — e.g. farm/queue switch or fetch error — must not run + validation (which would spuriously flag queue params as unrecognized).""" + _dialog, _widget, validate_mock = self._run( + qtbot, + tmp_path, + job_parameters=[{"name": "Foo", "value": "bar"}], + validate_side_effect=lambda *a, **k: True, + emit_after=[], + ) + + validate_mock.assert_not_called() + + def test_validator_runs_single_shot_on_first_nonempty_load( + self, qtbot, fresh_deadline_config, tmp_path + ): + """Validation waits through clearing emissions, runs once on the first non-empty load, + and disconnects so later reloads don't re-validate.""" + queue_parameters = [{"name": "CondaChannels", "type": "STRING"}] + _dialog, _widget, validate_mock = self._run( + qtbot, + tmp_path, + job_parameters=[{"name": "Foo", "value": "bar"}], + validate_side_effect=lambda *a, **k: True, + emit_after=[[], queue_parameters, [{"name": "Other"}]], + ) + + validate_mock.assert_called_once() + assert validate_mock.call_args.args[2] == queue_parameters + + def test_dialog_destroyed_disconnects_validator(self, qtbot, fresh_deadline_config, tmp_path): + """Destroying the dialog before queue params load tears down the connection, so the + stale closure never fires against the singleton controller.""" + dialog, widget, validate_mock = self._run( + qtbot, + tmp_path, + job_parameters=[{"name": "Foo", "value": "bar"}], + validate_side_effect=lambda *a, **k: True, + ) + + # Simulate the dialog being destroyed before any load completes. + dialog.destroyed.emit() + + with patch( + "deadline.client.ui.job_bundle_submitter._validate_and_warn_about_parameters", + validate_mock, + ): + widget._controller.queue_parameters_updated.emit([{"name": "CondaChannels"}]) + + validate_mock.assert_not_called() From f84a53a06125225ac319bfcae58837dc5699cdd7 Mon Sep 17 00:00:00 2001 From: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:35:11 -0700 Subject: [PATCH 3/6] fix: gate gui-submit --parameter validation on load completion, not payload Review follow-up: gating on a non-empty payload meant a queue that genuinely has zero queue parameters would never trigger validation, so an unrecognized CLI --parameter could slip through unflagged. The controller distinguishes a real load from a clearing emission via queue_parameters_loading: it emits loading(False) immediately before queue_parameters_updated on fetch completion (success or error), while clearing emissions (farm/queue switch, nothing selected) have no such prefix. Track that and run the single-shot validation on the first completed load regardless of payload emptiness. Dialog-destroyed teardown now disconnects both signals. Adds a test that an empty-but-real load still runs the validator. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com> --- .../client/ui/job_bundle_submitter.py | 44 +++++++--- .../gui/test_gui_submitter_cli_parameters.py | 80 ++++++++++++++----- 2 files changed, 90 insertions(+), 34 deletions(-) diff --git a/src/deadline/client/ui/job_bundle_submitter.py b/src/deadline/client/ui/job_bundle_submitter.py index faf3b1594..819d77ffe 100644 --- a/src/deadline/client/ui/job_bundle_submitter.py +++ b/src/deadline/client/ui/job_bundle_submitter.py @@ -413,24 +413,41 @@ def on_create_job_bundle_callback( if job_parameters: # The controller is a global singleton that outlives this dialog, and its # queue_parameters_updated signal also fires with [] to clear stale state - # (farm/queue switch, fetch error, nothing selected). Validate single-shot - # against the first real (non-empty) load, then disconnect so the closure - # over submitter_dialog can't fire against a closed dialog later. + # (farm/queue switch, nothing selected). A queue may genuinely have zero + # queue parameters, so we can't gate on payload non-emptiness — that would + # never validate against an empty-but-real load. Instead we recognize a + # completed load by the queue_parameters_loading(False) emission that the + # controller sends immediately before queue_parameters_updated on fetch + # completion (success or error); clearing emissions have no such prefix. + # Both emits happen consecutively in the same main-thread slot, so nothing + # can interleave between them. Validate single-shot on the first completed + # load, then disconnect so the closure over submitter_dialog can't fire + # against a closed dialog later. controller = submitter_dialog.shared_job_settings._controller + # Mutable cell: True right after a load finishes (loading(False)), reset + # when a new load starts (loading(True)). + load_finished = [False] - def disconnect_validation_callback(): - try: - controller.queue_parameters_updated.disconnect(validate_parameters_after_queue_load) - except (TypeError, RuntimeError): - # Already disconnected (validation ran before the dialog was destroyed). - pass + def track_queue_parameters_loading(is_loading: bool): + load_finished[0] = not is_loading + + def disconnect_validation_signals(): + for signal, slot in ( + (controller.queue_parameters_loading, track_queue_parameters_loading), + (controller.queue_parameters_updated, validate_parameters_after_queue_load), + ): + try: + signal.disconnect(slot) + except (TypeError, RuntimeError): + # Already disconnected (validation ran before the dialog was destroyed). + pass def validate_parameters_after_queue_load(queue_parameters: list): """Validate CLI parameters against loaded queue parameters and set parameter values""" - if not queue_parameters: + if not load_finished[0]: # A clearing emission, not a completed load. Keep waiting. return - disconnect_validation_callback() + disconnect_validation_signals() if not _validate_and_warn_about_parameters( job_parameters, initial_settings.parameters, queue_parameters, submitter_dialog ): @@ -438,9 +455,10 @@ def validate_parameters_after_queue_load(queue_parameters: list): submitter_dialog.close() # Validate CLI params once the controller finishes loading queue params. + controller.queue_parameters_loading.connect(track_queue_parameters_loading) controller.queue_parameters_updated.connect(validate_parameters_after_queue_load) - # If the dialog goes away before any load completes, tear the connection down. - submitter_dialog.destroyed.connect(disconnect_validation_callback) + # If the dialog goes away before any load completes, tear the connections down. + submitter_dialog.destroyed.connect(disconnect_validation_signals) submitter_dialog.show() return submitter_dialog diff --git a/test/unit/deadline_client/ui/gui/test_gui_submitter_cli_parameters.py b/test/unit/deadline_client/ui/gui/test_gui_submitter_cli_parameters.py index 230835369..b926a73bf 100644 --- a/test/unit/deadline_client/ui/gui/test_gui_submitter_cli_parameters.py +++ b/test/unit/deadline_client/ui/gui/test_gui_submitter_cli_parameters.py @@ -52,11 +52,18 @@ def _run(self, qtbot, tmp_path, *, job_parameters, validate_side_effect, emit_af the dialog's shared_job_settings. No farm/queue is configured (fresh_deadline_config), so the widget does not kick off a background load. - If ``emit_after`` is provided, the controller's queue_parameters_updated signal is - emitted *inside* the patched context so the connected validation callback runs - against the mocked ``_validate_and_warn_about_parameters``. It may be a single - queue-parameter list, or a list of such lists to emit in sequence. Returns - (dialog, widget, validate_mock).""" + If ``emit_after`` is provided, controller signals are emitted *inside* the patched + context so the connected validation callback runs against the mocked + ``_validate_and_warn_about_parameters``. It is a list of (kind, payload) tuples + emitted in sequence, mirroring the controller's real emission patterns: + + - ("load", payload): a completed fetch — queue_parameters_loading(True), then + queue_parameters_loading(False), then queue_parameters_updated(payload) + (see DeadlineUIController._on_queue_parameters_success/_error). + - ("clear", payload): a bare queue_parameters_updated(payload) with no loading + prefix, as emitted on farm/queue switch or when nothing is selected. + + Returns (dialog, widget, validate_mock).""" bundle_dir = _make_bundle(tmp_path) settings = JobBundleSettings(input_job_bundle_dir=bundle_dir, name="n") @@ -104,11 +111,15 @@ def close(self): # Emitting inside the patched block keeps _validate_and_warn_about_parameters mocked # when the connected callback fires. if emit_after is not None: - emissions = ( - emit_after if emit_after and isinstance(emit_after[0], list) else [emit_after] - ) - for emission in emissions: - real_widget._controller.queue_parameters_updated.emit(emission) + controller = real_widget._controller + for kind, payload in emit_after: + if kind == "load": + # A real fetch: loading toggles True -> False, then updated. + controller.queue_parameters_loading.emit(True) + controller.queue_parameters_loading.emit(False) + else: + assert kind == "clear" + controller.queue_parameters_updated.emit(payload) return dialog, real_widget, validate_mock def test_parameter_path_does_not_raise_attribute_error( @@ -126,15 +137,14 @@ def test_parameter_path_does_not_raise_attribute_error( def test_queue_parameters_update_invokes_validator_with_param_list( self, qtbot, fresh_deadline_config, tmp_path ): - """Emitting the controller's queue_parameters_updated signal runs the validator with the - emitted queue-parameter list.""" + """A completed queue-parameter load runs the validator with the loaded list.""" queue_parameters = [{"name": "CondaChannels", "type": "STRING"}] _dialog, _widget, validate_mock = self._run( qtbot, tmp_path, job_parameters=[{"name": "Foo", "value": "bar"}], validate_side_effect=lambda *a, **k: True, - emit_after=queue_parameters, + emit_after=[("load", queue_parameters)], ) validate_mock.assert_called_once() @@ -148,28 +158,48 @@ def test_validator_cancel_closes_dialog(self, qtbot, fresh_deadline_config, tmp_ tmp_path, job_parameters=[{"name": "Foo", "value": "bar"}], validate_side_effect=lambda *a, **k: False, - emit_after=[{"name": "Foo"}], + emit_after=[("load", [{"name": "Foo"}])], ) assert dialog.closed is True - def test_empty_emission_does_not_invoke_validator(self, qtbot, fresh_deadline_config, tmp_path): - """A clearing emission ([]) — e.g. farm/queue switch or fetch error — must not run - validation (which would spuriously flag queue params as unrecognized).""" + def test_clearing_emission_does_not_invoke_validator( + self, qtbot, fresh_deadline_config, tmp_path + ): + """A clearing emission ([] with no loading prefix) — e.g. farm/queue switch or nothing + selected — must not run validation (which would spuriously flag queue params as + unrecognized).""" _dialog, _widget, validate_mock = self._run( qtbot, tmp_path, job_parameters=[{"name": "Foo", "value": "bar"}], validate_side_effect=lambda *a, **k: True, - emit_after=[], + emit_after=[("clear", [])], ) validate_mock.assert_not_called() - def test_validator_runs_single_shot_on_first_nonempty_load( + def test_empty_but_real_load_still_invokes_validator( + self, qtbot, fresh_deadline_config, tmp_path + ): + """A queue that genuinely has zero queue parameters still validates: a completed load + (loading True -> False, then updated([])) runs the validator so an unrecognized CLI + --parameter is flagged rather than slipping through.""" + _dialog, _widget, validate_mock = self._run( + qtbot, + tmp_path, + job_parameters=[{"name": "Foo", "value": "bar"}], + validate_side_effect=lambda *a, **k: True, + emit_after=[("load", [])], + ) + + validate_mock.assert_called_once() + assert validate_mock.call_args.args[2] == [] + + def test_validator_runs_single_shot_on_first_completed_load( self, qtbot, fresh_deadline_config, tmp_path ): - """Validation waits through clearing emissions, runs once on the first non-empty load, + """Validation waits through clearing emissions, runs once on the first completed load, and disconnects so later reloads don't re-validate.""" queue_parameters = [{"name": "CondaChannels", "type": "STRING"}] _dialog, _widget, validate_mock = self._run( @@ -177,7 +207,11 @@ def test_validator_runs_single_shot_on_first_nonempty_load( tmp_path, job_parameters=[{"name": "Foo", "value": "bar"}], validate_side_effect=lambda *a, **k: True, - emit_after=[[], queue_parameters, [{"name": "Other"}]], + emit_after=[ + ("clear", []), + ("load", queue_parameters), + ("load", [{"name": "Other"}]), + ], ) validate_mock.assert_called_once() @@ -200,6 +234,10 @@ def test_dialog_destroyed_disconnects_validator(self, qtbot, fresh_deadline_conf "deadline.client.ui.job_bundle_submitter._validate_and_warn_about_parameters", validate_mock, ): + # A full completed-load emission pattern, which would run the validator + # if the connections were still live. + widget._controller.queue_parameters_loading.emit(True) + widget._controller.queue_parameters_loading.emit(False) widget._controller.queue_parameters_updated.emit([{"name": "CondaChannels"}]) validate_mock.assert_not_called() From 5fda4e1c7d361f455ae7029f0fc068a9269aacbf Mon Sep 17 00:00:00 2001 From: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:56:29 -0700 Subject: [PATCH 4/6] fix: validate gui-submit --parameter only on successful queue param loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up: gating on load completion via queue_parameters_loading still couldn't distinguish a failed fetch from a successful empty load — _on_queue_parameters_error also emits loading(False) then updated([]). A transient first-load failure (e.g. ResourceNotFoundException during a profile switch) would validate CLI parameters against an empty set, spuriously flag valid queue parameters, and never re-validate. Add a success-only queue_parameters_load_succeeded signal to DeadlineUIController, emitted from _on_queue_parameters_success but not the error or clearing paths, and connect the single-shot CLI --parameter validation to it. An errored load keeps waiting; an empty-but-real load still validates. Dialog-destroyed teardown unchanged. Adds controller tests for the new signal's success/error/clear semantics and a submitter test that a failed load defers validation to the next successful one. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com> --- .../ui/controllers/_deadline_controller.py | 9 +++ .../client/ui/job_bundle_submitter.py | 55 ++++++---------- .../controllers/test_deadline_controller.py | 66 +++++++++++++++++++ .../gui/test_gui_submitter_cli_parameters.py | 55 ++++++++++++---- 4 files changed, 136 insertions(+), 49 deletions(-) diff --git a/src/deadline/client/ui/controllers/_deadline_controller.py b/src/deadline/client/ui/controllers/_deadline_controller.py index 54f34bf2a..feecd3157 100644 --- a/src/deadline/client/ui/controllers/_deadline_controller.py +++ b/src/deadline/client/ui/controllers/_deadline_controller.py @@ -84,6 +84,10 @@ class DeadlineUIController(QObject): queues_updated: Emitted when queue list is updated. Args: [(name, queue_id), ...] storage_profiles_updated: Emitted when storage profiles are updated. queue_parameters_updated: Emitted when queue parameters are loaded. + queue_parameters_load_succeeded: Emitted only when a queue parameter fetch + completes successfully (queue_parameters_updated also fires with [] on + fetch errors and for clearing emissions). Args: [parameter, ...] — may + be empty for a queue that genuinely has no queue parameters. farms_loading: Emitted when farm loading state changes. Args: bool queues_loading: Emitted when queue loading state changes. Args: bool storage_profiles_loading: Emitted when storage profile loading state changes. @@ -101,6 +105,10 @@ class DeadlineUIController(QObject): queues_updated = Signal(list) storage_profiles_updated = Signal(list) queue_parameters_updated = Signal(list) + # Success-only companion to queue_parameters_updated: not emitted on fetch + # error or for clearing emissions, so consumers that must act only on real, + # successful loads (e.g. CLI --parameter validation) can connect here. + queue_parameters_load_succeeded = Signal(list) # Emitted after a farm or queue selection has been persisted, so a host dialog # can reload queue parameters and refresh its Submit button state. @@ -495,6 +503,7 @@ def _on_queue_parameters_success(self, parameters: List[JobParameter]) -> None: """Handle successful queue parameters fetch.""" self.queue_parameters_loading.emit(False) self.queue_parameters_updated.emit(parameters) + self.queue_parameters_load_succeeded.emit(parameters) def _on_queue_parameters_error(self, error: BaseException) -> None: """Handle queue parameters fetch error.""" diff --git a/src/deadline/client/ui/job_bundle_submitter.py b/src/deadline/client/ui/job_bundle_submitter.py index 819d77ffe..05378fd53 100644 --- a/src/deadline/client/ui/job_bundle_submitter.py +++ b/src/deadline/client/ui/job_bundle_submitter.py @@ -413,52 +413,37 @@ def on_create_job_bundle_callback( if job_parameters: # The controller is a global singleton that outlives this dialog, and its # queue_parameters_updated signal also fires with [] to clear stale state - # (farm/queue switch, nothing selected). A queue may genuinely have zero - # queue parameters, so we can't gate on payload non-emptiness — that would - # never validate against an empty-but-real load. Instead we recognize a - # completed load by the queue_parameters_loading(False) emission that the - # controller sends immediately before queue_parameters_updated on fetch - # completion (success or error); clearing emissions have no such prefix. - # Both emits happen consecutively in the same main-thread slot, so nothing - # can interleave between them. Validate single-shot on the first completed - # load, then disconnect so the closure over submitter_dialog can't fire - # against a closed dialog later. + # (farm/queue switch, fetch error, nothing selected) — indistinguishable + # from a queue that genuinely has zero queue parameters. Connect to the + # success-only queue_parameters_load_succeeded signal instead, so we only + # validate against a real, successful load (which may legitimately be + # empty) and keep waiting through clears and transient fetch errors. + # Validate single-shot, then disconnect so the closure over + # submitter_dialog can't fire against a closed dialog later. controller = submitter_dialog.shared_job_settings._controller - # Mutable cell: True right after a load finishes (loading(False)), reset - # when a new load starts (loading(True)). - load_finished = [False] - def track_queue_parameters_loading(is_loading: bool): - load_finished[0] = not is_loading - - def disconnect_validation_signals(): - for signal, slot in ( - (controller.queue_parameters_loading, track_queue_parameters_loading), - (controller.queue_parameters_updated, validate_parameters_after_queue_load), - ): - try: - signal.disconnect(slot) - except (TypeError, RuntimeError): - # Already disconnected (validation ran before the dialog was destroyed). - pass + def disconnect_validation_callback(): + try: + controller.queue_parameters_load_succeeded.disconnect( + validate_parameters_after_queue_load + ) + except (TypeError, RuntimeError): + # Already disconnected (validation ran before the dialog was destroyed). + pass def validate_parameters_after_queue_load(queue_parameters: list): """Validate CLI parameters against loaded queue parameters and set parameter values""" - if not load_finished[0]: - # A clearing emission, not a completed load. Keep waiting. - return - disconnect_validation_signals() + disconnect_validation_callback() if not _validate_and_warn_about_parameters( job_parameters, initial_settings.parameters, queue_parameters, submitter_dialog ): # User cancelled at the validation warning. submitter_dialog.close() - # Validate CLI params once the controller finishes loading queue params. - controller.queue_parameters_loading.connect(track_queue_parameters_loading) - controller.queue_parameters_updated.connect(validate_parameters_after_queue_load) - # If the dialog goes away before any load completes, tear the connections down. - submitter_dialog.destroyed.connect(disconnect_validation_signals) + # Validate CLI params once the controller successfully loads queue params. + controller.queue_parameters_load_succeeded.connect(validate_parameters_after_queue_load) + # If the dialog goes away before any load succeeds, tear the connection down. + submitter_dialog.destroyed.connect(disconnect_validation_callback) submitter_dialog.show() return submitter_dialog diff --git a/test/unit/deadline_client/ui/controllers/test_deadline_controller.py b/test/unit/deadline_client/ui/controllers/test_deadline_controller.py index 7d4ec11a9..898e37704 100644 --- a/test/unit/deadline_client/ui/controllers/test_deadline_controller.py +++ b/test/unit/deadline_client/ui/controllers/test_deadline_controller.py @@ -567,6 +567,72 @@ def test_select_storage_profile_persists_without_cascade( mock_api.list_storage_profiles_for_queue.assert_not_called() mock_api.get_queue_parameter_definitions.assert_not_called() + @patch("deadline.client.ui.controllers._deadline_controller.api") + def test_queue_params_success_emits_load_succeeded( + self, mock_api, qtbot, fresh_deadline_config + ): + """A successful fetch emits queue_parameters_load_succeeded with the parameters, + even when the queue genuinely has zero queue parameters.""" + controller = DeadlineUIController.getInstance() + mock_api.get_queue_parameter_definitions.return_value = [] + + succeeded = [] + updated = [] + controller.queue_parameters_load_succeeded.connect( + lambda x: succeeded.append(x), _QueuedConnection + ) + controller.queue_parameters_updated.connect(lambda x: updated.append(x), _QueuedConnection) + + controller.refresh_queue_parameters(farm_id="farm-123", queue_id="queue-456") + + qtbot.waitUntil(lambda: len(succeeded) > 0, timeout=2000) + assert succeeded[0] == [] + assert updated[0] == [] + + @patch("deadline.client.ui.controllers._deadline_controller.api") + def test_queue_params_error_does_not_emit_load_succeeded( + self, mock_api, qtbot, fresh_deadline_config + ): + """A failed fetch emits queue_parameters_updated([]) but NOT load_succeeded, so + success-only consumers can distinguish an error from an empty-but-real load.""" + controller = DeadlineUIController.getInstance() + mock_api.get_queue_parameter_definitions.side_effect = RuntimeError("boom") + + succeeded = [] + updated = [] + controller.queue_parameters_load_succeeded.connect( + lambda x: succeeded.append(x), _QueuedConnection + ) + controller.queue_parameters_updated.connect(lambda x: updated.append(x), _QueuedConnection) + + controller.refresh_queue_parameters(farm_id="farm-123", queue_id="queue-456") + + qtbot.waitUntil(lambda: len(updated) > 0, timeout=2000) + assert updated[0] == [] + assert succeeded == [] + + @patch("deadline.client.ui.controllers._deadline_controller.api") + def test_clearing_emission_does_not_emit_load_succeeded( + self, mock_api, qtbot, fresh_deadline_config + ): + """refresh with no farm/queue selected clears via queue_parameters_updated([]) without + emitting load_succeeded.""" + controller = DeadlineUIController.getInstance() + + succeeded = [] + updated = [] + controller.queue_parameters_load_succeeded.connect( + lambda x: succeeded.append(x), _QueuedConnection + ) + controller.queue_parameters_updated.connect(lambda x: updated.append(x), _QueuedConnection) + + controller.refresh_queue_parameters(farm_id="", queue_id="") + + qtbot.waitUntil(lambda: len(updated) > 0, timeout=2000) + assert updated[0] == [] + assert succeeded == [] + mock_api.get_queue_parameter_definitions.assert_not_called() + def test_shutdown_cancels_operations(self, qtbot): """Test that shutdown cancels pending operations.""" controller = DeadlineUIController.getInstance() diff --git a/test/unit/deadline_client/ui/gui/test_gui_submitter_cli_parameters.py b/test/unit/deadline_client/ui/gui/test_gui_submitter_cli_parameters.py index b926a73bf..c1ed851e2 100644 --- a/test/unit/deadline_client/ui/gui/test_gui_submitter_cli_parameters.py +++ b/test/unit/deadline_client/ui/gui/test_gui_submitter_cli_parameters.py @@ -55,11 +55,14 @@ def _run(self, qtbot, tmp_path, *, job_parameters, validate_side_effect, emit_af If ``emit_after`` is provided, controller signals are emitted *inside* the patched context so the connected validation callback runs against the mocked ``_validate_and_warn_about_parameters``. It is a list of (kind, payload) tuples - emitted in sequence, mirroring the controller's real emission patterns: - - - ("load", payload): a completed fetch — queue_parameters_loading(True), then - queue_parameters_loading(False), then queue_parameters_updated(payload) - (see DeadlineUIController._on_queue_parameters_success/_error). + emitted in sequence, mirroring the controller's real emission patterns + (see DeadlineUIController._on_queue_parameters_success/_error and the + clearing emits in select_farm/refresh_queue_parameters): + + - ("load", payload): a successful fetch — loading(True), loading(False), + queue_parameters_updated(payload), queue_parameters_load_succeeded(payload). + - ("error", _): a failed fetch — loading(True), loading(False), + queue_parameters_updated([]); load_succeeded is NOT emitted. - ("clear", payload): a bare queue_parameters_updated(payload) with no loading prefix, as emitted on farm/queue switch or when nothing is selected. @@ -114,12 +117,19 @@ def close(self): controller = real_widget._controller for kind, payload in emit_after: if kind == "load": - # A real fetch: loading toggles True -> False, then updated. + # A successful fetch (_on_queue_parameters_success). + controller.queue_parameters_loading.emit(True) + controller.queue_parameters_loading.emit(False) + controller.queue_parameters_updated.emit(payload) + controller.queue_parameters_load_succeeded.emit(payload) + elif kind == "error": + # A failed fetch (_on_queue_parameters_error): no load_succeeded. controller.queue_parameters_loading.emit(True) controller.queue_parameters_loading.emit(False) + controller.queue_parameters_updated.emit([]) else: assert kind == "clear" - controller.queue_parameters_updated.emit(payload) + controller.queue_parameters_updated.emit(payload) return dialog, real_widget, validate_mock def test_parameter_path_does_not_raise_attribute_error( @@ -196,10 +206,29 @@ def test_empty_but_real_load_still_invokes_validator( validate_mock.assert_called_once() assert validate_mock.call_args.args[2] == [] - def test_validator_runs_single_shot_on_first_completed_load( + def test_failed_load_does_not_invoke_validator(self, qtbot, fresh_deadline_config, tmp_path): + """A failed fetch (e.g. transient ResourceNotFoundException during a profile switch) + must not validate against []; the callback keeps waiting for a successful load.""" + queue_parameters = [{"name": "CondaChannels", "type": "STRING"}] + _dialog, _widget, validate_mock = self._run( + qtbot, + tmp_path, + job_parameters=[{"name": "Foo", "value": "bar"}], + validate_side_effect=lambda *a, **k: True, + emit_after=[ + ("error", None), + ("load", queue_parameters), + ], + ) + + # Only the successful load validates, with its real parameter list. + validate_mock.assert_called_once() + assert validate_mock.call_args.args[2] == queue_parameters + + def test_validator_runs_single_shot_on_first_successful_load( self, qtbot, fresh_deadline_config, tmp_path ): - """Validation waits through clearing emissions, runs once on the first completed load, + """Validation waits through clearing emissions, runs once on the first successful load, and disconnects so later reloads don't re-validate.""" queue_parameters = [{"name": "CondaChannels", "type": "STRING"}] _dialog, _widget, validate_mock = self._run( @@ -234,10 +263,8 @@ def test_dialog_destroyed_disconnects_validator(self, qtbot, fresh_deadline_conf "deadline.client.ui.job_bundle_submitter._validate_and_warn_about_parameters", validate_mock, ): - # A full completed-load emission pattern, which would run the validator - # if the connections were still live. - widget._controller.queue_parameters_loading.emit(True) - widget._controller.queue_parameters_loading.emit(False) - widget._controller.queue_parameters_updated.emit([{"name": "CondaChannels"}]) + # A successful-load emission, which would run the validator if the + # connection were still live. + widget._controller.queue_parameters_load_succeeded.emit([{"name": "CondaChannels"}]) validate_mock.assert_not_called() From e979dcfaf399f5bd1de7aa76eb65a137d2c8dd9e Mon Sep 17 00:00:00 2001 From: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:12:15 -0700 Subject: [PATCH 5/6] fix: guard double-disconnect of gui-submit validation callback Found during headless end-to-end verification: when validation runs (and self-disconnects) and the dialog is later destroyed, the destroyed-hook tried to disconnect again. PySide6 reports that with a RuntimeWarning rather than raising, so the try/except never caught it. Track connection state in a shared cell and skip the second disconnect. Adds a recwarn test that destroy-after-validation emits no disconnect warning. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com> --- src/deadline/client/ui/job_bundle_submitter.py | 10 +++++++++- .../gui/test_gui_submitter_cli_parameters.py | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/deadline/client/ui/job_bundle_submitter.py b/src/deadline/client/ui/job_bundle_submitter.py index 05378fd53..0ff8c8904 100644 --- a/src/deadline/client/ui/job_bundle_submitter.py +++ b/src/deadline/client/ui/job_bundle_submitter.py @@ -421,14 +421,21 @@ def on_create_job_bundle_callback( # Validate single-shot, then disconnect so the closure over # submitter_dialog can't fire against a closed dialog later. controller = submitter_dialog.shared_job_settings._controller + # Mutable cell so both closures below share the connection state. Guards + # the double-disconnect (validation ran, then the dialog is destroyed), + # which PySide6 reports with a RuntimeWarning rather than an exception. + connected = [False] def disconnect_validation_callback(): + if not connected[0]: + return + connected[0] = False try: controller.queue_parameters_load_succeeded.disconnect( validate_parameters_after_queue_load ) except (TypeError, RuntimeError): - # Already disconnected (validation ran before the dialog was destroyed). + # Some bindings raise instead of warn when already disconnected. pass def validate_parameters_after_queue_load(queue_parameters: list): @@ -442,6 +449,7 @@ def validate_parameters_after_queue_load(queue_parameters: list): # Validate CLI params once the controller successfully loads queue params. controller.queue_parameters_load_succeeded.connect(validate_parameters_after_queue_load) + connected[0] = True # If the dialog goes away before any load succeeds, tear the connection down. submitter_dialog.destroyed.connect(disconnect_validation_callback) diff --git a/test/unit/deadline_client/ui/gui/test_gui_submitter_cli_parameters.py b/test/unit/deadline_client/ui/gui/test_gui_submitter_cli_parameters.py index c1ed851e2..c8ecb7738 100644 --- a/test/unit/deadline_client/ui/gui/test_gui_submitter_cli_parameters.py +++ b/test/unit/deadline_client/ui/gui/test_gui_submitter_cli_parameters.py @@ -246,6 +246,24 @@ def test_validator_runs_single_shot_on_first_successful_load( validate_mock.assert_called_once() assert validate_mock.call_args.args[2] == queue_parameters + def test_destroy_after_validation_does_not_warn( + self, qtbot, fresh_deadline_config, tmp_path, recwarn + ): + """Dialog destruction after validation already ran (and self-disconnected) must not + attempt a second disconnect — PySide6 reports that with a RuntimeWarning.""" + dialog, _widget, validate_mock = self._run( + qtbot, + tmp_path, + job_parameters=[{"name": "Foo", "value": "bar"}], + validate_side_effect=lambda *a, **k: True, + emit_after=[("load", [{"name": "CondaChannels"}])], + ) + validate_mock.assert_called_once() + + dialog.destroyed.emit() + + assert not [w for w in recwarn.list if "disconnect" in str(w.message)] + def test_dialog_destroyed_disconnects_validator(self, qtbot, fresh_deadline_config, tmp_path): """Destroying the dialog before queue params load tears down the connection, so the stale closure never fires against the singleton controller.""" From 7a00b938861c0c48dd5099aa3472df3b3a19c773 Mon Sep 17 00:00:00 2001 From: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:15:13 -0700 Subject: [PATCH 6/6] fix: tear down gui-submit validation on dialog close, not only deletion SubmitJobToDeadlineDialog does not set WA_DeleteOnClose (and gui-submit holds a reference through app.exec()), so an ordinary close never emits destroyed. Closing the dialog before the first successful queue-parameter load left the validation callback connected to the long-lived singleton controller; a later successful fetch by another consumer would pop the warning against the closed dialog. Also connect the teardown to QDialog.finished, which fires on accept/reject/close of a shown dialog. The destroyed hook stays for deletion without done(); the connected-state guard makes the three teardown paths (self-disconnect, finished, destroyed) idempotent. The FakeDialog test stand-in is now a real QDialog so finished/destroyed behave as in production, and a new test closes the dialog the ordinary way (no destroyed emission) and asserts a later successful load does not invoke the validator. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com> --- .../client/ui/job_bundle_submitter.py | 4 ++ .../gui/test_gui_submitter_cli_parameters.py | 43 +++++++++++++++++-- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/deadline/client/ui/job_bundle_submitter.py b/src/deadline/client/ui/job_bundle_submitter.py index 0ff8c8904..65e3beb11 100644 --- a/src/deadline/client/ui/job_bundle_submitter.py +++ b/src/deadline/client/ui/job_bundle_submitter.py @@ -451,6 +451,10 @@ def validate_parameters_after_queue_load(queue_parameters: list): controller.queue_parameters_load_succeeded.connect(validate_parameters_after_queue_load) connected[0] = True # If the dialog goes away before any load succeeds, tear the connection down. + # The dialog does not set WA_DeleteOnClose, so an ordinary close never emits + # destroyed — hook finished (emitted on accept/reject/close of a visible + # dialog) for the close path, and destroyed for deletion without done(). + submitter_dialog.finished.connect(lambda _result: disconnect_validation_callback()) submitter_dialog.destroyed.connect(disconnect_validation_callback) submitter_dialog.show() diff --git a/test/unit/deadline_client/ui/gui/test_gui_submitter_cli_parameters.py b/test/unit/deadline_client/ui/gui/test_gui_submitter_cli_parameters.py index c8ecb7738..a256d96ee 100644 --- a/test/unit/deadline_client/ui/gui/test_gui_submitter_cli_parameters.py +++ b/test/unit/deadline_client/ui/gui/test_gui_submitter_cli_parameters.py @@ -14,7 +14,7 @@ import pytest -from qtpy.QtCore import QObject # type: ignore +from qtpy.QtWidgets import QDialog # type: ignore from deadline.client.ui.controllers._deadline_controller import DeadlineUIController from deadline.client.ui.controllers._thread_pool import DeadlineThreadPool @@ -75,9 +75,11 @@ def _run(self, qtbot, tmp_path, *, job_parameters, validate_side_effect, emit_af ) qtbot.addWidget(real_widget) - class FakeDialog(QObject): - # QObject supplies the real ``destroyed`` signal the production code - # connects its cleanup to. + class FakeDialog(QDialog): + # A real QDialog so the production code's teardown hooks (``finished`` + # on ordinary close, ``destroyed`` on deletion) behave exactly as they + # do for SubmitJobToDeadlineDialog, which also does not set + # WA_DeleteOnClose. def __init__(self, **kwargs): super().__init__() self.shared_job_settings = real_widget @@ -88,6 +90,7 @@ def show(self): def close(self): self.closed = True + return super().close() template = {"name": "Bundle Job", "steps": []} validate_mock = MagicMock(side_effect=validate_side_effect) @@ -264,6 +267,38 @@ def test_destroy_after_validation_does_not_warn( assert not [w for w in recwarn.list if "disconnect" in str(w.message)] + def test_dialog_closed_before_load_disconnects_validator( + self, qtbot, fresh_deadline_config, tmp_path + ): + """Ordinarily closing the dialog (user hits X/Esc; no WA_DeleteOnClose, so the + QObject stays alive and ``destroyed`` never fires) before any successful load + tears down the connection: a later success emission from the long-lived + singleton must not run the validator against the closed dialog.""" + dialog, widget, validate_mock = self._run( + qtbot, + tmp_path, + job_parameters=[{"name": "Foo", "value": "bar"}], + validate_side_effect=lambda *a, **k: True, + ) + qtbot.addWidget(dialog) + + # Make the dialog visible (as show_job_bundle_submitter does for real) and + # close it the ordinary way — closeEvent -> reject() -> finished. + QDialog.show(dialog) + assert dialog.isVisible() + QDialog.close(dialog) + assert not dialog.isVisible() + + with patch( + "deadline.client.ui.job_bundle_submitter._validate_and_warn_about_parameters", + validate_mock, + ): + # A successful-load emission (e.g. triggered by another consumer of the + # singleton), which would run the validator if the connection were live. + widget._controller.queue_parameters_load_succeeded.emit([{"name": "CondaChannels"}]) + + validate_mock.assert_not_called() + def test_dialog_destroyed_disconnects_validator(self, qtbot, fresh_deadline_config, tmp_path): """Destroying the dialog before queue params load tears down the connection, so the stale closure never fires against the singleton controller."""