commit 6a2ad9541ad030a46eb7b0a32db8b75553acec74
parent fe78528a985140c234580b2f9b093cb5fc6a6ed3
Author: Anders Damsgaard <anders@adamsgaard.dk>
Date: Thu, 4 Jun 2026 21:39:26 +0300
feat: add test suite, active-layer targeting, and canvas redraw fix
Diffstat:
5 files changed, 736 insertions(+), 10 deletions(-)
diff --git a/Makefile b/Makefile
@@ -2,14 +2,18 @@ PLUGIN_NAME = profile_interpreter
ZIPFILE = $(PLUGIN_NAME).zip
PYTHON ?= python3
FLAKE8 ?= flake8
+TEST_DIR = test
LINT_TARGETS = $(PLUGIN_NAME)
-.PHONY: package clean lint verify
+.PHONY: package clean lint verify test
lint:
$(FLAKE8) $(LINT_TARGETS)
-verify: lint
+test:
+ $(PYTHON) -m unittest discover -s $(TEST_DIR) -p 'test_*.py'
+
+verify: lint test
package:
rm -f $(ZIPFILE)
diff --git a/README.md b/README.md
@@ -28,8 +28,11 @@ click position (canvas pixels)
The clicked vertical position becomes the feature's elevation (Z), and the
horizontal position is mapped back to a true map X/Y along the cross-section
-line. Points are stored in a memory layer named **Profile interpretations**
-with `id`, `distance`, `elevation`, and `note` fields.
+line. If the **active layer** is an editable PointZ vector layer, clicked points land
+there (only the fields that already exist are written — the schema is never
+altered). Otherwise points are stored in a memory layer named
+**Profile interpretations** with `id`, `distance`, `elevation`, and `note`
+fields.
## Requirements
@@ -68,7 +71,8 @@ with `id`, `distance`, `elevation`, and `note` fields.
```sh
make lint # run flake8
-make verify # alias for lint
+make test # run unit tests (no QGIS required)
+make verify # lint + test (what CI runs)
make package # build profile_interpreter.zip for installation
make clean # remove the zip
```
diff --git a/profile_interpreter/profile_interpreter.py b/profile_interpreter/profile_interpreter.py
@@ -23,6 +23,7 @@ try:
_LEFT_BUTTON = Qt.MouseButton.LeftButton # PyQt6 / QGIS 4
except AttributeError:
_LEFT_BUTTON = Qt.LeftButton # PyQt5 / QGIS 3
+
from qgis.core import (
Qgis,
QgsFeature,
@@ -30,7 +31,9 @@ from qgis.core import (
QgsGeometry,
QgsPoint,
QgsProject,
+ QgsVectorDataProvider,
QgsVectorLayer,
+ QgsWkbTypes,
)
from qgis.PyQt.QtCore import QMetaType
from qgis.gui import QgsElevationProfileCanvas, QgsPlotTool
@@ -40,11 +43,36 @@ try:
except ImportError: # older builds expose it under a different path
QgsPlotToolPan = None
+try:
+ _POINT_GEOMETRY = QgsWkbTypes.GeometryType.PointGeometry # PyQt6 / QGIS 4
+ _ADD_FEATURES_CAP = QgsVectorDataProvider.Capability.AddFeatures
+except AttributeError:
+ _POINT_GEOMETRY = QgsWkbTypes.PointGeometry # PyQt5 / QGIS 3
+ _ADD_FEATURES_CAP = QgsVectorDataProvider.AddFeatures
+
MENU = 'Profile Interpreter'
LAYER_NAME = 'Profile interpretations'
+def _is_suitable_target(layer):
+ """True if layer is a writable PointZ vector layer."""
+ if layer is None or not isinstance(layer, QgsVectorLayer):
+ return False
+ if layer.geometryType() != _POINT_GEOMETRY:
+ return False
+ if not QgsWkbTypes.hasZ(layer.wkbType()):
+ return False
+ caps = layer.dataProvider().capabilities()
+ return bool(caps & _ADD_FEATURES_CAP)
+
+
+def _point_attributes(field_names, feature_id, distance, elevation):
+ """Return {field: value} for only the names present in field_names."""
+ values = {'id': feature_id, 'distance': distance, 'elevation': elevation}
+ return {k: v for k, v in values.items() if k in field_names}
+
+
class _ProfilePickTool(QgsPlotTool):
"""A plot tool that forwards left-button releases to a callback."""
@@ -125,6 +153,12 @@ class ProfileInterpreterPlugin:
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 _on_pick(self, event):
canvas = self._canvas
profile_point = canvas.canvasPointToPlotPoint(QPointF(event.pos()))
@@ -141,19 +175,29 @@ class ProfileInterpreterPlugin:
return
xy = point_geom.asPoint()
- layer = self._interpretation_layer(canvas.crs())
+ layer = self._target_layer(canvas.crs())
feature = QgsFeature(layer.fields())
feature.setGeometry(QgsGeometry(QgsPoint(xy.x(), xy.y(), elevation)))
- feature['id'] = self._next_id
- feature['distance'] = distance
- feature['elevation'] = elevation
+
+ current_id = self._next_id
+ attrs = _point_attributes(
+ {f.name() for f in layer.fields()},
+ current_id, distance, elevation,
+ )
+ for k, v in attrs.items():
+ feature[k] = v
+
layer.dataProvider().addFeatures([feature])
layer.updateExtents()
layer.triggerRepaint()
+
+ if hasattr(canvas, 'refresh'):
+ canvas.refresh()
+
self._next_id += 1
print(
- f'{MENU}: placed point #{feature["id"]} '
+ f'{MENU}: placed point #{current_id} '
f'at x={xy.x():.3f} y={xy.y():.3f} z={elevation:.3f} '
f'(distance={distance:.3f})'
)
diff --git a/test/fakeqgis.py b/test/fakeqgis.py
@@ -0,0 +1,363 @@
+"""
+Minimal fake qgis installed into sys.modules so that profile_interpreter
+can be imported and unit-tested on a QGIS-free Python runtime (e.g. CI).
+"""
+import sys
+from types import ModuleType
+
+
+# ── PyQt stubs ────────────────────────────────────────────────────────────
+
+class Qt:
+ class MouseButton:
+ LeftButton = 1
+ RightButton = 2
+ LeftButton = 1
+ RightButton = 2
+
+
+class QPointF:
+ def __init__(self, *args):
+ pass
+
+
+class QMetaType:
+ class Type:
+ Int = 1
+ Double = 2
+ QString = 3
+
+
+class QAction:
+ def __init__(self, *args, **kwargs):
+ self._checkable = False
+ self._checked = False
+ self._callbacks = []
+
+ def setCheckable(self, v):
+ self._checkable = v
+
+ def setChecked(self, v):
+ self._checked = v
+
+ def toggled(self):
+ pass
+
+ def connect(self, cb):
+ self._callbacks.append(cb)
+
+
+# ── qgis.core stubs ───────────────────────────────────────────────────────
+
+class Qgis:
+ Warning = 1
+ Info = 2
+ Success = 3
+
+
+class QgsField:
+ def __init__(self, name, type_=None):
+ self._name = name
+
+ def name(self):
+ return self._name
+
+
+class _FakeFields:
+ def __init__(self, fields):
+ self._fields = list(fields)
+
+ def __iter__(self):
+ return iter(self._fields)
+
+
+class QgsFeature:
+ def __init__(self, fields=None):
+ self._fields = fields if fields is not None else _FakeFields([])
+ self._attrs = {}
+ self._geom = None
+
+ def fields(self):
+ return self._fields
+
+ def setGeometry(self, geom):
+ self._geom = geom
+
+ def __setitem__(self, key, value):
+ self._attrs[key] = value
+
+ def __getitem__(self, key):
+ return self._attrs[key]
+
+
+class _FakeGeometry:
+ def __init__(self, empty=False, x=100.0, y=200.0):
+ self._empty = empty
+ self._x = x
+ self._y = y
+
+ def isEmpty(self):
+ return self._empty
+
+ def asPoint(self):
+ return _FakeXY(self._x, self._y)
+
+ def clone(self):
+ return self
+
+
+class _FakeXY:
+ def __init__(self, x=100.0, y=200.0):
+ self._x = x
+ self._y = y
+
+ def x(self):
+ return self._x
+
+ def y(self):
+ return self._y
+
+
+class QgsGeometry:
+ # Set to True in tests to make interpolate() return an empty geometry.
+ _next_interpolate_empty = False
+
+ def __init__(self, obj=None):
+ self._obj = obj
+
+ @classmethod
+ def set_interpolate_empty(cls, v):
+ cls._next_interpolate_empty = v
+
+ def interpolate(self, distance):
+ empty = QgsGeometry._next_interpolate_empty
+ return _FakeGeometry(empty=empty)
+
+ def isEmpty(self):
+ return False
+
+ def asPoint(self):
+ return _FakeXY()
+
+ def clone(self):
+ return self
+
+
+class QgsPoint:
+ def __init__(self, x=0.0, y=0.0, z=0.0):
+ self._x = x
+ self._y = y
+ self._z = z
+
+
+class QgsWkbTypes:
+ PointZ = 1001
+ Point = 1
+ PointGeometry = 0 # PyQt5 style
+ LineGeometry = 1
+ PolygonGeometry = 2
+
+ class GeometryType: # PyQt6 style
+ PointGeometry = 0
+ LineGeometry = 1
+ PolygonGeometry = 2
+
+ @staticmethod
+ def hasZ(wkb_type):
+ return wkb_type == QgsWkbTypes.PointZ
+
+
+class QgsVectorDataProvider:
+ AddFeatures = 1 # PyQt5 style
+
+ class Capability: # PyQt6 style
+ AddFeatures = 1
+
+
+class _FakeDataProvider:
+ def __init__(self, caps, features_store, fields_ref):
+ self._caps = caps
+ self._features = features_store
+ self._fields_ref = fields_ref
+
+ def capabilities(self):
+ return self._caps
+
+ def addFeatures(self, features):
+ self._features.extend(features)
+ return True
+
+ def addAttributes(self, attrs):
+ self._fields_ref.extend(attrs)
+
+
+class QgsVectorLayer:
+ def __init__(self, uri='', name='', provider=''):
+ self._name = name
+ self._features = []
+ self._fields_list = []
+ self._geom_type = QgsWkbTypes.PointGeometry
+ self._wkb_type = QgsWkbTypes.PointZ
+ self._provider = _FakeDataProvider(
+ QgsVectorDataProvider.AddFeatures,
+ self._features,
+ self._fields_list,
+ )
+
+ def name(self):
+ return self._name
+
+ def id(self):
+ return id(self)
+
+ def geometryType(self):
+ return self._geom_type
+
+ def wkbType(self):
+ return self._wkb_type
+
+ def dataProvider(self):
+ return self._provider
+
+ def fields(self):
+ return _FakeFields(self._fields_list)
+
+ def updateExtents(self):
+ pass
+
+ def triggerRepaint(self):
+ pass
+
+ def updateFields(self):
+ pass
+
+
+class QgsProject:
+ _instance = None
+
+ def __init__(self):
+ self._layers = {}
+
+ @classmethod
+ def instance(cls):
+ if cls._instance is None:
+ cls._instance = cls()
+ return cls._instance
+
+ def mapLayer(self, layer_id):
+ return self._layers.get(layer_id)
+
+ def addMapLayer(self, layer):
+ self._layers[layer.id()] = layer
+ return layer
+
+
+# ── qgis.gui stubs ────────────────────────────────────────────────────────
+
+class QgsPlotTool:
+ def __init__(self, canvas, name):
+ self._canvas = canvas
+ self._name = name
+
+ def plotReleaseEvent(self, event):
+ pass
+
+
+class QgsPlotToolPan:
+ def __init__(self, canvas):
+ self._canvas = canvas
+
+
+class QgsElevationProfileCanvas:
+ def __init__(self):
+ self._visible = True
+ self.refresh_count = 0
+ self._tool = None
+
+ def isVisible(self):
+ return self._visible
+
+ def setSnappingEnabled(self, v):
+ pass
+
+ def setTool(self, tool):
+ self._tool = tool
+
+ def refresh(self):
+ self.refresh_count += 1
+
+ def canvasPointToPlotPoint(self, pt):
+ return _FakeProfilePoint()
+
+ def profileCurve(self):
+ return _FakeCurve()
+
+ def crs(self):
+ return _FakeCrs()
+
+
+class _FakeProfilePoint:
+ def __init__(self, distance=10.0, elevation=5.0):
+ self._distance = distance
+ self._elevation = elevation
+
+ def distance(self):
+ return self._distance
+
+ def elevation(self):
+ return self._elevation
+
+
+class _FakeCurve:
+ def clone(self):
+ return self
+
+
+class _FakeCrs:
+ def isValid(self):
+ return True
+
+ def authid(self):
+ return 'EPSG:4326'
+
+
+# ── installer ─────────────────────────────────────────────────────────────
+
+def install():
+ """Register all fake qgis sub-modules into sys.modules."""
+ qgis = ModuleType('qgis')
+ qgis_core = ModuleType('qgis.core')
+ qgis_gui = ModuleType('qgis.gui')
+ qgis_pyqt = ModuleType('qgis.PyQt')
+ qgis_pyqt_qtcore = ModuleType('qgis.PyQt.QtCore')
+ qgis_pyqt_qtwidgets = ModuleType('qgis.PyQt.QtWidgets')
+
+ for name in [
+ 'Qgis', 'QgsFeature', 'QgsField', 'QgsGeometry', 'QgsPoint',
+ 'QgsProject', 'QgsVectorDataProvider', 'QgsVectorLayer', 'QgsWkbTypes',
+ ]:
+ setattr(qgis_core, name, globals()[name])
+
+ for name in ['QgsElevationProfileCanvas', 'QgsPlotTool', 'QgsPlotToolPan']:
+ setattr(qgis_gui, name, globals()[name])
+
+ qgis_pyqt_qtcore.Qt = Qt
+ qgis_pyqt_qtcore.QPointF = QPointF
+ qgis_pyqt_qtcore.QMetaType = QMetaType
+ qgis_pyqt_qtwidgets.QAction = QAction
+
+ sys.modules['qgis'] = qgis
+ sys.modules['qgis.core'] = qgis_core
+ sys.modules['qgis.gui'] = qgis_gui
+ sys.modules['qgis.PyQt'] = qgis_pyqt
+ sys.modules['qgis.PyQt.QtCore'] = qgis_pyqt_qtcore
+ sys.modules['qgis.PyQt.QtWidgets'] = qgis_pyqt_qtwidgets
+
+ qgis.core = qgis_core
+ qgis.gui = qgis_gui
+ qgis.PyQt = qgis_pyqt
+ qgis_pyqt.QtCore = qgis_pyqt_qtcore
+ qgis_pyqt.QtWidgets = qgis_pyqt_qtwidgets
+
+ # Reset project singleton for each test run.
+ QgsProject._instance = None
diff --git a/test/test_profile_interpreter.py b/test/test_profile_interpreter.py
@@ -0,0 +1,311 @@
+import math
+import os
+import sys
+import unittest
+import fakeqgis
+
+_HERE = os.path.dirname(__file__)
+sys.path.insert(0, os.path.join(_HERE, '..')) # project root
+sys.path.insert(0, _HERE) # test/ dir
+
+fakeqgis.install()
+
+from profile_interpreter.profile_interpreter import ( # noqa: E402
+ _is_suitable_target,
+ _point_attributes,
+ _LEFT_BUTTON,
+ _ProfilePickTool,
+ ProfileInterpreterPlugin,
+)
+import fakeqgis as fq # noqa: E402
+
+
+# ── test helpers ──────────────────────────────────────────────────────────
+
+class _FakeEvent:
+ def __init__(self, button=None, x=0.0, y=0.0):
+ self._button = button if button is not None else _LEFT_BUTTON
+
+ def button(self):
+ return self._button
+
+ def pos(self):
+ return None # consumed by fake QPointF(*args)
+
+
+class _FakeCanvas:
+ def __init__(self, distance=10.0, elevation=5.0, curve='default'):
+ self.refresh_count = 0
+ self._distance = distance
+ self._elevation = elevation
+ self._curve = fq._FakeCurve() if curve == 'default' else curve
+ self._tool = None
+
+ def canvasPointToPlotPoint(self, pt):
+ return fq._FakeProfilePoint(self._distance, self._elevation)
+
+ def profileCurve(self):
+ return self._curve
+
+ def crs(self):
+ return fq._FakeCrs()
+
+ def setSnappingEnabled(self, v):
+ pass
+
+ def setTool(self, tool):
+ self._tool = tool
+
+ def refresh(self):
+ self.refresh_count += 1
+
+
+class _FakeMainWindow:
+ def __init__(self, canvases=None):
+ self._canvases = canvases or []
+
+ def findChildren(self, cls):
+ return [c for c in self._canvases if isinstance(c, cls)]
+
+
+class _FakeBar:
+ def pushMessage(self, *args, **kwargs):
+ pass
+
+
+class _FakeIface:
+ def __init__(self, active_layer=None, canvases=None):
+ self._active_layer = active_layer
+ self._window = _FakeMainWindow(canvases or [])
+
+ def activeLayer(self):
+ return self._active_layer
+
+ def mainWindow(self):
+ return self._window
+
+ def messageBar(self):
+ return _FakeBar()
+
+ def addPluginToMenu(self, *args):
+ pass
+
+ def addToolBarIcon(self, *args):
+ pass
+
+ def removeToolBarIcon(self, *args):
+ pass
+
+ def removePluginMenu(self, *args):
+ pass
+
+
+def _make_plugin(active_layer=None, canvas=None):
+ iface = _FakeIface(active_layer=active_layer)
+ plugin = ProfileInterpreterPlugin(iface)
+ plugin._canvas = canvas or _FakeCanvas()
+ fq.QgsProject._instance = None
+ fq.QgsGeometry._next_interpolate_empty = False
+ return plugin, iface
+
+
+# ── _point_attributes ─────────────────────────────────────────────────────
+
+class TestPointAttributes(unittest.TestCase):
+ def test_all_fields_returned(self):
+ result = _point_attributes({'id', 'distance', 'elevation'}, 1, 10.0, 5.0)
+ self.assertEqual(result, {'id': 1, 'distance': 10.0, 'elevation': 5.0})
+
+ def test_subset_of_fields(self):
+ result = _point_attributes({'distance', 'elevation'}, 2, 3.0, 4.0)
+ self.assertNotIn('id', result)
+ self.assertEqual(result['distance'], 3.0)
+ self.assertEqual(result['elevation'], 4.0)
+
+ def test_empty_field_set(self):
+ result = _point_attributes(set(), 1, 10.0, 5.0)
+ self.assertEqual(result, {})
+
+ def test_note_field_never_auto_set(self):
+ result = _point_attributes({'id', 'distance', 'elevation', 'note'}, 1, 1.0, 1.0)
+ self.assertNotIn('note', result)
+
+
+# ── _is_suitable_target ───────────────────────────────────────────────────
+
+class TestIsSuitableTarget(unittest.TestCase):
+ def test_none_rejected(self):
+ self.assertFalse(_is_suitable_target(None))
+
+ def test_non_layer_rejected(self):
+ self.assertFalse(_is_suitable_target("not a layer"))
+ self.assertFalse(_is_suitable_target(42))
+
+ def test_pointz_writable_accepted(self):
+ layer = fq.QgsVectorLayer('PointZ', 'test', 'memory')
+ self.assertTrue(_is_suitable_target(layer))
+
+ def test_plain_point_rejected(self):
+ layer = fq.QgsVectorLayer('Point', 'test', 'memory')
+ layer._wkb_type = fq.QgsWkbTypes.Point
+ self.assertFalse(_is_suitable_target(layer))
+
+ def test_non_point_geometry_rejected(self):
+ layer = fq.QgsVectorLayer('LineZ', 'test', 'memory')
+ layer._geom_type = fq.QgsWkbTypes.LineGeometry
+ self.assertFalse(_is_suitable_target(layer))
+
+ def test_read_only_rejected(self):
+ layer = fq.QgsVectorLayer('PointZ', 'test', 'memory')
+ layer._provider._caps = 0
+ self.assertFalse(_is_suitable_target(layer))
+
+
+# ── _on_pick early exits ──────────────────────────────────────────────────
+
+class TestOnPickEarlyExits(unittest.TestCase):
+ def _pick(self, plugin, canvas=None):
+ """Fire _on_pick and return feature count on memory layer."""
+ event = _FakeEvent()
+ plugin._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.assertEqual(plugin._next_id, 1) # not incremented
+
+ def test_nan_elevation_skipped(self):
+ canvas = _FakeCanvas(distance=10.0, elevation=math.nan)
+ plugin, _ = _make_plugin(canvas=canvas)
+ self._pick(plugin)
+ self.assertEqual(plugin._next_id, 1)
+
+ def test_none_curve_skipped(self):
+ canvas = _FakeCanvas(curve=None)
+ plugin, _ = _make_plugin(canvas=canvas)
+ self._pick(plugin)
+ self.assertEqual(plugin._next_id, 1)
+
+ def test_empty_interpolated_geom_skipped(self):
+ canvas = _FakeCanvas()
+ plugin, _ = _make_plugin(canvas=canvas)
+ fq.QgsGeometry._next_interpolate_empty = True
+ self._pick(plugin)
+ fq.QgsGeometry._next_interpolate_empty = False
+ self.assertEqual(plugin._next_id, 1)
+
+
+# ── _on_pick successful pick ──────────────────────────────────────────────
+
+class TestOnPickSuccess(unittest.TestCase):
+ def setUp(self):
+ fq.QgsProject._instance = None
+ fq.QgsGeometry._next_interpolate_empty = False
+
+ def test_uses_memory_layer_when_no_suitable_active(self):
+ plugin, _ = _make_plugin(active_layer=None)
+ plugin._on_pick(_FakeEvent())
+ # memory layer was created and feature was added
+ layer = plugin._layer
+ self.assertIsNotNone(layer)
+ self.assertEqual(len(layer._features), 1)
+
+ def test_next_id_increments(self):
+ plugin, _ = _make_plugin()
+ plugin._on_pick(_FakeEvent())
+ self.assertEqual(plugin._next_id, 2)
+
+ def test_memory_layer_attrs_set(self):
+ plugin, _ = _make_plugin(active_layer=None)
+ plugin._on_pick(_FakeEvent())
+ feature = plugin._layer._features[0]
+ self.assertEqual(feature._attrs.get('id'), 1)
+ self.assertIn('distance', feature._attrs)
+ self.assertIn('elevation', feature._attrs)
+
+ def test_uses_active_layer_when_suitable(self):
+ 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())
+ # feature went to active layer, not memory layer
+ self.assertEqual(len(active._features), 1)
+ self.assertIsNone(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())
+ feature = active._features[0]
+ self.assertIn('elevation', feature._attrs)
+ self.assertNotIn('id', feature._attrs)
+ self.assertNotIn('distance', feature._attrs)
+
+ def test_canvas_refresh_called_once(self):
+ canvas = _FakeCanvas()
+ plugin, _ = _make_plugin(canvas=canvas)
+ plugin._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())
+ self.assertEqual(canvas.refresh_count, 0)
+
+
+# ── _ProfilePickTool ──────────────────────────────────────────────────────
+
+class TestPickTool(unittest.TestCase):
+ def _make_tool(self):
+ calls = []
+ canvas = _FakeCanvas()
+ tool = _ProfilePickTool(canvas, lambda e: calls.append(e))
+ return tool, calls
+
+ def test_left_button_calls_callback(self):
+ tool, calls = self._make_tool()
+ tool.plotReleaseEvent(_FakeEvent(button=_LEFT_BUTTON))
+ self.assertEqual(len(calls), 1)
+
+ def test_right_button_ignored(self):
+ tool, calls = self._make_tool()
+ tool.plotReleaseEvent(_FakeEvent(button=fq.Qt.MouseButton.RightButton))
+ 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())
+
+
+if __name__ == '__main__':
+ unittest.main()