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