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/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/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/codeplain_REST_api.py b/codeplain_REST_api.py index 29a4a532..3747d544 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,10 +477,11 @@ 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. + 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. @@ -504,10 +498,11 @@ 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) + response = self.post_request(endpoint_url, headers, payload, run_state) + return response["patched_response_files"], response["traceability"] def analyze_rendering( self, 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..13f17e32 100644 --- a/docs/plain2code_cli.md +++ b/docs/plain2code_cli.md @@ -6,15 +6,15 @@ 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] + [--job-tag JOB_TAG] [--template-dir TEMPLATE_DIR] + [--tests-folder TESTS_FOLDER] [--copy-build] [--build-dest BUILD_DEST] [--copy-conformance-tests] [--conformance-tests-dest CONFORMANCE_TESTS_DEST] [--render-machine-graph] @@ -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. @@ -94,12 +94,18 @@ 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 containing the plain file, 2) Custom template directory (if provided through this argument), 3) Built-in standard_template_library directory + --tests-folder TESTS_FOLDER + 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. diff --git a/examples/example_hello_world_golang/config.yaml b/examples/example_hello_world_golang/config.yaml index 195888ef..63ca941a 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 +tests-folder: tests 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/config.yaml b/examples/example_hello_world_python/config.yaml index 197ecb44..b8ce61a9 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 +tests-folder: 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/config.yaml b/examples/example_hello_world_react/config.yaml index 66ea4d66..1d7cf8a6 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 +tests-folder: 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/partial_rendering.py b/partial_rendering.py index 40fccdda..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, @@ -201,7 +227,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/plain2code.py b/plain2code.py index f88a6085..9a2544b1 100644 --- a/plain2code.py +++ b/plain2code.py @@ -272,29 +272,53 @@ 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_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. + + 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 +350,8 @@ 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) + 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. @@ -488,6 +513,8 @@ def main(): # noqa: C901 os.path.basename(args.filename), args.build_folder, template_dirs, + args.tests_folder, + args.render_conformance_tests, ) except Exception as e: console.error(f"Error: {user_error_message(e)}") @@ -503,7 +530,8 @@ 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) + warn_about_functionalities_without_scenarios(plain_module) return event_bus = EventBus() diff --git a/plain2code_arguments.py b/plain2code_arguments.py index e7d9c559..44512c9a 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 @@ -17,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 @@ -48,6 +51,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 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, args, @@ -87,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 @@ -204,11 +251,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(): @@ -285,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.", ) @@ -293,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.", ) @@ -393,6 +443,15 @@ def create_parser(): "3) Built-in standard_template_library directory", path=True, ) + _add_arg( + parser, + "--tests-folder", + action="append", + default=None, + help="Folder scanned for test plain files; repeat the option for more than one. " + f"Defaults to '{DEFAULT_TESTS_FOLDER}' next to the plain file.", + path_list=True, + ) _add_arg( parser, "--copy-build", @@ -507,9 +566,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/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/plain_modules.py b/plain_modules.py index 49b00dcb..ba07e5d5 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 @@ -133,8 +134,28 @@ def _strip_functional_requirements(plain_source_tree: dict) -> dict: return stripped +def _functionalities_without_scenarios(chain: list[tuple[str, dict]]) -> list[tuple[str, str]]: + """The functionalities whose test plain file holds no conformance scenario. + + Each one is rendered without conformance tests. + """ + 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: - def __init__(self, filename: str, build_folder: str, template_dirs: list[str]): + def __init__( + self, + filename: str, + build_folder: str, + template_dirs: list[str], + tests_folders: list[str] | None = None, + conformance_tests_enabled: bool = False, + ): self.filename = filename self.build_folder = build_folder self.template_dirs = template_dirs @@ -151,6 +172,7 @@ def __init__(self, filename: str, build_folder: str, template_dirs: list[str]): 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 = [ @@ -161,6 +183,21 @@ def __init__(self, filename: str, build_folder: str, template_dirs: list[str]): ) for module_name in required_modules_names ] + if tests_folders is not None: + self._bind_test_scenarios(tests_folders, conformance_tests_enabled) + + 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, 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) @cached_property def all_required_modules(self) -> list[PlainModule]: @@ -314,16 +351,34 @@ 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) + 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) @@ -361,15 +416,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()) @@ -399,18 +457,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) @@ -574,7 +633,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/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 73e5ca0e..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"): @@ -126,11 +128,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, @@ -147,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/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/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_conformance_tests.py b/render_machine/actions/render_conformance_tests.py index 178a9716..f3de21c6 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,11 +138,51 @@ 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 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 [] + 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)}.") + + 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. 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) ( @@ -156,18 +191,13 @@ 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 test being rendered (completed count is 1-based) - acceptance_test = 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( + 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, @@ -176,12 +206,14 @@ 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 = ( - render_context.conformance_tests_running_context.get_current_conformance_test_folder_name() - ) + + self._warn_about_untraced_scenarios(render_context, traceability) + ctx.rendered_acceptance_scenarios.append(scenario["id"]) + + 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/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 04222e00..07633285 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 @@ -176,7 +177,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 @@ -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) @@ -313,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, @@ -328,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 @@ -402,7 +432,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"]) @@ -411,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 @@ -425,11 +464,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.""" @@ -467,16 +515,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..02ed1da0 100644 --- a/render_machine/render_types.py +++ b/render_machine/render_types.py @@ -126,14 +126,16 @@ 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 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 +162,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/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 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_acceptance_scenario_rendered_once.py b/tests/test_acceptance_scenario_rendered_once.py new file mode 100644 index 00000000..cc03c43e --- /dev/null +++ b/tests/test_acceptance_scenario_rendered_once.py @@ -0,0 +1,169 @@ +"""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 AcceptanceTestPhase, 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 + + +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() 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 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_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_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. """ 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."}]}, ) 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_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 == [] 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"): diff --git a/tests/test_partial_rendering.py b/tests/test_partial_rendering.py index 49247a8a..c2f58655 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, @@ -22,7 +23,14 @@ module_comes_before_or_equal, spec_change, ) -from plain2code_exceptions import ModuleDoesNotExistError +from plain2code_exceptions import MissingPreviousFunctionalitiesError, 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: @@ -37,9 +45,10 @@ 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.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}" @@ -48,7 +57,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): @@ -267,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", @@ -321,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(): @@ -330,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). @@ -343,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" # ------------------------- @@ -380,21 +397,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 +422,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 +440,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 +454,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 +463,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 +477,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 +488,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 +505,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 +522,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 +544,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 +560,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 +576,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_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_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_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() diff --git a/tests/test_render_finished_report.py b/tests/test_render_finished_report.py index b38210fb..98baf38d 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, + tests_folder=[], 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_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()) 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 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/tests/test_terminal_attachment.py b/tests/test_terminal_attachment.py index 3da6e24c..641f6bd0 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, + tests_folder=[], build_folder="build", render_range=None, render_from=None, @@ -130,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), diff --git a/tests/test_tests_folders.py b/tests/test_tests_folders.py new file mode 100644 index 00000000..6cdc453e --- /dev/null +++ b/tests/test_tests_folders.py @@ -0,0 +1,91 @@ +"""The tests 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_the_default_is_tests_next_to_the_plain_file(tmp_path): + args = _parse(tmp_path) + + assert args.tests_folder == [str(tmp_path / "tests")] + + +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) + + assert args.tests_folder == [str(tmp_path / "tests")] + + +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), "--tests-folder", "scenarios") + + assert args.tests_folder == [str(tmp_path / "scenarios")] + + +def test_tests_folder_can_be_given_more_than_once(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + args = _parse(tmp_path, "--tests-folder", "scenarios", "--tests-folder", "extra") + + assert args.tests_folder == [str(tmp_path / "scenarios"), str(tmp_path / "extra")] + + +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.tests_folder == [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" diff --git a/tests/test_traceability_warnings.py b/tests/test_traceability_warnings.py new file mode 100644 index 00000000..90f50194 --- /dev/null +++ b/tests/test_traceability_warnings.py @@ -0,0 +1,69 @@ +"""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 + +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_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) == 3 + + +def test_a_response_without_the_lists_warns_about_nothing(): + assert _warn({}) == [] 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 d7d92d94..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,20 +21,10 @@ ) -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 +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): @@ -70,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 @@ -200,15 +195,19 @@ 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, [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, @@ -221,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)]