diff --git a/benchmark/cellprofiler_benchmark_cli.py b/benchmark/cellprofiler_benchmark_cli.py index 10cc6df6bd..1bb056d84f 100644 --- a/benchmark/cellprofiler_benchmark_cli.py +++ b/benchmark/cellprofiler_benchmark_cli.py @@ -231,7 +231,10 @@ def run(self, args: argparse.Namespace) -> int: MeasuredRunEnded, SessionMeasuredRun, ) - from openhcs.agent.dto.session import DatasetRunRequest + from openhcs.agent.dto.session import ( + DatasetRunRequest, + RuntimeObservationExportScopeName, + ) from openhcs.agent.path_policy import AgentPathPolicy from openhcs.agent.session_access import AgentPathDatasetAccess from openhcs.authoring.session.session import CallerThread, Session @@ -241,9 +244,6 @@ def run(self, args: argparse.Namespace) -> int: mirror_input_workspace, ) from openhcs.runtime.zmq_config import OpenHCSZMQConfig - from openhcs.runtime.zmq_execution_signature import ( - ZMQRuntimeObservationExportScope, - ) from python_introspect import to_jsonable output_dir = args.output_dir.expanduser().resolve() @@ -281,7 +281,7 @@ def run(self, args: argparse.Namespace) -> int: runtime_observation_export_path=str( MeasuredPipelineRunArtifact.RUNTIME_OBSERVATION.path_in(output_dir) ), - runtime_observation_export_scope=ZMQRuntimeObservationExportScope.named( + runtime_observation_export_scope=RuntimeObservationExportScopeName( args.observation_scope ), submit_timeout_ms=args.submit_timeout_ms, diff --git a/openhcs/authoring/session/operations/datasets.py b/openhcs/authoring/session/operations/datasets.py index d9030c452f..615072d580 100644 --- a/openhcs/authoring/session/operations/datasets.py +++ b/openhcs/authoring/session/operations/datasets.py @@ -357,7 +357,7 @@ def run(cls, session, request) -> SessionOperationResult: tuple(request.scope_ids), auxiliary_params=session.observation_export_params( request.runtime_observation_export_path, - request.runtime_observation_export_scope, + request.runtime_observation_export_scope.scope, ), submit_timeout_ms=request.submit_timeout_ms, wait_timeout_ms=request.wait_timeout_ms, diff --git a/scripts/smoke_installed_gui.py b/scripts/smoke_installed_gui.py index dcc5c5ba82..aa543cd014 100644 --- a/scripts/smoke_installed_gui.py +++ b/scripts/smoke_installed_gui.py @@ -25,7 +25,11 @@ from zmqruntime import OperationDeadline from openhcs.agent.dto.execution import RuntimeServerInfo - from openhcs.agent.dto.ui_bridge import UiWindowCatalog, UiWindowSnapshotResult + from openhcs.agent.dto.ui_bridge import ( + UiActionInvokeResult, + UiWindowCatalog, + UiWindowSnapshotResult, + ) from openhcs.agent.services.endpoint_function_catalog_service import ( ZMQFunctionCatalogService, ) @@ -454,6 +458,15 @@ def _typed_tool_payload( return payload +def require_ui_action_accepted(action_result: UiActionInvokeResult) -> None: + """Fail the smoke unless the live GUI took the requested action.""" + + from openhcs.agent.dto.ui_bridge import ActionAccepted + + if action_result.status is not ActionAccepted: + raise AssertionError(f"Installed UI action was not accepted: {action_result}") + + def _window_ids(catalog: UiWindowCatalog) -> frozenset[str]: return frozenset(window.window_id for window in catalog.windows) @@ -554,7 +567,6 @@ def run_installed_live_mcp_smoke( ) from openhcs.agent.dto.mcp import McpServerHealthResult from openhcs.agent.dto.ui_bridge import ( - UiActionInvocationStatus, UiActionInvokeResult, UiBridgeCatalog, UiBridgeStatus, @@ -691,10 +703,7 @@ def run_installed_live_mcp_smoke( capability=agent_capabilities.ui_invoke_action, payload_type=UiActionInvokeResult, ) - if action_result.status != UiActionInvocationStatus.ACCEPTED.value: - raise AssertionError( - f"Installed UI action was not accepted: {action_result}" - ) + require_ui_action_accepted(action_result) timeout_text = f"{deadline.remaining_seconds():g}" windows_execution = client.execute( diff --git a/tests/unit/test_benchmark_control.py b/tests/unit/test_benchmark_control.py index 5a2587dba2..e6497b3409 100644 --- a/tests/unit/test_benchmark_control.py +++ b/tests/unit/test_benchmark_control.py @@ -64,7 +64,10 @@ RuntimeEnvironmentSnapshot, ) from openhcs.runtime.zmq_execution_client import OpenHCSExecutionSubmission, ZMQExecutionRequestBuilder -from openhcs.runtime.zmq_execution_signature import ZMQAuxiliaryExecutionParams +from openhcs.runtime.zmq_execution_signature import ( + ZMQAuxiliaryExecutionParams, + ZMQRuntimeObservationExportScope, +) def test_benchmark_command_catalog_is_derived_from_registered_commands() -> None: @@ -1308,3 +1311,46 @@ def _run_receipt( completed_observation_count=completed_observation_count, updated_at_epoch_seconds=updated_at, ) + + +@pytest.mark.parametrize( + "scope_name", tuple(ZMQRuntimeObservationExportScope.__registry__) +) +def test_measured_cli_builds_its_run_request_for_every_observation_scope( + scope_name: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + class RequestWasBuilt(Exception): + """Raised by the first step after the run request, to stop before execution.""" + + def stop_after_request(*_args, **_kwargs): + raise RequestWasBuilt + + monkeypatch.setattr( + "openhcs.core.input_workspace.mirror_input_workspace", stop_after_request + ) + plate = tmp_path / "plate" + plate.mkdir() + source_file = tmp_path / "pipeline.py" + source_file.write_text("pipeline_steps = []\n", encoding="utf-8") + args = create_benchmark_argument_parser().parse_args( + [ + "run-measured", + "--plate", + str(plate), + "--pipeline-source-file", + str(source_file), + "--output-dir", + str(tmp_path / "evidence"), + "--run-id", + "test", + "--observation-scope", + scope_name, + "--wait-timeout-ms", + "1000", + ] + ) + + with pytest.raises(RequestWasBuilt): + args.cli_command.run(args) diff --git a/tests/unit/test_run_request_export_scope.py b/tests/unit/test_run_request_export_scope.py new file mode 100644 index 0000000000..c15c6458e3 --- /dev/null +++ b/tests/unit/test_run_request_export_scope.py @@ -0,0 +1,89 @@ +"""The run request names an export scope; the session receives the scope class.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from openhcs.agent.dto.session import ( + DatasetRunRequest, + RuntimeObservationExportScopeName, +) +from openhcs.authoring.session.operations.datasets import RunDatasets +from openhcs.authoring.session.session import Session +from openhcs.runtime.zmq_execution_signature import ZMQRuntimeObservationExportScope + +SCOPE_NAMES = tuple(ZMQRuntimeObservationExportScope.__registry__) + + +class _WritableAnywhere: + @staticmethod + def require_writable(path: Path) -> Path: + return path + + +class _RecordingSession: + """Only what ``RunDatasets.run`` touches; the export options are the real ones.""" + + observation_export_params = Session.observation_export_params + dataset_access = _WritableAnywhere() + event_log = SimpleNamespace(last_sequence=0) + run_datasets = object() + + def start(self, work, scope_ids, **options) -> None: + self.started = (work, scope_ids, options) + + +def test_every_registered_scope_has_a_request_name() -> None: + assert {member.value for member in RuntimeObservationExportScopeName} == set( + SCOPE_NAMES + ) + + +@pytest.mark.parametrize("scope_name", SCOPE_NAMES) +def test_run_datasets_gives_the_session_the_scope_class( + scope_name: str, tmp_path: Path +) -> None: + session = _RecordingSession() + export_path = tmp_path / "observation.pkl" + + result = RunDatasets.run( + session, + DatasetRunRequest( + scope_ids=("dataset",), + runtime_observation_export_path=str(export_path), + runtime_observation_export_scope=RuntimeObservationExportScopeName( + scope_name + ), + ), + ) + + work, scope_ids, options = session.started + assert result.status == "accepted" + assert work is session.run_datasets + assert scope_ids == ("dataset",) + export = options["auxiliary_params"] + assert export.runtime_observation_export_path == export_path + assert export.runtime_observation_export_scope is ( + ZMQRuntimeObservationExportScope.named(scope_name) + ) + + +def test_run_datasets_without_an_export_path_runs_with_the_default_scope() -> None: + session = _RecordingSession() + + RunDatasets.run(session, DatasetRunRequest(scope_ids=("dataset",))) + + assert session.started[2]["auxiliary_params"] is None + + +def test_run_request_refuses_the_scope_class_itself() -> None: + with pytest.raises(TypeError, match="runtime_observation_export_scope"): + DatasetRunRequest( + scope_ids=(), + runtime_observation_export_scope=ZMQRuntimeObservationExportScope.named( + SCOPE_NAMES[0] + ), + ) diff --git a/tests/unit/test_smoke_installed_gui.py b/tests/unit/test_smoke_installed_gui.py index 5a378c4850..e75a869b2d 100644 --- a/tests/unit/test_smoke_installed_gui.py +++ b/tests/unit/test_smoke_installed_gui.py @@ -4,6 +4,7 @@ import json from concurrent.futures import Future from pathlib import Path +from types import SimpleNamespace import pytest from pyqt_reactive.services.window_snapshot import WindowSnapshotCaptureScope @@ -18,7 +19,11 @@ from openhcs.agent.dto.common import AgentResourceRef from openhcs.agent.dto.execution import ExecutionConnectionSpec, RuntimeServerInfo from openhcs.agent.dto.functions import FunctionCatalogEntry, FunctionCatalogPage -from openhcs.agent.dto.ui_bridge import UiWindowSnapshotResult +from openhcs.agent.dto.ui_bridge import ( + ActionAccepted, + UiActionInvocationStatus, + UiWindowSnapshotResult, +) from openhcs.pyqt_gui.config import AgentUiBridgeConfig, UIConfig from openhcs.runtime.zmq_config import OpenHCSZMQConfig from scripts.smoke_installed_gui import ( @@ -30,6 +35,7 @@ InstalledGuiSnapshotEvidenceAuthority, _StartupObservation, assert_not_source_checkout_import, + require_ui_action_accepted, verify_installed_function_catalog, with_isolated_runtime_topology, ) @@ -396,3 +402,18 @@ def test_window_snapshot_cli_projects_declared_output_directory(tmp_path: Path) assert call.name == "openhcs_ui_snapshot_window" assert call.arguments["output_dir_path"] == str(tmp_path) + + +@pytest.mark.parametrize( + "status", tuple(UiActionInvocationStatus.__registry__.values()) +) +def test_gui_smoke_accepts_only_an_accepted_ui_action( + status: type[UiActionInvocationStatus], +) -> None: + action_result = SimpleNamespace(status=status) + + if status is ActionAccepted: + require_ui_action_accepted(action_result) + else: + with pytest.raises(AssertionError, match="not accepted"): + require_ui_action_accepted(action_result)