qgis-profile-interpreter

qgis plugin for placing 3D points along elevation profiles
git clone git://src.adamsgaard.dk/qgis-profile-interpreter # fast
git clone https://src.adamsgaard.dk/qgis-profile-interpreter.git # slow
Log | Files | Refs | README | LICENSE Back to index

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()