Skip to content

Commit 36d09ce

Browse files
run conformance tests as one whole-suite invocation per module
The conformance script now receives the module's suite folder (own module) or the required module's copied suite root instead of a per-FRID subfolder. The current-FRID phase's single run covers every prior FRID of the module, so the regression phase reduces to one run per required module (plus a re-run of the own module's suite when code changed while fixing). On failure, the run output is attributed to the implicated functionalities by matching suite folder names; the running context is pointed at the earliest implicated FRID so the existing fix loop, memory keying, and conflict detection work unchanged, and the failure evidence sent to the fix flow is scoped to that FRID (other implicated FRIDs appear only as a summary note). Layout-level failures (suites that cannot be discovered together, e.g. from projects rendered before whole-suite execution) fail fast with a regeneration hint instead of entering the fix loop.
1 parent 4aaa6e4 commit 36d09ce

6 files changed

Lines changed: 247 additions & 48 deletions

File tree

render_machine/actions/run_conformance_tests.py

Lines changed: 44 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,20 @@
44
import render_machine.render_utils as render_utils
55
from plain2code_console import console
66
from render_machine.actions.base_action import BaseAction
7+
from render_machine.failure_attribution import detect_layout_failure
78
from render_machine.render_context import RenderContext
89
from render_machine.render_types import RenderError
910

1011
UNRECOVERABLE_ERROR_EXIT_CODES = [69]
1112

13+
LAYOUT_FAILURE_MESSAGE = (
14+
"Conformance test suites of this module could not be discovered or run together. "
15+
"This usually means the conformance tests were generated by an older version of the renderer - "
16+
"delete the module's conformance tests folder so they get regenerated on the next render. "
17+
"If the project uses a custom conformance tests script, make sure it runs all tests found "
18+
"under the folder it receives as its second argument, including tests in subfolders."
19+
)
20+
1221

1322
class RunConformanceTests(BaseAction):
1423

@@ -19,26 +28,16 @@ class RunConformanceTests(BaseAction):
1928
def execute(self, render_context: RenderContext, _previous_action_payload: Any | None):
2029
conformance_tests_script = os.path.normpath(render_context.conformance_tests_script)
2130

22-
if render_context.module_name == render_context.conformance_tests_running_context.current_testing_module_name:
23-
conformance_tests_folder_name = (
24-
render_context.conformance_tests_running_context.get_current_conformance_test_folder_name()
25-
)
26-
else:
27-
[conformance_tests_folder_name, _] = (
28-
render_context.conformance_tests.get_source_conformance_test_folder_name(
29-
render_context.module_name,
30-
render_context.required_modules,
31-
render_context.conformance_tests_running_context.current_testing_module_name,
32-
render_context.conformance_tests_running_context.get_current_conformance_test_folder_name(),
33-
)
34-
)
31+
conformance_tests_folder_name = render_context.conformance_tests.get_module_suite_run_folder(
32+
render_context.module_name,
33+
render_context.required_modules,
34+
render_context.conformance_tests_running_context.current_testing_module_name,
35+
)
3536

