From c7e244d32192eedb81cda705175fbb2063df1cc5 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Thu, 22 Oct 2026 15:16:38 +0200 Subject: [PATCH 01/23] build: pin plain-parser to the explicit-test-scenarios branch --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8315f1ef..967263f9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -plain-parser==0.1.4 +plain-parser @ git+https://github.com/plainlang/plain-parser@feat/explicit-test-scenarios # Both transitive via plain-parser; pinned so an automatic mistletoe upgrade doesn't change spec hashes. mistletoe==1.6.0 From 9b58c0341dfb2ba11be175f4706d05f2b885d4bf Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Thu, 22 Oct 2026 15:25:12 +0200 Subject: [PATCH 02/23] test: convert the .plain fixtures and examples to the current syntax --- .../hello_world_golang.plain | 3 ++- .../hello_world_golang/display-hello-world.plain | 12 ++++++++++++ .../hello_world_python.plain | 9 ++------- .../hello_world_python/display-hello-world.plain | 13 +++++++++++++ .../hello_world_react.plain | 3 ++- .../hello_world_react/display-hello-world.plain | 11 +++++++++++ .../entry-point.plain | 11 +++++++++++ .../typescript-react-app-boilerplate.plain | 3 ++- .../main_requiring_acceptance_tests.plain | 3 ++- .../required_with_acceptance_tests.plain | 7 ++----- .../entry-point.plain | 11 +++++++++++ tests/data/partial_rendering/pr_code_var.plain | 6 ++++-- tests/data/partial_rendering/pr_leaf.plain | 6 ++++-- tests/data/partial_rendering/pr_middle.plain | 6 ++++-- tests/data/partial_rendering/pr_root.plain | 6 ++++-- tests/data/partial_rendering/pr_solo.plain | 9 ++++++--- tests/test_dry_run_link_validation.py | 3 ++- 17 files changed, 94 insertions(+), 28 deletions(-) create mode 100644 examples/example_hello_world_golang/tests/hello_world_golang/display-hello-world.plain create mode 100644 examples/example_hello_world_python/tests/hello_world_python/display-hello-world.plain create mode 100644 examples/example_hello_world_react/tests/hello_world_react/display-hello-world.plain create mode 100644 examples/example_hello_world_react/tests/typescript-react-app-boilerplate/entry-point.plain create mode 100644 tests/data/acceptance_tests_warning/tests/required_with_acceptance_tests/entry-point.plain diff --git a/examples/example_hello_world_golang/hello_world_golang.plain b/examples/example_hello_world_golang/hello_world_golang.plain index e294441a..0351c981 100644 --- a/examples/example_hello_world_golang/hello_world_golang.plain +++ b/examples/example_hello_world_golang/hello_world_golang.plain @@ -10,4 +10,5 @@ import: ***functional specs*** -- Display "hello, world" +# display-hello-world +:App: displays "hello, world". diff --git a/examples/example_hello_world_golang/tests/hello_world_golang/display-hello-world.plain b/examples/example_hello_world_golang/tests/hello_world_golang/display-hello-world.plain new file mode 100644 index 00000000..53975f04 --- /dev/null +++ b/examples/example_hello_world_golang/tests/hello_world_golang/display-hello-world.plain @@ -0,0 +1,12 @@ +--- +module: hello_world_golang +func_id: display-hello-world +--- + +***conformance tests*** + +# prints-hello-world +Running :App: prints "hello, world". + - :App: exits with status code 0. + +***acceptance tests*** diff --git a/examples/example_hello_world_python/hello_world_python.plain b/examples/example_hello_world_python/hello_world_python.plain index bb961241..a981fc34 100644 --- a/examples/example_hello_world_python/hello_world_python.plain +++ b/examples/example_hello_world_python/hello_world_python.plain @@ -10,10 +10,5 @@ import: ***functional specs*** -- Display "hello, world" - - ***acceptance tests*** - - - :App: should exit with status code 0 indicating successful execution. - - - :App: should complete execution in under 1 second. \ No newline at end of file +# display-hello-world +:App: displays "hello, world". diff --git a/examples/example_hello_world_python/tests/hello_world_python/display-hello-world.plain b/examples/example_hello_world_python/tests/hello_world_python/display-hello-world.plain new file mode 100644 index 00000000..f505b1fb --- /dev/null +++ b/examples/example_hello_world_python/tests/hello_world_python/display-hello-world.plain @@ -0,0 +1,13 @@ +--- +module: hello_world_python +func_id: display-hello-world +--- + +***conformance tests*** + +# prints-hello-world +Running :App: prints "hello, world". + - :App: exits with status code 0. + - :App: completes in under 1 second. + +***acceptance tests*** diff --git a/examples/example_hello_world_react/hello_world_react.plain b/examples/example_hello_world_react/hello_world_react.plain index e7340887..1c92e1ee 100644 --- a/examples/example_hello_world_react/hello_world_react.plain +++ b/examples/example_hello_world_react/hello_world_react.plain @@ -8,4 +8,5 @@ requires: ***functional specs*** -- Display "hello, world" +# display-hello-world +:App: displays "hello, world". diff --git a/examples/example_hello_world_react/tests/hello_world_react/display-hello-world.plain b/examples/example_hello_world_react/tests/hello_world_react/display-hello-world.plain new file mode 100644 index 00000000..53774e71 --- /dev/null +++ b/examples/example_hello_world_react/tests/hello_world_react/display-hello-world.plain @@ -0,0 +1,11 @@ +--- +module: hello_world_react +func_id: display-hello-world +--- + +***conformance tests*** + +# shows-hello-world +Opening :App: in a browser shows "hello, world". + +***acceptance tests*** diff --git a/examples/example_hello_world_react/tests/typescript-react-app-boilerplate/entry-point.plain b/examples/example_hello_world_react/tests/typescript-react-app-boilerplate/entry-point.plain new file mode 100644 index 00000000..2f7bee08 --- /dev/null +++ b/examples/example_hello_world_react/tests/typescript-react-app-boilerplate/entry-point.plain @@ -0,0 +1,11 @@ +--- +module: typescript-react-app-boilerplate +func_id: entry-point +--- + +***conformance tests*** + +# app-starts +:App: starts and serves a page on port 3000. + +***acceptance tests*** diff --git a/standard_template_library/typescript-react-app-boilerplate.plain b/standard_template_library/typescript-react-app-boilerplate.plain index 1e0d7123..cd16eb11 100644 --- a/standard_template_library/typescript-react-app-boilerplate.plain +++ b/standard_template_library/typescript-react-app-boilerplate.plain @@ -5,4 +5,5 @@ import: ***functional specs*** -- Implement the entry point for :App:. +# entry-point +:App: has an entry point. diff --git a/tests/data/acceptance_tests_warning/main_requiring_acceptance_tests.plain b/tests/data/acceptance_tests_warning/main_requiring_acceptance_tests.plain index 7f04d3fd..a623d72c 100644 --- a/tests/data/acceptance_tests_warning/main_requiring_acceptance_tests.plain +++ b/tests/data/acceptance_tests_warning/main_requiring_acceptance_tests.plain @@ -13,4 +13,5 @@ requires: ***functional specs*** -- Display "hello, world" +# display-hello +:MainApp: displays "hello, world". diff --git a/tests/data/acceptance_tests_warning/required_with_acceptance_tests.plain b/tests/data/acceptance_tests_warning/required_with_acceptance_tests.plain index b6e42fe1..7299edfb 100644 --- a/tests/data/acceptance_tests_warning/required_with_acceptance_tests.plain +++ b/tests/data/acceptance_tests_warning/required_with_acceptance_tests.plain @@ -8,8 +8,5 @@ ***functional specs*** -- Implement the entry point for :App:. - - ***acceptance tests*** - - - The entry point should run without errors. +# entry-point +:App: has an entry point. diff --git a/tests/data/acceptance_tests_warning/tests/required_with_acceptance_tests/entry-point.plain b/tests/data/acceptance_tests_warning/tests/required_with_acceptance_tests/entry-point.plain new file mode 100644 index 00000000..3673c0f3 --- /dev/null +++ b/tests/data/acceptance_tests_warning/tests/required_with_acceptance_tests/entry-point.plain @@ -0,0 +1,11 @@ +--- +module: required_with_acceptance_tests +func_id: entry-point +--- + +***conformance tests*** + +# runs-without-errors +Starting :App: exits with status 0. + +***acceptance tests*** diff --git a/tests/data/partial_rendering/pr_code_var.plain b/tests/data/partial_rendering/pr_code_var.plain index 0494ef99..c6ddad01 100644 --- a/tests/data/partial_rendering/pr_code_var.plain +++ b/tests/data/partial_rendering/pr_code_var.plain @@ -4,6 +4,8 @@ ***functional specs*** -- Plain functionality one. +# plain-one +Plain functionality one. -- Implement {% include "pr_implement.plain", variable_name: "configurable-value" %} +# implement-configurable +Implement {% include "pr_implement.plain", variable_name: "configurable-value" %} diff --git a/tests/data/partial_rendering/pr_leaf.plain b/tests/data/partial_rendering/pr_leaf.plain index 1d4193ec..bbc4e41b 100644 --- a/tests/data/partial_rendering/pr_leaf.plain +++ b/tests/data/partial_rendering/pr_leaf.plain @@ -4,6 +4,8 @@ ***functional specs*** -- Leaf functionality one. +# leaf-one +Leaf functionality one. -- Leaf functionality two. +# leaf-two +Leaf functionality two. diff --git a/tests/data/partial_rendering/pr_middle.plain b/tests/data/partial_rendering/pr_middle.plain index 8276d962..22adf6e0 100644 --- a/tests/data/partial_rendering/pr_middle.plain +++ b/tests/data/partial_rendering/pr_middle.plain @@ -9,6 +9,8 @@ requires: ***functional specs*** -- Middle functionality one. +# middle-one +Middle functionality one. -- Middle functionality two. +# middle-two +Middle functionality two. diff --git a/tests/data/partial_rendering/pr_root.plain b/tests/data/partial_rendering/pr_root.plain index e5d38a45..90e2ed83 100644 --- a/tests/data/partial_rendering/pr_root.plain +++ b/tests/data/partial_rendering/pr_root.plain @@ -9,6 +9,8 @@ requires: ***functional specs*** -- Root functionality one. +# root-one +Root functionality one. -- Root functionality two. +# root-two +Root functionality two. diff --git a/tests/data/partial_rendering/pr_solo.plain b/tests/data/partial_rendering/pr_solo.plain index d46cc9a1..4d1bf694 100644 --- a/tests/data/partial_rendering/pr_solo.plain +++ b/tests/data/partial_rendering/pr_solo.plain @@ -4,8 +4,11 @@ ***functional specs*** -- Solo functionality one. +# solo-one +Solo functionality one. -- Solo functionality two. +# solo-two +Solo functionality two. -- Solo functionality three. +# solo-three +Solo functionality three. diff --git a/tests/test_dry_run_link_validation.py b/tests/test_dry_run_link_validation.py index d3e49bb1..a4e22174 100644 --- a/tests/test_dry_run_link_validation.py +++ b/tests/test_dry_run_link_validation.py @@ -23,7 +23,8 @@ ***functional specs*** -- A :Widget: can be created. +# create-widget +A :Widget: can be created. """ From dbdc70872229780310fe9d1f65bfb7f00724dddd Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Fri, 23 Oct 2026 09:54:45 +0200 Subject: [PATCH 03/23] feat: read test scenarios from the test folders --- plain2code.py | 2 + plain2code_arguments.py | 65 +++++++++++++++++++++++-- plain_modules.py | 23 ++++++++- tests/test_render_finished_report.py | 2 + tests/test_terminal_attachment.py | 1 + tests/test_test_dirs.py | 73 ++++++++++++++++++++++++++++ 6 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 tests/test_test_dirs.py diff --git a/plain2code.py b/plain2code.py index f88a6085..bbf75283 100644 --- a/plain2code.py +++ b/plain2code.py @@ -488,6 +488,8 @@ def main(): # noqa: C901 os.path.basename(args.filename), args.build_folder, template_dirs, + args.test_dir, + args.render_conformance_tests, ) except Exception as e: console.error(f"Error: {user_error_message(e)}") diff --git a/plain2code_arguments.py b/plain2code_arguments.py index e7d9c559..ef97074f 100644 --- a/plain2code_arguments.py +++ b/plain2code_arguments.py @@ -17,6 +17,7 @@ DEFAULT_BUILD_FOLDER = "plain_modules" +DEFAULT_TEST_DIR = "tests" DEFAULT_BUILD_DEST = "dist" DEFAULT_CONFORMANCE_TESTS_DEST = "dist_conformance_tests" MAX_JOB_TAG_LENGTH = 300 @@ -48,6 +49,50 @@ def _resolve_path_arg( return resolved +def _resolve_path_list_arg( + arg_name: str, + args, + cwd: str, + config_dir: Optional[str], + spec_dir: str, +) -> None: + """Resolve a repeatable path-valued argument on ``args`` in-place. + + A single string (as config.yaml may give it) is read as a one-item list. + """ + original_value = getattr(args, arg_name, None) + if original_value is None: + return + + if isinstance(original_value, str): + original_value = [original_value] + + source = getattr(args, ARGUMENT_SOURCES, {}).get(arg_name, "default") + setattr( + args, + arg_name, + [resolve_path(value, source, cwd=cwd, config_dir=config_dir, spec_dir=spec_dir) for value in original_value], + ) + + +def _resolve_path_arguments( + parser: argparse.ArgumentParser, + args, + cwd: str, + config_dir: Optional[str], + spec_dir: str, +) -> None: + """Resolve every path-valued argument on ``args`` in-place and apply the test-folder default.""" + for action in parser._actions: + if getattr(action, "_is_path", False): + _resolve_path_arg(action.dest, args, cwd, config_dir, spec_dir) + elif getattr(action, "_is_path_list", False): + _resolve_path_list_arg(action.dest, args, cwd, config_dir, spec_dir) + + if args.test_dir is None: + args.test_dir = [os.path.join(spec_dir, DEFAULT_TEST_DIR)] + + def _resolve_script_path( script_arg_name: str, args, @@ -204,11 +249,14 @@ def update_args_with_config(args, parser, cli_provided: set[str]): return args -def _add_arg(parser, *args, path=False, **kwargs): - """Add an argument; tag as path-valued when ``path=True`` so ``parse_arguments`` resolves it.""" +def _add_arg(parser, *args, path=False, path_list=False, **kwargs): + """Add an argument; tag as path-valued when ``path=True`` (or ``path_list=True`` when it is + repeatable) so ``parse_arguments`` resolves it.""" action = parser.add_argument(*args, **kwargs) if path: action._is_path = True + if path_list: + action._is_path_list = True def create_parser(): @@ -393,6 +441,15 @@ def create_parser(): "3) Built-in standard_template_library directory", path=True, ) + _add_arg( + parser, + "--test-dir", + action="append", + default=None, + help="Folder scanned for test plain files; repeat the option for more than one. " + f"Defaults to '{DEFAULT_TEST_DIR}' next to the plain file.", + path_list=True, + ) _add_arg( parser, "--copy-build", @@ -507,9 +564,7 @@ def parse_arguments(command_line: Optional[Sequence[str]] = None): # otherwise it is still just the lookup name (e.g. "config.yaml"). config_dir = os.path.dirname(args.config_name) if os.path.isabs(args.config_name) else None - for action in parser._actions: - if getattr(action, "_is_path", False): - _resolve_path_arg(action.dest, args, cwd, config_dir, spec_dir) + _resolve_path_arguments(parser, args, cwd, config_dir, spec_dir) if args.build_folder == args.build_dest: parser.error("--build-folder and --build-dest cannot be the same") diff --git a/plain_modules.py b/plain_modules.py index 49b00dcb..f71d6163 100644 --- a/plain_modules.py +++ b/plain_modules.py @@ -18,6 +18,7 @@ from git import Repo from git.exc import NoSuchPathError +from plain_parser import project import file_utils import git_utils @@ -134,7 +135,14 @@ def _strip_functional_requirements(plain_source_tree: dict) -> dict: class PlainModule: - def __init__(self, filename: str, build_folder: str, template_dirs: list[str]): + def __init__( + self, + filename: str, + build_folder: str, + template_dirs: list[str], + test_dirs: list[str] | None = None, + conformance_tests_enabled: bool = False, + ): self.filename = filename self.build_folder = build_folder self.template_dirs = template_dirs @@ -161,6 +169,19 @@ def __init__(self, filename: str, build_folder: str, template_dirs: list[str]): ) for module_name in required_modules_names ] + if test_dirs is not None: + self._bind_test_scenarios(test_dirs, conformance_tests_enabled) + + def _bind_test_scenarios(self, test_dirs: list[str], conformance_tests_enabled: bool) -> None: + """Add the scenarios of the test plain files under ``test_dirs`` to the functionalities of the chain. + + With conformance tests enabled, a functionality without a conformance scenario stops the run here. + """ + chain = [(module.module_name, module.plain_source) for module in self.all_required_modules] + chain.append((self.module_name, self.plain_source)) + project.bind_test_plain_files(chain, test_dirs, self.template_dirs, self.template_dirs[0]) + if conformance_tests_enabled: + project.check_conformance_scenarios(chain) @cached_property def all_required_modules(self) -> list[PlainModule]: diff --git a/tests/test_render_finished_report.py b/tests/test_render_finished_report.py index b38210fb..e3138f41 100644 --- a/tests/test_render_finished_report.py +++ b/tests/test_render_finished_report.py @@ -92,6 +92,7 @@ def make_args(api_key="test-key", headless=False): api="https://api.example.com", filename="project.plain", template_dir=None, + test_dir=[], build_folder="build", render_range=None, render_from=None, @@ -100,6 +101,7 @@ def make_args(api_key="test-key", headless=False): job_tag=None, log_to_file=False, log_file_name="codeplain.log", + render_conformance_tests=False, conformance_tests_script=None, unittests_script=None, prepare_environment_script=None, diff --git a/tests/test_terminal_attachment.py b/tests/test_terminal_attachment.py index 3da6e24c..e10c801b 100644 --- a/tests/test_terminal_attachment.py +++ b/tests/test_terminal_attachment.py @@ -78,6 +78,7 @@ def make_args(): api="https://api.example.com", filename="project.plain", template_dir=None, + test_dir=[], build_folder="build", render_range=None, render_from=None, diff --git a/tests/test_test_dirs.py b/tests/test_test_dirs.py new file mode 100644 index 00000000..de04beaa --- /dev/null +++ b/tests/test_test_dirs.py @@ -0,0 +1,73 @@ +"""The test folders: where they come from, and what the client does with the scenarios it finds there.""" + +import os + +import pytest + +import file_utils # noqa: F401 # imported before plain_modules, which it imports back from +from plain2code_arguments import parse_arguments +from plain_modules import PlainModule + +FIXTURES = "data/acceptance_tests_warning" +MAIN_MODULE = "main_requiring_acceptance_tests.plain" +REQUIRED_MODULE = "required_with_acceptance_tests.plain" + + +@pytest.fixture +def fixtures_dir(get_test_data_path): + return get_test_data_path(FIXTURES) + + +@pytest.fixture +def scenarios_dir(fixtures_dir): + return os.path.join(fixtures_dir, "tests") + + +def _parse(tmp_path, *extra_args): + plain_file = tmp_path / "project.plain" + plain_file.write_text("***functional specs***\n\n# do-something\n:App: does something.\n") + return parse_arguments([str(plain_file), *extra_args]) + + +def test_test_dir_defaults_to_tests_next_to_the_plain_file(tmp_path): + args = _parse(tmp_path) + + assert args.test_dir == [str(tmp_path / "tests")] + + +def test_test_dir_can_be_given_more_than_once(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + args = _parse(tmp_path, "--test-dir", "scenarios", "--test-dir", "extra") + + assert args.test_dir == [str(tmp_path / "scenarios"), str(tmp_path / "extra")] + + +def test_test_dir_from_the_config_file_resolves_against_the_config_folder(tmp_path): + (tmp_path / "config.yaml").write_text("test-dir: scenarios\n") + args = _parse(tmp_path) + + assert args.test_dir == [str(tmp_path / "scenarios")] + + +def test_scenarios_of_a_test_plain_file_reach_the_functionality(tmp_path, fixtures_dir, scenarios_dir): + module = PlainModule(REQUIRED_MODULE, str(tmp_path / "build"), [fixtures_dir], [scenarios_dir]) + + functionality = module.plain_source["functional specs"][0] + assert [scenario["id"] for scenario in functionality["conformance_tests"]] == ["runs-without-errors"] + + +def test_a_functionality_without_a_conformance_scenario_stops_the_run(tmp_path, fixtures_dir, scenarios_dir): + with pytest.raises(Exception, match="main_requiring_acceptance_tests:display-hello"): + PlainModule( + MAIN_MODULE, + str(tmp_path / "build"), + [fixtures_dir], + [scenarios_dir], + conformance_tests_enabled=True, + ) + + +def test_the_run_goes_on_without_a_scenario_when_conformance_tests_are_off(tmp_path, fixtures_dir, scenarios_dir): + module = PlainModule(MAIN_MODULE, str(tmp_path / "build"), [fixtures_dir], [scenarios_dir]) + + assert module.module_name == "main_requiring_acceptance_tests" From 61bc811b210be6162346c449678f7b714c4ee394 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Fri, 23 Oct 2026 15:07:56 +0200 Subject: [PATCH 04/23] feat: take func-ids in --render-range and --render-from --- CLAUDE.md | 6 +++--- plain2code_arguments.py | 16 ++++++++------- tests/test_frid_arguments.py | 40 ++++++++++++++++++++++++++++++++++++ tests/test_render_range.py | 27 ++++++++++++++++-------- 4 files changed, 70 insertions(+), 19 deletions(-) create mode 100644 tests/test_frid_arguments.py diff --git a/CLAUDE.md b/CLAUDE.md index 03f4c3d0..b176a44f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,8 +76,8 @@ python plain2code.py path/to/file.plain --log-to-file python plain2code.py path/to/file.plain --headless # Render specific functionality range -python plain2code.py file.plain --render-range 1,3 # Render functionalities 1-3 -python plain2code.py file.plain --render-from 2 # Resume from functionality 2 +python plain2code.py file.plain --render-range add-task,delete-task # Render add-task through delete-task +python plain2code.py file.plain --render-from delete-task # Resume from delete-task # Run with examples cd examples/example_hello_world_python @@ -136,7 +136,7 @@ black . --check && isort . --check-only && flake8 . && mypy . --check-untyped-de **Plain Modules**: A `.plain` file can `import` other `.plain` files, creating a module dependency tree. The renderer processes required modules first (depth-first), then the top module. -**Functionalities (FRIDs)**: Each functional requirement in a `.plain` file has a unique ID (FRID). The renderer processes them sequentially. State is checkpointed after each FRID. +**Functionalities (FRIDs)**: Each functional requirement in a `.plain` file has a unique ID (FRID), the func-id of its `# ` heading. The renderer processes them sequentially. State is checkpointed after each FRID. **Render State Machine** (`render_machine/states.py`): Hierarchical FSM with states like: - `IMPLEMENTING_FRID` → `PROCESSING_UNIT_TESTS` → `REFACTORING_CODE` → `PROCESSING_CONFORMANCE_TESTS` diff --git a/plain2code_arguments.py b/plain2code_arguments.py index ef97074f..2d505819 100644 --- a/plain2code_arguments.py +++ b/plain2code_arguments.py @@ -2,6 +2,8 @@ import os from typing import Optional, Sequence +from plain_parser.plain_spec import is_semantic_id + from path_resolution import resolve_path from plain2code_console import console from plain2code_exceptions import AmbiguousConfigFileError @@ -132,14 +134,14 @@ def job_tag_string(s): def frid_string(s): - """Validate that the FRID is an integer.""" + """Validate that the functionality ID is a func-id.""" if not s: raise argparse.ArgumentTypeError("The functionality ID cannot be empty.") - try: - int(s) - except ValueError: - raise argparse.ArgumentTypeError("Functionality ID string must be a number.") + if not is_semantic_id(s): + raise argparse.ArgumentTypeError( + f"Invalid functionality ID '{s}'. A functionality ID holds lowercase letters, digits and hyphens." + ) return s @@ -333,7 +335,7 @@ def create_parser(): render_range_group, "--render-range", type=frid_range_string, - help="Specify a range of functionalities to render (e.g. `1` , `2`, `3`). " + help="Specify a range of functionalities to render (e.g. `add-task`, `add-task,list-tasks`). " "Use comma to separate start and end IDs. If only one functionality ID is provided, only that functionality is rendered. " "Range is inclusive of both start and end IDs.", ) @@ -341,7 +343,7 @@ def create_parser(): render_range_group, "--render-from", type=frid_string, - help="Continue generation starting from this specific functionality (e.g. `2`). " + help="Continue generation starting from this specific functionality (e.g. `add-task`). " "The functionality with this ID will be included in the output. The functionality ID must match one of the functionalities in your plain file.", ) diff --git a/tests/test_frid_arguments.py b/tests/test_frid_arguments.py new file mode 100644 index 00000000..9c35aaf4 --- /dev/null +++ b/tests/test_frid_arguments.py @@ -0,0 +1,40 @@ +"""--render-range and --render-from take func-ids.""" + +import pytest + +from plain2code_arguments import parse_arguments + + +def _parse(tmp_path, *extra_args): + plain_file = tmp_path / "project.plain" + plain_file.write_text("***functional specs***\n\n# add-task\nA :User: can add a :Task:.\n") + return parse_arguments([str(plain_file), *extra_args]) + + +def test_render_from_takes_a_func_id(tmp_path): + args = _parse(tmp_path, "--render-from", "add-task") + + assert args.render_from == "add-task" + + +def test_render_range_takes_two_func_ids(tmp_path): + args = _parse(tmp_path, "--render-range", "add-task,list-tasks") + + assert args.render_range == "add-task,list-tasks" + + +def test_render_range_takes_one_func_id(tmp_path): + args = _parse(tmp_path, "--render-range", "add-task") + + assert args.render_range == "add-task" + + +@pytest.mark.parametrize("value", ["Add-Task", "add task", "add_task", "add.task", ""]) +def test_a_value_that_is_not_a_func_id_is_rejected(tmp_path, value): + with pytest.raises(SystemExit): + _parse(tmp_path, "--render-from", value) + + +def test_a_range_of_more_than_two_func_ids_is_rejected(tmp_path): + with pytest.raises(SystemExit): + _parse(tmp_path, "--render-range", "add-task,list-tasks,rename-task") diff --git a/tests/test_render_range.py b/tests/test_render_range.py index d033154b..b7d1a302 100644 --- a/tests/test_render_range.py +++ b/tests/test_render_range.py @@ -4,31 +4,40 @@ import plain_spec +FUNC_IDS = ["add-task", "list-tasks", "rename-task", "delete-task"] + def _four_functionalities(): - return {plain_spec.FUNCTIONAL_REQUIREMENTS: [{"markdown": f"- Functionality {i}."} for i in range(1, 5)]} + return { + plain_spec.FUNCTIONAL_REQUIREMENTS: [ + {"id": func_id, "markdown": f"A :User: can {func_id.replace('-', ' ')}."} for func_id in FUNC_IDS + ] + } def test_get_render_range_parses_start_and_end(): - assert plain_spec.get_render_range("2,3", _four_functionalities()) == ["2", "3"] + assert plain_spec.get_render_range("list-tasks,rename-task", _four_functionalities()) == [ + "list-tasks", + "rename-task", + ] def test_get_render_range_single_value_renders_one_frid(): - assert plain_spec.get_render_range("2", _four_functionalities()) == ["2"] + assert plain_spec.get_render_range("list-tasks", _four_functionalities()) == ["list-tasks"] def test_get_render_range_from_runs_to_last_frid(): - assert plain_spec.get_render_range_from("3", _four_functionalities()) == ["3", "4"] + assert plain_spec.get_render_range_from("rename-task", _four_functionalities()) == ["rename-task", "delete-task"] def test_compute_render_range_prefers_render_range(): - args = SimpleNamespace(render_range="1,2", render_from="3") - assert plain_spec.compute_render_range(args, _four_functionalities()) == ["1", "2"] + args = SimpleNamespace(render_range="add-task,list-tasks", render_from="rename-task") + assert plain_spec.compute_render_range(args, _four_functionalities()) == ["add-task", "list-tasks"] def test_compute_render_range_falls_back_to_render_from(): - args = SimpleNamespace(render_range=None, render_from="3") - assert plain_spec.compute_render_range(args, _four_functionalities()) == ["3", "4"] + args = SimpleNamespace(render_range=None, render_from="rename-task") + assert plain_spec.compute_render_range(args, _four_functionalities()) == ["rename-task", "delete-task"] def test_compute_render_range_none_when_no_arguments(): @@ -37,6 +46,6 @@ def test_compute_render_range_none_when_no_arguments(): def test_compute_render_range_invalid_frid_raises(): - args = SimpleNamespace(render_range="9", render_from=None) + args = SimpleNamespace(render_range="no-such-task", render_from=None) with pytest.raises(plain_spec.InvalidFridArgument): plain_spec.compute_render_range(args, _four_functionalities()) From 785b8caf7688aaf2daf9be7ad4a6ef51fb64c0b8 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Mon, 26 Oct 2026 08:04:04 +0100 Subject: [PATCH 05/23] feat: track the render state by func-id --- change_detection.py | 159 ++++++++----------- partial_rendering.py | 6 +- plain_modules.py | 31 ++-- render_machine/render_context.py | 2 +- tests/test_change_detection.py | 265 ++++++++++++++++--------------- tests/test_partial_rendering.py | 96 +++++++---- tests/test_plain_modules.py | 94 ++++++----- 7 files changed, 338 insertions(+), 315 deletions(-) diff --git a/change_detection.py b/change_detection.py index 557f1fdd..54f66123 100644 --- a/change_detection.py +++ b/change_detection.py @@ -42,8 +42,7 @@ def determine_partial_render_start(plain_module: "PlainModule") -> PartialRender if not changes: continue - current_fr_count = len(module._get_module_functional_requirements()) - earliest_frid = _get_earliest_affected_frid(changes, current_fr_count) + earliest_frid = _get_earliest_affected_frid(changes, _stored_frids(module), _current_frids(module)) if earliest_frid is None: continue return PartialRenderStart(module=module, frid=earliest_frid) @@ -66,125 +65,97 @@ def _non_functional_content_changed(module: "PlainModule") -> bool: return stored_hash != module.get_module_non_functional_source_hash() -def _get_earliest_affected_frid(changes: list[FunctionalityChange], current_fr_count: int) -> str | None: - """Earliest FRID (in current spec numbering) that must be re-rendered. +def _stored_functionalities(module: "PlainModule") -> list[dict[str, str]]: + metadata = module.load_module_metadata() + return metadata.get(MODULE_FUNCTIONALITIES_KEY, []) if metadata else [] + + +def _stored_frids(module: "PlainModule") -> list[str]: + return [functionality["id"] for functionality in _stored_functionalities(module)] + + +def _current_frids(module: "PlainModule") -> list[str]: + return [functionality["id"] for functionality in module._get_module_functional_requirements()] + - Returns the minimum position across all changes: - - added / edited: the FRID itself. - - removed: the position the removal opened up (now occupied by the next FR). - - moved: the FR's old position. +def _get_earliest_affected_frid( + changes: list[FunctionalityChange], old_frids: list[str], new_frids: list[str] +) -> str | None: + """The func-id of the earliest functionality in the new order that must be re-rendered. - Correctness does not rely on any single change type's position being individually - "the" earliest (in particular, a move's old position is *not* always its earliest - touched position once moves mix with adds/removes). The guarantee is structural: - the first index at which the new spec diverges from the old is always emitted as - some change anchored at that index — a move with that old position, or an - edit/removal/addition there — so the minimum over all change FRIDs lands at or - before the true first divergence. Rendering runs from this FRID to the end of the - module, so an at-or-before start point is always safe (it can re-render unchanged - trailing FRs, but never skips a changed one). + Rendering runs from this functionality to the end of the module, so an at-or-before start + point is always safe: it can re-render unchanged trailing functionalities, but never skips + a changed one. - Returns None when every change is a removal beyond the current spec length - (only trailing FRs were removed, so nothing needs rendering). + Returns None when every change is a removal past the end of the new order (only trailing + functionalities were removed, so nothing needs rendering). """ earliest = None for change in changes: - frid_int = int(change.frid) - if change.change_type == "removed" and frid_int > current_fr_count: + position = _affected_position(change, old_frids, new_frids) + if position is None: continue - if earliest is None or frid_int < earliest: - earliest = frid_int + if earliest is None or position < earliest: + earliest = position if earliest is None: return None - return str(earliest) + return new_frids[earliest] + + +def _affected_position(change: FunctionalityChange, old_frids: list[str], new_frids: list[str]) -> int | None: + """Where in the new order the change must be rendered from. + + A removed functionality has no place in the new order, so the one that took its place is + rendered instead. A removal past the end of the new order needs no render. + """ + if change.change_type == "removed": + position = old_frids.index(change.frid) + return position if position < len(new_frids) else None + + return new_frids.index(change.frid) def _detect_module_changes(module: "PlainModule") -> list[FunctionalityChange]: - metadata = module.load_module_metadata() - old_frs: list[str] = metadata.get(MODULE_FUNCTIONALITIES_KEY, []) if metadata else [] - new_frs: list[str] = module._get_module_functional_requirements() + old_frs = _stored_functionalities(module) + new_frs = module._get_module_functional_requirements() if old_frs == new_frs: return [] - moves, edits, removed, added = _classify_changes(old_frs, new_frs) + old_frids = [functionality["id"] for functionality in old_frs] + new_frids = [functionality["id"] for functionality in new_frs] + old_markdown = {functionality["id"]: functionality["markdown"] for functionality in old_frs} changes: list[FunctionalityChange] = [] name = module.module_name - for old_idx, new_idx in moves: - old_frid = _frid_from_index(old_idx) - new_frid = _frid_from_index(new_idx) - changes.append(FunctionalityChange(module=name, frid=old_frid, change_type="moved", detail=new_frid)) + for frid in _moved_frids(old_frids, new_frids): + changes.append(FunctionalityChange(module=name, frid=frid, change_type="moved")) - for idx in edits: - changes.append(FunctionalityChange(module=name, frid=_frid_from_index(idx), change_type="edited")) + for functionality in new_frs: + frid = functionality["id"] + if frid in old_markdown and functionality["markdown"] != old_markdown[frid]: + changes.append(FunctionalityChange(module=name, frid=frid, change_type="edited")) - for idx in removed: - changes.append(FunctionalityChange(module=name, frid=_frid_from_index(idx), change_type="removed")) + for frid in old_frids: + if frid not in new_frids: + changes.append(FunctionalityChange(module=name, frid=frid, change_type="removed")) - for idx in added: - changes.append(FunctionalityChange(module=name, frid=_frid_from_index(idx), change_type="added")) + for frid in new_frids: + if frid not in old_markdown: + changes.append(FunctionalityChange(module=name, frid=frid, change_type="added")) return changes -def _classify_changes( - old_frs: list[str], new_frs: list[str] -) -> tuple[list[tuple[int, int]], list[int], list[int], list[int]]: - matched_old: set[int] = set() - matched_new: set[int] = set() +def _moved_frids(old_frids: list[str], new_frids: list[str]) -> list[str]: + """The func-ids the two orders keep but place differently, in the new order. - for i in range(min(len(old_frs), len(new_frs))): - if old_frs[i] == new_frs[i]: - matched_old.add(i) - matched_new.add(i) - - content_matches: list[tuple[int, int]] = [] - for old_idx in range(len(old_frs)): - if old_idx in matched_old: - continue - for new_idx in range(len(new_frs)): - if new_idx in matched_new: - continue - if old_frs[old_idx] == new_frs[new_idx]: - content_matches.append((old_idx, new_idx)) - matched_old.add(old_idx) - matched_new.add(new_idx) - break - - moves: list[tuple[int, int]] = [] - if content_matches and _has_relative_order_change(content_matches): - moves = content_matches - - edits: list[int] = [] - for i in range(min(len(old_frs), len(new_frs))): - if i not in matched_old and i not in matched_new: - edits.append(i) - matched_old.add(i) - matched_new.add(i) - - removed = [i for i in range(len(old_frs)) if i not in matched_old] - added = [i for i in range(len(new_frs)) if i not in matched_new] - - return moves, edits, removed, added - - -def _has_relative_order_change(matches: list[tuple[int, int]]) -> bool: - """Check if content matches represent a true reorder (relative order changed). - - If all matches preserve relative order (sorted by old_idx gives same ordering - as sorted by new_idx), it's just a positional shift from insertions/removals. + Only the functionalities present in both orders are compared, so an addition or a removal + alone shifts no one: it is a move only when the kept functionalities swap places. """ - if len(matches) <= 1: - return False - sorted_by_old = sorted(matches, key=lambda m: m[0]) - new_indices = [m[1] for m in sorted_by_old] - for i in range(len(new_indices) - 1): - if new_indices[i] > new_indices[i + 1]: - return True - return False - + kept = set(old_frids) & set(new_frids) + old_order = [frid for frid in old_frids if frid in kept] + new_order = [frid for frid in new_frids if frid in kept] -def _frid_from_index(index: int) -> str: - return str(index + 1) + return [frid for position, frid in enumerate(new_order) if old_order[position] != frid] diff --git a/partial_rendering.py b/partial_rendering.py index 40fccdda..afc851b7 100644 --- a/partial_rendering.py +++ b/partial_rendering.py @@ -201,7 +201,11 @@ def change_is_only_future_work( if plain_module_render_state.last_render_frid is None: return True - return int(partial_start.frid) > int(plain_module_render_state.last_render_frid) + frids = list(plain_spec.get_frids(partial_start.module.plain_source)) + if plain_module_render_state.last_render_frid not in frids: + return False + + return frids.index(partial_start.frid) > frids.index(plain_module_render_state.last_render_frid) def _resume_render_choice( diff --git a/plain_modules.py b/plain_modules.py index f71d6163..b30b0dcb 100644 --- a/plain_modules.py +++ b/plain_modules.py @@ -335,12 +335,13 @@ def update_frid_in_module_metadata(self, frid: str) -> None: # for every code-variable FR after a partial/interrupted render. metadata = self.load_module_metadata() or {} functionalities = metadata.get(MODULE_FUNCTIONALITIES, []) - frid_index = int(frid) - 1 - frid_text = self._get_module_functional_requirements()[frid_index] - if frid_index < len(functionalities): - functionalities[frid_index] = frid_text + functionality = next(item for item in self._get_module_functional_requirements() if item["id"] == frid) + for position, stored in enumerate(functionalities): + if stored["id"] == frid: + functionalities[position] = functionality + break else: - functionalities.append(frid_text) + functionalities.append(functionality) metadata[MODULE_FUNCTIONALITIES] = functionalities metadata_utils.write_metadata(self.module_metadata_path(), metadata) @@ -382,15 +383,18 @@ def has_plain_spec_changed(self) -> bool: return module_metadata["source_hash"] != self.get_module_source_hash() - def _get_module_functional_requirements(self) -> list[str]: + def _get_module_functional_requirements(self) -> list[dict[str, str]]: + """The functionalities of the module as `id` / `markdown` entries, in file order.""" module_functional_requirements = [] for functional_requirement in self.plain_source[plain_spec.FUNCTIONAL_REQUIREMENTS]: - module_functional_requirements.append(functional_requirement["markdown"]) + module_functional_requirements.append( + {"id": functional_requirement["id"], "markdown": functional_requirement["markdown"]} + ) return module_functional_requirements - def get_functionalities(self) -> dict[str, list[str]]: + def get_functionalities(self) -> dict[str, list[dict[str, str]]]: functionalities = {} for required_module in self.required_modules: functionalities.update(required_module.get_functionalities()) @@ -420,18 +424,19 @@ def seed_module_metadata(self) -> None: metadata_utils.write_metadata(self.module_metadata_path(), self.get_hashes()) def truncate_metadata_functionalities(self, frid: str | None) -> None: - """Trim the stored functionalities list to the first int(frid) entries. + """Trim the stored functionalities list to end at the functionality ``frid``. The metadata file is not tracked in the module's git repo, so when the code repo is reverted to an earlier functionality the stored list must - be trimmed to match the reverted code. A frid of None means no - functionality is implemented (empty list). + be trimmed to match the reverted code. A frid of None, or one the module + no longer has, means no functionality is implemented (empty list). """ metadata = self.load_module_metadata() if metadata is None: return - keep_count = int(frid) if frid is not None else 0 + frids = list(plain_spec.get_frids(self.plain_source)) + keep_count = frids.index(frid) + 1 if frid in frids else 0 if metadata_utils.truncate_functionalities(metadata, keep_count): metadata_utils.write_metadata(self.module_metadata_path(), metadata) @@ -595,7 +600,7 @@ def is_module_fully_rendered(self) -> bool: last_rendered_module_name is not None and last_rendered_module_name == self.module_name and last_rendered_frid is not None - and int(last_rendered_frid) >= int(frids[-1]) + and last_rendered_frid == frids[-1] ): return True diff --git a/render_machine/render_context.py b/render_machine/render_context.py index 04222e00..dc9407f0 100644 --- a/render_machine/render_context.py +++ b/render_machine/render_context.py @@ -176,7 +176,7 @@ def has_next_frid(self) -> bool: if self.render_range is None or len(self.render_range) == 0: return next_frid is not None - return next_frid is not None and int(next_frid) <= int(self.render_range[-1]) + return next_frid is not None and next_frid in self.render_range def finish_implementing_frid(self): self.functional_requirements_render_attempts_failed_unit_during_conformance_tests = 0 diff --git a/tests/test_change_detection.py b/tests/test_change_detection.py index 85f8c232..6e92b19a 100644 --- a/tests/test_change_detection.py +++ b/tests/test_change_detection.py @@ -8,12 +8,21 @@ from change_detection import FunctionalityChange, _detect_module_changes, determine_partial_render_start +def _frs(*items) -> list[dict[str, str]]: + """Functionalities from a func-id, or from a `(func-id, text)` pair when the text matters.""" + functionalities = [] + for item in items: + frid, markdown = item if isinstance(item, tuple) else (item, f"The {item} functionality.") + functionalities.append({"id": frid, "markdown": markdown}) + return functionalities + + class FakeModule: def __init__( self, module_name: str, - current_frs: list[str], - stored_frs: list[str] | None = None, + current_frs: list[dict[str, str]], + stored_frs: list[dict[str, str]] | None = None, current_non_fr_hash: str = "match", stored_non_fr_hash: str | None = "match", ): @@ -40,7 +49,7 @@ def load_module_metadata(self) -> dict | None: metadata["non_functional_source_hash"] = self._stored_non_fr_hash return metadata - def _get_module_functional_requirements(self) -> list[str]: + def _get_module_functional_requirements(self) -> list[dict[str, str]]: return self._current_frs def get_module_non_functional_source_hash(self) -> str: @@ -51,7 +60,7 @@ def get_module_non_functional_source_hash(self) -> str: def test_identical_specs_no_changes(): - module = FakeModule("mod", ["A", "B", "C"], ["A", "B", "C"]) + module = FakeModule("mod", _frs("fr-a", "fr-b", "fr-c"), _frs("fr-a", "fr-b", "fr-c")) changes = _detect_module_changes(module) assert changes == [] @@ -66,16 +75,16 @@ def test_empty_both_no_changes(): def test_no_metadata_all_added(): - module = FakeModule("mod", ["A", "B"], stored_frs=None) + module = FakeModule("mod", _frs("fr-a", "fr-b"), stored_frs=None) changes = _detect_module_changes(module) - assert len(changes) == 2 - assert all(c.change_type == "added" for c in changes) - assert changes[0] == FunctionalityChange(module="mod", frid="1", change_type="added") - assert changes[1] == FunctionalityChange(module="mod", frid="2", change_type="added") + assert changes == [ + FunctionalityChange(module="mod", frid="fr-a", change_type="added"), + FunctionalityChange(module="mod", frid="fr-b", change_type="added"), + ] def test_empty_stored_all_added(): - module = FakeModule("mod", ["A", "B"], stored_frs=[]) + module = FakeModule("mod", _frs("fr-a", "fr-b"), stored_frs=[]) changes = _detect_module_changes(module) assert len(changes) == 2 assert all(c.change_type == "added" for c in changes) @@ -85,66 +94,85 @@ def test_empty_stored_all_added(): def test_no_current_frs_all_removed(): - module = FakeModule("mod", [], stored_frs=["A", "B", "C"]) + module = FakeModule("mod", [], stored_frs=_frs("fr-a", "fr-b", "fr-c")) changes = _detect_module_changes(module) - assert len(changes) == 3 assert all(c.change_type == "removed" for c in changes) - assert changes[0].frid == "1" - assert changes[1].frid == "2" - assert changes[2].frid == "3" + assert [c.frid for c in changes] == ["fr-a", "fr-b", "fr-c"] # --- Edits --- def test_single_edit(): - module = FakeModule("mod", ["A modified", "B"], stored_frs=["A", "B"]) + module = FakeModule("mod", _frs(("fr-a", "new text"), "fr-b"), stored_frs=_frs("fr-a", "fr-b")) changes = _detect_module_changes(module) - assert len(changes) == 1 - assert changes[0] == FunctionalityChange(module="mod", frid="1", change_type="edited") + assert changes == [FunctionalityChange(module="mod", frid="fr-a", change_type="edited")] def test_multiple_edits(): - module = FakeModule("mod", ["X", "Y", "Z"], stored_frs=["A", "B", "C"]) + module = FakeModule( + "mod", + _frs(("fr-a", "new a"), ("fr-b", "new b"), ("fr-c", "new c")), + stored_frs=_frs("fr-a", "fr-b", "fr-c"), + ) changes = _detect_module_changes(module) assert len(changes) == 3 assert all(c.change_type == "edited" for c in changes) +def test_two_functionalities_with_the_same_text_are_told_apart_by_id(): + same_text = "The same text." + module = FakeModule( + "mod", + _frs(("fr-a", same_text), ("fr-b", "new b")), + stored_frs=_frs(("fr-a", same_text), ("fr-b", same_text)), + ) + changes = _detect_module_changes(module) + assert changes == [FunctionalityChange(module="mod", frid="fr-b", change_type="edited")] + + # --- Additions --- def test_addition_at_end(): - module = FakeModule("mod", ["A", "B", "C"], stored_frs=["A", "B"]) + module = FakeModule("mod", _frs("fr-a", "fr-b", "fr-c"), stored_frs=_frs("fr-a", "fr-b")) changes = _detect_module_changes(module) - assert len(changes) == 1 - assert changes[0] == FunctionalityChange(module="mod", frid="3", change_type="added") + assert changes == [FunctionalityChange(module="mod", frid="fr-c", change_type="added")] + + +def test_addition_in_the_middle_moves_nobody(): + module = FakeModule("mod", _frs("fr-a", "fr-x", "fr-b"), stored_frs=_frs("fr-a", "fr-b")) + changes = _detect_module_changes(module) + assert changes == [FunctionalityChange(module="mod", frid="fr-x", change_type="added")] # --- Removals --- def test_removal_at_end(): - module = FakeModule("mod", ["A", "B"], stored_frs=["A", "B", "C"]) + module = FakeModule("mod", _frs("fr-a", "fr-b"), stored_frs=_frs("fr-a", "fr-b", "fr-c")) changes = _detect_module_changes(module) - assert len(changes) == 1 - assert changes[0] == FunctionalityChange(module="mod", frid="3", change_type="removed") + assert changes == [FunctionalityChange(module="mod", frid="fr-c", change_type="removed")] + + +def test_removal_in_the_middle_moves_nobody(): + module = FakeModule("mod", _frs("fr-a", "fr-c"), stored_frs=_frs("fr-a", "fr-b", "fr-c")) + changes = _detect_module_changes(module) + assert changes == [FunctionalityChange(module="mod", frid="fr-b", change_type="removed")] # --- Moves --- def test_swap_detected_as_moves(): - module = FakeModule("mod", ["B", "A"], stored_frs=["A", "B"]) + module = FakeModule("mod", _frs("fr-b", "fr-a"), stored_frs=_frs("fr-a", "fr-b")) changes = _detect_module_changes(module) moves = [c for c in changes if c.change_type == "moved"] - assert len(moves) == 2 - assert FunctionalityChange(module="mod", frid="1", change_type="moved", detail="2") in moves - assert FunctionalityChange(module="mod", frid="2", change_type="moved", detail="1") in moves + assert [c.frid for c in moves] == ["fr-b", "fr-a"] def test_move_to_later_position(): - module = FakeModule("mod", ["B", "C", "A"], stored_frs=["A", "B", "C"]) + module = FakeModule("mod", _frs("fr-b", "fr-c", "fr-a"), stored_frs=_frs("fr-a", "fr-b", "fr-c")) changes = _detect_module_changes(module) moves = [c for c in changes if c.change_type == "moved"] assert len(moves) == 3 @@ -154,241 +182,224 @@ def test_move_to_later_position(): def test_edit_and_addition(): - module = FakeModule("mod", ["X", "B", "C"], stored_frs=["A", "B"]) + module = FakeModule( + "mod", + _frs(("fr-a", "new a"), "fr-b", "fr-c"), + stored_frs=_frs("fr-a", "fr-b"), + ) changes = _detect_module_changes(module) edits = [c for c in changes if c.change_type == "edited"] added = [c for c in changes if c.change_type == "added"] - assert len(edits) == 1 - assert edits[0].frid == "1" - assert len(added) == 1 - assert added[0].frid == "3" + assert [c.frid for c in edits] == ["fr-a"] + assert [c.frid for c in added] == ["fr-c"] def test_edit_and_removal(): - module = FakeModule("mod", ["X"], stored_frs=["A", "B"]) + module = FakeModule("mod", _frs(("fr-a", "new a")), stored_frs=_frs("fr-a", "fr-b")) changes = _detect_module_changes(module) edits = [c for c in changes if c.change_type == "edited"] removed = [c for c in changes if c.change_type == "removed"] - assert len(edits) == 1 - assert edits[0].frid == "1" - assert len(removed) == 1 - assert removed[0].frid == "2" + assert [c.frid for c in edits] == ["fr-a"] + assert [c.frid for c in removed] == ["fr-b"] def test_move_and_addition(): - # Old: [A, B], New: [B, A, C] → A moved 1→2, B moved 2→1, C added - module = FakeModule("mod", ["B", "A", "C"], stored_frs=["A", "B"]) + # Old: [a, b], New: [b, a, c] -> a and b swapped, c added + module = FakeModule("mod", _frs("fr-b", "fr-a", "fr-c"), stored_frs=_frs("fr-a", "fr-b")) changes = _detect_module_changes(module) moves = [c for c in changes if c.change_type == "moved"] added = [c for c in changes if c.change_type == "added"] assert len(moves) == 2 - assert len(added) == 1 - assert added[0].frid == "3" - - -# --- Duplicate FR texts --- - - -def test_duplicate_texts_matched_by_position(): - module = FakeModule("mod", ["A", "A", "B"], stored_frs=["A", "A", "B"]) - changes = _detect_module_changes(module) - assert changes == [] - - -def test_duplicate_texts_with_edit(): - module = FakeModule("mod", ["A", "X", "B"], stored_frs=["A", "A", "B"]) - changes = _detect_module_changes(module) - assert len(changes) == 1 - assert changes[0] == FunctionalityChange(module="mod", frid="2", change_type="edited") + assert [c.frid for c in added] == ["fr-c"] # --- determine_partial_render_start --- def test_partial_render_start_no_changes(): - module = FakeModule("mod", ["A", "B", "C"], stored_frs=["A", "B", "C"]) - result = determine_partial_render_start(module) - assert result is None + module = FakeModule("mod", _frs("fr-a", "fr-b", "fr-c"), _frs("fr-a", "fr-b", "fr-c")) + assert determine_partial_render_start(module) is None -def test_partial_render_start_edit_at_fr3(): - module = FakeModule("mod", ["A", "B", "C modified", "D", "E"], stored_frs=["A", "B", "C", "D", "E"]) +def test_partial_render_start_edit(): + module = FakeModule( + "mod", + _frs("fr-a", "fr-b", ("fr-c", "new c"), "fr-d", "fr-e"), + stored_frs=_frs("fr-a", "fr-b", "fr-c", "fr-d", "fr-e"), + ) result = determine_partial_render_start(module) assert result is not None assert result.module.module_name == "mod" - assert result.frid == "3" + assert result.frid == "fr-c" -def test_partial_render_start_removal_at_fr3(): - module = FakeModule("mod", ["A", "B", "D", "E"], stored_frs=["A", "B", "C", "D", "E"]) +def test_partial_render_start_removal_starts_at_what_took_its_place(): + module = FakeModule( + "mod", + _frs("fr-a", "fr-b", "fr-d", "fr-e"), + stored_frs=_frs("fr-a", "fr-b", "fr-c", "fr-d", "fr-e"), + ) result = determine_partial_render_start(module) assert result is not None - assert result.module.module_name == "mod" - assert result.frid == "3" + assert result.frid == "fr-d" def test_partial_render_start_addition_at_end(): - module = FakeModule("mod", ["A", "B", "C", "D"], stored_frs=["A", "B", "C"]) + module = FakeModule("mod", _frs("fr-a", "fr-b", "fr-c", "fr-d"), stored_frs=_frs("fr-a", "fr-b", "fr-c")) result = determine_partial_render_start(module) assert result is not None - assert result.module.module_name == "mod" - assert result.frid == "4" + assert result.frid == "fr-d" def test_partial_render_start_swap_returns_earliest_position(): - """A swap of FRs 1 and 2 should start partial render from FRID 1.""" - module = FakeModule("mod", ["B", "A", "C"], stored_frs=["A", "B", "C"]) + module = FakeModule("mod", _frs("fr-b", "fr-a", "fr-c"), stored_frs=_frs("fr-a", "fr-b", "fr-c")) result = determine_partial_render_start(module) assert result is not None - assert result.frid == "1" + assert result.frid == "fr-b" def test_partial_render_start_mid_module_swap_returns_earliest_position(): - """A swap of FRs 3 and 4 in the middle of a module should start from FRID 3.""" - module = FakeModule("mod", ["A", "B", "D", "C", "E"], stored_frs=["A", "B", "C", "D", "E"]) + module = FakeModule( + "mod", + _frs("fr-a", "fr-b", "fr-d", "fr-c", "fr-e"), + stored_frs=_frs("fr-a", "fr-b", "fr-c", "fr-d", "fr-e"), + ) result = determine_partial_render_start(module) assert result is not None - assert result.frid == "3" + assert result.frid == "fr-d" def test_partial_render_start_cyclic_move_returns_earliest_position(): - """A 3-cycle move should start from the lowest position in the cycle.""" - module = FakeModule("mod", ["B", "C", "A"], stored_frs=["A", "B", "C"]) + module = FakeModule("mod", _frs("fr-b", "fr-c", "fr-a"), stored_frs=_frs("fr-a", "fr-b", "fr-c")) result = determine_partial_render_start(module) assert result is not None - assert result.frid == "1" + assert result.frid == "fr-b" def test_partial_render_start_multi_module_change_in_required(): - req_module = FakeModule("req", ["R1", "R2 modified", "R3"], stored_frs=["R1", "R2", "R3"]) - top_module = FakeModule("top", ["T1", "T2"], stored_frs=["T1", "T2"]) + req_module = FakeModule( + "req", _frs("req-a", ("req-b", "new b"), "req-c"), stored_frs=_frs("req-a", "req-b", "req-c") + ) + top_module = FakeModule("top", _frs("top-a", "top-b"), stored_frs=_frs("top-a", "top-b")) top_module.required_modules = [req_module] result = determine_partial_render_start(top_module) assert result is not None assert result.module.module_name == "req" - assert result.frid == "2" + assert result.frid == "req-b" def test_partial_render_start_multi_module_no_change_in_required(): - req_module = FakeModule("req", ["R1", "R2"], stored_frs=["R1", "R2"]) - top_module = FakeModule("top", ["T1 modified", "T2"], stored_frs=["T1", "T2"]) + req_module = FakeModule("req", _frs("req-a", "req-b"), stored_frs=_frs("req-a", "req-b")) + top_module = FakeModule("top", _frs(("top-a", "new a"), "top-b"), stored_frs=_frs("top-a", "top-b")) top_module.required_modules = [req_module] result = determine_partial_render_start(top_module) assert result is not None assert result.module.module_name == "top" - assert result.frid == "1" + assert result.frid == "top-a" def test_partial_render_start_multi_module_no_changes_returns_none(): - req_module = FakeModule("req", ["R1", "R2"], stored_frs=["R1", "R2"]) - top_module = FakeModule("top", ["T1"], stored_frs=["T1"]) + req_module = FakeModule("req", _frs("req-a", "req-b"), stored_frs=_frs("req-a", "req-b")) + top_module = FakeModule("top", _frs("top-a"), stored_frs=_frs("top-a")) top_module.required_modules = [req_module] - result = determine_partial_render_start(top_module) - assert result is None + assert determine_partial_render_start(top_module) is None def test_partial_render_start_changes_in_multiple_modules_returns_earliest(): """When both a required module and the top module changed, the earliest module in the chain (the required one) determines the start point.""" - req_module = FakeModule("req", ["R1", "R2"], stored_frs=["R1"]) # R2 added - top_module = FakeModule("top", ["T1 modified"], stored_frs=["T1"]) # T1 edited + req_module = FakeModule("req", _frs("req-a", "req-b"), stored_frs=_frs("req-a")) + top_module = FakeModule("top", _frs(("top-a", "new a")), stored_frs=_frs("top-a")) top_module.required_modules = [req_module] result = determine_partial_render_start(top_module) assert result is not None assert result.module.module_name == "req" - assert result.frid == "2" + assert result.frid == "req-b" def test_partial_render_start_deep_chain_change_in_middle(): - mod_a = FakeModule("a", ["A1"], stored_frs=["A1"]) - mod_b = FakeModule("b", ["B1", "B2"], stored_frs=["B1"]) + mod_a = FakeModule("a", _frs("a-one"), stored_frs=_frs("a-one")) + mod_b = FakeModule("b", _frs("b-one", "b-two"), stored_frs=_frs("b-one")) mod_b.required_modules = [mod_a] - mod_c = FakeModule("c", ["C1"], stored_frs=["C1"]) + mod_c = FakeModule("c", _frs("c-one"), stored_frs=_frs("c-one")) mod_c.required_modules = [mod_b] result = determine_partial_render_start(mod_c) assert result is not None assert result.module.module_name == "b" - assert result.frid == "2" + assert result.frid == "b-two" def test_partial_render_start_never_rendered(): - module = FakeModule("mod", ["A", "B"], stored_frs=None) + module = FakeModule("mod", _frs("fr-a", "fr-b"), stored_frs=None) result = determine_partial_render_start(module) assert result is not None - assert result.frid == "1" + assert result.frid == "fr-a" def test_partial_render_start_trailing_removal_returns_none(): - """Removing the last FR doesn't require rendering any remaining FRs.""" - module = FakeModule("mod", ["A", "B"], stored_frs=["A", "B", "C"]) - result = determine_partial_render_start(module) - assert result is None + """Removing the last functionality doesn't require rendering any remaining one.""" + module = FakeModule("mod", _frs("fr-a", "fr-b"), stored_frs=_frs("fr-a", "fr-b", "fr-c")) + assert determine_partial_render_start(module) is None def test_partial_render_start_trailing_removal_with_earlier_edit(): - """Trailing removal combined with an earlier edit should still detect the edit.""" - module = FakeModule("mod", ["A modified", "B"], stored_frs=["A", "B", "C"]) + module = FakeModule("mod", _frs(("fr-a", "new a"), "fr-b"), stored_frs=_frs("fr-a", "fr-b", "fr-c")) result = determine_partial_render_start(module) assert result is not None - assert result.frid == "1" + assert result.frid == "fr-a" # --- Non-functional content changes --- def test_partial_render_start_blocked_by_non_fr_change(): - """An FR edit alongside a non-FR change (e.g. new definition) must block partial render.""" + """An edit alongside a non-FR change (e.g. new definition) must block partial render.""" module = FakeModule( "mod", - ["A modified", "B"], - stored_frs=["A", "B"], + _frs(("fr-a", "new a"), "fr-b"), + stored_frs=_frs("fr-a", "fr-b"), current_non_fr_hash="v2", stored_non_fr_hash="v1", ) - result = determine_partial_render_start(module) - assert result is None + assert determine_partial_render_start(module) is None def test_partial_render_start_non_fr_only_change_returns_none(): - """A non-FR-only change (FRs identical) blocks partial render.""" module = FakeModule( "mod", - ["A", "B"], - stored_frs=["A", "B"], + _frs("fr-a", "fr-b"), + stored_frs=_frs("fr-a", "fr-b"), current_non_fr_hash="v2", stored_non_fr_hash="v1", ) - result = determine_partial_render_start(module) - assert result is None + assert determine_partial_render_start(module) is None def test_partial_render_start_missing_stored_non_fr_hash_blocks(): """Older metadata without the non-FR hash must be treated as 'changed' (safe default).""" module = FakeModule( "mod", - ["A modified", "B"], - stored_frs=["A", "B"], + _frs(("fr-a", "new a"), "fr-b"), + stored_frs=_frs("fr-a", "fr-b"), stored_non_fr_hash=None, ) - result = determine_partial_render_start(module) - assert result is None + assert determine_partial_render_start(module) is None def test_partial_render_start_non_fr_change_in_required_module_blocks(): req_module = FakeModule( "req", - ["R1 modified"], - stored_frs=["R1"], + _frs(("req-a", "new a")), + stored_frs=_frs("req-a"), current_non_fr_hash="v2", stored_non_fr_hash="v1", ) - top_module = FakeModule("top", ["T1"], stored_frs=["T1"]) + top_module = FakeModule("top", _frs("top-a"), stored_frs=_frs("top-a")) top_module.required_modules = [req_module] - result = determine_partial_render_start(top_module) - assert result is None + assert determine_partial_render_start(top_module) is None diff --git a/tests/test_partial_rendering.py b/tests/test_partial_rendering.py index 49247a8a..18d3a9f8 100644 --- a/tests/test_partial_rendering.py +++ b/tests/test_partial_rendering.py @@ -12,6 +12,7 @@ import pytest import partial_rendering +import plain_spec from change_detection import PartialRenderStart from partial_rendering import ( PlainModuleRenderState, @@ -25,6 +26,13 @@ from plain2code_exceptions import ModuleDoesNotExistError +def _tree(*frids: str) -> dict: + """A plain source tree holding the given func-ids, in order.""" + return { + plain_spec.FUNCTIONAL_REQUIREMENTS: [{"id": frid, "markdown": f"The {frid} functionality."} for frid in frids] + } + + class FakeModule: def __init__( self, @@ -37,7 +45,7 @@ def __init__( no_rendered_functionality=False, fully_rendered=False, next_frid=None, - plain_source="", + frids=("fr-a", "fr-b", "fr-c", "fr-d", "fr-e"), ): self.module_name = module_name self.required_modules = list(required_modules or []) @@ -48,7 +56,7 @@ def __init__( self._no_rendered_functionality = no_rendered_functionality self._fully_rendered = fully_rendered self._next_frid = next_frid - self.plain_source = plain_source + self.plain_source = _tree(*frids) @property def all_required_modules(self): @@ -380,21 +388,21 @@ def test_render_choices_no_change_unrendered_module_offers_module_start(): def test_render_choices_no_change_interrupted_module_offers_continue(monkeypatch): root, middle, _ = _build_choices_tree() - root._next_frid = ("2", middle) - monkeypatch.setattr(partial_rendering.plain_spec, "get_render_range_from", lambda frid, source: ["2", "3"]) - pr = PlainModuleRenderState(last_render_module=middle, last_render_frid="1") + root._next_frid = ("fr-b", middle) + monkeypatch.setattr(partial_rendering.plain_spec, "get_render_range_from", lambda frid, source: ["fr-b", "fr-c"]) + pr = PlainModuleRenderState(last_render_module=middle, last_render_frid="fr-a") choices = get_render_choices(root, pr) assert _choice_types(choices) == ["continue_from_frid", "quit"] assert choices["1"].module is middle - assert choices["1"].render_range == ["2", "3"] + assert choices["1"].render_range == ["fr-b", "fr-c"] def test_render_choices_no_change_fully_rendered_required_module_starts_next(): root, middle, _ = _build_choices_tree() middle._fully_rendered = True - pr = PlainModuleRenderState(last_render_module=middle, last_render_frid="9") + pr = PlainModuleRenderState(last_render_module=middle, last_render_frid="fr-e") choices = get_render_choices(root, pr) @@ -405,7 +413,7 @@ def test_render_choices_no_change_fully_rendered_required_module_starts_next(): def test_render_choices_no_change_fully_rendered_top_module_rerenders_self(): root, _, _ = _build_choices_tree(fully_rendered=True) - pr = PlainModuleRenderState(last_render_module=root, last_render_frid="9") + pr = PlainModuleRenderState(last_render_module=root, last_render_frid="fr-e") choices = get_render_choices(root, pr) @@ -423,10 +431,12 @@ def test_render_choices_spec_change_in_first_module_offers_partial_and_full_rest leaf already is the first module.""" root, _, leaf = _build_choices_tree(fully_rendered=True) monkeypatch.setattr( - partial_rendering, "determine_partial_render_start", lambda pm: PartialRenderStart(module=leaf, frid="1") + partial_rendering, "determine_partial_render_start", lambda pm: PartialRenderStart(module=leaf, frid="fr-a") + ) + monkeypatch.setattr(partial_rendering.plain_spec, "get_render_range_from", lambda frid, source: ["fr-a", "fr-b"]) + pr = PlainModuleRenderState( + last_render_module=root, last_render_frid="fr-e", change=leaf, change_type="spec_change" ) - monkeypatch.setattr(partial_rendering.plain_spec, "get_render_range_from", lambda frid, source: ["1", "2"]) - pr = PlainModuleRenderState(last_render_module=root, last_render_frid="9", change=leaf, change_type="spec_change") choices = get_render_choices(root, pr) @@ -435,7 +445,7 @@ def test_render_choices_spec_change_in_first_module_offers_partial_and_full_rest assert "continue_from_frid" not in _choice_types(choices) assert _choice_types(choices) == ["render_from_change", "rerender_affected", "quit"] assert choices["1"].module is leaf - assert choices["1"].render_range == ["1", "2"] + assert choices["1"].render_range == ["fr-a", "fr-b"] assert choices["2"].module is leaf @@ -444,10 +454,12 @@ def test_render_choices_spec_change_in_top_module_offers_partial_restart_and_ful scratch, and (3) rebuild everything from the first module — plus quit.""" root, _, leaf = _build_choices_tree(fully_rendered=True) monkeypatch.setattr( - partial_rendering, "determine_partial_render_start", lambda pm: PartialRenderStart(module=root, frid="3") + partial_rendering, "determine_partial_render_start", lambda pm: PartialRenderStart(module=root, frid="fr-c") + ) + monkeypatch.setattr(partial_rendering.plain_spec, "get_render_range_from", lambda frid, source: ["fr-c", "fr-d"]) + pr = PlainModuleRenderState( + last_render_module=root, last_render_frid="fr-e", change=root, change_type="spec_change" ) - monkeypatch.setattr(partial_rendering.plain_spec, "get_render_range_from", lambda frid, source: ["3", "4"]) - pr = PlainModuleRenderState(last_render_module=root, last_render_frid="9", change=root, change_type="spec_change") choices = get_render_choices(root, pr) @@ -456,7 +468,7 @@ def test_render_choices_spec_change_in_top_module_offers_partial_restart_and_ful assert _choice_types(choices) == ["render_from_change", "rerender_affected", "rerender_from_first", "quit"] # (1) partial render from the changed functionality in C assert choices["1"].module is root - assert choices["1"].render_range == ["3", "4"] + assert choices["1"].render_range == ["fr-c", "fr-d"] # (2) re-render the affected module (C) from scratch assert choices["2"].module is root assert choices["2"].render_range is None @@ -467,7 +479,9 @@ def test_render_choices_spec_change_in_top_module_offers_partial_restart_and_ful def test_render_choices_code_change_suppresses_resume_and_offers_rerender_affected(): root, _, leaf = _build_choices_tree(fully_rendered=True) - pr = PlainModuleRenderState(last_render_module=root, last_render_frid="9", change=leaf, change_type="code_change") + pr = PlainModuleRenderState( + last_render_module=root, last_render_frid="fr-e", change=leaf, change_type="code_change" + ) choices = get_render_choices(root, pr) @@ -482,7 +496,9 @@ def test_render_choices_spec_change_no_partial_start_falls_back_to_full_rerender affected module and the first module are offered for a full re-render.""" root, middle, leaf = _build_choices_tree(fully_rendered=True) monkeypatch.setattr(partial_rendering, "determine_partial_render_start", lambda pm: None) - pr = PlainModuleRenderState(last_render_module=root, last_render_frid="9", change=middle, change_type="spec_change") + pr = PlainModuleRenderState( + last_render_module=root, last_render_frid="fr-e", change=middle, change_type="spec_change" + ) choices = get_render_choices(root, pr) @@ -497,18 +513,20 @@ def test_render_choices_appended_after_render_frontier_auto_continues(monkeypatc """A new functionality appended after the last-rendered one is pure future work: offer only a non-destructive continue, so the renderer resumes without prompting the user.""" root, _, _ = _build_choices_tree() # not fully rendered: a new FR sits past the frontier - root._next_frid = ("3", root) + root._next_frid = ("fr-c", root) monkeypatch.setattr( - partial_rendering, "determine_partial_render_start", lambda pm: PartialRenderStart(module=root, frid="3") + partial_rendering, "determine_partial_render_start", lambda pm: PartialRenderStart(module=root, frid="fr-c") + ) + monkeypatch.setattr(partial_rendering.plain_spec, "get_render_range_from", lambda frid, source: ["fr-c"]) + pr = PlainModuleRenderState( + last_render_module=root, last_render_frid="fr-b", change=root, change_type="spec_change" ) - monkeypatch.setattr(partial_rendering.plain_spec, "get_render_range_from", lambda frid, source: ["3"]) - pr = PlainModuleRenderState(last_render_module=root, last_render_frid="2", change=root, change_type="spec_change") choices = get_render_choices(root, pr) assert _choice_types(choices) == ["continue_from_frid", "quit"] assert choices["1"].module is root - assert choices["1"].render_range == ["3"] + assert choices["1"].render_range == ["fr-c"] assert choices["1"].is_destructive is False @@ -517,10 +535,12 @@ def test_render_choices_appended_in_required_module_still_prompts(monkeypatch): before the already-rendered top module, so a decision is still required.""" root, middle, leaf = _build_choices_tree(fully_rendered=True) monkeypatch.setattr( - partial_rendering, "determine_partial_render_start", lambda pm: PartialRenderStart(module=middle, frid="3") + partial_rendering, "determine_partial_render_start", lambda pm: PartialRenderStart(module=middle, frid="fr-c") + ) + monkeypatch.setattr(partial_rendering.plain_spec, "get_render_range_from", lambda frid, source: ["fr-c"]) + pr = PlainModuleRenderState( + last_render_module=root, last_render_frid="fr-e", change=middle, change_type="spec_change" ) - monkeypatch.setattr(partial_rendering.plain_spec, "get_render_range_from", lambda frid, source: ["3"]) - pr = PlainModuleRenderState(last_render_module=root, last_render_frid="9", change=middle, change_type="spec_change") choices = get_render_choices(root, pr) @@ -531,7 +551,7 @@ def test_render_choices_appended_in_required_module_still_prompts(monkeypatch): def test_render_choices_always_ends_with_quit(): root, _, _ = _build_choices_tree(fully_rendered=True) - pr = PlainModuleRenderState(last_render_module=root, last_render_frid="9") + pr = PlainModuleRenderState(last_render_module=root, last_render_frid="fr-e") choices = get_render_choices(root, pr) @@ -547,25 +567,31 @@ def test_render_choices_always_ends_with_quit(): def test_change_is_only_future_work_true_for_append_past_frontier(): root, _, _ = _build_choices_tree() - pr = PlainModuleRenderState(last_render_module=root, last_render_frid="2", change=root, change_type="spec_change") - assert change_is_only_future_work(root, pr, PartialRenderStart(module=root, frid="3")) is True + pr = PlainModuleRenderState( + last_render_module=root, last_render_frid="fr-b", change=root, change_type="spec_change" + ) + assert change_is_only_future_work(root, pr, PartialRenderStart(module=root, frid="fr-c")) is True def test_change_is_only_future_work_false_for_change_at_or_before_frontier(): root, _, _ = _build_choices_tree() - pr = PlainModuleRenderState(last_render_module=root, last_render_frid="3", change=root, change_type="spec_change") + pr = PlainModuleRenderState( + last_render_module=root, last_render_frid="fr-c", change=root, change_type="spec_change" + ) # Same frid (the changed functionality was already rendered) is not strictly after. - assert change_is_only_future_work(root, pr, PartialRenderStart(module=root, frid="3")) is False - assert change_is_only_future_work(root, pr, PartialRenderStart(module=root, frid="2")) is False + assert change_is_only_future_work(root, pr, PartialRenderStart(module=root, frid="fr-c")) is False + assert change_is_only_future_work(root, pr, PartialRenderStart(module=root, frid="fr-b")) is False def test_change_is_only_future_work_false_for_change_in_earlier_module(): root, middle, _ = _build_choices_tree() - pr = PlainModuleRenderState(last_render_module=root, last_render_frid="9", change=middle, change_type="spec_change") - assert change_is_only_future_work(root, pr, PartialRenderStart(module=middle, frid="3")) is False + pr = PlainModuleRenderState( + last_render_module=root, last_render_frid="fr-e", change=middle, change_type="spec_change" + ) + assert change_is_only_future_work(root, pr, PartialRenderStart(module=middle, frid="fr-c")) is False def test_change_is_only_future_work_false_when_no_partial_start(): root, _, _ = _build_choices_tree() - pr = PlainModuleRenderState(last_render_module=root, last_render_frid="9") + pr = PlainModuleRenderState(last_render_module=root, last_render_frid="fr-e") assert change_is_only_future_work(root, pr, None) is False diff --git a/tests/test_plain_modules.py b/tests/test_plain_modules.py index b3dff68c..2187a289 100644 --- a/tests/test_plain_modules.py +++ b/tests/test_plain_modules.py @@ -55,6 +55,11 @@ def code_var_module(fixtures_dir, tmp_build_folder): return PlainModule("pr_code_var.plain", tmp_build_folder, [fixtures_dir]) +def _stored(*frids: str) -> list[dict[str, str]]: + """Metadata entries for the given func-ids, as the module stores them.""" + return [{"id": frid, "markdown": f"{frid} markdown."} for frid in frids] + + def _write_metadata(module: PlainModule, metadata: dict) -> None: folder = module.get_codeplain_folder() os.makedirs(folder, exist_ok=True) @@ -220,24 +225,23 @@ def test_get_next_module_raises_when_module_not_found(root_module): def test_get_next_frid_within_same_module(root_module): - # Each fixture module has FRIDs ["1", "2"]. - next_frid, next_module = root_module.get_next_frid("1", "pr_leaf") - assert next_frid == "2" + next_frid, next_module = root_module.get_next_frid("leaf-one", "pr_leaf") + assert next_frid == "leaf-two" assert next_module.module_name == "pr_leaf" def test_get_next_frid_crosses_module_boundary(root_module): # After pr_leaf's last FRID, progress to pr_middle's first. - next_frid, next_module = root_module.get_next_frid("2", "pr_leaf") + next_frid, next_module = root_module.get_next_frid("leaf-two", "pr_leaf") assert next_module.module_name == "pr_middle" - assert next_frid == "1" + assert next_frid == "middle-one" def test_get_next_frid_from_last_required_module_progresses_to_root(root_module): # After pr_middle's last FRID, progress to the root's first FRID. - next_frid, next_module = root_module.get_next_frid("2", "pr_middle") + next_frid, next_module = root_module.get_next_frid("middle-two", "pr_middle") assert next_module is root_module - assert next_frid == "1" + assert next_frid == "root-one" # -------------------------------------------------------------------------- @@ -293,13 +297,13 @@ def test_is_module_fully_rendered_false_when_nothing_rendered(solo_module): def test_is_module_fully_rendered_false_when_only_first_frid_rendered(solo_module): - _init_build_repo_with_finished_frid(solo_module, "1") + _init_build_repo_with_finished_frid(solo_module, "solo-one") assert solo_module.is_module_fully_rendered() is False def test_is_module_fully_rendered_true_when_last_frid_rendered(solo_module): - # solo module has FRIDs ["1", "2", "3"]; "3" is the last. - _init_build_repo_with_finished_frid(solo_module, "3") + # solo module has FRIDs ["solo-one", "solo-two", "solo-three"]; the last one is "solo-three". + _init_build_repo_with_finished_frid(solo_module, "solo-three") assert solo_module.is_module_fully_rendered() is True @@ -314,16 +318,16 @@ def test_update_frid_stores_raw_markdown_not_rendered_text(code_var_module): rendered text (code variable substituted) would make every later diff report a spurious edit for that FRID.""" raw = code_var_module._get_module_functional_requirements() - # Sanity: FRID 2 really does carry an unsubstituted placeholder. - assert "{{ variable_name }}" in raw[1] + # Sanity: implement-configurable really does carry an unsubstituted placeholder. + assert "{{ variable_name }}" in raw[1]["markdown"] - code_var_module.update_frid_in_module_metadata("1") - code_var_module.update_frid_in_module_metadata("2") + code_var_module.update_frid_in_module_metadata("plain-one") + code_var_module.update_frid_in_module_metadata("implement-configurable") metadata = code_var_module.load_module_metadata() assert metadata["functionalities"] == raw # The substituted value must NOT leak into the stored baseline. - assert "configurable-value" not in metadata["functionalities"][1] + assert "configurable-value" not in metadata["functionalities"][1]["markdown"] def test_code_variable_frid_not_flagged_as_change(code_var_module): @@ -336,8 +340,8 @@ def test_code_variable_frid_not_flagged_as_change(code_var_module): code_var_module, {"non_functional_source_hash": code_var_module.get_module_non_functional_source_hash()}, ) - code_var_module.update_frid_in_module_metadata("1") - code_var_module.update_frid_in_module_metadata("2") + code_var_module.update_frid_in_module_metadata("plain-one") + code_var_module.update_frid_in_module_metadata("implement-configurable") assert determine_partial_render_start(code_var_module) is None @@ -651,26 +655,28 @@ def test_seed_module_metadata_overwrites_stale_functionalities(solo_module): def test_truncate_metadata_functionalities_no_metadata_is_noop(solo_module): - solo_module.truncate_metadata_functionalities("1") + solo_module.truncate_metadata_functionalities("solo-one") assert solo_module.load_module_metadata() is None def test_truncate_metadata_functionalities_shortens_list_and_keeps_hashes(solo_module): - _write_metadata(solo_module, {"source_hash": "abc", "functionalities": ["fr1", "fr2", "fr3"]}) - solo_module.truncate_metadata_functionalities("1") + _write_metadata( + solo_module, {"source_hash": "abc", "functionalities": _stored("solo-one", "solo-two", "solo-three")} + ) + solo_module.truncate_metadata_functionalities("solo-one") metadata = solo_module.load_module_metadata() - assert metadata["functionalities"] == ["fr1"] + assert metadata["functionalities"] == _stored("solo-one") assert metadata["source_hash"] == "abc" def test_truncate_metadata_functionalities_noop_when_list_short_enough(solo_module): - _write_metadata(solo_module, {"functionalities": ["fr1"]}) - solo_module.truncate_metadata_functionalities("2") - assert solo_module.load_module_metadata()["functionalities"] == ["fr1"] + _write_metadata(solo_module, {"functionalities": _stored("solo-one")}) + solo_module.truncate_metadata_functionalities("solo-two") + assert solo_module.load_module_metadata()["functionalities"] == _stored("solo-one") def test_truncate_metadata_functionalities_none_frid_empties_list(solo_module): - _write_metadata(solo_module, {"functionalities": ["fr1", "fr2"]}) + _write_metadata(solo_module, {"functionalities": _stored("solo-one", "solo-two")}) solo_module.truncate_metadata_functionalities(None) assert solo_module.load_module_metadata()["functionalities"] == [] @@ -694,16 +700,16 @@ def _commit_finished_frid(module: PlainModule, frid: str) -> None: def test_revert_code_to_frid_reverts_repo_and_trims_metadata(solo_module): os.makedirs(solo_module.module_build_folder) init_git_repo(solo_module.module_build_folder, module_name=solo_module.module_name) - _commit_finished_frid(solo_module, "1") - _commit_finished_frid(solo_module, "2") - _write_metadata(solo_module, {"source_hash": "abc", "functionalities": ["fr1", "fr2"]}) + _commit_finished_frid(solo_module, "solo-one") + _commit_finished_frid(solo_module, "solo-two") + _write_metadata(solo_module, {"source_hash": "abc", "functionalities": _stored("solo-one", "solo-two")}) - solo_module.revert_code_to_frid("1") + solo_module.revert_code_to_frid("solo-one") - assert os.path.exists(os.path.join(solo_module.module_build_folder, "frid_1.txt")) - assert not os.path.exists(os.path.join(solo_module.module_build_folder, "frid_2.txt")) + assert os.path.exists(os.path.join(solo_module.module_build_folder, "frid_solo-one.txt")) + assert not os.path.exists(os.path.join(solo_module.module_build_folder, "frid_solo-two.txt")) metadata = solo_module.load_module_metadata() - assert metadata["functionalities"] == ["fr1"] + assert metadata["functionalities"] == _stored("solo-one") assert metadata["source_hash"] == "abc" # The metadata folder lives outside the code repo and must survive the revert. assert os.path.exists(solo_module.get_codeplain_folder()) @@ -712,12 +718,12 @@ def test_revert_code_to_frid_reverts_repo_and_trims_metadata(solo_module): def test_revert_code_to_frid_none_reverts_to_initial_state(solo_module): os.makedirs(solo_module.module_build_folder) init_git_repo(solo_module.module_build_folder, module_name=solo_module.module_name) - _commit_finished_frid(solo_module, "1") - _write_metadata(solo_module, {"functionalities": ["fr1"]}) + _commit_finished_frid(solo_module, "solo-one") + _write_metadata(solo_module, {"functionalities": _stored("solo-one")}) solo_module.revert_code_to_frid(None) - assert not os.path.exists(os.path.join(solo_module.module_build_folder, "frid_1.txt")) + assert not os.path.exists(os.path.join(solo_module.module_build_folder, "frid_solo-one.txt")) assert solo_module.load_module_metadata()["functionalities"] == [] @@ -730,13 +736,13 @@ def test_reconcile_metadata_with_git_trims_metadata_ahead_of_git(solo_module): # Simulates the crash window: metadata records FR 2 but git only committed FR 1. os.makedirs(solo_module.module_build_folder) init_git_repo(solo_module.module_build_folder, module_name=solo_module.module_name) - _commit_finished_frid(solo_module, "1") - _write_metadata(solo_module, {"source_hash": "abc", "functionalities": ["fr1", "fr2"]}) + _commit_finished_frid(solo_module, "solo-one") + _write_metadata(solo_module, {"source_hash": "abc", "functionalities": _stored("solo-one", "solo-two")}) solo_module.reconcile_metadata_with_git() metadata = solo_module.load_module_metadata() - assert metadata["functionalities"] == ["fr1"] + assert metadata["functionalities"] == _stored("solo-one") # Only the functionalities baseline is trimmed; the hashes are left untouched. assert metadata["source_hash"] == "abc" @@ -744,20 +750,20 @@ def test_reconcile_metadata_with_git_trims_metadata_ahead_of_git(solo_module): def test_reconcile_metadata_with_git_in_sync_is_noop(solo_module): os.makedirs(solo_module.module_build_folder) init_git_repo(solo_module.module_build_folder, module_name=solo_module.module_name) - _commit_finished_frid(solo_module, "1") - _commit_finished_frid(solo_module, "2") - _write_metadata(solo_module, {"functionalities": ["fr1", "fr2"]}) + _commit_finished_frid(solo_module, "solo-one") + _commit_finished_frid(solo_module, "solo-two") + _write_metadata(solo_module, {"functionalities": _stored("solo-one", "solo-two")}) solo_module.reconcile_metadata_with_git() - assert solo_module.load_module_metadata()["functionalities"] == ["fr1", "fr2"] + assert solo_module.load_module_metadata()["functionalities"] == _stored("solo-one", "solo-two") def test_reconcile_metadata_with_git_no_finished_frid_empties_list(solo_module): # Only the initial commit exists (no finished FRID), so the baseline must be emptied. os.makedirs(solo_module.module_build_folder) init_git_repo(solo_module.module_build_folder, module_name=solo_module.module_name) - _write_metadata(solo_module, {"functionalities": ["fr1"]}) + _write_metadata(solo_module, {"functionalities": _stored("solo-one")}) solo_module.reconcile_metadata_with_git() @@ -777,7 +783,7 @@ def test_reconcile_metadata_with_git_ignores_foreign_module_frid(solo_module): module_name="other-module", frid="1", ) - _write_metadata(solo_module, {"functionalities": ["fr1"]}) + _write_metadata(solo_module, {"functionalities": _stored("solo-one")}) solo_module.reconcile_metadata_with_git() From 02ee1c3cf73860a19f192539b2da4da9aa5a17fe Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Mon, 26 Oct 2026 08:08:33 +0100 Subject: [PATCH 06/23] test: use func-ids in the git and journal tests --- tests/test_conformance_fix_journal.py | 92 +++++++++++++-------------- tests/test_git_utils.py | 26 ++++---- 2 files changed, 59 insertions(+), 59 deletions(-) diff --git a/tests/test_conformance_fix_journal.py b/tests/test_conformance_fix_journal.py index 9c470351..583103d8 100644 --- a/tests/test_conformance_fix_journal.py +++ b/tests/test_conformance_fix_journal.py @@ -23,7 +23,7 @@ def journal(memory_folder): def record_full_attempt( journal, module="mod", - frid="1", + frid="add-task", attempt=1, diff=None, summary=None, @@ -63,19 +63,19 @@ def test_empty_diff_has_no_hash(self): class TestJournalEntries: def test_attempt_lifecycle_is_recorded(self, journal): - journal.open_attempt("mod", "1", 1, "raw failure output") + journal.open_attempt("mod", "add-task", 1, "raw failure output") journal.record_fix( "mod", - "1", + "add-task", {"hypothesis": "the root cause", "approach": "the fix"}, ["src/app.py"], "IMPLEMENTATION_CODE", {"src/app.py": "diff"}, "the issue as the fixer saw it", ) - journal.record_result("mod", "1", passed=False) + journal.record_result("mod", "add-task", passed=False) - [entry] = journal._read_entries("mod", "1") + [entry] = journal._read_entries("mod", "add-task") assert entry["attempt"] == 1 assert entry["issue_before_raw"] == "raw failure output" assert entry["issue_before"] == "the issue as the fixer saw it" @@ -87,36 +87,36 @@ def test_attempt_lifecycle_is_recorded(self, journal): def test_raw_issue_is_stored_untruncated(self, journal): long_issue = "x" * (MAX_ISSUE_EXCERPT_CHARS * 3) + "THE END" - journal.open_attempt("mod", "1", 1, long_issue) + journal.open_attempt("mod", "add-task", 1, long_issue) - [entry] = journal._read_entries("mod", "1") + [entry] = journal._read_entries("mod", "add-task") assert entry["issue_before_raw"] == long_issue def test_a_passing_result_is_recorded(self, journal): record_full_attempt(journal) - journal.record_result("mod", "1", passed=True) + journal.record_result("mod", "add-task", passed=True) - [entry] = journal._read_entries("mod", "1") + [entry] = journal._read_entries("mod", "add-task") assert entry["result"] == "conformance tests passed" def test_record_result_without_a_pending_attempt_does_nothing(self, journal): - journal.record_result("mod", "1", passed=False) + journal.record_result("mod", "add-task", passed=False) - assert journal._read_entries("mod", "1") == [] + assert journal._read_entries("mod", "add-task") == [] def test_record_result_does_not_overwrite_a_completed_attempt(self, journal): record_full_attempt(journal) - journal.record_result("mod", "1", passed=False) - journal.record_result("mod", "1", passed=True) + journal.record_result("mod", "add-task", passed=False) + journal.record_result("mod", "add-task", passed=True) - [entry] = journal._read_entries("mod", "1") + [entry] = journal._read_entries("mod", "add-task") assert entry["result"] == "conformance tests still failed" def test_missing_summary_and_prepared_issue_leave_the_entry_without_them(self, journal): - journal.open_attempt("mod", "1", 1, "failure") - journal.record_fix("mod", "1", None, ["src/app.py"], "IMPLEMENTATION_CODE", {"src/app.py": "diff"}, None) + journal.open_attempt("mod", "add-task", 1, "failure") + journal.record_fix("mod", "add-task", None, ["src/app.py"], "IMPLEMENTATION_CODE", {"src/app.py": "diff"}, None) - [entry] = journal._read_entries("mod", "1") + [entry] = journal._read_entries("mod", "add-task") assert "hypothesis" not in entry assert "approach" not in entry assert "issue_before" not in entry @@ -126,37 +126,37 @@ class TestDuplicateDetection: def test_a_repeated_diff_is_marked_as_a_duplicate(self, journal): diff = {"src/app.py": "+ same change"} record_full_attempt(journal, attempt=1, diff=diff) - journal.record_result("mod", "1", passed=False) + journal.record_result("mod", "add-task", passed=False) record_full_attempt(journal, attempt=2, diff={"src/app.py": "+ same change "}) - entries = journal._read_entries("mod", "1") + entries = journal._read_entries("mod", "add-task") assert "duplicate_of" not in entries[0] assert entries[1]["duplicate_of"] == 1 def test_a_different_diff_is_not_marked(self, journal): record_full_attempt(journal, attempt=1, diff={"src/app.py": "+ change one"}) - journal.record_result("mod", "1", passed=False) + journal.record_result("mod", "add-task", passed=False) record_full_attempt(journal, attempt=2, diff={"src/app.py": "+ change two"}) - entries = journal._read_entries("mod", "1") + entries = journal._read_entries("mod", "add-task") assert "duplicate_of" not in entries[1] def test_attempts_without_a_diff_are_never_duplicates(self, journal): record_full_attempt(journal, attempt=1, diff={}, files=[]) - journal.record_result("mod", "1", passed=False) + journal.record_result("mod", "add-task", passed=False) record_full_attempt(journal, attempt=2, diff={}, files=[]) - entries = journal._read_entries("mod", "1") + entries = journal._read_entries("mod", "add-task") assert "duplicate_of" not in entries[1] class TestDigest: def test_digest_carries_the_compact_history_with_prepared_issues(self, journal): record_full_attempt(journal, attempt=1, prepared_issue="issue as fixer saw it 1") - journal.record_result("mod", "1", passed=False) + journal.record_result("mod", "add-task", passed=False) record_full_attempt(journal, attempt=2, prepared_issue="issue as fixer saw it 2") - digest = journal.build_digest("mod", "1") + digest = journal.build_digest("mod", "add-task") assert [entry["attempt"] for entry in digest] == [1, 2] assert digest[0]["result"] == "conformance tests still failed" @@ -173,76 +173,76 @@ def test_digest_issue_is_capped(self, journal): long_prepared = "x" * (MAX_ISSUE_EXCERPT_CHARS + 100) + "THE END" record_full_attempt(journal, prepared_issue=long_prepared) - [entry] = journal.build_digest("mod", "1") + [entry] = journal.build_digest("mod", "add-task") assert entry["issue"].endswith("THE END") assert len(entry["issue"]) < len(long_prepared) def test_digest_of_an_attempt_without_a_prepared_issue_has_no_issue(self, journal): record_full_attempt(journal, prepared_issue=None) - [entry] = journal.build_digest("mod", "1") + [entry] = journal.build_digest("mod", "add-task") assert "issue" not in entry def test_the_open_attempt_is_excluded_from_the_digest(self, journal): """The entry opened for the attempt about to be made carries nothing yet - the fixer must not see an empty attempt in its history.""" record_full_attempt(journal, attempt=1) - journal.record_result("mod", "1", passed=False) - journal.open_attempt("mod", "1", 2, "current failure output") + journal.record_result("mod", "add-task", passed=False) + journal.open_attempt("mod", "add-task", 2, "current failure output") - digest = journal.build_digest("mod", "1") + digest = journal.build_digest("mod", "add-task") assert [entry["attempt"] for entry in digest] == [1] def test_a_dangling_attempt_without_a_fix_is_excluded_from_the_digest(self, journal): """An interrupted render can leave an opened entry that later gets a result but never a fix.""" - journal.open_attempt("mod", "1", 1, "failure output") - journal.record_result("mod", "1", passed=False) + journal.open_attempt("mod", "add-task", 1, "failure output") + journal.record_result("mod", "add-task", passed=False) - assert journal.build_digest("mod", "1") == [] + assert journal.build_digest("mod", "add-task") == [] def test_digest_of_an_unknown_frid_is_empty(self, journal): - assert journal.build_digest("mod", "99") == [] + assert journal.build_digest("mod", "no-such-task") == [] class TestCollectAndClear: def test_collect_all_groups_by_module_and_frid(self, journal): - record_full_attempt(journal, module="mod", frid="1") - record_full_attempt(journal, module="other_module", frid="2.1") + record_full_attempt(journal, module="mod", frid="add-task") + record_full_attempt(journal, module="other_module", frid="list-tasks") journals = journal.collect_all() - assert [(j["module"], j["frid"]) for j in journals] == [("mod", "1"), ("other_module", "2.1")] + assert [(j["module"], j["frid"]) for j in journals] == [("mod", "add-task"), ("other_module", "list-tasks")] assert journals[0]["initial_issue"] == "prepared issue" assert journals[0]["attempts"][0]["hypothesis"] == "hypothesis 1" def test_initial_issue_falls_back_to_the_trimmed_raw_output(self, journal): long_raw = "x" * (MAX_ISSUE_EXCERPT_CHARS + 100) + "THE END" - journal.open_attempt("mod", "1", 1, long_raw) - journal.record_fix("mod", "1", None, [], "IMPLEMENTATION_CODE", None, None) + journal.open_attempt("mod", "add-task", 1, long_raw) + journal.record_fix("mod", "add-task", None, [], "IMPLEMENTATION_CODE", None, None) [collected] = journal.collect_all() assert collected["initial_issue"].endswith("THE END") assert len(collected["initial_issue"]) < len(long_raw) def test_module_names_containing_double_underscores_are_parsed(self, journal): - record_full_attempt(journal, module="my__module", frid="3") + record_full_attempt(journal, module="my__module", frid="rename-task") [collected] = journal.collect_all() assert collected["module"] == "my__module" - assert collected["frid"] == "3" + assert collected["frid"] == "rename-task" def test_collect_all_without_journals_is_empty(self, journal): assert journal.collect_all() == [] def test_collect_all_skips_a_journal_holding_only_an_open_attempt(self, journal): - journal.open_attempt("mod", "1", 1, "failure output") + journal.open_attempt("mod", "add-task", 1, "failure output") assert journal.collect_all() == [] def test_clear_all_removes_every_journal(self, journal): - record_full_attempt(journal, module="mod", frid="1") - record_full_attempt(journal, module="mod", frid="2") + record_full_attempt(journal, module="mod", frid="add-task") + record_full_attempt(journal, module="mod", frid="list-tasks") journal.clear_all() @@ -251,9 +251,9 @@ def test_clear_all_removes_every_journal(self, journal): def test_malformed_lines_are_skipped(self, journal, memory_folder): record_full_attempt(journal) - with open(journal._journal_path("mod", "1"), "a") as journal_file: + with open(journal._journal_path("mod", "add-task"), "a") as journal_file: journal_file.write("not json\n") journal_file.write(json.dumps({"attempt": 2, "issue_before_raw": "x"}) + "\n") - entries = journal._read_entries("mod", "1") + entries = journal._read_entries("mod", "add-task") assert [entry["attempt"] for entry in entries] == [1, 2] diff --git a/tests/test_git_utils.py b/tests/test_git_utils.py index dac3675d..a234daa9 100644 --- a/tests/test_git_utils.py +++ b/tests/test_git_utils.py @@ -455,20 +455,20 @@ def test_get_last_finished_frid_returns_latest(empty_repo): file_path.write_text("v1") add_all_files_and_commit( empty_repo, - FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("1"), + FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("add-task"), module_name="module_a", - frid="1", + frid="add-task", ) file_path.write_text("v2") add_all_files_and_commit( empty_repo, - FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("2"), + FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("list-tasks"), module_name="module_a", - frid="2", + frid="list-tasks", ) - assert get_last_rendered_functionality(empty_repo) == ("module_a", "2") + assert get_last_rendered_functionality(empty_repo) == ("module_a", "list-tasks") def test_get_last_finished_frid_ignores_non_finished_commits(empty_repo): @@ -477,22 +477,22 @@ def test_get_last_finished_frid_ignores_non_finished_commits(empty_repo): file_path.write_text("v1") add_all_files_and_commit( empty_repo, - FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("1"), + FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("add-task"), module_name="module_a", - frid="1", + frid="add-task", ) # A refactor commit (not a finished-frid checkpoint) comes after. file_path.write_text("v2") add_all_files_and_commit( empty_repo, - REFACTORED_CODE_COMMIT_MESSAGE.format("2"), + REFACTORED_CODE_COMMIT_MESSAGE.format("list-tasks"), module_name="module_a", - frid="2", + frid="list-tasks", ) - # The last *finished* FRID is still 1 - assert get_last_rendered_functionality(empty_repo) == ("module_a", "1") + # The last *finished* FRID is still add-task + assert get_last_rendered_functionality(empty_repo) == ("module_a", "add-task") def test_get_last_finished_frid_without_module_name(empty_repo): @@ -501,9 +501,9 @@ def test_get_last_finished_frid_without_module_name(empty_repo): file_path.write_text("v1") add_all_files_and_commit( empty_repo, - FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("7"), + FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE.format("rename-task"), module_name=None, - frid="7", + frid="rename-task", ) with pytest.raises(InvalidGitRepositoryError, match="Could not find module name in finished commit"): From 092a931c48d99ac9d0f2f4f5d623f15cf24ff32a Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Mon, 26 Oct 2026 13:41:56 +0100 Subject: [PATCH 07/23] feat: send test scenarios to the backend in the spec tree --- codeplain_REST_api.py | 17 ++----- .../actions/fix_conformance_test.py | 2 - .../actions/render_conformance_tests.py | 30 +++++++----- render_machine/render_context.py | 9 ---- render_machine/render_types.py | 17 +------ tests/test_traceability_warnings.py | 46 +++++++++++++++++++ tui/state_handlers.py | 24 +++------- 7 files changed, 78 insertions(+), 67 deletions(-) create mode 100644 tests/test_traceability_warnings.py diff --git a/codeplain_REST_api.py b/codeplain_REST_api.py index 29a4a532..ec5c9e54 100644 --- a/codeplain_REST_api.py +++ b/codeplain_REST_api.py @@ -82,6 +82,7 @@ def new_session(): "MissingResource": plain2code_exceptions.MissingResource, "PlainSyntaxError": plain2code_exceptions.PlainSyntaxError, "InternalServerError": plain2code_exceptions.InternalServerError, + "OutdatedClient": plain2code_exceptions.OutdatedClientVersion, } @@ -363,7 +364,6 @@ def render_conformance_tests( required_modules, conformance_tests_folder_name, conformance_tests_json, - all_acceptance_tests, run_state: RunState, ): endpoint_url = f"{self.api_url}/render_conformance_tests" @@ -380,11 +380,10 @@ def render_conformance_tests( "required_modules": required_modules, "conformance_tests_folder_name": conformance_tests_folder_name, "conformance_tests_json": conformance_tests_json, - "all_acceptance_tests": all_acceptance_tests, } response = self.post_request(endpoint_url, headers, payload, run_state) - return response["patched_response_files"], response["conformance_tests_plan_summary_string"] + return response["patched_response_files"], response["traceability"] def generate_folder_name_from_functional_requirement( self, @@ -419,11 +418,9 @@ def fix_conformance_tests_issue( required_modules, code_diff, conformance_tests_files, - acceptance_tests, conformance_tests_issue, implementation_fix_count, conformance_tests_folder_name, - current_testing_frid_high_level_implementation_plan: Optional[str], conflicting_requirements_count: int, fix_attempts_journal: list, run_state: RunState, @@ -446,14 +443,10 @@ def fix_conformance_tests_issue( "conformance_tests_issue": conformance_tests_issue, "implementation_fix_count": implementation_fix_count, "conformance_tests_folder_name": conformance_tests_folder_name, - "current_testing_frid_high_level_implementation_plan": current_testing_frid_high_level_implementation_plan, "conflicting_requirements_count": conflicting_requirements_count, "fix_attempts_journal": fix_attempts_journal, } - if acceptance_tests is not None: - payload["acceptance_tests"] = acceptance_tests - return self.post_request(endpoint_url, headers, payload, run_state) def render_acceptance_tests( @@ -466,7 +459,7 @@ def render_acceptance_tests( conformance_tests_files, module_name: str, required_modules, - acceptance_test, + scenario_id, run_state: RunState, ): """ @@ -484,7 +477,7 @@ def render_acceptance_tests( conformance_tests_files (dict): A dictionary containing conformance test files. required_modules (dict): A dictionary where the keys represent module names and the values are lists of functionalities implemented in those modules. - acceptance_test (dict): A dictionary containing acceptance test information. + scenario_id (str): The scenario-id of the acceptance scenario to render. Returns: dict: The rendered acceptance tests. @@ -504,7 +497,7 @@ def render_acceptance_tests( "conformance_tests_files": conformance_tests_files, "module_name": module_name, "required_modules": required_modules, - "acceptance_test": acceptance_test, + "scenario_id": scenario_id, } return self.post_request(endpoint_url, headers, payload, run_state) diff --git a/render_machine/actions/fix_conformance_test.py b/render_machine/actions/fix_conformance_test.py index 73e5ca0e..4bc741e9 100644 --- a/render_machine/actions/fix_conformance_test.py +++ b/render_machine/actions/fix_conformance_test.py @@ -126,11 +126,9 @@ def execute(self, render_context: RenderContext, previous_action_payload: Any | render_context.get_required_modules_functionalities(), previous_frid_code_diff, existing_conformance_test_files_content, - render_context.conformance_tests_running_context.get_current_acceptance_tests(), previous_conformance_tests_issue, render_context.conformance_tests_running_context.fix_attempts, render_context.conformance_tests_running_context.get_current_conformance_test_folder_name(), - render_context.conformance_tests_running_context.current_testing_frid_high_level_implementation_plan, render_context.conformance_tests_running_context.conflicting_requirement_count, journal.build_digest(ctx.current_testing_module_name, ctx.current_testing_frid), run_state=render_context.run_state, diff --git a/render_machine/actions/render_conformance_tests.py b/render_machine/actions/render_conformance_tests.py index 178a9716..0bd3fb6a 100644 --- a/render_machine/actions/render_conformance_tests.py +++ b/render_machine/actions/render_conformance_tests.py @@ -109,9 +109,7 @@ def _render_conformance_tests(self, render_context: RenderContext): style=console.INPUT_STYLE, ) - all_acceptance_tests = render_context.frid_context.specifications.get(plain_spec.ACCEPTANCE_TESTS, []) - - response_files, implementation_plan_summary = render_context.codeplain_api.render_conformance_tests( + response_files, traceability = render_context.codeplain_api.render_conformance_tests( render_context.frid_context.frid, render_context.conformance_tests_running_context.current_testing_frid, render_context.plain_source_tree, @@ -124,13 +122,10 @@ def _render_conformance_tests(self, render_context: RenderContext): render_context.conformance_tests_running_context.get_conformance_tests_json( render_context.conformance_tests_running_context.current_testing_module_name ), - all_acceptance_tests, run_state=render_context.run_state, ) - render_context.conformance_tests_running_context.current_testing_frid_high_level_implementation_plan = ( - implementation_plan_summary - ) + self._warn_about_untraced_scenarios(render_context, traceability) file_utils.store_response_files(conformance_tests_folder_name, response_files, []) @@ -143,6 +138,19 @@ def _render_conformance_tests(self, render_context: RenderContext): return self.SUCCESSFUL_OUTCOME, None + @staticmethod + def _warn_about_untraced_scenarios(render_context: RenderContext, traceability: dict) -> None: + """Warn about scenarios with no test, and about tests that implement no scenario.""" + frid = render_context.conformance_tests_running_context.current_testing_frid + + missing = traceability.get("missing") or [] + if missing: + console.warning(f"No conformance test was generated for {frid} scenarios: {', '.join(missing)}.") + + extra = traceability.get("extra") or [] + if extra: + console.warning(f"Conformance tests of {frid} that implement no scenario: {', '.join(extra)}.") + def _render_acceptance_test(self, render_context: RenderContext): if plain_spec.ACCEPTANCE_TESTS not in render_context.frid_context.specifications: # If there are no acceptance tests defined, continue. @@ -160,12 +168,12 @@ def _render_acceptance_test(self, render_context: RenderContext): render_context.conformance_tests_running_context.get_current_conformance_test_folder_name(), ) - # Get the current acceptance test being rendered (completed count is 1-based) - acceptance_test = render_context.frid_context.specifications[plain_spec.ACCEPTANCE_TESTS][ + # Get the current acceptance scenario being rendered (completed count is 1-based) + scenario = render_context.frid_context.specifications[plain_spec.ACCEPTANCE_TESTS][ render_context.conformance_tests_running_context.acceptance_tests_completed - 1 ] - console.info(f"Generating acceptance test:\n {acceptance_test}") + console.info(f"Generating acceptance test for scenario {scenario['id']}.") response_files = render_context.codeplain_api.render_acceptance_tests( render_context.frid_context.frid, @@ -176,7 +184,7 @@ def _render_acceptance_test(self, render_context: RenderContext): conformance_tests_files_content, render_context.module_name, render_context.get_required_modules_functionalities(), - acceptance_test, + scenario["id"], run_state=render_context.run_state, ) conformance_tests_folder_name = ( diff --git a/render_machine/render_context.py b/render_machine/render_context.py index dc9407f0..c08f81f4 100644 --- a/render_machine/render_context.py +++ b/render_machine/render_context.py @@ -467,16 +467,7 @@ def _handle_current_frid_testing(self): if ctx.acceptance_test_phase == AcceptanceTestPhase.IN_PROGRESS: if self._has_more_acceptance_test_phases(): # Run next phase - if ctx.acceptance_tests_completed == 0: - ctx.current_testing_frid_high_level_implementation_plan = None - ctx.acceptance_tests_completed += 1 - acceptance_tests = self.frid_context.specifications[plain_spec.ACCEPTANCE_TESTS][ - : ctx.acceptance_tests_completed - ] - ctx.get_conformance_tests_json(ctx.current_testing_module_name)[ctx.current_testing_frid][ - plain_spec.ACCEPTANCE_TESTS - ] = acceptance_tests return else: # All phases done - move to regression diff --git a/render_machine/render_types.py b/render_machine/render_types.py index 4f1da4f2..6b3a2a83 100644 --- a/render_machine/render_types.py +++ b/render_machine/render_types.py @@ -132,8 +132,6 @@ def __init__( self.regenerating_conformance_tests: bool = False - self.current_testing_frid_high_level_implementation_plan: Optional[str] = None - # Implementation code changes made by the conformance tests fixer during this conformance phase, in # order. Each entry is {"hypothesis": str | None, "approach": str | None, "code_diff": {file: diff}}. # Handed to the unit tests fixer so it adjusts the unit tests instead of reverting these changes. @@ -160,19 +158,8 @@ def current_conformance_tests_exist(self) -> bool: self.get_conformance_tests_json(self.current_testing_module_name).get(self.current_testing_frid) is not None ) - def get_current_acceptance_tests(self) -> Optional[list[str]]: - if ( - plain_spec.ACCEPTANCE_TESTS - in self.get_conformance_tests_json(self.current_testing_module_name)[self.current_testing_frid] - ): - return self.get_conformance_tests_json(self.current_testing_module_name)[self.current_testing_frid][ - plain_spec.ACCEPTANCE_TESTS - ] - - return [] - - def get_current_acceptance_test(self) -> Optional[str]: - """Get the current acceptance test text (raw, unformatted).""" + def get_current_acceptance_scenario(self) -> Optional[dict]: + """The acceptance scenario being rendered, as the functionality's tree entry holds it.""" if plain_spec.ACCEPTANCE_TESTS not in self.current_testing_frid_specifications: return None acceptance_tests = self.current_testing_frid_specifications[plain_spec.ACCEPTANCE_TESTS] diff --git a/tests/test_traceability_warnings.py b/tests/test_traceability_warnings.py new file mode 100644 index 00000000..e6ad334b --- /dev/null +++ b/tests/test_traceability_warnings.py @@ -0,0 +1,46 @@ +"""The client warns about scenarios with no conformance test, and about tests that implement no scenario.""" + +from types import SimpleNamespace +from unittest.mock import patch + +import file_utils # noqa: F401 # imported before plain_modules, which it imports back from +from render_machine.actions.render_conformance_tests import RenderConformanceTests + + +def _render_context(frid: str = "add-task"): + return SimpleNamespace( + conformance_tests_running_context=SimpleNamespace(current_testing_frid=frid), + ) + + +def _warn(traceability: dict) -> list[str]: + with patch("render_machine.actions.render_conformance_tests.console") as console: + RenderConformanceTests._warn_about_untraced_scenarios(_render_context(), traceability) + return [call.args[0] for call in console.warning.call_args_list] + + +def test_every_scenario_implemented_warns_about_nothing(): + assert _warn({"implemented": {"add-first-task": ["test_add_first_task"]}, "missing": [], "extra": []}) == [] + + +def test_a_scenario_without_a_test_is_named(): + [warning] = _warn({"implemented": {}, "missing": ["add-second-task"], "extra": []}) + + assert "add-task" in warning + assert "add-second-task" in warning + + +def test_a_test_implementing_no_scenario_is_named(): + [warning] = _warn({"implemented": {}, "missing": [], "extra": ["test_something_else"]}) + + assert "test_something_else" in warning + + +def test_both_are_reported(): + warnings = _warn({"implemented": {}, "missing": ["add-second-task"], "extra": ["test_something_else"]}) + + assert len(warnings) == 2 + + +def test_a_response_without_the_lists_warns_about_nothing(): + assert _warn({}) == [] diff --git a/tui/state_handlers.py b/tui/state_handlers.py index d7d92d94..0bdb7d52 100644 --- a/tui/state_handlers.py +++ b/tui/state_handlers.py @@ -20,22 +20,6 @@ ) -def format_acceptance_test_text(raw_text: str | None) -> str: - """Format acceptance test text for display by removing list prefix if present. - - Args: - raw_text: The raw acceptance test text from specifications (can be None during state transitions) - - Returns: - Formatted text with "- " prefix removed if present, or empty string if None - """ - if raw_text is None: - return "" - if raw_text.startswith("- "): - return raw_text[2:] - return raw_text - - class StateHandler(ABC): """Abstract base class for state handlers that process state machine transitions.""" @@ -207,8 +191,12 @@ def handle(self, segments: list[str], snapshot: RenderContextSnapshot, previous_ [Substate(rendering_text)], ) else: - acceptance_test = snapshot.conformance_tests_running_context.get_current_acceptance_test() - acceptance_test_text = f"Rendering acceptance test: {format_acceptance_test_text(acceptance_test)}" # type: ignore + scenario = snapshot.conformance_tests_running_context.get_current_acceptance_scenario() + acceptance_test_text = ( + f"Rendering acceptance test for scenario {scenario['id']}" + if scenario + else "Rendering acceptance test" + ) update_progress_item_substates( self.tui, TUIComponents.FRID_PROGRESS_CONFORMANCE_TEST.value, From 62412ec2e5dd6567f0021febad43c8a9c234e393 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Mon, 26 Oct 2026 13:46:32 +0100 Subject: [PATCH 08/23] feat: warn when scenarios are found but conformance tests are off --- cli_output/dry_run.py | 10 ++-- plain2code.py | 35 ++++++++------ tests/test_plain2code.py | 80 +++++++++++++++---------------- tests/test_terminal_attachment.py | 2 +- 4 files changed, 68 insertions(+), 59 deletions(-) diff --git a/cli_output/dry_run.py b/cli_output/dry_run.py index 6e693ec4..b64af2c5 100644 --- a/cli_output/dry_run.py +++ b/cli_output/dry_run.py @@ -22,9 +22,13 @@ def print_dry_run_output(plain_source_tree: dict, render_range: Optional[list[st f"{functional_requirement_text}\n" "-------------------------------------\n" ) - if plain_spec.ACCEPTANCE_TESTS in specifications: - for i, acceptance_test in enumerate(specifications[plain_spec.ACCEPTANCE_TESTS], 1): - console.info(f"Generating acceptance test #{i}:\n\n{acceptance_test}\n") + for section, label in ( + (plain_spec.CONFORMANCE_TESTS, "Conformance scenarios"), + (plain_spec.ACCEPTANCE_TESTS, "Acceptance scenarios"), + ): + scenario_ids = [scenario["id"] for scenario in specifications.get(section) or []] + if scenario_ids: + console.info(f"{label}: {', '.join(scenario_ids)}\n") else: console.info( "-------------------------------------\n" diff --git a/plain2code.py b/plain2code.py index bbf75283..1ed245fe 100644 --- a/plain2code.py +++ b/plain2code.py @@ -272,29 +272,36 @@ def report_render_finished( pass -def warn_if_acceptance_tests_without_conformance_script(plain_module, args) -> None: - """Warn when any loaded module (including required modules) defines acceptance tests - but no conformance tests script is configured. +def _has_scenarios(plain_source_tree) -> bool: + """Whether a test plain file bound a scenario to any functionality of the module.""" + for functionality in plain_source_tree.get(plain_spec.FUNCTIONAL_REQUIREMENTS) or []: + if functionality.get(plain_spec.CONFORMANCE_TESTS) or functionality.get(plain_spec.ACCEPTANCE_TESTS): + return True + return False - Acceptance tests are treated as conformance tests, so a conformance tests script is required - to actually run them. Without it, the acceptance tests cannot be executed. + +def warn_if_scenarios_without_conformance_script(plain_module, args) -> None: + """Warn when a test plain file was found but conformance tests are disabled. + + The scenarios are rendered as conformance tests, so without a conformance tests script + none of them is rendered. """ if args.conformance_tests_script: return - module_names_with_acceptance_tests = [ + module_names_with_scenarios = [ module.module_name for module in plain_module.all_required_modules + [plain_module] - if plain_spec.has_acceptance_tests(module.plain_source) + if _has_scenarios(module.plain_source) ] - if not module_names_with_acceptance_tests: + if not module_names_with_scenarios: return - module_names = ", ".join(module_names_with_acceptance_tests) + module_names = ", ".join(module_names_with_scenarios) console.warning( - f"Acceptance tests were found ({module_names}) but no conformance tests script is configured. " - "Acceptance tests are treated as conformance tests and require a conformance tests script " - "(--conformance-tests-script or 'conformance_tests_script' in config) to be executed." + f"Test scenarios were found ({module_names}) but no conformance tests script is configured, " + "so no scenario is rendered. Set --conformance-tests-script or 'conformance_tests_script' " + "in the config file to render them." ) @@ -326,7 +333,7 @@ def render( # noqa: C901 enter_pause_event = threading.Event() signal.signal(signal.SIGTERM, lambda _signum, _frame: stop_event.set()) - warn_if_acceptance_tests_without_conformance_script(plain_module, args) + warn_if_scenarios_without_conformance_script(plain_module, args) # A built module can be distributed as a ".module" zip archive instead of an unpacked directory. # Extract any such module to a scratch location up front. Scratch dirs are removed in main()'s finally. @@ -505,7 +512,7 @@ def main(): # noqa: C901 console.info("Printing dry run output...\n") render_range = plain_spec.compute_render_range(args, plain_module.plain_source) print_dry_run_output(plain_module.plain_source, render_range) - warn_if_acceptance_tests_without_conformance_script(plain_module, args) + warn_if_scenarios_without_conformance_script(plain_module, args) return event_bus = EventBus() diff --git a/tests/test_plain2code.py b/tests/test_plain2code.py index 84c93975..b01ddd7d 100644 --- a/tests/test_plain2code.py +++ b/tests/test_plain2code.py @@ -8,86 +8,84 @@ from plain_modules import PlainModule -def _make_module(module_name, has_acceptance_tests, required_modules=None): +def _make_module(module_name, has_scenarios, required_modules=None): """Build a minimal stand-in for a PlainModule. - The functional requirements only need an `acceptance_tests` key when the module - is expected to contain acceptance tests, because that is all - `plain_spec.has_acceptance_tests` inspects. + A functionality carries scenarios only when the module is expected to have a test plain + file bound to it, because that is all the warning inspects. """ - functional_requirement = {"markdown": f"- {module_name} functionality."} - if has_acceptance_tests: - functional_requirement[plain_spec.ACCEPTANCE_TESTS] = [{"markdown": "- Test it."}] + functionality = {"id": f"{module_name}-functionality", "markdown": f"The {module_name} functionality."} + if has_scenarios: + functionality[plain_spec.CONFORMANCE_TESTS] = [{"id": "main-path", "text": "It works."}] return SimpleNamespace( module_name=module_name, - plain_source={plain_spec.FUNCTIONAL_REQUIREMENTS: [functional_requirement]}, + plain_source={plain_spec.FUNCTIONAL_REQUIREMENTS: [functionality]}, all_required_modules=required_modules or [], ) -def test_warns_when_acceptance_tests_present_and_no_conformance_script(): - plain_module = _make_module("top", has_acceptance_tests=True) - args = Namespace(conformance_tests_script=None) - +def _warn(plain_module, conformance_tests_script=None): + args = Namespace(conformance_tests_script=conformance_tests_script) with patch("plain2code.console") as mock_console: - plain2code.warn_if_acceptance_tests_without_conformance_script(plain_module, args) + plain2code.warn_if_scenarios_without_conformance_script(plain_module, args) + return mock_console + + +def test_warns_when_scenarios_present_and_no_conformance_script(): + mock_console = _warn(_make_module("top", has_scenarios=True)) mock_console.warning.assert_called_once() warning_message = mock_console.warning.call_args.args[0] assert "top" in warning_message - assert "conformance tests script" in warning_message + assert "no scenario is rendered" in warning_message def test_no_warning_when_conformance_script_configured(): - plain_module = _make_module("top", has_acceptance_tests=True) - args = Namespace(conformance_tests_script="run_conformance_tests.sh") - - with patch("plain2code.console") as mock_console: - plain2code.warn_if_acceptance_tests_without_conformance_script(plain_module, args) + mock_console = _warn(_make_module("top", has_scenarios=True), "run_conformance_tests.sh") mock_console.warning.assert_not_called() -def test_no_warning_when_no_acceptance_tests(): - plain_module = _make_module("top", has_acceptance_tests=False) - args = Namespace(conformance_tests_script=None) - - with patch("plain2code.console") as mock_console: - plain2code.warn_if_acceptance_tests_without_conformance_script(plain_module, args) +def test_no_warning_when_no_scenarios(): + mock_console = _warn(_make_module("top", has_scenarios=False)) mock_console.warning.assert_not_called() -def test_warns_when_acceptance_tests_only_in_required_module(): - required_module = _make_module("dependency", has_acceptance_tests=True) - plain_module = _make_module("top", has_acceptance_tests=False, required_modules=[required_module]) - args = Namespace(conformance_tests_script=None) +def test_warns_when_an_acceptance_scenario_is_the_only_one(): + plain_module = _make_module("top", has_scenarios=False) + plain_module.plain_source[plain_spec.FUNCTIONAL_REQUIREMENTS][0][plain_spec.ACCEPTANCE_TESTS] = [ + {"id": "reject-empty-name", "text": "It is rejected."} + ] - with patch("plain2code.console") as mock_console: - plain2code.warn_if_acceptance_tests_without_conformance_script(plain_module, args) + _warn(plain_module).warning.assert_called_once() + + +def test_warns_when_scenarios_only_in_required_module(): + required_module = _make_module("dependency", has_scenarios=True) + plain_module = _make_module("top", has_scenarios=False, required_modules=[required_module]) + + mock_console = _warn(plain_module) mock_console.warning.assert_called_once() - warning_message = mock_console.warning.call_args.args[0] - assert "dependency" in warning_message + assert "dependency" in mock_console.warning.call_args.args[0] def test_warning_covers_required_modules_for_real_plain_module(get_test_data_path): - """Integration test: a main module without acceptance tests that requires a - module with acceptance tests should still trigger the warning, naming the - required module. This mirrors the dry-run path which builds a real PlainModule.""" + """Integration test: a main module without scenarios that requires a module with a test + plain file should still trigger the warning, naming the required module. This mirrors the + dry-run path which builds a real PlainModule.""" fixtures_dir = get_test_data_path("data/acceptance_tests_warning") with tempfile.TemporaryDirectory() as build: plain_module = PlainModule( "main_requiring_acceptance_tests.plain", build, [fixtures_dir], + [f"{fixtures_dir}/tests"], ) - args = Namespace(conformance_tests_script=None) - with patch("plain2code.console") as mock_console: - plain2code.warn_if_acceptance_tests_without_conformance_script(plain_module, args) + mock_console = _warn(plain_module) mock_console.warning.assert_called_once() - warning_message = mock_console.warning.call_args.args[0] - assert "required_with_acceptance_tests" in warning_message + assert "required_with_acceptance_tests" in mock_console.warning.call_args.args[0] diff --git a/tests/test_terminal_attachment.py b/tests/test_terminal_attachment.py index e10c801b..6321cc48 100644 --- a/tests/test_terminal_attachment.py +++ b/tests/test_terminal_attachment.py @@ -131,7 +131,7 @@ def make_module_renderer(codeplainAPI, plain_module, render_choice, render_range patch("plain2code.codeplain_api.CodeplainAPI"), patch("plain2code._check_connection", return_value="user@codeplain.ai"), patch("plain2code.signal.signal"), - patch("plain2code.warn_if_acceptance_tests_without_conformance_script"), + patch("plain2code.warn_if_scenarios_without_conformance_script"), patch("plain2code.get_plain_module_render_state", return_value=render_state), patch("plain2code.get_render_choices", return_value=render_choices), patch("plain2code.ModuleRenderer", side_effect=make_module_renderer), From d114c5a779be97a473db0276596fc45197211dbe Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Mon, 26 Oct 2026 13:52:34 +0100 Subject: [PATCH 09/23] feat: name a functionality as module and func-id in the TUI, telemetry and logs --- plain2code_telemetry.py | 3 ++- plain2code_utils.py | 14 ++++++++++ .../analyze_specification_ambiguity.py | 8 +++--- .../distill_conformance_test_memory.py | 4 ++- .../actions/fix_conformance_test.py | 6 +++-- render_machine/actions/refactor_code.py | 7 ++++- .../actions/render_functional_requirement.py | 7 +++-- .../actions/run_conformance_tests.py | 5 ++-- .../actions/summarize_conformance_tests.py | 4 ++- render_machine/render_context.py | 14 +++++++--- tests/test_plain2code_utils.py | 13 ++++++++- tests/test_telemetry.py | 4 +-- tui/plain_module_render_choice_tui.py | 10 +++---- tui/state_handlers.py | 27 ++++++++++--------- 14 files changed, 88 insertions(+), 38 deletions(-) diff --git a/plain2code_telemetry.py b/plain2code_telemetry.py index 0f4cf28c..e07f72f2 100644 --- a/plain2code_telemetry.py +++ b/plain2code_telemetry.py @@ -17,6 +17,7 @@ from sentry_sdk.scrubber import DEFAULT_DENYLIST, EventScrubber from plain2code_state import RunState +from plain2code_utils import qualified_frid from system_config import system_config SENTRY_DSN = "https://64d0d86b50b34e2dede3e4eaf5142282@o4510793955934208.ingest.us.sentry.io/4511540621213696" @@ -103,7 +104,7 @@ def capture_crash(exc_info, run_state: Optional[RunState], args) -> bool: scope.set_tag("render_id", run_state.render_id) scope.set_tag("render_state", run_state.current_render_state) scope.set_tag("current_module", run_state.current_module) - scope.set_tag("current_frid", run_state.current_frid) + scope.set_tag("current_frid", qualified_frid(run_state.current_module, run_state.current_frid)) if run_state.user_email: scope.set_user({"email": run_state.user_email}) scope.set_tag("headless", bool(getattr(args, "headless", False))) diff --git a/plain2code_utils.py b/plain2code_utils.py index df048b24..6ef6d08f 100644 --- a/plain2code_utils.py +++ b/plain2code_utils.py @@ -1,3 +1,5 @@ +from typing import Optional + from plain_parser.loaders import MAX_BASE64_BLOB_LENGTH, find_large_base64_blob # noqa: F401 @@ -27,3 +29,15 @@ def format_duration_hms(total_seconds: float) -> str: "functional_requirement_ambiguity": "Ambiguity is in the functionality", "other": "Ambiguity in the other parts of the specification", } + + +def qualified_frid(module_name: Optional[str], frid: Optional[str]) -> Optional[str]: + """A functionality named as `:`. + + A func-id is unique only within its module, so this is the form to use wherever the + module is not already named next to it. Returns the func-id alone when the module is + unknown, and None when there is no functionality to name. + """ + if frid is None: + return None + return f"{module_name}:{frid}" if module_name else frid diff --git a/render_machine/actions/analyze_specification_ambiguity.py b/render_machine/actions/analyze_specification_ambiguity.py index eaddb87c..949a8853 100644 --- a/render_machine/actions/analyze_specification_ambiguity.py +++ b/render_machine/actions/analyze_specification_ambiguity.py @@ -5,7 +5,7 @@ import plain_spec from plain2code_console import console from plain2code_exceptions import InternalClientError -from plain2code_utils import AMBIGUITY_CAUSES +from plain2code_utils import AMBIGUITY_CAUSES, qualified_frid from render_machine.actions.base_action import BaseAction from render_machine.implementation_code_helpers import ImplementationCodeHelpers from render_machine.render_context import RenderContext @@ -43,12 +43,14 @@ def execute(self, render_context: RenderContext, _previous_action_payload: Any | fixed_implementation_code_diff, run_state=render_context.run_state, ) + functionality = qualified_frid(render_context.module_name, render_context.frid_context.frid) if rendering_analysis: # TODO: Before this output is exposed to the user, we should check the 'guidance' field using LLM in the same way as we do conflicting requirements. console.info( - f"Specification ambiguity detected! {AMBIGUITY_CAUSES[rendering_analysis['cause']]} of the functionality {render_context.frid_context.frid}." + f"Specification ambiguity detected! {AMBIGUITY_CAUSES[rendering_analysis['cause']]} " + f"of the functionality {functionality}." ) console.info(rendering_analysis["guidance"]) else: - console.debug(f"No specification ambiguity detected for functionality {render_context.frid_context.frid}.") + console.debug(f"No specification ambiguity detected for functionality {functionality}.") return self.SUCCESSFUL_OUTCOME, None diff --git a/render_machine/actions/distill_conformance_test_memory.py b/render_machine/actions/distill_conformance_test_memory.py index 8d815ed3..520de79b 100644 --- a/render_machine/actions/distill_conformance_test_memory.py +++ b/render_machine/actions/distill_conformance_test_memory.py @@ -1,6 +1,7 @@ from typing import Any from plain2code_console import console +from plain2code_utils import qualified_frid from render_machine.actions.base_action import BaseAction from render_machine.implementation_code_helpers import ImplementationCodeHelpers from render_machine.render_context import RenderContext @@ -26,7 +27,8 @@ def execute(self, render_context: RenderContext, _previous_action_payload: Any | if not fix_journals: return self.SUCCESSFUL_OUTCOME, None - console.info(f"Distilling conformance test fix learnings for functionality {render_context.frid_context.frid}.") + functionality = qualified_frid(render_context.module_name, render_context.frid_context.frid) + console.info(f"Distilling conformance test fix learnings for functionality {functionality}.") try: _, existing_files_content = ImplementationCodeHelpers.fetch_existing_files(render_context.build_folder) diff --git a/render_machine/actions/fix_conformance_test.py b/render_machine/actions/fix_conformance_test.py index 4bc741e9..6116f361 100644 --- a/render_machine/actions/fix_conformance_test.py +++ b/render_machine/actions/fix_conformance_test.py @@ -6,6 +6,7 @@ from memory_management import MemoryManager from plain2code_console import RETRY_COLOR, console from plain2code_exceptions import InternalClientError +from plain2code_utils import qualified_frid from render_machine.actions.base_action import BaseAction from render_machine.implementation_code_helpers import ImplementationCodeHelpers from render_machine.render_context import RenderContext @@ -45,7 +46,8 @@ def execute(self, render_context: RenderContext, previous_action_payload: Any | console.info(f"Running conformance tests attempt {ctx.fix_attempts + 1}.") console.info( - f"Fixing conformance test for functionality {render_context.conformance_tests_running_context.current_testing_frid} in module {render_context.conformance_tests_running_context.current_testing_module_name}." + f"Fixing conformance test for functionality " + f"{qualified_frid(ctx.current_testing_module_name, ctx.current_testing_frid)}." ) if not previous_action_payload.get("previous_conformance_tests_issue"): @@ -145,7 +147,7 @@ def execute(self, render_context: RenderContext, previous_action_payload: Any | render_context.conformance_tests_running_context.conflicting_frid = current_testing_frid console.info( f"↻ Potential conflicting functionalities detected while fixing conformance tests " - f"for functionality {current_testing_frid} in module {current_testing_module_name}.", + f"for functionality {qualified_frid(current_testing_module_name, current_testing_frid)}.", color=RETRY_COLOR, ) diff --git a/render_machine/actions/refactor_code.py b/render_machine/actions/refactor_code.py index 60e95ebf..36bf863a 100644 --- a/render_machine/actions/refactor_code.py +++ b/render_machine/actions/refactor_code.py @@ -2,6 +2,7 @@ import file_utils from plain2code_console import console +from plain2code_utils import qualified_frid from render_machine.actions.base_action import BaseAction from render_machine.implementation_code_helpers import ImplementationCodeHelpers from render_machine.render_context import RenderContext @@ -22,7 +23,11 @@ def execute(self, render_context: RenderContext, _previous_action_payload: Any | render_context.frid_context.refactoring_iteration += 1 if render_context.frid_context.refactoring_iteration >= MAX_REFACTORING_ITERATIONS: - error_message = "Refactoring iterations limit of {MAX_REFACTORING_ITERATIONS} reached for functionality {render_context.frid_context.frid}." + functionality = qualified_frid(render_context.module_name, render_context.frid_context.frid) + error_message = ( + f"Refactoring iterations limit of {MAX_REFACTORING_ITERATIONS} reached " + f"for functionality {functionality}." + ) render_context.last_error_message = error_message return ( diff --git a/render_machine/actions/render_functional_requirement.py b/render_machine/actions/render_functional_requirement.py index 128e115c..7bee5d3f 100644 --- a/render_machine/actions/render_functional_requirement.py +++ b/render_machine/actions/render_functional_requirement.py @@ -5,6 +5,7 @@ from memory_management import MemoryManager from plain2code_console import RETRY_COLOR, console from plain2code_exceptions import FunctionalRequirementTooComplex +from plain2code_utils import qualified_frid from render_machine.actions.base_action import BaseAction from render_machine.implementation_code_helpers import ImplementationCodeHelpers from render_machine.render_context import MAX_UNITTEST_FIX_ATTEMPTS, RenderContext @@ -21,8 +22,9 @@ class RenderFunctionalRequirement(BaseAction): def execute(self, render_context: RenderContext, _previous_action_payload: Any | None): if render_context.frid_context.functional_requirement_render_attempts >= MAX_CODE_GENERATION_RETRIES: # A render attempt is only restarted once its unit tests used up every fix round. + functionality = qualified_frid(render_context.module_name, render_context.frid_context.frid) error_msg = ( - f"Functionality {render_context.frid_context.frid}: unit tests still fail after " + f"Functionality {functionality}: unit tests still fail after " f"{MAX_CODE_GENERATION_RETRIES} render attempts, {MAX_UNITTEST_FIX_ATTEMPTS} fix rounds each." ) last_output_path = render_context.script_execution_history.latest_unit_test_output_path @@ -36,7 +38,8 @@ def execute(self, render_context: RenderContext, _previous_action_payload: Any | if render_context.frid_context.functional_requirement_render_attempts > 1: console.info( f"↻ Unittests could not be fixed after rendering the functionality. " - f"Restarting rendering functionality {render_context.frid_context.frid} from scratch.", + f"Restarting rendering functionality " + f"{qualified_frid(render_context.module_name, render_context.frid_context.frid)} from scratch.", color=RETRY_COLOR, ) diff --git a/render_machine/actions/run_conformance_tests.py b/render_machine/actions/run_conformance_tests.py index 562d4596..b38eecff 100644 --- a/render_machine/actions/run_conformance_tests.py +++ b/render_machine/actions/run_conformance_tests.py @@ -3,6 +3,7 @@ import render_machine.render_utils as render_utils from plain2code_console import console +from plain2code_utils import qualified_frid from render_machine.actions.base_action import BaseAction from render_machine.render_context import RenderContext from render_machine.render_types import RenderError @@ -33,11 +34,11 @@ def execute(self, render_context: RenderContext, _previous_action_payload: Any | ) ) + ctx = render_context.conformance_tests_running_context console.info( f"Running conformance tests script {conformance_tests_script} " + f"for {conformance_tests_folder_name} (" - + f"functionality {render_context.conformance_tests_running_context.current_testing_frid} " - + f"in module {render_context.conformance_tests_running_context.current_testing_module_name}" + + f"functionality {qualified_frid(ctx.current_testing_module_name, ctx.current_testing_frid)}" + ")." ) exit_code, conformance_tests_issue, conformance_tests_temp_log_file_path = render_utils.execute_script( diff --git a/render_machine/actions/summarize_conformance_tests.py b/render_machine/actions/summarize_conformance_tests.py index 14e4d719..b9d39d63 100644 --- a/render_machine/actions/summarize_conformance_tests.py +++ b/render_machine/actions/summarize_conformance_tests.py @@ -1,6 +1,7 @@ from typing import Any from plain2code_console import console +from plain2code_utils import qualified_frid from render_machine.actions.base_action import BaseAction from render_machine.render_context import RenderContext @@ -9,7 +10,8 @@ class SummarizeConformanceTests(BaseAction): SUCCESSFUL_OUTCOME = "conformance_tests_summarized" def execute(self, render_context: RenderContext, _previous_action_payload: Any | None): - console.info(f"Summarizing conformance tests for functionality {render_context.frid_context.frid}.") + functionality = qualified_frid(render_context.module_name, render_context.frid_context.frid) + console.info(f"Summarizing conformance tests for functionality {functionality}.") _, existing_conformance_test_files_content = ( render_context.conformance_tests.fetch_existing_conformance_test_files( diff --git a/render_machine/render_context.py b/render_machine/render_context.py index c08f81f4..761112ad 100644 --- a/render_machine/render_context.py +++ b/render_machine/render_context.py @@ -10,6 +10,7 @@ from plain2code_console import RETRY_COLOR, console from plain2code_events import RenderContextSnapshot from plain2code_state import RunState +from plain2code_utils import qualified_frid from plain_modules import PlainModule from render_machine import triggers from render_machine.conformance_tests import CONFORMANCE_TESTS_DEFINITION_FILE_NAME, ConformanceTests @@ -297,13 +298,15 @@ def _on_unit_test_limit_exceeded_in_conformance_tests(self): self.functional_requirements_render_attempts_failed_unit_during_conformance_tests >= MAX_FUNCTIONAL_REQUIREMENT_RENDER_ATTEMPTS_FAILED_UNIT_DURING_CONFORMANCE_TESTS ): - error_msg = f"Failed to adjust the unit tests after implementation code was update while fixing the conformance tests for functionality {self.frid_context.frid} for the {MAX_FUNCTIONAL_REQUIREMENT_RENDER_ATTEMPTS_FAILED_UNIT_DURING_CONFORMANCE_TESTS} times." + functionality = qualified_frid(self.module_name, self.frid_context.frid) + error_msg = f"Failed to adjust the unit tests after implementation code was update while fixing the conformance tests for functionality {functionality} for the {MAX_FUNCTIONAL_REQUIREMENT_RENDER_ATTEMPTS_FAILED_UNIT_DURING_CONFORMANCE_TESTS} times." self.dispatch_error(error_msg) else: + functionality = qualified_frid(self.module_name, self.frid_context.frid) console.info( f"↻ Failed to adjust the unit tests after implementation code was updated while fixing the " - f"conformance tests for functionality {self.frid_context.frid}. " - f"Restarting rendering the functionality {self.frid_context.frid} from scratch.", + f"conformance tests for functionality {functionality}. " + f"Restarting rendering the functionality {functionality} from scratch.", color=RETRY_COLOR, ) self.machine.dispatch(triggers.RESTART_FRID_PROCESSING) @@ -402,7 +405,10 @@ def _handle_test_regeneration(self): """Handle regeneration of conformance tests after too many failures.""" ctx = self.conformance_tests_running_context - console.info(f"Recreating conformance tests for functionality {ctx.current_testing_frid}.") + console.info( + f"Recreating conformance tests for functionality " + f"{qualified_frid(ctx.current_testing_module_name, ctx.current_testing_frid)}." + ) existing_folder = ctx.get_conformance_tests_json(ctx.current_testing_module_name).pop(ctx.current_testing_frid) file_utils.delete_folder(existing_folder["folder_name"]) diff --git a/tests/test_plain2code_utils.py b/tests/test_plain2code_utils.py index d56b3d74..5f082498 100644 --- a/tests/test_plain2code_utils.py +++ b/tests/test_plain2code_utils.py @@ -1,6 +1,6 @@ """Tests for pure helpers in plain2code_utils.""" -from plain2code_utils import format_duration_hms +from plain2code_utils import format_duration_hms, qualified_frid class TestFormatDurationHms: @@ -34,3 +34,14 @@ def test_float_input_is_truncated(self): def test_negative_input_clamped_to_zero(self): assert format_duration_hms(-5) == "0s" + + +class TestQualifiedFrid: + def test_names_the_module_and_the_func_id(self): + assert qualified_frid("task-manager", "add-task") == "task-manager:add-task" + + def test_without_a_module_the_func_id_stands_alone(self): + assert qualified_frid(None, "add-task") == "add-task" + + def test_without_a_functionality_there_is_no_name(self): + assert qualified_frid("task-manager", None) is None diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 22ea7da3..5b26dc16 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -98,7 +98,7 @@ def test_capture_crash_sends_event_with_tags(transport): run_state = RunState(spec_filename="test.plain") run_state.current_module = "my_module" - run_state.current_frid = "2.1" + run_state.current_frid = "add-task" run_state.current_render_state = "IMPLEMENTING_FRID" run_state.user_email = "user@codeplain.ai" @@ -110,7 +110,7 @@ def test_capture_crash_sends_event_with_tags(transport): tags = event["tags"] assert tags["render_id"] == run_state.render_id assert tags["current_module"] == "my_module" - assert tags["current_frid"] == "2.1" + assert tags["current_frid"] == "my_module:add-task" assert tags["render_state"] == "IMPLEMENTING_FRID" assert tags["headless"] is True assert tags["unittests_script_provided"] is True diff --git a/tui/plain_module_render_choice_tui.py b/tui/plain_module_render_choice_tui.py index 0325672b..f3d206a4 100644 --- a/tui/plain_module_render_choice_tui.py +++ b/tui/plain_module_render_choice_tui.py @@ -8,6 +8,7 @@ import plain_spec from partial_rendering import PlainModuleRenderState, RenderChoice, get_all_affected_modules_from_change +from plain2code_utils import qualified_frid from plain_modules import PlainModule from tui.components import CustomFooter, TUIComponents @@ -154,8 +155,8 @@ def on_mount(self) -> None: elif pr.last_render_module.is_module_fully_rendered(): change_box.mount(Label("The current module is fully rendered.", classes="rendering-info-title")) else: - interrupted_frid = "1" interrupted_module = pr.last_render_module + interrupted_frid = plain_spec.get_first_frid(interrupted_module.plain_source) if pr.last_render_frid: next_frid, next_module = pr.last_render_module.get_next_frid( pr.last_render_frid, pr.last_render_module.module_name @@ -164,11 +165,8 @@ def on_mount(self) -> None: interrupted_frid = next_frid interrupted_module = next_module - msg = f"--- Rendering interrupted during [#5593FF]functionality {interrupted_frid}" - if interrupted_module != pr.last_render_module: - msg += f" of module {interrupted_module.module_name}[/] ---" - else: - msg += "[/] ---" + interrupted = qualified_frid(interrupted_module.module_name, interrupted_frid) + msg = f"--- Rendering interrupted during [#5593FF]functionality {interrupted}[/] ---" change_box.mount(Label(msg, classes="rendering-info-row")) diff --git a/tui/state_handlers.py b/tui/state_handlers.py index 0bdb7d52..ee6ec2c9 100644 --- a/tui/state_handlers.py +++ b/tui/state_handlers.py @@ -4,6 +4,7 @@ from typing import Optional from plain2code_events import RenderContextSnapshot +from plain2code_utils import qualified_frid from render_machine.render_types import AcceptanceTestPhase from render_machine.states import States @@ -20,6 +21,12 @@ ) +def _testing_functionality(snapshot: RenderContextSnapshot) -> str: + """The functionality whose conformance tests are running, as `:`.""" + ctx = snapshot.conformance_tests_running_context + return qualified_frid(ctx.current_testing_module_name, ctx.current_testing_frid) + + class StateHandler(ABC): """Abstract base class for state handlers that process state machine transitions.""" @@ -54,7 +61,11 @@ def handle(self, _: list[str], snapshot: RenderContextSnapshot, _previous_state_ """Handle READY_FOR_FRID_IMPLEMENTATION state.""" # Update FRID text - rendering_functionality_text = f"{tui_components.FRIDProgress.RENDERING_FUNCTIONALITY_TEXT}{snapshot.frid_context.frid}: {snapshot.frid_context.functional_requirement_text}" + functionality = qualified_frid(snapshot.module_name, snapshot.frid_context.frid) + rendering_functionality_text = ( + f"{tui_components.FRIDProgress.RENDERING_FUNCTIONALITY_TEXT}{functionality}: " + f"{snapshot.frid_context.functional_requirement_text}" + ) get_frid_progress(self.tui).update_functionality_text(rendering_functionality_text) # Set progress states @@ -184,7 +195,7 @@ def handle(self, segments: list[str], snapshot: RenderContextSnapshot, previous_ phase = snapshot.conformance_tests_running_context.acceptance_test_phase # Rendering full conformance tests (if there are no acceptance tests available or haven't been started yet) if phase == AcceptanceTestPhase.NOT_STARTED or phase == AcceptanceTestPhase.NOT_APPLICABLE: - rendering_text = f"Rendering conformance tests for functionality {snapshot.conformance_tests_running_context.current_testing_frid}" + rendering_text = f"Rendering conformance tests for functionality {_testing_functionality(snapshot)}" update_progress_item_substates( self.tui, TUIComponents.FRID_PROGRESS_CONFORMANCE_TEST.value, @@ -209,21 +220,13 @@ def handle(self, segments: list[str], snapshot: RenderContextSnapshot, previous_ [Substate("Preparing testing environment for conformance tests")], ) elif segments[2] == States.CONFORMANCE_TEST_ENV_PREPARED.value: - running_text = f"Running conformance tests for functionality {snapshot.conformance_tests_running_context.current_testing_frid}" - if snapshot.conformance_tests_running_context.current_testing_module_name != snapshot.module_name: - running_text += ( - f" of module {snapshot.conformance_tests_running_context.current_testing_module_name}" - ) + running_text = f"Running conformance tests for functionality {_testing_functionality(snapshot)}" update_progress_item_substates( self.tui, TUIComponents.FRID_PROGRESS_CONFORMANCE_TEST.value, [Substate(running_text)] ) elif segments[2] == States.CONFORMANCE_TEST_FAILED.value: - fixing_text = f"Fixing conformance tests for functionality {snapshot.conformance_tests_running_context.current_testing_frid}" - if snapshot.conformance_tests_running_context.current_testing_module_name != snapshot.module_name: - fixing_text += ( - f" of module {snapshot.conformance_tests_running_context.current_testing_module_name}" - ) + fixing_text = f"Fixing conformance tests for functionality {_testing_functionality(snapshot)}" update_progress_item_substates( self.tui, TUIComponents.FRID_PROGRESS_CONFORMANCE_TEST.value, [Substate(fixing_text)] From a4d18c45498dc7d49e6be49ae2206604692c7987 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Mon, 26 Oct 2026 13:54:26 +0100 Subject: [PATCH 10/23] docs: regenerate the CLI reference from the checkout --- docs/generate_cli.py | 5 +++-- docs/plain2code_cli.md | 31 +++++++++++++++++-------------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/docs/generate_cli.py b/docs/generate_cli.py index bb5c21c6..52d3b8e5 100644 --- a/docs/generate_cli.py +++ b/docs/generate_cli.py @@ -1,8 +1,9 @@ import os import sys -# Add the parent directory to the path so we can import plain2code_arguments -sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +# Put the checkout first on the path so we read its plain2code_arguments, not an +# installed copy of codeplain that would reflect whatever was last released. +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) def main(): diff --git a/docs/plain2code_cli.md b/docs/plain2code_cli.md index 68ec7c65..2d30f109 100644 --- a/docs/plain2code_cli.md +++ b/docs/plain2code_cli.md @@ -6,16 +6,16 @@ usage: generate_cli.py [-h] [--verbose] [--base-folder BASE_FOLDER] [--log-to-file | --no-log-to-file] [--log-file-name LOG_FILE_NAME] [--config-name CONFIG_NAME] - [--render-range RENDER_RANGE | - --render-from RENDER_FROM] [--force-render] - [--unittests-script UNITTESTS_SCRIPT] + [--render-range RENDER_RANGE | --render-from RENDER_FROM] + [--force-render] [--unittests-script UNITTESTS_SCRIPT] [--conformance-tests-script CONFORMANCE_TESTS_SCRIPT] [--prepare-environment-script PREPARE_ENVIRONMENT_SCRIPT] [--test-script-timeout TEST_SCRIPT_TIMEOUT] [--api [API]] [--api-key API_KEY] [--full-plain] [--dry-run] [--replay-with REPLAY_WITH] - [--template-dir TEMPLATE_DIR] [--copy-build] - [--build-dest BUILD_DEST] [--copy-conformance-tests] + [--template-dir TEMPLATE_DIR] [--test-dir TEST_DIR] + [--copy-build] [--build-dest BUILD_DEST] + [--copy-conformance-tests] [--conformance-tests-dest CONFORMANCE_TESTS_DEST] [--render-machine-graph] [--logging-config-path LOGGING_CONFIG_PATH] @@ -51,17 +51,17 @@ options: a file already exists at the resolved path, it will be overwritten by the current logs. --render-range RENDER_RANGE - Specify a range of functionalities to render (e.g. `1` - , `2`, `3`). Use comma to separate start and end IDs. - If only one functionality ID is provided, only that - functionality is rendered. Range is inclusive of both - start and end IDs. + Specify a range of functionalities to render (e.g. + `add-task`, `add-task,list-tasks`). Use comma to + separate start and end IDs. If only one functionality + ID is provided, only that functionality is rendered. + Range is inclusive of both start and end IDs. --render-from RENDER_FROM Continue generation starting from this specific - functionality (e.g. `2`). The functionality with this - ID will be included in the output. The functionality - ID must match one of the functionalities in your plain - file. + functionality (e.g. `add-task`). The functionality + with this ID will be included in the output. The + functionality ID must match one of the functionalities + in your plain file. --force-render Force re-render of all the required modules. --unittests-script UNITTESTS_SCRIPT Shell script to run unit tests on generated code. @@ -100,6 +100,9 @@ options: containing the plain file, 2) Custom template directory (if provided through this argument), 3) Built-in standard_template_library directory + --test-dir TEST_DIR Folder scanned for test plain files; repeat the option + for more than one. Defaults to 'tests' next to the + plain file. --copy-build If set, copy the rendered contents of code in `--base- folder` folder to `--build-dest` folder after successful rendering. From 73bce58f7a1db6347d1458703e93e4b3a722cc88 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Tue, 27 Oct 2026 12:18:37 +0100 Subject: [PATCH 11/23] feat: render a functionality without scenarios, without its conformance tests --- plain2code.py | 19 ++++ plain_modules.py | 35 +++++++- render_machine/render_context.py | 29 +++++- tests/test_functionality_without_scenarios.py | 88 +++++++++++++++++++ 4 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 tests/test_functionality_without_scenarios.py diff --git a/plain2code.py b/plain2code.py index 1ed245fe..4113f78b 100644 --- a/plain2code.py +++ b/plain2code.py @@ -280,6 +280,23 @@ def _has_scenarios(plain_source_tree) -> bool: return False +def warn_about_functionalities_without_scenarios(plain_module) -> None: + """Warn about every functionality whose test plain file holds no conformance scenario. + + Such a functionality is rendered, but no conformance test is rendered or run for it. + """ + functionalities = [ + f"{module_name}:{func_id}" for module_name, func_id in plain_module.functionalities_without_scenarios + ] + if not functionalities: + return + + console.warning( + f"No conformance scenario is written for {', '.join(functionalities)}, " + "so each one is rendered without conformance tests." + ) + + def warn_if_scenarios_without_conformance_script(plain_module, args) -> None: """Warn when a test plain file was found but conformance tests are disabled. @@ -334,6 +351,7 @@ def render( # noqa: C901 signal.signal(signal.SIGTERM, lambda _signum, _frame: stop_event.set()) warn_if_scenarios_without_conformance_script(plain_module, args) + warn_about_functionalities_without_scenarios(plain_module) # A built module can be distributed as a ".module" zip archive instead of an unpacked directory. # Extract any such module to a scratch location up front. Scratch dirs are removed in main()'s finally. @@ -513,6 +531,7 @@ def main(): # noqa: C901 render_range = plain_spec.compute_render_range(args, plain_module.plain_source) print_dry_run_output(plain_module.plain_source, render_range) warn_if_scenarios_without_conformance_script(plain_module, args) + warn_about_functionalities_without_scenarios(plain_module) return event_bus = EventBus() diff --git a/plain_modules.py b/plain_modules.py index b30b0dcb..6bfcffbf 100644 --- a/plain_modules.py +++ b/plain_modules.py @@ -19,6 +19,7 @@ from git import Repo from git.exc import NoSuchPathError from plain_parser import project +from plain_parser.exceptions import MissingConformanceScenariosError import file_utils import git_utils @@ -134,6 +135,31 @@ def _strip_functional_requirements(plain_source_tree: dict) -> dict: return stripped +def _check_test_plain_files( + chain: list[tuple[str, dict]], bound: list[project.BoundTestPlainFile] +) -> list[tuple[str, str]]: + """Raise for the first functionality of the chain, in render order, that has no test plain file. + + Returns the functionalities whose test plain file holds no conformance scenario. They are + rendered without conformance tests. + """ + with_a_test_plain_file = { + (test_plain_file.parsed.module, test_plain_file.parsed.func_id) for test_plain_file in bound + } + without_scenarios = [] + for module_name, tree in chain: + for functionality in tree.get(plain_spec.FUNCTIONAL_REQUIREMENTS) or []: + func_id = functionality["id"] + if (module_name, func_id) not in with_a_test_plain_file: + raise MissingConformanceScenariosError( + f"Functionality {module_name}:{func_id} has no test plain file. Add one " + f"(by convention tests/{module_name}/{func_id}.plain) with the add-test-scenarios skill." + ) + if not functionality[plain_spec.CONFORMANCE_TESTS]: + without_scenarios.append((module_name, func_id)) + return without_scenarios + + class PlainModule: def __init__( self, @@ -159,6 +185,7 @@ def __init__( self.required_modules_names = required_modules_names plain_spec.collect_linked_resources(plain_source, resources_list, None, True) self.resources_list = resources_list + self.functionalities_without_scenarios: list[tuple[str, str]] = [] self.required_modules = [] if len(required_modules_names) > 0: self.required_modules = [ @@ -175,13 +202,15 @@ def __init__( def _bind_test_scenarios(self, test_dirs: list[str], conformance_tests_enabled: bool) -> None: """Add the scenarios of the test plain files under ``test_dirs`` to the functionalities of the chain. - With conformance tests enabled, a functionality without a conformance scenario stops the run here. + With conformance tests enabled, a functionality with no test plain file stops the run here. A + functionality whose test plain file holds no scenario is recorded instead, so the run can warn + about it and go on without conformance tests for it. """ chain = [(module.module_name, module.plain_source) for module in self.all_required_modules] chain.append((self.module_name, self.plain_source)) - project.bind_test_plain_files(chain, test_dirs, self.template_dirs, self.template_dirs[0]) + bound = project.bind_test_plain_files(chain, test_dirs, self.template_dirs, self.template_dirs[0]) if conformance_tests_enabled: - project.check_conformance_scenarios(chain) + self.functionalities_without_scenarios = _check_test_plain_files(chain, bound) @cached_property def all_required_modules(self) -> list[PlainModule]: diff --git a/render_machine/render_context.py b/render_machine/render_context.py index 761112ad..53c72d50 100644 --- a/render_machine/render_context.py +++ b/render_machine/render_context.py @@ -316,10 +316,13 @@ def _on_unit_test_limit_exceeded_in_refactoring(self): self.machine.dispatch(triggers.START_NEW_REFACTORING_ITERATION) def start_conformance_tests_processing(self): - console.info("Implementing conformance tests...") current_frid_specifications, _ = plain_spec.get_specifications_for_frid( self.plain_source_tree, self.frid_context.frid ) + has_own_scenarios = bool(current_frid_specifications.get(plain_spec.CONFORMANCE_TESTS)) + if has_own_scenarios: + console.info("Implementing conformance tests...") + self.conformance_tests_running_context = ConformanceTestsRunningContext( current_testing_module_name=self.module_name, current_testing_frid=self.frid_context.frid, @@ -331,6 +334,30 @@ def start_conformance_tests_processing(self): frid_being_implemented=self.frid_context.frid, ) + if has_own_scenarios: + return + + # No scenario was written for this functionality, so no conformance test is rendered or run + # for it. The tests of the previous functionalities still run, so a regression it causes is + # still caught. + functionality = qualified_frid(self.module_name, self.frid_context.frid) + if self._has_conformance_tests_to_run_in_regression(): + console.info( + f"No conformance scenario is written for {functionality}. " + "Running the conformance tests of the previous functionalities." + ) + self._start_regression_phase() + else: + console.info(f"No conformance scenario is written for {functionality}, and there is none to re-run.") + self.conformance_tests_running_context.execution_phase = TestExecutionPhase.COMPLETED + + def _has_conformance_tests_to_run_in_regression(self) -> bool: + """Whether any functionality rendered before this one has conformance tests to re-run.""" + for module in self.required_modules or []: + if self.conformance_tests.get_conformance_tests_json(module.module_name): + return True + return bool(self.conformance_tests.get_conformance_tests_json(self.module_name)) + def finish_conformance_tests_processing(self): self.conformance_tests_running_context = None diff --git a/tests/test_functionality_without_scenarios.py b/tests/test_functionality_without_scenarios.py new file mode 100644 index 00000000..fbfe9f8f --- /dev/null +++ b/tests/test_functionality_without_scenarios.py @@ -0,0 +1,88 @@ +"""A functionality whose test plain file holds no scenario: it is rendered, without conformance tests. + +A functionality with no test plain file at all is a different case and stops the run. +""" + +import os + +import pytest + +import file_utils # noqa: F401 # imported before plain_modules, which it imports back from +import plain2code +from plain_modules import PlainModule + +MODULE = "required_with_acceptance_tests.plain" +EMPTY_TEST_PLAIN_FILE = """--- +module: required_with_acceptance_tests +func_id: entry-point +--- + +***conformance tests*** + +***acceptance tests*** +""" + + +@pytest.fixture +def project(tmp_path, get_test_data_path): + """A copy of the fixture project, so a test can edit its test plain files.""" + import shutil + + source = get_test_data_path("data/acceptance_tests_warning") + specs = tmp_path / "specs" + shutil.copytree(source, specs) + return specs + + +def _build(project, tmp_path, module=MODULE): + return PlainModule( + module, + str(tmp_path / "build"), + [str(project)], + [str(project / "tests")], + conformance_tests_enabled=True, + ) + + +def test_a_test_plain_file_with_a_scenario_is_not_reported(project, tmp_path): + module = _build(project, tmp_path) + + assert module.functionalities_without_scenarios == [] + + +def test_a_test_plain_file_without_scenarios_is_reported(project, tmp_path): + (project / "tests" / "required_with_acceptance_tests" / "entry-point.plain").write_text(EMPTY_TEST_PLAIN_FILE) + + module = _build(project, tmp_path) + + assert module.functionalities_without_scenarios == [("required_with_acceptance_tests", "entry-point")] + + +def test_a_missing_test_plain_file_stops_the_run(project, tmp_path): + os.remove(project / "tests" / "required_with_acceptance_tests" / "entry-point.plain") + + with pytest.raises(Exception, match="has no test plain file"): + _build(project, tmp_path) + + +def test_the_warning_names_every_functionality_without_scenarios(project, tmp_path, monkeypatch): + (project / "tests" / "required_with_acceptance_tests" / "entry-point.plain").write_text(EMPTY_TEST_PLAIN_FILE) + module = _build(project, tmp_path) + + warnings = [] + monkeypatch.setattr(plain2code.console, "warning", lambda message: warnings.append(message)) + plain2code.warn_about_functionalities_without_scenarios(module) + + assert len(warnings) == 1 + assert "required_with_acceptance_tests:entry-point" in warnings[0] + assert "without conformance tests" in warnings[0] + + +def test_nothing_is_warned_when_every_functionality_has_a_scenario(project, tmp_path, monkeypatch): + module = _build(project, tmp_path) + + warnings = [] + monkeypatch.setattr(plain2code.console, "warning", lambda message: warnings.append(message)) + plain2code.warn_about_functionalities_without_scenarios(module) + + assert warnings == [] From 492633050ab8bf2caf0f37f983336711d960bfaa Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Tue, 27 Oct 2026 14:55:43 +0100 Subject: [PATCH 12/23] feat: require an explicit test directory when conformance tests are on --- docs/plain2code_cli.md | 6 ++-- .../example_hello_world_golang/config.yaml | 3 +- .../example_hello_world_python/config.yaml | 1 + .../example_hello_world_react/config.yaml | 1 + plain2code_arguments.py | 13 ++++--- plain_modules.py | 34 ++++++------------- tests/test_script_path_resolution.py | 2 ++ tests/test_test_dirs.py | 22 ++++++++++-- 8 files changed, 49 insertions(+), 33 deletions(-) diff --git a/docs/plain2code_cli.md b/docs/plain2code_cli.md index 2d30f109..d1beffcb 100644 --- a/docs/plain2code_cli.md +++ b/docs/plain2code_cli.md @@ -101,8 +101,10 @@ options: directory (if provided through this argument), 3) Built-in standard_template_library directory --test-dir TEST_DIR Folder scanned for test plain files; repeat the option - for more than one. Defaults to 'tests' next to the - plain file. + for more than one. There is no default: without this + option and without the 'test-dir' config key, no test + plain file is read. Required when a conformance tests + script is configured. --copy-build If set, copy the rendered contents of code in `--base- folder` folder to `--build-dest` folder after successful rendering. diff --git a/examples/example_hello_world_golang/config.yaml b/examples/example_hello_world_golang/config.yaml index 195888ef..333d3397 100644 --- a/examples/example_hello_world_golang/config.yaml +++ b/examples/example_hello_world_golang/config.yaml @@ -1,3 +1,4 @@ verbose: true unittests-script: ../../test_scripts/run_unittests_golang.sh -conformance-tests-script: ../../test_scripts/run_conformance_tests_golang.sh \ No newline at end of file +conformance-tests-script: ../../test_scripts/run_conformance_tests_golang.sh +test-dir: tests diff --git a/examples/example_hello_world_python/config.yaml b/examples/example_hello_world_python/config.yaml index 197ecb44..026a41fa 100644 --- a/examples/example_hello_world_python/config.yaml +++ b/examples/example_hello_world_python/config.yaml @@ -1,3 +1,4 @@ unittests-script: ../../test_scripts/run_unittests_python.sh conformance-tests-script: ../../test_scripts/run_conformance_tests_python.sh verbose: true +test-dir: tests diff --git a/examples/example_hello_world_react/config.yaml b/examples/example_hello_world_react/config.yaml index 66ea4d66..b7e11afc 100644 --- a/examples/example_hello_world_react/config.yaml +++ b/examples/example_hello_world_react/config.yaml @@ -1,2 +1,3 @@ conformance-tests-script: ../../test_scripts/run_conformance_tests_cypress.sh verbose: true +test-dir: tests diff --git a/plain2code_arguments.py b/plain2code_arguments.py index 2d505819..c1f012f5 100644 --- a/plain2code_arguments.py +++ b/plain2code_arguments.py @@ -19,7 +19,6 @@ DEFAULT_BUILD_FOLDER = "plain_modules" -DEFAULT_TEST_DIR = "tests" DEFAULT_BUILD_DEST = "dist" DEFAULT_CONFORMANCE_TESTS_DEST = "dist_conformance_tests" MAX_JOB_TAG_LENGTH = 300 @@ -91,9 +90,6 @@ def _resolve_path_arguments( elif getattr(action, "_is_path_list", False): _resolve_path_list_arg(action.dest, args, cwd, config_dir, spec_dir) - if args.test_dir is None: - args.test_dir = [os.path.join(spec_dir, DEFAULT_TEST_DIR)] - def _resolve_script_path( script_arg_name: str, @@ -449,7 +445,8 @@ def create_parser(): action="append", default=None, help="Folder scanned for test plain files; repeat the option for more than one. " - f"Defaults to '{DEFAULT_TEST_DIR}' next to the plain file.", + "There is no default: without this option and without the 'test-dir' config key, no test " + "plain file is read. Required when a conformance tests script is configured.", path_list=True, ) _add_arg( @@ -575,6 +572,12 @@ def parse_arguments(command_line: Optional[Sequence[str]] = None): args.render_conformance_tests = args.conformance_tests_script is not None + if args.render_conformance_tests and not args.test_dir: + parser.error( + "A conformance tests script is configured, but no test directory is given. " + "Pass --test-dir or set test-dir in the config file." + ) + if not args.render_conformance_tests and args.copy_conformance_tests: parser.error("--copy-conformance-tests requires --conformance-tests-script to be set") diff --git a/plain_modules.py b/plain_modules.py index 6bfcffbf..3cd51cac 100644 --- a/plain_modules.py +++ b/plain_modules.py @@ -19,7 +19,6 @@ from git import Repo from git.exc import NoSuchPathError from plain_parser import project -from plain_parser.exceptions import MissingConformanceScenariosError import file_utils import git_utils @@ -135,29 +134,17 @@ def _strip_functional_requirements(plain_source_tree: dict) -> dict: return stripped -def _check_test_plain_files( - chain: list[tuple[str, dict]], bound: list[project.BoundTestPlainFile] -) -> list[tuple[str, str]]: - """Raise for the first functionality of the chain, in render order, that has no test plain file. +def _functionalities_without_scenarios(chain: list[tuple[str, dict]]) -> list[tuple[str, str]]: + """The functionalities whose test plain file holds no conformance scenario. - Returns the functionalities whose test plain file holds no conformance scenario. They are - rendered without conformance tests. + Each one is rendered without conformance tests. """ - with_a_test_plain_file = { - (test_plain_file.parsed.module, test_plain_file.parsed.func_id) for test_plain_file in bound - } - without_scenarios = [] - for module_name, tree in chain: - for functionality in tree.get(plain_spec.FUNCTIONAL_REQUIREMENTS) or []: - func_id = functionality["id"] - if (module_name, func_id) not in with_a_test_plain_file: - raise MissingConformanceScenariosError( - f"Functionality {module_name}:{func_id} has no test plain file. Add one " - f"(by convention tests/{module_name}/{func_id}.plain) with the add-test-scenarios skill." - ) - if not functionality[plain_spec.CONFORMANCE_TESTS]: - without_scenarios.append((module_name, func_id)) - return without_scenarios + return [ + (module_name, functionality["id"]) + for module_name, tree in chain + for functionality in tree.get(plain_spec.FUNCTIONAL_REQUIREMENTS) or [] + if not functionality[plain_spec.CONFORMANCE_TESTS] + ] class PlainModule: @@ -210,7 +197,8 @@ def _bind_test_scenarios(self, test_dirs: list[str], conformance_tests_enabled: chain.append((self.module_name, self.plain_source)) bound = project.bind_test_plain_files(chain, test_dirs, self.template_dirs, self.template_dirs[0]) if conformance_tests_enabled: - self.functionalities_without_scenarios = _check_test_plain_files(chain, bound) + project.check_test_plain_files(chain, bound) + self.functionalities_without_scenarios = _functionalities_without_scenarios(chain) @cached_property def all_required_modules(self) -> list[PlainModule]: diff --git a/tests/test_script_path_resolution.py b/tests/test_script_path_resolution.py index 4cc5bdde..476449b6 100644 --- a/tests/test_script_path_resolution.py +++ b/tests/test_script_path_resolution.py @@ -96,6 +96,8 @@ def test_all_three_script_args_resolved(layout): "unittests-script: script_in_config.sh\n" "conformance-tests-script: script_in_config.sh\n" "prepare-environment-script: script_in_config.sh\n" + # A conformance tests script needs a test directory beside it. + "test-dir: tests\n" ) with patch("os.getcwd", return_value=str(layout["config"])): diff --git a/tests/test_test_dirs.py b/tests/test_test_dirs.py index de04beaa..699f79e4 100644 --- a/tests/test_test_dirs.py +++ b/tests/test_test_dirs.py @@ -29,10 +29,28 @@ def _parse(tmp_path, *extra_args): return parse_arguments([str(plain_file), *extra_args]) -def test_test_dir_defaults_to_tests_next_to_the_plain_file(tmp_path): +def test_there_is_no_default_test_dir(tmp_path): args = _parse(tmp_path) - assert args.test_dir == [str(tmp_path / "tests")] + assert args.test_dir is None + + +def test_a_conformance_tests_script_without_a_test_dir_is_a_usage_error(tmp_path): + script = tmp_path / "run_conformance_tests.sh" + script.write_text("#!/bin/sh\n") + + with pytest.raises(SystemExit): + _parse(tmp_path, "--conformance-tests-script", str(script)) + + +def test_a_conformance_tests_script_with_a_test_dir_is_accepted(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + script = tmp_path / "run_conformance_tests.sh" + script.write_text("#!/bin/sh\n") + + args = _parse(tmp_path, "--conformance-tests-script", str(script), "--test-dir", "scenarios") + + assert args.test_dir == [str(tmp_path / "scenarios")] def test_test_dir_can_be_given_more_than_once(tmp_path, monkeypatch): From 5b4cc01c501f859bc8e6c5f0bdb457724fdeb253 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Tue, 27 Oct 2026 15:42:01 +0100 Subject: [PATCH 13/23] fix: bind scenarios once per module and refuse a numbered build folder --- partial_rendering.py | 28 +++++++++++++++++++++++++++- plain_modules.py | 20 ++++++++++++++++++-- tests/test_partial_rendering.py | 33 +++++++++++++++++++++------------ 3 files changed, 66 insertions(+), 15 deletions(-) diff --git a/partial_rendering.py b/partial_rendering.py index afc851b7..768e1ff0 100644 --- a/partial_rendering.py +++ b/partial_rendering.py @@ -3,7 +3,7 @@ import plain_spec from change_detection import PartialRenderStart, determine_partial_render_start -from plain2code_exceptions import ModuleDoesNotExistError +from plain2code_exceptions import MissingPreviousFunctionalitiesError, ModuleDoesNotExistError from plain_modules import PlainModule @@ -83,6 +83,30 @@ def module_comes_before_or_equal( raise ValueError(f"Module {module1.module_name} and {module2.module_name} not found in {all_required_modules}") +def _check_last_rendered_frid(module: PlainModule, last_rendered_frid: str | None) -> None: + """Stop when the build folder was rendered for a functionality the module no longer has. + + The resume point comes from the commit messages of the build folder. A folder rendered by a + client that numbered the functionalities names none of today's func-ids, and neither does one + whose functionality was removed from the specification. Either way the render cannot continue + from it. + """ + if last_rendered_frid is None: + return + + frids = list(plain_spec.get_frids(module.plain_source)) + if last_rendered_frid in frids: + return + + raise MissingPreviousFunctionalitiesError( + f"The build folder of module {module.module_name} was rendered for functionality " + f"'{last_rendered_frid}', which the module does not have. Its functionalities are: " + f"{', '.join(frids)}.\n\n" + "A build folder rendered by a client that numbered the functionalities cannot be continued. " + f"Remove {module.build_folder} and render again." + ) + + def get_plain_module_render_state( plain_module: PlainModule, render_conformance_tests: bool = False ) -> PlainModuleRenderState | None: @@ -114,6 +138,8 @@ def get_plain_module_render_state( ) module = found_module + _check_last_rendered_frid(module, last_rendered_frid) + pr = PlainModuleRenderState( last_render_module=module, last_render_frid=last_rendered_frid, diff --git a/plain_modules.py b/plain_modules.py index 3cd51cac..472992f5 100644 --- a/plain_modules.py +++ b/plain_modules.py @@ -193,8 +193,7 @@ def _bind_test_scenarios(self, test_dirs: list[str], conformance_tests_enabled: functionality whose test plain file holds no scenario is recorded instead, so the run can warn about it and go on without conformance tests for it. """ - chain = [(module.module_name, module.plain_source) for module in self.all_required_modules] - chain.append((self.module_name, self.plain_source)) + chain = self._unique_chain() bound = project.bind_test_plain_files(chain, test_dirs, self.template_dirs, self.template_dirs[0]) if conformance_tests_enabled: project.check_test_plain_files(chain, bound) @@ -363,6 +362,23 @@ def update_frid_in_module_metadata(self, frid: str) -> None: metadata_utils.write_metadata(self.module_metadata_path(), metadata) + def _unique_chain(self) -> list[tuple[str, dict]]: + """The chain as one entry per module, every module holding the one tree of its name. + + A module reached through more than one `requires` path is parsed once per path, so the chain + would otherwise carry two equal trees for it. Only one of them would get the scenarios of the + test plain files, and which one the renderer then reads is a matter of which path it took. + """ + chain: list[tuple[str, dict]] = [] + trees: dict[str, dict] = {} + for module in self.all_required_modules + [self]: + if module.module_name in trees: + module.plain_source = trees[module.module_name] + continue + trees[module.module_name] = module.plain_source + chain.append((module.module_name, module.plain_source)) + return chain + def get_module_source_hash(self) -> str: return plain_spec.get_hash_value([self.plain_source] + self.resources_list) diff --git a/tests/test_partial_rendering.py b/tests/test_partial_rendering.py index 18d3a9f8..c2f58655 100644 --- a/tests/test_partial_rendering.py +++ b/tests/test_partial_rendering.py @@ -23,7 +23,7 @@ module_comes_before_or_equal, spec_change, ) -from plain2code_exceptions import ModuleDoesNotExistError +from plain2code_exceptions import MissingPreviousFunctionalitiesError, ModuleDoesNotExistError def _tree(*frids: str) -> dict: @@ -48,6 +48,7 @@ def __init__( frids=("fr-a", "fr-b", "fr-c", "fr-d", "fr-e"), ): self.module_name = module_name + self.build_folder = "plain_modules" self.required_modules = list(required_modules or []) self._metadata = metadata self._source_hash = source_hash if source_hash is not None else f"src-{module_name}" @@ -275,51 +276,59 @@ def test_detect_partial_rendering_returns_none_when_nothing_rendered(): def test_detect_partial_rendering_no_changes_returns_last_rendered(): - root, _middle, leaf = _build_fresh_tree(last_rendered=("leaf", "1")) + root, _middle, leaf = _build_fresh_tree(last_rendered=("leaf", "fr-a")) pr = get_plain_module_render_state(root) assert isinstance(pr, PlainModuleRenderState) assert pr.last_render_module is leaf - assert pr.last_render_frid == "1" + assert pr.last_render_frid == "fr-a" assert pr.change is None assert pr.change_type is None def test_detect_partial_rendering_raises_when_last_rendered_module_unknown(): - root, _, _ = _build_fresh_tree(last_rendered=("phantom", "1")) + root, _, _ = _build_fresh_tree(last_rendered=("phantom", "fr-a")) with pytest.raises(ModuleDoesNotExistError, match="phantom"): get_plain_module_render_state(root) +def test_detect_partial_rendering_rejects_a_frid_the_module_does_not_have(): + """A build folder rendered by a client that numbered the functionalities cannot be continued.""" + root, _, _ = _build_fresh_tree(last_rendered=("leaf", "4")) + + with pytest.raises(MissingPreviousFunctionalitiesError, match="'4', which the module does not have"): + get_plain_module_render_state(root) + + def test_detect_partial_rendering_spec_change_on_earlier_module_wins(): """A spec change on an earlier (required) module is reported via ``change``/``change_type``; ``last_render_module`` and ``last_render_frid`` remain the ones reported by ``get_module_render_status``.""" - root, middle, leaf = _build_fresh_tree(last_rendered=("middle", "1")) + root, middle, leaf = _build_fresh_tree(last_rendered=("middle", "fr-a")) leaf._metadata = {"source_hash": "stale-leaf"} # leaf's spec changed pr = get_plain_module_render_state(root) assert pr.last_render_module is middle assert pr.change is leaf assert pr.change_type == "spec_change" - assert pr.last_render_frid == "1" + assert pr.last_render_frid == "fr-a" def test_detect_partial_rendering_spec_change_on_top_module_is_reported(): """A spec change on the top (root) module is reported on the ``PartialRender``; ``last_render_module``/``last_render_frid`` reflect the module that was last rendered.""" - root, _middle, leaf = _build_fresh_tree(last_rendered=("leaf", "1")) + root, _middle, leaf = _build_fresh_tree(last_rendered=("leaf", "fr-a")) root._metadata = {"source_hash": "stale-root"} # spec change on root pr = get_plain_module_render_state(root) assert pr.last_render_module is leaf assert pr.change is root assert pr.change_type == "spec_change" - assert pr.last_render_frid == "1" + assert pr.last_render_frid == "fr-a" def test_detect_partial_rendering_code_change_wins_over_last_rendered(): - root, middle, leaf = _build_fresh_tree(last_rendered=("middle", "1")) + root, middle, leaf = _build_fresh_tree(last_rendered=("middle", "fr-a")) middle._metadata = { "source_hash": middle.get_module_source_hash(), "required_modules_code_hash": "stale-code", @@ -329,7 +338,7 @@ def test_detect_partial_rendering_code_change_wins_over_last_rendered(): assert pr.last_render_module is middle assert pr.change is leaf assert pr.change_type == "code_change" - assert pr.last_render_frid == "1" + assert pr.last_render_frid == "fr-a" def test_detect_partial_rendering_spec_and_code_changes_are_mutually_exclusive(): @@ -338,7 +347,7 @@ def test_detect_partial_rendering_spec_and_code_changes_are_mutually_exclusive() current ``pr.change``. Here both point at ``leaf`` — spec_change returns the module whose spec is stale; code_change returns the previous module whose code has changed — so the code_change branch wins the tie.""" - root, middle, leaf = _build_fresh_tree(last_rendered=("middle", "1")) + root, middle, leaf = _build_fresh_tree(last_rendered=("middle", "fr-a")) # Leaf has a spec change. leaf._metadata = {"source_hash": "stale-leaf"} # Middle records a stale code hash for its required module (leaf). @@ -351,7 +360,7 @@ def test_detect_partial_rendering_spec_and_code_changes_are_mutually_exclusive() assert pr.last_render_module is middle assert pr.change is leaf assert pr.change_type == "code_change" - assert pr.last_render_frid == "1" + assert pr.last_render_frid == "fr-a" # ------------------------- From 743413d8fffe33cca8031910ce8c55107dce7264 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Thu, 29 Oct 2026 09:51:28 +0100 Subject: [PATCH 14/23] test: use func-ids in the render stop reason tests --- tests/test_render_stop_reason.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_render_stop_reason.py b/tests/test_render_stop_reason.py index c0e42619..41c69c35 100644 --- a/tests/test_render_stop_reason.py +++ b/tests/test_render_stop_reason.py @@ -100,7 +100,7 @@ def test_unit_tests_failing_after_the_last_attempt_name_the_cause(workspace, fix render_context = make_render_context(api, build_folder, unittests_script, fixtures_dir) code_renderer = CodeRenderer(render_context) - # The last render attempt of functionality 1, after its last unit-test fix round. + # The last render attempt of the first functionality, after its last unit-test fix round. render_context.start_implementing_frid() render_context.frid_context.functional_requirement_render_attempts = MAX_CODE_GENERATION_RETRIES render_context.unit_tests_running_context = UnitTestsRunningContext(fix_attempts=MAX_UNITTEST_FIX_ATTEMPTS) @@ -112,13 +112,13 @@ def test_unit_tests_failing_after_the_last_attempt_name_the_cause(workspace, fix code_renderer.run() assert render_context.state == States.RENDER_FAILED.value - assert api.failed_frids == ["1"] + assert api.failed_frids == ["solo-one"] last_output_path = render_context.script_execution_history.latest_unit_test_output_path assert last_output_path is not None and os.path.exists(last_output_path) expected_message = ( - f"Functionality 1: unit tests still fail after {MAX_CODE_GENERATION_RETRIES} render attempts, " + f"Functionality pr_solo:solo-one: unit tests still fail after {MAX_CODE_GENERATION_RETRIES} render attempts, " f"{MAX_UNITTEST_FIX_ATTEMPTS} fix rounds each. Last unit tests output: {last_output_path}" ) assert error_log_messages(caplog) == [expected_message] @@ -155,7 +155,7 @@ def test_unit_tests_failing_again_during_conformance_tests_name_the_cause_once(w error_messages = error_log_messages(caplog) assert len(error_messages) == 1 assert error_messages[0].startswith("Failed to adjust the unit tests") - assert "functionality 1" in error_messages[0] + assert "functionality pr_solo:solo-one" in error_messages[0] # The TUI shows the same stop reason as the log, not the unit tests output of the payload. assert render_context.last_error_message == error_messages[0] @@ -165,7 +165,7 @@ class TestExitWithErrorMessage: def make_context(last_error_message=None): render_context = RenderContext.__new__(RenderContext) render_context.codeplain_api = FakeCodeplainAPI() - render_context.frid_context = SimpleNamespace(frid="1") + render_context.frid_context = SimpleNamespace(frid="solo-one") render_context.module_name = "mod" render_context.run_state = RunState(spec_filename="mod.plain") render_context.last_error_message = last_error_message From f021773aec7e8c275060fe765cb9c51faf53660f Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Thu, 29 Oct 2026 14:10:12 +0100 Subject: [PATCH 15/23] fix: render the acceptance scenarios that remain after a code fix A conformance fix that changed implementation code moved the execution phase to RETRYING_AFTER_CODE_CHANGE. When the test passed again, the render went straight to regression, and the phase that renders the acceptance scenarios was never entered again. Nothing sets that phase back, so every scenario that had no test yet was lost without a word. In one render this dropped 9 of 36 written acceptance scenarios. The render now goes back to the testing phase of the functionality while acceptance scenarios remain. Regression starts after the last one, as before. --- render_machine/render_context.py | 19 ++- ...t_acceptance_scenarios_after_a_code_fix.py | 110 ++++++++++++++++++ 2 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 tests/test_acceptance_scenarios_after_a_code_fix.py diff --git a/render_machine/render_context.py b/render_machine/render_context.py index 53c72d50..4a0bb2d0 100644 --- a/render_machine/render_context.py +++ b/render_machine/render_context.py @@ -458,11 +458,20 @@ def _on_conformance_test_passed_after_retry(self): """Called when a test passes after being retried due to code changes.""" ctx = self.conformance_tests_running_context - if ctx.execution_phase == TestExecutionPhase.RETRYING_AFTER_CODE_CHANGE: - # Test passed after code change - mark that code changed and restart regression - ctx.code_changed_during_regression = True - ctx.test_that_triggered_code_change = None - self._start_regression_phase() + if ctx.execution_phase != TestExecutionPhase.RETRYING_AFTER_CODE_CHANGE: + return + + # Test passed after code change - mark that code changed + ctx.code_changed_during_regression = True + ctx.test_that_triggered_code_change = None + + if self._has_more_acceptance_test_phases(): + # The functionality still has acceptance scenarios with no test. Go back to its own testing + # phase, so that the scenarios that remain still get one. Regression starts after them. + ctx.execution_phase = TestExecutionPhase.TESTING_CURRENT_FRID + return + + self._start_regression_phase() def _handle_current_frid_testing(self): """Handle incremental testing of the current FRID being implemented.""" diff --git a/tests/test_acceptance_scenarios_after_a_code_fix.py b/tests/test_acceptance_scenarios_after_a_code_fix.py new file mode 100644 index 00000000..6fee50c6 --- /dev/null +++ b/tests/test_acceptance_scenarios_after_a_code_fix.py @@ -0,0 +1,110 @@ +"""A conformance fix that changes implementation code must not cancel the acceptance scenarios. + +The fix moves the execution phase to RETRYING_AFTER_CODE_CHANGE. When the test then passes, the +render must go back to the testing phase of the functionality while acceptance scenarios remain, +because that phase holds the only code that renders them. Regression starts after the last one. +""" + +from types import SimpleNamespace + +import pytest + +import plain_spec +from render_machine.render_context import RenderContext +from render_machine.render_types import ( + AcceptanceTestPhase, + ConformanceTestsRunningContext, +) +from render_machine.render_types import TestExecutionPhase as ExecutionPhase + +MODULE = "vault-cli" +FRID = "get-command" +SCENARIOS = [ + {"id": "get-with-an-id-that-is-not-in-the-vault", "text": "..."}, + {"id": "get-with-a-wrong-master-password", "text": "..."}, + {"id": "get-from-a-vault-that-is-not-there", "text": "..."}, +] + + +def make_render_context(acceptance_scenarios, acceptance_test_phase, acceptance_tests_completed): + """A render context stopped right after a code fix, with its test passing again.""" + render_context = RenderContext.__new__(RenderContext) + render_context.module_name = MODULE + render_context.frid_context = SimpleNamespace( + frid=FRID, specifications={plain_spec.ACCEPTANCE_TESTS: acceptance_scenarios} + ) + + ctx = ConformanceTestsRunningContext( + current_testing_module_name=MODULE, + current_testing_frid=FRID, + fix_attempts=0, + conformance_tests_json={FRID: {"folder_name": "get_command"}}, + conformance_tests_render_attempts=0, + current_testing_frid_specifications={plain_spec.ACCEPTANCE_TESTS: acceptance_scenarios}, + should_prepare_testing_environment=False, + frid_being_implemented=FRID, + ) + ctx.execution_phase = ExecutionPhase.RETRYING_AFTER_CODE_CHANGE + ctx.test_that_triggered_code_change = (MODULE, FRID) + ctx.acceptance_test_phase = acceptance_test_phase + ctx.acceptance_tests_completed = acceptance_tests_completed + render_context.conformance_tests_running_context = ctx + + return render_context + + +def test_a_scenario_that_remains_gets_a_test_after_a_code_fix(): + """The first scenario has a test, the fix changed code, two scenarios remain.""" + render_context = make_render_context(SCENARIOS, AcceptanceTestPhase.IN_PROGRESS, 1) + + render_context.start_conformance_tests_for_frid() + + ctx = render_context.conformance_tests_running_context + # The render asks for the second scenario instead of moving on to regression. + assert ctx.acceptance_tests_completed == 2 + assert ctx.execution_phase == ExecutionPhase.TESTING_CURRENT_FRID + assert ctx.acceptance_test_phase == AcceptanceTestPhase.IN_PROGRESS + + +def test_every_scenario_is_reached_when_a_code_fix_follows_each_one(): + """A code fix after every scenario still leaves no scenario without a test.""" + render_context = make_render_context(SCENARIOS, AcceptanceTestPhase.IN_PROGRESS, 1) + ctx = render_context.conformance_tests_running_context + + for _ in range(len(SCENARIOS) - 1): + ctx.execution_phase = ExecutionPhase.RETRYING_AFTER_CODE_CHANGE + ctx.test_that_triggered_code_change = (MODULE, FRID) + render_context.start_conformance_tests_for_frid() + + assert ctx.acceptance_tests_completed == len(SCENARIOS) + + +def test_regression_starts_when_every_scenario_has_a_test(): + render_context = make_render_context(SCENARIOS, AcceptanceTestPhase.IN_PROGRESS, len(SCENARIOS)) + + render_context._on_conformance_test_passed_after_retry() + + ctx = render_context.conformance_tests_running_context + assert ctx.execution_phase == ExecutionPhase.RUNNING_REGRESSION + assert ctx.code_changed_during_regression is True + + +@pytest.mark.parametrize( + "acceptance_scenarios, acceptance_test_phase, acceptance_tests_completed", + [ + ([], AcceptanceTestPhase.NOT_APPLICABLE, 1), + (SCENARIOS, AcceptanceTestPhase.COMPLETED, len(SCENARIOS)), + ], + ids=["no acceptance scenario", "acceptance phase completed"], +) +def test_regression_starts_when_the_functionality_has_no_scenario_left( + acceptance_scenarios, acceptance_test_phase, acceptance_tests_completed +): + """A code fix during regression keeps the behaviour it had before.""" + render_context = make_render_context(acceptance_scenarios, acceptance_test_phase, acceptance_tests_completed) + + render_context._on_conformance_test_passed_after_retry() + + ctx = render_context.conformance_tests_running_context + assert ctx.execution_phase == ExecutionPhase.RUNNING_REGRESSION + assert ctx.current_testing_frid is None From 76dc42234dcb6653c1792cb339804c2108700891 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Thu, 29 Oct 2026 14:10:17 +0100 Subject: [PATCH 16/23] feat: check that the acceptance tests implement the scenarios The conformance call checked its traceability and warned. The acceptance call did not: the endpoint returned the files alone, so neither a scenario without a test nor a test without a scenario produced one warning line. Both calls now return the same object, and the client checks both the same way. The check also reports a scenario that more than one test implements, because one scenario asks for one test. --- codeplain_REST_api.py | 6 ++-- .../actions/render_conformance_tests.py | 19 ++++++++++-- tests/test_traceability_warnings.py | 31 ++++++++++++++++--- 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/codeplain_REST_api.py b/codeplain_REST_api.py index ec5c9e54..3747d544 100644 --- a/codeplain_REST_api.py +++ b/codeplain_REST_api.py @@ -480,7 +480,8 @@ def render_acceptance_tests( scenario_id (str): The scenario-id of the acceptance scenario to render. Returns: - dict: The rendered acceptance tests. + tuple: The rendered acceptance tests, and the traceability of every scenario of the + functionality a test is due for. Raises: Exception: If the request fails or returns an error. @@ -500,7 +501,8 @@ def render_acceptance_tests( "scenario_id": scenario_id, } - return self.post_request(endpoint_url, headers, payload, run_state) + response = self.post_request(endpoint_url, headers, payload, run_state) + return response["patched_response_files"], response["traceability"] def analyze_rendering( self, diff --git a/render_machine/actions/render_conformance_tests.py b/render_machine/actions/render_conformance_tests.py index 0bd3fb6a..12da24b6 100644 --- a/render_machine/actions/render_conformance_tests.py +++ b/render_machine/actions/render_conformance_tests.py @@ -140,7 +140,13 @@ def _render_conformance_tests(self, render_context: RenderContext): @staticmethod def _warn_about_untraced_scenarios(render_context: RenderContext, traceability: dict) -> None: - """Warn about scenarios with no test, and about tests that implement no scenario.""" + """Warn about a scenario with no test, a test that implements no scenario, and a scenario + with more than one test. + + Both test renders report the same traceability, so both are checked the same way. A render + that writes a test the specification did not ask for, or leaves one it did ask for unwritten, + must say so instead of reporting success. + """ frid = render_context.conformance_tests_running_context.current_testing_frid missing = traceability.get("missing") or [] @@ -151,6 +157,12 @@ def _warn_about_untraced_scenarios(render_context: RenderContext, traceability: if extra: console.warning(f"Conformance tests of {frid} that implement no scenario: {', '.join(extra)}.") + duplicated = traceability.get("duplicated") or [] + if duplicated: + console.warning( + f"More than one conformance test of {frid} implements the scenarios: {', '.join(duplicated)}." + ) + def _render_acceptance_test(self, render_context: RenderContext): if plain_spec.ACCEPTANCE_TESTS not in render_context.frid_context.specifications: # If there are no acceptance tests defined, continue. @@ -175,7 +187,7 @@ def _render_acceptance_test(self, render_context: RenderContext): console.info(f"Generating acceptance test for scenario {scenario['id']}.") - response_files = render_context.codeplain_api.render_acceptance_tests( + response_files, traceability = render_context.codeplain_api.render_acceptance_tests( render_context.frid_context.frid, render_context.plain_source_tree, render_context.frid_context.linked_resources, @@ -187,6 +199,9 @@ def _render_acceptance_test(self, render_context: RenderContext): scenario["id"], run_state=render_context.run_state, ) + + self._warn_about_untraced_scenarios(render_context, traceability) + conformance_tests_folder_name = ( render_context.conformance_tests_running_context.get_current_conformance_test_folder_name() ) diff --git a/tests/test_traceability_warnings.py b/tests/test_traceability_warnings.py index e6ad334b..90f50194 100644 --- a/tests/test_traceability_warnings.py +++ b/tests/test_traceability_warnings.py @@ -1,4 +1,6 @@ -"""The client warns about scenarios with no conformance test, and about tests that implement no scenario.""" +"""The client warns about a scenario with no conformance test, a test that implements no scenario, +and a scenario with more than one test. Both test renders report the same traceability, so the +conformance call and the acceptance call are checked the same way.""" from types import SimpleNamespace from unittest.mock import patch @@ -36,10 +38,31 @@ def test_a_test_implementing_no_scenario_is_named(): assert "test_something_else" in warning -def test_both_are_reported(): - warnings = _warn({"implemented": {}, "missing": ["add-second-task"], "extra": ["test_something_else"]}) +def test_a_scenario_with_more_than_one_test_is_named(): + [warning] = _warn( + { + "implemented": {"add-first-task": ["test_add_first_task", "test_add_first_task_scenario"]}, + "missing": [], + "extra": [], + "duplicated": ["add-first-task"], + } + ) + + assert "add-task" in warning + assert "add-first-task" in warning + + +def test_every_fault_is_reported(): + warnings = _warn( + { + "implemented": {}, + "missing": ["add-second-task"], + "extra": ["test_something_else"], + "duplicated": ["add-first-task"], + } + ) - assert len(warnings) == 2 + assert len(warnings) == 3 def test_a_response_without_the_lists_warns_about_nothing(): From ec2a4fe8e7b8bc8d31ebb93b6fe23e29e3058553 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Thu, 29 Oct 2026 15:53:25 +0100 Subject: [PATCH 17/23] docs: add --job-tag to the CLI reference --- docs/plain2code_cli.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/plain2code_cli.md b/docs/plain2code_cli.md index d1beffcb..89938ca1 100644 --- a/docs/plain2code_cli.md +++ b/docs/plain2code_cli.md @@ -13,9 +13,9 @@ usage: generate_cli.py [-h] [--verbose] [--base-folder BASE_FOLDER] [--test-script-timeout TEST_SCRIPT_TIMEOUT] [--api [API]] [--api-key API_KEY] [--full-plain] [--dry-run] [--replay-with REPLAY_WITH] - [--template-dir TEMPLATE_DIR] [--test-dir TEST_DIR] - [--copy-build] [--build-dest BUILD_DEST] - [--copy-conformance-tests] + [--job-tag JOB_TAG] [--template-dir TEMPLATE_DIR] + [--test-dir TEST_DIR] [--copy-build] + [--build-dest BUILD_DEST] [--copy-conformance-tests] [--conformance-tests-dest CONFORMANCE_TESTS_DEST] [--render-machine-graph] [--logging-config-path LOGGING_CONFIG_PATH] @@ -94,6 +94,8 @@ options: --dry-run Dry run preview of the code generation (without actually making any changes). --replay-with REPLAY_WITH + --job-tag JOB_TAG Tag sent to the server with every request and attached + to the render's LangSmith traces (max 300 characters). --template-dir TEMPLATE_DIR Path to a custom template directory. Templates are searched in the following order: 1) Directory From 1bad7b3a4921ba9c93989db80370ac500cce7414 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Fri, 30 Oct 2026 00:02:05 +0100 Subject: [PATCH 18/23] fix: render an acceptance scenario once, whatever path asks for it The action that renders an acceptance test picked its scenario by an index into the list, acceptance_tests_completed - 1. That counter belongs to the state machine, not to the action, and the action is entered through more than one path. A path that entered it again without moving the counter asked for the same scenario a second time. Nothing could tell that apart from a normal call, so the render paid for another call and could write a second test for the one scenario. The render context now records the scenarios that have a test. A repeat renders nothing and warns, before it reads any file. --- .../actions/render_conformance_tests.py | 29 +++-- render_machine/render_types.py | 4 + .../test_acceptance_scenario_rendered_once.py | 117 ++++++++++++++++++ 3 files changed, 140 insertions(+), 10 deletions(-) create mode 100644 tests/test_acceptance_scenario_rendered_once.py diff --git a/render_machine/actions/render_conformance_tests.py b/render_machine/actions/render_conformance_tests.py index 12da24b6..f3de21c6 100644 --- a/render_machine/actions/render_conformance_tests.py +++ b/render_machine/actions/render_conformance_tests.py @@ -168,6 +168,21 @@ def _render_acceptance_test(self, render_context: RenderContext): # If there are no acceptance tests defined, continue. return self.SUCCESSFUL_OUTCOME, None + ctx = render_context.conformance_tests_running_context + + # Get the current acceptance scenario being rendered (completed count is 1-based) + scenario = render_context.frid_context.specifications[plain_spec.ACCEPTANCE_TESTS][ + ctx.acceptance_tests_completed - 1 + ] + + # This action is entered through more than one path, and the counter above belongs to the + # state machine, not to the action. A path that enters it again without the counter moving + # asks for a scenario that already has a test. Rendering it again costs a call and lets the + # render write a second test for the one scenario. + if scenario["id"] in ctx.rendered_acceptance_scenarios: + console.warning(f"The acceptance scenario {scenario['id']} already has a test. It is not rendered again.") + return self.SUCCESSFUL_OUTCOME, None + _, existing_files_content = ImplementationCodeHelpers.fetch_existing_files(render_context.build_folder) _, memory_files_content = MemoryManager.fetch_memory_files(render_context.memory_manager.memory_folder) ( @@ -176,15 +191,10 @@ def _render_acceptance_test(self, render_context: RenderContext): ) = render_context.conformance_tests.fetch_existing_conformance_test_files( render_context.module_name, render_context.required_modules, - render_context.conformance_tests_running_context.current_testing_module_name, - render_context.conformance_tests_running_context.get_current_conformance_test_folder_name(), + ctx.current_testing_module_name, + ctx.get_current_conformance_test_folder_name(), ) - # Get the current acceptance scenario being rendered (completed count is 1-based) - scenario = render_context.frid_context.specifications[plain_spec.ACCEPTANCE_TESTS][ - render_context.conformance_tests_running_context.acceptance_tests_completed - 1 - ] - console.info(f"Generating acceptance test for scenario {scenario['id']}.") response_files, traceability = render_context.codeplain_api.render_acceptance_tests( @@ -201,10 +211,9 @@ def _render_acceptance_test(self, render_context: RenderContext): ) self._warn_about_untraced_scenarios(render_context, traceability) + ctx.rendered_acceptance_scenarios.append(scenario["id"]) - conformance_tests_folder_name = ( - render_context.conformance_tests_running_context.get_current_conformance_test_folder_name() - ) + conformance_tests_folder_name = ctx.get_current_conformance_test_folder_name() file_utils.store_response_files(conformance_tests_folder_name, response_files, conformance_tests_files) console.print_files( diff --git a/render_machine/render_types.py b/render_machine/render_types.py index 6b3a2a83..02ed1da0 100644 --- a/render_machine/render_types.py +++ b/render_machine/render_types.py @@ -126,6 +126,10 @@ def __init__( self.execution_phase: TestExecutionPhase = TestExecutionPhase.TESTING_CURRENT_FRID self.acceptance_test_phase: AcceptanceTestPhase = AcceptanceTestPhase.NOT_STARTED self.acceptance_tests_completed: int = 0 + # The acceptance scenarios of this functionality that already have a test, in render order. + # The action that renders them is entered through more than one path, so it reads this + # instead of trusting the counter above, which a repeated entry leaves where it was. + self.rendered_acceptance_scenarios: list[str] = [] self.frid_being_implemented: Optional[str] = frid_being_implemented self.test_that_triggered_code_change: Optional[tuple[str, str]] = None self.code_changed_during_regression: bool = False diff --git a/tests/test_acceptance_scenario_rendered_once.py b/tests/test_acceptance_scenario_rendered_once.py new file mode 100644 index 00000000..66ea821d --- /dev/null +++ b/tests/test_acceptance_scenario_rendered_once.py @@ -0,0 +1,117 @@ +"""An acceptance scenario is rendered once, whatever path enters the render action. + +The action reads the scenario from `acceptance_tests_completed`, a counter the state machine owns. +A path that enters the action again without moving the counter asked for the same scenario a second +time, which cost a call and let the render write a second test for the one scenario. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +import plain_spec +from render_machine.actions.render_conformance_tests import RenderConformanceTests +from render_machine.render_types import ConformanceTestsRunningContext + +MODULE = "vault_functions" +FRID = "change-encryption-key" +SCENARIOS = [ + {"id": "the-old-key-no-longer-opens-the-vault", "text": "..."}, + {"id": "change-with-a-wrong-current-key", "text": "..."}, +] + + +@pytest.fixture +def render_context(tmp_path): + ctx = ConformanceTestsRunningContext( + current_testing_module_name=MODULE, + current_testing_frid=FRID, + fix_attempts=0, + conformance_tests_json={FRID: {"folder_name": str(tmp_path)}}, + conformance_tests_render_attempts=0, + current_testing_frid_specifications={plain_spec.ACCEPTANCE_TESTS: SCENARIOS}, + should_prepare_testing_environment=False, + frid_being_implemented=FRID, + ) + ctx.acceptance_tests_completed = 1 + + api = MagicMock() + api.render_acceptance_tests.return_value = ({}, {"implemented": {}, "missing": [], "extra": []}) + + conformance_tests = MagicMock() + conformance_tests.fetch_existing_conformance_test_files.return_value = ({}, {}) + + return SimpleNamespace( + module_name=MODULE, + build_folder=str(tmp_path), + plain_source_tree={}, + required_modules=[], + run_state=SimpleNamespace(), + codeplain_api=api, + conformance_tests=conformance_tests, + conformance_tests_running_context=ctx, + memory_manager=SimpleNamespace(memory_folder=str(tmp_path)), + frid_context=SimpleNamespace( + frid=FRID, linked_resources={}, specifications={plain_spec.ACCEPTANCE_TESTS: SCENARIOS} + ), + get_required_modules_functionalities=lambda: {}, + ) + + +def _render(render_context): + with patch("render_machine.actions.render_conformance_tests.ImplementationCodeHelpers") as code_helpers, patch( + "render_machine.actions.render_conformance_tests.MemoryManager" + ) as memory, patch("render_machine.actions.render_conformance_tests.file_utils"), patch( + "render_machine.actions.render_conformance_tests.console" + ) as console: + code_helpers.fetch_existing_files.return_value = ({}, {}) + memory.fetch_memory_files.return_value = ({}, {}) + outcome, _ = RenderConformanceTests()._render_acceptance_test(render_context) + return outcome, [call.args[0] for call in console.warning.call_args_list] + + +def test_the_scenario_is_rendered_and_recorded(render_context): + outcome, warnings = _render(render_context) + + assert outcome == RenderConformanceTests.SUCCESSFUL_OUTCOME + assert render_context.codeplain_api.render_acceptance_tests.call_count == 1 + assert render_context.conformance_tests_running_context.rendered_acceptance_scenarios == [SCENARIOS[0]["id"]] + assert warnings == [] + + +def test_a_second_entry_without_the_counter_moving_renders_nothing(render_context): + _render(render_context) + + outcome, warnings = _render(render_context) + + assert outcome == RenderConformanceTests.SUCCESSFUL_OUTCOME + # The call that would have written a second test for the scenario is not made. + assert render_context.codeplain_api.render_acceptance_tests.call_count == 1 + assert len(warnings) == 1 + assert SCENARIOS[0]["id"] in warnings[0] + + +def test_the_next_scenario_is_rendered_once_the_counter_moves(render_context): + _render(render_context) + render_context.conformance_tests_running_context.acceptance_tests_completed = 2 + + outcome, warnings = _render(render_context) + + assert outcome == RenderConformanceTests.SUCCESSFUL_OUTCOME + assert render_context.codeplain_api.render_acceptance_tests.call_count == 2 + assert [call.args[8] for call in render_context.codeplain_api.render_acceptance_tests.call_args_list] == [ + SCENARIOS[0]["id"], + SCENARIOS[1]["id"], + ] + assert warnings == [] + + +def test_a_functionality_without_acceptance_scenarios_renders_nothing(render_context): + render_context.frid_context.specifications = {} + + outcome, _ = _render(render_context) + + assert outcome == RenderConformanceTests.SUCCESSFUL_OUTCOME + assert render_context.codeplain_api.render_acceptance_tests.call_count == 0 + From f15373bc7d3ebfc8df50c677c3e91c891dc5f84e Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Fri, 30 Oct 2026 00:02:33 +0100 Subject: [PATCH 19/23] fix: write the acceptance tests again when the conformance tests are regenerated Regeneration deletes the tests folder of a functionality and removes its entry. The acceptance tests of the functionality live in that same folder, so they are deleted with it, but the acceptance phase was left where it stood. The render then held a folder name for a folder that is gone. A functionality that reaches the regeneration limit while its acceptance scenarios are being rendered hits this, because the fix attempts are not counted per scenario. Regeneration now starts the acceptance phase again, so the functionality writes all of its tests from the first scenario, as it does when regeneration falls before the acceptance phase. --- render_machine/render_context.py | 6 +++ .../test_acceptance_scenario_rendered_once.py | 53 ++++++++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/render_machine/render_context.py b/render_machine/render_context.py index 4a0bb2d0..07633285 100644 --- a/render_machine/render_context.py +++ b/render_machine/render_context.py @@ -444,6 +444,12 @@ def _handle_test_regeneration(self): ctx.fix_attempts = 0 ctx.regenerating_conformance_tests = False + # The folder held the acceptance tests of the functionality as well, so they are gone with + # it. The functionality writes all of its tests again, from the first scenario. + ctx.acceptance_test_phase = AcceptanceTestPhase.NOT_STARTED + ctx.acceptance_tests_completed = 0 + ctx.rendered_acceptance_scenarios = [] + def _handle_retry_after_code_change(self): """Re-run the test that failed and triggered a code change.""" ctx = self.conformance_tests_running_context diff --git a/tests/test_acceptance_scenario_rendered_once.py b/tests/test_acceptance_scenario_rendered_once.py index 66ea821d..2a503096 100644 --- a/tests/test_acceptance_scenario_rendered_once.py +++ b/tests/test_acceptance_scenario_rendered_once.py @@ -12,7 +12,7 @@ import plain_spec from render_machine.actions.render_conformance_tests import RenderConformanceTests -from render_machine.render_types import ConformanceTestsRunningContext +from render_machine.render_types import AcceptanceTestPhase, ConformanceTestsRunningContext MODULE = "vault_functions" FRID = "change-encryption-key" @@ -115,3 +115,54 @@ def test_a_functionality_without_acceptance_scenarios_renders_nothing(render_con assert outcome == RenderConformanceTests.SUCCESSFUL_OUTCOME assert render_context.codeplain_api.render_acceptance_tests.call_count == 0 + +class TestRegeneratedConformanceTests: + """Regeneration deletes the tests folder of the functionality, so the acceptance tests in it are + gone too. The functionality must then write all of its tests again, from the first scenario.""" + + @staticmethod + def make_render_context(tmp_path, acceptance_tests_completed, rendered): + from render_machine.render_context import RenderContext + + render_context = RenderContext.__new__(RenderContext) + render_context.module_name = MODULE + folder = tmp_path / "tests_folder" + folder.mkdir() + + ctx = ConformanceTestsRunningContext( + current_testing_module_name=MODULE, + current_testing_frid=FRID, + fix_attempts=5, + conformance_tests_json={FRID: {"folder_name": str(folder)}}, + conformance_tests_render_attempts=0, + current_testing_frid_specifications={plain_spec.ACCEPTANCE_TESTS: SCENARIOS}, + should_prepare_testing_environment=False, + frid_being_implemented=FRID, + ) + ctx.acceptance_test_phase = AcceptanceTestPhase.IN_PROGRESS + ctx.acceptance_tests_completed = acceptance_tests_completed + ctx.rendered_acceptance_scenarios = list(rendered) + ctx.regenerating_conformance_tests = True + render_context.conformance_tests_running_context = ctx + + return render_context + + def test_the_acceptance_phase_starts_again(self, tmp_path): + render_context = self.make_render_context(tmp_path, 1, [SCENARIOS[0]["id"]]) + + with patch("render_machine.render_context.console"): + render_context._handle_test_regeneration() + + ctx = render_context.conformance_tests_running_context + assert ctx.acceptance_test_phase == AcceptanceTestPhase.NOT_STARTED + assert ctx.acceptance_tests_completed == 0 + assert ctx.rendered_acceptance_scenarios == [] + + def test_the_deleted_folder_is_no_longer_claimed(self, tmp_path): + render_context = self.make_render_context(tmp_path, 1, [SCENARIOS[0]["id"]]) + + with patch("render_machine.render_context.console"): + render_context._handle_test_regeneration() + + ctx = render_context.conformance_tests_running_context + assert not ctx.current_conformance_tests_exist() From aa47895319add7818e51fef9e4b0328b15a820a0 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Fri, 30 Oct 2026 11:39:53 +0100 Subject: [PATCH 20/23] fix: read the markdown of a required module functionality in the unit tests fixer A functionality of a required module became an id and markdown entry, in place of bare markdown. The unit tests fixer still joined the entries as text when it built the functionalities section of its prompt, so every render that fixes a unit test in a module with a required module stopped with "sequence item 0: expected str instance, dict found". The test of this section fed the old shape, so it did not catch the change. It now feeds the shape the renderer gets. --- render_machine/actions/fix_unit_tests.py | 4 +++- tests/test_fix_unit_tests_action.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/render_machine/actions/fix_unit_tests.py b/render_machine/actions/fix_unit_tests.py index 4575b9e2..c351a210 100644 --- a/render_machine/actions/fix_unit_tests.py +++ b/render_machine/actions/fix_unit_tests.py @@ -232,8 +232,10 @@ def _relevant_files(build_folder: str, file_names: set[str]) -> dict[str, str]: def _functional_requirements_section(render_context: RenderContext, specifications: dict) -> str: sections = [] for module_name, functionalities in render_context.get_required_modules_functionalities().items(): + # A functionality of a required module is an `id` / `markdown` entry, not bare markdown. sections.append( - f"### Module: {module_name} (Already Implemented, for context)\n" + "\n".join(functionalities) + f"### Module: {module_name} (Already Implemented, for context)\n" + + "\n".join(functionality["markdown"] for functionality in functionalities) ) current = specifications.get(plain_spec.FUNCTIONAL_REQUIREMENTS, []) if len(current) > 1: diff --git a/tests/test_fix_unit_tests_action.py b/tests/test_fix_unit_tests_action.py index 27b9bea5..d7b18f3c 100644 --- a/tests/test_fix_unit_tests_action.py +++ b/tests/test_fix_unit_tests_action.py @@ -65,7 +65,7 @@ def render_context(tmp_path, monkeypatch): unit_tests_running_context=UnitTestsRunningContext(fix_attempts=1), conformance_tests_running_context=None, script_execution_history=ScriptExecutionHistory(), - get_required_modules_functionalities=lambda: {"base": ["- Base feature."]}, + get_required_modules_functionalities=lambda: {"base": [{"id": "base-feature", "markdown": "- Base feature."}]}, ) From d320a768c37172f0acf55d65d60a878734957784 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Fri, 30 Oct 2026 12:48:22 +0100 Subject: [PATCH 21/23] style: format the acceptance scenario test with black --- tests/test_acceptance_scenario_rendered_once.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/test_acceptance_scenario_rendered_once.py b/tests/test_acceptance_scenario_rendered_once.py index 2a503096..cc03c43e 100644 --- a/tests/test_acceptance_scenario_rendered_once.py +++ b/tests/test_acceptance_scenario_rendered_once.py @@ -60,11 +60,12 @@ def render_context(tmp_path): def _render(render_context): - with patch("render_machine.actions.render_conformance_tests.ImplementationCodeHelpers") as code_helpers, patch( - "render_machine.actions.render_conformance_tests.MemoryManager" - ) as memory, patch("render_machine.actions.render_conformance_tests.file_utils"), patch( - "render_machine.actions.render_conformance_tests.console" - ) as console: + with ( + patch("render_machine.actions.render_conformance_tests.ImplementationCodeHelpers") as code_helpers, + patch("render_machine.actions.render_conformance_tests.MemoryManager") as memory, + patch("render_machine.actions.render_conformance_tests.file_utils"), + patch("render_machine.actions.render_conformance_tests.console") as console, + ): code_helpers.fetch_existing_files.return_value = ({}, {}) memory.fetch_memory_files.return_value = ({}, {}) outcome, _ = RenderConformanceTests()._render_acceptance_test(render_context) From b1647aee65c84a33fd4f11840ef1363643976ef2 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Sat, 31 Oct 2026 20:30:24 +0100 Subject: [PATCH 22/23] feat: rename the test folder option to --tests-folder The option and the config key are now named after what they hold, a folder of test plain files, and they match the word the parser is to take. There is no alias. A config file that still says test-dir is refused by name, so an old project says what to change instead of reading no test plain file in silence. --- docs/plain2code_cli.md | 11 ++++---- .../example_hello_world_golang/config.yaml | 2 +- .../example_hello_world_python/config.yaml | 2 +- .../example_hello_world_react/config.yaml | 2 +- plain2code.py | 2 +- plain2code_arguments.py | 10 +++---- plain_modules.py | 12 ++++----- tests/test_render_finished_report.py | 2 +- tests/test_script_path_resolution.py | 4 +-- tests/test_terminal_attachment.py | 2 +- ...est_test_dirs.py => test_tests_folders.py} | 26 +++++++++---------- 11 files changed, 38 insertions(+), 37 deletions(-) rename tests/{test_test_dirs.py => test_tests_folders.py} (71%) diff --git a/docs/plain2code_cli.md b/docs/plain2code_cli.md index 89938ca1..7c9ca9ae 100644 --- a/docs/plain2code_cli.md +++ b/docs/plain2code_cli.md @@ -14,7 +14,7 @@ usage: generate_cli.py [-h] [--verbose] [--base-folder BASE_FOLDER] [--api [API]] [--api-key API_KEY] [--full-plain] [--dry-run] [--replay-with REPLAY_WITH] [--job-tag JOB_TAG] [--template-dir TEMPLATE_DIR] - [--test-dir TEST_DIR] [--copy-build] + [--tests-folder TESTS_FOLDER] [--copy-build] [--build-dest BUILD_DEST] [--copy-conformance-tests] [--conformance-tests-dest CONFORMANCE_TESTS_DEST] [--render-machine-graph] @@ -102,11 +102,12 @@ options: containing the plain file, 2) Custom template directory (if provided through this argument), 3) Built-in standard_template_library directory - --test-dir TEST_DIR Folder scanned for test plain files; repeat the option + --tests-folder TESTS_FOLDER + Folder scanned for test plain files; repeat the option for more than one. There is no default: without this - option and without the 'test-dir' config key, no test - plain file is read. Required when a conformance tests - script is configured. + option and without the 'tests-folder' config key, no + test plain file is read. Required when a conformance + tests script is configured. --copy-build If set, copy the rendered contents of code in `--base- folder` folder to `--build-dest` folder after successful rendering. diff --git a/examples/example_hello_world_golang/config.yaml b/examples/example_hello_world_golang/config.yaml index 333d3397..63ca941a 100644 --- a/examples/example_hello_world_golang/config.yaml +++ b/examples/example_hello_world_golang/config.yaml @@ -1,4 +1,4 @@ verbose: true unittests-script: ../../test_scripts/run_unittests_golang.sh conformance-tests-script: ../../test_scripts/run_conformance_tests_golang.sh -test-dir: tests +tests-folder: tests diff --git a/examples/example_hello_world_python/config.yaml b/examples/example_hello_world_python/config.yaml index 026a41fa..b8ce61a9 100644 --- a/examples/example_hello_world_python/config.yaml +++ b/examples/example_hello_world_python/config.yaml @@ -1,4 +1,4 @@ unittests-script: ../../test_scripts/run_unittests_python.sh conformance-tests-script: ../../test_scripts/run_conformance_tests_python.sh verbose: true -test-dir: tests +tests-folder: tests diff --git a/examples/example_hello_world_react/config.yaml b/examples/example_hello_world_react/config.yaml index b7e11afc..1d7cf8a6 100644 --- a/examples/example_hello_world_react/config.yaml +++ b/examples/example_hello_world_react/config.yaml @@ -1,3 +1,3 @@ conformance-tests-script: ../../test_scripts/run_conformance_tests_cypress.sh verbose: true -test-dir: tests +tests-folder: tests diff --git a/plain2code.py b/plain2code.py index 4113f78b..9a2544b1 100644 --- a/plain2code.py +++ b/plain2code.py @@ -513,7 +513,7 @@ def main(): # noqa: C901 os.path.basename(args.filename), args.build_folder, template_dirs, - args.test_dir, + args.tests_folder, args.render_conformance_tests, ) except Exception as e: diff --git a/plain2code_arguments.py b/plain2code_arguments.py index c1f012f5..df4b2082 100644 --- a/plain2code_arguments.py +++ b/plain2code_arguments.py @@ -441,11 +441,11 @@ def create_parser(): ) _add_arg( parser, - "--test-dir", + "--tests-folder", action="append", default=None, help="Folder scanned for test plain files; repeat the option for more than one. " - "There is no default: without this option and without the 'test-dir' config key, no test " + "There is no default: without this option and without the 'tests-folder' config key, no test " "plain file is read. Required when a conformance tests script is configured.", path_list=True, ) @@ -572,10 +572,10 @@ def parse_arguments(command_line: Optional[Sequence[str]] = None): args.render_conformance_tests = args.conformance_tests_script is not None - if args.render_conformance_tests and not args.test_dir: + if args.render_conformance_tests and not args.tests_folder: parser.error( - "A conformance tests script is configured, but no test directory is given. " - "Pass --test-dir or set test-dir in the config file." + "A conformance tests script is configured, but no tests folder is given. " + "Pass --tests-folder or set tests-folder in the config file." ) if not args.render_conformance_tests and args.copy_conformance_tests: diff --git a/plain_modules.py b/plain_modules.py index 472992f5..ba07e5d5 100644 --- a/plain_modules.py +++ b/plain_modules.py @@ -153,7 +153,7 @@ def __init__( filename: str, build_folder: str, template_dirs: list[str], - test_dirs: list[str] | None = None, + tests_folders: list[str] | None = None, conformance_tests_enabled: bool = False, ): self.filename = filename @@ -183,18 +183,18 @@ def __init__( ) for module_name in required_modules_names ] - if test_dirs is not None: - self._bind_test_scenarios(test_dirs, conformance_tests_enabled) + if tests_folders is not None: + self._bind_test_scenarios(tests_folders, conformance_tests_enabled) - def _bind_test_scenarios(self, test_dirs: list[str], conformance_tests_enabled: bool) -> None: - """Add the scenarios of the test plain files under ``test_dirs`` to the functionalities of the chain. + def _bind_test_scenarios(self, tests_folders: list[str], conformance_tests_enabled: bool) -> None: + """Add the scenarios of the test plain files under ``tests_folders`` to the functionalities of the chain. With conformance tests enabled, a functionality with no test plain file stops the run here. A functionality whose test plain file holds no scenario is recorded instead, so the run can warn about it and go on without conformance tests for it. """ chain = self._unique_chain() - bound = project.bind_test_plain_files(chain, test_dirs, self.template_dirs, self.template_dirs[0]) + bound = project.bind_test_plain_files(chain, tests_folders, self.template_dirs, self.template_dirs[0]) if conformance_tests_enabled: project.check_test_plain_files(chain, bound) self.functionalities_without_scenarios = _functionalities_without_scenarios(chain) diff --git a/tests/test_render_finished_report.py b/tests/test_render_finished_report.py index e3138f41..98baf38d 100644 --- a/tests/test_render_finished_report.py +++ b/tests/test_render_finished_report.py @@ -92,7 +92,7 @@ def make_args(api_key="test-key", headless=False): api="https://api.example.com", filename="project.plain", template_dir=None, - test_dir=[], + tests_folder=[], build_folder="build", render_range=None, render_from=None, diff --git a/tests/test_script_path_resolution.py b/tests/test_script_path_resolution.py index 476449b6..2c89a66c 100644 --- a/tests/test_script_path_resolution.py +++ b/tests/test_script_path_resolution.py @@ -96,8 +96,8 @@ def test_all_three_script_args_resolved(layout): "unittests-script: script_in_config.sh\n" "conformance-tests-script: script_in_config.sh\n" "prepare-environment-script: script_in_config.sh\n" - # A conformance tests script needs a test directory beside it. - "test-dir: tests\n" + # A conformance tests script needs a tests folder beside it. + "tests-folder: tests\n" ) with patch("os.getcwd", return_value=str(layout["config"])): diff --git a/tests/test_terminal_attachment.py b/tests/test_terminal_attachment.py index 6321cc48..641f6bd0 100644 --- a/tests/test_terminal_attachment.py +++ b/tests/test_terminal_attachment.py @@ -78,7 +78,7 @@ def make_args(): api="https://api.example.com", filename="project.plain", template_dir=None, - test_dir=[], + tests_folder=[], build_folder="build", render_range=None, render_from=None, diff --git a/tests/test_test_dirs.py b/tests/test_tests_folders.py similarity index 71% rename from tests/test_test_dirs.py rename to tests/test_tests_folders.py index 699f79e4..6dc79f0a 100644 --- a/tests/test_test_dirs.py +++ b/tests/test_tests_folders.py @@ -1,4 +1,4 @@ -"""The test folders: where they come from, and what the client does with the scenarios it finds there.""" +"""The tests folders: where they come from, and what the client does with the scenarios it finds there.""" import os @@ -29,13 +29,13 @@ def _parse(tmp_path, *extra_args): return parse_arguments([str(plain_file), *extra_args]) -def test_there_is_no_default_test_dir(tmp_path): +def test_there_is_no_default_tests_folder(tmp_path): args = _parse(tmp_path) - assert args.test_dir is None + assert args.tests_folder is None -def test_a_conformance_tests_script_without_a_test_dir_is_a_usage_error(tmp_path): +def test_a_conformance_tests_script_without_a_tests_folder_is_a_usage_error(tmp_path): script = tmp_path / "run_conformance_tests.sh" script.write_text("#!/bin/sh\n") @@ -43,28 +43,28 @@ def test_a_conformance_tests_script_without_a_test_dir_is_a_usage_error(tmp_path _parse(tmp_path, "--conformance-tests-script", str(script)) -def test_a_conformance_tests_script_with_a_test_dir_is_accepted(tmp_path, monkeypatch): +def test_a_conformance_tests_script_with_a_tests_folder_is_accepted(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) script = tmp_path / "run_conformance_tests.sh" script.write_text("#!/bin/sh\n") - args = _parse(tmp_path, "--conformance-tests-script", str(script), "--test-dir", "scenarios") + args = _parse(tmp_path, "--conformance-tests-script", str(script), "--tests-folder", "scenarios") - assert args.test_dir == [str(tmp_path / "scenarios")] + assert args.tests_folder == [str(tmp_path / "scenarios")] -def test_test_dir_can_be_given_more_than_once(tmp_path, monkeypatch): +def test_tests_folder_can_be_given_more_than_once(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) - args = _parse(tmp_path, "--test-dir", "scenarios", "--test-dir", "extra") + args = _parse(tmp_path, "--tests-folder", "scenarios", "--tests-folder", "extra") - assert args.test_dir == [str(tmp_path / "scenarios"), str(tmp_path / "extra")] + assert args.tests_folder == [str(tmp_path / "scenarios"), str(tmp_path / "extra")] -def test_test_dir_from_the_config_file_resolves_against_the_config_folder(tmp_path): - (tmp_path / "config.yaml").write_text("test-dir: scenarios\n") +def test_tests_folder_from_the_config_file_resolves_against_the_config_folder(tmp_path): + (tmp_path / "config.yaml").write_text("tests-folder: scenarios\n") args = _parse(tmp_path) - assert args.test_dir == [str(tmp_path / "scenarios")] + assert args.tests_folder == [str(tmp_path / "scenarios")] def test_scenarios_of_a_test_plain_file_reach_the_functionality(tmp_path, fixtures_dir, scenarios_dir): From b455488af6a57a581e83465e187c8f0b909db869 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Sat, 31 Oct 2026 20:37:55 +0100 Subject: [PATCH 23/23] feat: default the tests folder to "tests" next to the plain file Without the option and without the config key, the client reads the test plain files from a folder named tests beside the plain file. A project that follows the convention needs no setting. The usage error that refused a conformance tests script without a tests folder is removed, because the value is never empty now. A project whose folder holds no test plain file for a functionality still stops, and the message names the file to write. --- docs/plain2code_cli.md | 6 ++---- plain2code_arguments.py | 15 ++++++--------- tests/test_script_path_resolution.py | 2 -- tests/test_tests_folders.py | 14 +++++++------- 4 files changed, 15 insertions(+), 22 deletions(-) diff --git a/docs/plain2code_cli.md b/docs/plain2code_cli.md index 7c9ca9ae..13f17e32 100644 --- a/docs/plain2code_cli.md +++ b/docs/plain2code_cli.md @@ -104,10 +104,8 @@ options: Built-in standard_template_library directory --tests-folder TESTS_FOLDER Folder scanned for test plain files; repeat the option - for more than one. There is no default: without this - option and without the 'tests-folder' config key, no - test plain file is read. Required when a conformance - tests script is configured. + for more than one. Defaults to 'tests' next to the + plain file. --copy-build If set, copy the rendered contents of code in `--base- folder` folder to `--build-dest` folder after successful rendering. diff --git a/plain2code_arguments.py b/plain2code_arguments.py index df4b2082..44512c9a 100644 --- a/plain2code_arguments.py +++ b/plain2code_arguments.py @@ -19,6 +19,7 @@ DEFAULT_BUILD_FOLDER = "plain_modules" +DEFAULT_TESTS_FOLDER = "tests" DEFAULT_BUILD_DEST = "dist" DEFAULT_CONFORMANCE_TESTS_DEST = "dist_conformance_tests" MAX_JOB_TAG_LENGTH = 300 @@ -83,13 +84,16 @@ def _resolve_path_arguments( config_dir: Optional[str], spec_dir: str, ) -> None: - """Resolve every path-valued argument on ``args`` in-place and apply the test-folder default.""" + """Resolve every path-valued argument on ``args`` in-place and apply the tests-folder default.""" for action in parser._actions: if getattr(action, "_is_path", False): _resolve_path_arg(action.dest, args, cwd, config_dir, spec_dir) elif getattr(action, "_is_path_list", False): _resolve_path_list_arg(action.dest, args, cwd, config_dir, spec_dir) + if args.tests_folder is None: + args.tests_folder = [os.path.join(spec_dir, DEFAULT_TESTS_FOLDER)] + def _resolve_script_path( script_arg_name: str, @@ -445,8 +449,7 @@ def create_parser(): action="append", default=None, help="Folder scanned for test plain files; repeat the option for more than one. " - "There is no default: without this option and without the 'tests-folder' config key, no test " - "plain file is read. Required when a conformance tests script is configured.", + f"Defaults to '{DEFAULT_TESTS_FOLDER}' next to the plain file.", path_list=True, ) _add_arg( @@ -572,12 +575,6 @@ def parse_arguments(command_line: Optional[Sequence[str]] = None): args.render_conformance_tests = args.conformance_tests_script is not None - if args.render_conformance_tests and not args.tests_folder: - parser.error( - "A conformance tests script is configured, but no tests folder is given. " - "Pass --tests-folder or set tests-folder in the config file." - ) - if not args.render_conformance_tests and args.copy_conformance_tests: parser.error("--copy-conformance-tests requires --conformance-tests-script to be set") diff --git a/tests/test_script_path_resolution.py b/tests/test_script_path_resolution.py index 2c89a66c..4cc5bdde 100644 --- a/tests/test_script_path_resolution.py +++ b/tests/test_script_path_resolution.py @@ -96,8 +96,6 @@ def test_all_three_script_args_resolved(layout): "unittests-script: script_in_config.sh\n" "conformance-tests-script: script_in_config.sh\n" "prepare-environment-script: script_in_config.sh\n" - # A conformance tests script needs a tests folder beside it. - "tests-folder: tests\n" ) with patch("os.getcwd", return_value=str(layout["config"])): diff --git a/tests/test_tests_folders.py b/tests/test_tests_folders.py index 6dc79f0a..6cdc453e 100644 --- a/tests/test_tests_folders.py +++ b/tests/test_tests_folders.py @@ -29,18 +29,18 @@ def _parse(tmp_path, *extra_args): return parse_arguments([str(plain_file), *extra_args]) -def test_there_is_no_default_tests_folder(tmp_path): +def test_the_default_is_tests_next_to_the_plain_file(tmp_path): args = _parse(tmp_path) - assert args.tests_folder is None + assert args.tests_folder == [str(tmp_path / "tests")] -def test_a_conformance_tests_script_without_a_tests_folder_is_a_usage_error(tmp_path): - script = tmp_path / "run_conformance_tests.sh" - script.write_text("#!/bin/sh\n") +def test_the_default_holds_when_a_config_file_names_no_tests_folder(tmp_path): + (tmp_path / "config.yaml").write_text("verbose: true\n") + + args = _parse(tmp_path) - with pytest.raises(SystemExit): - _parse(tmp_path, "--conformance-tests-script", str(script)) + assert args.tests_folder == [str(tmp_path / "tests")] def test_a_conformance_tests_script_with_a_tests_folder_is_accepted(tmp_path, monkeypatch):