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