3637
console.info(
3738
f"Running conformance tests script {conformance_tests_script} "
38-
+ f"for {conformance_tests_folder_name} ("
39-
+ f"functionality {render_context.conformance_tests_running_context.current_testing_frid} "
40-
+ f"in module {render_context.conformance_tests_running_context.current_testing_module_name}"
41-
+ ")."
39+
+ f"for the test suite {conformance_tests_folder_name} "
40+
+ f"of module {render_context.conformance_tests_running_context.current_testing_module_name}."
4241
)
4342
exit_code, conformance_tests_issue, conformance_tests_temp_log_file_path = render_utils.execute_script(
4443
conformance_tests_script,
@@ -54,21 +53,23 @@ def execute(self, render_context: RenderContext, _previous_action_payload: Any |
5453
)
5554
render_context.script_execution_history.should_update_script_outputs = True
5655

57-
render_context.memory_manager.create_conformance_tests_memory(
58-
render_context, exit_code, conformance_tests_issue
59-
)
60-
6156
if exit_code == 0:
57+
render_context.memory_manager.create_conformance_tests_memory(
58+
render_context, exit_code, conformance_tests_issue
59+
)
60+
# A passing whole-suite run of the module being rendered covers the FRID being
61+
# implemented, so its unresolved memory entries can be cleared.
6262
if (
6363
render_context.conformance_tests_running_context.current_testing_module_name
6464
== render_context.module_name
65-
and render_context.conformance_tests_running_context.current_testing_frid
66-
== render_context.frid_context.frid
6765
):
6866
render_context.memory_manager.delete_unresolved_memory_files()
6967
return self.SUCCESSFUL_OUTCOME, None
7068

7169
if exit_code in UNRECOVERABLE_ERROR_EXIT_CODES:
70+
render_context.memory_manager.create_conformance_tests_memory(
71+
render_context, exit_code, conformance_tests_issue
72+
)
7273
console.error(conformance_tests_issue)
7374
return (
7475
self.UNRECOVERABLE_ERROR_OUTCOME,
@@ -80,4 +81,23 @@ def execute(self, render_context: RenderContext, _previous_action_payload: Any |
8081
).to_payload(),
8182
)
8283

83-
return self.FAILED_OUTCOME, {"previous_conformance_tests_issue": conformance_tests_issue}
84+
if detect_layout_failure(conformance_tests_issue):
85+
console.error(conformance_tests_issue)
86+
return (
87+
self.UNRECOVERABLE_ERROR_OUTCOME,
88+
RenderError.encode(
89+
message=LAYOUT_FAILURE_MESSAGE,
90+
error_type="ENVIRONMENT_ERROR",
91+
script=conformance_tests_script,
92+
issue=conformance_tests_issue,
93+
).to_payload(),
94+
)
95+
96+
# Attribute the failure to a FRID (re-pointing the running context for the fix loop)
97+
# before creating memory, so the memory entry is keyed to the implicated FRID.
98+
conformance_tests_evidence = render_context.route_conformance_failure_to_frid(conformance_tests_issue)
99+
render_context.memory_manager.create_conformance_tests_memory(
100+
render_context, exit_code, conformance_tests_issue
101+
)
102+
103+
return self.FAILED_OUTCOME, {"previous_conformance_tests_issue": conformance_tests_evidence}

render_machine/conformance_tests.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,34 @@ def store_conformance_tests_files(
144144
style=console.OUTPUT_STYLE,
145145
)
146146

147+
def get_module_suite_run_folder(
148+
self,
149+
module_name: str,
150+
required_modules: list[PlainModule],
151+
current_testing_module_name: str,
152+
) -> str:
153+
"""Resolve the folder to pass to the conformance test script for a whole-module run.
154+
155+
For the module being rendered this is its own conformance tests folder. For a
156+
required module it is the most specific existing copy of that module's tests
157+
(mirroring get_source_conformance_test_folder_name at module granularity), falling
158+
back to the required module's own folder when no copy exists yet.
159+
"""
160+
if current_testing_module_name == module_name:
161+
return self.get_module_conformance_tests_folder(module_name)
162+
163+
modules_list = [module_name] + [m.module_name for m in reversed(required_modules)]
164+
165+
for copy_from_module in modules_list:
166+
if copy_from_module == current_testing_module_name:
167+
break
168+
169+
candidate = self.get_module_conformance_tests_folder(copy_from_module + "/." + current_testing_module_name)
170+
if os.path.exists(candidate):
171+
return candidate
172+
173+
return self.get_module_conformance_tests_folder(current_testing_module_name)
174+
147175
def fetch_all_existing_conformance_test_files(self, module_name: str) -> dict[str, str]:
148176
"""Fetch the content of all existing conformance test files of the module.
149177

render_machine/render_context.py

Lines changed: 98 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os.path
12
import threading
23
from copy import deepcopy
34
from typing import Callable, Optional
@@ -11,7 +12,7 @@
1112
from plain2code_events import RenderContextSnapshot
1213
from plain2code_state import RunState
1314
from plain_modules import PlainModule
14-
from render_machine import triggers
15+
from render_machine import failure_attribution, triggers
1516
from render_machine.conformance_tests import CONFORMANCE_TESTS_DEFINITION_FILE_NAME, ConformanceTests
1617
from render_machine.render_types import (
1718
AcceptanceTestPhase,
@@ -343,7 +344,7 @@ def _start_regression_phase(self):
343344
ctx.code_changed_during_regression = False
344345

345346
ctx.execution_phase = TestExecutionPhase.RUNNING_REGRESSION
346-
ctx.current_testing_frid = None # Will be set by get_first_conformance_tests_running_context
347+
ctx.regression_module_index = None # Will be advanced by _get_next_regression_module
347348

348349
def _get_next_test_to_run(self):
349350
"""Determine which test to run next based on current phase."""
@@ -354,6 +355,78 @@ def _get_next_test_to_run(self):
354355
else:
355356
return self.get_next_conformance_tests_running_context()
356357

358+
def _module_has_conformance_tests(self, module_name: str) -> bool:
359+
return len(self.conformance_tests.get_conformance_tests_json(module_name)) > 0
360+
361+
def _get_next_regression_module(self) -> Optional[str]:
362+
"""Advance to the next module whose whole suite should run during regression.
363+
364+
The regression sequence is every required module (in requires order). When code
365+
changed while fixing a conformance test, the module being rendered is appended so
366+
its suite gets re-verified against the changed code.
367+
"""
368+
ctx = self.conformance_tests_running_context
369+
370+
module_names = [module.module_name for module in (self.required_modules or [])]
371+
if ctx.code_changed_during_regression:
372+
module_names = module_names + [self.module_name]
373+
374+
next_index = 0 if ctx.regression_module_index is None else ctx.regression_module_index + 1
375+
while next_index < len(module_names):
376+
module_name = module_names[next_index]
377+
if module_name == self.module_name or self._module_has_conformance_tests(module_name):
378+
ctx.regression_module_index = next_index
379+
return module_name
380+
next_index += 1
381+
382+
return None
383+
384+
def _switch_to_module_suite(self, module_name: str):
385+
"""Point the running context at a module so its whole suite is the next run."""
386+
ctx = self.conformance_tests_running_context
387+
388+
if module_name == self.module_name:
389+
ctx.current_testing_module_name = self.module_name
390+
ctx.current_testing_frid = ctx.frid_being_implemented
391+
else:
392+
if not ctx.conformance_tests_json_has_module_populated(module_name):
393+
ctx.set_conformance_tests_json(
394+
module_name, self.conformance_tests.get_conformance_tests_json(module_name)
395+
)
396+
ctx.current_testing_module_name = module_name
397+
ctx.current_testing_frid = next(iter(ctx.get_conformance_tests_json(module_name)))
398+
399+
self._setup_test_specifications()
400+
401+
def route_conformance_failure_to_frid(self, conformance_tests_issue: str) -> str:
402+
"""Attribute a failed whole-suite run to a functionality and scope the evidence to it.
403+
404+
Points the running context at the earliest implicated FRID (the fix loop, memory
405+
creation, and conflict detection all key off current_testing_frid) and returns the
406+
failure evidence for that FRID: its own failure blocks plus a summary note about
407+
other implicated FRIDs. Returns the issue unchanged when no FRID can be identified.
408+
"""
409+
ctx = self.conformance_tests_running_context
410+
module_json = ctx.get_conformance_tests_json(ctx.current_testing_module_name)
411+
412+
implicated_frids = failure_attribution.attribute_failures(conformance_tests_issue, module_json)
413+
if not implicated_frids:
414+
return conformance_tests_issue
415+
416+
target_frid = implicated_frids[0]
417+
if target_frid != ctx.current_testing_frid:
418+
console.info(
419+
f"Conformance test failure attributed to functionality {target_frid} "
420+
f"in module {ctx.current_testing_module_name}."
421+
)
422+
ctx.current_testing_frid = target_frid
423+
self._setup_test_specifications()
424+
425+
folder_basename = os.path.basename(module_json[target_frid]["folder_name"])
426+
evidence = failure_attribution.extract_frid_failure_evidence(conformance_tests_issue, folder_basename)
427+
428+
return evidence + failure_attribution.format_other_frids_note(implicated_frids, target_frid)
429+
357430
def _has_reached_implementation_frid(self) -> bool:
358431
"""Check if regression has reached the FRID being implemented."""
359432
ctx = self.conformance_tests_running_context
@@ -469,32 +542,23 @@ def _handle_current_frid_testing(self):
469542
raise RuntimeError(f"Unexpected acceptance test phase: {ctx.acceptance_test_phase}")
470543

471544
def _handle_regression_testing(self):
472-
"""Handle regression testing of all earlier FRIDs."""
473-
474-
# Get next test to run
475-
self.conformance_tests_running_context = self._get_next_test_to_run()
545+
"""Handle regression testing: one whole-suite run per module.
476546
477-
# Get reference to the updated context
547+
The module's own suite already ran in full during the current-FRID phase, so
548+
regression only needs the required modules' suites (plus a re-run of the own
549+
module's suite when code changed while fixing a conformance test).
550+
"""
478551
ctx = self.conformance_tests_running_context
479552

480-
# Set up specs and run test
481-
self._setup_test_specifications()
553+
next_module = self._get_next_regression_module()
482554

483-
if ctx.current_conformance_tests_exist():
484-
# Check if this is the implementation FRID (last test to run)
485-
if self._has_reached_implementation_frid():
486-
# Reached implementation FRID - only re-run it if code changed during regression
487-
if ctx.code_changed_during_regression:
488-
# Code changed - run the implementation FRID again to verify no regression
489-
# After it passes, mark as completed on next iteration
490-
ctx.execution_phase = TestExecutionPhase.COMPLETED
491-
else:
492-
# No code changes - skip re-running implementation FRID, mark as completed immediately
493-
ctx.execution_phase = TestExecutionPhase.COMPLETED
494-
self.machine.dispatch(triggers.MARK_ALL_CONFORMANCE_TESTS_PASSED)
495-
return
555+
if next_module is None:
556+
ctx.execution_phase = TestExecutionPhase.COMPLETED
557+
self.machine.dispatch(triggers.MARK_ALL_CONFORMANCE_TESTS_PASSED)
558+
return
496559

497-
self.machine.dispatch(triggers.MARK_CONFORMANCE_TESTS_READY)
560+
self._switch_to_module_suite(next_module)
561+
self.machine.dispatch(triggers.MARK_CONFORMANCE_TESTS_READY)
498562

499563
# ========== Main Conformance Test Orchestration ==========
500564

@@ -534,7 +598,17 @@ def start_conformance_tests_for_frid(self):
534598
return
535599

536600
# ========== STEP 3: Handle Current FRID Testing ==========
537-
if self._should_run_current_frid_tests():
601+
if ctx.execution_phase == TestExecutionPhase.TESTING_CURRENT_FRID:
602+
# A failed whole-suite run may have re-pointed current_testing_frid at the
603+
# implicated FRID for the fix loop; restore the FRID being implemented before
604+
# continuing the current-FRID phases.
605+
if (
606+
ctx.current_testing_module_name != self.module_name
607+
or ctx.current_testing_frid != ctx.frid_being_implemented
608+
):
609+
ctx.current_testing_module_name = self.module_name
610+
ctx.current_testing_frid = ctx.frid_being_implemented
611+
self._setup_test_specifications()
538612
self._handle_current_frid_testing()
539613
return
540614

render_machine/render_types.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ def __init__(
8383
self.execution_phase: TestExecutionPhase = TestExecutionPhase.TESTING_CURRENT_FRID
8484
self.acceptance_test_phase: AcceptanceTestPhase = AcceptanceTestPhase.NOT_STARTED
8585
self.acceptance_tests_completed: int = 0
86+
# Index into the regression module sequence; None means regression has not started.
87+
self.regression_module_index: Optional[int] = None
8688
self.frid_being_implemented: Optional[str] = frid_being_implemented
8789
self.test_that_triggered_code_change: Optional[tuple[str, str]] = None
8890
self.code_changed_during_regression: bool = False

tests/test_conformance_tests.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,33 @@ def test_fetch_all_existing_conformance_test_files_skips_binary_files(conformanc
7676
files_content = conformance_tests.fetch_all_existing_conformance_test_files(MODULE_NAME)
7777

7878
assert files_content == {os.path.join("some_functionality", "test_some.py"): "some test"}
79+
80+
81+
class _FakeModule:
82+
def __init__(self, module_name):
83+
self.module_name = module_name
84+
85+
86+
def test_get_module_suite_run_folder_own_module(conformance_tests, conformance_tests_dir):
87+
folder = conformance_tests.get_module_suite_run_folder(MODULE_NAME, [], MODULE_NAME)
88+
89+
assert folder == os.path.join(conformance_tests_dir, MODULE_NAME)
90+
91+
92+
def test_get_module_suite_run_folder_required_module_with_copy(conformance_tests, conformance_tests_dir):
93+
copy_folder = os.path.join(conformance_tests_dir, MODULE_NAME, ".required_module")
94+
_write_file(copy_folder, os.path.join("some_frid", "test_x.py"), "copied test")
95+
96+
folder = conformance_tests.get_module_suite_run_folder(
97+
MODULE_NAME, [_FakeModule("required_module")], "required_module"
98+
)
99+
100+
assert folder == copy_folder
101+
102+
103+
def test_get_module_suite_run_folder_required_module_without_copy(conformance_tests, conformance_tests_dir):
104+
folder = conformance_tests.get_module_suite_run_folder(
105+
MODULE_NAME, [_FakeModule("required_module")], "required_module"
106+
)
107+
108+
assert folder == os.path.join(conformance_tests_dir, "required_module")

0 commit comments

Comments
 (0)