qgis-tem-loader

qgis plugin for loading TEM geophysical inversion XYZ files as 3D objects
git clone git://src.adamsgaard.dk/qgis-tem-loader # fast
git clone https://src.adamsgaard.dk/qgis-tem-loader.git # slow
Log | Files | Refs | README | LICENSE Back to index

test_core.py (73136B)


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