diff --git a/.github/workflows/test_pyqt5.yml b/.github/workflows/test_pyqt5.yml index 86216478..b2d10cae 100644 --- a/.github/workflows/test_pyqt5.yml +++ b/.github/workflows/test_pyqt5.yml @@ -52,18 +52,21 @@ jobs: python -m pip install --upgrade pip python -m pip install ruff pytest httpx pip install PyQt5 - if [ "${{ github.ref_name }}" = "develop" ]; then + if [ "${{ github.base_ref || github.ref_name }}" = "develop" ]; then # Clone and install development versions of key dependencies with editable install cd .. git clone --depth 1 https://github.com/PlotPyStack/PythonQwt.git git clone --depth 1 --branch develop https://github.com/PlotPyStack/guidata.git git clone --depth 1 --branch develop https://github.com/PlotPyStack/plotpy.git - git clone --depth 1 --branch develop https://github.com/DataLab-Platform/sigima.git + DEPENDENCY_BRANCH="${{ github.head_ref || github.ref_name }}" + git clone --depth 1 --branch "$DEPENDENCY_BRANCH" https://github.com/DataLab-Platform/sigima.git || git clone --depth 1 --branch develop https://github.com/DataLab-Platform/sigima.git + git clone --depth 1 --branch "$DEPENDENCY_BRANCH" https://github.com/DataLab-Platform/SigimaX.git || git clone --depth 1 --branch develop https://github.com/DataLab-Platform/SigimaX.git cd DataLab pip install -e ../guidata pip install -e ../PythonQwt pip install -e ../plotpy pip install -e ../sigima + pip install -e ../SigimaX --no-deps # Install tomli for TOML parsing (safe if already present) pip install tomli # Extract dependencies and save to file, then install @@ -71,7 +74,7 @@ jobs: pip install -r deps.txt # Install DataLab without dependencies pip install --no-deps . - elif [ "${{ github.ref_name }}" = "release" ]; then + elif [ "${{ github.base_ref || github.ref_name }}" = "release" ]; then # Clone dependencies from release branches (with fallback to main/master) cd .. # Try cloning PythonQwt from main or master diff --git a/.github/workflows/test_pyqt6.yml b/.github/workflows/test_pyqt6.yml index 2bd710dd..afbe83ef 100644 --- a/.github/workflows/test_pyqt6.yml +++ b/.github/workflows/test_pyqt6.yml @@ -52,18 +52,21 @@ jobs: python -m pip install --upgrade pip python -m pip install ruff pytest httpx pip install PyQt6 - if [ "${{ github.ref_name }}" = "develop" ]; then + if [ "${{ github.base_ref || github.ref_name }}" = "develop" ]; then # Clone and install development versions of key dependencies with editable install cd .. git clone --depth 1 https://github.com/PlotPyStack/PythonQwt.git git clone --depth 1 --branch develop https://github.com/PlotPyStack/guidata.git git clone --depth 1 --branch develop https://github.com/PlotPyStack/plotpy.git - git clone --depth 1 --branch develop https://github.com/DataLab-Platform/sigima.git + DEPENDENCY_BRANCH="${{ github.head_ref || github.ref_name }}" + git clone --depth 1 --branch "$DEPENDENCY_BRANCH" https://github.com/DataLab-Platform/sigima.git || git clone --depth 1 --branch develop https://github.com/DataLab-Platform/sigima.git + git clone --depth 1 --branch "$DEPENDENCY_BRANCH" https://github.com/DataLab-Platform/SigimaX.git || git clone --depth 1 --branch develop https://github.com/DataLab-Platform/SigimaX.git cd DataLab pip install -e ../guidata pip install -e ../PythonQwt pip install -e ../plotpy pip install -e ../sigima + pip install -e ../SigimaX --no-deps # Install tomli for TOML parsing (safe if already present) pip install tomli # Extract dependencies and save to file, then install @@ -71,7 +74,7 @@ jobs: pip install -r deps.txt # Install DataLab without dependencies pip install --no-deps . - elif [ "${{ github.ref_name }}" = "release" ]; then + elif [ "${{ github.base_ref || github.ref_name }}" = "release" ]; then # Clone dependencies from release branches (with fallback to main/master) cd .. # Try cloning PythonQwt from main or master diff --git a/datalab/gui/panel/history/interactive_replay.py b/datalab/gui/panel/history/interactive_replay.py index c686d55c..cfd436dd 100644 --- a/datalab/gui/panel/history/interactive_replay.py +++ b/datalab/gui/panel/history/interactive_replay.py @@ -10,6 +10,7 @@ import guidata.dataset as gds from qtpy import QtWidgets as QW +from sigima.objects.base import BaseROIParam from datalab.config import _ from datalab.env import execenv @@ -220,9 +221,51 @@ def _entry_still_in_history( return hchain.find_parent_session(panel, entry) is not None +def action_has_roi_params(action: HistoryAction) -> bool: + """Return whether ``action`` was recorded with region-of-interest parameters. + + Args: + action: Recorded action to inspect + + Returns: + True if at least one recorded parameter is a ROI parameter. + """ + values: list[Any] = [] + for key in ("param", "params"): + value = action.kwargs.get(key) + if isinstance(value, (list, tuple)): + values.extend(value) + elif value is not None: + values.append(value) + return any(isinstance(value, BaseROIParam) for value in values) + + +def inform_roi_edit_unsupported(panel: HistoryPanel) -> None: + """Tell the user that ROI parameters cannot be edited from the history. + + Args: + panel: History panel instance + """ + if execenv.unattended: + return + QW.QMessageBox.information( + panel.mainwindow, + _("Recompute regions of interest"), + _( + "Regions of interest cannot be edited from the History panel: " + "the ROI editor cannot be reopened with the recorded parameters. " + "The recorded regions of interest are kept as is." + ), + ) + + def prepare_action_param_edit(action: HistoryAction) -> ActionParamEdit | None: """Prepare the editable parameter copy for ``action``.""" result = None + if action_has_roi_params(action): + # ROIs are defined with the interactive ROI editor, which cannot be + # reopened with the recorded parameters. + return None if ( action.kind == HistoryAction.KIND_UI and action.method_name in HistoryAction.UI_CREATION_METHODS @@ -445,6 +488,10 @@ def run_replay_actions( ordered = order_selected_actions(panel, actions) if not ordered: return + # Non-editable actions (ROIs, interactive fits) only report their refusal + # when a single action was selected: replaying a session or a batch keeps + # their recorded parameters silently. + report_non_editable = prompt and len(ordered) == 1 with panel.runtime.execution.replaying_edits() as started: if not started: return @@ -483,6 +530,12 @@ def run_replay_actions( continue if prompt: result = prompt_edit_action_params(panel, action) + if ( + result is None + and report_non_editable + and action_has_roi_params(action) + ): + inform_roi_edit_unsupported(panel) if result is False: for selected_action in ordered: kwargs, saved_kwargs = entry_states[selected_action.uuid] @@ -572,10 +625,11 @@ def run_replay_actions( if is_load_action else None ) - payload_before = action.kwargs.get("payload") with panel.replaying(), panel.output_suppressed(): action.replay( - panel.mainwindow, restore_selection=True, edit=prompt + panel.mainwindow, + restore_selection=True, + edit=report_non_editable, ) if before_ids is not None: new_uuids = [ @@ -590,16 +644,6 @@ def run_replay_actions( # recorded outputs would break duplicate detection # and downstream reconnection. panel.register_action_outputs(action, new_uuids) - if ( - prompt - and action.kind == HistoryAction.KIND_MUTATION - and action.kwargs.get("payload") is not payload_before - ): - # The mutation payload was edited in the dialog: - # recompute the downstream closure (seeded from the - # mutation targets, see ``get_downstream_actions``). - panel.tree.refresh_action_item(action) - hrec.recompute_cascade(panel, action) continue if hchain.action_consumes_any(action, blocked_outputs): blocked_outputs.update( diff --git a/datalab/gui/panel/history/recompute.py b/datalab/gui/panel/history/recompute.py index 08413864..630032bd 100644 --- a/datalab/gui/panel/history/recompute.py +++ b/datalab/gui/panel/history/recompute.py @@ -29,6 +29,7 @@ insert_processing_parameters, ) from datalab.history import HistoryAction +from datalab.history.core import decode_roi from datalab.history.effects import AnalysisEffects, capture_effects, merge_effects from datalab.objectmodel import get_uuid @@ -855,6 +856,10 @@ def recompute_1_to_0_in_place(panel: HistoryPanel, action: HistoryAction) -> boo copied and a failed attempt rolls back exactly those keys (plus any key the attempt created), leaving unrelated metadata untouched. Legacy actions without a manifest fall back to a full-metadata snapshot. + Sources whose manifest records a pre-analysis ROI (``roi_before``) are + restored to it and re-run with first-run semantics, regenerating + detection ROIs; user ROI edits recorded as later mutation actions are + then re-applied by the replay/cascade sequence. On success, the freshly captured effects are merged into the manifest. """ panel_data = hchain.resolve_panel_for_action(panel, action) @@ -877,10 +882,16 @@ def recompute_1_to_0_in_place(panel: HistoryPanel, action: HistoryAction) -> boo for uuid, obj in zip(sources, source_objs) ] captured: dict[str, AnalysisEffects] = {} + roi_snapshots: dict[str, Any] = {} def rollback() -> None: for uuid, obj, (saved, absent) in zip(sources, source_objs, snapshots): _restore_analysis_source(obj, saved, absent, captured.get(uuid)) + # Undo the pre-analysis ROI restoration after the metadata restore so + # the ROI setter leaves both metadata and cache consistent. + for uuid, obj in zip(sources, source_objs): + if uuid in roi_snapshots: + obj.roi = roi_snapshots[uuid] try: for uuid, src_obj in zip(sources, source_objs): @@ -888,6 +899,17 @@ def rollback() -> None: plugin_origin = action.plugin_origin or ( analysis_parameters.plugin_origin if analysis_parameters else None ) + # Restore the recorded pre-analysis ROI so the detection re-runs + # on the same region as the first run, with ROI creation enabled + # ("" encodes "no ROI before", None means legacy/not recorded). + roi_before = AnalysisEffects.from_dict( + (action.effects or {}).get(uuid) or {} + ).roi_before + if roi_before is not None: + roi_snapshots[uuid] = ( + src_obj.roi.copy() if src_obj.roi is not None else None + ) + src_obj.roi = decode_roi(roi_before) if roi_before else None with capture_effects(src_obj) as effects: # Register the (mutable) effects before running so rollback # sees them even when the recompute raises @@ -897,6 +919,7 @@ def rollback() -> None: src_obj, param, plugin_origin=plugin_origin, + first_run_side_effects=roi_before is not None, ) if not success: rollback() diff --git a/datalab/gui/processor/base.py b/datalab/gui/processor/base.py index c78be696..6b997b85 100644 --- a/datalab/gui/processor/base.py +++ b/datalab/gui/processor/base.py @@ -1608,6 +1608,7 @@ def recompute_1_to_0( obj: SignalObj | ImageObj, param: gds.DataSet | None = None, plugin_origin: dict[str, Any] | None = None, + first_run_side_effects: bool = False, ) -> bool: """Recompute a 1-to-0 analysis on ``obj`` in place. @@ -1620,6 +1621,10 @@ def recompute_1_to_0( obj: Object whose analysis must be refreshed. param: Analysis parameters (optional). plugin_origin: Optional plugin origin descriptor. + first_run_side_effects: If True, keep first-run-only side effects + enabled (e.g. ``create_rois``) so detection ROIs are regenerated. + Used by the history replay engine after restoring the object's + pre-analysis ROI; the default (False) protects user-edited ROIs. Returns: True if the analysis result was refreshed successfully. @@ -1627,7 +1632,8 @@ def recompute_1_to_0( # Work on a local copy so callers' kwargs are never mutated, and # disable side effects that must only run on first execution param = copy.deepcopy(param) - disable_first_run_side_effects(param) + if not first_run_side_effects: + disable_first_run_side_effects(param) paramclass_name = type(param).__name__ if param is not None else None feature = self.get_feature( func_name, diff --git a/datalab/history/action.py b/datalab/history/action.py index a54dd0a2..486195d4 100644 --- a/datalab/history/action.py +++ b/datalab/history/action.py @@ -27,7 +27,8 @@ import sigima.proc.image import sigima.proc.signal -from guidata.dataset.datatypes import DataSet, DataSetGroup +from guidata.dataset.datatypes import DataSet +from qtpy import QtWidgets as QW from datalab.config import _ from datalab.env import execenv @@ -572,11 +573,10 @@ def replay_mutation( Args: mainwindow: DataLab's main window - edit: If True (and not in unattended mode), open the ROI parameter - dialog before applying so the recorded payload can be modified. - Deletion payloads (None) have nothing to edit and are applied - directly. If the dialog is cancelled, the recorded payload is - applied as-is. + edit: If True, the replay was requested in edit mode. Regions of + interest are defined with the interactive ROI editor, which + cannot be reopened with the recorded payload, so nothing is + edited and the recorded ROI is re-applied as is. refresh: If True (default), refresh the panel selection and plot after applying the mutation. The cascade engine passes False as it refreshes each target itself. @@ -610,14 +610,16 @@ def replay_mutation( if not targets: return [] if edit and payload is not None and not execenv.unattended: - # Edit mode: let the user adjust the ROI payload before applying. - obj = panel_data.objmodel[targets[0]] - params = payload.to_params(obj) - group = DataSetGroup(params, title=_("Regions of Interest")) - if group.edit(parent=mainwindow): - payload = payload.__class__.from_params(obj, params) - self.snapshot_kwargs() - self.kwargs["payload"] = payload + QW.QMessageBox.information( + mainwindow, + _("Recompute regions of interest"), + _( + "Regions of interest cannot be edited from the History " + "panel: the ROI editor cannot be reopened with the " + "recorded parameters. The recorded regions of interest " + "are kept as is." + ), + ) for uuid in targets: obj = panel_data.objmodel[uuid] obj.roi = payload.copy() if payload is not None else None diff --git a/datalab/history/effects.py b/datalab/history/effects.py index 3cdec689..8f02ccc2 100644 --- a/datalab/history/effects.py +++ b/datalab/history/effects.py @@ -11,6 +11,8 @@ import numpy as np from sigima.objects.base import ROI_KEY +from datalab.history.core import encode_roi + # Private bookkeeping keys excluded from the metadata diff (ROI changes are # tracked separately through ``roi_modified``, not the metadata diff) EXCLUDED_METADATA_KEYS = frozenset({"__uuid", "__number", ROI_KEY}) @@ -24,23 +26,33 @@ class AnalysisEffects: metadata_added: Metadata keys created by the analysis. metadata_replaced: Pre-existing metadata keys whose value changed. roi_modified: True when the analysis created or changed the object's ROI. + roi_before: Encoded ROI (:func:`datalab.history.core.encode_roi`) of + the object before the **first** execution; ``""`` when the object had + no ROI, ``None`` when not recorded (legacy manifests). """ metadata_added: list[str] = field(default_factory=list) metadata_replaced: list[str] = field(default_factory=list) roi_modified: bool = False + roi_before: str | None = None def to_dict(self) -> dict[str, Any]: """Return a JSON-safe dictionary representation. + The ``roi_before`` key is only present when recorded, so legacy + payloads round-trip unchanged. + Returns: Dictionary suitable for ``json.dumps`` round-trip. """ - return { + data: dict[str, Any] = { "metadata_added": list(self.metadata_added), "metadata_replaced": list(self.metadata_replaced), "roi_modified": bool(self.roi_modified), } + if self.roi_before is not None: + data["roi_before"] = self.roi_before + return data @classmethod def from_dict(cls, data: dict[str, Any]) -> AnalysisEffects: @@ -56,6 +68,7 @@ def from_dict(cls, data: dict[str, Any]) -> AnalysisEffects: metadata_added=list(data.get("metadata_added", [])), metadata_replaced=list(data.get("metadata_replaced", [])), roi_modified=bool(data.get("roi_modified", False)), + roi_before=data.get("roi_before"), ) @@ -91,8 +104,9 @@ def merge_effects( Keys produced on the first run stay in ``metadata_added`` even though a recompute observes them as replaced (the analysis owns them for their whole lifetime). ``roi_modified`` is sticky: once an execution touched - the ROI, the merged manifest keeps the flag. Output lists are sorted for - deterministic ordering. + the ROI, the merged manifest keeps the flag. ``roi_before`` is first-run + sticky: the ROI recorded before the first execution is never overwritten + by recompute captures. Output lists are sorted for deterministic ordering. Args: previous: Manifest from earlier executions, or None on first merge. @@ -106,6 +120,7 @@ def merge_effects( metadata_added=sorted(new.metadata_added), metadata_replaced=sorted(new.metadata_replaced), roi_modified=new.roi_modified, + roi_before=new.roi_before, ) added = set(previous.metadata_added) | set(new.metadata_added) replaced = (set(previous.metadata_replaced) | set(new.metadata_replaced)) - added @@ -113,6 +128,9 @@ def merge_effects( metadata_added=sorted(added), metadata_replaced=sorted(replaced), roi_modified=previous.roi_modified or new.roi_modified, + roi_before=( + previous.roi_before if previous.roi_before is not None else new.roi_before + ), ) @@ -123,7 +141,9 @@ def capture_effects(obj: Any) -> Generator[AnalysisEffects, None, None]: Snapshots the object's metadata keys/values and ROI before yielding, then fills the yielded :class:`AnalysisEffects` instance on exit. Private bookkeeping keys (``__uuid``, ``__number``) and the ROI metadata key are - excluded from the diff. + excluded from the diff. When the ROI was modified, the pre-execution ROI + is kept in ``roi_before`` (encoded payload) so a later replay can restore + it before re-running the analysis. Args: obj: Signal or image object whose ``metadata`` and ``roi`` are watched. @@ -153,3 +173,10 @@ def capture_effects(obj: Any) -> Generator[AnalysisEffects, None, None]: if not safe_equal(before[key], after[key]) ) effects.roi_modified = not safe_equal(roi_before, obj.roi) + if effects.roi_modified: + try: + effects.roi_before = ( + "" if roi_before is None else encode_roi(roi_before) + ) + except (TypeError, ValueError): + pass # Unencodable ROI: degrade to the flag-only legacy behavior diff --git a/datalab/locale/fr/LC_MESSAGES/datalab.po b/datalab/locale/fr/LC_MESSAGES/datalab.po index 8c1cae63..441bc667 100644 --- a/datalab/locale/fr/LC_MESSAGES/datalab.po +++ b/datalab/locale/fr/LC_MESSAGES/datalab.po @@ -2885,6 +2885,12 @@ msgstr "Retraiter l'ajustement" msgid "Interactive fits cannot be edited from the History panel: the fit dialog cannot be reopened with the recorded parameters. The recorded fit is kept as is." msgstr "Les ajustements interactifs ne peuvent pas être modifiés depuis le panneau Historique : la boîte de dialogue d'ajustement ne peut pas être rouverte avec les paramètres enregistrés. L'ajustement enregistré est conservé tel quel." +msgid "Recompute regions of interest" +msgstr "Retraiter les régions d'intérêt" + +msgid "Regions of interest cannot be edited from the History panel: the ROI editor cannot be reopened with the recorded parameters. The recorded regions of interest are kept as is." +msgstr "Les régions d'intérêt ne peuvent pas être modifiées depuis le panneau Historique : l'éditeur de ROI ne peut pas être rouvert avec les paramètres enregistrés. Les régions d'intérêt enregistrées sont conservées telles quelles." + msgid "Full width at y" msgstr "Largeur à y=..." diff --git a/datalab/tests/features/common/history_model_unit_test.py b/datalab/tests/features/common/history_model_unit_test.py index 1dfb9a6f..aea62788 100644 --- a/datalab/tests/features/common/history_model_unit_test.py +++ b/datalab/tests/features/common/history_model_unit_test.py @@ -45,6 +45,7 @@ from datalab.history.core import ( HISTORY_ACTION_SCHEMA_VERSION, HISTORY_SCHEMA_VERSION, + decode_roi, numpy_to_json_safe, ) from datalab.history.effects import AnalysisEffects, capture_effects, merge_effects @@ -1000,10 +1001,13 @@ def test_capture_effects_metadata_and_roi_diff() -> None: with capture_effects(obj) as effects: obj.roi = create_image_roi("rectangle", [2, 2, 5, 5]) assert effects.roi_modified is True + # No ROI existed before: recorded as the restorable "no ROI" sentinel + assert effects.roi_before == "" # An existing ROI left untouched by the analysis is not modified with capture_effects(obj) as effects: obj.metadata["another_key"] = 0 assert effects.roi_modified is False + assert effects.roi_before is None # Re-assigning an equal ROI is not a modification (relies on ROI equality) with capture_effects(obj) as effects: obj.roi = create_image_roi("rectangle", [2, 2, 5, 5]) @@ -1012,6 +1016,11 @@ def test_capture_effects_metadata_and_roi_diff() -> None: with capture_effects(obj) as effects: obj.roi = create_image_roi("rectangle", [3, 3, 6, 6]) assert effects.roi_modified is True + # The pre-execution ROI payload round-trips through encode/decode + restored = decode_roi(effects.roi_before) + assert numpy_to_json_safe(restored.to_dict()) == numpy_to_json_safe( + create_image_roi("rectangle", [2, 2, 5, 5]).to_dict() + ) def test_analysis_effects_round_trip_merge_and_persistence() -> None: @@ -1029,6 +1038,11 @@ def test_analysis_effects_round_trip_merge_and_persistence() -> None: } assert AnalysisEffects.from_dict(payload) == effects assert AnalysisEffects.from_dict({}) == AnalysisEffects() + # roi_before round-trips only when recorded (legacy payloads unchanged) + recorded = AnalysisEffects(roi_modified=True, roi_before="") + recorded_payload = recorded.to_dict() + assert "roi_before" in recorded_payload + assert AnalysisEffects.from_dict(recorded_payload) == recorded # Merge semantics: added-stays-added, sticky roi_modified, sorted output new = AnalysisEffects( metadata_added=["b", "a"], metadata_replaced=["c"], roi_modified=False @@ -1041,6 +1055,15 @@ def test_analysis_effects_round_trip_merge_and_persistence() -> None: assert merged.metadata_added == ["result"] assert merged.metadata_replaced == ["params"] assert merged.roi_modified is True + # roi_before is first-run sticky: recompute captures never overwrite it + previous = AnalysisEffects(roi_modified=True, roi_before="first-run-payload") + recomputed = AnalysisEffects(roi_modified=True, roi_before="recompute-payload") + merged = merge_effects(previous, recomputed) + assert merged.roi_before == "first-run-payload" + # A legacy previous manifest adopts the freshly recorded roi_before + legacy_previous = AnalysisEffects(roi_modified=True) + merged = merge_effects(legacy_previous, recomputed) + assert merged.roi_before == "recompute-payload" # HDF5 round-trip on an action, with legacy tolerance (no effects group) action = build_history_action() action.effects = {"source-uuid": payload} diff --git a/datalab/tests/features/common/history_workflow_test.py b/datalab/tests/features/common/history_workflow_test.py index 42ab3bb5..cc940f40 100644 --- a/datalab/tests/features/common/history_workflow_test.py +++ b/datalab/tests/features/common/history_workflow_test.py @@ -949,8 +949,7 @@ def test_1_to_0_failure_rolls_back_all_source_metadata() -> None: call_count = 0 - def fail_second_analysis(_func_name, source, _param, plugin_origin=None): - del plugin_origin + def fail_second_analysis(_func_name, source, _param, **_kwargs): nonlocal call_count call_count += 1 source.metadata["temporary_analysis"] = call_count @@ -990,8 +989,7 @@ def fail_second_analysis(_func_name, source, _param, plugin_origin=None): img.metadata["user_marker"] = 123 effects_before = copy.deepcopy(img_action.effects) - def failing_recompute(_func_name, obj, _param, plugin_origin=None): - del plugin_origin + def failing_recompute(_func_name, obj, _param, **_kwargs): obj.metadata[geometry_key] = "recreated-by-failed-attempt" obj.metadata[present_key] = "corrupted" raise RuntimeError("forced recompute failure") @@ -1236,6 +1234,14 @@ def test_analysis_effects_manifest_populated_and_recomputed() -> None: for key in manifest.metadata_added ), f"Expected a Geometry_*_dict key, got {manifest.metadata_added}" assert manifest.roi_modified is True, "Detection ROIs must flag roi_modified" + # The image had no ROI before the detection: recorded as "" sentinel + assert manifest.roi_before == "" + assert img.roi is not None, "Detection must have created ROIs" + roi_dict_first = numpy_to_json_safe(img.roi.to_dict()) + geometry_key = next( + key for key in manifest.metadata_added if key.startswith("Geometry_") + ) + geometry_first = copy.deepcopy(img.metadata[geometry_key]) added_before = manifest.metadata_added # A history recompute keeps first-run keys under metadata_added assert hrec.recompute_1_to_0_in_place(history, action) is True @@ -1244,6 +1250,47 @@ def test_analysis_effects_manifest_populated_and_recomputed() -> None: "First-run keys must stay under metadata_added after recompute" ) assert not set(added_before) & set(manifest.metadata_replaced) + # The recompute restored the pre-analysis ROI (none) and re-ran the + # detection with first-run semantics: ROIs and results are identical + # to the first run instead of being detected inside their own ROIs + assert manifest.roi_before == "" + assert img.roi is not None, "Recompute must regenerate detection ROIs" + assert numpy_to_json_safe(img.roi.to_dict()) == roi_dict_first, ( + "Regenerated ROIs must match the first run" + ) + assert numpy_to_json_safe(img.metadata[geometry_key]) == numpy_to_json_safe( + geometry_first + ), "Recomputed detection results must match the first run" + + +def test_replay_reapplies_user_roi_edits_after_detection_recompute() -> None: + """Regenerate detection ROIs on recompute, then re-apply user ROI edits.""" + with datalab_test_app_context(console=False, history=True) as win: + history = win.historypanel + history.toggle_record_mode(True) + panel = win.imagepanel + img = create_peak_image() + panel.add_object(img) + det_param = sigima.params.Peak2DDetectionParam.create( + create_rois=True, threshold=0.5 + ) + with Conf.show_result_dialog.context(False): + panel.processor.run_feature("peak_detection", det_param) + detection = history[len(history)] + assert img.roi is not None + # User deletes the detection ROIs: recorded as a mutation entry + panel.objview.select_objects([get_uuid(img)]) + panel.processor.delete_regions_of_interest() + assert img.roi is None + mutation = history[len(history)] + assert mutation.kind == HistoryAction.KIND_MUTATION + # Recomputing the detection regenerates the ROIs from the recorded + # pre-analysis state (instead of re-detecting inside the ROIs)... + assert hrec.recompute_1_to_0_in_place(history, detection) is True + assert img.roi is not None, "Detection recompute must regenerate ROIs" + # ...and replaying the recorded sequence re-applies the user's edit + mutation.replay(win, restore_selection=True, edit=False) + assert img.roi is None, "Replay must re-apply the user's ROI edit" def test_roi_mutation_recording_replay_and_partial_targets() -> None: @@ -1312,7 +1359,7 @@ def test_roi_mutation_recording_replay_and_partial_targets() -> None: def test_cascade_reapplies_roi_mutation() -> None: - """Cascade recompute re-applies, blocks or edits a downstream ROI mutation.""" + """Cascade recompute re-applies or blocks a downstream ROI mutation.""" with datalab_test_app_context(history=True) as win: history, panel = win.historypanel, win.signalpanel history.toggle_record_mode(True) @@ -1358,18 +1405,18 @@ def test_cascade_reapplies_roi_mutation() -> None: compute_action.is_stale = False history.runtime.execution.cascade_warnings.clear() - # An edited mutation payload triggers a downstream cascade recompute - def edit_payload(_mainwindow, restore_selection=True, edit=False): - del restore_selection - assert edit is True - mutation_action.kwargs["payload"] = mutation_action.kwargs["payload"].copy() - - with ( - patch.object(mutation_action, "replay", side_effect=edit_payload), - patch.object(hrec, "recompute_cascade") as cascade, - ): + # Edit mode is refused for ROI mutations: the payload is kept as is + # and no downstream cascade is triggered + output.roi = None + payload_before = mutation_action.kwargs["payload"] + with patch.object(hrec, "recompute_cascade") as cascade: hireplay.replay_actions(history, [mutation_action], prompt=True) - cascade.assert_called_once_with(history, mutation_action) + cascade.assert_not_called() + assert mutation_action.kwargs["payload"] is payload_before + assert output.roi is not None + assert numpy_to_json_safe(output.roi.to_dict()) == numpy_to_json_safe( + payload_before.to_dict() + ) def test_mutation_root_has_downstream_computes() -> None: