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 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:
MMakefile | 8++++++--
MREADME.md | 10+++++++---
Mprofile_interpreter/profile_interpreter.py | 54+++++++++++++++++++++++++++++++++++++++++++++++++-----
Atest/fakeqgis.py | 363+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/test_profile_interpreter.py | 311+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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()