diff --git a/.github/actions/unit-test/action.yml b/.github/actions/unit-test/action.yml index ccc3300af..1f39f1ecc 100644 --- a/.github/actions/unit-test/action.yml +++ b/.github/actions/unit-test/action.yml @@ -36,6 +36,18 @@ inputs: default: "" type: string + codecov-token: + description: Token for Codecov upload + required: false + default: "" + type: string + + coverage-file: + description: Coverage file path + required: false + default: "" + type: string + runs: using: "composite" steps: @@ -72,9 +84,9 @@ runs: - name: Run pytest run: | if [ "${{ inputs.pytest-markers }}" = "" ]; then - pytest + pytest -ra --cov=ansys.dyna.core --cov-report html:.cov/html --cov-report xml:.cov/xml --cov-report term -vv else - pytest -m "${{ inputs.pytest-markers }}" + pytest -m "${{ inputs.pytest-markers }}" -ra --cov=ansys.dyna.core --cov-report html:.cov/html --cov-report xml:.cov/xml --cov-report term -vv fi shell: bash env: @@ -96,10 +108,16 @@ runs: name: server_output_tests.txt path: server_output.txt - - name: Upload coverage results - if: ${{ inputs.server-logs == 'true' }} - uses: actions/upload-artifact@v4 + - name: Upload coverage to Codecov + uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 + env: + CODECOV_TOKEN: ${{ inputs.codecov-token }} with: - name: coverage-html - path: .cov/html - retention-days: 7 + flags: ${{ inputs.coverage-file }} + + - name: Stop container + if: ${{ inputs.docker-image != '' && inputs.pytest-markers != 'keywords' }} + run: | + docker stop kw_server + docker rm kw_server + shell: bash \ No newline at end of file diff --git a/.github/workflows/ci_cd_pr.yml b/.github/workflows/ci_cd_pr.yml index 47f9c4b50..bf017524e 100644 --- a/.github/workflows/ci_cd_pr.yml +++ b/.github/workflows/ci_cd_pr.yml @@ -19,6 +19,7 @@ env: PACKAGE_NAMESPACE: 'ansys.dyna.core' DOCUMENTATION_CNAME: "dyna.docs.pyansys.com" PYDYNA_RUN_CONTAINER: ${{ github.event.inputs.PyDynaRunContainer || 'ghcr.io/ansys/pydyna-run:latest'}} + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -148,13 +149,32 @@ jobs: library-name: ${{ env.PACKAGE_NAME }} operating-system: ${{ matrix.os }} python-version: ${{ matrix.python-version }} + + keyword-testing: + name: "Keyword testing for matrix" + needs: [smoke-tests] + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + python-version: ['3.10', '3.11', '3.12', '3.13'] + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + - name: Run test with "keywords" marker + uses: ./.github/actions/unit-test + with: + python-version: ${{ matrix.python-version }} + github-token: ${{ secrets.GITHUB_TOKEN }} + pytest-markers: keywords + coverage-file: keywords run-testing: name: Test the "run" subpackage runs-on: ubuntu-latest needs: [smoke-tests] - steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 @@ -166,36 +186,22 @@ jobs: docker-image: ${{ env.PYDYNA_RUN_CONTAINER }} pytest-markers: run license-server: ${{ secrets.LICENSE_SERVER }} - - keyword-testing: - name: "Keyword testing" - runs-on: ${{ matrix.os }} - needs: [smoke-tests] - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest] - python-version: ['3.10', '3.11', '3.12', '3.13'] - steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - - - uses: ./.github/actions/unit-test - with: - python-version: ${{ matrix.python-version }} - github-token: ${{ secrets.GITHUB_TOKEN }} - pytest-markers: keywords + coverage-file: run unit-tests: name: "Testing" runs-on: ubuntu-latest - needs: [run-testing, keyword-testing] + needs: [keyword-testing, run-testing, codegen-testing] steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - - uses: ./.github/actions/unit-test + - name: Run test without marker + uses: ./.github/actions/unit-test with: python-version: ${{ env.MAIN_PYTHON_VERSION }} github-token: ${{ secrets.GITHUB_TOKEN }} server-logs: true + codecov-token: ${{ env.CODECOV_TOKEN }} + coverage-file: unittests build-library: name: "Build library" diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 000000000..f9522f439 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,43 @@ +coverage: + range: 75..100 + round: down + precision: 2 + status: + project: + default: + target: 90% + patch: off + + +codecov: + notify: + wait_for_ci: yes + +flag_management: + default_rules: # the rules that will be followed for any flag added, generally + carryforward: true + statuses: + - type: project + target: auto + threshold: 1% + - type: patch + target: 90% + individual_flags: # exceptions to the default rules above, stated flag by flag + - name: run + paths: + - src/ansys/dyna/core/run + carryforward: true + statuses: + - type: project + target: 20% + - type: patch + target: 100% + - name: keywords + paths: + - src/ansys/dyna/core/keywords #fill in your own path. Note, accepts globs, not regexes + carryforward: true + statuses: + - type: project + target: 20% + - type: patch + target: 100% \ No newline at end of file diff --git a/doc/changelog/909.added.md b/doc/changelog/909.added.md new file mode 100644 index 000000000..024ab838a --- /dev/null +++ b/doc/changelog/909.added.md @@ -0,0 +1 @@ +Add coverage diff --git a/pyproject.toml b/pyproject.toml index 6ed4e7d56..70cb0af28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -119,6 +119,9 @@ src_paths = ["doc", "src", "tests"] [tool.coverage.run] source = ["ansys.dyna.core"] +omit = [ + "src/ansys/dyna/core/keywords/keyword_classes/auto/*" +] [tool.coverage.report] show_missing = true diff --git a/tests/test_windows_runner.py b/tests/test_windows_runner.py new file mode 100644 index 000000000..bbf5739ef --- /dev/null +++ b/tests/test_windows_runner.py @@ -0,0 +1,136 @@ +import pytest +from pathlib import Path +from unittest.mock import MagicMock, patch + +import ansys.dyna.core.run.windows_runner as windows_runner +from ansys.dyna.core.run.options import MpiOption, Precision + + +@pytest.fixture +def tmp_workdir(tmp_path): + return tmp_path + + +def make_runner(tmp_workdir, mpi=MpiOption.SMP, precision=Precision.SINGLE): + # create dummy solver executable + exe = tmp_workdir / "lsdyna.exe" + exe.write_text("dummy") + + runner = windows_runner.WindowsRunner(executable=str(exe)) + runner.mpi_option = mpi + runner.precision = precision + runner.input_file = "input.k" + runner.working_directory = str(tmp_workdir) + runner.ncpu = 4 + runner.get_memory_string = MagicMock(return_value="2000m") + return runner + + +def test_find_solver_with_explicit_executable(tmp_workdir): + exe = tmp_workdir / "lsdyna.exe" + exe.write_text("dummy") + + runner = windows_runner.WindowsRunner(executable=str(exe)) + assert runner.solver_location == str(tmp_workdir) + assert runner.solver.endswith("lsdyna.exe\"") + + +@patch("ansys.dyna.core.run.windows_runner._get_unified_install_base_for_version") +def test_find_solver_with_version(mock_install_base, tmp_workdir): + exe = tmp_workdir / "ansys" / "bin" / "winx64" / "lsdyna_sp.exe" + exe.parent.mkdir(parents=True) + exe.write_text("dummy") + + mock_install_base.return_value = (tmp_workdir, None) + runner = windows_runner.WindowsRunner(version=241, precision=Precision.SINGLE) + assert runner.solver.endswith("lsdyna_sp.exe\"") + +def test_find_solver_executable_not_found(tmp_workdir): + with pytest.raises(FileNotFoundError): + windows_runner.WindowsRunner(executable=str(tmp_workdir / "missing.exe")) + + +@pytest.mark.parametrize( + "mpi,prec,expected", + [ + (MpiOption.SMP, Precision.SINGLE, "lsdyna_sp.exe"), + (MpiOption.SMP, Precision.DOUBLE, "lsdyna_dp.exe"), + (MpiOption.MPP_INTEL_MPI, Precision.SINGLE, "lsdyna_mpp_sp_impi.exe"), + (MpiOption.MPP_INTEL_MPI, Precision.DOUBLE, "lsdyna_mpp_dp_impi.exe"), + (MpiOption.MPP_MS_MPI, Precision.SINGLE, "lsdyna_mpp_sp_msmpi.exe"), + (MpiOption.MPP_MS_MPI, Precision.DOUBLE, "lsdyna_mpp_dp_msmpi.exe"), + ], +) +def test_get_exe_name_variants(tmp_workdir, mpi, prec, expected): + runner = make_runner(tmp_workdir, mpi, prec) + assert runner._get_exe_name() == expected + + +def test_get_env_script_intel(tmp_workdir): + runner = make_runner(tmp_workdir, MpiOption.MPP_INTEL_MPI) + (tmp_workdir / "lsprepost_foo").mkdir() + script = runner._get_env_script() + assert script.endswith("lsdynaintelvar.bat") + + +def test_write_runscript(tmp_workdir): + runner = make_runner(tmp_workdir) + runner._get_command_line = MagicMock(return_value="echo hello") + runner._write_runscript() + script_path = tmp_workdir / runner._scriptname + assert "echo hello" in script_path.read_text() + + +@patch("ansys.dyna.core.run.windows_runner.subprocess.Popen") +def test_run_success(mock_popen, tmp_workdir): + runner = make_runner(tmp_workdir) + runner._get_command_line = MagicMock(return_value="echo hello") + + process = MagicMock() + process.poll.side_effect = [None, 0] + process.wait.return_value = 0 + process.returncode = 0 + mock_popen.return_value = process + + # fake log file + log_file = tmp_workdir / "lsrun.out.txt" + log_file.write_text("all good\n") + + runner.run() + assert process.wait.called + + +@patch("ansys.dyna.core.run.windows_runner.subprocess.Popen") +def test_run_with_warning_logs(mock_popen, tmp_workdir, caplog): + runner = make_runner(tmp_workdir) + runner._get_command_line = MagicMock(return_value="echo hello") + + process = MagicMock() + process.poll.side_effect = [None, 0] + process.wait.return_value = 0 + process.returncode = 0 + mock_popen.return_value = process + + log_file = tmp_workdir / "lsrun.out.txt" + log_file.write_text("Warning: something\n") + + runner.run() + assert "completed with warnings" in caplog.text + + +@patch("ansys.dyna.core.run.windows_runner.subprocess.Popen") +def test_run_failure(mock_popen, tmp_workdir): + runner = make_runner(tmp_workdir) + runner._get_command_line = MagicMock(return_value="echo hello") + + process = MagicMock() + process.poll.side_effect = [0] + process.wait.return_value = 1 + process.returncode = 1 + mock_popen.return_value = process + + log_file = tmp_workdir / "lsrun.out.txt" + log_file.write_text("Error: fail\n") + + with pytest.raises(RuntimeError): + runner.run()