From 4172c68f6e3154cba95f42d606ca467eecb904c3 Mon Sep 17 00:00:00 2001 From: Predrag Radenkovic Date: Thu, 2 Apr 2026 00:19:19 +0200 Subject: [PATCH] WIP --- module_renderer.py | 448 +++++++++++++++-- plain2code.py | 6 + plain2code_arguments.py | 7 + tests/test_module_renderer_prompt.py | 711 +++++++++++++++++++++++++++ 4 files changed, 1138 insertions(+), 34 deletions(-) create mode 100644 tests/test_module_renderer_prompt.py diff --git a/module_renderer.py b/module_renderer.py index e247649..d84471c 100644 --- a/module_renderer.py +++ b/module_renderer.py @@ -1,6 +1,10 @@ +from __future__ import annotations + import argparse import os import threading +from dataclasses import dataclass +from typing import Callable import git_utils import plain_file @@ -19,6 +23,32 @@ from render_machine.states import States +@dataclass +class ModuleRenderInfo: + module_name: str + change_reasons: list[str] + all_frids: list[str] + implemented_frids: list[str] + no_prior_render: bool + + +@dataclass +class _ModuleTraversalResult: + """State produced by _traverse_module_tree, shared by rendering and plan collection.""" + module_name: str + plain_source: dict + resources_list: list[dict] + required_modules: list[PlainModule] + has_any_required_module_changed: bool + changed_required_module_name: str | None + plain_module: PlainModule + no_prior_render: bool + spec_changed: bool + required_code_changed: bool + should_skip: bool + rendering_failed: bool + + class ModuleRenderer: def __init__( self, @@ -39,6 +69,9 @@ def __init__( self.run_state = run_state self.event_bus = event_bus self.stop_event = stop_event + self._render_plan: list[ModuleRenderInfo] = [] + self._dry_run_processed: set[str] = set() + self._skip_required_rerender: bool = False def _ensure_module_folders_exist(self, module_name: str, first_render_frid: str) -> tuple[str, str]: """ @@ -183,68 +216,165 @@ def _build_render_context_for_module( stop_event=self.stop_event, ) - def _render_module( - self, filename: str, render_range: list[str] | None, force_render: bool - ) -> tuple[bool, list[PlainModule], bool]: - """Render a module. + def _get_required_modules_without_rendering(self, filename: str) -> list[PlainModule]: + """Parse module and collect all transitive required modules without rendering.""" + module_name, _, required_modules_list = plain_file.plain_file_parser(filename, self.template_dirs) + + result = [] + for req_name in required_modules_list: + sub = self._get_required_modules_without_rendering(req_name + plain_file.PLAIN_SOURCE_FILE_EXTENSION) + for m in sub: + if m.name not in [r.name for r in result]: + result.append(m) + result.append(plain_modules.PlainModule(req_name, self.args.build_folder)) + + return result + + def _traverse_module_tree( + self, + filename: str, + render_range: list[str] | None, + force_render: bool, + required_module_fn: Callable[[str], tuple[bool, list[PlainModule], bool]], + already_processed_fn: Callable[[str], bool], + validate_commits: bool = True, + log_progress: bool = True, + ) -> _ModuleTraversalResult: + """Parse a module, process its required modules, and evaluate the skip condition. + + This is the shared traversal logic used by both _render_module and + _collect_render_plan_module. Callers supply: + - required_module_fn: what to do for each required module (render vs. collect) + - already_processed_fn: how to check if a module was already handled this run + + Args: + filename: Plain file to process. + render_range: FRIDs to render (used only for commit validation). + force_render: Whether to force rendering even if nothing changed. + required_module_fn: Called for each required module filename; returns + (has_changed, sub_required_modules, rendering_failed). + already_processed_fn: Called with module_name; returns True if already handled. + validate_commits: Whether to validate prior FRID commits against render_range. + log_progress: Whether to emit debug logs while processing required modules. Returns: - tuple[bool, list[PlainModule], bool]: (Whether the module was rendered, the required modules, and whether the rendering failed) + _ModuleTraversalResult with all state needed by the caller, including + should_skip and rendering_failed flags. """ module_name, plain_source, required_modules_list = plain_file.plain_file_parser(filename, self.template_dirs) resources_list = [] plain_spec.collect_linked_resources(plain_source, resources_list, None, True) - # Ensure that all previous FRID commits exist before proceeding with render_range - if render_range is not None: + if render_range is not None and validate_commits: self._ensure_previous_frid_commits_exist(module_name, plain_source, render_range) - required_modules = [] + required_modules: list[PlainModule] = [] has_any_required_module_changed = False + changed_required_module_name = None + if not self.args.render_machine_graph and required_modules_list: - console.debug(f"Analyzing required modules of module {module_name}...") + if log_progress: + console.debug(f"Analyzing required modules of module {module_name}...") for required_module_name in required_modules_list: required_module_filename = required_module_name + plain_file.PLAIN_SOURCE_FILE_EXTENSION - has_module_changed, sub_required_modules, rendering_failed = self._render_module( - required_module_filename, - None, - self.args.force_render, - ) + has_module_changed, sub_required_modules, rendering_failed = required_module_fn(required_module_filename) if rendering_failed: - return False, required_modules, True + plain_module = plain_modules.PlainModule(module_name, self.args.build_folder) + return _ModuleTraversalResult( + module_name=module_name, + plain_source=plain_source, + resources_list=resources_list, + required_modules=required_modules, + has_any_required_module_changed=has_any_required_module_changed, + changed_required_module_name=changed_required_module_name, + plain_module=plain_module, + no_prior_render=False, + spec_changed=False, + required_code_changed=False, + should_skip=False, + rendering_failed=True, + ) if has_module_changed: has_any_required_module_changed = True + if changed_required_module_name is None: + changed_required_module_name = required_module_name - for sub_required_module in sub_required_modules: - if sub_required_module.name not in [m.name for m in required_modules]: - required_modules.append( - plain_modules.PlainModule(sub_required_module.name, self.args.build_folder) - ) + for sub in sub_required_modules: + if sub.name not in [m.name for m in required_modules]: + required_modules.append(plain_modules.PlainModule(sub.name, self.args.build_folder)) required_modules.append(plain_modules.PlainModule(required_module_name, self.args.build_folder)) plain_module = plain_modules.PlainModule(module_name, self.args.build_folder) - if ( - ((not force_render) or any(module.name == plain_module.name for module in self.loaded_modules)) - and plain_module.get_repo() is not None - and not plain_module.has_plain_spec_changed(plain_source, resources_list) - and not plain_module.has_required_modules_code_changed(required_modules) + no_prior_render = plain_module.get_repo() is None + spec_changed = not no_prior_render and plain_module.has_plain_spec_changed(plain_source, resources_list) + required_code_changed = not no_prior_render and plain_module.has_required_modules_code_changed(required_modules) + already_processed = already_processed_fn(module_name) + + should_skip = ( + ((not force_render) or already_processed) + and not no_prior_render + and not spec_changed + and not required_code_changed and not has_any_required_module_changed - ): - return False, required_modules, False + ) + + return _ModuleTraversalResult( + module_name=module_name, + plain_source=plain_source, + resources_list=resources_list, + required_modules=required_modules, + has_any_required_module_changed=has_any_required_module_changed, + changed_required_module_name=changed_required_module_name, + plain_module=plain_module, + no_prior_render=no_prior_render, + spec_changed=spec_changed, + required_code_changed=required_code_changed, + should_skip=should_skip, + rendering_failed=False, + ) + + def _render_module( + self, filename: str, render_range: list[str] | None, force_render: bool + ) -> tuple[bool, list[PlainModule], bool]: + """Render a module and all its required modules recursively. + + Returns: + tuple[bool, list[PlainModule], bool]: (Whether the module was rendered, + the required modules, and whether the rendering failed) + """ + def required_module_fn(rf: str) -> tuple[bool, list[PlainModule], bool]: + if self._skip_required_rerender: + return False, self._get_required_modules_without_rendering(rf), False + return self._render_module(rf, None, self.args.force_render) + + already_processed_fn = lambda name: any(m.name == name for m in self.loaded_modules) - memory_manager = MemoryManager(self.codeplainAPI, os.path.join(self.args.build_folder, module_name)) + state = self._traverse_module_tree( + filename, render_range, force_render, + required_module_fn, already_processed_fn, + validate_commits=True, log_progress=True, + ) + + if state.rendering_failed: + return False, state.required_modules, True + + if state.should_skip: + return False, state.required_modules, False + + memory_manager = MemoryManager(self.codeplainAPI, os.path.join(self.args.build_folder, state.module_name)) render_context = self._build_render_context_for_module( - module_name, memory_manager, plain_source, required_modules, self.template_dirs, render_range + state.module_name, memory_manager, state.plain_source, + state.required_modules, self.template_dirs, render_range, ) code_renderer = CodeRenderer(render_context) if self.args.render_machine_graph: code_renderer.generate_render_machine_graph() - return True, required_modules, False + return True, state.required_modules, False code_renderer.run() if code_renderer.render_context.state == States.RENDER_FAILED.value: @@ -253,16 +383,266 @@ def _render_module( fallback_message=code_renderer.render_context.last_error_message, ) code_renderer.render_context.event_bus.publish(RenderFailed(error_message=error_message)) - return False, required_modules, True + return False, state.required_modules, True + + state.plain_module.save_module_metadata(state.plain_source, state.resources_list, state.required_modules) + self.loaded_modules.append(state.plain_module) + + return True, state.required_modules, False + + def _collect_render_plan_module( + self, filename: str, force_render: bool + ) -> tuple[bool, list[PlainModule], bool]: + """Collect render plan info for a module without rendering anything. + + Appends a ModuleRenderInfo to self._render_plan for each module that + would be rendered. Returns the same (has_changed, required_modules, failed) + tuple shape as _render_module so it can be used as a required_module_fn. + """ + required_module_fn = lambda rf: self._collect_render_plan_module(rf, self.args.force_render) + already_processed_fn = lambda name: name in self._dry_run_processed + + state = self._traverse_module_tree( + filename, None, force_render, + required_module_fn, already_processed_fn, + validate_commits=False, log_progress=False, + ) + + if state.rendering_failed or state.should_skip: + return False, state.required_modules, state.rendering_failed + + all_frids = list(plain_spec.get_frids(state.plain_source)) + + if state.no_prior_render: + implemented_frids = [] + else: + build_folder_path = os.path.join(self.args.build_folder, state.module_name) + implemented_frids = [ + frid for frid in all_frids + if git_utils.has_commit_for_frid(build_folder_path, frid, state.module_name) + ] + + change_reasons = [] + if state.spec_changed: + change_reasons.append("spec changed") + if state.required_code_changed or state.has_any_required_module_changed: + if state.changed_required_module_name: + change_reasons.append(f"required module '{state.changed_required_module_name}' changed") + else: + change_reasons.append("required module changed") + + self._render_plan.append(ModuleRenderInfo( + module_name=state.module_name, + change_reasons=change_reasons, + all_frids=all_frids, + implemented_frids=implemented_frids, + no_prior_render=state.no_prior_render, + )) + self._dry_run_processed.add(state.module_name) + + return True, state.required_modules, False + + def collect_render_plan(self) -> None: + """Dry-run traversal to populate self._render_plan without rendering anything.""" + self._render_plan = [] + self._dry_run_processed = set() + self._collect_render_plan_module(self.filename, True) - plain_module.save_module_metadata(plain_source, resources_list, required_modules) + def prompt_user_if_needed(self) -> bool: + """Prompt the user about re-rendering choices if relevant. - self.loaded_modules.append(plain_module) + Must be called on the main thread before the TUI is started. - return True, required_modules, False + Returns: + True if the user cancelled, False otherwise. + """ + render_plan = self._render_plan + + if not render_plan: + return False + + current = render_plan[-1] + required_in_plan = render_plan[:-1] + + has_spec_changes = any("spec changed" in m.change_reasons for m in render_plan) + has_required_changes = len(required_in_plan) > 0 + + # Situation 11: fresh render with no prior state — no prompt needed + if not has_spec_changes and not has_required_changes and current.no_prior_render: + return False + + impl = current.implemented_frids + all_f = current.all_frids + no_prior = current.no_prior_render + is_partial = not no_prior and 0 < len(impl) < len(all_f) + is_full = not no_prior and len(all_f) > 0 and len(impl) == len(all_f) + + first_unimplemented = None + if is_partial and all_f: + impl_set = set(impl) + first_unimplemented = next((f for f in all_f if f not in impl_set), None) + + spec_changed_modules = [m for m in render_plan if "spec changed" in m.change_reasons] + all_module_names = [m.module_name for m in render_plan] + cli_render_range_set = self.render_range is not None + + # --- Build message --- + if spec_changed_modules: + names = " and ".join(m.module_name for m in spec_changed_modules) + print(f"\nChanges in specs in {names} have been identified.") + + if has_spec_changes or has_required_changes: + print("This would require re-rendering of the following modules:\n") + for m in required_in_plan: + print(f" {m.module_name}") + + if no_prior: + annotation = "(not yet rendered)" + elif is_full: + annotation = "(all functionalities were already implemented)" + elif is_partial: + annotation = f"(functionalities {', '.join(impl)} were already implemented)" + else: + annotation = "" + + if annotation: + print(f" {current.module_name} {annotation}") + else: + print(f" {current.module_name}") + else: + # Situations 12/13: no spec changes, module partially or fully rendered + if is_partial: + print(f"\n{current.module_name} has been partially rendered (functionalities {', '.join(impl)} were already implemented).") + elif is_full: + print(f"\nAll functionalities in {current.module_name} were already implemented.") + else: + return False + + print() + + # --- Build choices --- + choices: dict[str, tuple[str, str | None]] = {} + + if has_required_changes and no_prior: + # Situation 9 + print(f"[a] Re-render all ({', '.join(all_module_names)})") + print(f"[b] Render {current.module_name}") + print("[c] Cancel") + choices = { + "a": ("rerender_all", None), + "b": ("rerender_current", None), + "c": ("cancel", None), + } + + elif has_required_changes and is_full: + # Situations 2 / 6 / 8 + print(f"[a] Re-render all ({', '.join(all_module_names)})") + print(f"[b] Re-render {current.module_name} from scratch") + print("[c] Cancel") + choices = { + "a": ("rerender_all", None), + "b": ("rerender_current", None), + "c": ("cancel", None), + } + + elif has_required_changes and is_partial: + # Situations 1 / 5 / 7 + print(f"[a] Re-render all ({', '.join(all_module_names)})") + if not cli_render_range_set and first_unimplemented: + print(f"[b] Continue from functionality {first_unimplemented} ({current.module_name} only)") + print(f"[c] Re-render {current.module_name} from scratch") + print("[d] Cancel") + choices = { + "a": ("rerender_all", None), + "b": ("continue_from", first_unimplemented), + "c": ("rerender_current", None), + "d": ("cancel", None), + } + else: + print(f"[b] Re-render {current.module_name} from scratch") + print("[c] Cancel") + choices = { + "a": ("rerender_all", None), + "b": ("rerender_current", None), + "c": ("cancel", None), + } + + elif not has_required_changes and "spec changed" in current.change_reasons and is_full: + # Situation 4 + print(f"[a] Re-render ({current.module_name})") + print("[b] Cancel") + choices = {"a": ("rerender_all", None), "b": ("cancel", None)} + + elif not has_required_changes and "spec changed" in current.change_reasons and is_partial: + # Situation 3 + print(f"[a] Re-render all ({current.module_name})") + if not cli_render_range_set and first_unimplemented: + print(f"[b] Continue from functionality {first_unimplemented}") + print("[c] Cancel") + choices = { + "a": ("rerender_all", None), + "b": ("continue_from", first_unimplemented), + "c": ("cancel", None), + } + else: + print("[b] Cancel") + choices = {"a": ("rerender_all", None), "b": ("cancel", None)} + + elif is_full and not has_spec_changes: + # Situation 13 + print(f"[a] Re-render {current.module_name} from scratch") + print("[b] Cancel") + choices = {"a": ("rerender_all", None), "b": ("cancel", None)} + + elif is_partial and not has_spec_changes: + # Situation 12 + print(f"[a] Re-render {current.module_name} from scratch") + if not cli_render_range_set and first_unimplemented: + print(f"[b] Continue from functionality {first_unimplemented}") + print("[c] Cancel") + choices = { + "a": ("rerender_all", None), + "b": ("continue_from", first_unimplemented), + "c": ("cancel", None), + } + else: + print("[b] Cancel") + choices = {"a": ("rerender_all", None), "b": ("cancel", None)} + + else: + return False + + # --- Get user input --- + print() + while True: + try: + raw = input("Your choice: ").strip().lower() + except (EOFError, KeyboardInterrupt): + return True + + if raw in choices: + decision = choices[raw] + break + print(f"Please enter one of: {', '.join(choices.keys())}") + + if decision[0] == "cancel": + return True + + if decision[0] == "continue_from": + frid = decision[1] + start_idx = current.all_frids.index(frid) + self.render_range = current.all_frids[start_idx:] + self._skip_required_rerender = True + elif decision[0] == "rerender_current": + self.render_range = None + self._skip_required_rerender = True + # rerender_all: keep existing render_range, _skip_required_rerender stays False + + return False def render_module(self) -> None: self.loaded_modules = list[PlainModule]() + self._skip_required_rerender = False _, _, rendering_failed = self._render_module(self.filename, self.render_range, True) if not rendering_failed: # Get the last module that completed rendering diff --git a/plain2code.py b/plain2code.py index 554d7f6..cb13063 100644 --- a/plain2code.py +++ b/plain2code.py @@ -216,6 +216,12 @@ def render(args, run_state: RunState, event_bus: EventBus): # noqa: C901 stop_event=stop_event, ) + # Collect render plan and prompt user before TUI starts (must run on main thread) + module_renderer.collect_render_plan() + if not args.headless and not getattr(args, "yes", False) and not args.force_render: + if module_renderer.prompt_user_if_needed(): + return + render_error: list[Exception] = [] def run_render(): diff --git a/plain2code_arguments.py b/plain2code_arguments.py index 1dbb36a..35527c5 100644 --- a/plain2code_arguments.py +++ b/plain2code_arguments.py @@ -350,6 +350,13 @@ def create_parser(color: bool = False): "All logs are written to the log file.", ) + parser.add_argument( + "--yes", + action="store_true", + default=False, + help="Skip the re-render confirmation prompt and re-render all changed modules.", + ) + return parser diff --git a/tests/test_module_renderer_prompt.py b/tests/test_module_renderer_prompt.py new file mode 100644 index 0000000..d9765f8 --- /dev/null +++ b/tests/test_module_renderer_prompt.py @@ -0,0 +1,711 @@ +"""Tests for ModuleRenderer collect_render_plan and prompt_user_if_needed.""" +import argparse +from unittest.mock import MagicMock, call, patch + +import pytest + +from module_renderer import ModuleRenderInfo, ModuleRenderer + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def make_args(**overrides): + defaults = { + "build_folder": "plain_modules", + "conformance_tests_folder": "conformance_tests", + "build_dest": "dist", + "conformance_tests_dest": "dist_conformance_tests", + "render_conformance_tests": False, + "force_render": False, + "render_machine_graph": False, + "copy_build": False, + "copy_conformance_tests": False, + "unittests_script": None, + "conformance_tests_script": None, + "prepare_environment_script": None, + "verbose": False, + "base_folder": None, + "test_script_timeout": None, + "yes": False, + "headless": False, + } + defaults.update(overrides) + return argparse.Namespace(**defaults) + + +def make_renderer(filename="module_c.plain", render_range=None, **args_overrides): + return ModuleRenderer( + codeplainAPI=MagicMock(), + filename=filename, + render_range=render_range, + template_dirs=[], + args=make_args(**args_overrides), + run_state=MagicMock(), + event_bus=MagicMock(), + ) + + +def make_info( + module_name="module_c", + change_reasons=None, + all_frids=None, + implemented_frids=None, + no_prior_render=False, +): + return ModuleRenderInfo( + module_name=module_name, + change_reasons=change_reasons or [], + all_frids=all_frids or ["1", "2", "3", "4", "5", "6"], + implemented_frids=implemented_frids or [], + no_prior_render=no_prior_render, + ) + + +def make_plain_module_mock(spec_changed=False, required_code_changed=False, has_repo=True): + m = MagicMock() + m.get_repo.return_value = object() if has_repo else None + m.has_plain_spec_changed.return_value = spec_changed + m.has_required_modules_code_changed.return_value = required_code_changed + return m + + +# --------------------------------------------------------------------------- +# collect_render_plan — dry-run traversal tests +# --------------------------------------------------------------------------- + +class TestCollectRenderPlan: + + def _patch_all(self, parser_returns, module_mock, frids, has_commit_fn): + """Return a context-manager-compatible patch stack for single-module tests.""" + return ( + patch("plain_file.plain_file_parser", side_effect=parser_returns), + patch("plain_spec.collect_linked_resources"), + patch("plain_modules.PlainModule", return_value=module_mock), + patch("plain_spec.get_frids", return_value=frids), + patch("git_utils.has_commit_for_frid", side_effect=has_commit_fn), + ) + + def test_no_changes_root_module_always_in_plan(self): + """Root module is always in the plan (force_render=True), even with no spec changes. + It appears with empty change_reasons reflecting that no changes were detected.""" + renderer = make_renderer() + module_mock = make_plain_module_mock(spec_changed=False, required_code_changed=False) + + with patch("plain_file.plain_file_parser", return_value=("module_c", {}, [])), \ + patch("plain_spec.collect_linked_resources"), \ + patch("plain_modules.PlainModule", return_value=module_mock), \ + patch("plain_spec.get_frids", return_value=["1", "2", "3"]), \ + patch("git_utils.has_commit_for_frid", return_value=True): + + renderer.collect_render_plan() + + assert len(renderer._render_plan) == 1 + assert renderer._render_plan[0].change_reasons == [] + assert renderer._render_plan[0].module_name == "module_c" + + def test_no_changes_required_module_not_in_plan(self): + """Required modules with no changes and force_render=False are skipped from the plan.""" + renderer = make_renderer() + + module_b_mock = make_plain_module_mock(spec_changed=False, required_code_changed=False) + module_b_mock.name = "module_b" + module_c_mock = make_plain_module_mock(spec_changed=False, required_code_changed=False) + module_c_mock.name = "module_c" + + def parser_side_effect(filename, _): + if "module_b" in filename: + return ("module_b", {}, []) + return ("module_c", {}, ["module_b"]) + + def plain_module_side_effect(name, _): + return module_b_mock if name == "module_b" else module_c_mock + + with patch("plain_file.plain_file_parser", side_effect=parser_side_effect), \ + patch("plain_spec.collect_linked_resources"), \ + patch("plain_modules.PlainModule", side_effect=plain_module_side_effect), \ + patch("plain_spec.get_frids", return_value=["1", "2"]), \ + patch("git_utils.has_commit_for_frid", return_value=True): + + renderer.collect_render_plan() + + # module_b (required, force_render=False, no changes) should be skipped + # module_c (root, force_render=True) always appears + names = [i.module_name for i in renderer._render_plan] + assert "module_b" not in names + assert "module_c" in names + + def test_no_prior_render_added_to_plan(self): + renderer = make_renderer() + module_mock = make_plain_module_mock(has_repo=False) + + with patch("plain_file.plain_file_parser", return_value=("module_c", {}, [])), \ + patch("plain_spec.collect_linked_resources"), \ + patch("plain_modules.PlainModule", return_value=module_mock), \ + patch("plain_spec.get_frids", return_value=["1", "2", "3"]): + + renderer.collect_render_plan() + + assert len(renderer._render_plan) == 1 + info = renderer._render_plan[0] + assert info.module_name == "module_c" + assert info.no_prior_render is True + assert info.implemented_frids == [] + assert info.change_reasons == [] + + def test_spec_changed_partially_rendered(self): + renderer = make_renderer() + module_mock = make_plain_module_mock(spec_changed=True) + + with patch("plain_file.plain_file_parser", return_value=("module_c", {}, [])), \ + patch("plain_spec.collect_linked_resources"), \ + patch("plain_modules.PlainModule", return_value=module_mock), \ + patch("plain_spec.get_frids", return_value=["1", "2", "3", "4", "5", "6"]), \ + patch("git_utils.has_commit_for_frid", side_effect=lambda p, f, m: f in {"1", "2", "3"}): + + renderer.collect_render_plan() + + assert len(renderer._render_plan) == 1 + info = renderer._render_plan[0] + assert info.change_reasons == ["spec changed"] + assert info.all_frids == ["1", "2", "3", "4", "5", "6"] + assert info.implemented_frids == ["1", "2", "3"] + assert info.no_prior_render is False + + def test_spec_changed_fully_rendered(self): + renderer = make_renderer() + module_mock = make_plain_module_mock(spec_changed=True) + + with patch("plain_file.plain_file_parser", return_value=("module_c", {}, [])), \ + patch("plain_spec.collect_linked_resources"), \ + patch("plain_modules.PlainModule", return_value=module_mock), \ + patch("plain_spec.get_frids", return_value=["1", "2", "3"]), \ + patch("git_utils.has_commit_for_frid", return_value=True): + + renderer.collect_render_plan() + + info = renderer._render_plan[0] + assert info.implemented_frids == ["1", "2", "3"] + assert info.all_frids == ["1", "2", "3"] + + def test_required_module_spec_changed_propagates(self): + """Required module spec change causes both modules to appear in plan.""" + renderer = make_renderer() + + module_b_mock = make_plain_module_mock(spec_changed=True) + module_b_mock.name = "module_b" + module_c_mock = make_plain_module_mock(spec_changed=False, required_code_changed=True) + module_c_mock.name = "module_c" + + def parser_side_effect(filename, _): + if "module_b" in filename: + return ("module_b", {}, []) + return ("module_c", {}, ["module_b"]) + + def plain_module_side_effect(name, build_folder): + if name == "module_b": + return module_b_mock + return module_c_mock + + with patch("plain_file.plain_file_parser", side_effect=parser_side_effect), \ + patch("plain_spec.collect_linked_resources"), \ + patch("plain_modules.PlainModule", side_effect=plain_module_side_effect), \ + patch("plain_spec.get_frids", return_value=["1", "2", "3"]), \ + patch("git_utils.has_commit_for_frid", return_value=True): + + renderer.collect_render_plan() + + assert len(renderer._render_plan) == 2 + assert renderer._render_plan[0].module_name == "module_b" + assert renderer._render_plan[1].module_name == "module_c" + assert "spec changed" in renderer._render_plan[0].change_reasons + assert any("required module" in r for r in renderer._render_plan[1].change_reasons) + + def test_chain_a_b_c_spec_change_in_a(self): + """A spec change in A propagates through B to C — all three in plan.""" + renderer = make_renderer() + + mocks = { + "module_a": make_plain_module_mock(spec_changed=True), + "module_b": make_plain_module_mock(spec_changed=False, required_code_changed=True), + "module_c": make_plain_module_mock(spec_changed=False, required_code_changed=True), + } + for name, m in mocks.items(): + m.name = name + + def parser_side_effect(filename, _): + if "module_a" in filename: + return ("module_a", {}, []) + if "module_b" in filename: + return ("module_b", {}, ["module_a"]) + return ("module_c", {}, ["module_b"]) + + def plain_module_side_effect(name, _): + return mocks[name] + + with patch("plain_file.plain_file_parser", side_effect=parser_side_effect), \ + patch("plain_spec.collect_linked_resources"), \ + patch("plain_modules.PlainModule", side_effect=plain_module_side_effect), \ + patch("plain_spec.get_frids", return_value=["1", "2", "3"]), \ + patch("git_utils.has_commit_for_frid", return_value=True): + + renderer.collect_render_plan() + + names = [i.module_name for i in renderer._render_plan] + assert names == ["module_a", "module_b", "module_c"] + + +# --------------------------------------------------------------------------- +# prompt_user_if_needed — situation tests +# (These set _render_plan directly to test prompt logic in isolation.) +# --------------------------------------------------------------------------- + +class TestPromptUserIfNeeded: + + def test_empty_plan_no_prompt(self, capsys): + renderer = make_renderer() + renderer._render_plan = [] + + result = renderer.prompt_user_if_needed() + + assert result is False + assert capsys.readouterr().out == "" + + def test_situation_11_no_prior_render_no_changes_no_prompt(self, capsys): + renderer = make_renderer() + renderer._render_plan = [make_info(no_prior_render=True, change_reasons=[])] + + result = renderer.prompt_user_if_needed() + + assert result is False + assert capsys.readouterr().out == "" + + # --- Situation 1: required module changed, current partially rendered --- + + def test_situation_1_shows_correct_message(self, capsys): + renderer = make_renderer() + renderer._render_plan = [ + make_info("module_b", change_reasons=["spec changed"], all_frids=["1", "2"], implemented_frids=["1", "2"]), + make_info("module_c", change_reasons=["required module 'module_b' changed"], + all_frids=["1", "2", "3", "4", "5", "6"], implemented_frids=["1", "2", "3"]), + ] + + with patch("builtins.input", return_value="d"): + renderer.prompt_user_if_needed() + + out = capsys.readouterr().out + assert "Changes in specs in module_b have been identified." in out + assert "This would require re-rendering of the following modules:" in out + assert "module_b" in out + assert "module_c" in out + assert "functionalities 1, 2, 3 were already implemented" in out + assert "Continue from functionality 4" in out + assert "Re-render module_c from scratch" in out + + def test_situation_1_choice_a_rerenders_all(self, capsys): + renderer = make_renderer() + renderer._render_plan = [ + make_info("module_b", change_reasons=["spec changed"], all_frids=["1", "2"], implemented_frids=["1", "2"]), + make_info("module_c", change_reasons=["required module 'module_b' changed"], + all_frids=["1", "2", "3", "4", "5", "6"], implemented_frids=["1", "2", "3"]), + ] + + with patch("builtins.input", return_value="a"): + result = renderer.prompt_user_if_needed() + + assert result is False + assert renderer._skip_required_rerender is False + assert renderer.render_range is None + + def test_situation_1_choice_b_continues_from_frid(self, capsys): + renderer = make_renderer() + renderer._render_plan = [ + make_info("module_b", change_reasons=["spec changed"], all_frids=["1", "2"], implemented_frids=["1", "2"]), + make_info("module_c", change_reasons=["required module 'module_b' changed"], + all_frids=["1", "2", "3", "4", "5", "6"], implemented_frids=["1", "2", "3"]), + ] + + with patch("builtins.input", return_value="b"): + result = renderer.prompt_user_if_needed() + + assert result is False + assert renderer._skip_required_rerender is True + assert renderer.render_range == ["4", "5", "6"] + + def test_situation_1_choice_c_rerenders_current_from_scratch(self, capsys): + renderer = make_renderer() + renderer._render_plan = [ + make_info("module_b", change_reasons=["spec changed"], all_frids=["1", "2"], implemented_frids=["1", "2"]), + make_info("module_c", change_reasons=["required module 'module_b' changed"], + all_frids=["1", "2", "3", "4", "5", "6"], implemented_frids=["1", "2", "3"]), + ] + + with patch("builtins.input", return_value="c"): + result = renderer.prompt_user_if_needed() + + assert result is False + assert renderer._skip_required_rerender is True + assert renderer.render_range is None + + def test_situation_1_choice_d_cancels(self, capsys): + renderer = make_renderer() + renderer._render_plan = [ + make_info("module_b", change_reasons=["spec changed"], all_frids=["1", "2"], implemented_frids=["1", "2"]), + make_info("module_c", change_reasons=["required module 'module_b' changed"], + all_frids=["1", "2", "3", "4", "5", "6"], implemented_frids=["1", "2", "3"]), + ] + + with patch("builtins.input", return_value="d"): + result = renderer.prompt_user_if_needed() + + assert result is True + + # --- Situation 2: required module changed, current fully rendered --- + + def test_situation_2_shows_rerender_current_from_scratch_option(self, capsys): + renderer = make_renderer() + renderer._render_plan = [ + make_info("module_b", change_reasons=["spec changed"], all_frids=["1", "2"], implemented_frids=["1", "2"]), + make_info("module_c", change_reasons=["required module 'module_b' changed"], + all_frids=["1", "2", "3"], implemented_frids=["1", "2", "3"]), + ] + + with patch("builtins.input", return_value="c"): + renderer.prompt_user_if_needed() + + out = capsys.readouterr().out + assert "all functionalities were already implemented" in out + assert "Re-render module_c from scratch" in out + + def test_situation_2_choice_b_sets_skip_rerender(self, capsys): + renderer = make_renderer() + renderer._render_plan = [ + make_info("module_b", change_reasons=["spec changed"], all_frids=["1", "2"], implemented_frids=["1", "2"]), + make_info("module_c", change_reasons=["required module 'module_b' changed"], + all_frids=["1", "2", "3"], implemented_frids=["1", "2", "3"]), + ] + + with patch("builtins.input", return_value="b"): + result = renderer.prompt_user_if_needed() + + assert result is False + assert renderer._skip_required_rerender is True + assert renderer.render_range is None + + # --- Situation 3: spec changed in current module, partially rendered --- + + def test_situation_3_shows_continue_from_option(self, capsys): + renderer = make_renderer() + renderer._render_plan = [ + make_info("module_c", change_reasons=["spec changed"], + all_frids=["1", "2", "3", "4", "5", "6"], implemented_frids=["1", "2", "3"]), + ] + + with patch("builtins.input", return_value="c"): + renderer.prompt_user_if_needed() + + out = capsys.readouterr().out + assert "Changes in specs in module_c have been identified." in out + assert "Continue from functionality 4" in out + assert "Re-render all" in out + + def test_situation_3_choice_b_sets_render_range(self, capsys): + renderer = make_renderer() + renderer._render_plan = [ + make_info("module_c", change_reasons=["spec changed"], + all_frids=["1", "2", "3", "4", "5", "6"], implemented_frids=["1", "2", "3"]), + ] + + with patch("builtins.input", return_value="b"): + renderer.prompt_user_if_needed() + + assert renderer.render_range == ["4", "5", "6"] + assert renderer._skip_required_rerender is True + + # --- Situation 4: spec changed in current module, fully rendered --- + + def test_situation_4_only_rerender_or_cancel(self, capsys): + renderer = make_renderer() + renderer._render_plan = [ + make_info("module_c", change_reasons=["spec changed"], + all_frids=["1", "2", "3"], implemented_frids=["1", "2", "3"]), + ] + + with patch("builtins.input", return_value="b"): + result = renderer.prompt_user_if_needed() + + out = capsys.readouterr().out + assert "all functionalities were already implemented" in out + assert "Continue from" not in out + assert result is True # choice b = cancel in situation 4 + + def test_situation_4_choice_a_rerenders(self, capsys): + renderer = make_renderer() + renderer._render_plan = [ + make_info("module_c", change_reasons=["spec changed"], + all_frids=["1", "2", "3"], implemented_frids=["1", "2", "3"]), + ] + + with patch("builtins.input", return_value="a"): + result = renderer.prompt_user_if_needed() + + assert result is False + assert renderer._skip_required_rerender is False + + # --- Situation 7: chain change (A→B→C), current partially rendered --- + + def test_situation_7_lists_all_three_modules(self, capsys): + renderer = make_renderer() + renderer._render_plan = [ + make_info("module_a", change_reasons=["spec changed"], all_frids=["1", "2"], implemented_frids=["1", "2"]), + make_info("module_b", change_reasons=["required module 'module_a' changed"], all_frids=["1", "2"], implemented_frids=["1", "2"]), + make_info("module_c", change_reasons=["required module 'module_b' changed"], + all_frids=["1", "2", "3", "4", "5", "6"], implemented_frids=["1", "2", "3"]), + ] + + with patch("builtins.input", return_value="c"): + renderer.prompt_user_if_needed() + + out = capsys.readouterr().out + assert "Changes in specs in module_a have been identified." in out + assert "module_a" in out + assert "module_b" in out + assert "module_c" in out + assert "Re-render all (module_a, module_b, module_c)" in out + + # --- Situation 8: chain change (A→B→C), current fully rendered --- + + def test_situation_8_rerender_current_from_scratch_option(self, capsys): + renderer = make_renderer() + renderer._render_plan = [ + make_info("module_a", change_reasons=["spec changed"], all_frids=["1"], implemented_frids=["1"]), + make_info("module_b", change_reasons=["required module 'module_a' changed"], all_frids=["1"], implemented_frids=["1"]), + make_info("module_c", change_reasons=["required module 'module_b' changed"], + all_frids=["1", "2", "3"], implemented_frids=["1", "2", "3"]), + ] + + with patch("builtins.input", return_value="b"): + result = renderer.prompt_user_if_needed() + + assert result is False + assert renderer._skip_required_rerender is True + assert renderer.render_range is None + + # --- Situation 9: required module changed, current not yet rendered --- + + def test_situation_9_not_rendered_shows_render_current_option(self, capsys): + renderer = make_renderer() + renderer._render_plan = [ + make_info("module_b", change_reasons=["spec changed"], all_frids=["1"], implemented_frids=["1"]), + make_info("module_c", change_reasons=["required module 'module_b' changed"], + no_prior_render=True, all_frids=["1", "2", "3"], implemented_frids=[]), + ] + + with patch("builtins.input", return_value="c"): + result = renderer.prompt_user_if_needed() + + out = capsys.readouterr().out + assert "not yet rendered" in out + assert "Continue from" not in out + assert f"Render module_c" in out + assert result is True # c = cancel in situation 9 + + def test_situation_9_choice_a_rerenders_all(self, capsys): + renderer = make_renderer() + renderer._render_plan = [ + make_info("module_b", change_reasons=["spec changed"], all_frids=["1"], implemented_frids=["1"]), + make_info("module_c", change_reasons=["required module 'module_b' changed"], + no_prior_render=True, all_frids=["1", "2", "3"], implemented_frids=[]), + ] + + with patch("builtins.input", return_value="a"): + result = renderer.prompt_user_if_needed() + + assert result is False + assert renderer._skip_required_rerender is False + + def test_situation_9_choice_b_renders_current_only(self, capsys): + renderer = make_renderer() + renderer._render_plan = [ + make_info("module_b", change_reasons=["spec changed"], all_frids=["1"], implemented_frids=["1"]), + make_info("module_c", change_reasons=["required module 'module_b' changed"], + no_prior_render=True, all_frids=["1", "2", "3"], implemented_frids=[]), + ] + + with patch("builtins.input", return_value="b"): + result = renderer.prompt_user_if_needed() + + assert result is False + assert renderer._skip_required_rerender is True + assert renderer.render_range is None + + # --- Situation 12: no spec changes, current partially rendered --- + + def test_situation_12_no_opening_line(self, capsys): + renderer = make_renderer() + renderer._render_plan = [ + make_info("module_c", change_reasons=[], + all_frids=["1", "2", "3", "4", "5", "6"], implemented_frids=["1", "2", "3"]), + ] + + with patch("builtins.input", return_value="c"): + renderer.prompt_user_if_needed() + + out = capsys.readouterr().out + assert "Changes in specs" not in out + assert "module_c has been partially rendered" in out + assert "functionalities 1, 2, 3 were already implemented" in out + assert "Continue from functionality 4" in out + + def test_situation_12_choice_b_sets_render_range(self, capsys): + renderer = make_renderer() + renderer._render_plan = [ + make_info("module_c", change_reasons=[], + all_frids=["1", "2", "3", "4", "5", "6"], implemented_frids=["1", "2", "3"]), + ] + + with patch("builtins.input", return_value="b"): + renderer.prompt_user_if_needed() + + assert renderer.render_range == ["4", "5", "6"] + + # --- Situation 13: no spec changes, current fully rendered --- + + def test_situation_13_no_opening_line(self, capsys): + renderer = make_renderer() + renderer._render_plan = [ + make_info("module_c", change_reasons=[], + all_frids=["1", "2", "3"], implemented_frids=["1", "2", "3"]), + ] + + with patch("builtins.input", return_value="b"): + renderer.prompt_user_if_needed() + + out = capsys.readouterr().out + assert "Changes in specs" not in out + assert "All functionalities in module_c were already implemented." in out + assert "Re-render module_c from scratch" in out + assert "Continue from" not in out + + # --- CLI render_range set: hides "Continue from" --- + + def test_cli_render_range_hides_continue_from(self, capsys): + renderer = make_renderer(render_range=["3", "4", "5", "6"]) + renderer._render_plan = [ + make_info("module_b", change_reasons=["spec changed"], all_frids=["1", "2"], implemented_frids=["1", "2"]), + make_info("module_c", change_reasons=["required module 'module_b' changed"], + all_frids=["1", "2", "3", "4", "5", "6"], implemented_frids=["1", "2", "3"]), + ] + + with patch("builtins.input", return_value="b"): + renderer.prompt_user_if_needed() + + out = capsys.readouterr().out + assert "Continue from functionality" not in out + assert f"Re-render {renderer._render_plan[-1].module_name} from scratch" in out + + # --- Invalid input re-prompts --- + + def test_invalid_input_reprompts(self, capsys): + renderer = make_renderer() + renderer._render_plan = [ + make_info("module_c", change_reasons=["spec changed"], + all_frids=["1", "2", "3"], implemented_frids=["1", "2", "3"]), + ] + + with patch("builtins.input", side_effect=["z", "x", "a"]): + result = renderer.prompt_user_if_needed() + + assert result is False + out = capsys.readouterr().out + assert out.count("Please enter one of") == 2 + + # --- EOFError / KeyboardInterrupt cancel --- + + def test_eof_cancels(self): + renderer = make_renderer() + renderer._render_plan = [ + make_info("module_c", change_reasons=["spec changed"], + all_frids=["1", "2", "3"], implemented_frids=["1"]), + ] + + with patch("builtins.input", side_effect=EOFError): + result = renderer.prompt_user_if_needed() + + assert result is True + + def test_keyboard_interrupt_cancels(self): + renderer = make_renderer() + renderer._render_plan = [ + make_info("module_c", change_reasons=["spec changed"], + all_frids=["1", "2", "3"], implemented_frids=["1"]), + ] + + with patch("builtins.input", side_effect=KeyboardInterrupt): + result = renderer.prompt_user_if_needed() + + assert result is True + + +# --------------------------------------------------------------------------- +# _skip_required_rerender — integration with render_range updates +# --------------------------------------------------------------------------- + +class TestUserDecisionState: + + def test_rerender_all_does_not_set_skip_flag(self, capsys): + renderer = make_renderer() + renderer._render_plan = [ + make_info("module_b", change_reasons=["spec changed"], all_frids=["1"], implemented_frids=["1"]), + make_info("module_c", change_reasons=["required module 'module_b' changed"], + all_frids=["1", "2", "3"], implemented_frids=["1", "2", "3"]), + ] + + with patch("builtins.input", return_value="a"): + renderer.prompt_user_if_needed() + + assert renderer._skip_required_rerender is False + + def test_continue_from_computes_range_from_first_unimplemented(self, capsys): + renderer = make_renderer() + renderer._render_plan = [ + make_info("module_b", change_reasons=["spec changed"], all_frids=["1"], implemented_frids=["1"]), + make_info("module_c", change_reasons=["required module 'module_b' changed"], + all_frids=["1", "2", "3", "4", "5"], implemented_frids=["1", "2"]), + ] + + with patch("builtins.input", return_value="b"): + renderer.prompt_user_if_needed() + + assert renderer.render_range == ["3", "4", "5"] + assert renderer._skip_required_rerender is True + + def test_rerender_current_clears_render_range(self, capsys): + renderer = make_renderer(render_range=["3", "4"]) + renderer._render_plan = [ + make_info("module_b", change_reasons=["spec changed"], all_frids=["1"], implemented_frids=["1"]), + make_info("module_c", change_reasons=["required module 'module_b' changed"], + all_frids=["1", "2", "3", "4"], implemented_frids=["1", "2", "3", "4"]), + ] + + with patch("builtins.input", return_value="b"): + renderer.prompt_user_if_needed() + + assert renderer.render_range is None + assert renderer._skip_required_rerender is True + + def test_cancel_does_not_modify_state(self, capsys): + renderer = make_renderer() + renderer._render_plan = [ + make_info("module_c", change_reasons=["spec changed"], + all_frids=["1", "2", "3"], implemented_frids=["1"]), + ] + original_render_range = renderer.render_range + + with patch("builtins.input", return_value="c"): + result = renderer.prompt_user_if_needed() + + assert result is True + assert renderer.render_range == original_render_range + assert renderer._skip_required_rerender is False