|
6 | 6 |
|
7 | 7 | import os |
8 | 8 | import tempfile |
| 9 | +from types import SimpleNamespace |
| 10 | +from unittest.mock import patch |
9 | 11 |
|
| 12 | +import numpy as np |
| 13 | +from sigima.objects import Gauss2DParam, ImageObj |
| 14 | + |
| 15 | +from datalab.gui.creation import ( |
| 16 | + create_image_from_param, |
| 17 | + extract_creation_parameters, |
| 18 | +) |
| 19 | +from datalab.gui.panel.history import recompute as hrec |
| 20 | +from datalab.gui.processor.base import ( |
| 21 | + ProcessingParameters, |
| 22 | + extract_processing_parameters, |
| 23 | + insert_processing_parameters, |
| 24 | +) |
10 | 25 | from datalab.h5.native import NativeH5Writer |
11 | 26 | from datalab.history.action import HistoryAction |
12 | 27 | from datalab.history.core import HISTORY_ACTION_SCHEMA_VERSION, HISTORY_SCHEMA_VERSION |
13 | 28 | from datalab.history.session import HistorySession |
| 29 | +from datalab.history.workspace_state import WorkspaceState |
| 30 | +from datalab.objectmodel import get_uuid, set_uuid |
14 | 31 | from datalab.tests.features.common.history_test_helpers import ( |
15 | 32 | build_history_action, |
16 | 33 | build_replay_map, |
|
20 | 37 | ) |
21 | 38 |
|
22 | 39 |
|
| 40 | +class CascadeObjectModel: |
| 41 | + """Minimal object model for pure cascade recomputation tests.""" |
| 42 | + |
| 43 | + def __init__(self, objects: list[ImageObj]) -> None: |
| 44 | + self.objects = {get_uuid(obj): obj for obj in objects} |
| 45 | + |
| 46 | + def __getitem__(self, uuid: str) -> ImageObj: |
| 47 | + """Return the image identified by ``uuid``.""" |
| 48 | + return self.objects[uuid] |
| 49 | + |
| 50 | + def has_uuid(self, uuid: str) -> bool: |
| 51 | + """Return whether ``uuid`` exists in the model.""" |
| 52 | + return uuid in self.objects |
| 53 | + |
| 54 | + def get_object_ids(self) -> list[str]: |
| 55 | + """Return all object UUIDs in insertion order.""" |
| 56 | + return list(self.objects) |
| 57 | + |
| 58 | + |
23 | 59 | def test_action_hdf5_current_and_legacy_contract() -> None: |
24 | 60 | """Round-trip current fields and apply all legacy defaults.""" |
25 | 61 | action = build_history_action() |
@@ -143,3 +179,166 @@ def test_replay_uuid_map_preserves_operands_and_tracks_changes() -> None: |
143 | 179 | replay_map.capture_changes(HistoryAction(), before) |
144 | 180 | assert "old-new" not in replay_map.mapping["signal"] |
145 | 181 | assert image_model.get_object_ids() == ["image-old"] |
| 182 | + |
| 183 | + |
| 184 | +def test_edited_image_creation_recomputes_downstream_in_place() -> None: |
| 185 | + """Regenerate an edited image before recomputing its existing descendant.""" |
| 186 | + initial_param = Gauss2DParam.create( |
| 187 | + title="Initial Gaussian", |
| 188 | + height=24, |
| 189 | + width=28, |
| 190 | + x0=-4.0, |
| 191 | + y0=2.0, |
| 192 | + sigma=1.2, |
| 193 | + a=25.0, |
| 194 | + ) |
| 195 | + edited_param = Gauss2DParam.create( |
| 196 | + title="Edited Gaussian", |
| 197 | + height=24, |
| 198 | + width=28, |
| 199 | + x0=3.0, |
| 200 | + y0=-2.0, |
| 201 | + sigma=3.5, |
| 202 | + a=80.0, |
| 203 | + ) |
| 204 | + source = create_image_from_param(initial_param) |
| 205 | + source_uuid = get_uuid(source) |
| 206 | + source_identity = id(source) |
| 207 | + initial_source_data = source.data.copy() |
| 208 | + expected_source = create_image_from_param(edited_param) |
| 209 | + |
| 210 | + downstream = source.copy() |
| 211 | + set_uuid(downstream) |
| 212 | + downstream.title = "Initial downstream" |
| 213 | + downstream.data = np.full(source.data.shape, -1.0) |
| 214 | + downstream_uuid = get_uuid(downstream) |
| 215 | + downstream_identity = id(downstream) |
| 216 | + initial_downstream_data = downstream.data.copy() |
| 217 | + insert_processing_parameters( |
| 218 | + downstream, |
| 219 | + ProcessingParameters( |
| 220 | + func_name="unit_transform", |
| 221 | + pattern="1-to-1", |
| 222 | + source_uuid=source_uuid, |
| 223 | + ), |
| 224 | + ) |
| 225 | + |
| 226 | + creation_action = HistoryAction( |
| 227 | + title="Create edited Gaussian", |
| 228 | + kind=HistoryAction.KIND_UI, |
| 229 | + target="imagepanel", |
| 230 | + method_name="new_object", |
| 231 | + kwargs={"param": edited_param}, |
| 232 | + ) |
| 233 | + creation_action.output_uuids = [source_uuid] |
| 234 | + downstream_state = WorkspaceState() |
| 235 | + downstream_state.selection = {"image": [source_uuid]} |
| 236 | + downstream_action = HistoryAction( |
| 237 | + title="Transform edited Gaussian", |
| 238 | + kind=HistoryAction.KIND_COMPUTE, |
| 239 | + panel_str="image", |
| 240 | + func_name="unit_transform", |
| 241 | + pattern="1_to_1", |
| 242 | + state=downstream_state, |
| 243 | + ) |
| 244 | + downstream_action.output_uuids = [downstream_uuid] |
| 245 | + session = HistorySession(number=1) |
| 246 | + session.add_action(creation_action) |
| 247 | + session.add_action(downstream_action) |
| 248 | + |
| 249 | + processor_source_objects: list[ImageObj] = [] |
| 250 | + processor_source_data: list[np.ndarray] = [] |
| 251 | + |
| 252 | + def recompute_1_to_1( |
| 253 | + func_name: str | None, |
| 254 | + source_obj: ImageObj, |
| 255 | + param: object, |
| 256 | + *, |
| 257 | + plugin_origin: dict[str, object] | None, |
| 258 | + ) -> SimpleNamespace: |
| 259 | + assert func_name == "unit_transform" |
| 260 | + assert param is None |
| 261 | + assert plugin_origin is None |
| 262 | + processor_source_objects.append(source_obj) |
| 263 | + processor_source_data.append(source_obj.data.copy()) |
| 264 | + new_obj = source_obj.copy() |
| 265 | + new_obj.title = f"unit_transform({source_obj.title})" |
| 266 | + new_obj.data = source_obj.data.astype(float) * 2.0 + 3.0 |
| 267 | + return SimpleNamespace(cancelled=False, error_msg=None, result=new_obj) |
| 268 | + |
| 269 | + def apply_recomputed_object_in_place( |
| 270 | + obj: ImageObj, |
| 271 | + new_obj: ImageObj, |
| 272 | + proc_params: ProcessingParameters, |
| 273 | + ) -> None: |
| 274 | + hrec.update_obj_in_place(obj, new_obj) |
| 275 | + insert_processing_parameters(obj, proc_params) |
| 276 | + |
| 277 | + object_model = CascadeObjectModel([source, downstream]) |
| 278 | + data_panel = SimpleNamespace( |
| 279 | + PANEL_STR_ID="image", |
| 280 | + objmodel=object_model, |
| 281 | + processor=SimpleNamespace(recompute_1_to_1=recompute_1_to_1), |
| 282 | + objprop=SimpleNamespace( |
| 283 | + apply_recomputed_object_in_place=apply_recomputed_object_in_place |
| 284 | + ), |
| 285 | + ) |
| 286 | + runtime = SimpleNamespace( |
| 287 | + objects=SimpleNamespace( |
| 288 | + action_output_uuids={ |
| 289 | + creation_action.uuid: [source_uuid], |
| 290 | + downstream_action.uuid: [downstream_uuid], |
| 291 | + } |
| 292 | + ), |
| 293 | + execution=SimpleNamespace(cascade_warnings=[], broken_actions=set()), |
| 294 | + ) |
| 295 | + history_panel = SimpleNamespace( |
| 296 | + runtime=runtime, |
| 297 | + history_sessions=[session], |
| 298 | + ) |
| 299 | + refreshed_uuids: list[str] = [] |
| 300 | + |
| 301 | + def record_refresh(panel_data: object, output_uuid: str) -> None: |
| 302 | + assert panel_data is data_panel |
| 303 | + refreshed_uuids.append(output_uuid) |
| 304 | + |
| 305 | + descendants = hrec.hchain.get_downstream_actions(history_panel, creation_action) |
| 306 | + assert descendants == [downstream_action] |
| 307 | + |
| 308 | + with ( |
| 309 | + patch.object(hrec.hchain, "resolve_panel_for_action", return_value=data_panel), |
| 310 | + patch.object( |
| 311 | + hrec, "create_image_from_param", wraps=create_image_from_param |
| 312 | + ) as create_image_mock, |
| 313 | + patch.object(hrec, "refresh_target", side_effect=record_refresh), |
| 314 | + ): |
| 315 | + assert hrec.recompute_creation_in_place(history_panel, creation_action) |
| 316 | + assert hrec.recompute_1_to_1_in_place(history_panel, downstream_action) |
| 317 | + |
| 318 | + create_image_mock.assert_called_once() |
| 319 | + assert create_image_mock.call_args.args[0] is edited_param |
| 320 | + |
| 321 | + assert id(source) == source_identity |
| 322 | + assert object_model[source_uuid] is source |
| 323 | + assert get_uuid(source) == source_uuid |
| 324 | + np.testing.assert_allclose(source.data, expected_source.data) |
| 325 | + assert not np.array_equal(source.data, initial_source_data) |
| 326 | + creation_param = extract_creation_parameters(source) |
| 327 | + assert isinstance(creation_param, Gauss2DParam) |
| 328 | + for name in ("height", "width", "x0", "y0", "sigma", "a"): |
| 329 | + assert getattr(creation_param, name) == getattr(edited_param, name) |
| 330 | + |
| 331 | + assert processor_source_objects == [source] |
| 332 | + np.testing.assert_allclose(processor_source_data[0], expected_source.data) |
| 333 | + assert id(downstream) == downstream_identity |
| 334 | + assert object_model[downstream_uuid] is downstream |
| 335 | + assert get_uuid(downstream) == downstream_uuid |
| 336 | + np.testing.assert_allclose( |
| 337 | + downstream.data, expected_source.data.astype(float) * 2.0 + 3.0 |
| 338 | + ) |
| 339 | + assert not np.array_equal(downstream.data, initial_downstream_data) |
| 340 | + downstream_params = extract_processing_parameters(downstream) |
| 341 | + assert downstream_params is not None |
| 342 | + assert downstream_params.source_uuid == source_uuid |
| 343 | + assert refreshed_uuids == [source_uuid, downstream_uuid] |
| 344 | + assert runtime.execution.cascade_warnings == [] |
0 commit comments