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