turbo_turtle._tests.test_system — turbo-turtle 1.2.9 documentation

Skip to main content
`Ctrl`+`K`

#

# Source code for turbo_turtle._tests.test_system

    """System test wrapper for executing shell commands with pytest results reporting.

    All tests should be marked ``pytest.mark.systemtest`` (handled in the test function markers).

    All tests that require a third-party software unavailable on conda-forge should be marked as
    ``pytest.mark.require_third_party``.

    All tests should use string template substitution instead of f-strings, if possible. See :meth:`test_system` for
    available substitutions.
    """

    import getpass
    import importlib
    import os
    import pathlib
    import platform
    import string
    import subprocess
    import tempfile
    import typing
    from unittest.mock import patch

    import pytest

    from turbo_turtle import _settings, _utilities
    from turbo_turtle._main import get_parser
    from turbo_turtle.conftest import missing_display

    # TODO: Remove user check when Windows CI server Gitlab-Runner account has access to the Abaqus license server
    # https://re-git.lanl.gov/aea/python-projects/waves/-/issues/984
    def check_ci_user() -> bool:
        user = getpass.getuser().lower()
        return "pn2606796" in user or user == "gitlab-runner"

    test_check_ci_user_cases = {
        "windows ci user": ("PN2606796$", True),
        "windows ci user without trailing service account character": ("PN2606796", True),
        "macos ci user": ("gitlab-runner", True),
        "player character": ("roppenheimer", False),
    }

    @pytest.mark.parametrize(
        ("mock_user", "expected"),
        test_check_ci_user_cases.values(),
        ids=test_check_ci_user_cases.keys(),
    )
    def test_check_ci_user(mock_user: str, expected: bool) -> None:
        with patch("getpass.getuser", return_value=mock_user):
            testing_ci_user = check_ci_user()
        assert testing_ci_user is expected

    def check_installed(package_name: str = "turbo_turtle") -> bool:
        try:
            importlib.metadata.version(package_name)
            return True
        except importlib.metadata.PackageNotFoundError:
            return False

    def test_check_installed() -> None:
        with patch("importlib.metadata.version", return_value="0.0.0"):
            assert check_installed()
        with patch("importlib.metadata.version", side_effect=importlib.metadata.PackageNotFoundError):
            assert not check_installed()

    parser = get_parser()
    subcommand_list = parser._subparsers._group_actions[0].choices.keys()
    env = os.environ.copy()
    turbo_turtle_command = "turbo-turtle"
    testing_windows = True if platform.system().lower() == "windows" else False
    testing_ci_user = check_ci_user()
    installed = check_installed()
    if not installed:
        # TODO: Recover from the SCons task definition?
        build_directory = _settings._project_root_abspath.parent / "build" / "systemtests"
        build_directory.mkdir(parents=True, exist_ok=True)
        turbo_turtle_command = "python -m turbo_turtle._main"
        package_parent_path = _settings._project_root_abspath.parent
        key = "PYTHONPATH"
        if key in env:
            env[key] = f"{package_parent_path}{os.pathsep}{env[key]}"
        else:
            env[key] = f"{package_parent_path}"

    def setup_sphere_commands(
        model: str,
        inner_radius: float,
        outer_radius: float,
        angle: float,
        y_offset: float,
        quadrant: str,
        element_type: str,
        element_replacement: str,
        backend: str,
        output_type: str,
    ) -> list[string.Template]:
        """Return the sphere/partition/mesh commands for system testing.

        :returns: list of string or string template commands
        """
        model = pathlib.Path(model).with_suffix(".cae")
        image = model.with_suffix(".png")
        # TODO: Merge Gmsh sphere system tests when partition/mesh/image subcommands are implemented
        # https://re-git.lanl.gov/aea/python-projects/turbo-turtle/-/issues/217
        if backend == "cubit":
            model = model.with_suffix(".cub")
            image = image.parent / f"{image.stem}-cubit{image.suffix}"
        assembly = model.stem + "_assembly.inp"
        center = f"0. {y_offset} 0."
        xvector = _utilities.character_delimited_list([1.0, 0.0, 0.0])
        zvector = _utilities.character_delimited_list([0.0, 0.0, 1.0])
        backend_option = f"--backend {backend}" if backend is not None else ""

        commands = [
            string.Template(
                "${turbo_turtle_command} sphere ${abaqus_command} ${cubit_command} "
                f"--inner-radius {inner_radius} --outer-radius {outer_radius} --output-file {model} "
                f"--model-name {model.stem} --part-name {model.stem} --quadrant {quadrant} "
                f"--revolution-angle {angle} --y-offset {y_offset} {backend_option}"
            ),
            string.Template(
                "${turbo_turtle_command} partition ${abaqus_command} ${cubit_command} "
                f"--input-file {model} --output-file {model} "
                f"--model-name {model.stem} --part-name {model.stem} --center {center} "
                f"--xvector {xvector} --zvector {zvector} {backend_option}"
            ),
            string.Template(
                "${turbo_turtle_command} mesh ${abaqus_command} ${cubit_command} "
                f"--input-file {model} --output-file {model} "
                f"--model-name {model.stem} --part-name {model.stem} --global-seed 0.15 "
                f"--element-type {element_type} {backend_option}"
            ),
            string.Template(
                "${turbo_turtle_command} image ${abaqus_command} ${cubit_command} "
                f"--input-file {model} --output-file {image} "
                f"--model-name {model.stem} --part-name {model.stem} {backend_option}"
            ),
            string.Template(
                "${turbo_turtle_command} export ${abaqus_command} ${cubit_command} "
                f"--input-file {model} --model-name {model.stem} --part-name {model.stem} "
                f"--element-type {element_replacement} --destination . "
                f"--assembly {assembly} --output-type {output_type} {backend_option}"
            ),
        ]
        # Skip the image subcommand when DISPLAY is not found
        # Skip the image subcommand when running the Genesis variations. We don't need duplicate images of the cubit meshes
        if (backend == "cubit" and missing_display) or (backend == "cubit" and output_type.lower() == "genesis"):
            commands.pop(3)
        # Skip the partition/mesh/image/export
        if inner_radius == 0:
            commands = [commands[0]]
        marks = [getattr(pytest.mark, backend)]
        if backend == "abaqus":
            # TODO: Remove user check when Windows CI Gitlab-Runner account can access the Abaqus license server
            # https://re-git.lanl.gov/aea/python-projects/waves/-/issues/984
            marks.append(
                pytest.mark.skipif(
                    testing_windows and testing_ci_user,
                    reason="Windows CI server Gitlab-Runner user does not have access to Abaqus license server",
                )
            )
        return pytest.param(commands, marks=marks)

    def setup_geometry_xyplot_commands(model: str, input_file: list[pathlib.Path], backend: str) -> list[string.Template]:
        """Return system test geometry xyplot commands."""
        part_name = _utilities.character_delimited_list(csv.stem for csv in input_file)
        input_file = _utilities.character_delimited_list(input_file)
        commands = [
            string.Template(
                "${turbo_turtle_command} geometry-xyplot "
                "${abaqus_command} ${cubit_command} "
                f"--input-file {input_file} --output-file {model}.png --part-name {part_name} "
                f"--backend {backend}"
            )
        ]
        marks = [getattr(pytest.mark, backend)]
        if backend == "abaqus":
            # TODO: Remove user check when Windows CI Gitlab-Runner account can access the Abaqus license server
            # https://re-git.lanl.gov/aea/python-projects/waves/-/issues/984
            marks.append(
                pytest.mark.skipif(
                    testing_windows and testing_ci_user,
                    reason="Windows CI server Gitlab-Runner user does not have access to Abaqus license server",
                )
            )
        return pytest.param(commands, marks=marks)

    def setup_geometry_commands(
        model: str,
        input_file: list[pathlib.Path],
        revolution_angle: float,
        y_offset: float,
        backend: str,
    ) -> list[string.Template]:
        """Return system test geometry commands."""
        model = pathlib.Path(model).with_suffix(".cae")
        if backend == "cubit":
            model = model.with_suffix(".cub")
        if backend == "gmsh":
            model = model.with_suffix(".step")
        part_name = " ".join(csv.stem for csv in input_file)
        input_file = _utilities.character_delimited_list(input_file)
        backend_option = f"--backend {backend}" if backend is not None else ""
        commands = [
            string.Template(
                "${turbo_turtle_command} geometry ${abaqus_command} ${cubit_command} "
                f"--input-file {input_file} --model-name {model.stem} "
                f"--part-name {part_name} --output-file {model} --revolution-angle {revolution_angle} "
                f"--y-offset {y_offset} {backend_option}"
            )
        ]
        marks = [getattr(pytest.mark, backend)]
        if backend == "abaqus":
            # TODO: Remove user check when Windows CI Gitlab-Runner account can access the Abaqus license server
            # https://re-git.lanl.gov/aea/python-projects/waves/-/issues/984
            marks.append(
                pytest.mark.skipif(
                    testing_windows and testing_ci_user,
                    reason="Windows CI server Gitlab-Runner user does not have access to Abaqus license server",
                )
            )
        return pytest.param(commands, marks=marks)

    def setup_sets_commands(
        model: str,
        input_file: pathlib.Path,
        revolution_angle: float,
        face_sets: typing.Sequence[tuple[str, str]] | None,
        edge_sets: typing.Sequence[tuple[str, str]] | None,
        edge_seeds: typing.Sequence[tuple[str, str]] | None,
        element_type: str,
        backend: str,
    ) -> list[string.Template]:
        """Return system test sets commands."""
        model = pathlib.Path(model).with_suffix(".cae")
        if backend == "cubit":
            model = model.with_suffix(".cub")
        part_name = " ".join(csv.stem for csv in input_file)
        commands = setup_geometry_commands(model, input_file, revolution_angle, 0.0, backend).values[0]
        if face_sets is not None:
            face_sets = _utilities.construct_append_options("--face-set", face_sets)
        else:
            face_sets = ""
        if edge_sets is not None:
            edge_sets = _utilities.construct_append_options("--edge-set", edge_sets)
        else:
            edge_sets = ""
        if edge_seeds is not None:
            edge_seeds = _utilities.construct_append_options("--edge-seed", edge_seeds)
        else:
            edge_seeds = ""
        backend_option = f"--backend {backend}" if backend is not None else ""
        sets_commands = [
            string.Template(
                "${turbo_turtle_command} sets ${abaqus_command} ${cubit_command} "
                f"--input-file {model} --model-name {model.stem} "
                f"--part-name {part_name} --output-file {model} {face_sets} {edge_sets} {backend_option}"
            ),
            string.Template(
                "${turbo_turtle_command} mesh ${abaqus_command} ${cubit_command} "
                f"--input-file {model} --model-name {model.stem} "
                f"--part-name {part_name} --output-file {model} --global-seed 1. --element-type {element_type} "
                f"{edge_seeds} {backend_option}"
            ),
        ]
        commands.extend(sets_commands)
        marks = [getattr(pytest.mark, backend)]
        if backend == "abaqus":
            # TODO: Remove user check when Windows CI Gitlab-Runner account can access the Abaqus license server
            # https://re-git.lanl.gov/aea/python-projects/waves/-/issues/984
            marks.append(
                pytest.mark.skipif(
                    testing_windows and testing_ci_user,
                    reason="Windows CI server Gitlab-Runner user does not have access to Abaqus license server",
                )
            )
        return pytest.param(commands, marks=marks)

    def setup_cylinder_commands(model: str, revolution_angle: float, backend: str) -> list[string.Template]:
        """Return system test cylinder commands."""
        model = pathlib.Path(model).with_suffix(".cae")
        if backend == "cubit":
            model = model.with_suffix(".cub")
        if backend == "gmsh":
            model = model.with_suffix(".step")
        backend_option = f"--backend {backend}" if backend is not None else ""
        commands = [
            string.Template(
                "${turbo_turtle_command} cylinder ${abaqus_command} ${cubit_command} "
                f"--model-name {model.stem} --part-name {model.stem} "
                f"--output-file {model} --revolution-angle {revolution_angle} "
                f"--inner-radius 1 --outer-radius 2 --height 1 {backend_option}"
            )
        ]
        marks = [getattr(pytest.mark, backend)]
        if backend == "abaqus":
            # TODO: Remove user check when Windows CI Gitlab-Runner account can access the Abaqus license server
            # https://re-git.lanl.gov/aea/python-projects/waves/-/issues/984
            marks.append(
                pytest.mark.skipif(
                    testing_windows and testing_ci_user,
                    reason="Windows CI server Gitlab-Runner user does not have access to Abaqus license server",
                )
            )
        return pytest.param(commands, marks=marks)

    def setup_merge_commands(part_name: str, backend: str) -> list[string.Template]:
        """Return system test merge commands."""
        commands = []

        sphere_model = pathlib.Path("merge-sphere.cae")
        sphere_element_type = "C3D8"
        sphere_element_replacement = "C3D8R"
        geometry_model = pathlib.Path("merge-multi-part")
        output_file = pathlib.Path("merge.cae")
        if backend == "cubit":
            sphere_model = sphere_model.with_suffix(".cub")
            sphere_element_type = None
            sphere_element_replacement = "HEX20"
            geometry_model = geometry_model.with_suffix(".cub")
            output_file = output_file.with_suffix(".cub")

        # Create sphere file
        sphere_options = (
            str(sphere_model),
            1.0,
            2.0,
            360.0,
            0.0,
            "both",
            sphere_element_type,
            sphere_element_replacement,
            backend,
            "abaqus",
        )
        commands.append(setup_sphere_commands(*sphere_options).values[0][0])

        # Create washer/vase combined file
        geometry_options = (
            str(geometry_model),
            [
                _settings._project_root_abspath / "_tests" / "washer.csv",
                _settings._project_root_abspath / "_tests" / "vase.csv",
            ],
            360.0,
            0.0,
            backend,
        )
        commands.extend(setup_geometry_commands(*geometry_options).values[0])

        # Run the actual merge command
        part_name = f"--part-name {part_name}" if part_name else ""
        backend_option = f"--backend {backend}" if backend is not None else ""
        merge_command = string.Template(
            "${turbo_turtle_command} merge ${abaqus_command} ${cubit_command} "
            f"--input-file {sphere_model} {geometry_model} "
            f"--output-file {output_file} --merged-model-name merge "
            f"--model-name merge-multi-part merge-sphere {part_name} {backend_option}"
        )
        commands.append(merge_command)

        marks = [getattr(pytest.mark, backend)]
        if backend == "abaqus":
            # TODO: Remove user check when Windows CI Gitlab-Runner account can access the Abaqus license server
            # https://re-git.lanl.gov/aea/python-projects/waves/-/issues/984
            marks.append(
                pytest.mark.skipif(
                    testing_windows and testing_ci_user,
                    reason="Windows CI server Gitlab-Runner user does not have access to Abaqus license server",
                )
            )
        return pytest.param(commands, marks=marks)

    commands_list = []
    # Legacy geometry system tests requires a series of commands before the temp directory is removed
    # TODO: Decide if we should package or drop the legacy geometry tests
    name = "Turbo-Turtle-Tests"
    legacy_geometry_file = _settings._project_root_abspath / "_tests" / "legacy_geometry.py"
    commands_list.append(
        pytest.param(
            [
                string.Template(f"${{abaqus_command_path}} cae -noGui {legacy_geometry_file}"),
                string.Template(
                    "${turbo_turtle_command} partition ${abaqus_command} ${cubit_command} "
                    f"--input-file {name}.cae --output-file {name}.cae --model-name {name} "
                    f"--part-name seveneigths-sphere --center 0 0 0 --xvector 1 0 0 --zvector 0 0 1"
                ),
                string.Template(
                    "${turbo_turtle_command} image ${abaqus_command} ${cubit_command} "
                    f"--input-file {name}.cae --model-name {name} "
                    f"--output-file seveneigths-sphere.png --part-name seveneigths-sphere"
                ),
                string.Template(
                    "${turbo_turtle_command} partition ${abaqus_command} ${cubit_command} "
                    f"--input-file {name}.cae --output-file {name}.cae --model-name {name} --part-name swiss-cheese "
                    "--center 0 0 0 --xvector 1 0 0 --zvector 0 0 1"
                ),
                string.Template(
                    "${turbo_turtle_command} image ${abaqus_command} ${cubit_command} "
                    f"--input-file {name}.cae --model-name {name} --output-file swiss-cheese.png --part-name swiss-cheese"
                ),
            ],
            marks=[
                pytest.mark.abaqus,
                # TODO: Remove user check when Windows CI Gitlab-Runner account can access the Abaqus license server
                # https://re-git.lanl.gov/aea/python-projects/waves/-/issues/984
                pytest.mark.skipif(
                    testing_windows and testing_ci_user,
                    reason="Windows CI server Gitlab-Runner user does not have access to Abaqus license server",
                ),
            ],
        )
    )

    # Sphere/partition/mesh
    sphere_tests = (
        # Arguments:
        #   model/part, inner_radius, outer_radius, angle, y-offset,
        #   quadrant, element_type, element_replacement, backend, output_type
        # Abaqus CAE
        ("sphere.cae", 1.0, 2.0, 360.0, 0.0, "both", "C3D8", "C3D8R", "abaqus", "abaqus"),
        ("solid-sphere.cae", 0.0, 2.0, 360.0, 0.0, "both", "C3D8", "C3D8R", "abaqus", "abaqus"),
        ("axisymmetric.cae", 1.0, 2.0, 0.0, 0.0, "both", "CAX4", "CAX4R", "abaqus", "abaqus"),
        ("quarter-sphere.cae", 1.0, 2.0, 90.0, 0.0, "both", "C3D8", "C3D8R", "abaqus", "abaqus"),
        ("offset-sphere.cae", 1.0, 2.0, 360.0, 1.0, "both", "C3D8", "C3D8R", "abaqus", "abaqus"),
        ("eigth-sphere.cae", 1.0, 2.0, 90.0, 0.0, "upper", "C3D8", "C3D8R", "abaqus", "abaqus"),
        ("half-sphere.cae", 1.0, 2.0, 360.0, 0.0, "upper", "C3D8", "C3D8R", "abaqus", "abaqus"),
        # Cubit: for Abaqus INP
        ("sphere.cae", 1.0, 2.0, 360.0, 0.0, "both", None, "C3D8R", "cubit", "abaqus"),
        ("solid-sphere.cae", 0.0, 2.0, 360.0, 0.0, "both", None, "C3D8R", "cubit", "abaqus"),
        ("axisymmetric.cae", 1.0, 2.0, 0.0, 0.0, "both", None, "CAX4R", "cubit", "abaqus"),
        ("quarter-sphere.cae", 1.0, 2.0, 90.0, 0.0, "both", None, "C3D8R", "cubit", "abaqus"),
        ("offset-sphere.cae", 1.0, 2.0, 360.0, 1.0, "both", None, "C3D8R", "cubit", "abaqus"),
        ("eigth-sphere.cae", 1.0, 2.0, 90.0, 0.0, "upper", None, "C3D8R", "cubit", "abaqus"),
        ("half-sphere.cae", 1.0, 2.0, 360.0, 0.0, "upper", None, "C3D8R", "cubit", "abaqus"),
        # Cubit "element type" is really a "meshing scheme"
        ("sphere-tets.cae", 1.0, 2.0, 360.0, 0.0, "both", "tetmesh", None, "cubit", "abaqus"),
        ("axisymmetric-tri.cae", 1.0, 2.0, 0.0, 0.0, "both", "trimesh", None, "cubit", "abaqus"),
        # Cubit: for Genesis INP
        ("sphere-genesis.cae", 1.0, 2.0, 360.0, 0.0, "both", None, "HEX", "cubit", "genesis"),
        (
            "solid-sphere-genesis.cae",
            0.0,
            2.0,
            360.0,
            0.0,
            "both",
            None,
            "HEX",
            "cubit",
            "genesis",
        ),
        (
            "axisymmetric-genesis.cae",
            1.0,
            2.0,
            0.0,
            0.0,
            "both",
            None,
            "QUAD",
            "cubit",
            "genesis",
        ),
        (
            "quarter-sphere-genesis.cae",
            1.0,
            2.0,
            90.0,
            0.0,
            "both",
            None,
            "HEX",
            "cubit",
            "genesis",
        ),
        (
            "offset-sphere-genesis.cae",
            1.0,
            2.0,
            360.0,
            1.0,
            "both",
            None,
            "HEX",
            "cubit",
            "genesis",
        ),
        (
            "eigth-sphere-genesis.cae",
            1.0,
            2.0,
            90.0,
            0.0,
            "upper",
            None,
            "HEX",
            "cubit",
            "genesis",
        ),
        (
            "half-sphere-genesis.cae",
            1.0,
            2.0,
            360.0,
            0.0,
            "upper",
            None,
            "HEX",
            "cubit",
            "genesis",
        ),
        # Cubit "element type" is really a "meshing scheme"
        (
            "sphere-tets-genesis.cae",
            1.0,
            2.0,
            360.0,
            0.0,
            "both",
            "tetmesh",
            "TRI",
            "cubit",
            "genesis",
        ),
        (
            "axisymmetric-tri-genesis.cae",
            1.0,
            2.0,
            0.0,
            0.0,
            "both",
            "trimesh",
            "TETRA",
            "cubit",
            "genesis",
        ),
    )
    commands_list.extend([setup_sphere_commands(*test) for test in sphere_tests])

    # Geometry XY Plot tests
    geometry_xyplot_tests = (
        # model/part,                                                  input_file, backend
        ("washer", [_settings._project_root_abspath / "_tests" / "washer.csv"], "abaqus"),
        ("vase", [_settings._project_root_abspath / "_tests" / "vase.csv"], "abaqus"),
        (
            "multi-part",
            [
                _settings._project_root_abspath / "_tests" / "washer.csv",
                _settings._project_root_abspath / "_tests" / "vase.csv",
            ],
            "abaqus",
        ),
    )
    commands_list.extend([setup_geometry_xyplot_commands(*test) for test in geometry_xyplot_tests])

    # Geometry tests
    geometry_tests = (
        # model/part, input_file, angle, y-offset, backend
        # Abaqus
        (
            "washer",
            [_settings._project_root_abspath / "_tests" / "washer.csv"],
            360.0,
            0.0,
            "abaqus",
        ),
        (
            "offset-washer",
            [_settings._project_root_abspath / "_tests" / "washer.csv"],
            360.0,
            1.0,
            "abaqus",
        ),
        (
            "washer-axisymmetric",
            [_settings._project_root_abspath / "_tests" / "washer.csv"],
            0.0,
            0.0,
            "abaqus",
        ),
        ("vase", [_settings._project_root_abspath / "_tests" / "vase.csv"], 360.0, 0.0, "abaqus"),
        (
            "vase-axisymmetric",
            [_settings._project_root_abspath / "_tests" / "vase.csv"],
            0.0,
            0.0,
            "abaqus",
        ),
        (
            "multi-part-3D",
            [
                _settings._project_root_abspath / "_tests" / "washer.csv",
                _settings._project_root_abspath / "_tests" / "vase.csv",
            ],
            360.0,
            0.0,
            "abaqus",
        ),
        (
            "multi-part-2D",
            [
                _settings._project_root_abspath / "_tests" / "washer.csv",
                _settings._project_root_abspath / "_tests" / "vase.csv",
            ],
            0.0,
            0.0,
            "abaqus",
        ),
        # Cubit
        (
            "washer",
            [_settings._project_root_abspath / "_tests" / "washer.csv"],
            360.0,
            0.0,
            "cubit",
        ),
        (
            "offset-washer",
            [_settings._project_root_abspath / "_tests" / "washer.csv"],
            360.0,
            1.0,
            "cubit",
        ),
        (
            "washer-axisymmetric",
            [_settings._project_root_abspath / "_tests" / "washer.csv"],
            0.0,
            0.0,
            "cubit",
        ),
        ("vase", [_settings._project_root_abspath / "_tests" / "vase.csv"], 360.0, 0.0, "cubit"),
        (
            "vase-axisymmetric",
            [_settings._project_root_abspath / "_tests" / "vase.csv"],
            0.0,
            0.0,
            "cubit",
        ),
        (
            "multi-part-3D",
            [
                _settings._project_root_abspath / "_tests" / "washer.csv",
                _settings._project_root_abspath / "_tests" / "vase.csv",
            ],
            360.0,
            0.0,
            "cubit",
        ),
        (
            "multi-part-2D",
            [
                _settings._project_root_abspath / "_tests" / "washer.csv",
                _settings._project_root_abspath / "_tests" / "vase.csv",
            ],
            0.0,
            0.0,
            "cubit",
        ),
        # Gmsh
        (
            "washer",
            [_settings._project_root_abspath / "_tests" / "washer.csv"],
            360.0,
            0.0,
            "gmsh",
        ),
        (
            "offset-washer",
            [_settings._project_root_abspath / "_tests" / "washer.csv"],
            360.0,
            1.0,
            "gmsh",
        ),
        (
            "washer-axisymmetric",
            [_settings._project_root_abspath / "_tests" / "washer.csv"],
            0.0,
            0.0,
            "gmsh",
        ),
        ("vase", [_settings._project_root_abspath / "_tests" / "vase.csv"], 360.0, 0.0, "gmsh"),
        (
            "vase-axisymmetric",
            [_settings._project_root_abspath / "_tests" / "vase.csv"],
            0.0,
            0.0,
            "gmsh",
        ),
        (
            "multi-part-3D",
            [
                _settings._project_root_abspath / "_tests" / "washer.csv",
                _settings._project_root_abspath / "_tests" / "vase.csv",
            ],
            360.0,
            0.0,
            "gmsh",
        ),
        (
            "multi-part-2D",
            [
                _settings._project_root_abspath / "_tests" / "washer.csv",
                _settings._project_root_abspath / "_tests" / "vase.csv",
            ],
            0.0,
            0.0,
            "gmsh",
        ),
    )
    commands_list.extend([setup_geometry_commands(*test) for test in geometry_tests])

    # Sets/mesh tests
    sets_tests = (
        # model/part, input_file, angle, face_sets, edge_sets, edge seeds, element_type, backend
        # Abaqus
        # TODO: Pick some Abaqus edge sets for the system tests
        (
            "vase",
            [_settings._project_root_abspath / "_tests" / "vase.csv"],
            360.0,
            [["top", '"[#4 ]"'], ["bottom", '"[#40 ]"']],
            None,
            None,
            "C3D8R",
            "abaqus",
        ),
        (
            "vase-axisymmetric",
            [_settings._project_root_abspath / "_tests" / "vase.csv"],
            0.0,
            None,
            [["top", '"[#10 ]"'], ["bottom", '"[#1 ]"']],
            [["top", "5"], ["bottom", "0.5"]],
            "CAX4",
            "abaqus",
        ),
        # Cubit
        (
            "vase",
            [_settings._project_root_abspath / "_tests" / "vase.csv"],
            360.0,
            [["top", "4"], ["bottom", "7"], ["outer", '"2 3 8"']],
            [["top_outer", "13"]],
            [["top_outer", "1.000001"]],
            "C3D8R",
            "cubit",
        ),
        (
            "vase-axisymmetric",
            [_settings._project_root_abspath / "_tests" / "vase.csv"],
            0.0,
            None,
            [["top", "2"], ["bottom", "6"], ["outer", '"1 7 8"']],
            [["top", "1.000001"]],
            "CAX4",
            "cubit",
        ),
    )
    commands_list.extend([setup_sets_commands(*test) for test in sets_tests])

    # Cylinder tests
    cylinder_tests = (
        # model/part,   angle, backend
        ("cylinder_3d", 360.0, "abaqus"),
        ("cylinder_2d", 0.0, "abaqus"),
        ("cylinder_3d", 360.0, "cubit"),
        ("cylinder_2d", 0.0, "cubit"),
        ("cylinder_3d", 360.0, "gmsh"),
        ("cylinder_2d", 0.0, "gmsh"),
    )
    commands_list.extend([setup_cylinder_commands(*test) for test in cylinder_tests])

    # TODO: Merge Gmsh sphere system tests when partition/mesh/image subcommands are implemented
    # https://re-git.lanl.gov/aea/python-projects/turbo-turtle/-/issues/217
    gmsh_sphere_2D = [  # noqa: N816
        [
            string.Template(
                "${turbo_turtle_command} sphere ${abaqus_command} ${cubit_command} "
                "--inner-radius 1. --outer-radius 2. --output-file sphere.step --revolution-angle=0. --backend gmsh"
            )
        ],
        [
            string.Template(
                "${turbo_turtle_command} sphere ${abaqus_command} ${cubit_command} "
                "--inner-radius 1. --outer-radius 2. --output-file sphere.step --revolution-angle=0. "
                "--backend gmsh --quadrant upper"
            ),
        ],
        [
            string.Template(
                "${turbo_turtle_command} sphere ${abaqus_command} ${cubit_command} "
                "--inner-radius 1. --outer-radius 2. --output-file sphere.step --revolution-angle=0. "
                "--backend gmsh --quadrant lower"
            ),
        ],
        [
            string.Template(
                "${turbo_turtle_command} sphere ${abaqus_command} ${cubit_command} "
                "--inner-radius 0. --outer-radius 1. --output-file sphere.step --revolution-angle=0. --backend gmsh"
            ),
        ],
        [
            string.Template(
                "${turbo_turtle_command} sphere ${abaqus_command} ${cubit_command} "
                "--inner-radius 0. --outer-radius 1. --output-file sphere.step --revolution-angle=0. "
                "--backend gmsh --quadrant upper"
            ),
        ],
        [
            string.Template(
                "${turbo_turtle_command} sphere ${abaqus_command} ${cubit_command} "
                "--inner-radius 0. --outer-radius 1. --output-file sphere.step --revolution-angle=0. "
                "--backend gmsh --quadrant lower"
            ),
        ],
    ]
    for test in gmsh_sphere_2D:
        test.append(
            string.Template(
                "${turbo_turtle_command} mesh ${abaqus_command} ${cubit_command} "
                "--input-file sphere.step --output-file sphere.msh --global-seed 1. --element-type unused "
                "--backend gmsh"
            )
        )
        if not missing_display:
            test.append(
                string.Template(
                    "${turbo_turtle_command} image ${abaqus_command} ${cubit_command} "
                    "--input-file sphere.step --output-file sphere.step.png --x-angle 0 --y-angle 0 --backend gmsh"
                )
            )
            test.append(
                string.Template(
                    "${turbo_turtle_command} image ${abaqus_command} ${cubit_command} "
                    "--input-file sphere.msh --output-file sphere.msh.png --x-angle 0 --y-angle 0 --backend gmsh"
                )
            )
        commands_list.append(pytest.param(test, marks=pytest.mark.gmsh))
    gmsh_sphere_3D = [  # noqa: N816
        [
            string.Template(
                "${turbo_turtle_command} sphere ${abaqus_command} ${cubit_command} "
                "--inner-radius 1. --outer-radius 2. --output-file sphere.step --revolution-angle=360. "
                "--backend gmsh"
            ),
        ],
        [
            string.Template(
                "${turbo_turtle_command} sphere ${abaqus_command} ${cubit_command} "
                "--inner-radius 1. --outer-radius 2. --output-file sphere.step --revolution-angle=360. "
                "--backend gmsh --quadrant upper"
            ),
        ],
        [
            string.Template(
                "${turbo_turtle_command} sphere ${abaqus_command} ${cubit_command} "
                "--inner-radius 1. --outer-radius 2. --output-file sphere.step --revolution-angle=360. "
                "--backend gmsh --quadrant lower"
            ),
        ],
        # TODO: Fix solid sphere revolve
        # https://re-git.lanl.gov/aea/python-projects/turbo-turtle/-/issues/218
        # Undo changes in commit 72579ae84a82071cee349b05046cabb26f835f55
        [
            string.Template(
                "${turbo_turtle_command} sphere ${abaqus_command} ${cubit_command} "
                "--inner-radius 0. --outer-radius 1. --output-file sphere.step --revolution-angle=360. "
                "--backend gmsh --quadrant upper"
            )
        ],
        [
            string.Template(
                "${turbo_turtle_command} sphere ${abaqus_command} ${cubit_command} "
                "--inner-radius 0. --outer-radius 1. --output-file sphere.step --revolution-angle=360. "
                "--backend gmsh --quadrant lower"
            )
        ],
    ]
    for test in gmsh_sphere_3D:
        test.append(
            string.Template(
                "${turbo_turtle_command} mesh ${abaqus_command} ${cubit_command} "
                "--input-file sphere.step --output-file sphere.msh --global-seed 1. --element-type unused "
                "--backend gmsh"
            )
        )
        if not missing_display:
            test.append(
                string.Template(
                    "${turbo_turtle_command} image ${abaqus_command} ${cubit_command} "
                    "--input-file sphere.step --output-file sphere.step.png --x-angle 45.0 --y-angle -45.0 "
                    "--backend gmsh"
                )
            )
            test.append(
                string.Template(
                    "${turbo_turtle_command} image ${abaqus_command} ${cubit_command} "
                    "--input-file sphere.msh --output-file sphere.msh.png --x-angle 45.0 --y-angle -45.0 --backend gmsh"
                )
            )
        commands_list.append(pytest.param(test, marks=pytest.mark.gmsh))

    # Merge tests
    for part_name in ("washer vase merge-sphere", ""):
        commands_list.append(setup_merge_commands(part_name, backend="abaqus"))
        commands_list.append(setup_merge_commands(part_name, backend="cubit"))

    # SCons extensions tests
    # System tests as SCons tasks
    # TODO: Decide how to handle this system test which requires both Abaqus and Cubit.
    # Separate the SConstruct file into Abaqus/Cubit halves? Dedicated, non-matrixed construction environment?
    sconstruct = _settings._project_root_abspath / "_tests/SConstruct"
    commands_list.append(
        pytest.param(
            [
                string.Template(
                    f'scons . --sconstruct={sconstruct} --turbo-turtle-command="${{turbo_turtle_command}}" '
                    "${abaqus_command} --backend=abaqus"
                )
            ],
            marks=[
                pytest.mark.abaqus,
                # TODO: Remove user check when Windows CI Gitlab-Runner account can access the Abaqus license server
                # https://re-git.lanl.gov/aea/python-projects/waves/-/issues/984
                pytest.mark.skipif(
                    testing_windows and testing_ci_user,
                    reason="Windows CI server Gitlab-Runner user does not have access to Abaqus license server",
                ),
            ],
        )
    )
    commands_list.append(
        pytest.param(
            [
                string.Template(
                    f'scons . --sconstruct={sconstruct} --turbo-turtle-command="${{turbo_turtle_command}}" '
                    "${cubit_command} --backend=cubit"
                )
            ],
            marks=pytest.mark.cubit,
        )
    )
    # User manual example SCons tasks
    sconstruct_files = [
        [
            _settings._project_root_abspath / "tutorials/SConstruct",
            _settings._project_root_abspath / "tutorials/SConscript",
        ]
    ]
    for files in sconstruct_files:
        space_delimited_files = " ".join([str(path) for path in files])
        scons_test_commands = [
            string.Template("${turbo_turtle_command} fetch SConstruct SConscript"),
            # FIXME: Figure out why this command fails on the CI server, but not in local user tests
            # https://re-git.lanl.gov/aea/python-projects/turbo-turtle/-/issues/159
            # f'scons . --turbo-turtle-command="{turbo_turtle_command}"'  # noqa: ERA001
        ]
        commands_list.append(scons_test_commands)

    @pytest.mark.systemtest
    @pytest.mark.require_third_party
    @pytest.mark.parametrize("commands", commands_list)
    def test_require_third_party(
        abaqus_command: pathlib.Path, cubit_command: pathlib.Path, commands: list[str | string.Template]
    ) -> None:
        """Run system tests that require third-party software.

        Executes with a temporary directory that is cleaned up after each test execution.

        Accepts custom pytest CLI options to specify abaqus and cubit commands

        .. code-block::

           pytest /my/system/abaqus /my/system/cubit

        :param abaqus_command: string absolute path to Abaqus executable
        :param cubit_command: string absolute path to Cubit executable
        :param command: the full list of command string(s) for the system test
        """
        test_project_shell_commands(abaqus_command, cubit_command, commands)

    def run_commands(
        commands: list[str | string.Template],
        build_directory: str | pathlib.Path | tempfile.TemporaryDirectory,
        template_substitution: dict | None = None,
    ) -> None:
        """Run shell commands with a dedicated test shell environment in a temporary working directory."""
        if template_substitution is None:
            template_substitution = {}
        for command in commands:
            if isinstance(command, string.Template):
                command_string = command.substitute(template_substitution)
            else:
                command_string = command
            subprocess.check_output(command_string, env=env, cwd=build_directory, shell=True).decode("utf-8")

    # Help/Usage sign-of-life
    project_only_commands_list = [string.Template("${turbo_turtle_command} -h")]
    project_only_commands_list.extend(
        [string.Template(f"${{turbo_turtle_command}} {subcommand} -h") for subcommand in subcommand_list]
    )
    project_only_commands_list.append(string.Template("${turbo_turtle_command} fetch"))

    @pytest.mark.systemtest
    @pytest.mark.parametrize("commands", project_only_commands_list)
    def test_project_shell_commands(
        abaqus_command: list[str], cubit_command: list[str], commands: list[str | string.Template]
    ) -> None:
        """Run the system tests.

        Executes with a temporary directory that is cleaned up after each test execution.

        Accepts custom pytest CLI options to specify abaqus and cubit commands

        .. code-block::

           pytest --abaqus-command=/my/system/abaqus --cubit-command=/my/system/cubit

        :param abaqus_command: string absolute path to Abaqus executable
        :param cubit_command: string absolute path to Cubit executable
        :param command: the full list of command string(s) for the system test
        """
        template_substitution = {
            "turbo_turtle_command": turbo_turtle_command,
            "abaqus_command_path": "abaqus" if len(abaqus_command) == 0 else abaqus_command[0],
            "abaqus_command": " ".join(f'--abaqus-command="{command}"' for command in abaqus_command),
            "cubit_command": " ".join(f'--cubit-command="{command}"' for command in cubit_command),
        }
        if isinstance(commands, str) or isinstance(commands, string.Template):
            commands = [commands]
        if installed:
            with tempfile.TemporaryDirectory() as temp_directory:
                run_commands(commands, temp_directory, template_substitution=template_substitution)
        else:
            with tempfile.TemporaryDirectory(dir=build_directory, ignore_cleanup_errors=True) as temp_directory:
                run_commands(commands, temp_directory, template_substitution=template_substitution)