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


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