From 4956383740677638d4f3e0ff894faa252296ee05 Mon Sep 17 00:00:00 2001 From: Rajdeep Singh Date: Mon, 2 Mar 2026 02:09:10 +0530 Subject: [PATCH 1/3] Add metadata.yaml for partitioned-heat-conduction tutorial Registers the partitioned-heat-conduction tutorial with the system test framework. Defines two participants (Dirichlet, Neumann) and six cases across three solver adapters: fenics-adapter, nutils-adapter, and openfoam-adapter. Also adds the reference-results/ directory placeholder and a corresponding partitioned_heat_conduction_test suite in tests.yaml covering the three homogeneous solver combinations (FEniCS, Nutils, OpenFOAM). Related to the GSoC 2026 entry test requirement (add a tutorial to the system tests that does not yet have a metadata.yaml). --- partitioned-heat-conduction/metadata.yaml | 44 +++++++++++++++++++ .../reference-results/.gitkeep | 11 +++++ tools/tests/tests.yaml | 18 ++++++++ 3 files changed, 73 insertions(+) create mode 100644 partitioned-heat-conduction/metadata.yaml create mode 100644 partitioned-heat-conduction/reference-results/.gitkeep diff --git a/partitioned-heat-conduction/metadata.yaml b/partitioned-heat-conduction/metadata.yaml new file mode 100644 index 000000000..e863cf379 --- /dev/null +++ b/partitioned-heat-conduction/metadata.yaml @@ -0,0 +1,44 @@ +name: Partitioned heat conduction +path: partitioned-heat-conduction +url: https://precice.org/tutorials-partitioned-heat-conduction.html + +participants: + - Dirichlet + - Neumann + +cases: + dirichlet-fenics: + participant: Dirichlet + directory: ./dirichlet-fenics + run: ./run.sh + component: fenics-adapter + + dirichlet-nutils: + participant: Dirichlet + directory: ./dirichlet-nutils + run: ./run.sh + component: nutils-adapter + + dirichlet-openfoam: + participant: Dirichlet + directory: ./dirichlet-openfoam + run: ./run.sh + component: openfoam-adapter + + neumann-fenics: + participant: Neumann + directory: ./neumann-fenics + run: ./run.sh + component: fenics-adapter + + neumann-nutils: + participant: Neumann + directory: ./neumann-nutils + run: ./run.sh + component: nutils-adapter + + neumann-openfoam: + participant: Neumann + directory: ./neumann-openfoam + run: ./run.sh + component: openfoam-adapter diff --git a/partitioned-heat-conduction/reference-results/.gitkeep b/partitioned-heat-conduction/reference-results/.gitkeep new file mode 100644 index 000000000..43df6545d --- /dev/null +++ b/partitioned-heat-conduction/reference-results/.gitkeep @@ -0,0 +1,11 @@ +# Reference results for partitioned-heat-conduction are stored as Git LFS archives. +# +# To generate them locally, run from tutorials/tools/tests: +# +# python generate_reference_results.py --tutorial partitioned-heat-conduction \ +# --case-combination dirichlet-fenics neumann-fenics +# +# Expected archives (one per registered case combination in tests.yaml): +# dirichlet-fenics_neumann-fenics.tar.gz +# dirichlet-nutils_neumann-nutils.tar.gz +# dirichlet-openfoam_neumann-openfoam.tar.gz diff --git a/tools/tests/tests.yaml b/tools/tests/tests.yaml index cc18820ea..c77fc1768 100644 --- a/tools/tests/tests.yaml +++ b/tools/tests/tests.yaml @@ -71,6 +71,24 @@ test_suites: - solid-upstream-dealii - solid-downstream-dealii reference_result: ./perpendicular-flap/reference-results/fluid-openfoam_solid-upstream-dealii_solid-downstream-dealii.tar.gz + partitioned_heat_conduction_test: + tutorials: + - path: partitioned-heat-conduction + case_combination: + - dirichlet-fenics + - neumann-fenics + reference_result: ./partitioned-heat-conduction/reference-results/dirichlet-fenics_neumann-fenics.tar.gz + - path: partitioned-heat-conduction + case_combination: + - dirichlet-nutils + - neumann-nutils + reference_result: ./partitioned-heat-conduction/reference-results/dirichlet-nutils_neumann-nutils.tar.gz + - path: partitioned-heat-conduction + case_combination: + - dirichlet-openfoam + - neumann-openfoam + reference_result: ./partitioned-heat-conduction/reference-results/dirichlet-openfoam_neumann-openfoam.tar.gz + elastic_tube_1d_test: tutorials: - path: elastic-tube-1d From 41a6df87a6c09cba31c65084e006c58045ec1e70 Mon Sep 17 00:00:00 2001 From: Rajdeep Singh Date: Mon, 2 Mar 2026 02:09:55 +0530 Subject: [PATCH 2/3] fix(systemtests): generate friendly error when reference results missing (#404) Previously, a missing reference archive caused a raw Python exception (tarfile.TarError or FileNotFoundError) that was difficult to interpret. Now __unpack_reference_results() detects the missing file before attempting to open it and raises a FileNotFoundError with a clear message pointing the user to generate_reference_results.py. _run_field_compare() catches that error and returns a FieldCompareResult(exit_code=1) with the message in stderr_data so it surfaces cleanly in the final table. --- tools/tests/systemtests/Systemtest.py | 87 +++++++++++++++++++++------ 1 file changed, 69 insertions(+), 18 deletions(-) diff --git a/tools/tests/systemtests/Systemtest.py b/tools/tests/systemtests/Systemtest.py index 6abc5a029..162a81572 100644 --- a/tools/tests/systemtests/Systemtest.py +++ b/tools/tests/systemtests/Systemtest.py @@ -74,47 +74,83 @@ class SystemtestResult: fieldcompare_time: float # in seconds +def _escape_markdown_cell(text: str) -> str: + """ + Escape content for use inside a GitHub Flavored Markdown table cell. + + The pipe character must be escaped as ``\\|`` because it is the column + delimiter in GFM tables. Other characters that can trigger unwanted + inline formatting (backtick, asterisk, underscore, tilde) are also + escaped so that e.g. a tutorial path like ``fluid_openfoam`` is not + rendered as italic text. + """ + text = str(text) + # Order matters: backslash first to avoid double-escaping + for char in ('\\', '|', '`', '*', '_', '~'): + text = text.replace(char, f'\\{char}') + return text + + def display_systemtestresults_as_table(results: List[SystemtestResult]): """ - Prints the result in a nice tabluated way to get an easy overview + Prints the result in a nice tabluated way to get an easy overview. + + Plain-text output goes to stdout with fixed-width columns. + A properly-escaped GitHub Flavored Markdown table is appended to + GITHUB_STEP_SUMMARY when that environment variable is set. """ def _get_length_of_name(results: List[SystemtestResult]) -> int: return max(len(str(result.systemtest)) for result in results) max_name_length = _get_length_of_name(results) - header = f"| {'systemtest':<{max_name_length + 2}} "\ + # --- plain-text output (terminal) --- + header_plain = f"| {'systemtest':<{max_name_length + 2}} "\ f"| {'success':^7} "\ f"| {'building time [s]':^17} "\ f"| {'solver time [s]':^15} "\ f"| {'fieldcompare time [s]':^21} |" separator_plaintext = "+-" + "-" * (max_name_length + 2) + \ "-+---------+-------------------+-----------------+-----------------------+" - separator_markdown = "| --- | --- | --- | --- | --- |" print(separator_plaintext) - print(header) + print(header_plain) print(separator_plaintext) - if "GITHUB_STEP_SUMMARY" in os.environ: - with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as f: - print(header, file=f) - print(separator_markdown, file=f) - for result in results: - row = f"| {str(result.systemtest):<{max_name_length + 2}} "\ + row_plain = f"| {str(result.systemtest):<{max_name_length + 2}} "\ f"| {result.success:^7} "\ f"| {result.build_time:^17.1f} "\ f"| {result.solver_time:^15.1f} "\ f"| {result.fieldcompare_time:^21.1f} |" - print(row) + print(row_plain) print(separator_plaintext) - if "GITHUB_STEP_SUMMARY" in os.environ: - with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as f: - print(row, file=f) + # --- GitHub step summary (Markdown) --- if "GITHUB_STEP_SUMMARY" in os.environ: + # Use a clean, properly-escaped Markdown table — never reuse the + # fixed-width plain-text format because extra spaces are collapsed + # and pipe characters in cell content would break the table structure. + md_header = "| systemtest | success | building time [s] | solver time [s] | fieldcompare time [s] |" + md_separator = "| --- | --- | --- | --- | --- |" + with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as f: + print(md_header, file=f) + print(md_separator, file=f) + for result in results: + # Represent success as a clear visual symbol rather than the + # Python literal ``True`` / ``False``. + success_icon = ":white_check_mark:" if result.success else ":x:" + # Escape all cell content that may contain Markdown-special chars. + name_escaped = _escape_markdown_cell(str(result.systemtest)) + md_row = ( + f"| {name_escaped} " + f"| {success_icon} " + f"| {result.build_time:.1f} " + f"| {result.solver_time:.1f} " + f"| {result.fieldcompare_time:.1f} |" + ) + print(md_row, file=f) print("\n\n", file=f) print( "In case a test fails, download the archive from the bottom of this page and look into each `stdout.log` and `stderr.log`. The time spent in each step might already give useful hints.", @@ -134,6 +170,9 @@ class Systemtest: arguments: SystemtestArguments case_combination: CaseCombination reference_result: ReferenceResult + # Maximum number of seconds to wait for a docker-compose process before + # considering it hung and killing it. Defaults to GLOBAL_TIMEOUT. + timeout: int = GLOBAL_TIMEOUT params_to_use: Dict[str, str] = field(init=False) env: Dict[str, str] = field(init=False) @@ -354,6 +393,12 @@ def __write_env_file(self): env_file.write(f"{key}={value}\n") def __unpack_reference_results(self): + if not self.reference_result.path.exists(): + raise FileNotFoundError( + f"Reference results archive not found at '{self.reference_result.path}'. " + f"Please generate reference results first by running " + f"'python generate_reference_results.py' from the tools/tests directory, " + f"or download them from the CI artifacts stored in Git LFS.") with tarfile.open(self.reference_result.path) as reference_results_tared: # specify which folder to extract to reference_results_tared.extractall(self.system_test_dir / PRECICE_REL_REFERENCE_DIR) @@ -372,7 +417,13 @@ def _run_field_compare(self): """ logging.debug(f"Running fieldcompare for {self}") time_start = time.perf_counter() - self.__unpack_reference_results() + try: + self.__unpack_reference_results() + except FileNotFoundError as e: + elapsed_time = time.perf_counter() - time_start + error_msg = str(e) + logging.error(f"Cannot run field comparison for {self}: {error_msg}") + return FieldCompareResult(1, [], [error_msg], self, elapsed_time) docker_compose_content = self.__get_field_compare_compose_file() stdout_data = [] stderr_data = [] @@ -394,7 +445,7 @@ def _run_field_compare(self): cwd=self.system_test_dir) try: - stdout, stderr = process.communicate(timeout=GLOBAL_TIMEOUT) + stdout, stderr = process.communicate(timeout=self.timeout) except KeyboardInterrupt as k: process.kill() raise KeyboardInterrupt from k @@ -439,7 +490,7 @@ def _build_docker(self): cwd=self.system_test_dir) try: - stdout, stderr = process.communicate(timeout=GLOBAL_TIMEOUT) + stdout, stderr = process.communicate(timeout=self.timeout) except KeyboardInterrupt as k: process.kill() # process.send_signal(9) @@ -483,7 +534,7 @@ def _run_tutorial(self): cwd=self.system_test_dir) try: - stdout, stderr = process.communicate(timeout=GLOBAL_TIMEOUT) + stdout, stderr = process.communicate(timeout=self.timeout) except KeyboardInterrupt as k: process.kill() # process.send_signal(9) From a109ef30c01c3ffceb32b7b644313bd38658666b Mon Sep 17 00:00:00 2001 From: Rajdeep Singh Date: Mon, 2 Mar 2026 02:10:14 +0530 Subject: [PATCH 3/3] feat(systemtests): add --timeout flag to override per-run simulation limit (#402) The simulation timeout was previously hardcoded as GLOBAL_TIMEOUT = 900 s in Systemtest.py with no way to change it at runtime. Changes: - Systemtest dataclass gains a 'timeout: int = GLOBAL_TIMEOUT' field; all three process.communicate() calls (build, run, field-compare) now use self.timeout instead of the module-level constant. - systemtests.py gains a --timeout CLI argument (default 900 s) whose value is forwarded to every Systemtest instance. - Also separates the GitHub step summary markdown rendering from the plain-text terminal output and properly escapes pipe characters and other Markdown-special symbols in cell content (fixes #674). Usage examples: python systemtests.py --suites=fenics_test --timeout=300 # catch hangs fast python systemtests.py --suites=fenics_test --timeout=3600 # slow machines --- tools/tests/systemtests.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/tools/tests/systemtests.py b/tools/tests/systemtests.py index 8a37670eb..70290222f 100644 --- a/tools/tests/systemtests.py +++ b/tools/tests/systemtests.py @@ -2,7 +2,7 @@ import argparse from pathlib import Path from systemtests.SystemtestArguments import SystemtestArguments -from systemtests.Systemtest import Systemtest, display_systemtestresults_as_table +from systemtests.Systemtest import Systemtest, display_systemtestresults_as_table, GLOBAL_TIMEOUT from systemtests.TestSuite import TestSuites from metadata_parser.metdata import Tutorials, Case import logging @@ -26,6 +26,19 @@ def main(): parser.add_argument('--log-level', choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], default='INFO', help='Set the logging level') + parser.add_argument( + '--timeout', + type=int, + default=GLOBAL_TIMEOUT, + help=( + f'Maximum number of seconds to wait for each docker-compose process ' + f'(build, run, or field-compare) before killing it and marking the ' + f'test as failed. Defaults to {GLOBAL_TIMEOUT} seconds. ' + f'Increase this value for slow machines or large simulations; ' + f'decrease it to catch hanging tests faster.' + ) + ) + # Parse the command-line arguments args = parser.parse_args() @@ -33,6 +46,7 @@ def main(): logging.basicConfig(level=args.log_level, format='%(levelname)s: %(message)s') print(f"Using log-level: {args.log_level}") + print(f"Using timeout: {args.timeout} seconds") systemtests_to_run = [] available_tutorials = Tutorials.from_path(PRECICE_TUTORIAL_DIR) @@ -61,7 +75,7 @@ def main(): for case, reference_result in zip( test_suite.cases_of_tutorial[tutorial], test_suite.reference_results[tutorial]): systemtests_to_run.append( - Systemtest(tutorial, build_args, case, reference_result)) + Systemtest(tutorial, build_args, case, reference_result, timeout=args.timeout)) if not systemtests_to_run: raise RuntimeError("Did not find any Systemtests to execute.")