commit 53b50fcde9bc34a113593b5b2a89bef754a28ec9
parent dd528061bfd5634a7a932fc82aa41cc3bc93526c
Author: Anders Damsgaard <anders@adamsgaard.dk>
Date: Sat, 29 Aug 2026 14:30:36 +0200
feat(gui): add Undo Point button to the elevation profile panel
Diffstat:
4 files changed, 436 insertions(+), 18 deletions(-)
diff --git a/README.md b/README.md
@@ -50,7 +50,12 @@ altered). Otherwise points are stored in a memory layer named
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. Toggle the tool off to return to normal pan/zoom on the profile.
+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.
+6. Toggle the tool off to return to normal pan/zoom on the profile.
# Limitations
@@ -60,7 +65,13 @@ altered). Otherwise points are stored in a memory layer named
- 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.
-- No editing/undo of placed points beyond standard layer editing.
+- **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).
+- The **Undo Point** button is 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 button is silently omitted there and remains in the plugin
+ menu.
- No snapping configuration UI; snapping is left to the user's QGIS settings.
## Development
diff --git a/profile_interpreter/profile_interpreter.py b/profile_interpreter/profile_interpreter.py
@@ -19,7 +19,7 @@ import os
from qgis.PyQt.QtCore import Qt, QPointF
from qgis.PyQt.QtGui import QIcon
-from qgis.PyQt.QtWidgets import QAction
+from qgis.PyQt.QtWidgets import QAction, QToolBar
try:
_LEFT_BUTTON = Qt.MouseButton.LeftButton # PyQt6 / QGIS 4
@@ -28,6 +28,7 @@ except AttributeError:
from qgis.core import (
Qgis,
+ QgsApplication,
QgsCoordinateTransform,
QgsFeature,
QgsField,
@@ -51,9 +52,11 @@ except ImportError: # older builds expose it under a different path
try:
_POINT_GEOMETRY = QgsWkbTypes.GeometryType.PointGeometry # PyQt6 / QGIS 4
_ADD_FEATURES_CAP = QgsVectorDataProvider.Capability.AddFeatures
+ _DELETE_FEATURES_CAP = QgsVectorDataProvider.Capability.DeleteFeatures
except AttributeError:
_POINT_GEOMETRY = QgsWkbTypes.PointGeometry # PyQt5 / QGIS 3
_ADD_FEATURES_CAP = QgsVectorDataProvider.AddFeatures
+ _DELETE_FEATURES_CAP = QgsVectorDataProvider.DeleteFeatures
MENU = 'Profile Interpreter'
@@ -105,6 +108,35 @@ def _next_feature_id(layer):
return max_id + 1
+def _can_delete(layer):
+ """True if features can be removed from layer's provider."""
+ if layer is None:
+ return False
+ caps = layer.dataProvider().capabilities()
+ return bool(caps & _DELETE_FEATURES_CAP)
+
+
+def _added_feature_id(added, fallback):
+ """Provider-assigned id of the feature just written, else the fallback feature's id."""
+ feature = added[0] if added else fallback
+ return feature.id()
+
+
+def _find_panel_toolbar(canvas, main_window):
+ """First QToolBar in the panel holding canvas, or None.
+
+ Walks up from the canvas and stops at the main window so the plugin never
+ lands its action on one of the main QGIS toolbars.
+ """
+ widget = canvas.parentWidget()
+ while widget is not None and widget is not main_window:
+ bars = widget.findChildren(QToolBar)
+ if bars:
+ return bars[0]
+ widget = widget.parentWidget()
+ return None
+
+
class _ProfilePickTool(QgsPlotTool):
"""A plot tool that forwards left-button releases to a callback."""
@@ -127,6 +159,9 @@ class ProfileInterpreterPlugin:
self._pick_tool = None
self._pan_tool = None
self._layer = None
+ self._undo_action = None
+ self._panel_toolbar = None
+ self._history = []
def initGui(self):
icon_path = os.path.join(os.path.dirname(__file__), 'icon.svg')
@@ -136,11 +171,22 @@ class ProfileInterpreterPlugin:
self.iface.addPluginToMenu(MENU, self._action)
self.iface.addToolBarIcon(self._action)
+ self._undo_action = QAction(
+ QgsApplication.getThemeIcon('mActionUndo.svg'),
+ 'Undo Point', self.iface.mainWindow())
+ 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)
+
def unload(self):
self._set_active(False)
self.iface.removeToolBarIcon(self._action)
self.iface.removePluginMenu(MENU, self._action)
+ self.iface.removePluginMenu(MENU, self._undo_action)
self._action = None
+ self._undo_action = None
+ self._history.clear()
def _set_active(self, active):
if not active:
@@ -162,6 +208,7 @@ class ProfileInterpreterPlugin:
self._canvas = canvas
self._pick_tool = _ProfilePickTool(canvas, self._on_pick)
canvas.setTool(self._pick_tool)
+ self._attach_undo_action(canvas)
self.iface.messageBar().pushMessage(
MENU,
'Click on the elevation profile to place interpretation points.',
@@ -169,6 +216,7 @@ class ProfileInterpreterPlugin:
)
def _deactivate(self):
+ self._detach_undo_action()
if self._canvas is not None and self._pick_tool is not None:
if QgsPlotToolPan is not None:
self._pan_tool = QgsPlotToolPan(self._canvas)
@@ -178,6 +226,63 @@ class ProfileInterpreterPlugin:
self._pick_tool = None
self._canvas = None
+ def _attach_undo_action(self, canvas):
+ """Put Undo Point on the Elevation Profile panel toolbar, if it has one."""
+ if self._undo_action is None:
+ return
+ toolbar = _find_panel_toolbar(canvas, self.iface.mainWindow())
+ if toolbar is None:
+ return
+ toolbar.addAction(self._undo_action)
+ self._panel_toolbar = toolbar
+
+ def _detach_undo_action(self):
+ if self._panel_toolbar is not None and self._undo_action is not None:
+ self._panel_toolbar.removeAction(self._undo_action)
+ self._panel_toolbar = None
+
+ def _refresh_undo_state(self):
+ if self._undo_action is not None:
+ self._undo_action.setEnabled(bool(self._history))
+
+ def _undo_last_point(self):
+ """Delete the most recently placed point, skipping layers since removed."""
+ project = QgsProject.instance()
+ while self._history:
+ layer, feature_id = self._history.pop()
+ if project.mapLayer(layer.id()) is None:
+ continue
+ if not _can_delete(layer):
+ self.iface.messageBar().pushMessage(
+ MENU,
+ f'Layer "{layer.name()}" does not support deleting features.',
+ level=Qgis.Warning,
+ )
+ break
+ if not layer.dataProvider().deleteFeatures([feature_id]):
+ self.iface.messageBar().pushMessage(
+ MENU,
+ 'Failed to remove the last point.',
+ level=Qgis.Warning,
+ )
+ break
+ layer.updateExtents()
+ layer.triggerRepaint()
+ if self._canvas is not None and hasattr(self._canvas, 'refresh'):
+ self._canvas.refresh()
+ QgsMessageLog.logMessage(
+ f'removed point {feature_id} from "{layer.name()}"',
+ MENU, level=Qgis.Info,
+ )
+ self.iface.messageBar().pushMessage(
+ MENU,
+ 'Removed last interpretation point.',
+ level=Qgis.Success,
+ duration=2,
+ )
+ break
+ self._refresh_undo_state()
+
def _find_profile_canvas(self):
main_window = self.iface.mainWindow()
canvases = main_window.findChildren(QgsElevationProfileCanvas)
@@ -221,7 +326,7 @@ class ProfileInterpreterPlugin:
for k, v in attrs.items():
feature[k] = v
- ok, _ = layer.dataProvider().addFeatures([feature])
+ ok, added = layer.dataProvider().addFeatures([feature])
if not ok:
self.iface.messageBar().pushMessage(
MENU,
@@ -229,6 +334,8 @@ class ProfileInterpreterPlugin:
level=Qgis.Warning,
)
return
+ self._history.append((layer, _added_feature_id(added, feature)))
+ self._refresh_undo_state()
layer.updateExtents()
layer.triggerRepaint()
diff --git a/test/fakeqgis.py b/test/fakeqgis.py
@@ -33,11 +33,26 @@ class QIcon:
pass
+class _FakeSignal:
+ def __init__(self):
+ self._callbacks = []
+
+ def connect(self, cb):
+ self._callbacks.append(cb)
+
+ def emit(self, *args):
+ for cb in self._callbacks:
+ cb(*args)
+
+
class QAction:
def __init__(self, *args, **kwargs):
self._checkable = False
self._checked = False
- self._callbacks = []
+ self._enabled = True
+ self._tooltip = ''
+ self.toggled = _FakeSignal()
+ self.triggered = _FakeSignal()
def setCheckable(self, v):
self._checkable = v
@@ -45,11 +60,26 @@ class QAction:
def setChecked(self, v):
self._checked = v
- def toggled(self):
- pass
+ def setEnabled(self, v):
+ self._enabled = v
- def connect(self, cb):
- self._callbacks.append(cb)
+ def isEnabled(self):
+ return self._enabled
+
+ def setToolTip(self, v):
+ self._tooltip = v
+
+
+class QToolBar:
+ def __init__(self, parent=None):
+ self.actions_added = []
+
+ def addAction(self, action):
+ self.actions_added.append(action)
+
+ def removeAction(self, action):
+ if action in self.actions_added:
+ self.actions_added.remove(action)
# ── qgis.core stubs ───────────────────────────────────────────────────────
@@ -60,6 +90,12 @@ class Qgis:
Success = 3
+class QgsApplication:
+ @staticmethod
+ def getThemeIcon(name):
+ return QIcon()
+
+
class QgsField:
def __init__(self, name, type_=None):
self._name = name
@@ -81,6 +117,13 @@ class QgsFeature:
self._fields = fields if fields is not None else _FakeFields([])
self._attrs = {}
self._geom = None
+ self._id = -1
+
+ def id(self):
+ return self._id
+
+ def setId(self, fid):
+ self._id = fid
def fields(self):
return self._fields
@@ -215,19 +258,23 @@ class QgsWkbTypes:
class QgsVectorDataProvider:
- AddFeatures = 1 # PyQt5 style
+ AddFeatures = 1 # PyQt5 style
+ DeleteFeatures = 2
- class Capability: # PyQt6 style
+ class Capability: # PyQt6 style
AddFeatures = 1
+ DeleteFeatures = 2
class _FakeDataProvider:
- _fail_next = False # set True in a test to simulate addFeatures failure
+ _fail_next = False # set True in a test to simulate addFeatures failure
+ _fail_next_delete = False # set True in a test to simulate deleteFeatures failure
def __init__(self, caps, features_store, fields_ref):
self._caps = caps
self._features = features_store
self._fields_ref = fields_ref
+ self._next_id = 1
def capabilities(self):
return self._caps
@@ -236,9 +283,20 @@ class _FakeDataProvider:
if _FakeDataProvider._fail_next:
_FakeDataProvider._fail_next = False
return (False, [])
+ for feature in features:
+ feature.setId(self._next_id)
+ self._next_id += 1
self._features.extend(features)
return (True, list(features))
+ def deleteFeatures(self, ids):
+ if _FakeDataProvider._fail_next_delete:
+ _FakeDataProvider._fail_next_delete = False
+ return False
+ remaining = [f for f in self._features if f.id() not in ids]
+ self._features[:] = remaining
+ return True
+
def addAttributes(self, attrs):
self._fields_ref.extend(attrs)
@@ -251,7 +309,7 @@ class QgsVectorLayer:
self._geom_type = QgsWkbTypes.PointGeometry
self._wkb_type = QgsWkbTypes.PointZ
self._provider = _FakeDataProvider(
- QgsVectorDataProvider.AddFeatures,
+ QgsVectorDataProvider.AddFeatures | QgsVectorDataProvider.DeleteFeatures,
self._features,
self._fields_list,
)
@@ -335,6 +393,9 @@ class QgsElevationProfileCanvas:
def isVisible(self):
return self._visible
+ def parentWidget(self):
+ return getattr(self, '_parent', None)
+
def setTool(self, tool):
self._tool = tool
@@ -400,7 +461,7 @@ def install():
qgis_pyqt_qtwidgets = ModuleType('qgis.PyQt.QtWidgets')
for name in [
- 'Qgis', 'QgsCoordinateTransform', 'QgsFeature', 'QgsField',
+ 'Qgis', 'QgsApplication', 'QgsCoordinateTransform', 'QgsFeature', 'QgsField',
'QgsGeometry', 'QgsMessageLog', 'QgsPoint', 'QgsPointXY',
'QgsProject', 'QgsVectorDataProvider', 'QgsVectorLayer', 'QgsWkbTypes',
]:
@@ -414,6 +475,7 @@ def install():
qgis_pyqt_qtcore.QMetaType = QMetaType
qgis_pyqt_qtgui.QIcon = QIcon
qgis_pyqt_qtwidgets.QAction = QAction
+ qgis_pyqt_qtwidgets.QToolBar = QToolBar
sys.modules['qgis'] = qgis
sys.modules['qgis.core'] = qgis_core
diff --git a/test/test_profile_interpreter.py b/test/test_profile_interpreter.py
@@ -11,6 +11,9 @@ sys.path.insert(0, _HERE) # test/ dir
fakeqgis.install()
from profile_interpreter.profile_interpreter import ( # noqa: E402
+ _added_feature_id,
+ _can_delete,
+ _find_panel_toolbar,
_is_suitable_target,
_next_feature_id,
_point_attributes,
@@ -53,6 +56,9 @@ class _FakeCanvas:
def crs(self):
return self._crs_val if self._crs_val is not None else fq._FakeCrs()
+ def parentWidget(self):
+ return getattr(self, '_parent', None)
+
def setTool(self, tool):
self._tool = tool
@@ -86,6 +92,8 @@ class _FakeIface:
self._active_layer = active_layer
self._window = _FakeMainWindow(canvases or [])
self._bar = _FakeBar()
+ self.menu_actions = []
+ self.toolbar_actions = []
def activeLayer(self):
return self._active_layer
@@ -97,16 +105,20 @@ class _FakeIface:
return self._bar
def addPluginToMenu(self, *args):
- pass
+ self.menu_actions.append(args[-1])
def addToolBarIcon(self, *args):
- pass
+ self.toolbar_actions.append(args[-1])
def removeToolBarIcon(self, *args):
- pass
+ action = args[-1]
+ if action in self.toolbar_actions:
+ self.toolbar_actions.remove(action)
def removePluginMenu(self, *args):
- pass
+ action = args[-1]
+ if action in self.menu_actions:
+ self.menu_actions.remove(action)
def _make_plugin(active_layer=None, canvas=None):
@@ -482,5 +494,231 @@ class TestDeactivate(unittest.TestCase):
self.assertIsNone(plugin._canvas)
+# ── _can_delete ───────────────────────────────────────────────────────────
+
+class TestCanDelete(unittest.TestCase):
+ def test_none_rejected(self):
+ self.assertFalse(_can_delete(None))
+
+ def test_memory_layer_accepted(self):
+ layer = fq.QgsVectorLayer('PointZ', 'test', 'memory')
+ self.assertTrue(_can_delete(layer))
+
+ def test_provider_without_capability_rejected(self):
+ layer = fq.QgsVectorLayer('PointZ', 'test', 'memory')
+ layer._provider._caps = fq.QgsVectorDataProvider.AddFeatures
+ self.assertFalse(_can_delete(layer))
+
+
+# ── _added_feature_id ─────────────────────────────────────────────────────
+
+class TestAddedFeatureId(unittest.TestCase):
+ def test_prefers_provider_assigned_feature(self):
+ added = fq.QgsFeature()
+ added.setId(7)
+ fallback = fq.QgsFeature()
+ fallback.setId(1)
+ self.assertEqual(_added_feature_id([added], fallback), 7)
+
+ def test_falls_back_when_provider_returns_nothing(self):
+ fallback = fq.QgsFeature()
+ fallback.setId(3)
+ self.assertEqual(_added_feature_id([], fallback), 3)
+
+
+# ── _find_panel_toolbar ───────────────────────────────────────────────────
+
+class _FakeWidget:
+ def __init__(self, parent=None, children=None):
+ self._parent = parent
+ self._children = children or []
+
+ def parentWidget(self):
+ return self._parent
+
+ def findChildren(self, cls):
+ return [c for c in self._children if isinstance(c, cls)]
+
+
+class TestFindPanelToolbar(unittest.TestCase):
+ def test_finds_toolbar_in_panel(self):
+ main_window = _FakeWidget()
+ toolbar = fq.QToolBar()
+ panel = _FakeWidget(parent=main_window, children=[toolbar])
+ canvas = _FakeCanvas()
+ canvas._parent = panel
+ self.assertIs(_find_panel_toolbar(canvas, main_window), toolbar)
+
+ def test_walks_up_until_a_toolbar_is_found(self):
+ main_window = _FakeWidget()
+ toolbar = fq.QToolBar()
+ panel = _FakeWidget(parent=main_window, children=[toolbar])
+ inner = _FakeWidget(parent=panel)
+ canvas = _FakeCanvas()
+ canvas._parent = inner
+ self.assertIs(_find_panel_toolbar(canvas, main_window), toolbar)
+
+ def test_stops_at_main_window(self):
+ main_toolbar = fq.QToolBar()
+ main_window = _FakeWidget(children=[main_toolbar])
+ panel = _FakeWidget(parent=main_window)
+ canvas = _FakeCanvas()
+ canvas._parent = panel
+ self.assertIsNone(_find_panel_toolbar(canvas, main_window))
+
+ def test_returns_none_without_parent(self):
+ canvas = _FakeCanvas()
+ self.assertIsNone(_find_panel_toolbar(canvas, _FakeWidget()))
+
+
+# ── 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_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
+
+ 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_on_init(self):
+ iface = _FakeIface()
+ plugin = ProfileInterpreterPlugin(iface)
+ plugin.initGui()
+ self.assertIn(plugin._undo_action, iface.menu_actions)
+
+ 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())
+
+ def test_appears_on_panel_toolbar_while_active(self):
+ plugin, _, toolbar = self._activated_plugin()
+ self.assertIn(plugin._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)
+
+
+# ── _undo_last_point ──────────────────────────────────────────────────────
+
+class TestUndoLastPoint(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 test_removes_last_placed_feature(self):
+ plugin, _ = _make_plugin()
+ plugin._on_pick(_FakeEvent())
+ plugin._on_pick(_FakeEvent())
+ layer = plugin._layer
+ plugin._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()
+ self.assertEqual(layer._features, [])
+ self.assertEqual(plugin._history, [])
+
+ def test_undo_with_empty_history_is_a_no_op(self):
+ plugin, _ = _make_plugin()
+ plugin._undo_last_point()
+ self.assertEqual(plugin._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])
+
+ def test_canvas_refreshed_after_undo(self):
+ canvas = _FakeCanvas()
+ plugin, _ = _make_plugin(canvas=canvas)
+ plugin._on_pick(_FakeEvent())
+ plugin._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
+ fq.QgsProject.instance()._layers.pop(layer.id())
+ plugin._undo_last_point()
+ # both history entries pointed at the vanished layer; nothing deleted
+ self.assertEqual(len(layer._features), 2)
+ self.assertEqual(plugin._history, [])
+
+ def test_read_only_layer_warns_and_keeps_feature(self):
+ plugin, iface = _make_plugin()
+ bar = _TrackingBar()
+ iface._bar = bar
+ plugin._on_pick(_FakeEvent())
+ layer = plugin._layer
+ layer._provider._caps = fq.QgsVectorDataProvider.AddFeatures
+ plugin._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()
+ bar = _TrackingBar()
+ iface._bar = bar
+ plugin._on_pick(_FakeEvent())
+ fq._FakeDataProvider._fail_next_delete = True
+ plugin._undo_last_point()
+ self.assertEqual(len(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()
+ fq._FakeDataProvider._fail_next = True
+ plugin._on_pick(_FakeEvent())
+ self.assertEqual(plugin._history, [])
+
+
if __name__ == '__main__':
unittest.main()