From 4792e269241bbe27b715a9b58578aa50e9618933 Mon Sep 17 00:00:00 2001 From: Paul Saxe Date: Sat, 17 Oct 2026 11:12:45 -0400 Subject: [PATCH 1/4] The MD runs of a parallel loop come back for the analysis steps A parallel loop now appends each iteration's records in _md_runs (read from the iteration's checkpoint: baseline.db at the start, seamm.db at the end) to the parent's, in iteration order and committed with the database merge, as a serial loop leaves them. The records of a skipped iteration, whose directory is removed, are dropped, in a serial loop too. MANIFEST.in now includes versioneer.py and the requirements, so the wheel builds from the sdist. Co-Authored-By: Claude Opus 5.5 --- HISTORY.rst | 12 ++++++ MANIFEST.in | 2 + docs/user_guide/index.rst | 9 ++++- loop_step/loop.py | 67 ++++++++++++++++++++++++++++++++ loop_step/loop_parameters.py | 3 +- tests/test_parallel.py | 74 +++++++++++++++++++++++++++++++++--- 6 files changed, 159 insertions(+), 8 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 5826239..1132f69 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,6 +1,18 @@ ======= History ======= +2026.10.17 -- The MD runs of a parallel loop are kept for the analysis steps + * The record of the MD runs that analysis steps read (Viscosity, Diffusivity, + Liquid Structure, Dielectric Constant, Ionic Conductivity) now comes back from + a parallel loop: each iteration's runs are added in iteration order, as after a + serial loop. Independent MD runs in a loop can now run in parallel and be + analyzed together after it, with "the last MD run of each loop iteration". + Before, the steps after a parallel loop did not see its runs. + * Bugfix: the runs of a skipped iteration, whose directory is removed, are no + longer left in the record, where they named files that no longer existed. + * Internal: the source distribution now includes the files needed to build the + package from it. + 2026.10.5 -- Run a loop's iterations in parallel * New option "Run iterations in parallel" (off by default). Each iteration then runs in an evaluator of its own, at the same time as others, on this machine or diff --git a/MANIFEST.in b/MANIFEST.in index 965b2dd..3035cc8 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,6 +3,8 @@ include CONTRIBUTING.rst include HISTORY.rst include LICENSE include README.rst +include requirements* +include versioneer.py recursive-include tests * recursive-exclude * __pycache__ diff --git a/docs/user_guide/index.rst b/docs/user_guide/index.rst index cfd7e66..9ce82b8 100644 --- a/docs/user_guide/index.rst +++ b/docs/user_guide/index.rst @@ -68,7 +68,14 @@ Choosing parallel declares that the iterations are independent: Write Structure in append mode, get each iteration's part in order; other files are those of the last iteration that wrote them, as for a serial loop; a table saved inside the loop is saved again after the merge. -- Variables set in the body are not visible after the loop. +- Variables set in the body are not visible after the loop, with one exception: + the record of the MD runs (``_md_runs``) that analysis steps such as + Viscosity, Diffusivity, Liquid Structure, Dielectric Constant and Ionic + Conductivity read. Each iteration's runs are added to it, in iteration order, + as a serial loop leaves it, so a loop of independent MD runs can run in + parallel and the steps after it analyse all of them ("the last MD run of each + loop iteration"). The runs of a skipped iteration, whose directory is removed, + are left out, in a serial loop too. - ``break``, ``continue`` and skipping an iteration work as in a serial loop: after a break in iteration *k* the iterations after *k* are not merged, so the result is the serial one. diff --git a/loop_step/loop.py b/loop_step/loop.py index 1107e15..7c424a9 100644 --- a/loop_step/loop.py +++ b/loop_step/loop.py @@ -39,6 +39,42 @@ def _plain(value): # (killed, out of memory) is run again, resuming from its checkpoint MAX_RETRIES = 2 +#: Workspace variables that steps add records to, one after another, such as +#: ``_md_runs``: a record of each MD run and its data file, which analysis steps +#: read. A parallel loop appends each iteration's records, in iteration order, as a +#: serial loop leaves them; the records of a skipped iteration, whose directory is +#: removed, are dropped in both. +APPENDED_VARIABLES = ("_md_runs",) + + +def _added_records(before, after): + """The records an iteration added to a list that it started as ``before``.""" + before = list(before or []) + after = list(after or []) + if after[: len(before)] == before: + return after[len(before) :] + return [record for record in after if record not in before] + + +def _checkpoint_variable(path, name): + """A variable as the checkpoint in the database ``path`` holds it, or None.""" + from seamm.checkpoint import decode_value + + checkpoint = seamm.read_checkpoint(path) + if checkpoint is None: + return None + data = checkpoint.get("variables", {}).get(name) + return None if data is None else decode_value(data) + + +def _inside(path, directory): + """Whether ``path`` is in ``directory``.""" + try: + Path(path).resolve().relative_to(Path(directory).resolve()) + except (TypeError, ValueError): + return False + return True + class _Collect(logging.Handler): """Collects the warnings of a merge, for job.out.""" @@ -747,6 +783,7 @@ def _run_serial(self, P, context, checkpointer, state, resume_node, only=False): # The parent removes the iteration's directory raise seamm.IterationDone(skipped=True) next_node = self + self._drop_records_in(iter_dir) shutil.rmtree(iter_dir, ignore_errors=True) except Exception as e: tmp = self.working_path.name @@ -1151,6 +1188,16 @@ def _merge_iteration( from molsystem.snapshot import MergeConflict + added = {} + if not outcome.get("skip"): + for variable in APPENDED_VARIABLES: + records = _added_records( + _checkpoint_variable(evaluator / "baseline.db", variable), + _checkpoint_variable(evaluator / "seamm.db", variable), + ) + if len(records) > 0: + added[variable] = records + if P["type"] == "For systems in the database": # As a serial loop leaves it: the iteration's configuration current, # unless its body chose another (merged below). The merge cannot see @@ -1196,6 +1243,12 @@ def _merge_iteration( system_id = merged["maps"].get("system", {}).get(system_id, system_id) if system_id in system_db.system_ids: system_db.system = system_id + # Committed with the database merge, in the checkpoint below + for variable, records in added.items(): + current = [] + if self.variable_exists(variable): + current = list(self.get_variable(variable)) + self.set_variable(variable, current + records) plan = iterations.plan_files(evaluator, job_directory, outcome.get("run_id")) state["files"] = {"iteration": k, "directory": name, "plan": plan} state["next"] = k + 1 @@ -1212,6 +1265,20 @@ def _merge_iteration( printer.job(f" Merged loop iteration {name}.") return stop + def _drop_records_in(self, directory): + """Drop the records whose files are in a directory about to be removed.""" + for variable in APPENDED_VARIABLES: + if not self.variable_exists(variable): + continue + records = list(self.get_variable(variable)) + kept = [ + r + for r in records + if not (isinstance(r, dict) and _inside(r.get("path"), directory)) + ] + if len(kept) < len(records): + self.set_variable(variable, kept) + def _merge_iteration_files(self, pending, loop_directory, job_directory): """The job-level files and citations of a merged iteration. diff --git a/loop_step/loop_parameters.py b/loop_step/loop_parameters.py index 636c105..8040e89 100644 --- a/loop_step/loop_parameters.py +++ b/loop_step/loop_parameters.py @@ -216,7 +216,8 @@ class LoopParameters(seamm.Parameters): "Run the iterations at the same time, each in its own evaluator. " "They must be independent: each sees the job as it was when the " "loop started, and variables set in the body are not visible " - "after the loop. Tables, properties, structures and files come " + "after the loop, except the record of MD runs that analysis steps " + "read. Tables, properties, structures, files and MD runs come " "back, merged in iteration order." ), }, diff --git a/tests/test_parallel.py b/tests/test_parallel.py index b8d4cb3..0f818c2 100644 --- a/tests/test_parallel.py +++ b/tests/test_parallel.py @@ -4,11 +4,13 @@ A tiny step plug-in, installed for the test on PYTHONPATH (a module and its dist-info with the entry points), makes a system named after the loop's value, -appends a row to a table it creates if needed, and writes a job-level file. The +appends a row to a table it creates if needed, writes a job-level file, and records +a run in ``_md_runs`` as an MD step does, with a data file in its directory. The same flowchart is run serially and in parallel, each by ``run_flowchart`` in its own directory, and the databases and files are compared. """ +import json import os from pathlib import Path import sqlite3 @@ -25,7 +27,10 @@ RUN = "import sys; from seamm_exec import run; sys.argv[0] = 'run_flowchart'; run()" FAKE_STEP = textwrap.dedent(''' - """A step for testing parallel loops: a system, a table row, a file.""" + """A step for testing parallel loops: a system, a table row, a file, a run.""" + + import json + from pathlib import Path import seamm @@ -67,6 +72,17 @@ def run(self): context=seamm.flowchart_variables._data ) name = str(P["name"]) + if name == "report": + # The runs recorded so far, with their paths relative to the job + runs = [] + if self.variable_exists("_md_runs"): + runs = self.get_variable("_md_runs") + job = Path(self.flowchart.root_directory).resolve() + text = [ + dict(r, path=str(Path(r["path"]).relative_to(job))) for r in runs + ] + (self.job_path / "md_runs.json").write_text(json.dumps(text)) + return next_node db = self.get_variable("_system_db") system = db.create_system(name=name) configuration = system.create_configuration(name=name) @@ -79,6 +95,26 @@ def run(self): table.add_column("name", "string", "") table.append_row(name=name) (self.job_path / "last.txt").write_text(name + "\\n") + # A run, as an MD step records it + directory = Path(self.directory) + directory.mkdir(parents=True, exist_ok=True) + path = directory / "md.h5md" + path.write_text(name + "\\n") + runs = [] + if self.variable_exists("_md_runs"): + runs = list(self.get_variable("_md_runs")) + runs.append( + { + "path": str(path.resolve()), + "step": f"{'.'.join(str(i) for i in self._id)} {self.title}", + "name": name, + } + ) + self.set_variable("_md_runs", runs) + if name == "skipped": + from loop_step import skip_iteration + + skip_iteration() return next_node @@ -108,12 +144,12 @@ def create_tk_node(self, canvas=None, **kwargs): - Loop: type: Foreach variable: name - values: alpha beta gamma delta + values: {values} {parallel} body: - Fake: name: $name - """) + {after}""") @pytest.fixture(scope="module") @@ -145,12 +181,14 @@ def environment(site): return env -def run(site, directory, parallel): +def run(site, directory, parallel, values="alpha beta gamma delta", after=""): directory.mkdir() text = "parallel: 'yes'" if parallel else "" if parallel: text += "\n iterations at once: '2'\n memory per iteration: '0.2'" - (directory / "spec.yaml").write_text(SPEC.format(parallel=text)) + (directory / "spec.yaml").write_text( + SPEC.format(parallel=text, values=values, after=after) + ) env = environment(site) built = subprocess.run( [sys.executable, "-c", BUILD, "build", "spec.yaml", "-o", "test.flow"], @@ -201,3 +239,27 @@ def test_parallel_loop_gives_the_serial_result(fake_plugin, tmp_path): # Each iteration ran in an evaluator of its own evaluators = sorted(p.parent.name for p in parallel.rglob("_evaluator/job.out")) assert len(evaluators) == 4 + + +AFTER = """\ +- Fake: + name: report +""" + + +@pytest.mark.skipif( + not (BIN / "run_flowchart").exists(), + reason="run_flowchart (for the iterations) is not installed beside Python", +) +def test_parallel_loop_keeps_the_runs_of_its_iterations(fake_plugin, tmp_path): + """The runs recorded in ``_md_runs`` in the iterations are there after the + loop, in order, as after a serial loop; a skipped iteration's are not.""" + values = "alpha beta skipped delta" + serial = run(fake_plugin, tmp_path / "serial", False, values, AFTER) + parallel = run(fake_plugin, tmp_path / "parallel", True, values, AFTER) + expected = json.loads((serial / "md_runs.json").read_text()) + assert [r["name"] for r in expected] == ["alpha", "beta", "delta"] + assert json.loads((parallel / "md_runs.json").read_text()) == expected + for directory in (serial, parallel): + for record in expected: + assert (directory / record["path"]).read_text() == record["name"] + "\n" From c2b9265c589c740d5b9a11b61d6db0c3c9c009a0 Mon Sep 17 00:00:00 2001 From: Paul Saxe Date: Sat, 17 Oct 2026 11:19:06 -0400 Subject: [PATCH 2/4] Require seamm 2026.10.17 (sub-flowcharts keep their iteration's job directory) Co-Authored-By: Claude Opus 5.5 --- HISTORY.rst | 3 +++ requirements.txt | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/HISTORY.rst b/HISTORY.rst index 1132f69..7888376 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -10,6 +10,9 @@ History Before, the steps after a parallel loop did not see its runs. * Bugfix: the runs of a skipped iteration, whose directory is removed, are no longer left in the record, where they named files that no longer existed. + * Requires seamm 2026.10.17, in which the sub-steps of steps such as LAMMPS keep + their citations and files in their own iteration; before, iterations running + together could fail on the job's shared citations. * Internal: the source distribution now includes the files needed to build the package from it. diff --git a/requirements.txt b/requirements.txt index 9f4d8a9..7fddda1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ molsystem>=2026.10.5 psutil -seamm>=2026.10.5 +seamm>=2026.10.17 seamm-exec>=2026.10.5 From 808f00c7c68e8ddcaa7e2d4a94d866f2a192ee2b Mon Sep 17 00:00:00 2001 From: Paul Saxe Date: Sat, 17 Oct 2026 11:28:52 -0400 Subject: [PATCH 3/4] User guide: MD replicas in a loop should discard their final structure Co-Authored-By: Claude Opus 5.5 --- docs/user_guide/index.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/user_guide/index.rst b/docs/user_guide/index.rst index 9ce82b8..ada8808 100644 --- a/docs/user_guide/index.rst +++ b/docs/user_guide/index.rst @@ -76,6 +76,13 @@ Choosing parallel declares that the iterations are independent: parallel and the steps after it analyse all of them ("the last MD run of each loop iteration"). The runs of a skipped iteration, whose directory is removed, are left out, in a serial loop too. + + For such replicas, set the MD steps' "structure handling" to "Discard the + structure", so each iteration starts from the same structure with new + velocities. By default each MD step writes its final structure over the + current configuration, which in a serial loop makes each iteration continue + from the last, and in a parallel loop is an error (two iterations changing + one configuration). - ``break``, ``continue`` and skipping an iteration work as in a serial loop: after a break in iteration *k* the iterations after *k* are not merged, so the result is the serial one. From 2cf75c30843ccca7b341bb6bd241de71e417f5d0 Mon Sep 17 00:00:00 2001 From: Paul Saxe Date: Sat, 17 Oct 2026 12:03:30 -0400 Subject: [PATCH 4/4] Warn before a parallel loop runs about steps overwriting the shared structure check_flowchart warns, with fixes (discard the structure, or create a new configuration), about body steps whose structure handling overwrites the configuration the iterations share; the warning is repeated when the loop starts. Requires molsystem 2026.10.17 (identical changes merge). Co-Authored-By: Claude Opus 5.5 --- HISTORY.rst | 12 +++- docs/user_guide/index.rst | 4 +- loop_step/loop.py | 111 ++++++++++++++++++++++++++++++++++ requirements.txt | 2 +- tests/test_check_flowchart.py | 104 +++++++++++++++++++++++++++++++ 5 files changed, 229 insertions(+), 4 deletions(-) create mode 100644 tests/test_check_flowchart.py diff --git a/HISTORY.rst b/HISTORY.rst index 7888376..ccf4ced 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,7 +1,7 @@ ======= History ======= -2026.10.17 -- The MD runs of a parallel loop are kept for the analysis steps +2026.10.17 -- Parallel loops: MD runs kept for analysis, and a check before running * The record of the MD runs that analysis steps read (Viscosity, Diffusivity, Liquid Structure, Dielectric Constant, Ionic Conductivity) now comes back from a parallel loop: each iteration's runs are added in iteration order, as after a @@ -10,9 +10,17 @@ History Before, the steps after a parallel loop did not see its runs. * Bugfix: the runs of a skipped iteration, whose directory is removed, are no longer left in the record, where they named files that no longer existed. + * A parallel loop over one structure now warns, before the job runs, about each + step in its body that writes its structure over the current configuration -- + the default of MD and optimization steps -- since two iterations changing the + structure cannot be merged. The warning offers to discard the structure (for + independent runs such as MD replicas) or to create a new configuration. The + warning is repeated in the job's output when the loop starts. * Requires seamm 2026.10.17, in which the sub-steps of steps such as LAMMPS keep their citations and files in their own iteration; before, iterations running - together could fail on the job's shared citations. + together could fail on the job's shared citations. Requires molsystem + 2026.10.17, in which iterations that change a structure in the same way (for + example by reading its spin multiplicity) no longer conflict. * Internal: the source distribution now includes the files needed to build the package from it. diff --git a/docs/user_guide/index.rst b/docs/user_guide/index.rst index ada8808..b96b5e5 100644 --- a/docs/user_guide/index.rst +++ b/docs/user_guide/index.rst @@ -82,7 +82,9 @@ Choosing parallel declares that the iterations are independent: velocities. By default each MD step writes its final structure over the current configuration, which in a serial loop makes each iteration continue from the last, and in a parallel loop is an error (two iterations changing - one configuration). + one configuration). The check before the job runs warns about each such step + in the body of a parallel loop over one structure, and the editor offers to + change it with one click (``seamm-flowchart validate --fix`` does the same). - ``break``, ``continue`` and skipping an iteration work as in a serial loop: after a break in iteration *k* the iterations after *k* are not merged, so the result is the serial one. diff --git a/loop_step/loop.py b/loop_step/loop.py index 7c424a9..5151f8c 100644 --- a/loop_step/loop.py +++ b/loop_step/loop.py @@ -47,6 +47,21 @@ def _plain(value): APPENDED_VARIABLES = ("_md_runs",) +#: The structure handling that writes a step's structure over the current +#: configuration, which the iterations of a parallel loop over one structure share +OVERWRITE = "Overwrite the current configuration" + + +def _static(parameters, key): + """A parameter's value as given, or None if it is a variable or expression.""" + if parameters is None or key not in parameters: + return None + value = parameters[key].value + if isinstance(value, str) and value.strip().startswith(("$", "=")): + return None + return value + + def _added_records(before, after): """The records an iteration added to a list that it started as ``before``.""" before = list(before or []) @@ -894,6 +909,10 @@ def _run_parallel(self, P, context, checkpointer, state): state["items"] = [_plain(x) for x in items] if indices is not None: state["indices"] = [_plain(x) for x in indices] + for problem in self._overwriting_steps(): + printer.important( + __(f"Warning: {problem}", indent=self.indent + 4 * " ") + ) else: state = json.loads(json.dumps(state)) printer.important( @@ -1265,6 +1284,98 @@ def _merge_iteration( printer.job(f" Merged loop iteration {name}.") return stop + def check_flowchart(self, preceding): + """Check, before the job runs, that a parallel loop's iterations can merge. + + The steps in the body of a parallel loop over one structure must not all + write their structure over it: two iterations changing the same + configuration cannot be merged. Each such step is reported as a warning -- + some steps that may write the structure do not change it -- with fixes that + set its structure handling. + + Parameters + ---------- + preceding : [seamm.Node] + The steps that run before this one (not used). + + Returns + ------- + [str] + The warnings, as seamm.Problem with their fixes; empty if none. + """ + return self._overwriting_steps() + + def _overwriting_steps(self): + """The warnings for the body's steps that overwrite the shared structure.""" + try: + from seamm.problems import WARNING, Edit, Fix, Problem + except ImportError: # seamm before 2026.10.17 + return [] + P = self.parameters + if not _yes(_static(P, "parallel") or "no"): + return [] + if _static(P, "type") in (None, "For systems in the database"): + return [] + if "later" in str(_static(P, "same-cell writes")): + return [] + problems = [] + for node in self._body_nodes(): + if _static(node.parameters, "structure handling") != OVERWRITE: + continue + fixes = [ + Fix( + f"Discard the structure in '{node.title}'", + [Edit(node, "structure handling", "Discard the structure")], + ), + Fix( + f"Create a new configuration in '{node.title}'", + [Edit(node, "structure handling", "Create a new configuration")], + ), + ] + problems.append( + Problem( + f"'{node.title}' in the parallel loop '{self.title}' writes its " + "structure over the current configuration, which all the " + "iterations share; if it changes the structure, two iterations " + "changing it cannot be merged and the job stops after the " + "iterations have run. Discard the structure for independent " + "runs such as MD replicas, or create a new configuration to " + "keep each iteration's structure -- or run the loop serially.", + severity=WARNING, + fixes=fixes, + ) + ) + return problems + + def _body_nodes(self): + """The steps in the loop's body, including those inside sub-flowcharts.""" + from seamm import format3 + + def find(items): + for item in items: + if item.node is self: + return item.body or [] + for inner in (item.body or [], _sub_items(item.node)): + found = find(inner) + if found is not None: + return found + return None + + def _sub_items(node): + sub = format3.subflowchart(node) + return [] if sub is None else format3.tree(sub)[0] + + nodes = [] + + def collect(items): + for item in items: + nodes.append(item.node) + collect(item.body or []) + collect(_sub_items(item.node)) + + collect(find(format3.tree(self.flowchart)[0]) or []) + return nodes + def _drop_records_in(self, directory): """Drop the records whose files are in a directory about to be removed.""" for variable in APPENDED_VARIABLES: diff --git a/requirements.txt b/requirements.txt index 7fddda1..a052f61 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -molsystem>=2026.10.5 +molsystem>=2026.10.17 psutil seamm>=2026.10.17 seamm-exec>=2026.10.5 diff --git a/tests/test_check_flowchart.py b/tests/test_check_flowchart.py new file mode 100644 index 0000000..5872f07 --- /dev/null +++ b/tests/test_check_flowchart.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- + +"""The check of a parallel loop's body before the job runs: steps writing their +structure over the configuration the iterations share.""" + +import pytest + +import seamm +from seamm import edit +from seamm.builder import validate + +import loop_step + +pytest.importorskip("seamm.problems") + + +class Step(seamm.Node): + """A step with the standard structure handling, as MD or optimization has.""" + + def __init__(self, flowchart, title): + super().__init__(flowchart=flowchart, title=title, extension="Step") + self.parameters = seamm.Parameters( + defaults=dict(seamm.standard_parameters.structure_handling_parameters) + ) + + +def _connect(flowchart, a, b, subtype): + flowchart.add_edge(a, b, edge_type="execution", edge_subtype=subtype) + + +def make(parallel="yes", kind="For", later=False, inside=False): + """Start -> Loop, whose body is a step, or a step holding it in a sub-flowchart.""" + flowchart = seamm.Flowchart() + loop = loop_step.Loop(flowchart=flowchart) + flowchart.add_node(loop) + _connect(flowchart, flowchart.get_node("1"), loop, "next") + if inside: + body = seamm.Node(flowchart=flowchart, title="LAMMPS", extension="Code") + body.subflowchart = seamm.Flowchart(parent=body) + step = Step(body.subflowchart, "NVT") + body.subflowchart.add_node(step) + _connect(body.subflowchart, body.subflowchart.get_node("1"), step, "next") + else: + body = step = Step(flowchart, "Optimization") + flowchart.add_node(body) + _connect(flowchart, loop, body, "loop") + _connect(flowchart, body, loop, "next") + loop.parameters["parallel"].value = parallel + loop.parameters["type"].value = kind + if later: + loop.parameters["same-cell writes"].value = "the later iteration wins" + return flowchart, loop, step + + +def test_a_parallel_loop_warns_and_offers_fixes(): + flowchart, loop, step = make() + assert validate(flowchart) == [] # a warning, not an error + (problem,) = validate(flowchart, warnings=True) + assert problem.severity == "warning" + assert "'Optimization' in the parallel loop 'Loop'" in problem + (described,) = edit.describe_problems(flowchart, [problem]) + assert [f["set"] for f in described["fixes"]] == [ + [ + { + "step": "1.1", + "parameter": "structure handling", + "value": "Discard the structure", + } + ], + [ + { + "step": "1.1", + "parameter": "structure handling", + "value": "Create a new configuration", + } + ], + ] + problem.fixes[1].apply() + assert step.parameters["structure handling"].value == "Create a new configuration" + assert validate(flowchart, warnings=True) == [] + + +def test_a_step_inside_a_body_step(): + flowchart, loop, step = make(inside=True) + (problem,) = validate(flowchart, warnings=True) + (described,) = edit.describe_problems(flowchart, [problem]) + assert described["fixes"][0]["set"][0]["step"] == "1.1.1" + problem.fixes[0].apply() + assert step.parameters["structure handling"].value == "Discard the structure" + assert validate(flowchart, warnings=True) == [] + + +@pytest.mark.parametrize( + "settings", + [ + {"parallel": "no"}, + {"kind": "For systems in the database"}, + {"later": True}, + {"parallel": "$parallel"}, + ], +) +def test_no_warning(settings): + flowchart, loop, step = make(**settings) + assert validate(flowchart, warnings=True) == []