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