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

commit e310c017efe9af7653e68c5c36da5c9a07b7947d
parent ce370b652a5ca8eae63504a2cbda1f8ed4f09157
Author: Anders Damsgaard <anders@adamsgaard.dk>
Date:   Thu, 22 Oct 2026 09:39:01 +0000

feat(gui): give each Elevation Profile panel its own interpretation buttons

Diffstat:
MREADME.md | 35++++++++++++++++++++---------------
Mprofile_interpreter/profile_interpreter.py | 240++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mtest/fakeqgis.py | 36+++++++++++++++++++++++++++++++-----
Mtest/test_profile_interpreter.py | 667++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
4 files changed, 551 insertions(+), 427 deletions(-)

diff --git a/README.md b/README.md @@ -44,36 +44,39 @@ altered). Otherwise points are stored in a memory layer named 1. Load data that supports elevation profiles (e.g. TEM Loader layers). 2. Open the profile panel: **View > Elevation Profile**, then draw a profile line across your data so the section renders. -3. Toggle **Interpret on Profile** (toolbar button or **Plugins > Profile - Interpreter**) on. +3. Toggle **Interpret on Profile** on. The button sits on the Elevation Profile + panel's own toolbar; every open profile panel has its own button. 4. Click on the profile where you interpret a boundary. A PointZ feature is added to the active layer (if it is a writable PointZ layer) or to the fallback **Profile interpretations** memory layer. The point's X/Y/Z is printed to the Python console and shown in the message bar. 5. To remove the point you just placed, click **Undo Point**. While the tool is - on, this button is added to the Elevation Profile panel's own toolbar; it is - always available under **Plugins > Profile Interpreter** as well, and stays - disabled until you have placed at least one point. Repeated clicks walk back - through the points in reverse order. + on, this button is added next to **Interpret on Profile** on the panel + toolbar, and stays disabled until you have placed at least one point in that + panel. Repeated clicks walk back through the points in reverse order. 6. To move a point, check **Edit Point**, click the point (it is selected), then click its new position. Its `distance` and `elevation` fields are updated if the layer has them. Right-click or uncheck **Edit Point** to cancel a selection. 7. To remove a point, check **Delete Point** and click the point. 8. **Edit Point** and **Delete Point** sit next to **Undo Point** on the panel - toolbar and in the plugin menu. Only one of them can be checked at a time; + toolbar. Only one of them can be checked at a time; with neither checked, clicks add points. A point counts as clicked when it is drawn within 10 pixels of the cursor. 9. Toggle the tool off to return to normal pan/zoom on the profile. +10. With several profile panels open, each one works on its own: it has its own + on/off state, Edit/Delete mode and **Undo Point** history, and several + panels can be on at once. Points from all panels go to the same target + layer. # Limitations - When no active PointZ layer is set, interpretation points are stored in an in-memory fallback layer only; there is no GeoPackage persistence or per-boundary classification. -- The plugin grabs the profile canvas by walking the Qt widget tree - (`findChildren(QgsElevationProfileCanvas)`); there is no official PyQGIS - accessor. With multiple profile docks open it picks the first visible one. +- The plugin finds profile panels by walking the Qt widget tree + (`findChildren(QgsElevationProfileCanvas)`) whenever a window is added to the + QGIS main window; there is no official PyQGIS accessor or signal. - **Undo Point** only walks back points placed in the current QGIS session, in reverse order; it does not participate in QGIS's own undo stack (points are written straight to the data provider, not through an edit buffer). @@ -81,11 +84,13 @@ altered). Otherwise points are stored in a memory layer named points; undoing after moving a point deletes the moved point. - **Edit Point** and **Delete Point** only search the layer new points go to (the active PointZ layer, otherwise the fallback memory layer). Selecting a - point for editing replaces that layer's current selection. -- The panel buttons are placed on the profile panel by locating the - panel's toolbar in the Qt widget tree. If a QGIS build lays the panel out - differently, the buttons are silently omitted there and remain in the plugin - menu. + point for editing replaces that layer's current selection, so two panels + editing the same layer share one selection highlight. +- The buttons are placed on a profile panel by locating the panel's toolbar in + the Qt widget tree. If a QGIS build lays the panel out differently, that + panel gets no buttons. +- Picking another tool on the panel toolbar (Pan, Identify, Measure) leaves + **Interpret on Profile** checked; toggle it off and on again to resume. - Placing and moving points follow the profile's snap toggle (magnet icon): with snapping on, the point lands on the snapped crosshair position. Selecting a point for **Edit Point** or **Delete Point** uses the raw cursor. diff --git a/profile_interpreter/profile_interpreter.py b/profile_interpreter/profile_interpreter.py @@ -17,16 +17,18 @@ The conversion path is: import math import os -from qgis.PyQt.QtCore import Qt, QPointF +from qgis.PyQt.QtCore import QEvent, QObject, QPointF, Qt, QTimer from qgis.PyQt.QtGui import QIcon from qgis.PyQt.QtWidgets import QAction, QToolBar try: _LEFT_BUTTON = Qt.MouseButton.LeftButton # PyQt6 / QGIS 4 _RIGHT_BUTTON = Qt.MouseButton.RightButton + _CHILD_ADDED = QEvent.Type.ChildAdded except AttributeError: _LEFT_BUTTON = Qt.LeftButton # PyQt5 / QGIS 3 _RIGHT_BUTTON = Qt.RightButton + _CHILD_ADDED = QEvent.ChildAdded from qgis.core import ( Qgis, @@ -192,90 +194,81 @@ class _ProfilePickTool(QgsPlotTool): super().plotReleaseEvent(event) -class ProfileInterpreterPlugin: - def __init__(self, iface): - self.iface = iface - self._action = None - self._canvas = None +class _PanelWatcher(QObject): + """Event filter that calls on_change once per burst of children added to the watched window.""" + + def __init__(self, on_change): + super().__init__() + self._on_change = on_change + self._pending = False + + def eventFilter(self, watched, event): + if event.type() == _CHILD_ADDED and not self._pending: + self._pending = True + QTimer.singleShot(0, self._fire) + return False + + def _fire(self): + self._pending = False + self._on_change() + + +class _PanelSession: + """Interpretation tool, buttons and undo history bound to one Elevation Profile panel.""" + + def __init__(self, plugin, canvas, toolbar): + self._plugin = plugin + self.iface = plugin.iface + self._canvas = canvas + self._toolbar = toolbar self._pick_tool = None self._pan_tool = None - self._layer = None - self._undo_action = None - self._edit_action = None - self._delete_action = None - self._panel_toolbar = None self._history = [] self._mode = 'add' self._selected = None - def initGui(self): icon_path = os.path.join(os.path.dirname(__file__), 'icon.svg') - self._action = QAction(QIcon(icon_path), 'Interpret on Profile', self.iface.mainWindow()) + self._action = QAction(QIcon(icon_path), 'Interpret on Profile', toolbar) + self._action.setToolTip('Place interpretation points on this elevation profile') self._action.setCheckable(True) self._action.toggled.connect(self._set_active) - self.iface.addPluginToMenu(MENU, self._action) - self.iface.addToolBarIcon(self._action) self._undo_action = QAction( - QgsApplication.getThemeIcon('mActionUndo.svg'), - 'Undo Point', self.iface.mainWindow()) + QgsApplication.getThemeIcon('mActionUndo.svg'), 'Undo Point', toolbar) self._undo_action.setToolTip('Remove the last interpretation point') self._undo_action.setEnabled(False) self._undo_action.triggered.connect(self._undo_last_point) - self.iface.addPluginToMenu(MENU, self._undo_action) self._edit_action = QAction( - QgsApplication.getThemeIcon('mActionVertexTool.svg'), - 'Edit Point', self.iface.mainWindow()) + QgsApplication.getThemeIcon('mActionVertexTool.svg'), 'Edit Point', toolbar) self._edit_action.setToolTip( 'Click a point to select it, then click its new position') self._edit_action.setCheckable(True) self._edit_action.setEnabled(False) self._edit_action.toggled.connect(lambda on: self._set_mode('edit', on)) - self.iface.addPluginToMenu(MENU, self._edit_action) self._delete_action = QAction( QgsApplication.getThemeIcon('mActionDeleteSelectedFeatures.svg'), - 'Delete Point', self.iface.mainWindow()) + 'Delete Point', toolbar) self._delete_action.setToolTip('Click a point to delete it') self._delete_action.setCheckable(True) self._delete_action.setEnabled(False) self._delete_action.toggled.connect(lambda on: self._set_mode('delete', on)) - self.iface.addPluginToMenu(MENU, self._delete_action) - def unload(self): - self._set_active(False) - self.iface.removeToolBarIcon(self._action) - for action in (self._action, self._undo_action, self._edit_action, - self._delete_action): - self.iface.removePluginMenu(MENU, action) - self._action = None - self._undo_action = None - self._edit_action = None - self._delete_action = None - self._history.clear() + toolbar.addAction(self._action) + + def remove(self): + """Switch the tool off and take this session's buttons off the panel toolbar.""" + self._action.setChecked(False) + self._toolbar.removeAction(self._action) def _set_active(self, active): if not active: self._deactivate() return - - canvas = self._find_profile_canvas() - if canvas is None: - self.iface.messageBar().pushMessage( - MENU, - 'Open the Elevation Profile panel first ' - '(View > Elevation Profile), then enable this tool.', - level=Qgis.Warning, - ) - if self._action is not None: - self._action.setChecked(False) - return - - self._canvas = canvas - self._pick_tool = _ProfilePickTool(canvas, self._on_pick, self._clear_selection) - canvas.setTool(self._pick_tool) - self._attach_panel_actions(canvas) + self._pick_tool = _ProfilePickTool(self._canvas, self._on_pick, self._clear_selection) + self._canvas.setTool(self._pick_tool) + self._attach_panel_actions() self.iface.messageBar().pushMessage( MENU, 'Click on the elevation profile to place interpretation points.', @@ -284,45 +277,32 @@ class ProfileInterpreterPlugin: def _deactivate(self): self._detach_panel_actions() - if self._canvas is not None and self._pick_tool is not None: + if self._pick_tool is not None: if QgsPlotToolPan is not None: self._pan_tool = QgsPlotToolPan(self._canvas) else: self._pan_tool = QgsPlotTool(self._canvas, 'Inactive') self._canvas.setTool(self._pan_tool) self._pick_tool = None - self._canvas = None def _panel_actions(self): - actions = (self._undo_action, self._edit_action, self._delete_action) - return [a for a in actions if a is not None] + return [self._undo_action, self._edit_action, self._delete_action] - def _attach_panel_actions(self, canvas): - """Enable the mode actions and put all panel actions on the profile panel toolbar.""" + def _attach_panel_actions(self): + """Enable the mode actions and put them next to Interpret on the panel toolbar.""" for action in (self._edit_action, self._delete_action): - if action is not None: - action.setEnabled(True) - actions = self._panel_actions() - if not actions: - return - toolbar = _find_panel_toolbar(canvas, self.iface.mainWindow()) - if toolbar is None: - return - for action in actions: - toolbar.addAction(action) - self._panel_toolbar = toolbar + action.setEnabled(True) + for action in self._panel_actions(): + self._toolbar.addAction(action) def _detach_panel_actions(self): for action in (self._edit_action, self._delete_action): - if action is not None: - action.setChecked(False) - action.setEnabled(False) + action.setChecked(False) + action.setEnabled(False) self._clear_selection() self._mode = 'add' - if self._panel_toolbar is not None: - for action in self._panel_actions(): - self._panel_toolbar.removeAction(action) - self._panel_toolbar = None + for action in self._panel_actions(): + self._toolbar.removeAction(action) def _set_mode(self, mode, checked): """Switch between add, edit and delete; edit and delete are mutually exclusive.""" @@ -333,8 +313,7 @@ class ProfileInterpreterPlugin: return self._mode = mode other = self._delete_action if mode == 'edit' else self._edit_action - if other is not None: - other.setChecked(False) + other.setChecked(False) hint = ('Click a point to select it, then click its new position.' if mode == 'edit' else 'Click a point to delete it.') self.iface.messageBar().pushMessage(MENU, hint, level=Qgis.Info, duration=3) @@ -347,13 +326,12 @@ class ProfileInterpreterPlugin: self._selected = None def _refresh_undo_state(self): - if self._undo_action is not None: - self._undo_action.setEnabled(bool(self._history)) + self._undo_action.setEnabled(bool(self._history)) def _refresh_layer(self, layer): layer.updateExtents() layer.triggerRepaint() - if self._canvas is not None and hasattr(self._canvas, 'refresh'): + if hasattr(self._canvas, 'refresh'): self._canvas.refresh() def _undo_last_point(self): @@ -391,29 +369,6 @@ class ProfileInterpreterPlugin: break self._refresh_undo_state() - def _find_profile_canvas(self): - main_window = self.iface.mainWindow() - canvases = main_window.findChildren(QgsElevationProfileCanvas) - for canvas in canvases: - if canvas.isVisible(): - return canvas - return canvases[0] if canvases else None - - def _target_layer(self, crs): - active = self.iface.activeLayer() - if _is_suitable_target(active): - return active - return self._interpretation_layer(crs) - - def _search_layer(self): - """The layer points are added to, without creating the fallback layer.""" - active = self.iface.activeLayer() - if _is_suitable_target(active): - return active - if self._layer is not None and QgsProject.instance().mapLayer(self._layer.id()): - return self._layer - return None - def _click_to_point(self, event): """(distance, elevation, xy in canvas CRS) under the click, or None off the section.""" canvas = self._canvas @@ -469,7 +424,7 @@ class ProfileInterpreterPlugin: distance, elevation, xy = picked canvas = self._canvas - layer = self._target_layer(canvas.crs()) + layer = self._plugin._target_layer(canvas.crs()) x, y = _project_xy(xy, canvas.crs(), layer.crs()) feature = QgsFeature(layer.fields()) feature.setGeometry(QgsGeometry(QgsPoint(x, y, elevation))) @@ -515,7 +470,7 @@ class ProfileInterpreterPlugin: self._move_selected(event) def _select_for_edit(self, event): - layer = self._search_layer() + layer = self._plugin._search_layer() fid = self._find_point_at(event, layer) if layer is not None else None if fid is None: self.iface.messageBar().pushMessage( @@ -573,7 +528,7 @@ class ProfileInterpreterPlugin: ) def _delete_click(self, event): - layer = self._search_layer() + layer = self._plugin._search_layer() fid = self._find_point_at(event, layer) if layer is not None else None if fid is None: self.iface.messageBar().pushMessage( @@ -590,17 +545,80 @@ class ProfileInterpreterPlugin: self.iface.messageBar().pushMessage( MENU, 'Failed to delete the point.', level=Qgis.Warning) return - self._history = [ - (lyr, f) for lyr, f in self._history - if not (lyr.id() == layer.id() and f == fid) - ] - self._refresh_undo_state() + self._plugin._forget_point(layer, fid) self._refresh_layer(layer) QgsMessageLog.logMessage( f'deleted point {fid} from "{layer.name()}"', MENU, level=Qgis.Info) self.iface.messageBar().pushMessage( MENU, 'Deleted interpretation point.', level=Qgis.Success, duration=2) + +class ProfileInterpreterPlugin: + def __init__(self, iface): + self.iface = iface + self._layer = None + self._sessions = [] + self._watcher = None + + def initGui(self): + self._watcher = _PanelWatcher(self._scan_panels) + self.iface.mainWindow().installEventFilter(self._watcher) + self._scan_panels() + + def unload(self): + if self._watcher is not None: + self.iface.mainWindow().removeEventFilter(self._watcher) + self._watcher = None + for session in self._sessions: + session.remove() + self._sessions = [] + + def _scan_panels(self): + """Give every profile panel that has a toolbar and no session its own session.""" + if self._watcher is None: + return + main_window = self.iface.mainWindow() + known = [session._canvas for session in self._sessions] + for canvas in main_window.findChildren(QgsElevationProfileCanvas): + if canvas in known: + continue + toolbar = _find_panel_toolbar(canvas, main_window) + if toolbar is None: + continue + session = _PanelSession(self, canvas, toolbar) + canvas.destroyed.connect(lambda *_, s=session: self._drop_session(s)) + self._sessions.append(session) + + def _drop_session(self, session): + """Forget a session whose panel was closed; its Qt objects are already gone.""" + session._clear_selection() + if session in self._sessions: + self._sessions.remove(session) + + def _forget_point(self, layer, fid): + """Remove a deleted point from every session's undo history.""" + for session in self._sessions: + session._history = [ + (lyr, f) for lyr, f in session._history + if not (lyr.id() == layer.id() and f == fid) + ] + session._refresh_undo_state() + + def _target_layer(self, crs): + active = self.iface.activeLayer() + if _is_suitable_target(active): + return active + return self._interpretation_layer(crs) + + def _search_layer(self): + """The layer points are added to, without creating the fallback layer.""" + active = self.iface.activeLayer() + if _is_suitable_target(active): + return active + if self._layer is not None and QgsProject.instance().mapLayer(self._layer.id()): + return self._layer + return None + def _interpretation_layer(self, crs): project = QgsProject.instance() if self._layer is not None and project.mapLayer(self._layer.id()): diff --git a/test/fakeqgis.py b/test/fakeqgis.py @@ -35,6 +35,29 @@ class QMetaType: QString = 3 +class QEvent: + class Type: + ChildAdded = 68 + Show = 17 + + def __init__(self, event_type): + self._type = event_type + + def type(self): + return self._type + + +class QObject: + def __init__(self, parent=None): + pass + + +class QTimer: + @staticmethod + def singleShot(msec, callback): + callback() + + class QIcon: def __init__(self, *args, **kwargs): pass @@ -48,7 +71,7 @@ class _FakeSignal: self._callbacks.append(cb) def emit(self, *args): - for cb in self._callbacks: + for cb in list(self._callbacks): cb(*args) @@ -94,6 +117,9 @@ class QToolBar: if action in self.actions_added: self.actions_added.remove(action) + def actions(self): + return list(self.actions_added) + # ── qgis.core stubs ─────────────────────────────────────────────────────── @@ -468,12 +494,9 @@ class QgsPlotToolPan: class QgsElevationProfileCanvas: def __init__(self): - self._visible = True self.refresh_count = 0 self._tool = None - - def isVisible(self): - return self._visible + self.destroyed = _FakeSignal() def parentWidget(self): return getattr(self, '_parent', None) @@ -557,6 +580,9 @@ def install(): qgis_pyqt_qtcore.Qt = Qt qgis_pyqt_qtcore.QPointF = QPointF + qgis_pyqt_qtcore.QEvent = QEvent + qgis_pyqt_qtcore.QObject = QObject + qgis_pyqt_qtcore.QTimer = QTimer qgis_pyqt_qtcore.QMetaType = QMetaType qgis_pyqt_qtgui.QIcon = QIcon qgis_pyqt_qtwidgets.QAction = QAction diff --git a/test/test_profile_interpreter.py b/test/test_profile_interpreter.py @@ -23,6 +23,8 @@ from profile_interpreter.profile_interpreter import ( # noqa: E402 _project_xy, _LEFT_BUTTON, _RIGHT_BUTTON, + _PanelSession, + _PanelWatcher, _ProfilePickTool, ProfileInterpreterPlugin, ) @@ -64,6 +66,7 @@ class _FakeCanvas: self._tool = None self._crs_val = crs self.last_canvas_point = None + self.destroyed = fq._FakeSignal() def canvasPointToPlotPoint(self, pt): self.last_canvas_point = (pt.x(), pt.y()) @@ -91,10 +94,17 @@ class _FakeCanvas: class _FakeMainWindow: def __init__(self, canvases=None): self._canvases = canvases or [] + self.event_filters = [] def findChildren(self, cls): return [c for c in self._canvases if isinstance(c, cls)] + def installEventFilter(self, obj): + self.event_filters.append(obj) + + def removeEventFilter(self, obj): + self.event_filters.remove(obj) + class _FakeBar: def pushMessage(self, *args, **kwargs): @@ -143,13 +153,14 @@ class _FakeIface: self.menu_actions.remove(action) -def _make_plugin(active_layer=None, canvas=None): +def _make_session(active_layer=None, canvas=None): iface = _FakeIface(active_layer=active_layer) plugin = ProfileInterpreterPlugin(iface) - plugin._canvas = canvas or _FakeCanvas() + session = _PanelSession(plugin, canvas or _FakeCanvas(), fq.QToolBar()) + plugin._sessions.append(session) fq.QgsProject._instance = None fq.QgsGeometry._next_interpolate_empty = False - return plugin, iface + return session, iface # ── _point_attributes ───────────────────────────────────────────────────── @@ -270,36 +281,36 @@ class TestIsSuitableTarget(unittest.TestCase): # ── _on_pick early exits ────────────────────────────────────────────────── class TestOnPickEarlyExits(unittest.TestCase): - def _pick(self, plugin, canvas=None): + def _pick(self, session, canvas=None): """Fire _on_pick and return feature count on memory layer.""" event = _FakeEvent() - plugin._on_pick(event) + session._on_pick(event) def test_nan_distance_skipped(self): canvas = _FakeCanvas(distance=math.nan, elevation=5.0) - plugin, _ = _make_plugin(canvas=canvas) - self._pick(plugin) - self.assertIsNone(plugin._layer) + session, _ = _make_session(canvas=canvas) + self._pick(session) + self.assertIsNone(session._plugin._layer) def test_nan_elevation_skipped(self): canvas = _FakeCanvas(distance=10.0, elevation=math.nan) - plugin, _ = _make_plugin(canvas=canvas) - self._pick(plugin) - self.assertIsNone(plugin._layer) + session, _ = _make_session(canvas=canvas) + self._pick(session) + self.assertIsNone(session._plugin._layer) def test_none_curve_skipped(self): canvas = _FakeCanvas(curve=None) - plugin, _ = _make_plugin(canvas=canvas) - self._pick(plugin) - self.assertIsNone(plugin._layer) + session, _ = _make_session(canvas=canvas) + self._pick(session) + self.assertIsNone(session._plugin._layer) def test_empty_interpolated_geom_skipped(self): canvas = _FakeCanvas() - plugin, _ = _make_plugin(canvas=canvas) + session, _ = _make_session(canvas=canvas) fq.QgsGeometry._next_interpolate_empty = True - self._pick(plugin) + self._pick(session) fq.QgsGeometry._next_interpolate_empty = False - self.assertIsNone(plugin._layer) + self.assertIsNone(session._plugin._layer) # ── _on_pick successful pick ────────────────────────────────────────────── @@ -311,25 +322,25 @@ class TestOnPickSuccess(unittest.TestCase): fq.QgsCoordinateTransform.reset() def test_uses_memory_layer_when_no_suitable_active(self): - plugin, _ = _make_plugin(active_layer=None) - plugin._on_pick(_FakeEvent()) + session, _ = _make_session(active_layer=None) + session._on_pick(_FakeEvent()) # memory layer was created and feature was added - layer = plugin._layer + layer = session._plugin._layer self.assertIsNotNone(layer) self.assertEqual(len(layer._features), 1) def test_feature_ids_do_not_collide(self): - plugin, _ = _make_plugin() - plugin._on_pick(_FakeEvent()) - plugin._on_pick(_FakeEvent()) - layer = plugin._layer + session, _ = _make_session() + session._on_pick(_FakeEvent()) + session._on_pick(_FakeEvent()) + layer = session._plugin._layer ids = [f._attrs.get('id') for f in layer._features] self.assertEqual(ids, [1, 2]) def test_memory_layer_attrs_set(self): - plugin, _ = _make_plugin(active_layer=None) - plugin._on_pick(_FakeEvent()) - feature = plugin._layer._features[0] + session, _ = _make_session(active_layer=None) + session._on_pick(_FakeEvent()) + feature = session._plugin._layer._features[0] self.assertEqual(feature._attrs.get('id'), 1) self.assertIn('distance', feature._attrs) self.assertIn('elevation', feature._attrs) @@ -338,18 +349,18 @@ class TestOnPickSuccess(unittest.TestCase): active = fq.QgsVectorLayer('PointZ', 'my_layer', 'memory') active._fields_list.append(fq.QgsField('distance')) active._fields_list.append(fq.QgsField('elevation')) - plugin, _ = _make_plugin(active_layer=active) - plugin._on_pick(_FakeEvent()) + session, _ = _make_session(active_layer=active) + session._on_pick(_FakeEvent()) # feature went to active layer, not memory layer self.assertEqual(len(active._features), 1) - self.assertIsNone(plugin._layer) + self.assertIsNone(session._plugin._layer) def test_only_matching_fields_written_to_active_layer(self): active = fq.QgsVectorLayer('PointZ', 'my_layer', 'memory') active._fields_list.append(fq.QgsField('elevation')) # 'id' and 'distance' fields absent - plugin, _ = _make_plugin(active_layer=active) - plugin._on_pick(_FakeEvent()) + session, _ = _make_session(active_layer=active) + session._on_pick(_FakeEvent()) feature = active._features[0] self.assertIn('elevation', feature._attrs) self.assertNotIn('id', feature._attrs) @@ -357,22 +368,22 @@ class TestOnPickSuccess(unittest.TestCase): def test_canvas_refresh_called_once(self): canvas = _FakeCanvas() - plugin, _ = _make_plugin(canvas=canvas) - plugin._on_pick(_FakeEvent()) + session, _ = _make_session(canvas=canvas) + session._on_pick(_FakeEvent()) self.assertEqual(canvas.refresh_count, 1) def test_canvas_refresh_not_called_on_early_exit(self): canvas = _FakeCanvas(curve=None) - plugin, _ = _make_plugin(canvas=canvas) - plugin._on_pick(_FakeEvent()) + session, _ = _make_session(canvas=canvas) + session._on_pick(_FakeEvent()) self.assertEqual(canvas.refresh_count, 0) def test_active_layer_different_crs_transforms_geometry(self): active = fq.QgsVectorLayer('PointZ', 'my_layer', 'memory') active._crs = fq._FakeCrs('EPSG:32632') canvas = _FakeCanvas(crs=fq._FakeCrs('EPSG:4326')) - plugin, _ = _make_plugin(active_layer=active, canvas=canvas) - plugin._on_pick(_FakeEvent()) + session, _ = _make_session(active_layer=active, canvas=canvas) + session._on_pick(_FakeEvent()) self.assertEqual(len(fq.QgsCoordinateTransform._calls), 1) feature = active._features[0] self.assertAlmostEqual(feature._geom._obj._x, 1100.0) @@ -382,8 +393,8 @@ class TestOnPickSuccess(unittest.TestCase): active = fq.QgsVectorLayer('PointZ', 'my_layer', 'memory') active._crs = fq._FakeCrs('EPSG:4326') canvas = _FakeCanvas(crs=fq._FakeCrs('EPSG:4326')) - plugin, _ = _make_plugin(active_layer=active, canvas=canvas) - plugin._on_pick(_FakeEvent()) + session, _ = _make_session(active_layer=active, canvas=canvas) + session._on_pick(_FakeEvent()) self.assertEqual(fq.QgsCoordinateTransform._calls, []) feature = active._features[0] self.assertAlmostEqual(feature._geom._obj._x, 100.0) @@ -410,35 +421,6 @@ class TestPickTool(unittest.TestCase): self.assertEqual(calls, []) -# ── _find_profile_canvas ────────────────────────────────────────────────── - -class TestFindProfileCanvas(unittest.TestCase): - def test_prefers_visible_canvas(self): - hidden = fq.QgsElevationProfileCanvas() - hidden._visible = False - visible = fq.QgsElevationProfileCanvas() - visible._visible = True - - iface = _FakeIface(canvases=[hidden, visible]) - plugin = ProfileInterpreterPlugin(iface) - self.assertIs(plugin._find_profile_canvas(), visible) - - def test_falls_back_to_first_when_all_hidden(self): - c1 = fq.QgsElevationProfileCanvas() - c1._visible = False - c2 = fq.QgsElevationProfileCanvas() - c2._visible = False - - iface = _FakeIface(canvases=[c1, c2]) - plugin = ProfileInterpreterPlugin(iface) - self.assertIs(plugin._find_profile_canvas(), c1) - - def test_returns_none_when_no_canvases(self): - iface = _FakeIface(canvases=[]) - plugin = ProfileInterpreterPlugin(iface) - self.assertIsNone(plugin._find_profile_canvas()) - - # ── _interpretation_layer field names ──────────────────────────────────── class TestInterpretationLayerFields(unittest.TestCase): @@ -447,9 +429,9 @@ class TestInterpretationLayerFields(unittest.TestCase): fq.QgsGeometry._next_interpolate_empty = False def test_fallback_layer_has_exactly_id_distance_elevation(self): - plugin, _ = _make_plugin(active_layer=None) - plugin._on_pick(_FakeEvent()) - layer = plugin._layer + session, _ = _make_session(active_layer=None) + session._on_pick(_FakeEvent()) + layer = session._plugin._layer self.assertIsNotNone(layer) field_names = {f.name() for f in layer.fields()} self.assertEqual(field_names, {'id', 'distance', 'elevation'}) @@ -466,16 +448,16 @@ class TestOnPickAddFeaturesFailure(unittest.TestCase): fq._FakeDataProvider._fail_next = False def test_failure_shows_warning_and_adds_no_features(self): - plugin, iface = _make_plugin(active_layer=None) + session, iface = _make_session(active_layer=None) bar = _TrackingBar() iface._bar = bar fq._FakeDataProvider._fail_next = True - canvas = plugin._canvas - plugin._on_pick(_FakeEvent()) + canvas = session._canvas + session._on_pick(_FakeEvent()) # feature was not committed - self.assertEqual(len(plugin._layer._features), 0) + self.assertEqual(len(session._plugin._layer._features), 0) # a warning was pushed self.assertTrue(any(level == fq.Qgis.Warning for _, _, level in bar.messages)) # canvas was not refreshed @@ -487,33 +469,29 @@ class TestOnPickAddFeaturesFailure(unittest.TestCase): class TestDeactivate(unittest.TestCase): def _make_active_plugin(self): canvas = _FakeCanvas() - iface = _FakeIface() - plugin = ProfileInterpreterPlugin(iface) - plugin._canvas = canvas + session, _ = _make_session(canvas=canvas) pick_tool = fq.QgsPlotTool(canvas, 'test') - plugin._pick_tool = pick_tool + session._pick_tool = pick_tool canvas._tool = pick_tool - return plugin, canvas, pick_tool + return session, canvas, pick_tool def test_pick_tool_replaced_when_pan_available(self): - plugin, canvas, pick_tool = self._make_active_plugin() - plugin._deactivate() + session, canvas, pick_tool = self._make_active_plugin() + session._deactivate() self.assertIsNot(canvas._tool, pick_tool) - self.assertIsNone(plugin._pick_tool) - self.assertIsNone(plugin._canvas) + self.assertIsNone(session._pick_tool) def test_pick_tool_replaced_when_pan_none(self): import profile_interpreter.profile_interpreter as pi_module - plugin, canvas, pick_tool = self._make_active_plugin() + session, canvas, pick_tool = self._make_active_plugin() original = pi_module.QgsPlotToolPan pi_module.QgsPlotToolPan = None try: - plugin._deactivate() + session._deactivate() finally: pi_module.QgsPlotToolPan = original self.assertIsNot(canvas._tool, pick_tool) - self.assertIsNone(plugin._pick_tool) - self.assertIsNone(plugin._canvas) + self.assertIsNone(session._pick_tool) # ── _can_delete ─────────────────────────────────────────────────────────── @@ -562,6 +540,28 @@ class _FakeWidget: return [c for c in self._children if isinstance(c, cls)] +def _panel(main_window, toolbar=None): + """Profile canvas inside a panel widget holding toolbar (or no toolbar).""" + canvas = fq.QgsElevationProfileCanvas() + children = [toolbar] if toolbar is not None else [] + canvas._parent = _FakeWidget(parent=main_window, children=children) + main_window._canvases.append(canvas) + return canvas + + +def _activated_session(): + main_window = _FakeMainWindow() + toolbar = fq.QToolBar() + _panel(main_window, toolbar) + iface = _FakeIface() + iface._window = main_window + plugin = ProfileInterpreterPlugin(iface) + plugin.initGui() + session = plugin._sessions[0] + session._action.setChecked(True) + return session, iface, toolbar + + class TestFindPanelToolbar(unittest.TestCase): def test_finds_toolbar_in_panel(self): main_window = _FakeWidget() @@ -593,67 +593,164 @@ class TestFindPanelToolbar(unittest.TestCase): self.assertIsNone(_find_panel_toolbar(canvas, _FakeWidget())) -# ── undo action lifecycle ───────────────────────────────────────────────── +# ── per-panel sessions ──────────────────────────────────────────────────── -class TestUndoAction(unittest.TestCase): +class TestPanelSessions(unittest.TestCase): def setUp(self): fq.QgsProject._instance = None fq.QgsGeometry._next_interpolate_empty = False + fq.QgsGeometry._lateral_distance = 0.0 fq._FakeDataProvider._fail_next = False fq._FakeDataProvider._fail_next_delete = False - def _activated_plugin(self): - """Plugin with initGui() run and the pick tool active on a panel canvas.""" - canvas = fq.QgsElevationProfileCanvas() - toolbar = fq.QToolBar() - main_window = _FakeMainWindow([canvas]) - panel = _FakeWidget(parent=main_window, children=[toolbar]) - canvas._parent = panel - + def _plugin(self, panels=2): + """Plugin after initGui() with that many panels, each with its own toolbar.""" + main_window = _FakeMainWindow() + toolbars = [fq.QToolBar() for _ in range(panels)] + canvases = [_panel(main_window, toolbar) for toolbar in toolbars] iface = _FakeIface() iface._window = main_window plugin = ProfileInterpreterPlugin(iface) plugin.initGui() - plugin._set_active(True) - return plugin, iface, toolbar + return plugin, iface, canvases, toolbars + + def test_init_adds_nothing_to_plugins_toolbar_or_menu(self): + plugin, iface, _, _ = self._plugin(panels=0) + self.assertEqual(iface.toolbar_actions, []) + self.assertEqual(iface.menu_actions, []) + self.assertEqual(iface._window.event_filters, [plugin._watcher]) + + def test_each_panel_gets_its_own_interpret_action(self): + plugin, _, canvases, toolbars = self._plugin() + a, b = plugin._sessions + self.assertEqual((a._canvas, b._canvas), tuple(canvases)) + self.assertEqual(toolbars[0].actions_added, [a._action]) + self.assertEqual(toolbars[1].actions_added, [b._action]) + self.assertIsNot(a._action, b._action) + + def test_rescan_creates_no_duplicates(self): + plugin, _, _, toolbars = self._plugin() + plugin._scan_panels() + self.assertEqual(len(plugin._sessions), 2) + self.assertEqual([len(t.actions_added) for t in toolbars], [1, 1]) + + def test_panel_opened_later_gets_a_session(self): + plugin, iface, _, _ = self._plugin(panels=0) + toolbar = fq.QToolBar() + canvas = _panel(iface._window, toolbar) + plugin._watcher.eventFilter(iface._window, fq.QEvent(fq.QEvent.Type.Show)) + self.assertEqual(plugin._sessions, []) + handled = plugin._watcher.eventFilter( + iface._window, fq.QEvent(fq.QEvent.Type.ChildAdded)) + self.assertFalse(handled) + self.assertEqual([s._canvas for s in plugin._sessions], [canvas]) + self.assertEqual(toolbar.actions_added, [plugin._sessions[0]._action]) + + def test_panel_without_toolbar_is_skipped(self): + plugin, iface, _, _ = self._plugin(panels=0) + _panel(iface._window) + plugin._scan_panels() + self.assertEqual(plugin._sessions, []) + + def test_panels_switch_on_independently(self): + plugin, _, canvases, toolbars = self._plugin() + a, b = plugin._sessions + a._action.setChecked(True) + self.assertIs(canvases[0]._tool, a._pick_tool) + self.assertIsNone(canvases[1]._tool) + self.assertEqual(toolbars[0].actions_added, + [a._action, a._undo_action, a._edit_action, a._delete_action]) + self.assertEqual(toolbars[1].actions_added, [b._action]) + b._action.setChecked(True) + self.assertIs(canvases[0]._tool, a._pick_tool) + self.assertIs(canvases[1]._tool, b._pick_tool) + + def test_undo_histories_are_separate(self): + plugin, _, _, _ = self._plugin() + a, b = plugin._sessions + a._on_pick(_FakeEvent()) + a._on_pick(_FakeEvent()) + b._on_pick(_FakeEvent()) + layer = plugin._layer + b._undo_last_point() + self.assertEqual([f._attrs['id'] for f in layer._features], [1, 2]) + self.assertEqual(len(a._history), 2) + self.assertFalse(b._undo_action.isEnabled()) + + def test_delete_from_other_panel_prunes_history(self): + plugin, _, _, _ = self._plugin() + a, b = plugin._sessions + a._on_pick(_FakeEvent()) + layer = plugin._layer + layer._features[0].setGeometry(fq.QgsGeometry(fq.QgsPoint(30.0, 0.0, 5.0))) + b._action.setChecked(True) + b._delete_action.setChecked(True) + b._on_pick(_FakeEvent(x=30.0, y=5.0)) + self.assertEqual(layer._features, []) + self.assertEqual(a._history, []) + self.assertFalse(a._undo_action.isEnabled()) + + def test_closed_panel_drops_its_session_only(self): + plugin, _, canvases, _ = self._plugin() + a, b = plugin._sessions + a._action.setChecked(True) + b._action.setChecked(True) + + def deleted(*args): + raise RuntimeError('wrapped C/C++ object has been deleted') + canvases[0].setTool = deleted + canvases[0].destroyed.emit(canvases[0]) + self.assertEqual(plugin._sessions, [b]) + self.assertIs(canvases[1]._tool, b._pick_tool) + plugin.unload() - def test_added_to_plugin_menu_on_init(self): - iface = _FakeIface() - plugin = ProfileInterpreterPlugin(iface) - plugin.initGui() - self.assertIn(plugin._undo_action, iface.menu_actions) + def test_unload_restores_pan_and_removes_everything(self): + plugin, iface, canvases, toolbars = self._plugin() + plugin._sessions[0]._action.setChecked(True) + plugin.unload() + self.assertIsInstance(canvases[0]._tool, fq.QgsPlotToolPan) + self.assertIsNone(canvases[1]._tool) + self.assertEqual([t.actions_added for t in toolbars], [[], []]) + self.assertEqual(iface._window.event_filters, []) + self.assertEqual(plugin._sessions, []) + plugin._scan_panels() + self.assertEqual(plugin._sessions, []) + + def test_watcher_ignores_events_while_pending(self): + calls = [] + watcher = _PanelWatcher(lambda: calls.append(1)) + watcher._pending = True + watcher.eventFilter(None, fq.QEvent(fq.QEvent.Type.ChildAdded)) + self.assertEqual(calls, []) + + +# ── undo action lifecycle ───────────────────────────────────────────────── + +class TestUndoAction(unittest.TestCase): + def setUp(self): + fq.QgsProject._instance = None + fq.QgsGeometry._next_interpolate_empty = False + fq._FakeDataProvider._fail_next = False + fq._FakeDataProvider._fail_next_delete = False + + def _activated(self): + """Session of a panel found by initGui(), with the pick tool on.""" + return _activated_session() def test_disabled_until_a_point_is_placed(self): - plugin, _, _ = self._activated_plugin() - self.assertFalse(plugin._undo_action.isEnabled()) - plugin._on_pick(_FakeEvent()) - self.assertTrue(plugin._undo_action.isEnabled()) + session, _, _ = self._activated() + self.assertFalse(session._undo_action.isEnabled()) + session._on_pick(_FakeEvent()) + self.assertTrue(session._undo_action.isEnabled()) def test_appears_on_panel_toolbar_while_active(self): - plugin, _, toolbar = self._activated_plugin() - self.assertIn(plugin._undo_action, toolbar.actions_added) + session, _, toolbar = self._activated() + self.assertIn(session._undo_action, toolbar.actions_added) def test_removed_from_panel_toolbar_when_deactivated(self): - plugin, _, toolbar = self._activated_plugin() - plugin._set_active(False) - self.assertNotIn(plugin._undo_action, toolbar.actions_added) - - def test_missing_panel_toolbar_is_tolerated(self): - canvas = fq.QgsElevationProfileCanvas() - main_window = _FakeMainWindow([canvas]) - canvas._parent = _FakeWidget(parent=main_window) - iface = _FakeIface() - iface._window = main_window - plugin = ProfileInterpreterPlugin(iface) - plugin.initGui() - plugin._set_active(True) - self.assertIsNone(plugin._panel_toolbar) - - def test_menu_entry_removed_on_unload(self): - plugin, iface, _ = self._activated_plugin() - undo_action = plugin._undo_action - plugin.unload() - self.assertNotIn(undo_action, iface.menu_actions) + session, _, toolbar = self._activated() + session._action.setChecked(False) + self.assertNotIn(session._undo_action, toolbar.actions_added) # ── _undo_last_point ────────────────────────────────────────────────────── @@ -666,80 +763,80 @@ class TestUndoLastPoint(unittest.TestCase): fq._FakeDataProvider._fail_next_delete = False def test_removes_last_placed_feature(self): - plugin, _ = _make_plugin() - plugin._on_pick(_FakeEvent()) - plugin._on_pick(_FakeEvent()) - layer = plugin._layer - plugin._undo_last_point() + session, _ = _make_session() + session._on_pick(_FakeEvent()) + session._on_pick(_FakeEvent()) + layer = session._plugin._layer + session._undo_last_point() self.assertEqual([f._attrs['id'] for f in layer._features], [1]) def test_repeated_undo_empties_the_layer(self): - plugin, _ = _make_plugin() - plugin._on_pick(_FakeEvent()) - plugin._on_pick(_FakeEvent()) - layer = plugin._layer - plugin._undo_last_point() - plugin._undo_last_point() + session, _ = _make_session() + session._on_pick(_FakeEvent()) + session._on_pick(_FakeEvent()) + layer = session._plugin._layer + session._undo_last_point() + session._undo_last_point() self.assertEqual(layer._features, []) - self.assertEqual(plugin._history, []) + self.assertEqual(session._history, []) def test_undo_with_empty_history_is_a_no_op(self): - plugin, _ = _make_plugin() - plugin._undo_last_point() - self.assertEqual(plugin._history, []) + session, _ = _make_session() + session._undo_last_point() + self.assertEqual(session._history, []) def test_ids_reused_after_undo(self): - plugin, _ = _make_plugin() - plugin._on_pick(_FakeEvent()) - plugin._on_pick(_FakeEvent()) - plugin._undo_last_point() - plugin._on_pick(_FakeEvent()) - self.assertEqual([f._attrs['id'] for f in plugin._layer._features], [1, 2]) + session, _ = _make_session() + session._on_pick(_FakeEvent()) + session._on_pick(_FakeEvent()) + session._undo_last_point() + session._on_pick(_FakeEvent()) + self.assertEqual([f._attrs['id'] for f in session._plugin._layer._features], [1, 2]) def test_canvas_refreshed_after_undo(self): canvas = _FakeCanvas() - plugin, _ = _make_plugin(canvas=canvas) - plugin._on_pick(_FakeEvent()) - plugin._undo_last_point() + session, _ = _make_session(canvas=canvas) + session._on_pick(_FakeEvent()) + session._undo_last_point() self.assertEqual(canvas.refresh_count, 2) def test_layer_removed_from_project_is_skipped(self): - plugin, _ = _make_plugin() - plugin._on_pick(_FakeEvent()) - plugin._on_pick(_FakeEvent()) - layer = plugin._layer + session, _ = _make_session() + session._on_pick(_FakeEvent()) + session._on_pick(_FakeEvent()) + layer = session._plugin._layer fq.QgsProject.instance()._layers.pop(layer.id()) - plugin._undo_last_point() + session._undo_last_point() # both history entries pointed at the vanished layer; nothing deleted self.assertEqual(len(layer._features), 2) - self.assertEqual(plugin._history, []) + self.assertEqual(session._history, []) def test_read_only_layer_warns_and_keeps_feature(self): - plugin, iface = _make_plugin() + session, iface = _make_session() bar = _TrackingBar() iface._bar = bar - plugin._on_pick(_FakeEvent()) - layer = plugin._layer + session._on_pick(_FakeEvent()) + layer = session._plugin._layer layer._provider._caps = fq.QgsVectorDataProvider.AddFeatures - plugin._undo_last_point() + session._undo_last_point() self.assertEqual(len(layer._features), 1) self.assertTrue(any(level == fq.Qgis.Warning for _, _, level in bar.messages)) def test_delete_failure_warns(self): - plugin, iface = _make_plugin() + session, iface = _make_session() bar = _TrackingBar() iface._bar = bar - plugin._on_pick(_FakeEvent()) + session._on_pick(_FakeEvent()) fq._FakeDataProvider._fail_next_delete = True - plugin._undo_last_point() - self.assertEqual(len(plugin._layer._features), 1) + session._undo_last_point() + self.assertEqual(len(session._plugin._layer._features), 1) self.assertTrue(any(level == fq.Qgis.Warning for _, _, level in bar.messages)) def test_failed_pick_adds_no_history(self): - plugin, _ = _make_plugin() + session, _ = _make_session() fq._FakeDataProvider._fail_next = True - plugin._on_pick(_FakeEvent()) - self.assertEqual(plugin._history, []) + session._on_pick(_FakeEvent()) + self.assertEqual(session._history, []) # ── _nearest_hit / _can_move ────────────────────────────────────────────── @@ -800,157 +897,135 @@ class _ModeTestCase(unittest.TestCase): fq._FakeDataProvider._fail_next_delete = False fq._FakeDataProvider._fail_next_change = False - def _plugin(self, layer=None, canvas=None): - plugin, iface = _make_plugin(active_layer=layer, canvas=canvas) + def _session(self, layer=None, canvas=None): + session, iface = _make_session(active_layer=layer, canvas=canvas) if layer is not None: fq.QgsProject.instance().addMapLayer(layer) iface._bar = _TrackingBar() - plugin.initGui() - return plugin, iface + return session, iface class TestModeActions(_ModeTestCase): - def _activated_plugin(self): - canvas = fq.QgsElevationProfileCanvas() - toolbar = fq.QToolBar() - main_window = _FakeMainWindow([canvas]) - canvas._parent = _FakeWidget(parent=main_window, children=[toolbar]) - iface = _FakeIface() - iface._window = main_window - plugin = ProfileInterpreterPlugin(iface) - plugin.initGui() - plugin._set_active(True) - return plugin, iface, toolbar - - def test_added_to_plugin_menu_disabled(self): - plugin, iface = self._plugin() - for action in (plugin._edit_action, plugin._delete_action): - self.assertIn(action, iface.menu_actions) - self.assertFalse(action.isEnabled()) + def _activated(self): + return _activated_session() def test_attached_next_to_undo_while_active(self): - plugin, _, toolbar = self._activated_plugin() + session, _, toolbar = self._activated() self.assertEqual( toolbar.actions_added, - [plugin._undo_action, plugin._edit_action, plugin._delete_action]) - self.assertTrue(plugin._edit_action.isEnabled()) - self.assertTrue(plugin._delete_action.isEnabled()) + [session._action, session._undo_action, session._edit_action, + session._delete_action]) + self.assertTrue(session._edit_action.isEnabled()) + self.assertTrue(session._delete_action.isEnabled()) def test_detached_and_reset_on_deactivate(self): - plugin, _, toolbar = self._activated_plugin() - plugin._edit_action.setChecked(True) - plugin._set_active(False) - self.assertEqual(toolbar.actions_added, []) - self.assertFalse(plugin._edit_action.isChecked()) - self.assertFalse(plugin._edit_action.isEnabled()) - self.assertEqual(plugin._mode, 'add') + session, _, toolbar = self._activated() + session._edit_action.setChecked(True) + session._action.setChecked(False) + self.assertEqual(toolbar.actions_added, [session._action]) + self.assertFalse(session._edit_action.isChecked()) + self.assertFalse(session._edit_action.isEnabled()) + self.assertEqual(session._mode, 'add') def test_edit_and_delete_are_exclusive(self): - plugin, _ = self._plugin() - plugin._edit_action.setChecked(True) - self.assertEqual(plugin._mode, 'edit') - plugin._delete_action.setChecked(True) - self.assertEqual(plugin._mode, 'delete') - self.assertFalse(plugin._edit_action.isChecked()) - plugin._delete_action.setChecked(False) - self.assertEqual(plugin._mode, 'add') - - def test_menu_entries_removed_on_unload(self): - plugin, iface, _ = self._activated_plugin() - edit, delete = plugin._edit_action, plugin._delete_action - plugin.unload() - self.assertNotIn(edit, iface.menu_actions) - self.assertNotIn(delete, iface.menu_actions) + session, _ = self._session() + session._edit_action.setChecked(True) + self.assertEqual(session._mode, 'edit') + session._delete_action.setChecked(True) + self.assertEqual(session._mode, 'delete') + self.assertFalse(session._edit_action.isChecked()) + session._delete_action.setChecked(False) + self.assertEqual(session._mode, 'add') class TestEditPoint(_ModeTestCase): def test_select_then_move_updates_geometry_and_attrs(self): layer = _layer_with_points([(30.0, 5.0), (60.0, 5.0)]) canvas = _FakeCanvas(distance=12.0, elevation=7.0) - plugin, _ = self._plugin(layer, canvas) - plugin._edit_action.setChecked(True) + session, _ = self._session(layer, canvas) + session._edit_action.setChecked(True) - plugin._on_pick(_FakeEvent(x=31.0, y=6.0)) - self.assertEqual(plugin._selected, (layer, 1)) + session._on_pick(_FakeEvent(x=31.0, y=6.0)) + self.assertEqual(session._selected, (layer, 1)) self.assertEqual(layer.selected_ids, [1]) - plugin._on_pick(_FakeEvent(x=200.0, y=200.0)) + session._on_pick(_FakeEvent(x=200.0, y=200.0)) moved = layer._features[0] self.assertEqual(_xyz(moved), (100.0, 200.0, 7.0)) self.assertEqual(moved._attrs['distance'], 12.0) self.assertEqual(moved._attrs['elevation'], 7.0) self.assertNotIn('id', moved._attrs) self.assertEqual(_xyz(layer._features[1]), (60.0, 0.0, 5.0)) - self.assertIsNone(plugin._selected) + self.assertIsNone(session._selected) self.assertEqual(layer.selected_ids, []) self.assertEqual(canvas.refresh_count, 1) def test_miss_with_nothing_selected_changes_nothing(self): layer = _layer_with_points([(30.0, 5.0)]) - plugin, iface = self._plugin(layer) - plugin._edit_action.setChecked(True) - plugin._on_pick(_FakeEvent(x=80.0, y=80.0)) - self.assertIsNone(plugin._selected) + session, iface = self._session(layer) + session._edit_action.setChecked(True) + session._on_pick(_FakeEvent(x=80.0, y=80.0)) + self.assertIsNone(session._selected) self.assertEqual(_xyz(layer._features[0]), (30.0, 0.0, 5.0)) self.assertIn('No point near the click.', [m for _, m, _ in iface._bar.messages]) def test_right_click_cancels_selection(self): layer = _layer_with_points([(30.0, 5.0)]) - plugin, _ = self._plugin(layer) - plugin._edit_action.setChecked(True) - plugin._on_pick(_FakeEvent(x=30.0, y=5.0)) - tool = _ProfilePickTool(plugin._canvas, plugin._on_pick, plugin._clear_selection) + session, _ = self._session(layer) + session._edit_action.setChecked(True) + session._on_pick(_FakeEvent(x=30.0, y=5.0)) + tool = _ProfilePickTool(session._canvas, session._on_pick, session._clear_selection) tool.plotReleaseEvent(_FakeEvent(button=_RIGHT_BUTTON)) - self.assertIsNone(plugin._selected) + self.assertIsNone(session._selected) self.assertEqual(layer.selected_ids, []) def test_toggling_edit_off_cancels_selection(self): layer = _layer_with_points([(30.0, 5.0)]) - plugin, _ = self._plugin(layer) - plugin._edit_action.setChecked(True) - plugin._on_pick(_FakeEvent(x=30.0, y=5.0)) - plugin._edit_action.setChecked(False) - self.assertIsNone(plugin._selected) - self.assertEqual(plugin._mode, 'add') + session, _ = self._session(layer) + session._edit_action.setChecked(True) + session._on_pick(_FakeEvent(x=30.0, y=5.0)) + session._edit_action.setChecked(False) + self.assertIsNone(session._selected) + self.assertEqual(session._mode, 'add') def test_layer_without_change_geometries_warns(self): layer = _layer_with_points([(30.0, 5.0)]) layer._provider._caps = fq.QgsVectorDataProvider.AddFeatures - plugin, iface = self._plugin(layer) - plugin._edit_action.setChecked(True) - plugin._on_pick(_FakeEvent(x=30.0, y=5.0)) - self.assertIsNone(plugin._selected) + session, iface = self._session(layer) + session._edit_action.setChecked(True) + session._on_pick(_FakeEvent(x=30.0, y=5.0)) + self.assertIsNone(session._selected) self.assertTrue(any(lvl == fq.Qgis.Warning for _, _, lvl in iface._bar.messages)) def test_change_failure_warns_and_keeps_selection(self): layer = _layer_with_points([(30.0, 5.0)]) - plugin, iface = self._plugin(layer) - plugin._edit_action.setChecked(True) - plugin._on_pick(_FakeEvent(x=30.0, y=5.0)) + session, iface = self._session(layer) + session._edit_action.setChecked(True) + session._on_pick(_FakeEvent(x=30.0, y=5.0)) fq._FakeDataProvider._fail_next_change = True - plugin._on_pick(_FakeEvent(x=0.0, y=0.0)) + session._on_pick(_FakeEvent(x=0.0, y=0.0)) self.assertEqual(_xyz(layer._features[0]), (30.0, 0.0, 5.0)) - self.assertEqual(plugin._selected, (layer, 1)) + self.assertEqual(session._selected, (layer, 1)) self.assertTrue(any(lvl == fq.Qgis.Warning for _, _, lvl in iface._bar.messages)) def test_points_beyond_profile_tolerance_ignored(self): layer = _layer_with_points([(30.0, 5.0)]) canvas = _FakeCanvas() canvas.tolerance = lambda: 5.0 - plugin, _ = self._plugin(layer, canvas) - plugin._edit_action.setChecked(True) + session, _ = self._session(layer, canvas) + session._edit_action.setChecked(True) fq.QgsGeometry._lateral_distance = 6.0 - plugin._on_pick(_FakeEvent(x=30.0, y=5.0)) - self.assertIsNone(plugin._selected) + session._on_pick(_FakeEvent(x=30.0, y=5.0)) + self.assertIsNone(session._selected) fq.QgsGeometry._lateral_distance = 3.0 - plugin._on_pick(_FakeEvent(x=30.0, y=5.0)) - self.assertEqual(plugin._selected, (layer, 1)) + session._on_pick(_FakeEvent(x=30.0, y=5.0)) + self.assertEqual(session._selected, (layer, 1)) def test_no_fallback_layer_created(self): - plugin, _ = self._plugin() - plugin._edit_action.setChecked(True) - plugin._on_pick(_FakeEvent(x=30.0, y=5.0)) - self.assertIsNone(plugin._layer) + session, _ = self._session() + session._edit_action.setChecked(True) + session._on_pick(_FakeEvent(x=30.0, y=5.0)) + self.assertIsNone(session._plugin._layer) class TestSnapping(_ModeTestCase): @@ -964,33 +1039,33 @@ class TestSnapping(_ModeTestCase): def test_add_converts_snapped_point(self): canvas = _FakeCanvas() - plugin, _ = self._plugin(canvas=canvas) - plugin._on_pick(_FakeEvent(x=1.0, y=2.0, snapped=(3.0, 4.0))) + session, _ = self._session(canvas=canvas) + session._on_pick(_FakeEvent(x=1.0, y=2.0, snapped=(3.0, 4.0))) self.assertEqual(canvas.last_canvas_point, (3.0, 4.0)) - self.assertEqual(len(plugin._layer._features), 1) + self.assertEqual(len(session._plugin._layer._features), 1) def test_move_converts_snapped_point(self): layer = _layer_with_points([(30.0, 5.0)]) canvas = _FakeCanvas() - plugin, _ = self._plugin(layer, canvas) - plugin._edit_action.setChecked(True) - plugin._on_pick(_FakeEvent(x=30.0, y=5.0)) - plugin._on_pick(_FakeEvent(x=1.0, y=2.0, snapped=(3.0, 4.0))) + session, _ = self._session(layer, canvas) + session._edit_action.setChecked(True) + session._on_pick(_FakeEvent(x=30.0, y=5.0)) + session._on_pick(_FakeEvent(x=1.0, y=2.0, snapped=(3.0, 4.0))) self.assertEqual(canvas.last_canvas_point, (3.0, 4.0)) - self.assertIsNone(plugin._selected) + self.assertIsNone(session._selected) def test_select_for_edit_uses_raw_cursor(self): layer = _layer_with_points([(30.0, 5.0)]) - plugin, _ = self._plugin(layer) - plugin._edit_action.setChecked(True) - plugin._on_pick(_FakeEvent(x=30.0, y=5.0, snapped=(200.0, 200.0))) - self.assertEqual(plugin._selected, (layer, 1)) + session, _ = self._session(layer) + session._edit_action.setChecked(True) + session._on_pick(_FakeEvent(x=30.0, y=5.0, snapped=(200.0, 200.0))) + self.assertEqual(session._selected, (layer, 1)) def test_delete_uses_raw_cursor(self): layer = _layer_with_points([(30.0, 5.0)]) - plugin, _ = self._plugin(layer) - plugin._delete_action.setChecked(True) - plugin._on_pick(_FakeEvent(x=30.0, y=5.0, snapped=(200.0, 200.0))) + session, _ = self._session(layer) + session._delete_action.setChecked(True) + session._on_pick(_FakeEvent(x=30.0, y=5.0, snapped=(200.0, 200.0))) self.assertEqual(layer._features, []) @@ -998,47 +1073,47 @@ class TestDeletePoint(_ModeTestCase): def test_deletes_point_under_click(self): layer = _layer_with_points([(30.0, 5.0), (60.0, 5.0)]) canvas = _FakeCanvas() - plugin, _ = self._plugin(layer, canvas) - plugin._delete_action.setChecked(True) - plugin._on_pick(_FakeEvent(x=58.0, y=5.0)) + session, _ = self._session(layer, canvas) + session._delete_action.setChecked(True) + session._on_pick(_FakeEvent(x=58.0, y=5.0)) self.assertEqual([f.id() for f in layer._features], [1]) self.assertEqual(canvas.refresh_count, 1) def test_miss_is_a_no_op(self): layer = _layer_with_points([(30.0, 5.0)]) - plugin, _ = self._plugin(layer) - plugin._delete_action.setChecked(True) - plugin._on_pick(_FakeEvent(x=45.0, y=5.0)) + session, _ = self._session(layer) + session._delete_action.setChecked(True) + session._on_pick(_FakeEvent(x=45.0, y=5.0)) self.assertEqual(len(layer._features), 1) def test_deleted_point_pruned_from_undo_history(self): - plugin, _ = self._plugin() - plugin._on_pick(_FakeEvent()) - plugin._on_pick(_FakeEvent()) - layer = plugin._layer + session, _ = self._session() + session._on_pick(_FakeEvent()) + session._on_pick(_FakeEvent()) + layer = session._plugin._layer layer._features[0].setGeometry(fq.QgsGeometry(fq.QgsPoint(30.0, 0.0, 5.0))) - plugin._delete_action.setChecked(True) - plugin._on_pick(_FakeEvent(x=30.0, y=5.0)) - self.assertEqual(plugin._history, [(layer, 2)]) - plugin._undo_last_point() + session._delete_action.setChecked(True) + session._on_pick(_FakeEvent(x=30.0, y=5.0)) + self.assertEqual(session._history, [(layer, 2)]) + session._undo_last_point() self.assertEqual(layer._features, []) - self.assertFalse(plugin._undo_action.isEnabled()) + self.assertFalse(session._undo_action.isEnabled()) def test_read_only_layer_warns_and_keeps_point(self): layer = _layer_with_points([(30.0, 5.0)]) layer._provider._caps = fq.QgsVectorDataProvider.AddFeatures - plugin, iface = self._plugin(layer) - plugin._delete_action.setChecked(True) - plugin._on_pick(_FakeEvent(x=30.0, y=5.0)) + session, iface = self._session(layer) + session._delete_action.setChecked(True) + session._on_pick(_FakeEvent(x=30.0, y=5.0)) self.assertEqual(len(layer._features), 1) self.assertTrue(any(lvl == fq.Qgis.Warning for _, _, lvl in iface._bar.messages)) def test_delete_failure_warns(self): layer = _layer_with_points([(30.0, 5.0)]) - plugin, iface = self._plugin(layer) - plugin._delete_action.setChecked(True) + session, iface = self._session(layer) + session._delete_action.setChecked(True) fq._FakeDataProvider._fail_next_delete = True - plugin._on_pick(_FakeEvent(x=30.0, y=5.0)) + session._on_pick(_FakeEvent(x=30.0, y=5.0)) self.assertEqual(len(layer._features), 1) self.assertTrue(any(lvl == fq.Qgis.Warning for _, _, lvl in iface._bar.messages))