qgis-profile-interpreter

qgis plugin for placing 3D points along elevation profiles
git clone git://src.adamsgaard.dk/qgis-profile-interpreter # fast
git clone https://src.adamsgaard.dk/qgis-profile-interpreter.git # slow
Log | Files | Refs | README | LICENSE Back to index

test_profile_interpreter.py (18322B)


      1 import math
      2 import os
      3 import sys
      4 import unittest
      5 import fakeqgis
      6 
      7 _HERE = os.path.dirname(__file__)
      8 sys.path.insert(0, os.path.join(_HERE, '..'))  # project root
      9 sys.path.insert(0, _HERE)                       # test/ dir
     10 
     11 fakeqgis.install()
     12 
     13 from profile_interpreter.profile_interpreter import (  # noqa: E402
     14     _is_suitable_target,
     15     _next_feature_id,
     16     _point_attributes,
     17     _project_xy,
     18     _LEFT_BUTTON,
     19     _ProfilePickTool,
     20     ProfileInterpreterPlugin,
     21 )
     22 import fakeqgis as fq  # noqa: E402
     23 
     24 
     25 # ── test helpers ──────────────────────────────────────────────────────────
     26 
     27 class _FakeEvent:
     28     def __init__(self, button=None, x=0.0, y=0.0):
     29         self._button = button if button is not None else _LEFT_BUTTON
     30 
     31     def button(self):
     32         return self._button
     33 
     34     def pos(self):
     35         return None  # consumed by fake QPointF(*args)
     36 
     37 
     38 class _FakeCanvas:
     39     def __init__(self, distance=10.0, elevation=5.0, curve='default', crs=None):
     40         self.refresh_count = 0
     41         self._distance = distance
     42         self._elevation = elevation
     43         self._curve = fq._FakeCurve() if curve == 'default' else curve
     44         self._tool = None
     45         self._crs_val = crs
     46 
     47     def canvasPointToPlotPoint(self, pt):
     48         return fq._FakeProfilePoint(self._distance, self._elevation)
     49 
     50     def profileCurve(self):
     51         return self._curve
     52 
     53     def crs(self):
     54         return self._crs_val if self._crs_val is not None else fq._FakeCrs()
     55 
     56     def setTool(self, tool):
     57         self._tool = tool
     58 
     59     def refresh(self):
     60         self.refresh_count += 1
     61 
     62 
     63 class _FakeMainWindow:
     64     def __init__(self, canvases=None):
     65         self._canvases = canvases or []
     66 
     67     def findChildren(self, cls):
     68         return [c for c in self._canvases if isinstance(c, cls)]
     69 
     70 
     71 class _FakeBar:
     72     def pushMessage(self, *args, **kwargs):
     73         pass
     74 
     75 
     76 class _TrackingBar:
     77     def __init__(self):
     78         self.messages = []
     79 
     80     def pushMessage(self, tag, msg, level=None, **kwargs):
     81         self.messages.append((tag, msg, level))
     82 
     83 
     84 class _FakeIface:
     85     def __init__(self, active_layer=None, canvases=None):
     86         self._active_layer = active_layer
     87         self._window = _FakeMainWindow(canvases or [])
     88         self._bar = _FakeBar()
     89 
     90     def activeLayer(self):
     91         return self._active_layer
     92 
     93     def mainWindow(self):
     94         return self._window
     95 
     96     def messageBar(self):
     97         return self._bar
     98 
     99     def addPluginToMenu(self, *args):
    100         pass
    101 
    102     def addToolBarIcon(self, *args):
    103         pass
    104 
    105     def removeToolBarIcon(self, *args):
    106         pass
    107 
    108     def removePluginMenu(self, *args):
    109         pass
    110 
    111 
    112 def _make_plugin(active_layer=None, canvas=None):
    113     iface = _FakeIface(active_layer=active_layer)
    114     plugin = ProfileInterpreterPlugin(iface)
    115     plugin._canvas = canvas or _FakeCanvas()
    116     fq.QgsProject._instance = None
    117     fq.QgsGeometry._next_interpolate_empty = False
    118     return plugin, iface
    119 
    120 
    121 # ── _point_attributes ─────────────────────────────────────────────────────
    122 
    123 class TestPointAttributes(unittest.TestCase):
    124     def test_all_fields_returned(self):
    125         result = _point_attributes({'id', 'distance', 'elevation'}, 1, 10.0, 5.0)
    126         self.assertEqual(result, {'id': 1, 'distance': 10.0, 'elevation': 5.0})
    127 
    128     def test_subset_of_fields(self):
    129         result = _point_attributes({'distance', 'elevation'}, 2, 3.0, 4.0)
    130         self.assertNotIn('id', result)
    131         self.assertEqual(result['distance'], 3.0)
    132         self.assertEqual(result['elevation'], 4.0)
    133 
    134     def test_empty_field_set(self):
    135         result = _point_attributes(set(), 1, 10.0, 5.0)
    136         self.assertEqual(result, {})
    137 
    138     def test_note_field_never_auto_set(self):
    139         result = _point_attributes({'id', 'distance', 'elevation', 'note'}, 1, 1.0, 1.0)
    140         self.assertNotIn('note', result)
    141 
    142 
    143 # ── _project_xy ──────────────────────────────────────────────────────────
    144 
    145 class TestProjectXY(unittest.TestCase):
    146     def setUp(self):
    147         fq.QgsCoordinateTransform.reset()
    148 
    149     def _xy(self, x=100.0, y=200.0):
    150         return fq._FakeXY(x, y)
    151 
    152     def test_same_crs_object_returns_identity(self):
    153         crs = fq._FakeCrs('EPSG:4326')
    154         x, y = _project_xy(self._xy(), crs, crs)
    155         self.assertEqual((x, y), (100.0, 200.0))
    156         self.assertEqual(fq.QgsCoordinateTransform._calls, [])
    157 
    158     def test_equal_crs_authid_returns_identity(self):
    159         src = fq._FakeCrs('EPSG:4326')
    160         dst = fq._FakeCrs('EPSG:4326')
    161         x, y = _project_xy(self._xy(), src, dst)
    162         self.assertEqual((x, y), (100.0, 200.0))
    163         self.assertEqual(fq.QgsCoordinateTransform._calls, [])
    164 
    165     def test_different_crs_transforms_xy(self):
    166         src = fq._FakeCrs('EPSG:4326')
    167         dst = fq._FakeCrs('EPSG:32632')
    168         x, y = _project_xy(self._xy(), src, dst)
    169         self.assertAlmostEqual(x, 1100.0)
    170         self.assertAlmostEqual(y, 1200.0)
    171         self.assertEqual(len(fq.QgsCoordinateTransform._calls), 1)
    172 
    173 
    174 # ── _next_feature_id ─────────────────────────────────────────────────────
    175 
    176 class TestNextFeatureId(unittest.TestCase):
    177     def _make_layer_with_ids(self, id_values):
    178         layer = fq.QgsVectorLayer('PointZ', 'test', 'memory')
    179         layer._fields_list.append(fq.QgsField('id'))
    180         for v in id_values:
    181             feat = fq.QgsFeature(fq._FakeFields(layer._fields_list))
    182             feat['id'] = v
    183             layer._features.append(feat)
    184         return layer
    185 
    186     def test_empty_layer_returns_1(self):
    187         layer = self._make_layer_with_ids([])
    188         self.assertEqual(_next_feature_id(layer), 1)
    189 
    190     def test_max_id_plus_one(self):
    191         layer = self._make_layer_with_ids([1, 2, 5])
    192         self.assertEqual(_next_feature_id(layer), 6)
    193 
    194     def test_no_id_field_returns_none(self):
    195         layer = fq.QgsVectorLayer('PointZ', 'test', 'memory')
    196         self.assertIsNone(_next_feature_id(layer))
    197 
    198     def test_absent_key_on_feature_is_skipped(self):
    199         layer = fq.QgsVectorLayer('PointZ', 'test', 'memory')
    200         layer._fields_list.append(fq.QgsField('id'))
    201         # feature with no 'id' set in _attrs → KeyError → skipped, falls back to 1
    202         layer._features.append(fq.QgsFeature(fq._FakeFields(layer._fields_list)))
    203         self.assertEqual(_next_feature_id(layer), 1)
    204 
    205 
    206 # ── _is_suitable_target ───────────────────────────────────────────────────
    207 
    208 class TestIsSuitableTarget(unittest.TestCase):
    209     def test_none_rejected(self):
    210         self.assertFalse(_is_suitable_target(None))
    211 
    212     def test_non_layer_rejected(self):
    213         self.assertFalse(_is_suitable_target("not a layer"))
    214         self.assertFalse(_is_suitable_target(42))
    215 
    216     def test_pointz_writable_accepted(self):
    217         layer = fq.QgsVectorLayer('PointZ', 'test', 'memory')
    218         self.assertTrue(_is_suitable_target(layer))
    219 
    220     def test_plain_point_rejected(self):
    221         layer = fq.QgsVectorLayer('Point', 'test', 'memory')
    222         layer._wkb_type = fq.QgsWkbTypes.Point
    223         self.assertFalse(_is_suitable_target(layer))
    224 
    225     def test_non_point_geometry_rejected(self):
    226         layer = fq.QgsVectorLayer('LineZ', 'test', 'memory')
    227         layer._geom_type = fq.QgsWkbTypes.LineGeometry
    228         self.assertFalse(_is_suitable_target(layer))
    229 
    230     def test_read_only_rejected(self):
    231         layer = fq.QgsVectorLayer('PointZ', 'test', 'memory')
    232         layer._provider._caps = 0
    233         self.assertFalse(_is_suitable_target(layer))
    234 
    235 
    236 # ── _on_pick early exits ──────────────────────────────────────────────────
    237 
    238 class TestOnPickEarlyExits(unittest.TestCase):
    239     def _pick(self, plugin, canvas=None):
    240         """Fire _on_pick and return feature count on memory layer."""
    241         event = _FakeEvent()
    242         plugin._on_pick(event)
    243 
    244     def test_nan_distance_skipped(self):
    245         canvas = _FakeCanvas(distance=math.nan, elevation=5.0)
    246         plugin, _ = _make_plugin(canvas=canvas)
    247         self._pick(plugin)
    248         self.assertIsNone(plugin._layer)
    249 
    250     def test_nan_elevation_skipped(self):
    251         canvas = _FakeCanvas(distance=10.0, elevation=math.nan)
    252         plugin, _ = _make_plugin(canvas=canvas)
    253         self._pick(plugin)
    254         self.assertIsNone(plugin._layer)
    255 
    256     def test_none_curve_skipped(self):
    257         canvas = _FakeCanvas(curve=None)
    258         plugin, _ = _make_plugin(canvas=canvas)
    259         self._pick(plugin)
    260         self.assertIsNone(plugin._layer)
    261 
    262     def test_empty_interpolated_geom_skipped(self):
    263         canvas = _FakeCanvas()
    264         plugin, _ = _make_plugin(canvas=canvas)
    265         fq.QgsGeometry._next_interpolate_empty = True
    266         self._pick(plugin)
    267         fq.QgsGeometry._next_interpolate_empty = False
    268         self.assertIsNone(plugin._layer)
    269 
    270 
    271 # ── _on_pick successful pick ──────────────────────────────────────────────
    272 
    273 class TestOnPickSuccess(unittest.TestCase):
    274     def setUp(self):
    275         fq.QgsProject._instance = None
    276         fq.QgsGeometry._next_interpolate_empty = False
    277         fq.QgsCoordinateTransform.reset()
    278 
    279     def test_uses_memory_layer_when_no_suitable_active(self):
    280         plugin, _ = _make_plugin(active_layer=None)
    281         plugin._on_pick(_FakeEvent())
    282         # memory layer was created and feature was added
    283         layer = plugin._layer
    284         self.assertIsNotNone(layer)
    285         self.assertEqual(len(layer._features), 1)
    286 
    287     def test_feature_ids_do_not_collide(self):
    288         plugin, _ = _make_plugin()
    289         plugin._on_pick(_FakeEvent())
    290         plugin._on_pick(_FakeEvent())
    291         layer = plugin._layer
    292         ids = [f._attrs.get('id') for f in layer._features]
    293         self.assertEqual(ids, [1, 2])
    294 
    295     def test_memory_layer_attrs_set(self):
    296         plugin, _ = _make_plugin(active_layer=None)
    297         plugin._on_pick(_FakeEvent())
    298         feature = plugin._layer._features[0]
    299         self.assertEqual(feature._attrs.get('id'), 1)
    300         self.assertIn('distance', feature._attrs)
    301         self.assertIn('elevation', feature._attrs)
    302 
    303     def test_uses_active_layer_when_suitable(self):
    304         active = fq.QgsVectorLayer('PointZ', 'my_layer', 'memory')
    305         active._fields_list.append(fq.QgsField('distance'))
    306         active._fields_list.append(fq.QgsField('elevation'))
    307         plugin, _ = _make_plugin(active_layer=active)
    308         plugin._on_pick(_FakeEvent())
    309         # feature went to active layer, not memory layer
    310         self.assertEqual(len(active._features), 1)
    311         self.assertIsNone(plugin._layer)
    312 
    313     def test_only_matching_fields_written_to_active_layer(self):
    314         active = fq.QgsVectorLayer('PointZ', 'my_layer', 'memory')
    315         active._fields_list.append(fq.QgsField('elevation'))
    316         # 'id' and 'distance' fields absent
    317         plugin, _ = _make_plugin(active_layer=active)
    318         plugin._on_pick(_FakeEvent())
    319         feature = active._features[0]
    320         self.assertIn('elevation', feature._attrs)
    321         self.assertNotIn('id', feature._attrs)
    322         self.assertNotIn('distance', feature._attrs)
    323 
    324     def test_canvas_refresh_called_once(self):
    325         canvas = _FakeCanvas()
    326         plugin, _ = _make_plugin(canvas=canvas)
    327         plugin._on_pick(_FakeEvent())
    328         self.assertEqual(canvas.refresh_count, 1)
    329 
    330     def test_canvas_refresh_not_called_on_early_exit(self):
    331         canvas = _FakeCanvas(curve=None)
    332         plugin, _ = _make_plugin(canvas=canvas)
    333         plugin._on_pick(_FakeEvent())
    334         self.assertEqual(canvas.refresh_count, 0)
    335 
    336     def test_active_layer_different_crs_transforms_geometry(self):
    337         active = fq.QgsVectorLayer('PointZ', 'my_layer', 'memory')
    338         active._crs = fq._FakeCrs('EPSG:32632')
    339         canvas = _FakeCanvas(crs=fq._FakeCrs('EPSG:4326'))
    340         plugin, _ = _make_plugin(active_layer=active, canvas=canvas)
    341         plugin._on_pick(_FakeEvent())
    342         self.assertEqual(len(fq.QgsCoordinateTransform._calls), 1)
    343         feature = active._features[0]
    344         self.assertAlmostEqual(feature._geom._obj._x, 1100.0)
    345         self.assertAlmostEqual(feature._geom._obj._y, 1200.0)
    346 
    347     def test_same_crs_no_transform(self):
    348         active = fq.QgsVectorLayer('PointZ', 'my_layer', 'memory')
    349         active._crs = fq._FakeCrs('EPSG:4326')
    350         canvas = _FakeCanvas(crs=fq._FakeCrs('EPSG:4326'))
    351         plugin, _ = _make_plugin(active_layer=active, canvas=canvas)
    352         plugin._on_pick(_FakeEvent())
    353         self.assertEqual(fq.QgsCoordinateTransform._calls, [])
    354         feature = active._features[0]
    355         self.assertAlmostEqual(feature._geom._obj._x, 100.0)
    356         self.assertAlmostEqual(feature._geom._obj._y, 200.0)
    357 
    358 
    359 # ── _ProfilePickTool ──────────────────────────────────────────────────────
    360 
    361 class TestPickTool(unittest.TestCase):
    362     def _make_tool(self):
    363         calls = []
    364         canvas = _FakeCanvas()
    365         tool = _ProfilePickTool(canvas, lambda e: calls.append(e))
    366         return tool, calls
    367 
    368     def test_left_button_calls_callback(self):
    369         tool, calls = self._make_tool()
    370         tool.plotReleaseEvent(_FakeEvent(button=_LEFT_BUTTON))
    371         self.assertEqual(len(calls), 1)
    372 
    373     def test_right_button_ignored(self):
    374         tool, calls = self._make_tool()
    375         tool.plotReleaseEvent(_FakeEvent(button=fq.Qt.MouseButton.RightButton))
    376         self.assertEqual(calls, [])
    377 
    378 
    379 # ── _find_profile_canvas ──────────────────────────────────────────────────
    380 
    381 class TestFindProfileCanvas(unittest.TestCase):
    382     def test_prefers_visible_canvas(self):
    383         hidden = fq.QgsElevationProfileCanvas()
    384         hidden._visible = False
    385         visible = fq.QgsElevationProfileCanvas()
    386         visible._visible = True
    387 
    388         iface = _FakeIface(canvases=[hidden, visible])
    389         plugin = ProfileInterpreterPlugin(iface)
    390         self.assertIs(plugin._find_profile_canvas(), visible)
    391 
    392     def test_falls_back_to_first_when_all_hidden(self):
    393         c1 = fq.QgsElevationProfileCanvas()
    394         c1._visible = False
    395         c2 = fq.QgsElevationProfileCanvas()
    396         c2._visible = False
    397 
    398         iface = _FakeIface(canvases=[c1, c2])
    399         plugin = ProfileInterpreterPlugin(iface)
    400         self.assertIs(plugin._find_profile_canvas(), c1)
    401 
    402     def test_returns_none_when_no_canvases(self):
    403         iface = _FakeIface(canvases=[])
    404         plugin = ProfileInterpreterPlugin(iface)
    405         self.assertIsNone(plugin._find_profile_canvas())
    406 
    407 
    408 # ── _interpretation_layer field names ────────────────────────────────────
    409 
    410 class TestInterpretationLayerFields(unittest.TestCase):
    411     def setUp(self):
    412         fq.QgsProject._instance = None
    413         fq.QgsGeometry._next_interpolate_empty = False
    414 
    415     def test_fallback_layer_has_exactly_id_distance_elevation(self):
    416         plugin, _ = _make_plugin(active_layer=None)
    417         plugin._on_pick(_FakeEvent())
    418         layer = plugin._layer
    419         self.assertIsNotNone(layer)
    420         field_names = {f.name() for f in layer.fields()}
    421         self.assertEqual(field_names, {'id', 'distance', 'elevation'})
    422         self.assertNotIn('note', field_names)
    423 
    424 
    425 # ── addFeatures failure ───────────────────────────────────────────────────
    426 
    427 class TestOnPickAddFeaturesFailure(unittest.TestCase):
    428     def setUp(self):
    429         fq.QgsProject._instance = None
    430         fq.QgsGeometry._next_interpolate_empty = False
    431         fq.QgsCoordinateTransform.reset()
    432         fq._FakeDataProvider._fail_next = False
    433 
    434     def test_failure_shows_warning_and_adds_no_features(self):
    435         plugin, iface = _make_plugin(active_layer=None)
    436         bar = _TrackingBar()
    437         iface._bar = bar
    438 
    439         fq._FakeDataProvider._fail_next = True
    440         canvas = plugin._canvas
    441         plugin._on_pick(_FakeEvent())
    442 
    443         # feature was not committed
    444         self.assertEqual(len(plugin._layer._features), 0)
    445         # a warning was pushed
    446         self.assertTrue(any(level == fq.Qgis.Warning for _, _, level in bar.messages))
    447         # canvas was not refreshed
    448         self.assertEqual(canvas.refresh_count, 0)
    449 
    450 
    451 # ── _deactivate ───────────────────────────────────────────────────────────
    452 
    453 class TestDeactivate(unittest.TestCase):
    454     def _make_active_plugin(self):
    455         canvas = _FakeCanvas()
    456         iface = _FakeIface()
    457         plugin = ProfileInterpreterPlugin(iface)
    458         plugin._canvas = canvas
    459         pick_tool = fq.QgsPlotTool(canvas, 'test')
    460         plugin._pick_tool = pick_tool
    461         canvas._tool = pick_tool
    462         return plugin, canvas, pick_tool
    463 
    464     def test_pick_tool_replaced_when_pan_available(self):
    465         plugin, canvas, pick_tool = self._make_active_plugin()
    466         plugin._deactivate()
    467         self.assertIsNot(canvas._tool, pick_tool)
    468         self.assertIsNone(plugin._pick_tool)
    469         self.assertIsNone(plugin._canvas)
    470 
    471     def test_pick_tool_replaced_when_pan_none(self):
    472         import profile_interpreter.profile_interpreter as pi_module
    473         plugin, canvas, pick_tool = self._make_active_plugin()
    474         original = pi_module.QgsPlotToolPan
    475         pi_module.QgsPlotToolPan = None
    476         try:
    477             plugin._deactivate()
    478         finally:
    479             pi_module.QgsPlotToolPan = original
    480         self.assertIsNot(canvas._tool, pick_tool)
    481         self.assertIsNone(plugin._pick_tool)
    482         self.assertIsNone(plugin._canvas)
    483 
    484 
    485 if __name__ == '__main__':
    486     unittest.main()