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()