test_core.py (73136B)
1 import importlib 2 import math 3 from pathlib import Path 4 from tempfile import TemporaryDirectory 5 import shutil 6 import sys 7 import types 8 import unittest 9 from unittest.mock import Mock, patch 10 import xml.etree.ElementTree as ET 11 12 from tem_loader.core import ( 13 ABOVE_DOI_OPACITY, 14 BELOW_DOI_OPACITY, 15 detect_source_epsg, 16 layer_opacity, 17 process_xyz, 18 resistivity_color, 19 write_csv, 20 ) 21 22 23 FIXTURE_DIR = Path(__file__).parent / "data" 24 PLUGIN_DIR = Path(__file__).resolve().parent.parent / "tem_loader" 25 STYLES_DIR = PLUGIN_DIR / "styles" 26 METADATA_PATH = PLUGIN_DIR / "metadata.txt" 27 28 29 class MetadataTests(unittest.TestCase): 30 def test_metadata_version_tracks_geopackage_release(self): 31 text = METADATA_PATH.read_text() 32 version_line = next( 33 line for line in text.splitlines() if line.startswith("version=") 34 ) 35 version = version_line.split("=", 1)[1] 36 version_tuple = tuple(int(part) for part in version.split(".")) 37 38 self.assertGreaterEqual(version_tuple, (0, 1, 6)) 39 self.assertIn(f"\t{version}\n", text) 40 self.assertIn("GeoPackage", text) 41 42 43 class ProcessXYZTests(unittest.TestCase): 44 def test_temimage_4_0_4_6_fixture(self): 45 path = FIXTURE_DIR / "profiler_temimager_4_0_4_6.xyz" 46 points, doi_points, layers = process_xyz(path, clip_below_doi=False) 47 48 self.assertEqual(len(points), 166) 49 self.assertEqual(len(doi_points), 166) 50 self.assertEqual(len(layers), 4814) 51 self.assertEqual(points[0]["StationNo"], "1_00002") 52 self.assertEqual(points[0]["NumLayers"], 30) 53 self.assertEqual(layers[0]["Layer"], 1) 54 55 def test_temimage_4_0_7_8_fixture(self): 56 path = FIXTURE_DIR / "profiler_temimager_4_0_7_8.xyz" 57 points, doi_points, layers = process_xyz(path, clip_below_doi=False) 58 59 self.assertEqual(len(points), 44) 60 self.assertEqual(len(doi_points), 44) 61 self.assertEqual(len(layers), 1276) 62 self.assertEqual(points[0]["StationNo"], "20_00004") 63 self.assertEqual(points[0]["NumLayers"], 30) 64 65 def test_stem_temimage_4_0_4_6_fixture(self): 66 path = FIXTURE_DIR / "stem_temimager_4_0_4_6.xyz" 67 points, doi_points, layers = process_xyz(path, clip_below_doi=False) 68 69 self.assertEqual(len(points), 14) 70 self.assertEqual(len(doi_points), 14) 71 self.assertEqual(len(layers), 406) 72 self.assertEqual(points[0]["StationNo"], "1_00001") 73 self.assertEqual(points[0]["NumLayers"], 30) 74 75 def test_aarhus_workbench_fixture(self): 76 path = FIXTURE_DIR / "stem_workbench_2026_1.xyz" 77 points, doi_points, layers = process_xyz(path, clip_below_doi=False) 78 79 self.assertEqual(len(points), 158) 80 self.assertEqual(len(doi_points), 158) 81 self.assertEqual(len(layers), 4740) 82 self.assertEqual(points[0]["Line"], "1") 83 self.assertEqual(points[0]["StationNo"], "1_00001") 84 self.assertEqual(points[0]["NumLayers"], 30) 85 self.assertAlmostEqual(doi_points[0]["DOI"], 264.862) 86 self.assertEqual(layers[0]["DepthTop"], 0.0) 87 self.assertEqual(layers[0]["DepthBottom"], 2.0) 88 self.assertAlmostEqual(layers[-1]["DepthBottom"], 599.977) 89 90 def test_aarhus_workbench_2024_2_0_0_stem_40x40_fixture(self): 91 path = FIXTURE_DIR / "stem_40x40_workbench_2024_2_0_0.xyz" 92 points, doi_points, layers = process_xyz(path, clip_below_doi=False) 93 94 self.assertEqual(len(points), 20) 95 self.assertEqual(len(doi_points), 20) 96 self.assertEqual(len(layers), 700) 97 self.assertEqual(points[0]["Line"], "10001") 98 self.assertEqual(points[0]["StationNo"], "10001_00001") 99 self.assertEqual(points[0]["NumLayers"], 35) 100 self.assertAlmostEqual(points[0]["X"], 502395.50) 101 self.assertAlmostEqual(points[0]["Y"], 6287846.60) 102 self.assertAlmostEqual(doi_points[0]["DOI"], 204.463) 103 self.assertEqual(layers[0]["DepthTop"], 0.0) 104 self.assertEqual(layers[0]["DepthBottom"], 3.0) 105 106 def test_aarhus_workbench_2024_2_0_0_stem_80x80_fixture(self): 107 path = FIXTURE_DIR / "stem_80x80_workbench_2024_2_0_0.xyz" 108 points, doi_points, layers = process_xyz(path, clip_below_doi=False) 109 110 self.assertEqual(len(points), 3) 111 self.assertEqual(len(doi_points), 3) 112 self.assertEqual(len(layers), 105) 113 self.assertEqual(points[0]["Line"], "1") 114 self.assertEqual(points[0]["StationNo"], "1_00001") 115 self.assertEqual(points[0]["NumLayers"], 35) 116 self.assertAlmostEqual(points[0]["X"], 502873.80) 117 self.assertAlmostEqual(points[0]["Y"], 6287746.70) 118 self.assertAlmostEqual(doi_points[0]["DOI"], 285.405) 119 self.assertEqual(layers[0]["DepthTop"], 0.0) 120 self.assertEqual(layers[0]["DepthBottom"], 3.0) 121 122 def test_aarhus_workbench_2024_2_0_0_stem_profiler_fixture(self): 123 path = FIXTURE_DIR / "stem_profiler_workbench_2024_2_0_0.xyz" 124 points, doi_points, layers = process_xyz(path, clip_below_doi=False) 125 126 self.assertEqual(len(points), 274) 127 self.assertEqual(len(doi_points), 274) 128 self.assertEqual(len(layers), 9590) 129 self.assertEqual(points[0]["Line"], "1") 130 self.assertEqual(points[0]["StationNo"], "1_00001") 131 self.assertEqual(points[0]["NumLayers"], 35) 132 self.assertAlmostEqual(points[0]["X"], 502941.82) 133 self.assertAlmostEqual(points[0]["Y"], 6287498.15) 134 self.assertAlmostEqual(doi_points[0]["DOI"], 133.160) 135 self.assertEqual(layers[0]["DepthTop"], 0.0) 136 self.assertEqual(layers[0]["DepthBottom"], 2.0) 137 138 def test_sci_workbench_2026_1_fixture(self): 139 path = FIXTURE_DIR / "sci_workbench_2026_1.xyz" 140 points, doi_points, layers = process_xyz(path) 141 142 self.assertEqual(len(points), 5) 143 self.assertEqual(len(doi_points), 0) 144 self.assertEqual(len(layers), 5 * 30) 145 self.assertEqual(points[0]["Line"], "1") 146 self.assertEqual(points[0]["StationNo"], "1_00001") 147 self.assertEqual(points[0]["NumLayers"], 30) 148 self.assertAlmostEqual(points[0]["Z"], 9.84666, places=4) 149 self.assertEqual(layers[0]["DepthTop"], 0.0) 150 self.assertEqual(layers[0]["DepthBottom"], 1.0) 151 self.assertAlmostEqual(layers[0]["Resistivity"], 31.1, places=6) 152 153 def test_atem_sci_workbench_fixture(self): 154 path = FIXTURE_DIR / "atem_sci_workbench.xyz" 155 points, doi_points, layers = process_xyz(path, clip_below_doi=False) 156 157 self.assertEqual(len(points), 49) 158 self.assertEqual(len(doi_points), 49) 159 self.assertEqual(len(layers), 49 * 30) 160 self.assertEqual(points[0]["Line"], "100101") 161 self.assertEqual(points[0]["StationNo"], "100101_00001") 162 self.assertEqual(points[0]["NumLayers"], 30) 163 self.assertAlmostEqual(points[0]["X"], 597017.4) 164 self.assertAlmostEqual(points[0]["Y"], 6207329.0) 165 self.assertAlmostEqual(points[0]["Z"], 0.0) 166 self.assertAlmostEqual(points[0]["DataResidual"], 0.299) 167 self.assertAlmostEqual(doi_points[0]["DOI"], 46.22) 168 self.assertAlmostEqual(doi_points[0]["ZDOI"], -46.22) 169 self.assertEqual(layers[0]["DepthTop"], 0.0) 170 self.assertEqual(layers[0]["DepthBottom"], 0.5) 171 self.assertAlmostEqual(layers[0]["Resistivity"], 0.2732) 172 self.assertEqual(layers[0]["Color"], resistivity_color(0.2732)) 173 174 def test_opacity_constants(self): 175 self.assertEqual(ABOVE_DOI_OPACITY, 100) 176 self.assertEqual(BELOW_DOI_OPACITY, 10) 177 178 def test_process_xyz_accepts_default_masking_options(self): 179 path = FIXTURE_DIR / "profiler_temimager_4_0_4_6.xyz" 180 _, _, default_layers = process_xyz(path, clip_below_doi=False) 181 _, _, option_layers = process_xyz( 182 path, 183 mask_below_doi=True, 184 below_doi_opacity=BELOW_DOI_OPACITY, 185 clip_below_doi=False, 186 ) 187 188 self.assertEqual(len(option_layers), len(default_layers)) 189 self.assertEqual( 190 [row["Opacity"] for row in option_layers], 191 [row["Opacity"] for row in default_layers], 192 ) 193 194 def test_process_xyz_rejects_invalid_below_doi_opacity(self): 195 path = FIXTURE_DIR / "profiler_temimager_4_0_4_6.xyz" 196 197 with self.assertRaisesRegex(ValueError, "opacity must be between 0 and 100"): 198 process_xyz(path, below_doi_opacity=101) 199 200 def test_process_xyz_can_disable_below_doi_mask(self): 201 path = FIXTURE_DIR / "profiler_temimager_4_0_4_6.xyz" 202 _, _, layers = process_xyz( 203 path, mask_below_doi=False, clip_below_doi=False, 204 ) 205 206 self.assertTrue(all(row["Opacity"] == ABOVE_DOI_OPACITY for row in layers)) 207 208 def test_atem_sci_process_xyz_uses_custom_below_doi_opacity(self): 209 path = FIXTURE_DIR / "atem_sci_workbench.xyz" 210 _, _, layers = process_xyz( 211 path, below_doi_opacity=25, clip_below_doi=False, 212 ) 213 opacities = {row["Opacity"] for row in layers} 214 215 self.assertIn(ABOVE_DOI_OPACITY, opacities) 216 self.assertIn(25, opacities) 217 self.assertNotIn(BELOW_DOI_OPACITY, opacities) 218 219 def test_layer_opacity_can_disable_below_doi_mask(self): 220 self.assertEqual( 221 layer_opacity(200.0, 50.0, mask_below_doi=False), 222 ABOVE_DOI_OPACITY, 223 ) 224 225 def test_layer_opacity_uses_custom_below_doi_opacity(self): 226 self.assertEqual( 227 layer_opacity(200.0, 50.0, below_doi_opacity=25), 228 25, 229 ) 230 231 def test_layer_opacity_returns_above_when_doi_is_none(self): 232 self.assertEqual(layer_opacity(50.0, None), ABOVE_DOI_OPACITY) 233 self.assertEqual(layer_opacity(0.0, None), ABOVE_DOI_OPACITY) 234 self.assertEqual(layer_opacity(999.0, None), ABOVE_DOI_OPACITY) 235 236 def test_layer_opacity_returns_above_when_midpoint_depth_at_doi(self): 237 self.assertEqual(layer_opacity(10.0, 10.0), ABOVE_DOI_OPACITY) 238 self.assertEqual(layer_opacity(0.0, 50.0), ABOVE_DOI_OPACITY) 239 240 def test_layer_opacity_returns_below_when_midpoint_depth_exceeds_doi(self): 241 self.assertEqual(layer_opacity(10.1, 10.0), BELOW_DOI_OPACITY) 242 self.assertEqual(layer_opacity(200.0, 50.0), BELOW_DOI_OPACITY) 243 244 def test_crossing_layer_is_above_when_midpoint_is_at_doi(self): 245 with TemporaryDirectory() as tmp: 246 path = Path(tmp) / "midpoint.xyz" 247 path.write_text( 248 "/ Line StationNo X Y Z DOI DataResidual NumLayers " 249 "Res_001 Res_002 Res_003 Thick_001 Thick_002 Thick_003\n" 250 "1 1 0 0 100 15 0 3 10 20 30 10 10 10\n" 251 ) 252 253 _, _, layers = process_xyz(path, clip_below_doi=False) 254 255 self.assertEqual(layers[0]["Opacity"], ABOVE_DOI_OPACITY) 256 self.assertEqual(layers[1]["DepthTop"], 10.0) 257 self.assertEqual(layers[1]["DepthBottom"], 20.0) 258 self.assertEqual(layers[1]["Opacity"], ABOVE_DOI_OPACITY) 259 self.assertEqual(layers[2]["Opacity"], BELOW_DOI_OPACITY) 260 261 def test_fixture_layers_have_correct_opacity(self): 262 # TEMImage fixture has DOI, so opacity depends on midpoint depth vs DOI. 263 # Each sounding has its own DOI; we verify the first sounding's layers. 264 path = FIXTURE_DIR / "profiler_temimager_4_0_4_6.xyz" 265 points, doi_points, layers = process_xyz(path, clip_below_doi=False) 266 self.assertTrue(all("Opacity" in row for row in layers)) 267 # Every layer's opacity is one of the two valid values 268 self.assertTrue( 269 all(row["Opacity"] in (ABOVE_DOI_OPACITY, BELOW_DOI_OPACITY) for row in layers) 270 ) 271 # The first sounding's DOI and layer count 272 first_doi = doi_points[0]["DOI"] 273 first_n = points[0]["NumLayers"] 274 first_layers = layers[:first_n] 275 for layer in first_layers: 276 depth_mid = (layer["DepthTop"] + layer["DepthBottom"]) / 2 277 expected = ABOVE_DOI_OPACITY if depth_mid <= first_doi else BELOW_DOI_OPACITY 278 self.assertEqual( 279 layer["Opacity"], 280 expected, 281 f"Layer {layer['Layer']} midpoint depth {depth_mid} vs DOI {first_doi}", 282 ) 283 284 def test_clip_below_doi_drops_layers_fully_below_doi(self): 285 with TemporaryDirectory() as tmp: 286 path = Path(tmp) / "clip.xyz" 287 path.write_text( 288 "/ Line StationNo X Y Z DOI DataResidual NumLayers " 289 "Res_001 Res_002 Res_003 Thick_001 Thick_002 Thick_003\n" 290 "1 1 0 0 100 15 0 3 10 20 30 10 10 10\n" 291 ) 292 293 _, _, layers = process_xyz(path, clip_below_doi=True) 294 295 self.assertEqual(len(layers), 2) 296 self.assertEqual(layers[0]["DepthTop"], 0.0) 297 self.assertEqual(layers[0]["DepthBottom"], 10.0) 298 self.assertEqual(layers[1]["DepthTop"], 10.0) 299 self.assertEqual(layers[1]["DepthBottom"], 15.0) 300 self.assertEqual(layers[1]["ZBottom"], 85.0) 301 self.assertIn( 302 "LINESTRING Z (0.0 0.0 90.0, 0.0 0.0 85.0)", 303 layers[1]["Geometry"], 304 ) 305 306 def test_clip_below_doi_keeps_layer_when_doi_at_layer_bottom(self): 307 with TemporaryDirectory() as tmp: 308 path = Path(tmp) / "boundary.xyz" 309 path.write_text( 310 "/ Line StationNo X Y Z DOI DataResidual NumLayers " 311 "Res_001 Res_002 Thick_001 Thick_002\n" 312 "1 1 0 0 100 10 0 2 10 20 10 10\n" 313 ) 314 315 _, _, layers = process_xyz(path, clip_below_doi=True) 316 317 self.assertEqual(len(layers), 1) 318 self.assertEqual(layers[0]["DepthTop"], 0.0) 319 self.assertEqual(layers[0]["DepthBottom"], 10.0) 320 321 def test_clip_below_doi_is_noop_when_disabled(self): 322 with TemporaryDirectory() as tmp: 323 path = Path(tmp) / "unclipped.xyz" 324 path.write_text( 325 "/ Line StationNo X Y Z DOI DataResidual NumLayers " 326 "Res_001 Res_002 Res_003 Thick_001 Thick_002 Thick_003\n" 327 "1 1 0 0 100 15 0 3 10 20 30 10 10 10\n" 328 ) 329 330 _, _, layers = process_xyz(path, clip_below_doi=False) 331 332 self.assertEqual(len(layers), 3) 333 self.assertEqual(layers[-1]["DepthBottom"], 30.0) 334 335 def test_clip_below_doi_default_is_enabled(self): 336 with TemporaryDirectory() as tmp: 337 path = Path(tmp) / "default.xyz" 338 path.write_text( 339 "/ Line StationNo X Y Z DOI DataResidual NumLayers " 340 "Res_001 Res_002 Res_003 Thick_001 Thick_002 Thick_003\n" 341 "1 1 0 0 100 15 0 3 10 20 30 10 10 10\n" 342 ) 343 344 _, _, layers = process_xyz(path) 345 346 self.assertEqual(len(layers), 2) 347 348 def test_clip_below_doi_skips_sci_format_safely(self): 349 path = FIXTURE_DIR / "sci_workbench_2026_1.xyz" 350 _, _, layers_default = process_xyz(path) 351 _, _, layers_unclipped = process_xyz(path, clip_below_doi=False) 352 353 self.assertEqual(len(layers_default), len(layers_unclipped)) 354 355 def test_clip_below_doi_clips_atem_sci_layers(self): 356 path = FIXTURE_DIR / "atem_sci_workbench.xyz" 357 _, _, unclipped = process_xyz(path, clip_below_doi=False) 358 _, _, clipped = process_xyz(path, clip_below_doi=True) 359 360 self.assertLess(len(clipped), len(unclipped)) 361 362 def test_sci_fixture_layers_all_above_opacity(self): 363 # SCI format has no DOI, so all layers get ABOVE_DOI_OPACITY 364 path = FIXTURE_DIR / "sci_workbench_2026_1.xyz" 365 _, _, layers = process_xyz(path) 366 self.assertTrue(all(row["Opacity"] == ABOVE_DOI_OPACITY for row in layers)) 367 368 def test_resistivity_color_buckets(self): 369 self.assertEqual(resistivity_color(-5), "#000091") 370 self.assertEqual(resistivity_color(0.5), "#000091") 371 self.assertEqual(resistivity_color(31.1), "#00ff00") 372 self.assertEqual(resistivity_color(60), "#ffb500") 373 self.assertEqual(resistivity_color(90), "#ff0000") 374 self.assertEqual(resistivity_color(125), "#ff1c8d") 375 self.assertEqual(resistivity_color(2000), "#540054") 376 self.assertEqual(resistivity_color(float("nan")), "#ffffff") 377 self.assertEqual(resistivity_color(None), "#ffffff") 378 379 def test_layer_rows_include_color_field(self): 380 path = FIXTURE_DIR / "sci_workbench_2026_1.xyz" 381 _, _, layers = process_xyz(path) 382 self.assertEqual(layers[0]["Color"], resistivity_color(layers[0]["Resistivity"])) 383 self.assertTrue(all("Color" in row for row in layers)) 384 385 def test_layers_3d_renderer_uses_color_field(self): 386 tree = ET.parse(STYLES_DIR / "layers.qml") 387 renderer = tree.getroot().find("./renderer-3d") 388 self.assertIsNotNone(renderer) 389 ambient = renderer.find( 390 ".//material/data-defined-properties/Option/Option[@name='properties']" 391 "/Option[@name='ambient']" 392 ) 393 self.assertIsNotNone(ambient) 394 active = ambient.find("./Option[@name='active']") 395 expr = ambient.find("./Option[@name='expression']") 396 self.assertEqual(active.attrib["value"], "true") 397 self.assertEqual(expr.attrib["value"], '"Color"') 398 399 def test_detect_source_epsg_for_sci_fixture(self): 400 path = FIXTURE_DIR / "sci_workbench_2026_1.xyz" 401 self.assertEqual(detect_source_epsg(path), "EPSG:25832") 402 403 def test_detect_source_epsg_for_aarhus_workbench_fixture(self): 404 path = FIXTURE_DIR / "stem_workbench_2026_1.xyz" 405 self.assertEqual(detect_source_epsg(path), "EPSG:32637") 406 407 def test_detect_source_epsg_for_workbench_2024_2_0_0_fixture(self): 408 path = FIXTURE_DIR / "stem_40x40_workbench_2024_2_0_0.xyz" 409 self.assertEqual(detect_source_epsg(path), "EPSG:32632") 410 411 def test_detect_source_epsg_returns_none_when_not_declared(self): 412 path = FIXTURE_DIR / "profiler_temimager_4_0_7_8.xyz" 413 self.assertIsNone(detect_source_epsg(path)) 414 415 def test_write_csv_writes_expected_headers(self): 416 source = FIXTURE_DIR / "profiler_temimager_4_0_4_6.xyz" 417 points, _, _ = process_xyz(source) 418 419 with TemporaryDirectory() as tmp: 420 tmp_source = Path(tmp) / source.name 421 shutil.copyfile(source, tmp_source) 422 out_path = write_csv(points, tmp_source, ".points.csv") 423 424 self.assertTrue(out_path.exists()) 425 self.assertIn("StationNo", out_path.read_text().splitlines()[0]) 426 427 def test_process_xyz_rejects_metadata_only_file(self): 428 with TemporaryDirectory() as tmp: 429 path = Path(tmp) / "metadata_only.xyz" 430 path.write_text("/ epsg:32632\n/ no header here\n") 431 432 with self.assertRaisesRegex( 433 ValueError, "supported header row" 434 ): 435 process_xyz(path) 436 437 def test_process_xyz_rejects_unsupported_header(self): 438 with TemporaryDirectory() as tmp: 439 path = Path(tmp) / "unsupported.xyz" 440 path.write_text("A B C\n1 2 3\n") 441 442 with self.assertRaisesRegex( 443 ValueError, "supported header row" 444 ): 445 process_xyz(path) 446 447 def test_process_xyz_rejects_mismatched_row_length(self): 448 with TemporaryDirectory() as tmp: 449 path = Path(tmp) / "broken.xyz" 450 path.write_text( 451 "/ X Y Z DOI DataResidual NumLayers Line StationNo\n" 452 "1 2 3 4 5 6 7\n" 453 ) 454 455 with self.assertRaisesRegex( 456 ValueError, r"Row 2 has 7 columns, expected 8" 457 ): 458 process_xyz(path) 459 460 def test_process_xyz_rejects_mismatched_sci_row_length(self): 461 with TemporaryDirectory() as tmp: 462 path = Path(tmp) / "broken_sci.xyz" 463 path.write_text( 464 "/epsg:25832\n" 465 "/ ID Line_No Layer_No X Y Elevation_Cell Resistivity " 466 "Resistivity_STD Conductivity Depth_top Depth_bottom " 467 "Thickness Thickness_STD\n" 468 "1 1 1 500000 6000000 10 20 1 50 0 1 1 1\n" 469 "2 1 2 500000 6000000\n" 470 ) 471 472 with self.assertRaisesRegex( 473 ValueError, r"Row 4 has 5 columns, expected 13" 474 ): 475 process_xyz(path) 476 477 def test_fixture_doi_values_fit_fixed_scale(self): 478 for path in sorted(FIXTURE_DIR.glob("*.xyz")): 479 _, doi_points, _ = process_xyz(path) 480 if not doi_points: 481 continue 482 values = [row["DOI"] for row in doi_points] 483 484 self.assertGreaterEqual(min(values), 0.0, path.name) 485 self.assertLessEqual(max(values), 500.0, path.name) 486 487 def test_doi_style_uses_fixed_zero_to_five_hundred_ranges(self): 488 tree = ET.parse(STYLES_DIR / "doi.qml") 489 renderer = tree.getroot().find(".//renderer-v2") 490 self.assertIsNotNone(renderer) 491 self.assertEqual(renderer.attrib["attr"], "DOI") 492 493 ranges = renderer.findall("./ranges/range") 494 self.assertEqual(len(ranges), 10) 495 self.assertEqual(ranges[0].attrib["lower"], "0.000000000000000") 496 self.assertEqual(ranges[-1].attrib["upper"], "500.000000000000000") 497 self.assertEqual( 498 [(r.attrib["lower"], r.attrib["upper"]) for r in ranges], 499 [ 500 ("0.000000000000000", "50.000000000000000"), 501 ("50.000000000000000", "100.000000000000000"), 502 ("100.000000000000000", "150.000000000000000"), 503 ("150.000000000000000", "200.000000000000000"), 504 ("200.000000000000000", "250.000000000000000"), 505 ("250.000000000000000", "300.000000000000000"), 506 ("300.000000000000000", "350.000000000000000"), 507 ("350.000000000000000", "400.000000000000000"), 508 ("400.000000000000000", "450.000000000000000"), 509 ("450.000000000000000", "500.000000000000000"), 510 ], 511 ) 512 513 method = renderer.find("./classificationMethod") 514 self.assertIsNotNone(method) 515 self.assertEqual(method.attrib["id"], "EqualInterval") 516 517 518 class PluginTests(unittest.TestCase): 519 def _import_plugin_module(self, qt6_enums=False): 520 class FakeSignal: 521 def __init__(self): 522 self._callbacks = [] 523 524 def connect(self, callback): 525 self._callbacks.append(callback) 526 527 def emit(self, *args): 528 for callback in self._callbacks: 529 callback(*args) 530 531 class FakeAction: 532 def __init__(self, *_args, **_kwargs): 533 self.triggered = FakeSignal() 534 535 class FakeFileDialog: 536 paths = [] 537 538 @staticmethod 539 def getOpenFileNames(*_args, **_kwargs): 540 return FakeFileDialog.paths, "" 541 542 class FakeMessageBox: 543 warnings = [] 544 545 @staticmethod 546 def warning(*args): 547 FakeMessageBox.warnings.append(args) 548 549 class FakeDialogBase: 550 def __init__(self, *_args, **_kwargs): 551 self.title = None 552 self.layout = None 553 554 def setWindowTitle(self, title): 555 self.title = title 556 557 def setLayout(self, layout): 558 self.layout = layout 559 560 def accept(self): 561 pass 562 563 def reject(self): 564 pass 565 566 if qt6_enums: 567 class FakeDialog(FakeDialogBase): 568 class DialogCode: 569 Accepted = 1 570 Rejected = 0 571 else: 572 class FakeDialog(FakeDialogBase): 573 Accepted = 1 574 Rejected = 0 575 576 class FakeCheckBox: 577 def __init__(self, text): 578 self.text = text 579 self._checked = False 580 self.enabled = True 581 self.toggled = FakeSignal() 582 583 def setChecked(self, checked): 584 checked = bool(checked) 585 changed = checked != self._checked 586 self._checked = checked 587 if changed: 588 self.toggled.emit(checked) 589 590 def setEnabled(self, enabled): 591 self.enabled = bool(enabled) 592 593 def isChecked(self): 594 return self._checked 595 596 class FakeDialogButtonBoxBase: 597 def __init__(self, buttons): 598 self.buttons = buttons 599 self.accepted = FakeSignal() 600 self.rejected = FakeSignal() 601 602 if qt6_enums: 603 class FakeDialogButtonBox(FakeDialogButtonBoxBase): 604 class StandardButton: 605 Ok = 4 606 Cancel = 8 607 else: 608 class FakeDialogButtonBox(FakeDialogButtonBoxBase): 609 Ok = 1 610 Cancel = 2 611 612 class FakeFormLayout: 613 def __init__(self): 614 self.rows = [] 615 616 def addRow(self, label, widget): 617 self.rows.append((label, widget)) 618 619 class FakeSpinBox: 620 def __init__(self): 621 self.minimum = None 622 self.maximum = None 623 self.suffix = None 624 self._value = None 625 self.enabled = True 626 627 def setRange(self, minimum, maximum): 628 self.minimum = minimum 629 self.maximum = maximum 630 631 def setSuffix(self, suffix): 632 self.suffix = suffix 633 634 def setValue(self, value): 635 self._value = value 636 637 def setEnabled(self, enabled): 638 self.enabled = bool(enabled) 639 640 def value(self): 641 return self._value 642 643 class FakeMapLayerComboBox: 644 def __init__(self): 645 self.project = None 646 self.filters = None 647 self.allow_empty = False 648 self.empty_text = None 649 self.enabled = True 650 self._layer = None 651 652 def setProject(self, project): 653 self.project = project 654 655 def setFilters(self, filters): 656 self.filters = filters 657 658 def setAllowEmptyLayer(self, allow_empty, text=""): 659 self.allow_empty = bool(allow_empty) 660 self.empty_text = text 661 662 def setEnabled(self, enabled): 663 self.enabled = bool(enabled) 664 665 def currentLayer(self): 666 return self._layer 667 668 def setLayer(self, layer): 669 self._layer = layer 670 671 class FakeVBoxLayout: 672 def __init__(self): 673 self.items = [] 674 675 def addWidget(self, widget): 676 self.items.append(widget) 677 678 def addLayout(self, layout): 679 self.items.append(layout) 680 681 class FakeQMetaType: 682 class Type: 683 QString = "QString" 684 Int = "Int" 685 Double = "Double" 686 687 qtcore = types.ModuleType("qgis.PyQt.QtCore") 688 qtcore.QMetaType = FakeQMetaType 689 690 qtwidgets = types.ModuleType("qgis.PyQt.QtWidgets") 691 qtwidgets.QAction = FakeAction 692 qtwidgets.QCheckBox = FakeCheckBox 693 qtwidgets.QDialog = FakeDialog 694 qtwidgets.QDialogButtonBox = FakeDialogButtonBox 695 qtwidgets.QFileDialog = FakeFileDialog 696 qtwidgets.QFormLayout = FakeFormLayout 697 qtwidgets.QMessageBox = FakeMessageBox 698 qtwidgets.QSpinBox = FakeSpinBox 699 qtwidgets.QVBoxLayout = FakeVBoxLayout 700 701 class FakeQgis: 702 class LayerFilter: 703 RasterLayer = "RasterLayer" 704 705 class WkbType: 706 PointZ = "PointZ" 707 LineStringZ = "LineStringZ" 708 709 class FakeField: 710 def __init__(self, name, field_type): 711 self._name = name 712 self.field_type = field_type 713 714 def name(self): 715 return self._name 716 717 class FakeFields: 718 def __init__(self): 719 self._fields = [] 720 721 def append(self, field): 722 self._fields.append(field) 723 724 def __iter__(self): 725 return iter(self._fields) 726 727 def __len__(self): 728 return len(self._fields) 729 730 def __getitem__(self, index): 731 return self._fields[index] 732 733 class FakeGeometry: 734 def __init__(self, wkt): 735 self.wkt = wkt 736 737 @staticmethod 738 def fromWkt(wkt): 739 return FakeGeometry(wkt) 740 741 class FakeFeature: 742 def __init__(self, fields): 743 self.fields = fields 744 self.geometry = None 745 self.attributes = None 746 747 def setGeometry(self, geometry): 748 self.geometry = geometry 749 750 def setAttributes(self, attributes): 751 self.attributes = list(attributes) 752 753 class FakeSaveVectorOptions: 754 def __init__(self): 755 self.driverName = None 756 self.fileEncoding = None 757 self.layerName = None 758 self.actionOnExistingFile = None 759 760 class FakeVectorFileWriter: 761 NoError = "NoError" 762 CreateOrOverwriteFile = "CreateOrOverwriteFile" 763 CreateOrOverwriteLayer = "CreateOrOverwriteLayer" 764 calls = [] 765 created = [] 766 next_writer = None 767 768 class WriterError: 769 NoError = "NoError" 770 771 SaveVectorOptions = FakeSaveVectorOptions 772 773 def __init__(self, error=NoError, message="", add_features_result=True): 774 self._error = error 775 self._message = message 776 self._add_features_result = add_features_result 777 self.features = [] 778 779 @staticmethod 780 def create( 781 file_name, fields, geometry_type, crs, transform_context, options 782 ): 783 writer = FakeVectorFileWriter.next_writer or FakeVectorFileWriter() 784 FakeVectorFileWriter.next_writer = None 785 FakeVectorFileWriter.calls.append({ 786 "fileName": file_name, 787 "fields": fields, 788 "geometryType": geometry_type, 789 "crs": crs, 790 "transformContext": transform_context, 791 "driverName": options.driverName, 792 "fileEncoding": options.fileEncoding, 793 "layerName": options.layerName, 794 "actionOnExistingFile": options.actionOnExistingFile, 795 }) 796 FakeVectorFileWriter.created.append(writer) 797 return writer 798 799 def hasError(self): 800 return self._error 801 802 def errorMessage(self): 803 return self._message 804 805 def addFeatures(self, features): 806 self.features.extend(features) 807 return self._add_features_result 808 809 class FakeCoordinateReferenceSystem: 810 def __init__(self, authid="EPSG:32632", valid=True): 811 self._authid = authid 812 self._valid = valid 813 814 def createFromString(self, authid): 815 self._authid = authid 816 self._valid = True 817 return True 818 819 def isValid(self): 820 return self._valid 821 822 def authid(self): 823 return self._authid 824 825 class FakeCoordinateTransform: 826 def __init__(self, source_crs, destination_crs, project): 827 self.source_crs = source_crs 828 self.destination_crs = destination_crs 829 self.project = project 830 831 def transform(self, point): 832 return point 833 834 class FakeCsException(Exception): 835 pass 836 837 class FakePointXY: 838 def __init__(self, x, y): 839 self.x = x 840 self.y = y 841 842 class FakeRasterDataProvider: 843 def __init__(self, samples=None): 844 self.samples = dict(samples or {}) 845 self.calls = [] 846 847 def sample(self, point, band): 848 self.calls.append((point, band)) 849 return self.samples.get((point.x, point.y, band), (math.nan, False)) 850 851 class FakeRasterLayer: 852 def __init__(self, samples=None, name="DEM", crs=None): 853 self._provider = FakeRasterDataProvider(samples) 854 self._name = name 855 self._crs = crs or FakeCoordinateReferenceSystem("EPSG:3857") 856 857 def crs(self): 858 return self._crs 859 860 def dataProvider(self): 861 return self._provider 862 863 def name(self): 864 return self._name 865 866 class FakeLayerGroup: 867 def __init__(self, name): 868 self.name = name 869 self.layers = [] 870 871 def insertLayer(self, index, layer): 872 self.layers.insert(index, layer) 873 874 class FakeLayerTreeRoot: 875 def __init__(self): 876 self.groups = [] 877 878 def insertGroup(self, index, name): 879 group = FakeLayerGroup(name) 880 self.groups.insert(index, group) 881 return group 882 883 class FakeProject: 884 _instance = None 885 886 def __init__(self): 887 self._crs = FakeCoordinateReferenceSystem() 888 self._transform_context = object() 889 self.root = FakeLayerTreeRoot() 890 self.layers = [] 891 892 @classmethod 893 def instance(cls): 894 if cls._instance is None: 895 cls._instance = cls() 896 return cls._instance 897 898 def crs(self): 899 return self._crs 900 901 def transformContext(self): 902 return self._transform_context 903 904 def addMapLayer(self, layer, add_to_legend=True): 905 self.layers.append((layer, add_to_legend)) 906 907 def layerTreeRoot(self): 908 return self.root 909 910 class FakeVectorLayer: 911 created = [] 912 valid_by_name = {} 913 914 def __init__(self, uri, name, provider): 915 self.uri = uri 916 self.name = name 917 self.provider = provider 918 self.styles = [] 919 self._valid = self.valid_by_name.get(name, True) 920 FakeVectorLayer.created.append(self) 921 922 def isValid(self): 923 return self._valid 924 925 def loadNamedStyle(self, style_path): 926 self.styles.append(style_path) 927 928 qgis_core = types.ModuleType("qgis.core") 929 qgis_core.Qgis = FakeQgis 930 qgis_core.QgsProject = FakeProject 931 qgis_core.QgsVectorLayer = FakeVectorLayer 932 qgis_core.QgsCoordinateReferenceSystem = FakeCoordinateReferenceSystem 933 qgis_core.QgsCoordinateTransform = FakeCoordinateTransform 934 qgis_core.QgsCsException = FakeCsException 935 qgis_core.QgsPointXY = FakePointXY 936 qgis_core.QgsFeature = FakeFeature 937 qgis_core.QgsField = FakeField 938 qgis_core.QgsFields = FakeFields 939 qgis_core.QgsGeometry = FakeGeometry 940 qgis_core.QgsVectorFileWriter = FakeVectorFileWriter 941 qgis_core.QgsRasterLayer = FakeRasterLayer 942 943 qgis_gui = types.ModuleType("qgis.gui") 944 qgis_gui.QgsMapLayerComboBox = FakeMapLayerComboBox 945 946 module_map = { 947 "qgis": types.ModuleType("qgis"), 948 "qgis.PyQt": types.ModuleType("qgis.PyQt"), 949 "qgis.PyQt.QtCore": qtcore, 950 "qgis.PyQt.QtWidgets": qtwidgets, 951 "qgis.core": qgis_core, 952 "qgis.gui": qgis_gui, 953 } 954 955 with patch.dict(sys.modules, module_map): 956 sys.modules.pop("tem_loader.tem_loader", None) 957 module = importlib.import_module("tem_loader.tem_loader") 958 959 module.FakeRasterLayer = FakeRasterLayer 960 module.FakeRasterDataProvider = FakeRasterDataProvider 961 962 return module, FakeFileDialog, FakeMessageBox 963 964 def test_run_continues_after_failed_file_and_shows_filename(self): 965 module, file_dialog, message_box = self._import_plugin_module() 966 file_dialog.paths = ["/tmp/bad.xyz", "/tmp/good.xyz"] 967 iface = Mock() 968 iface.mainWindow.return_value = object() 969 dialog = Mock() 970 dialog.options.return_value = { 971 "clip_below_doi": True, 972 "mask_below_doi": True, 973 "below_doi_opacity": 35, 974 "elevation_raster_layer": None, 975 } 976 module._ImportOptionsDialog = Mock(return_value=dialog) 977 module._exec_dialog = Mock(return_value=module.QDialog.Accepted) 978 plugin = module.TEMLoaderPlugin(iface) 979 plugin._load_xyz = Mock( 980 side_effect=[ValueError("Row 3 has 4 columns, expected 6"), None] 981 ) 982 983 plugin.run() 984 985 self.assertEqual(plugin._load_xyz.call_count, 2) 986 self.assertIsNone( 987 plugin._load_xyz.call_args_list[0].kwargs["elevation_raster_layer"] 988 ) 989 self.assertIsNone( 990 plugin._load_xyz.call_args_list[1].kwargs["elevation_raster_layer"] 991 ) 992 self.assertEqual(len(message_box.warnings), 1) 993 self.assertIn("bad.xyz", message_box.warnings[0][2]) 994 self.assertIn("Row 3 has 4 columns, expected 6", message_box.warnings[0][2]) 995 996 def test_run_opens_options_dialog_after_file_selection_and_passes_options(self): 997 module, file_dialog, _ = self._import_plugin_module() 998 file_dialog.paths = ["/tmp/model.xyz"] 999 iface = Mock() 1000 parent = object() 1001 iface.mainWindow.return_value = parent 1002 dialog = Mock() 1003 raster_layer = object() 1004 dialog.options.return_value = { 1005 "clip_below_doi": True, 1006 "mask_below_doi": True, 1007 "below_doi_opacity": 35, 1008 "elevation_raster_layer": raster_layer, 1009 } 1010 module._ImportOptionsDialog = Mock(return_value=dialog) 1011 module._exec_dialog = Mock(return_value=module.QDialog.Accepted) 1012 plugin = module.TEMLoaderPlugin(iface) 1013 plugin._load_xyz = Mock() 1014 1015 plugin.run() 1016 1017 module._ImportOptionsDialog.assert_called_once_with(parent) 1018 module._exec_dialog.assert_called_once_with(dialog) 1019 plugin._load_xyz.assert_called_once_with( 1020 Path("/tmp/model.xyz"), 1021 clip_below_doi=True, 1022 mask_below_doi=True, 1023 below_doi_opacity=35, 1024 elevation_raster_layer=raster_layer, 1025 ) 1026 1027 def test_run_cancel_options_dialog_skips_all_loads(self): 1028 module, file_dialog, _ = self._import_plugin_module() 1029 file_dialog.paths = ["/tmp/one.xyz", "/tmp/two.xyz"] 1030 iface = Mock() 1031 iface.mainWindow.return_value = object() 1032 dialog = Mock() 1033 module._ImportOptionsDialog = Mock(return_value=dialog) 1034 module._exec_dialog = Mock(return_value=module.QDialog.Rejected) 1035 plugin = module.TEMLoaderPlugin(iface) 1036 plugin._load_xyz = Mock() 1037 1038 plugin.run() 1039 1040 plugin._load_xyz.assert_not_called() 1041 dialog.options.assert_not_called() 1042 1043 def test_import_options_dialog_defaults_and_options(self): 1044 module, _, _ = self._import_plugin_module() 1045 1046 dialog = module._ImportOptionsDialog(object()) 1047 1048 self.assertEqual(dialog.title, "TEM Loader Options") 1049 self.assertEqual( 1050 dialog._clip_checkbox.text, 1051 "Hide layers below depth of interest (DOI, 2D and 3D views)", 1052 ) 1053 self.assertTrue(dialog._clip_checkbox.isChecked()) 1054 self.assertEqual( 1055 dialog._mask_checkbox.text, 1056 "Make layers below DOI partially transparent (2D view only)", 1057 ) 1058 self.assertEqual( 1059 dialog._dem_checkbox.text, 1060 "Adjust vertical position to digital elevation model", 1061 ) 1062 self.assertFalse(dialog._dem_checkbox.isChecked()) 1063 self.assertEqual(dialog._opacity_spinbox.minimum, 0) 1064 self.assertEqual(dialog._opacity_spinbox.maximum, 100) 1065 self.assertEqual(dialog._opacity_spinbox.suffix, "%") 1066 self.assertFalse(dialog._mask_checkbox.enabled) 1067 self.assertFalse(dialog._opacity_spinbox.enabled) 1068 self.assertIs(dialog.layout.items[0], dialog._clip_checkbox) 1069 self.assertIs(dialog.layout.items[1], dialog._mask_checkbox) 1070 self.assertEqual( 1071 dialog.layout.items[2].rows, 1072 [("Opacity", dialog._opacity_spinbox)], 1073 ) 1074 self.assertIs(dialog.layout.items[3], dialog._dem_checkbox) 1075 self.assertEqual( 1076 dialog.layout.items[4].rows, 1077 [("Elevation raster", dialog._dem_raster_combo)], 1078 ) 1079 self.assertEqual( 1080 dialog.options(), 1081 { 1082 "clip_below_doi": True, 1083 "mask_below_doi": True, 1084 "below_doi_opacity": module.core.BELOW_DOI_OPACITY, 1085 "elevation_raster_layer": None, 1086 }, 1087 ) 1088 1089 dialog._clip_checkbox.setChecked(False) 1090 self.assertTrue(dialog._mask_checkbox.enabled) 1091 self.assertTrue(dialog._opacity_spinbox.enabled) 1092 1093 dialog._opacity_spinbox.setValue(35) 1094 dialog._mask_checkbox.setChecked(False) 1095 1096 self.assertFalse(dialog._opacity_spinbox.enabled) 1097 self.assertEqual( 1098 dialog.options(), 1099 { 1100 "clip_below_doi": False, 1101 "mask_below_doi": False, 1102 "below_doi_opacity": 35, 1103 "elevation_raster_layer": None, 1104 }, 1105 ) 1106 1107 def test_import_options_dialog_dem_raster_combo_toggles_with_checkbox(self): 1108 module, _, _ = self._import_plugin_module() 1109 1110 dialog = module._ImportOptionsDialog(object()) 1111 1112 self.assertIs(dialog._dem_raster_combo.project, module.QgsProject.instance()) 1113 self.assertEqual( 1114 dialog._dem_raster_combo.filters, 1115 module.Qgis.LayerFilter.RasterLayer, 1116 ) 1117 self.assertTrue(dialog._dem_raster_combo.allow_empty) 1118 self.assertEqual(dialog._dem_raster_combo.empty_text, "No elevation raster") 1119 self.assertFalse(dialog._dem_raster_combo.enabled) 1120 1121 dialog._dem_checkbox.setChecked(True) 1122 self.assertTrue(dialog._dem_raster_combo.enabled) 1123 1124 dialog._dem_checkbox.setChecked(False) 1125 self.assertFalse(dialog._dem_raster_combo.enabled) 1126 1127 def test_import_options_dialog_returns_dem_layer_only_when_enabled(self): 1128 module, _, _ = self._import_plugin_module() 1129 raster_layer = object() 1130 dialog = module._ImportOptionsDialog(object()) 1131 1132 dialog._dem_raster_combo.setLayer(raster_layer) 1133 1134 self.assertIsNone(dialog.options()["elevation_raster_layer"]) 1135 1136 dialog._dem_checkbox.setChecked(True) 1137 self.assertIs(dialog.options()["elevation_raster_layer"], raster_layer) 1138 1139 dialog._dem_raster_combo.setLayer(None) 1140 self.assertIsNone(dialog.options()["elevation_raster_layer"]) 1141 1142 def test_import_options_dialog_supports_qt6_button_namespace(self): 1143 module, _, _ = self._import_plugin_module(qt6_enums=True) 1144 1145 dialog = module._ImportOptionsDialog(object()) 1146 button_box = dialog.layout.items[-1] 1147 1148 self.assertFalse(hasattr(module.QDialogButtonBox, "Ok")) 1149 self.assertEqual( 1150 button_box.buttons, 1151 module.QDialogButtonBox.StandardButton.Ok 1152 | module.QDialogButtonBox.StandardButton.Cancel, 1153 ) 1154 1155 def test_run_accepts_qt6_dialog_code_namespace(self): 1156 module, file_dialog, _ = self._import_plugin_module(qt6_enums=True) 1157 file_dialog.paths = ["/tmp/model.xyz"] 1158 iface = Mock() 1159 iface.mainWindow.return_value = object() 1160 dialog = Mock() 1161 dialog.options.return_value = { 1162 "clip_below_doi": True, 1163 "mask_below_doi": True, 1164 "below_doi_opacity": 35, 1165 "elevation_raster_layer": None, 1166 } 1167 module._ImportOptionsDialog = Mock(return_value=dialog) 1168 module._exec_dialog = Mock(return_value=module.QDialog.DialogCode.Accepted) 1169 plugin = module.TEMLoaderPlugin(iface) 1170 plugin._load_xyz = Mock() 1171 1172 plugin.run() 1173 1174 self.assertFalse(hasattr(module.QDialog, "Accepted")) 1175 plugin._load_xyz.assert_called_once_with( 1176 Path("/tmp/model.xyz"), 1177 clip_below_doi=True, 1178 mask_below_doi=True, 1179 below_doi_opacity=35, 1180 elevation_raster_layer=None, 1181 ) 1182 1183 def test_run_rejects_qt6_dialog_code_namespace(self): 1184 module, file_dialog, _ = self._import_plugin_module(qt6_enums=True) 1185 file_dialog.paths = ["/tmp/model.xyz"] 1186 iface = Mock() 1187 iface.mainWindow.return_value = object() 1188 dialog = Mock() 1189 module._ImportOptionsDialog = Mock(return_value=dialog) 1190 module._exec_dialog = Mock(return_value=module.QDialog.DialogCode.Rejected) 1191 plugin = module.TEMLoaderPlugin(iface) 1192 plugin._load_xyz = Mock() 1193 1194 plugin.run() 1195 1196 plugin._load_xyz.assert_not_called() 1197 dialog.options.assert_not_called() 1198 1199 def test_layer_filter_uses_qgis_layer_filter_namespace(self): 1200 module, _, _ = self._import_plugin_module() 1201 1202 raster_filter = module._layer_filter("RasterLayer") 1203 1204 self.assertEqual(raster_filter, module.Qgis.LayerFilter.RasterLayer) 1205 1206 def test_layer_filter_supports_legacy_flat_namespace(self): 1207 module, _, _ = self._import_plugin_module() 1208 1209 class LegacyQgis: 1210 RasterLayer = "LegacyRasterLayer" 1211 1212 module.Qgis = LegacyQgis 1213 1214 self.assertEqual(module._layer_filter("RasterLayer"), "LegacyRasterLayer") 1215 1216 def test_sample_dem_elevation_samples_band_one(self): 1217 module, _, _ = self._import_plugin_module() 1218 provider = Mock() 1219 1220 def sample(point, band): 1221 self.assertEqual(point.x, 1.5) 1222 self.assertEqual(point.y, 2.5) 1223 self.assertEqual(band, 1) 1224 return "123.5", True 1225 1226 provider.sample.side_effect = sample 1227 raster_layer = Mock() 1228 raster_layer.crs.return_value = module.QgsCoordinateReferenceSystem( 1229 "EPSG:3857" 1230 ) 1231 raster_layer.dataProvider.return_value = provider 1232 raster_layer.name.return_value = "DEM" 1233 1234 elevation = module._sample_dem_elevation( 1235 raster_layer, 1236 module.QgsCoordinateReferenceSystem("EPSG:25832"), 1237 module.QgsProject.instance(), 1238 1.5, 1239 2.5, 1240 ) 1241 1242 self.assertEqual(elevation, 123.5) 1243 raster_layer.crs.assert_called_once_with() 1244 raster_layer.dataProvider.assert_called_once_with() 1245 1246 def test_sample_dem_elevation_works_with_fake_raster_layer(self): 1247 module, _, _ = self._import_plugin_module() 1248 raster_layer = module.FakeRasterLayer({(1.5, 2.5, 1): (123.5, True)}) 1249 1250 elevation = module._sample_dem_elevation( 1251 raster_layer, 1252 module.QgsCoordinateReferenceSystem("EPSG:25832"), 1253 module.QgsProject.instance(), 1254 1.5, 1255 2.5, 1256 ) 1257 1258 self.assertEqual(elevation, 123.5) 1259 self.assertEqual(len(raster_layer.dataProvider().calls), 1) 1260 point, band = raster_layer.dataProvider().calls[0] 1261 self.assertEqual((point.x, point.y), (1.5, 2.5)) 1262 self.assertEqual(band, 1) 1263 1264 def test_sample_dem_elevation_warns_for_invalid_sample(self): 1265 module, _, _ = self._import_plugin_module() 1266 provider = Mock() 1267 provider.sample.return_value = math.nan, False 1268 raster_layer = Mock() 1269 raster_layer.crs.return_value = module.QgsCoordinateReferenceSystem( 1270 "EPSG:3857" 1271 ) 1272 raster_layer.dataProvider.return_value = provider 1273 raster_layer.name.return_value = "DEM" 1274 1275 with patch("builtins.print") as print_mock: 1276 elevation = module._sample_dem_elevation( 1277 raster_layer, 1278 module.QgsCoordinateReferenceSystem("EPSG:25832"), 1279 module.QgsProject.instance(), 1280 1.5, 1281 2.5, 1282 ) 1283 1284 self.assertIsNone(elevation) 1285 print_mock.assert_called_once_with( 1286 "TEM Loader warning: point (1.5, 2.5) is outside DEM raster DEM" 1287 ) 1288 1289 def test_sample_dem_elevation_warns_for_transform_failure(self): 1290 module, _, _ = self._import_plugin_module() 1291 1292 class BrokenTransform: 1293 def __init__(self, *_args): 1294 pass 1295 1296 def transform(self, _point): 1297 raise module.QgsCsException("bad transform") 1298 1299 module.QgsCoordinateTransform = BrokenTransform 1300 raster_layer = Mock() 1301 raster_layer.crs.return_value = module.QgsCoordinateReferenceSystem( 1302 "EPSG:3857" 1303 ) 1304 raster_layer.name.return_value = "DEM" 1305 1306 with patch("builtins.print") as print_mock: 1307 elevation = module._sample_dem_elevation( 1308 raster_layer, 1309 module.QgsCoordinateReferenceSystem("EPSG:25832"), 1310 module.QgsProject.instance(), 1311 1.5, 1312 2.5, 1313 ) 1314 1315 self.assertIsNone(elevation) 1316 raster_layer.dataProvider.assert_not_called() 1317 print_mock.assert_called_once_with( 1318 "TEM Loader warning: could not transform point (1.5, 2.5) " 1319 "to DEM raster DEM: bad transform" 1320 ) 1321 1322 def test_adjust_rows_to_dem_updates_vertical_fields_and_geometry(self): 1323 module, _, _ = self._import_plugin_module() 1324 provider = Mock() 1325 provider.sample.return_value = "123.0", True 1326 raster_layer = Mock() 1327 raster_layer.crs.return_value = module.QgsCoordinateReferenceSystem( 1328 "EPSG:3857" 1329 ) 1330 raster_layer.dataProvider.return_value = provider 1331 points = [ 1332 { 1333 "X": 1.0, 1334 "Y": 2.0, 1335 "Z": 100.0, 1336 "Geometry": "POINT Z (1.0 2.0 100.0)", 1337 } 1338 ] 1339 doi_points = [ 1340 { 1341 "X": 1.0, 1342 "Y": 2.0, 1343 "Z": 90.0, 1344 "DOI": 10.0, 1345 "ZDOI": 90.0, 1346 "Geometry": "POINT Z (1.0 2.0 90.0)", 1347 } 1348 ] 1349 layers = [ 1350 { 1351 "X": 1.0, 1352 "Y": 2.0, 1353 "Z": 100.0, 1354 "DepthTop": 0.0, 1355 "DepthBottom": 5.0, 1356 "ZTop": 100.0, 1357 "ZMid": 97.5, 1358 "ZBottom": 95.0, 1359 "Geometry": "LINESTRING Z (1.0 2.0 100.0, 1.0 2.0 95.0)", 1360 } 1361 ] 1362 1363 module._adjust_rows_to_dem( 1364 points, 1365 doi_points, 1366 layers, 1367 raster_layer, 1368 module.QgsCoordinateReferenceSystem("EPSG:25832"), 1369 module.QgsProject.instance(), 1370 ) 1371 1372 self.assertEqual(points[0]["Z"], 123.0) 1373 self.assertEqual(points[0]["Geometry"], "POINT Z (1.0 2.0 123.0)") 1374 self.assertEqual(doi_points[0]["Z"], 113.0) 1375 self.assertEqual(doi_points[0]["ZDOI"], 113.0) 1376 self.assertEqual(doi_points[0]["Geometry"], "POINT Z (1.0 2.0 113.0)") 1377 self.assertEqual(layers[0]["Z"], 123.0) 1378 self.assertEqual(layers[0]["ZTop"], 123.0) 1379 self.assertEqual(layers[0]["ZMid"], 120.5) 1380 self.assertEqual(layers[0]["ZBottom"], 118.0) 1381 self.assertEqual( 1382 layers[0]["Geometry"], 1383 "LINESTRING Z (1.0 2.0 123.0, 1.0 2.0 118.0)", 1384 ) 1385 1386 def test_adjust_rows_to_dem_uses_each_sounding_dem_sample(self): 1387 module, _, _ = self._import_plugin_module() 1388 raster_layer = module.FakeRasterLayer( 1389 { 1390 (1.0, 2.0, 1): (123.0, True), 1391 (3.0, 4.0, 1): (200.0, True), 1392 } 1393 ) 1394 points = [ 1395 { 1396 "X": 1.0, 1397 "Y": 2.0, 1398 "Z": 100.0, 1399 "Geometry": "POINT Z (1.0 2.0 100.0)", 1400 }, 1401 { 1402 "X": 3.0, 1403 "Y": 4.0, 1404 "Z": 190.0, 1405 "Geometry": "POINT Z (3.0 4.0 190.0)", 1406 }, 1407 ] 1408 doi_points = [ 1409 { 1410 "X": 1.0, 1411 "Y": 2.0, 1412 "Z": 90.0, 1413 "DOI": 10.0, 1414 "ZDOI": 90.0, 1415 "Geometry": "POINT Z (1.0 2.0 90.0)", 1416 }, 1417 { 1418 "X": 3.0, 1419 "Y": 4.0, 1420 "Z": 182.0, 1421 "DOI": 8.0, 1422 "ZDOI": 182.0, 1423 "Geometry": "POINT Z (3.0 4.0 182.0)", 1424 }, 1425 ] 1426 layers = [ 1427 { 1428 "X": 1.0, 1429 "Y": 2.0, 1430 "Z": 100.0, 1431 "DepthTop": 0.0, 1432 "DepthBottom": 5.0, 1433 "ZTop": 100.0, 1434 "ZMid": 97.5, 1435 "ZBottom": 95.0, 1436 "Geometry": "LINESTRING Z (1.0 2.0 100.0, 1.0 2.0 95.0)", 1437 }, 1438 { 1439 "X": 3.0, 1440 "Y": 4.0, 1441 "Z": 190.0, 1442 "DepthTop": 1.5, 1443 "DepthBottom": 5.0, 1444 "ZTop": 188.5, 1445 "ZMid": 186.75, 1446 "ZBottom": 185.0, 1447 "Geometry": "LINESTRING Z (3.0 4.0 188.5, 3.0 4.0 185.0)", 1448 }, 1449 ] 1450 1451 module._adjust_rows_to_dem( 1452 points, 1453 doi_points, 1454 layers, 1455 raster_layer, 1456 module.QgsCoordinateReferenceSystem("EPSG:25832"), 1457 module.QgsProject.instance(), 1458 ) 1459 1460 self.assertEqual([point["Z"] for point in points], [123.0, 200.0]) 1461 self.assertEqual( 1462 [point["Geometry"] for point in points], 1463 ["POINT Z (1.0 2.0 123.0)", "POINT Z (3.0 4.0 200.0)"], 1464 ) 1465 self.assertEqual( 1466 [doi_point["ZDOI"] for doi_point in doi_points], 1467 [113.0, 192.0], 1468 ) 1469 self.assertEqual( 1470 [layer["ZTop"] for layer in layers], 1471 [123.0, 198.5], 1472 ) 1473 self.assertEqual( 1474 [layer["ZMid"] for layer in layers], 1475 [120.5, 196.75], 1476 ) 1477 self.assertEqual( 1478 [layer["ZBottom"] for layer in layers], 1479 [118.0, 195.0], 1480 ) 1481 self.assertEqual( 1482 [layer["Geometry"] for layer in layers], 1483 [ 1484 "LINESTRING Z (1.0 2.0 123.0, 1.0 2.0 118.0)", 1485 "LINESTRING Z (3.0 4.0 198.5, 3.0 4.0 195.0)", 1486 ], 1487 ) 1488 self.assertEqual(len(raster_layer.dataProvider().calls), 2) 1489 1490 def test_adjust_rows_to_dem_warns_and_keeps_original_z_outside_dem(self): 1491 module, _, _ = self._import_plugin_module() 1492 raster_layer = module.FakeRasterLayer({(1.0, 2.0, 1): (math.nan, False)}) 1493 points = [ 1494 { 1495 "X": 1.0, 1496 "Y": 2.0, 1497 "Z": 100.0, 1498 "Geometry": "POINT Z (1.0 2.0 100.0)", 1499 } 1500 ] 1501 doi_points = [ 1502 { 1503 "X": 1.0, 1504 "Y": 2.0, 1505 "Z": 90.0, 1506 "DOI": 10.0, 1507 "ZDOI": 90.0, 1508 "Geometry": "POINT Z (1.0 2.0 90.0)", 1509 } 1510 ] 1511 layers = [ 1512 { 1513 "X": 1.0, 1514 "Y": 2.0, 1515 "Z": 100.0, 1516 "DepthTop": 0.0, 1517 "DepthBottom": 5.0, 1518 "ZTop": 100.0, 1519 "ZMid": 97.5, 1520 "ZBottom": 95.0, 1521 "Geometry": "LINESTRING Z (1.0 2.0 100.0, 1.0 2.0 95.0)", 1522 } 1523 ] 1524 1525 with patch("builtins.print") as print_mock: 1526 module._adjust_rows_to_dem( 1527 points, 1528 doi_points, 1529 layers, 1530 raster_layer, 1531 module.QgsCoordinateReferenceSystem("EPSG:25832"), 1532 module.QgsProject.instance(), 1533 ) 1534 1535 self.assertEqual(points[0]["Z"], 100.0) 1536 self.assertEqual(points[0]["Geometry"], "POINT Z (1.0 2.0 100.0)") 1537 self.assertEqual(doi_points[0]["Z"], 90.0) 1538 self.assertEqual(doi_points[0]["ZDOI"], 90.0) 1539 self.assertEqual(doi_points[0]["Geometry"], "POINT Z (1.0 2.0 90.0)") 1540 self.assertEqual(layers[0]["Z"], 100.0) 1541 self.assertEqual(layers[0]["ZTop"], 100.0) 1542 self.assertEqual(layers[0]["ZMid"], 97.5) 1543 self.assertEqual(layers[0]["ZBottom"], 95.0) 1544 self.assertEqual( 1545 layers[0]["Geometry"], 1546 "LINESTRING Z (1.0 2.0 100.0, 1.0 2.0 95.0)", 1547 ) 1548 print_mock.assert_called_once_with( 1549 "TEM Loader warning: point (1.0, 2.0) is outside DEM raster DEM" 1550 ) 1551 1552 def test_exec_dialog_supports_exec_and_exec_apis(self): 1553 module, _, _ = self._import_plugin_module() 1554 exec_dialog = Mock() 1555 exec_dialog.exec.return_value = module.QDialog.Accepted 1556 exec_legacy_dialog = Mock(spec=["exec_"]) 1557 exec_legacy_dialog.exec_.return_value = module.QDialog.Rejected 1558 1559 self.assertEqual(module._exec_dialog(exec_dialog), module.QDialog.Accepted) 1560 self.assertEqual(module._exec_dialog(exec_legacy_dialog), module.QDialog.Rejected) 1561 1562 def test_build_geopackage_uri_uses_layername(self): 1563 module, _, _ = self._import_plugin_module() 1564 gpkg_path = Mock() 1565 gpkg_path.resolve.return_value = Path("/tmp/model.gpkg") 1566 1567 uri = module._build_geopackage_uri(gpkg_path, "layers") 1568 1569 self.assertEqual(uri, "/tmp/model.gpkg|layername=layers") 1570 1571 def test_build_fields_skips_geometry_and_uses_expected_types(self): 1572 module, _, _ = self._import_plugin_module() 1573 rows = [ 1574 { 1575 "X": 1.0, 1576 "Line": "1", 1577 "StationNo": "1_00001", 1578 "NumLayers": 30, 1579 "Layer": 1, 1580 "Opacity": 100, 1581 "Color": "#00ff00", 1582 "Geometry": "POINT Z (1 2 3)", 1583 } 1584 ] 1585 1586 fields = module._build_fields(rows) 1587 1588 self.assertEqual( 1589 [field.name() for field in fields], 1590 ["X", "Line", "StationNo", "NumLayers", "Layer", "Opacity", "Color"], 1591 ) 1592 self.assertEqual( 1593 [field.field_type for field in fields], 1594 [ 1595 module.QMetaType.Type.Double, 1596 module.QMetaType.Type.QString, 1597 module.QMetaType.Type.QString, 1598 module.QMetaType.Type.Int, 1599 module.QMetaType.Type.Int, 1600 module.QMetaType.Type.Int, 1601 module.QMetaType.Type.QString, 1602 ], 1603 ) 1604 1605 def test_rows_to_features_copies_geometry_and_attributes(self): 1606 module, _, _ = self._import_plugin_module() 1607 rows = [ 1608 { 1609 "X": 1.0, 1610 "Line": "7", 1611 "Geometry": "POINT Z (1 2 3)", 1612 } 1613 ] 1614 fields = module._build_fields(rows) 1615 1616 features = module._rows_to_features(rows, fields) 1617 1618 self.assertEqual(len(features), 1) 1619 self.assertEqual(features[0].geometry.wkt, "POINT Z (1 2 3)") 1620 self.assertEqual(features[0].attributes, [1.0, "7"]) 1621 1622 def test_write_geopackage_layer_configures_writer_and_features(self): 1623 module, _, _ = self._import_plugin_module() 1624 rows = [ 1625 { 1626 "X": 1.0, 1627 "Y": 2.0, 1628 "Layer": 1, 1629 "Opacity": 10, 1630 "Color": "#00ff00", 1631 "Geometry": "LINESTRING Z (1 2 3, 1 2 2)", 1632 } 1633 ] 1634 crs = object() 1635 transform_context = object() 1636 1637 module._write_geopackage_layer( 1638 rows, 1639 Path("/tmp/model.gpkg"), 1640 "layers", 1641 module.Qgis.WkbType.LineStringZ, 1642 crs, 1643 transform_context, 1644 module.QgsVectorFileWriter.CreateOrOverwriteFile, 1645 ) 1646 1647 call = module.QgsVectorFileWriter.calls[0] 1648 writer = module.QgsVectorFileWriter.created[0] 1649 self.assertEqual(call["fileName"], "/tmp/model.gpkg") 1650 self.assertEqual(call["geometryType"], module.Qgis.WkbType.LineStringZ) 1651 self.assertIs(call["crs"], crs) 1652 self.assertIs(call["transformContext"], transform_context) 1653 self.assertEqual(call["driverName"], "GPKG") 1654 self.assertEqual(call["fileEncoding"], "UTF-8") 1655 self.assertEqual(call["layerName"], "layers") 1656 self.assertEqual( 1657 call["actionOnExistingFile"], 1658 module.QgsVectorFileWriter.CreateOrOverwriteFile, 1659 ) 1660 self.assertEqual(len(writer.features), 1) 1661 self.assertEqual( 1662 writer.features[0].geometry.wkt, 1663 "LINESTRING Z (1 2 3, 1 2 2)", 1664 ) 1665 self.assertEqual( 1666 writer.features[0].attributes, 1667 [1.0, 2.0, 1, 10, "#00ff00"], 1668 ) 1669 1670 def test_write_geopackage_layer_reports_writer_error(self): 1671 module, _, _ = self._import_plugin_module() 1672 module.QgsVectorFileWriter.next_writer = module.QgsVectorFileWriter( 1673 error="ErrCreateLayer", 1674 message="disk full", 1675 ) 1676 1677 with self.assertRaisesRegex( 1678 ValueError, 1679 "failed to create GeoPackage layer points: disk full", 1680 ): 1681 module._write_geopackage_layer( 1682 [{"X": 1.0, "Geometry": "POINT Z (1 2 3)"}], 1683 Path("/tmp/model.gpkg"), 1684 "points", 1685 module.Qgis.WkbType.PointZ, 1686 object(), 1687 object(), 1688 module.QgsVectorFileWriter.CreateOrOverwriteFile, 1689 ) 1690 1691 def test_load_xyz_writes_geopackage_layers(self): 1692 module, _, _ = self._import_plugin_module() 1693 points = [ 1694 { 1695 "X": 1.0, 1696 "Y": 2.0, 1697 "Z": 3.0, 1698 "Line": "1", 1699 "StationNo": "1_00001", 1700 "NumLayers": 1, 1701 "Geometry": "POINT Z (1 2 3)", 1702 } 1703 ] 1704 doi_points = [ 1705 { 1706 "X": 1.0, 1707 "Y": 2.0, 1708 "Z": -4.0, 1709 "DOI": 7.0, 1710 "ZDOI": -4.0, 1711 "Geometry": "POINT Z (1 2 -4)", 1712 } 1713 ] 1714 layers = [ 1715 { 1716 "X": 1.0, 1717 "Y": 2.0, 1718 "Z": 3.0, 1719 "ZTop": 3.0, 1720 "ZMid": 2.5, 1721 "ZBottom": 2.0, 1722 "DepthTop": 0.0, 1723 "DepthBottom": 1.0, 1724 "Resistivity": 10.0, 1725 "Opacity": 100, 1726 "Color": "#008cff", 1727 "Layer": 1, 1728 "Geometry": "LINESTRING Z (1 2 3, 1 2 2)", 1729 } 1730 ] 1731 module.core.process_xyz = Mock(return_value=(points, doi_points, layers)) 1732 module.core.detect_source_epsg = Mock(return_value=None) 1733 module.core.write_csv = Mock() 1734 plugin = module.TEMLoaderPlugin(Mock()) 1735 1736 plugin._load_xyz( 1737 Path("/tmp/model.xyz"), 1738 mask_below_doi=False, 1739 below_doi_opacity=35, 1740 ) 1741 1742 module.core.process_xyz.assert_called_once_with( 1743 Path("/tmp/model.xyz"), 1744 mask_below_doi=False, 1745 below_doi_opacity=35, 1746 clip_below_doi=True, 1747 ) 1748 self.assertFalse(module.core.write_csv.called) 1749 self.assertEqual( 1750 [call["layerName"] for call in module.QgsVectorFileWriter.calls], 1751 ["layers", "doi", "points"], 1752 ) 1753 self.assertEqual( 1754 [call["geometryType"] for call in module.QgsVectorFileWriter.calls], 1755 [ 1756 module.Qgis.WkbType.LineStringZ, 1757 module.Qgis.WkbType.PointZ, 1758 module.Qgis.WkbType.PointZ, 1759 ], 1760 ) 1761 self.assertEqual( 1762 [ 1763 call["actionOnExistingFile"] 1764 for call in module.QgsVectorFileWriter.calls 1765 ], 1766 [ 1767 module.QgsVectorFileWriter.CreateOrOverwriteFile, 1768 module.QgsVectorFileWriter.CreateOrOverwriteLayer, 1769 module.QgsVectorFileWriter.CreateOrOverwriteLayer, 1770 ], 1771 ) 1772 gpkg_uri = str(Path("/tmp/model.gpkg").resolve()) 1773 self.assertEqual( 1774 [ 1775 (layer.uri, layer.name, layer.provider) 1776 for layer in module.QgsVectorLayer.created 1777 ], 1778 [ 1779 (f"{gpkg_uri}|layername=layers", "layers", "ogr"), 1780 (f"{gpkg_uri}|layername=doi", "doi", "ogr"), 1781 (f"{gpkg_uri}|layername=points", "points", "ogr"), 1782 ], 1783 ) 1784 project = module.QgsProject.instance() 1785 self.assertEqual( 1786 [(layer.name, add_to_legend) for layer, add_to_legend in project.layers], 1787 [("layers", False), ("doi", False), ("points", False)], 1788 ) 1789 self.assertEqual(project.root.groups[0].name, "model") 1790 self.assertEqual( 1791 [layer.name for layer in project.root.groups[0].layers], 1792 ["points", "doi", "layers"], 1793 ) 1794 1795 def test_load_xyz_applies_dem_adjustment_before_writing(self): 1796 module, _, _ = self._import_plugin_module() 1797 points = [ 1798 { 1799 "X": 1.0, 1800 "Y": 2.0, 1801 "Z": 3.0, 1802 "Geometry": "POINT Z (1 2 3)", 1803 } 1804 ] 1805 doi_points = [ 1806 { 1807 "X": 1.0, 1808 "Y": 2.0, 1809 "Z": -4.0, 1810 "DOI": 7.0, 1811 "ZDOI": -4.0, 1812 "Geometry": "POINT Z (1 2 -4)", 1813 } 1814 ] 1815 layers = [ 1816 { 1817 "X": 1.0, 1818 "Y": 2.0, 1819 "Z": 3.0, 1820 "ZTop": 3.0, 1821 "ZMid": 2.5, 1822 "ZBottom": 2.0, 1823 "DepthTop": 0.0, 1824 "DepthBottom": 1.0, 1825 "Layer": 1, 1826 "Geometry": "LINESTRING Z (1 2 3, 1 2 2)", 1827 } 1828 ] 1829 provider = Mock() 1830 provider.sample.return_value = "50.0", True 1831 raster_layer = Mock() 1832 raster_layer.crs.return_value = module.QgsCoordinateReferenceSystem( 1833 "EPSG:3857" 1834 ) 1835 raster_layer.dataProvider.return_value = provider 1836 module.core.process_xyz = Mock(return_value=(points, doi_points, layers)) 1837 module.core.detect_source_epsg = Mock(return_value="EPSG:25832") 1838 plugin = module.TEMLoaderPlugin(Mock()) 1839 1840 plugin._load_xyz( 1841 Path("/tmp/model.xyz"), 1842 mask_below_doi=False, 1843 below_doi_opacity=35, 1844 elevation_raster_layer=raster_layer, 1845 ) 1846 1847 module.core.process_xyz.assert_called_once_with( 1848 Path("/tmp/model.xyz"), 1849 mask_below_doi=False, 1850 below_doi_opacity=35, 1851 clip_below_doi=True, 1852 ) 1853 self.assertEqual( 1854 module.QgsVectorFileWriter.created[0].features[0].geometry.wkt, 1855 "LINESTRING Z (1.0 2.0 50.0, 1.0 2.0 49.0)", 1856 ) 1857 self.assertEqual( 1858 module.QgsVectorFileWriter.created[1].features[0].geometry.wkt, 1859 "POINT Z (1.0 2.0 43.0)", 1860 ) 1861 self.assertEqual( 1862 module.QgsVectorFileWriter.created[2].features[0].geometry.wkt, 1863 "POINT Z (1.0 2.0 50.0)", 1864 ) 1865 self.assertEqual( 1866 [call["crs"].authid() for call in module.QgsVectorFileWriter.calls], 1867 ["EPSG:25832", "EPSG:25832", "EPSG:25832"], 1868 ) 1869 1870 def test_load_xyz_skips_empty_doi_geopackage_layer(self): 1871 module, _, _ = self._import_plugin_module() 1872 points = [ 1873 { 1874 "X": 1.0, 1875 "Y": 2.0, 1876 "Z": 3.0, 1877 "Geometry": "POINT Z (1 2 3)", 1878 } 1879 ] 1880 layers = [ 1881 { 1882 "X": 1.0, 1883 "Y": 2.0, 1884 "Z": 3.0, 1885 "Layer": 1, 1886 "Geometry": "LINESTRING Z (1 2 3, 1 2 2)", 1887 } 1888 ] 1889 module.core.process_xyz = Mock(return_value=(points, [], layers)) 1890 module.core.detect_source_epsg = Mock(return_value=None) 1891 module.core.write_csv = Mock() 1892 plugin = module.TEMLoaderPlugin(Mock()) 1893 1894 plugin._load_xyz(Path("/tmp/sci.xyz")) 1895 1896 self.assertFalse(module.core.write_csv.called) 1897 self.assertEqual( 1898 [call["layerName"] for call in module.QgsVectorFileWriter.calls], 1899 ["layers", "points"], 1900 ) 1901 self.assertEqual( 1902 [layer.name for layer in module.QgsVectorLayer.created], 1903 ["layers", "points"], 1904 ) 1905 project = module.QgsProject.instance() 1906 self.assertEqual( 1907 [layer.name for layer in project.root.groups[0].layers], 1908 ["points", "layers"], 1909 ) 1910 1911 def test_load_xyz_uses_source_crs_and_loads_styles(self): 1912 module, _, _ = self._import_plugin_module() 1913 points = [ 1914 { 1915 "X": 1.0, 1916 "Y": 2.0, 1917 "Z": 3.0, 1918 "Geometry": "POINT Z (1 2 3)", 1919 } 1920 ] 1921 doi_points = [ 1922 { 1923 "X": 1.0, 1924 "Y": 2.0, 1925 "Z": -4.0, 1926 "DOI": 7.0, 1927 "Geometry": "POINT Z (1 2 -4)", 1928 } 1929 ] 1930 layers = [ 1931 { 1932 "X": 1.0, 1933 "Y": 2.0, 1934 "Z": 3.0, 1935 "Layer": 1, 1936 "Geometry": "LINESTRING Z (1 2 3, 1 2 2)", 1937 } 1938 ] 1939 module.core.process_xyz = Mock(return_value=(points, doi_points, layers)) 1940 module.core.detect_source_epsg = Mock(return_value="EPSG:25832") 1941 plugin = module.TEMLoaderPlugin(Mock()) 1942 1943 plugin._load_xyz(Path("/tmp/styled.xyz")) 1944 1945 self.assertEqual( 1946 [call["crs"].authid() for call in module.QgsVectorFileWriter.calls], 1947 ["EPSG:25832", "EPSG:25832", "EPSG:25832"], 1948 ) 1949 self.assertEqual( 1950 { 1951 layer.name: [Path(style).name for style in layer.styles] 1952 for layer in module.QgsVectorLayer.created 1953 }, 1954 { 1955 "layers": ["layers.qml"], 1956 "doi": ["doi.qml"], 1957 "points": ["points.qml"], 1958 }, 1959 ) 1960 1961 def test_resolve_crs_uses_project_crs_when_source_epsg_missing(self): 1962 module, _, _ = self._import_plugin_module() 1963 module.core.detect_source_epsg = Mock(return_value=None) 1964 project = module.QgsProject.instance() 1965 project._crs = module.QgsCoordinateReferenceSystem("EPSG:3857") 1966 plugin = module.TEMLoaderPlugin(Mock()) 1967 1968 crs = plugin._resolve_crs(Path("/tmp/no_epsg.xyz"), project) 1969 1970 self.assertEqual(crs.authid(), "EPSG:3857") 1971 1972 def test_resolve_crs_falls_back_to_epsg_4326_when_project_crs_invalid(self): 1973 module, _, _ = self._import_plugin_module() 1974 module.core.detect_source_epsg = Mock(return_value=None) 1975 project = module.QgsProject.instance() 1976 project._crs = module.QgsCoordinateReferenceSystem("EPSG:3857", valid=False) 1977 plugin = module.TEMLoaderPlugin(Mock()) 1978 1979 crs = plugin._resolve_crs(Path("/tmp/invalid_project_crs.xyz"), project) 1980 1981 self.assertEqual(crs.authid(), "EPSG:4326") 1982 self.assertTrue(crs.isValid()) 1983 1984 def test_load_xyz_warns_about_failed_geopackage_layer_load(self): 1985 module, _, message_box = self._import_plugin_module() 1986 points = [ 1987 { 1988 "X": 1.0, 1989 "Y": 2.0, 1990 "Z": 3.0, 1991 "Geometry": "POINT Z (1 2 3)", 1992 } 1993 ] 1994 doi_points = [ 1995 { 1996 "X": 1.0, 1997 "Y": 2.0, 1998 "Z": -4.0, 1999 "DOI": 7.0, 2000 "Geometry": "POINT Z (1 2 -4)", 2001 } 2002 ] 2003 layers = [ 2004 { 2005 "X": 1.0, 2006 "Y": 2.0, 2007 "Z": 3.0, 2008 "Layer": 1, 2009 "Geometry": "LINESTRING Z (1 2 3, 1 2 2)", 2010 } 2011 ] 2012 module.core.process_xyz = Mock(return_value=(points, doi_points, layers)) 2013 module.core.detect_source_epsg = Mock(return_value=None) 2014 module.QgsVectorLayer.valid_by_name = {"doi": False} 2015 iface = Mock() 2016 iface.mainWindow.return_value = object() 2017 plugin = module.TEMLoaderPlugin(iface) 2018 2019 plugin._load_xyz(Path("/tmp/model.xyz")) 2020 2021 project = module.QgsProject.instance() 2022 self.assertEqual( 2023 [layer.name for layer in project.root.groups[0].layers], 2024 ["points", "layers"], 2025 ) 2026 self.assertEqual(len(message_box.warnings), 1) 2027 self.assertIn( 2028 "model.xyz: failed to load layers: doi", 2029 message_box.warnings[0][2], 2030 )