From 805fa1e60f3be3cca4550799da8b35bf37741817 Mon Sep 17 00:00:00 2001 From: Predrag Radenkovic Date: Fri, 19 Jun 2026 15:24:41 +0200 Subject: [PATCH] Automatic partial rendering Replace the coarse "spec changed -> re-render the whole affected module" behavior with functionality-level change detection. On re-render, each module's current functional specs are diffed against the set rendered last time and classified (added / removed / edited / moved); rendering resumes from the earliest affected functionality, keeping the unchanged prefix instead of rebuilding from scratch. - Persist the rendered functionality list per module in .codeplain/module_metadata.json, committed per-FRID so an interrupted render still has an accurate baseline (storing raw FR markdown so code-variable FRs are not falsely flagged as edited). - change_detection.py computes the earliest affected FRID; non-functional changes (definitions, impl reqs) and missing baselines fall back to a full re-render. - get_render_choices offers the optimized "render from changed functionality" as the default, alongside full-rebuild and reset options; a functionality appended past the render frontier is treated as a normal continue. - TUI defaults to the fastest (least-work) choice. Remove the obsolete --smart flag. Backward compatible: builds without the new metadata fall back to a full re-render. Covered by tests in test_change_detection.py, test_partial_rendering.py, and test_plain_modules.py. --- change_detection.py | 190 +++++++++ partial_rendering.py | 185 +++++--- plain_modules.py | 38 +- .../actions/finish_functional_requirement.py | 2 + .../data/partial_rendering/pr_code_var.plain | 9 + .../data/partial_rendering/pr_implement.plain | 2 + tests/test_change_detection.py | 394 ++++++++++++++++++ tests/test_partial_rendering.py | 255 ++++++++++++ tests/test_plain_modules.py | 49 +++ tui/plain_module_render_choice_tui.py | 37 +- 10 files changed, 1097 insertions(+), 64 deletions(-) create mode 100644 change_detection.py create mode 100644 tests/data/partial_rendering/pr_code_var.plain create mode 100644 tests/data/partial_rendering/pr_implement.plain create mode 100644 tests/test_change_detection.py diff --git a/change_detection.py b/change_detection.py new file mode 100644 index 00000000..557f1fdd --- /dev/null +++ b/change_detection.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal + +if TYPE_CHECKING: + from plain_modules import PlainModule + +MODULE_FUNCTIONALITIES_KEY = "functionalities" +NON_FUNCTIONAL_SOURCE_HASH_KEY = "non_functional_source_hash" + + +@dataclass +class FunctionalityChange: + module: str + frid: str + change_type: Literal["added", "removed", "edited", "moved"] + detail: str | None = None + + +@dataclass +class PartialRenderStart: + module: "PlainModule" + frid: str + + +def determine_partial_render_start(plain_module: "PlainModule") -> PartialRenderStart | None: + """Determine where to start partial rendering based on spec changes. + + Returns None (only full render is safe) if non-FR sections changed + (e.g. definitions, implementation reqs) since previously-rendered FRs + were generated without that context, if no changes are found, or if + all changes are trailing removals that don't require rendering. + """ + all_modules = plain_module.all_required_modules + [plain_module] + + for module in all_modules: + if _non_functional_content_changed(module): + return None + + changes = _detect_module_changes(module) + if not changes: + continue + + current_fr_count = len(module._get_module_functional_requirements()) + earliest_frid = _get_earliest_affected_frid(changes, current_fr_count) + if earliest_frid is None: + continue + return PartialRenderStart(module=module, frid=earliest_frid) + + return None + + +def _non_functional_content_changed(module: "PlainModule") -> bool: + """Check whether anything outside functional specs changed since last render. + + A missing stored hash (older builds) is treated as changed — partial rendering + is unsafe without a known baseline. + """ + metadata = module.load_module_metadata() + if not metadata: + return False + stored_hash = metadata.get(NON_FUNCTIONAL_SOURCE_HASH_KEY) + if stored_hash is None: + return True + 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. + + 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. + + 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). + + Returns None when every change is a removal beyond the current spec length + (only trailing FRs 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: + continue + if earliest is None or frid_int < earliest: + earliest = frid_int + if earliest is None: + return None + return str(earliest) + + +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() + + if old_frs == new_frs: + return [] + + moves, edits, removed, added = _classify_changes(old_frs, new_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 idx in edits: + changes.append(FunctionalityChange(module=name, frid=_frid_from_index(idx), change_type="edited")) + + for idx in removed: + changes.append(FunctionalityChange(module=name, frid=_frid_from_index(idx), change_type="removed")) + + for idx in added: + changes.append(FunctionalityChange(module=name, frid=_frid_from_index(idx), 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() + + 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. + """ + 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 + + +def _frid_from_index(index: int) -> str: + return str(index + 1) diff --git a/partial_rendering.py b/partial_rendering.py index 8e864efd..202bff87 100644 --- a/partial_rendering.py +++ b/partial_rendering.py @@ -2,6 +2,7 @@ from typing import Literal import plain_spec +from change_detection import PartialRenderStart, determine_partial_render_start from plain2code_exceptions import ModuleDoesNotExistError from plain_modules import PlainModule @@ -143,86 +144,154 @@ def get_all_affected_modules_from_change( return list(all_affected_modules.values()) -def get_render_choices( +def change_is_only_future_work( plain_module: PlainModule, plain_module_render_state: PlainModuleRenderState, - force_render: bool = False, -) -> dict[str, RenderChoice]: - choices = dict[str, RenderChoice]() - choice_idx = 1 - module_start_points = list[str]() + partial_start: "PartialRenderStart | None", +) -> bool: + """Return True when a spec change only adds work *after* the last-rendered functionality. + + Appending a new functionality past the render frontier (e.g. a new functionality at the + end of the last module) does not affect anything already rendered, so it needs no user + decision. The normal "continue" path renders the outstanding functionalities, starting + from the first one that was never rendered. + """ + if partial_start is None: + return False + + all_modules = plain_module.all_required_modules + [plain_module] + order = {module.module_name: index for index, module in enumerate(all_modules)} + last_index = order[plain_module_render_state.last_render_module.module_name] + start_index = order[partial_start.module.module_name] + + if start_index != last_index: + return start_index > last_index - if plain_module_render_state.last_render_module.is_initial_module(): - choices[str(choice_idx)] = RenderChoice( - module=plain_module_render_state.last_render_module, + 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) + + +def _resume_render_choice( + plain_module: PlainModule, + plain_module_render_state: PlainModuleRenderState, + force_render: bool, +) -> RenderChoice: + """The natural next step from the last-rendered position when no decision is needed: + start an unrendered module, continue a partially-rendered one, or advance to the next. + """ + pr = plain_module_render_state + + if pr.last_render_module.has_no_rendered_functionality(): + return RenderChoice( + module=pr.last_render_module, render_range=None, choice_type="module_start", wipe_later_modules=True, is_destructive=False, ) - choice_idx += 1 - elif not plain_module_render_state.last_render_module.is_module_fully_rendered(): - if not plain_module_render_state.last_render_frid: + if not pr.last_render_module.is_module_fully_rendered(): + if not pr.last_render_frid: raise ValueError("Last render FRID is not set for a non-initial module") - next_frid, next_module = plain_module.get_next_frid( - plain_module_render_state.last_render_frid, plain_module_render_state.last_render_module.module_name - ) + next_frid, next_module = plain_module.get_next_frid(pr.last_render_frid, pr.last_render_module.module_name) render_range = plain_spec.get_render_range_from(next_frid, next_module.plain_source) - - choices[str(choice_idx)] = RenderChoice( + return RenderChoice( module=next_module, - render_range=render_range if not force_render else None, + render_range=None if force_render else render_range, wipe_later_modules=force_render, choice_type="continue_from_frid", ) - choice_idx += 1 - else: - next_module = plain_module.get_next_module(plain_module_render_state.last_render_module.module_name) - if next_module is None: - next_module = plain_module_render_state.last_render_module + next_module = plain_module.get_next_module(pr.last_render_module.module_name) or pr.last_render_module + return RenderChoice( + module=next_module, + render_range=None, + choice_type="module_start", + is_destructive=pr.last_render_module.module_name == plain_module.module_name, + ) - choices[str(choice_idx)] = RenderChoice( - module=next_module, - render_range=None, - is_destructive=plain_module_render_state.last_render_module.module_name == plain_module.module_name, - choice_type="module_start", - ) - module_start_points.append(next_module.module_name) - choice_idx += 1 - if plain_module_render_state.change: +def get_render_choices( + plain_module: PlainModule, + plain_module_render_state: PlainModuleRenderState, + force_render: bool = False, +) -> dict[str, RenderChoice]: + render_choices = list[RenderChoice]() + module_start_points = list[str]() + + # Decide whether the detected change actually requires a decision. A spec change that only + # adds work *after* the last-rendered functionality (e.g. a new functionality appended to + # the end of the last module) does not affect anything already rendered, so it is treated + # like a normal "continue" rather than a change that requires choosing where to restart. + partial_start = None + treat_as_continue = plain_module_render_state.change is None + if plain_module_render_state.change is not None and plain_module_render_state.change_type == "spec_change": + partial_start = determine_partial_render_start(plain_module) + if change_is_only_future_work(plain_module, plain_module_render_state, partial_start): + treat_as_continue = True + + if treat_as_continue: + # Continue / resume from the last-rendered position. The change-driven blocks below decide + # the start otherwise — once a change touches already-rendered work, resuming from the old + # position would build on stale code. + render_choices.append(_resume_render_choice(plain_module, plain_module_render_state, force_render)) + + else: + # change is set here (otherwise treat_as_continue would be True), so change_type is always + # "spec_change" or "code_change" and get_all_affected_modules_from_change is well-defined. all_affected_modules = get_all_affected_modules_from_change(plain_module, plain_module_render_state) + if plain_module_render_state.change_type == "spec_change" and partial_start is not None: + # Optimized partial render: render from the changed functionality, keeping the + # module's earlier (unchanged) functionalities. + render_range = plain_spec.get_render_range_from(partial_start.frid, partial_start.module.plain_source) + render_choices.append( + RenderChoice( + module=partial_start.module, + render_range=render_range, + choice_type="render_from_change", + wipe_later_modules=len(all_affected_modules) > 1, + is_destructive=True, + ) + ) + + # Full re-render of the affected module(s) from scratch. For a code change this is always + # the action; for a spec change with no partial start (non-FR sections changed, or only + # trailing FR removals) it is the only safe option, and when a partial start does exist it + # is offered alongside it as the "rebuild this module cleanly" alternative. The "re-render + # from first module" choice below remains the full-chain reset. if len(all_affected_modules) > 0 and all_affected_modules[0].module_name not in module_start_points: - choices[str(choice_idx)] = RenderChoice( - module=all_affected_modules[0], - render_range=None, - choice_type="rerender_affected", - wipe_later_modules=True, - is_destructive=True, + render_choices.append( + RenderChoice( + module=all_affected_modules[0], + render_range=None, + choice_type="rerender_affected", + wipe_later_modules=True, + is_destructive=True, + ) ) module_start_points.append(all_affected_modules[0].module_name) - choice_idx += 1 - - if len(plain_module.all_required_modules) > 0: - first_module = plain_module.all_required_modules[0] - if first_module.module_name != plain_module_render_state.last_render_module.module_name and ( - plain_module_render_state.change is not None - and first_module.module_name != plain_module_render_state.change.module_name - and first_module.module_name not in module_start_points - ): - choices[str(choice_idx)] = RenderChoice( - module=first_module, - render_range=None, - choice_type="rerender_from_first", - wipe_later_modules=True, - is_destructive=True, - ) - module_start_points.append(first_module.module_name) - choice_idx += 1 - choices[str(choice_idx)] = RenderChoice(module=None, render_range=None, choice_type="quit") - return choices + if len(plain_module.all_required_modules) > 0: + first_module = plain_module.all_required_modules[0] + if first_module.module_name != plain_module_render_state.last_render_module.module_name and ( + plain_module_render_state.change is not None + and first_module.module_name != plain_module_render_state.change.module_name + and first_module.module_name not in module_start_points + ): + render_choices.append( + RenderChoice( + module=first_module, + render_range=None, + choice_type="rerender_from_first", + wipe_later_modules=True, + is_destructive=True, + ) + ) + module_start_points.append(first_module.module_name) + + render_choices.append(RenderChoice(module=None, render_range=None, choice_type="quit")) + return {str(idx): choice for idx, choice in enumerate(render_choices, start=1)} diff --git a/plain_modules.py b/plain_modules.py index 1ed8da68..0812135e 100644 --- a/plain_modules.py +++ b/plain_modules.py @@ -25,6 +25,13 @@ REQUIRED_MODULES_FUNCTIONALITIES = "required_modules_functionalities" +def _strip_functional_requirements(plain_source_tree: dict) -> dict: + stripped = {k: v for k, v in plain_source_tree.items() if k != plain_spec.FUNCTIONAL_REQUIREMENTS} + if "sections" in stripped: + stripped["sections"] = [_strip_functional_requirements(section) for section in stripped["sections"]] + return stripped + + class PlainModule: def __init__(self, filename: str, build_folder: str, conformance_tests_folder: str, template_dirs: list[str]): self.filename = filename @@ -108,9 +115,33 @@ def load_module_metadata(self) -> dict | None: with open(metadata_path, "r", encoding="utf-8") as f: return json.load(f) + def update_frid_in_module_metadata(self, frid: str) -> None: + # Store the raw FR markdown (with any {{ code_variable }} placeholders intact), exactly + # as save_module_metadata and the change-detection diff read it. Storing the rendered + # text (code variables already substituted) would make the diff report a spurious edit + # 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 + else: + functionalities.append(frid_text) + metadata[MODULE_FUNCTIONALITIES] = functionalities + + codeplain_folder = self.get_codeplain_folder() + os.makedirs(codeplain_folder, exist_ok=True) + with open(self.module_metadata_path(), "w", encoding="utf-8") as f: + json.dump(metadata, f, indent=4) + def get_module_source_hash(self) -> str: return plain_spec.get_hash_value([self.plain_source] + self.resources_list) + def get_module_non_functional_source_hash(self) -> str: + stripped = _strip_functional_requirements(self.plain_source) + return plain_spec.get_hash_value([stripped] + self.resources_list) + def get_module_code_hash(self) -> str: return ImplementationCodeHelpers.calculate_build_folder_hash(self.module_build_folder) @@ -162,7 +193,10 @@ def module_metadata_path(self, for_git_repo: bool = False) -> str: return os.path.join(self.get_codeplain_folder(), MODULE_METADATA_FILENAME) def get_hashes(self) -> dict[str, str]: - hashes = {"source_hash": self.get_module_source_hash()} + hashes = { + "source_hash": self.get_module_source_hash(), + "non_functional_source_hash": self.get_module_non_functional_source_hash(), + } if len(self.required_modules) > 0: hashes["required_modules_code_hash"] = self.required_modules[-1].get_module_code_hash() return hashes @@ -324,7 +358,7 @@ def is_module_fully_rendered(self) -> bool: return False - def is_initial_module(self) -> bool: + def has_no_rendered_functionality(self) -> bool: last_rendered_module_name, last_rendered_frid = git_utils.get_last_rendered_functionality( self.module_build_folder ) diff --git a/render_machine/actions/finish_functional_requirement.py b/render_machine/actions/finish_functional_requirement.py index 1a27f10f..7439c583 100644 --- a/render_machine/actions/finish_functional_requirement.py +++ b/render_machine/actions/finish_functional_requirement.py @@ -8,6 +8,8 @@ class FinishFunctionalRequirement(CommitImplementationCodeChanges): SUCCESSFUL_OUTCOME = "functional_requirement_finished" def execute(self, render_context: RenderContext, previous_action_payload: Any | None): + render_context.plain_module.update_frid_in_module_metadata(render_context.frid_context.frid) + super().execute(render_context, previous_action_payload) render_context.codeplain_api.finish_functional_requirement( diff --git a/tests/data/partial_rendering/pr_code_var.plain b/tests/data/partial_rendering/pr_code_var.plain new file mode 100644 index 00000000..0494ef99 --- /dev/null +++ b/tests/data/partial_rendering/pr_code_var.plain @@ -0,0 +1,9 @@ +***implementation reqs*** + +- Code-variable implementation requirement. + +***functional specs*** + +- Plain functionality one. + +- Implement {% include "pr_implement.plain", variable_name: "configurable-value" %} diff --git a/tests/data/partial_rendering/pr_implement.plain b/tests/data/partial_rendering/pr_implement.plain new file mode 100644 index 00000000..8110371f --- /dev/null +++ b/tests/data/partial_rendering/pr_implement.plain @@ -0,0 +1,2 @@ +something configurable + - the configurable thing should be really {{ variable_name | code_variable }} diff --git a/tests/test_change_detection.py b/tests/test_change_detection.py new file mode 100644 index 00000000..85f8c232 --- /dev/null +++ b/tests/test_change_detection.py @@ -0,0 +1,394 @@ +"""Tests for the change detection logic. + +Uses lightweight fake PlainModule-like objects (same pattern as +test_partial_rendering.py) to exercise the comparison algorithm +without filesystem or real PlainModule dependencies. +""" + +from change_detection import FunctionalityChange, _detect_module_changes, determine_partial_render_start + + +class FakeModule: + def __init__( + self, + module_name: str, + current_frs: list[str], + stored_frs: list[str] | None = None, + current_non_fr_hash: str = "match", + stored_non_fr_hash: str | None = "match", + ): + self.module_name = module_name + self._current_frs = current_frs + self._stored_frs = stored_frs + self._current_non_fr_hash = current_non_fr_hash + self._stored_non_fr_hash = stored_non_fr_hash + self.required_modules: list[FakeModule] = [] + + @property + def all_required_modules(self) -> list["FakeModule"]: + result = [] + for rm in self.required_modules: + result.extend(rm.all_required_modules) + result.append(rm) + return result + + def load_module_metadata(self) -> dict | None: + if self._stored_frs is None: + return None + metadata: dict = {"functionalities": self._stored_frs} + if self._stored_non_fr_hash is not None: + metadata["non_functional_source_hash"] = self._stored_non_fr_hash + return metadata + + def _get_module_functional_requirements(self) -> list[str]: + return self._current_frs + + def get_module_non_functional_source_hash(self) -> str: + return self._current_non_fr_hash + + +# --- No changes --- + + +def test_identical_specs_no_changes(): + module = FakeModule("mod", ["A", "B", "C"], ["A", "B", "C"]) + changes = _detect_module_changes(module) + assert changes == [] + + +def test_empty_both_no_changes(): + module = FakeModule("mod", [], []) + changes = _detect_module_changes(module) + assert changes == [] + + +# --- All added (no metadata / never rendered) --- + + +def test_no_metadata_all_added(): + module = FakeModule("mod", ["A", "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") + + +def test_empty_stored_all_added(): + module = FakeModule("mod", ["A", "B"], stored_frs=[]) + changes = _detect_module_changes(module) + assert len(changes) == 2 + assert all(c.change_type == "added" for c in changes) + + +# --- All removed --- + + +def test_no_current_frs_all_removed(): + module = FakeModule("mod", [], stored_frs=["A", "B", "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" + + +# --- Edits --- + + +def test_single_edit(): + module = FakeModule("mod", ["A modified", "B"], stored_frs=["A", "B"]) + changes = _detect_module_changes(module) + assert len(changes) == 1 + assert changes[0] == FunctionalityChange(module="mod", frid="1", change_type="edited") + + +def test_multiple_edits(): + module = FakeModule("mod", ["X", "Y", "Z"], stored_frs=["A", "B", "C"]) + changes = _detect_module_changes(module) + assert len(changes) == 3 + assert all(c.change_type == "edited" for c in changes) + + +# --- Additions --- + + +def test_addition_at_end(): + module = FakeModule("mod", ["A", "B", "C"], stored_frs=["A", "B"]) + changes = _detect_module_changes(module) + assert len(changes) == 1 + assert changes[0] == FunctionalityChange(module="mod", frid="3", change_type="added") + + +# --- Removals --- + + +def test_removal_at_end(): + module = FakeModule("mod", ["A", "B"], stored_frs=["A", "B", "C"]) + changes = _detect_module_changes(module) + assert len(changes) == 1 + assert changes[0] == FunctionalityChange(module="mod", frid="3", change_type="removed") + + +# --- Moves --- + + +def test_swap_detected_as_moves(): + module = FakeModule("mod", ["B", "A"], stored_frs=["A", "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 + + +def test_move_to_later_position(): + module = FakeModule("mod", ["B", "C", "A"], stored_frs=["A", "B", "C"]) + changes = _detect_module_changes(module) + moves = [c for c in changes if c.change_type == "moved"] + assert len(moves) == 3 + + +# --- Combined changes --- + + +def test_edit_and_addition(): + module = FakeModule("mod", ["X", "B", "C"], stored_frs=["A", "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" + + +def test_edit_and_removal(): + module = FakeModule("mod", ["X"], stored_frs=["A", "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" + + +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"]) + 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") + + +# --- 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 + + +def test_partial_render_start_edit_at_fr3(): + module = FakeModule("mod", ["A", "B", "C modified", "D", "E"], stored_frs=["A", "B", "C", "D", "E"]) + result = determine_partial_render_start(module) + assert result is not None + assert result.module.module_name == "mod" + assert result.frid == "3" + + +def test_partial_render_start_removal_at_fr3(): + module = FakeModule("mod", ["A", "B", "D", "E"], stored_frs=["A", "B", "C", "D", "E"]) + result = determine_partial_render_start(module) + assert result is not None + assert result.module.module_name == "mod" + assert result.frid == "3" + + +def test_partial_render_start_addition_at_end(): + module = FakeModule("mod", ["A", "B", "C", "D"], stored_frs=["A", "B", "C"]) + result = determine_partial_render_start(module) + assert result is not None + assert result.module.module_name == "mod" + assert result.frid == "4" + + +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"]) + result = determine_partial_render_start(module) + assert result is not None + assert result.frid == "1" + + +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"]) + result = determine_partial_render_start(module) + assert result is not None + assert result.frid == "3" + + +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"]) + result = determine_partial_render_start(module) + assert result is not None + assert result.frid == "1" + + +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"]) + 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" + + +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"]) + 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" + + +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"]) + top_module.required_modules = [req_module] + + result = determine_partial_render_start(top_module) + assert result 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 + 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" + + +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_b.required_modules = [mod_a] + mod_c = FakeModule("c", ["C1"], stored_frs=["C1"]) + 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" + + +def test_partial_render_start_never_rendered(): + module = FakeModule("mod", ["A", "B"], stored_frs=None) + result = determine_partial_render_start(module) + assert result is not None + assert result.frid == "1" + + +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 + + +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"]) + result = determine_partial_render_start(module) + assert result is not None + assert result.frid == "1" + + +# --- 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.""" + module = FakeModule( + "mod", + ["A modified", "B"], + stored_frs=["A", "B"], + current_non_fr_hash="v2", + stored_non_fr_hash="v1", + ) + result = determine_partial_render_start(module) + assert result 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"], + current_non_fr_hash="v2", + stored_non_fr_hash="v1", + ) + result = determine_partial_render_start(module) + assert result 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"], + stored_non_fr_hash=None, + ) + result = determine_partial_render_start(module) + assert result is None + + +def test_partial_render_start_non_fr_change_in_required_module_blocks(): + req_module = FakeModule( + "req", + ["R1 modified"], + stored_frs=["R1"], + current_non_fr_hash="v2", + stored_non_fr_hash="v1", + ) + top_module = FakeModule("top", ["T1"], stored_frs=["T1"]) + top_module.required_modules = [req_module] + + result = determine_partial_render_start(top_module) + assert result is None diff --git a/tests/test_partial_rendering.py b/tests/test_partial_rendering.py index 0c62bade..49247a8a 100644 --- a/tests/test_partial_rendering.py +++ b/tests/test_partial_rendering.py @@ -11,10 +11,14 @@ import pytest +import partial_rendering +from change_detection import PartialRenderStart from partial_rendering import ( PlainModuleRenderState, + change_is_only_future_work, code_change, get_plain_module_render_state, + get_render_choices, module_comes_before_or_equal, spec_change, ) @@ -30,6 +34,10 @@ def __init__( source_hash: str | None = None, code_hash: str | None = None, last_rendered=(None, None), + no_rendered_functionality=False, + fully_rendered=False, + next_frid=None, + plain_source="", ): self.module_name = module_name self.required_modules = list(required_modules or []) @@ -37,6 +45,10 @@ def __init__( self._source_hash = source_hash if source_hash is not None else f"src-{module_name}" self._code_hash = code_hash if code_hash is not None else f"code-{module_name}" self._last_rendered = last_rendered + self._no_rendered_functionality = no_rendered_functionality + self._fully_rendered = fully_rendered + self._next_frid = next_frid + self.plain_source = plain_source @property def all_required_modules(self): @@ -59,6 +71,24 @@ def get_module_code_hash(self): def get_module_render_status(self): return self._last_rendered + def has_no_rendered_functionality(self): + return self._no_rendered_functionality + + def is_module_fully_rendered(self): + return self._fully_rendered + + def get_next_frid(self, frid, module_name): + return self._next_frid + + def get_next_module(self, module_name): + all_modules = self.all_required_modules + [self] + for idx, module in enumerate(all_modules): + if module.module_name == module_name and idx < len(all_modules) - 1: + return all_modules[idx + 1] + if module_name == self.module_name: + return None + raise ModuleDoesNotExistError(f"Module {module_name} does not exist") + def _unchanged_metadata(module: FakeModule) -> dict: """Return a metadata dict that reflects the module's current hashes @@ -314,3 +344,228 @@ def test_detect_partial_rendering_spec_and_code_changes_are_mutually_exclusive() assert pr.change is leaf assert pr.change_type == "code_change" assert pr.last_render_frid == "1" + + +# ------------------------- +# get_render_choices +# ------------------------- + + +def _choice_types(choices): + return [choice.choice_type for choice in choices.values()] + + +def _build_choices_tree(**root_kwargs): + """leaf -> middle -> root, where root is the top module passed to + ``get_render_choices``. ``root_kwargs`` configures the top module.""" + leaf = FakeModule("leaf") + middle = FakeModule("middle", required_modules=[leaf]) + root = FakeModule("root", required_modules=[middle], **root_kwargs) + return root, middle, leaf + + +# --- Block 1: primary-resume choices (only offered when nothing changed) --- + + +def test_render_choices_no_change_unrendered_module_offers_module_start(): + root, _, _ = _build_choices_tree(no_rendered_functionality=True) + pr = PlainModuleRenderState(last_render_module=root, last_render_frid=None) + + choices = get_render_choices(root, pr) + + assert _choice_types(choices) == ["module_start", "quit"] + assert choices["1"].module is root + assert choices["1"].is_destructive is False + + +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") + + 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"] + + +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") + + choices = get_render_choices(root, pr) + + assert _choice_types(choices) == ["module_start", "quit"] + assert choices["1"].module is root + assert choices["1"].is_destructive is False + + +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") + + choices = get_render_choices(root, pr) + + assert _choice_types(choices) == ["module_start", "quit"] + assert choices["1"].module is root + assert choices["1"].is_destructive is True + + +# --- Block 2: a detected change suppresses the resume choice (the merge) --- + + +def test_render_choices_spec_change_in_first_module_offers_partial_and_full_restart(monkeypatch): + """Change in the first (leaf) module: offer (1) the partial render and (2) a full restart + of the affected module(s). The "re-render from first" option is deduped away because the + 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") + ) + 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) + + # The stale "continue / re-render the current module" choice must NOT appear. + assert "module_start" not in _choice_types(choices) + 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["2"].module is leaf + + +def test_render_choices_spec_change_in_top_module_offers_partial_restart_and_full_reset(monkeypatch): + """Change in the top module C at FR 3: offer (1) start from FR 3, (2) re-render C from + 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") + ) + 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) + + assert "module_start" not in _choice_types(choices) + assert "continue_from_frid" not in _choice_types(choices) + 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"] + # (2) re-render the affected module (C) from scratch + assert choices["2"].module is root + assert choices["2"].render_range is None + assert choices["2"].is_destructive is True + # (3) rebuild everything from the first module + assert choices["3"].module is leaf + + +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") + + choices = get_render_choices(root, pr) + + assert "module_start" not in _choice_types(choices) + assert "continue_from_frid" not in _choice_types(choices) + assert _choice_types(choices) == ["rerender_affected", "quit"] + assert choices["1"].module is leaf + + +def test_render_choices_spec_change_no_partial_start_falls_back_to_full_rerenders(monkeypatch): + """When no safe partial start exists (e.g. non-FR sections changed), the + 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") + + choices = get_render_choices(root, pr) + + assert "module_start" not in _choice_types(choices) + assert "continue_from_frid" not in _choice_types(choices) + assert _choice_types(choices) == ["rerender_affected", "rerender_from_first", "quit"] + assert choices["1"].module is middle + assert choices["2"].module is leaf + + +def test_render_choices_appended_after_render_frontier_auto_continues(monkeypatch): + """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) + monkeypatch.setattr( + partial_rendering, "determine_partial_render_start", lambda pm: PartialRenderStart(module=root, frid="3") + ) + 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"].is_destructive is False + + +def test_render_choices_appended_in_required_module_still_prompts(monkeypatch): + """Appending a functionality to a *required* module is not pure future work — it sits + 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") + ) + 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) + + assert "continue_from_frid" not in _choice_types(choices) + assert _choice_types(choices) == ["render_from_change", "rerender_affected", "rerender_from_first", "quit"] + assert choices["1"].module is middle + + +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") + + choices = get_render_choices(root, pr) + + last_key = list(choices.keys())[-1] + assert choices[last_key].choice_type == "quit" + assert choices[last_key].module is None + + +# ------------------------- +# change_is_only_future_work +# ------------------------- + + +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 + + +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") + # 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 + + +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 + + +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") + assert change_is_only_future_work(root, pr, None) is False diff --git a/tests/test_plain_modules.py b/tests/test_plain_modules.py index ff43657f..c0148eba 100644 --- a/tests/test_plain_modules.py +++ b/tests/test_plain_modules.py @@ -12,6 +12,7 @@ import pytest +from change_detection import determine_partial_render_start from git_utils import FUNCTIONAL_REQUIREMENT_FINISHED_COMMIT_MESSAGE, add_all_files_and_commit, init_git_repo from plain2code_exceptions import ModuleDoesNotExistError from plain_modules import CODEPLAIN_METADATA_FOLDER, MODULE_METADATA_FILENAME, PlainModule @@ -46,6 +47,15 @@ def root_module(fixtures_dir, tmp_build_folders): return PlainModule("pr_root.plain", build, conformance, [fixtures_dir]) +@pytest.fixture +def code_var_module(fixtures_dir, tmp_build_folders): + """A solo module whose FRID 2 pulls in a template with a code variable, so its + raw markdown keeps a ``{{ variable_name }}`` placeholder that differs from the + rendered (variable-substituted) text.""" + build, conformance = tmp_build_folders + return PlainModule("pr_code_var.plain", build, conformance, [fixtures_dir]) + + def _write_metadata(module: PlainModule, metadata: dict) -> None: folder = os.path.join(module.module_build_folder, CODEPLAIN_METADATA_FOLDER) os.makedirs(folder, exist_ok=True) @@ -292,3 +302,42 @@ 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") assert solo_module.is_module_fully_rendered() is True + + +# -------------------------------------------------------------------------- +# update_frid_in_module_metadata — code-variable baseline consistency +# -------------------------------------------------------------------------- + + +def test_update_frid_stores_raw_markdown_not_rendered_text(code_var_module): + """The per-FRID metadata write must store the raw FR markdown (placeholder + intact), identical to what the change-detection diff reads. Storing the + 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] + + code_var_module.update_frid_in_module_metadata("1") + code_var_module.update_frid_in_module_metadata("2") + + 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] + + +def test_code_variable_frid_not_flagged_as_change(code_var_module): + """After a (possibly interrupted) render persisted the per-FRID baseline, + re-running with an unchanged spec must detect no change — even though the + FR uses a code variable whose rendered text differs from its markdown.""" + # Seed the matching non-functional hash, as prepare_repositories does at the + # start of a real render; update_frid then layers the functionalities on top. + _write_metadata( + 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") + + assert determine_partial_render_start(code_var_module) is None diff --git a/tui/plain_module_render_choice_tui.py b/tui/plain_module_render_choice_tui.py index bc1e6deb..4e7f986b 100644 --- a/tui/plain_module_render_choice_tui.py +++ b/tui/plain_module_render_choice_tui.py @@ -47,12 +47,25 @@ def get_msg_from_choice(self, render_choice: RenderChoice) -> str: return f"Start rendering the current module ([#5593FF]{render_choice.module.module_name}[/])" else: return f"Start from module [#5593FF]{render_choice.module.module_name}[/]" + elif render_choice.choice_type == "render_from_change": + assert render_choice.module is not None + assert render_choice.render_range is not None + start_frid = render_choice.render_range[0] + quote = self._short_functionality_quote(render_choice.module, start_frid) + return ( + f"Render from changed functionality [#5593FF]{start_frid}[/]" + f" in module [#5593FF]{render_choice.module.module_name}[/]" + f": [#888]{quote}[/]" + ) elif render_choice.choice_type == "rerender_affected" and self.plain_module_render_state.change is not None: all_affected_modules = get_all_affected_modules_from_change( self.plain_module, self.plain_module_render_state ) - return f"Re-render all affected modules ([#5593FF]{', '.join([m.module_name for m in all_affected_modules])}[/])" + module_names = ", ".join([m.module_name for m in all_affected_modules]) + if len(all_affected_modules) == 1: + return f"Re-render module [#5593FF]{module_names}[/] from scratch" + return f"Re-render affected modules ([#5593FF]{module_names}[/]) from scratch" elif render_choice.choice_type == "rerender_from_first": return f"Re-render from first module ([#5593FF]{render_choice.module.module_name}[/])" @@ -142,7 +155,7 @@ def on_mount(self) -> None: change_box.mount(Label(msg, classes="rendering-info-row")) - if pr.last_render_module.is_initial_module(): + if pr.last_render_module.has_no_rendered_functionality(): change_box.mount( Label( "Resume from interrupted functionality.", @@ -157,13 +170,29 @@ def on_mount(self) -> None: ) ) - # Populate the ListView + # Populate the ListView. The first choice is always the fastest option (least work to + # render), so it is highlighted as the default — the user can just press Enter. lv = self.query_one("#choice-list", ListView) self.mount(Label("How would you like to proceed?", classes="partial-render-question"), before=lv) + default_key = next(iter(self.render_choices), None) for key, choice in self.render_choices.items(): - lv.append(ListItem(Label(f"[bold]{key}.[/bold] {self.get_msg_from_choice(choice)}"), id=f"choice-{key}")) + label_text = f"[bold]{key}.[/bold] {self.get_msg_from_choice(choice)}" + if key == default_key: + label_text += " [#888](default · press Enter)[/]" + lv.append(ListItem(Label(label_text), id=f"choice-{key}")) + lv.index = 0 lv.focus() + def _short_functionality_quote(self, module: PlainModule, frid: str, max_length: int = 60) -> str: + specifications, _ = plain_spec.get_specifications_for_frid(module.plain_source, frid) + functionality = specifications[plain_spec.FUNCTIONAL_REQUIREMENTS][-1] + first_line = functionality.splitlines()[0].strip() + if first_line.startswith("- "): + first_line = first_line[2:] + if len(first_line) > max_length: + first_line = first_line[: max_length - 1].rstrip() + "…" + return first_line + def _register_expandable(self, label: Label, prefix: str, full_text: str) -> None: first_lines = "\n".join(full_text.splitlines()[:3]) short = f"{prefix} {first_lines} [#888](ctrl+o to expand)[/]"