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