test_profile_interpreter.py (44975B)
1 import math 2 import os 3 import sys 4 import unittest 5 import fakeqgis 6 7 _HERE = os.path.dirname(__file__) 8 sys.path.insert(0, os.path.join(_HERE, '..')) # project root 9 sys.path.insert(0, _HERE) # test/ dir 10 11 fakeqgis.install() 12 13 from profile_interpreter.profile_interpreter import ( # noqa: E402 14 _added_feature_id, 15 _can_delete, 16 _can_move, 17 _event_canvas_point, 18 _find_panel_toolbar, 19 _is_suitable_target, 20 _nearest_hit, 21 _next_feature_id, 22 _point_attributes, 23 _project_xy, 24 _LEFT_BUTTON, 25 _RIGHT_BUTTON, 26 _PanelSession, 27 _PanelWatcher, 28 _ProfilePickTool, 29 ProfileInterpreterPlugin, 30 ) 31 import fakeqgis as fq # noqa: E402 32 33 34 # ── test helpers ────────────────────────────────────────────────────────── 35 36 class _FakeEvent: 37 def __init__(self, button=None, x=0.0, y=0.0, snapped=None): 38 self._button = button if button is not None else _LEFT_BUTTON 39 self._pos = fq.QgsPointXY(x, y) 40 self._snapped = fq.QgsPointXY(*snapped) if snapped is not None else self._pos 41 42 def button(self): 43 return self._button 44 45 def pos(self): 46 return self._pos 47 48 def snappedPoint(self): 49 return self._snapped 50 51 52 class _RawEvent: 53 def __init__(self, x=0.0, y=0.0): 54 self._pos = fq.QgsPointXY(x, y) 55 56 def pos(self): 57 return self._pos 58 59 60 class _FakeCanvas: 61 def __init__(self, distance=10.0, elevation=5.0, curve='default', crs=None): 62 self.refresh_count = 0 63 self._distance = distance 64 self._elevation = elevation 65 self._curve = fq._FakeCurve() if curve == 'default' else curve 66 self._tool = None 67 self._crs_val = crs 68 self.last_canvas_point = None 69 self.destroyed = fq._FakeSignal() 70 71 def canvasPointToPlotPoint(self, pt): 72 self.last_canvas_point = (pt.x(), pt.y()) 73 return fq._FakeProfilePoint(self._distance, self._elevation) 74 75 def plotPointToCanvasPoint(self, point): 76 return fq.QgsPointXY(point.distance(), point.elevation()) 77 78 def profileCurve(self): 79 return self._curve 80 81 def crs(self): 82 return self._crs_val if self._crs_val is not None else fq._FakeCrs() 83 84 def parentWidget(self): 85 return getattr(self, '_parent', None) 86 87 def setTool(self, tool): 88 self._tool = tool 89 90 def refresh(self): 91 self.refresh_count += 1 92 93 94 class _FakeMainWindow: 95 def __init__(self, canvases=None): 96 self._canvases = canvases or [] 97 self.event_filters = [] 98 99 def findChildren(self, cls): 100 return [c for c in self._canvases if isinstance(c, cls)] 101 102 def installEventFilter(self, obj): 103 self.event_filters.append(obj) 104 105 def removeEventFilter(self, obj): 106 self.event_filters.remove(obj) 107 108 109 class _FakeBar: 110 def pushMessage(self, *args, **kwargs): 111 pass 112 113 114 class _TrackingBar: 115 def __init__(self): 116 self.messages = [] 117 118 def pushMessage(self, tag, msg, level=None, **kwargs): 119 self.messages.append((tag, msg, level)) 120 121 122 class _FakeIface: 123 def __init__(self, active_layer=None, canvases=None): 124 self._active_layer = active_layer 125 self._window = _FakeMainWindow(canvases or []) 126 self._bar = _FakeBar() 127 self.menu_actions = [] 128 self.toolbar_actions = [] 129 130 def activeLayer(self): 131 return self._active_layer 132 133 def mainWindow(self): 134 return self._window 135 136 def messageBar(self): 137 return self._bar 138 139 def addPluginToMenu(self, *args): 140 self.menu_actions.append(args[-1]) 141 142 def addToolBarIcon(self, *args): 143 self.toolbar_actions.append(args[-1]) 144 145 def removeToolBarIcon(self, *args): 146 action = args[-1] 147 if action in self.toolbar_actions: 148 self.toolbar_actions.remove(action) 149 150 def removePluginMenu(self, *args): 151 action = args[-1] 152 if action in self.menu_actions: 153 self.menu_actions.remove(action) 154 155 156 def _make_session(active_layer=None, canvas=None): 157 iface = _FakeIface(active_layer=active_layer) 158 plugin = ProfileInterpreterPlugin(iface) 159 session = _PanelSession(plugin, canvas or _FakeCanvas(), fq.QToolBar()) 160 plugin._sessions.append(session) 161 fq.QgsProject._instance = None 162 fq.QgsGeometry._next_interpolate_empty = False 163 return session, iface 164 165 166 # ── _point_attributes ───────────────────────────────────────────────────── 167 168 class TestPointAttributes(unittest.TestCase): 169 def test_all_fields_returned(self): 170 result = _point_attributes({'id', 'distance', 'elevation'}, 1, 10.0, 5.0) 171 self.assertEqual(result, {'id': 1, 'distance': 10.0, 'elevation': 5.0}) 172 173 def test_subset_of_fields(self): 174 result = _point_attributes({'distance', 'elevation'}, 2, 3.0, 4.0) 175 self.assertNotIn('id', result) 176 self.assertEqual(result['distance'], 3.0) 177 self.assertEqual(result['elevation'], 4.0) 178 179 def test_empty_field_set(self): 180 result = _point_attributes(set(), 1, 10.0, 5.0) 181 self.assertEqual(result, {}) 182 183 def test_note_field_never_auto_set(self): 184 result = _point_attributes({'id', 'distance', 'elevation', 'note'}, 1, 1.0, 1.0) 185 self.assertNotIn('note', result) 186 187 188 # ── _project_xy ────────────────────────────────────────────────────────── 189 190 class TestProjectXY(unittest.TestCase): 191 def setUp(self): 192 fq.QgsCoordinateTransform.reset() 193 194 def _xy(self, x=100.0, y=200.0): 195 return fq._FakeXY(x, y) 196 197 def test_same_crs_object_returns_identity(self): 198 crs = fq._FakeCrs('EPSG:4326') 199 x, y = _project_xy(self._xy(), crs, crs) 200 self.assertEqual((x, y), (100.0, 200.0)) 201 self.assertEqual(fq.QgsCoordinateTransform._calls, []) 202 203 def test_equal_crs_authid_returns_identity(self): 204 src = fq._FakeCrs('EPSG:4326') 205 dst = fq._FakeCrs('EPSG:4326') 206 x, y = _project_xy(self._xy(), src, dst) 207 self.assertEqual((x, y), (100.0, 200.0)) 208 self.assertEqual(fq.QgsCoordinateTransform._calls, []) 209 210 def test_different_crs_transforms_xy(self): 211 src = fq._FakeCrs('EPSG:4326') 212 dst = fq._FakeCrs('EPSG:32632') 213 x, y = _project_xy(self._xy(), src, dst) 214 self.assertAlmostEqual(x, 1100.0) 215 self.assertAlmostEqual(y, 1200.0) 216 self.assertEqual(len(fq.QgsCoordinateTransform._calls), 1) 217 218 219 # ── _next_feature_id ───────────────────────────────────────────────────── 220 221 class TestNextFeatureId(unittest.TestCase): 222 def _make_layer_with_ids(self, id_values): 223 layer = fq.QgsVectorLayer('PointZ', 'test', 'memory') 224 layer._fields_list.append(fq.QgsField('id')) 225 for v in id_values: 226 feat = fq.QgsFeature(fq._FakeFields(layer._fields_list)) 227 feat['id'] = v 228 layer._features.append(feat) 229 return layer 230 231 def test_empty_layer_returns_1(self): 232 layer = self._make_layer_with_ids([]) 233 self.assertEqual(_next_feature_id(layer), 1) 234 235 def test_max_id_plus_one(self): 236 layer = self._make_layer_with_ids([1, 2, 5]) 237 self.assertEqual(_next_feature_id(layer), 6) 238 239 def test_no_id_field_returns_none(self): 240 layer = fq.QgsVectorLayer('PointZ', 'test', 'memory') 241 self.assertIsNone(_next_feature_id(layer)) 242 243 def test_absent_key_on_feature_is_skipped(self): 244 layer = fq.QgsVectorLayer('PointZ', 'test', 'memory') 245 layer._fields_list.append(fq.QgsField('id')) 246 # feature with no 'id' set in _attrs → KeyError → skipped, falls back to 1 247 layer._features.append(fq.QgsFeature(fq._FakeFields(layer._fields_list))) 248 self.assertEqual(_next_feature_id(layer), 1) 249 250 251 # ── _is_suitable_target ─────────────────────────────────────────────────── 252 253 class TestIsSuitableTarget(unittest.TestCase): 254 def test_none_rejected(self): 255 self.assertFalse(_is_suitable_target(None)) 256 257 def test_non_layer_rejected(self): 258 self.assertFalse(_is_suitable_target("not a layer")) 259 self.assertFalse(_is_suitable_target(42)) 260 261 def test_pointz_writable_accepted(self): 262 layer = fq.QgsVectorLayer('PointZ', 'test', 'memory') 263 self.assertTrue(_is_suitable_target(layer)) 264 265 def test_plain_point_rejected(self): 266 layer = fq.QgsVectorLayer('Point', 'test', 'memory') 267 layer._wkb_type = fq.QgsWkbTypes.Point 268 self.assertFalse(_is_suitable_target(layer)) 269 270 def test_non_point_geometry_rejected(self): 271 layer = fq.QgsVectorLayer('LineZ', 'test', 'memory') 272 layer._geom_type = fq.QgsWkbTypes.LineGeometry 273 self.assertFalse(_is_suitable_target(layer)) 274 275 def test_read_only_rejected(self): 276 layer = fq.QgsVectorLayer('PointZ', 'test', 'memory') 277 layer._provider._caps = 0 278 self.assertFalse(_is_suitable_target(layer)) 279 280 281 # ── _on_pick early exits ────────────────────────────────────────────────── 282 283 class TestOnPickEarlyExits(unittest.TestCase): 284 def _pick(self, session, canvas=None): 285 """Fire _on_pick and return feature count on memory layer.""" 286 event = _FakeEvent() 287 session._on_pick(event) 288 289 def test_nan_distance_skipped(self): 290 canvas = _FakeCanvas(distance=math.nan, elevation=5.0) 291 session, _ = _make_session(canvas=canvas) 292 self._pick(session) 293 self.assertIsNone(session._plugin._layer) 294 295 def test_nan_elevation_skipped(self): 296 canvas = _FakeCanvas(distance=10.0, elevation=math.nan) 297 session, _ = _make_session(canvas=canvas) 298 self._pick(session) 299 self.assertIsNone(session._plugin._layer) 300 301 def test_none_curve_skipped(self): 302 canvas = _FakeCanvas(curve=None) 303 session, _ = _make_session(canvas=canvas) 304 self._pick(session) 305 self.assertIsNone(session._plugin._layer) 306 307 def test_empty_interpolated_geom_skipped(self): 308 canvas = _FakeCanvas() 309 session, _ = _make_session(canvas=canvas) 310 fq.QgsGeometry._next_interpolate_empty = True 311 self._pick(session) 312 fq.QgsGeometry._next_interpolate_empty = False 313 self.assertIsNone(session._plugin._layer) 314 315 316 # ── _on_pick successful pick ────────────────────────────────────────────── 317 318 class TestOnPickSuccess(unittest.TestCase): 319 def setUp(self): 320 fq.QgsProject._instance = None 321 fq.QgsGeometry._next_interpolate_empty = False 322 fq.QgsCoordinateTransform.reset() 323 324 def test_uses_memory_layer_when_no_suitable_active(self): 325 session, _ = _make_session(active_layer=None) 326 session._on_pick(_FakeEvent()) 327 # memory layer was created and feature was added 328 layer = session._plugin._layer 329 self.assertIsNotNone(layer) 330 self.assertEqual(len(layer._features), 1) 331 332 def test_feature_ids_do_not_collide(self): 333 session, _ = _make_session() 334 session._on_pick(_FakeEvent()) 335 session._on_pick(_FakeEvent()) 336 layer = session._plugin._layer 337 ids = [f._attrs.get('id') for f in layer._features] 338 self.assertEqual(ids, [1, 2]) 339 340 def test_memory_layer_attrs_set(self): 341 session, _ = _make_session(active_layer=None) 342 session._on_pick(_FakeEvent()) 343 feature = session._plugin._layer._features[0] 344 self.assertEqual(feature._attrs.get('id'), 1) 345 self.assertIn('distance', feature._attrs) 346 self.assertIn('elevation', feature._attrs) 347 348 def test_uses_active_layer_when_suitable(self): 349 active = fq.QgsVectorLayer('PointZ', 'my_layer', 'memory') 350 active._fields_list.append(fq.QgsField('distance')) 351 active._fields_list.append(fq.QgsField('elevation')) 352 session, _ = _make_session(active_layer=active) 353 session._on_pick(_FakeEvent()) 354 # feature went to active layer, not memory layer 355 self.assertEqual(len(active._features), 1) 356 self.assertIsNone(session._plugin._layer) 357 358 def test_only_matching_fields_written_to_active_layer(self): 359 active = fq.QgsVectorLayer('PointZ', 'my_layer', 'memory') 360 active._fields_list.append(fq.QgsField('elevation')) 361 # 'id' and 'distance' fields absent 362 session, _ = _make_session(active_layer=active) 363 session._on_pick(_FakeEvent()) 364 feature = active._features[0] 365 self.assertIn('elevation', feature._attrs) 366 self.assertNotIn('id', feature._attrs) 367 self.assertNotIn('distance', feature._attrs) 368 369 def test_canvas_refresh_called_once(self): 370 canvas = _FakeCanvas() 371 session, _ = _make_session(canvas=canvas) 372 session._on_pick(_FakeEvent()) 373 self.assertEqual(canvas.refresh_count, 1) 374 375 def test_canvas_refresh_not_called_on_early_exit(self): 376 canvas = _FakeCanvas(curve=None) 377 session, _ = _make_session(canvas=canvas) 378 session._on_pick(_FakeEvent()) 379 self.assertEqual(canvas.refresh_count, 0) 380 381 def test_active_layer_different_crs_transforms_geometry(self): 382 active = fq.QgsVectorLayer('PointZ', 'my_layer', 'memory') 383 active._crs = fq._FakeCrs('EPSG:32632') 384 canvas = _FakeCanvas(crs=fq._FakeCrs('EPSG:4326')) 385 session, _ = _make_session(active_layer=active, canvas=canvas) 386 session._on_pick(_FakeEvent()) 387 self.assertEqual(len(fq.QgsCoordinateTransform._calls), 1) 388 feature = active._features[0] 389 self.assertAlmostEqual(feature._geom._obj._x, 1100.0) 390 self.assertAlmostEqual(feature._geom._obj._y, 1200.0) 391 392 def test_same_crs_no_transform(self): 393 active = fq.QgsVectorLayer('PointZ', 'my_layer', 'memory') 394 active._crs = fq._FakeCrs('EPSG:4326') 395 canvas = _FakeCanvas(crs=fq._FakeCrs('EPSG:4326')) 396 session, _ = _make_session(active_layer=active, canvas=canvas) 397 session._on_pick(_FakeEvent()) 398 self.assertEqual(fq.QgsCoordinateTransform._calls, []) 399 feature = active._features[0] 400 self.assertAlmostEqual(feature._geom._obj._x, 100.0) 401 self.assertAlmostEqual(feature._geom._obj._y, 200.0) 402 403 404 # ── _ProfilePickTool ────────────────────────────────────────────────────── 405 406 class TestPickTool(unittest.TestCase): 407 def _make_tool(self): 408 calls = [] 409 canvas = _FakeCanvas() 410 tool = _ProfilePickTool(canvas, lambda e: calls.append(e)) 411 return tool, calls 412 413 def test_left_button_calls_callback(self): 414 tool, calls = self._make_tool() 415 tool.plotReleaseEvent(_FakeEvent(button=_LEFT_BUTTON)) 416 self.assertEqual(len(calls), 1) 417 418 def test_right_button_ignored(self): 419 tool, calls = self._make_tool() 420 tool.plotReleaseEvent(_FakeEvent(button=fq.Qt.MouseButton.RightButton)) 421 self.assertEqual(calls, []) 422 423 424 # ── _interpretation_layer field names ──────────────────────────────────── 425 426 class TestInterpretationLayerFields(unittest.TestCase): 427 def setUp(self): 428 fq.QgsProject._instance = None 429 fq.QgsGeometry._next_interpolate_empty = False 430 431 def test_fallback_layer_has_exactly_id_distance_elevation(self): 432 session, _ = _make_session(active_layer=None) 433 session._on_pick(_FakeEvent()) 434 layer = session._plugin._layer 435 self.assertIsNotNone(layer) 436 field_names = {f.name() for f in layer.fields()} 437 self.assertEqual(field_names, {'id', 'distance', 'elevation'}) 438 self.assertNotIn('note', field_names) 439 440 441 # ── addFeatures failure ─────────────────────────────────────────────────── 442 443 class TestOnPickAddFeaturesFailure(unittest.TestCase): 444 def setUp(self): 445 fq.QgsProject._instance = None 446 fq.QgsGeometry._next_interpolate_empty = False 447 fq.QgsCoordinateTransform.reset() 448 fq._FakeDataProvider._fail_next = False 449 450 def test_failure_shows_warning_and_adds_no_features(self): 451 session, iface = _make_session(active_layer=None) 452 bar = _TrackingBar() 453 iface._bar = bar 454 455 fq._FakeDataProvider._fail_next = True 456 canvas = session._canvas 457 session._on_pick(_FakeEvent()) 458 459 # feature was not committed 460 self.assertEqual(len(session._plugin._layer._features), 0) 461 # a warning was pushed 462 self.assertTrue(any(level == fq.Qgis.Warning for _, _, level in bar.messages)) 463 # canvas was not refreshed 464 self.assertEqual(canvas.refresh_count, 0) 465 466 467 # ── _deactivate ─────────────────────────────────────────────────────────── 468 469 class TestDeactivate(unittest.TestCase): 470 def _make_active_plugin(self): 471 canvas = _FakeCanvas() 472 session, _ = _make_session(canvas=canvas) 473 pick_tool = fq.QgsPlotTool(canvas, 'test') 474 session._pick_tool = pick_tool 475 canvas._tool = pick_tool 476 return session, canvas, pick_tool 477 478 def test_pick_tool_replaced_when_pan_available(self): 479 session, canvas, pick_tool = self._make_active_plugin() 480 session._deactivate() 481 self.assertIsNot(canvas._tool, pick_tool) 482 self.assertIsNone(session._pick_tool) 483 484 def test_pick_tool_replaced_when_pan_none(self): 485 import profile_interpreter.profile_interpreter as pi_module 486 session, canvas, pick_tool = self._make_active_plugin() 487 original = pi_module.QgsPlotToolPan 488 pi_module.QgsPlotToolPan = None 489 try: 490 session._deactivate() 491 finally: 492 pi_module.QgsPlotToolPan = original 493 self.assertIsNot(canvas._tool, pick_tool) 494 self.assertIsNone(session._pick_tool) 495 496 497 # ── _can_delete ─────────────────────────────────────────────────────────── 498 499 class TestCanDelete(unittest.TestCase): 500 def test_none_rejected(self): 501 self.assertFalse(_can_delete(None)) 502 503 def test_memory_layer_accepted(self): 504 layer = fq.QgsVectorLayer('PointZ', 'test', 'memory') 505 self.assertTrue(_can_delete(layer)) 506 507 def test_provider_without_capability_rejected(self): 508 layer = fq.QgsVectorLayer('PointZ', 'test', 'memory') 509 layer._provider._caps = fq.QgsVectorDataProvider.AddFeatures 510 self.assertFalse(_can_delete(layer)) 511 512 513 # ── _added_feature_id ───────────────────────────────────────────────────── 514 515 class TestAddedFeatureId(unittest.TestCase): 516 def test_prefers_provider_assigned_feature(self): 517 added = fq.QgsFeature() 518 added.setId(7) 519 fallback = fq.QgsFeature() 520 fallback.setId(1) 521 self.assertEqual(_added_feature_id([added], fallback), 7) 522 523 def test_falls_back_when_provider_returns_nothing(self): 524 fallback = fq.QgsFeature() 525 fallback.setId(3) 526 self.assertEqual(_added_feature_id([], fallback), 3) 527 528 529 # ── _find_panel_toolbar ─────────────────────────────────────────────────── 530 531 class _FakeWidget: 532 def __init__(self, parent=None, children=None): 533 self._parent = parent 534 self._children = children or [] 535 536 def parentWidget(self): 537 return self._parent 538 539 def findChildren(self, cls): 540 return [c for c in self._children if isinstance(c, cls)] 541 542 543 def _panel(main_window, toolbar=None): 544 """Profile canvas inside a panel widget holding toolbar (or no toolbar).""" 545 canvas = fq.QgsElevationProfileCanvas() 546 children = [toolbar] if toolbar is not None else [] 547 canvas._parent = _FakeWidget(parent=main_window, children=children) 548 main_window._canvases.append(canvas) 549 return canvas 550 551 552 def _activated_session(): 553 main_window = _FakeMainWindow() 554 toolbar = fq.QToolBar() 555 _panel(main_window, toolbar) 556 iface = _FakeIface() 557 iface._window = main_window 558 plugin = ProfileInterpreterPlugin(iface) 559 plugin.initGui() 560 session = plugin._sessions[0] 561 session._action.setChecked(True) 562 return session, iface, toolbar 563 564 565 class TestFindPanelToolbar(unittest.TestCase): 566 def test_finds_toolbar_in_panel(self): 567 main_window = _FakeWidget() 568 toolbar = fq.QToolBar() 569 panel = _FakeWidget(parent=main_window, children=[toolbar]) 570 canvas = _FakeCanvas() 571 canvas._parent = panel 572 self.assertIs(_find_panel_toolbar(canvas, main_window), toolbar) 573 574 def test_walks_up_until_a_toolbar_is_found(self): 575 main_window = _FakeWidget() 576 toolbar = fq.QToolBar() 577 panel = _FakeWidget(parent=main_window, children=[toolbar]) 578 inner = _FakeWidget(parent=panel) 579 canvas = _FakeCanvas() 580 canvas._parent = inner 581 self.assertIs(_find_panel_toolbar(canvas, main_window), toolbar) 582 583 def test_stops_at_main_window(self): 584 main_toolbar = fq.QToolBar() 585 main_window = _FakeWidget(children=[main_toolbar]) 586 panel = _FakeWidget(parent=main_window) 587 canvas = _FakeCanvas() 588 canvas._parent = panel 589 self.assertIsNone(_find_panel_toolbar(canvas, main_window)) 590 591 def test_returns_none_without_parent(self): 592 canvas = _FakeCanvas() 593 self.assertIsNone(_find_panel_toolbar(canvas, _FakeWidget())) 594 595 596 # ── per-panel sessions ──────────────────────────────────────────────────── 597 598 class TestPanelSessions(unittest.TestCase): 599 def setUp(self): 600 fq.QgsProject._instance = None 601 fq.QgsGeometry._next_interpolate_empty = False 602 fq.QgsGeometry._lateral_distance = 0.0 603 fq._FakeDataProvider._fail_next = False 604 fq._FakeDataProvider._fail_next_delete = False 605 606 def _plugin(self, panels=2): 607 """Plugin after initGui() with that many panels, each with its own toolbar.""" 608 main_window = _FakeMainWindow() 609 toolbars = [fq.QToolBar() for _ in range(panels)] 610 canvases = [_panel(main_window, toolbar) for toolbar in toolbars] 611 iface = _FakeIface() 612 iface._window = main_window 613 plugin = ProfileInterpreterPlugin(iface) 614 plugin.initGui() 615 return plugin, iface, canvases, toolbars 616 617 def test_init_adds_nothing_to_plugins_toolbar_or_menu(self): 618 plugin, iface, _, _ = self._plugin(panels=0) 619 self.assertEqual(iface.toolbar_actions, []) 620 self.assertEqual(iface.menu_actions, []) 621 self.assertEqual(iface._window.event_filters, [plugin._watcher]) 622 623 def test_each_panel_gets_its_own_interpret_action(self): 624 plugin, _, canvases, toolbars = self._plugin() 625 a, b = plugin._sessions 626 self.assertEqual((a._canvas, b._canvas), tuple(canvases)) 627 self.assertEqual(toolbars[0].actions_added, [a._action]) 628 self.assertEqual(toolbars[1].actions_added, [b._action]) 629 self.assertIsNot(a._action, b._action) 630 631 def test_rescan_creates_no_duplicates(self): 632 plugin, _, _, toolbars = self._plugin() 633 plugin._scan_panels() 634 self.assertEqual(len(plugin._sessions), 2) 635 self.assertEqual([len(t.actions_added) for t in toolbars], [1, 1]) 636 637 def test_panel_opened_later_gets_a_session(self): 638 plugin, iface, _, _ = self._plugin(panels=0) 639 toolbar = fq.QToolBar() 640 canvas = _panel(iface._window, toolbar) 641 plugin._watcher.eventFilter(iface._window, fq.QEvent(fq.QEvent.Type.Show)) 642 self.assertEqual(plugin._sessions, []) 643 handled = plugin._watcher.eventFilter( 644 iface._window, fq.QEvent(fq.QEvent.Type.ChildAdded)) 645 self.assertFalse(handled) 646 self.assertEqual([s._canvas for s in plugin._sessions], [canvas]) 647 self.assertEqual(toolbar.actions_added, [plugin._sessions[0]._action]) 648 649 def test_panel_without_toolbar_is_skipped(self): 650 plugin, iface, _, _ = self._plugin(panels=0) 651 _panel(iface._window) 652 plugin._scan_panels() 653 self.assertEqual(plugin._sessions, []) 654 655 def test_panels_switch_on_independently(self): 656 plugin, _, canvases, toolbars = self._plugin() 657 a, b = plugin._sessions 658 a._action.setChecked(True) 659 self.assertIs(canvases[0]._tool, a._pick_tool) 660 self.assertIsNone(canvases[1]._tool) 661 self.assertEqual(toolbars[0].actions_added, 662 [a._action, a._undo_action, a._edit_action, a._delete_action]) 663 self.assertEqual(toolbars[1].actions_added, [b._action]) 664 b._action.setChecked(True) 665 self.assertIs(canvases[0]._tool, a._pick_tool) 666 self.assertIs(canvases[1]._tool, b._pick_tool) 667 668 def test_undo_histories_are_separate(self): 669 plugin, _, _, _ = self._plugin() 670 a, b = plugin._sessions 671 a._on_pick(_FakeEvent()) 672 a._on_pick(_FakeEvent()) 673 b._on_pick(_FakeEvent()) 674 layer = plugin._layer 675 b._undo_last_point() 676 self.assertEqual([f._attrs['id'] for f in layer._features], [1, 2]) 677 self.assertEqual(len(a._history), 2) 678 self.assertFalse(b._undo_action.isEnabled()) 679 680 def test_delete_from_other_panel_prunes_history(self): 681 plugin, _, _, _ = self._plugin() 682 a, b = plugin._sessions 683 a._on_pick(_FakeEvent()) 684 layer = plugin._layer 685 layer._features[0].setGeometry(fq.QgsGeometry(fq.QgsPoint(30.0, 0.0, 5.0))) 686 b._action.setChecked(True) 687 b._delete_action.setChecked(True) 688 b._on_pick(_FakeEvent(x=30.0, y=5.0)) 689 self.assertEqual(layer._features, []) 690 self.assertEqual(a._history, []) 691 self.assertFalse(a._undo_action.isEnabled()) 692 693 def test_closed_panel_drops_its_session_only(self): 694 plugin, _, canvases, _ = self._plugin() 695 a, b = plugin._sessions 696 a._action.setChecked(True) 697 b._action.setChecked(True) 698 699 def deleted(*args): 700 raise RuntimeError('wrapped C/C++ object has been deleted') 701 canvases[0].setTool = deleted 702 canvases[0].destroyed.emit(canvases[0]) 703 self.assertEqual(plugin._sessions, [b]) 704 self.assertIs(canvases[1]._tool, b._pick_tool) 705 plugin.unload() 706 707 def test_unload_restores_pan_and_removes_everything(self): 708 plugin, iface, canvases, toolbars = self._plugin() 709 plugin._sessions[0]._action.setChecked(True) 710 plugin.unload() 711 self.assertIsInstance(canvases[0]._tool, fq.QgsPlotToolPan) 712 self.assertIsNone(canvases[1]._tool) 713 self.assertEqual([t.actions_added for t in toolbars], [[], []]) 714 self.assertEqual(iface._window.event_filters, []) 715 self.assertEqual(plugin._sessions, []) 716 plugin._scan_panels() 717 self.assertEqual(plugin._sessions, []) 718 719 def test_watcher_ignores_events_while_pending(self): 720 calls = [] 721 watcher = _PanelWatcher(lambda: calls.append(1)) 722 watcher._pending = True 723 watcher.eventFilter(None, fq.QEvent(fq.QEvent.Type.ChildAdded)) 724 self.assertEqual(calls, []) 725 726 727 # ── undo action lifecycle ───────────────────────────────────────────────── 728 729 class TestUndoAction(unittest.TestCase): 730 def setUp(self): 731 fq.QgsProject._instance = None 732 fq.QgsGeometry._next_interpolate_empty = False 733 fq._FakeDataProvider._fail_next = False 734 fq._FakeDataProvider._fail_next_delete = False 735 736 def _activated(self): 737 """Session of a panel found by initGui(), with the pick tool on.""" 738 return _activated_session() 739 740 def test_disabled_until_a_point_is_placed(self): 741 session, _, _ = self._activated() 742 self.assertFalse(session._undo_action.isEnabled()) 743 session._on_pick(_FakeEvent()) 744 self.assertTrue(session._undo_action.isEnabled()) 745 746 def test_appears_on_panel_toolbar_while_active(self): 747 session, _, toolbar = self._activated() 748 self.assertIn(session._undo_action, toolbar.actions_added) 749 750 def test_removed_from_panel_toolbar_when_deactivated(self): 751 session, _, toolbar = self._activated() 752 session._action.setChecked(False) 753 self.assertNotIn(session._undo_action, toolbar.actions_added) 754 755 756 # ── _undo_last_point ────────────────────────────────────────────────────── 757 758 class TestUndoLastPoint(unittest.TestCase): 759 def setUp(self): 760 fq.QgsProject._instance = None 761 fq.QgsGeometry._next_interpolate_empty = False 762 fq._FakeDataProvider._fail_next = False 763 fq._FakeDataProvider._fail_next_delete = False 764 765 def test_removes_last_placed_feature(self): 766 session, _ = _make_session() 767 session._on_pick(_FakeEvent()) 768 session._on_pick(_FakeEvent()) 769 layer = session._plugin._layer 770 session._undo_last_point() 771 self.assertEqual([f._attrs['id'] for f in layer._features], [1]) 772 773 def test_repeated_undo_empties_the_layer(self): 774 session, _ = _make_session() 775 session._on_pick(_FakeEvent()) 776 session._on_pick(_FakeEvent()) 777 layer = session._plugin._layer 778 session._undo_last_point() 779 session._undo_last_point() 780 self.assertEqual(layer._features, []) 781 self.assertEqual(session._history, []) 782 783 def test_undo_with_empty_history_is_a_no_op(self): 784 session, _ = _make_session() 785 session._undo_last_point() 786 self.assertEqual(session._history, []) 787 788 def test_ids_reused_after_undo(self): 789 session, _ = _make_session() 790 session._on_pick(_FakeEvent()) 791 session._on_pick(_FakeEvent()) 792 session._undo_last_point() 793 session._on_pick(_FakeEvent()) 794 self.assertEqual([f._attrs['id'] for f in session._plugin._layer._features], [1, 2]) 795 796 def test_canvas_refreshed_after_undo(self): 797 canvas = _FakeCanvas() 798 session, _ = _make_session(canvas=canvas) 799 session._on_pick(_FakeEvent()) 800 session._undo_last_point() 801 self.assertEqual(canvas.refresh_count, 2) 802 803 def test_layer_removed_from_project_is_skipped(self): 804 session, _ = _make_session() 805 session._on_pick(_FakeEvent()) 806 session._on_pick(_FakeEvent()) 807 layer = session._plugin._layer 808 fq.QgsProject.instance()._layers.pop(layer.id()) 809 session._undo_last_point() 810 # both history entries pointed at the vanished layer; nothing deleted 811 self.assertEqual(len(layer._features), 2) 812 self.assertEqual(session._history, []) 813 814 def test_read_only_layer_warns_and_keeps_feature(self): 815 session, iface = _make_session() 816 bar = _TrackingBar() 817 iface._bar = bar 818 session._on_pick(_FakeEvent()) 819 layer = session._plugin._layer 820 layer._provider._caps = fq.QgsVectorDataProvider.AddFeatures 821 session._undo_last_point() 822 self.assertEqual(len(layer._features), 1) 823 self.assertTrue(any(level == fq.Qgis.Warning for _, _, level in bar.messages)) 824 825 def test_delete_failure_warns(self): 826 session, iface = _make_session() 827 bar = _TrackingBar() 828 iface._bar = bar 829 session._on_pick(_FakeEvent()) 830 fq._FakeDataProvider._fail_next_delete = True 831 session._undo_last_point() 832 self.assertEqual(len(session._plugin._layer._features), 1) 833 self.assertTrue(any(level == fq.Qgis.Warning for _, _, level in bar.messages)) 834 835 def test_failed_pick_adds_no_history(self): 836 session, _ = _make_session() 837 fq._FakeDataProvider._fail_next = True 838 session._on_pick(_FakeEvent()) 839 self.assertEqual(session._history, []) 840 841 842 # ── _nearest_hit / _can_move ────────────────────────────────────────────── 843 844 class TestNearestHit(unittest.TestCase): 845 def test_no_candidates(self): 846 self.assertIsNone(_nearest_hit([], 0.0, 0.0, 10.0)) 847 848 def test_outside_tolerance(self): 849 self.assertIsNone(_nearest_hit([(1, 20.0, 0.0)], 0.0, 0.0, 10.0)) 850 851 def test_nearest_of_several(self): 852 candidates = [(1, 8.0, 0.0), (2, 3.0, 4.0), (3, 0.0, 9.0)] 853 self.assertEqual(_nearest_hit(candidates, 0.0, 0.0, 10.0), 2) 854 855 def test_on_tolerance_boundary_hits(self): 856 self.assertEqual(_nearest_hit([(4, 6.0, 8.0)], 0.0, 0.0, 10.0), 4) 857 858 859 class TestCanMove(unittest.TestCase): 860 def test_none_rejected(self): 861 self.assertFalse(_can_move(None)) 862 863 def test_memory_layer_accepted(self): 864 self.assertTrue(_can_move(fq.QgsVectorLayer('PointZ', 'test', 'memory'))) 865 866 def test_provider_without_capability_rejected(self): 867 layer = fq.QgsVectorLayer('PointZ', 'test', 'memory') 868 layer._provider._caps = fq.QgsVectorDataProvider.AddFeatures 869 self.assertFalse(_can_move(layer)) 870 871 872 # ── edit / delete modes ─────────────────────────────────────────────────── 873 874 def _layer_with_points(points, fields=('id', 'distance', 'elevation')): 875 """Writable PointZ layer holding a feature per (x, z); the fake maps it to pixel (x, z).""" 876 layer = fq.QgsVectorLayer('PointZ', 'points', 'memory') 877 for name in fields: 878 layer._fields_list.append(fq.QgsField(name)) 879 for x, z in points: 880 feature = fq.QgsFeature(fq._FakeFields(layer._fields_list)) 881 feature.setGeometry(fq.QgsGeometry(fq.QgsPoint(x, 0.0, z))) 882 layer._provider.addFeatures([feature]) 883 return layer 884 885 886 def _xyz(feature): 887 point = feature._geom._obj 888 return point.x(), point.y(), point.z() 889 890 891 class _ModeTestCase(unittest.TestCase): 892 def setUp(self): 893 fq.QgsProject._instance = None 894 fq.QgsGeometry._next_interpolate_empty = False 895 fq.QgsGeometry._lateral_distance = 0.0 896 fq._FakeDataProvider._fail_next = False 897 fq._FakeDataProvider._fail_next_delete = False 898 fq._FakeDataProvider._fail_next_change = False 899 900 def _session(self, layer=None, canvas=None): 901 session, iface = _make_session(active_layer=layer, canvas=canvas) 902 if layer is not None: 903 fq.QgsProject.instance().addMapLayer(layer) 904 iface._bar = _TrackingBar() 905 return session, iface 906 907 908 class TestModeActions(_ModeTestCase): 909 def _activated(self): 910 return _activated_session() 911 912 def test_attached_next_to_undo_while_active(self): 913 session, _, toolbar = self._activated() 914 self.assertEqual( 915 toolbar.actions_added, 916 [session._action, session._undo_action, session._edit_action, 917 session._delete_action]) 918 self.assertTrue(session._edit_action.isEnabled()) 919 self.assertTrue(session._delete_action.isEnabled()) 920 921 def test_detached_and_reset_on_deactivate(self): 922 session, _, toolbar = self._activated() 923 session._edit_action.setChecked(True) 924 session._action.setChecked(False) 925 self.assertEqual(toolbar.actions_added, [session._action]) 926 self.assertFalse(session._edit_action.isChecked()) 927 self.assertFalse(session._edit_action.isEnabled()) 928 self.assertEqual(session._mode, 'add') 929 930 def test_edit_and_delete_are_exclusive(self): 931 session, _ = self._session() 932 session._edit_action.setChecked(True) 933 self.assertEqual(session._mode, 'edit') 934 session._delete_action.setChecked(True) 935 self.assertEqual(session._mode, 'delete') 936 self.assertFalse(session._edit_action.isChecked()) 937 session._delete_action.setChecked(False) 938 self.assertEqual(session._mode, 'add') 939 940 941 class TestEditPoint(_ModeTestCase): 942 def test_select_then_move_updates_geometry_and_attrs(self): 943 layer = _layer_with_points([(30.0, 5.0), (60.0, 5.0)]) 944 canvas = _FakeCanvas(distance=12.0, elevation=7.0) 945 session, _ = self._session(layer, canvas) 946 session._edit_action.setChecked(True) 947 948 session._on_pick(_FakeEvent(x=31.0, y=6.0)) 949 self.assertEqual(session._selected, (layer, 1)) 950 self.assertEqual(layer.selected_ids, [1]) 951 952 session._on_pick(_FakeEvent(x=200.0, y=200.0)) 953 moved = layer._features[0] 954 self.assertEqual(_xyz(moved), (100.0, 200.0, 7.0)) 955 self.assertEqual(moved._attrs['distance'], 12.0) 956 self.assertEqual(moved._attrs['elevation'], 7.0) 957 self.assertNotIn('id', moved._attrs) 958 self.assertEqual(_xyz(layer._features[1]), (60.0, 0.0, 5.0)) 959 self.assertIsNone(session._selected) 960 self.assertEqual(layer.selected_ids, []) 961 self.assertEqual(canvas.refresh_count, 1) 962 963 def test_miss_with_nothing_selected_changes_nothing(self): 964 layer = _layer_with_points([(30.0, 5.0)]) 965 session, iface = self._session(layer) 966 session._edit_action.setChecked(True) 967 session._on_pick(_FakeEvent(x=80.0, y=80.0)) 968 self.assertIsNone(session._selected) 969 self.assertEqual(_xyz(layer._features[0]), (30.0, 0.0, 5.0)) 970 self.assertIn('No point near the click.', [m for _, m, _ in iface._bar.messages]) 971 972 def test_right_click_cancels_selection(self): 973 layer = _layer_with_points([(30.0, 5.0)]) 974 session, _ = self._session(layer) 975 session._edit_action.setChecked(True) 976 session._on_pick(_FakeEvent(x=30.0, y=5.0)) 977 tool = _ProfilePickTool(session._canvas, session._on_pick, session._clear_selection) 978 tool.plotReleaseEvent(_FakeEvent(button=_RIGHT_BUTTON)) 979 self.assertIsNone(session._selected) 980 self.assertEqual(layer.selected_ids, []) 981 982 def test_toggling_edit_off_cancels_selection(self): 983 layer = _layer_with_points([(30.0, 5.0)]) 984 session, _ = self._session(layer) 985 session._edit_action.setChecked(True) 986 session._on_pick(_FakeEvent(x=30.0, y=5.0)) 987 session._edit_action.setChecked(False) 988 self.assertIsNone(session._selected) 989 self.assertEqual(session._mode, 'add') 990 991 def test_layer_without_change_geometries_warns(self): 992 layer = _layer_with_points([(30.0, 5.0)]) 993 layer._provider._caps = fq.QgsVectorDataProvider.AddFeatures 994 session, iface = self._session(layer) 995 session._edit_action.setChecked(True) 996 session._on_pick(_FakeEvent(x=30.0, y=5.0)) 997 self.assertIsNone(session._selected) 998 self.assertTrue(any(lvl == fq.Qgis.Warning for _, _, lvl in iface._bar.messages)) 999 1000 def test_change_failure_warns_and_keeps_selection(self): 1001 layer = _layer_with_points([(30.0, 5.0)]) 1002 session, iface = self._session(layer) 1003 session._edit_action.setChecked(True) 1004 session._on_pick(_FakeEvent(x=30.0, y=5.0)) 1005 fq._FakeDataProvider._fail_next_change = True 1006 session._on_pick(_FakeEvent(x=0.0, y=0.0)) 1007 self.assertEqual(_xyz(layer._features[0]), (30.0, 0.0, 5.0)) 1008 self.assertEqual(session._selected, (layer, 1)) 1009 self.assertTrue(any(lvl == fq.Qgis.Warning for _, _, lvl in iface._bar.messages)) 1010 1011 def test_points_beyond_profile_tolerance_ignored(self): 1012 layer = _layer_with_points([(30.0, 5.0)]) 1013 canvas = _FakeCanvas() 1014 canvas.tolerance = lambda: 5.0 1015 session, _ = self._session(layer, canvas) 1016 session._edit_action.setChecked(True) 1017 fq.QgsGeometry._lateral_distance = 6.0 1018 session._on_pick(_FakeEvent(x=30.0, y=5.0)) 1019 self.assertIsNone(session._selected) 1020 fq.QgsGeometry._lateral_distance = 3.0 1021 session._on_pick(_FakeEvent(x=30.0, y=5.0)) 1022 self.assertEqual(session._selected, (layer, 1)) 1023 1024 def test_no_fallback_layer_created(self): 1025 session, _ = self._session() 1026 session._edit_action.setChecked(True) 1027 session._on_pick(_FakeEvent(x=30.0, y=5.0)) 1028 self.assertIsNone(session._plugin._layer) 1029 1030 1031 class TestSnapping(_ModeTestCase): 1032 def test_event_point_uses_snapped_position(self): 1033 point = _event_canvas_point(_FakeEvent(x=1.0, y=2.0, snapped=(3.0, 4.0))) 1034 self.assertEqual((point.x(), point.y()), (3.0, 4.0)) 1035 1036 def test_event_point_falls_back_to_pos(self): 1037 point = _event_canvas_point(_RawEvent(x=1.0, y=2.0)) 1038 self.assertEqual((point.x(), point.y()), (1.0, 2.0)) 1039 1040 def test_add_converts_snapped_point(self): 1041 canvas = _FakeCanvas() 1042 session, _ = self._session(canvas=canvas) 1043 session._on_pick(_FakeEvent(x=1.0, y=2.0, snapped=(3.0, 4.0))) 1044 self.assertEqual(canvas.last_canvas_point, (3.0, 4.0)) 1045 self.assertEqual(len(session._plugin._layer._features), 1) 1046 1047 def test_move_converts_snapped_point(self): 1048 layer = _layer_with_points([(30.0, 5.0)]) 1049 canvas = _FakeCanvas() 1050 session, _ = self._session(layer, canvas) 1051 session._edit_action.setChecked(True) 1052 session._on_pick(_FakeEvent(x=30.0, y=5.0)) 1053 session._on_pick(_FakeEvent(x=1.0, y=2.0, snapped=(3.0, 4.0))) 1054 self.assertEqual(canvas.last_canvas_point, (3.0, 4.0)) 1055 self.assertIsNone(session._selected) 1056 1057 def test_select_for_edit_uses_raw_cursor(self): 1058 layer = _layer_with_points([(30.0, 5.0)]) 1059 session, _ = self._session(layer) 1060 session._edit_action.setChecked(True) 1061 session._on_pick(_FakeEvent(x=30.0, y=5.0, snapped=(200.0, 200.0))) 1062 self.assertEqual(session._selected, (layer, 1)) 1063 1064 def test_delete_uses_raw_cursor(self): 1065 layer = _layer_with_points([(30.0, 5.0)]) 1066 session, _ = self._session(layer) 1067 session._delete_action.setChecked(True) 1068 session._on_pick(_FakeEvent(x=30.0, y=5.0, snapped=(200.0, 200.0))) 1069 self.assertEqual(layer._features, []) 1070 1071 1072 class TestDeletePoint(_ModeTestCase): 1073 def test_deletes_point_under_click(self): 1074 layer = _layer_with_points([(30.0, 5.0), (60.0, 5.0)]) 1075 canvas = _FakeCanvas() 1076 session, _ = self._session(layer, canvas) 1077 session._delete_action.setChecked(True) 1078 session._on_pick(_FakeEvent(x=58.0, y=5.0)) 1079 self.assertEqual([f.id() for f in layer._features], [1]) 1080 self.assertEqual(canvas.refresh_count, 1) 1081 1082 def test_miss_is_a_no_op(self): 1083 layer = _layer_with_points([(30.0, 5.0)]) 1084 session, _ = self._session(layer) 1085 session._delete_action.setChecked(True) 1086 session._on_pick(_FakeEvent(x=45.0, y=5.0)) 1087 self.assertEqual(len(layer._features), 1) 1088 1089 def test_deleted_point_pruned_from_undo_history(self): 1090 session, _ = self._session() 1091 session._on_pick(_FakeEvent()) 1092 session._on_pick(_FakeEvent()) 1093 layer = session._plugin._layer 1094 layer._features[0].setGeometry(fq.QgsGeometry(fq.QgsPoint(30.0, 0.0, 5.0))) 1095 session._delete_action.setChecked(True) 1096 session._on_pick(_FakeEvent(x=30.0, y=5.0)) 1097 self.assertEqual(session._history, [(layer, 2)]) 1098 session._undo_last_point() 1099 self.assertEqual(layer._features, []) 1100 self.assertFalse(session._undo_action.isEnabled()) 1101 1102 def test_read_only_layer_warns_and_keeps_point(self): 1103 layer = _layer_with_points([(30.0, 5.0)]) 1104 layer._provider._caps = fq.QgsVectorDataProvider.AddFeatures 1105 session, iface = self._session(layer) 1106 session._delete_action.setChecked(True) 1107 session._on_pick(_FakeEvent(x=30.0, y=5.0)) 1108 self.assertEqual(len(layer._features), 1) 1109 self.assertTrue(any(lvl == fq.Qgis.Warning for _, _, lvl in iface._bar.messages)) 1110 1111 def test_delete_failure_warns(self): 1112 layer = _layer_with_points([(30.0, 5.0)]) 1113 session, iface = self._session(layer) 1114 session._delete_action.setChecked(True) 1115 fq._FakeDataProvider._fail_next_delete = True 1116 session._on_pick(_FakeEvent(x=30.0, y=5.0)) 1117 self.assertEqual(len(layer._features), 1) 1118 self.assertTrue(any(lvl == fq.Qgis.Warning for _, _, lvl in iface._bar.messages)) 1119 1120 1121 if __name__ == '__main__': 1122 unittest.main()