diff --git a/plain2code.py b/plain2code.py index 6cfc857..1f93375 100644 --- a/plain2code.py +++ b/plain2code.py @@ -158,15 +158,43 @@ def _check_connection(codeplainAPI: codeplain_api.CodeplainAPI): ) -def render(args, run_state: RunState, event_bus: EventBus, default_log_level: str = "INFO"): # noqa: C901 - template_dirs = file_utils.get_template_directories(args.filename, args.template_dir, DEFAULT_TEMPLATE_DIRS) +def warn_if_acceptance_tests_without_conformance_script(plain_module, args) -> None: + """Warn when any loaded module (including required modules) defines acceptance tests + but no conformance tests script is configured. + + Acceptance tests are treated as conformance tests, so a conformance tests script is required + to actually run them. Without it, the acceptance tests cannot be executed. + """ + if args.conformance_tests_script: + return + + module_names_with_acceptance_tests = [ + module.module_name + for module in plain_module.all_required_modules + [plain_module] + if plain_spec.has_acceptance_tests(module.plain_source) + ] + if not module_names_with_acceptance_tests: + return + + module_names = ", ".join(module_names_with_acceptance_tests) + console.warning( + f"Acceptance tests were found ({module_names}) but no conformance tests script is configured. " + "Acceptance tests are treated as conformance tests and require a conformance tests script " + "(--conformance-tests-script or 'conformance_tests_script' in config) to be executed." + ) + +def render( # noqa: C901 + plain_module: plain_modules.PlainModule, + args, + run_state: RunState, + event_bus: EventBus, + default_log_level: str = "INFO", +): # Compute render range from either --render-range or --render-from render_range = None if args.render_range or args.render_from: - # Parse the plain file to get the plain_source for FRID extraction - _, plain_source, _ = plain_file.plain_file_parser(args.filename, template_dirs) - render_range = plain_spec.compute_render_range(args, plain_source) + render_range = plain_spec.compute_render_range(args, plain_module.plain_source) codeplainAPI = codeplain_api.CodeplainAPI(args.api_key, console) assert args.api is not None and args.api != "", "API URL is required" @@ -178,12 +206,7 @@ def render(args, run_state: RunState, event_bus: EventBus, default_log_level: st enter_pause_event = threading.Event() signal.signal(signal.SIGTERM, lambda _signum, _frame: stop_event.set()) - plain_module = plain_modules.PlainModule( - args.filename, - args.build_folder, - args.conformance_tests_folder, - template_dirs, - ) + warn_if_acceptance_tests_without_conformance_script(plain_module, args) render_choice = None if render_range is None: @@ -308,28 +331,38 @@ def main(): # noqa: C901 console.error(f"Error fetching status: {str(e)}") return - # Handle early-exit flags before heavy initialization - if args.dry_run or args.full_plain: - template_dirs = file_utils.get_template_directories(args.filename, args.template_dir, DEFAULT_TEMPLATE_DIRS) + template_dirs = file_utils.get_template_directories(args.filename, args.template_dir, DEFAULT_TEMPLATE_DIRS) + # Handle full plain early-exit (raw text dump; does not require a parsed module). + if args.full_plain: try: - if args.full_plain: - module_name = Path(args.filename).stem - plain_source = plain_file.read_module_plain_source(module_name, template_dirs) - [full_plain_source, _] = file_utils.get_loaded_templates(template_dirs, plain_source) - console.info("Full plain text:\n") - console.info(full_plain_source) - return - - if args.dry_run: - console.info("Printing dry run output...\n") - _, plain_source_tree, _ = plain_file.plain_file_parser(args.filename, template_dirs) - render_range = plain_spec.compute_render_range(args, plain_source_tree) - print_dry_run_output(plain_source_tree, render_range) - return + module_name = Path(args.filename).stem + plain_source = plain_file.read_module_plain_source(module_name, template_dirs) + [full_plain_source, _] = file_utils.get_loaded_templates(template_dirs, plain_source) + console.info("Full plain text:\n") + console.info(full_plain_source) except Exception as e: console.error(f"Error: {str(e)}") - return + return + + # Parse the plain file (and its required modules) once; reused by dry-run and rendering. + try: + plain_module = plain_modules.PlainModule( + args.filename, + args.build_folder, + args.conformance_tests_folder, + template_dirs, + ) + except Exception as e: + console.error(f"Error: {str(e)}") + return + + if args.dry_run: + console.info("Printing dry run output...\n") + render_range = plain_spec.compute_render_range(args, plain_module.plain_source) + print_dry_run_output(plain_module.plain_source, render_range) + warn_if_acceptance_tests_without_conformance_script(plain_module, args) + return event_bus = EventBus() @@ -357,7 +390,7 @@ def main(): # noqa: C901 raise MissingAPIKey( "Your API key is required. Please set the CODEPLAIN_API_KEY environment variable or provide it with the --api-key argument.\n" ) - render(args, run_state, event_bus, default_log_level) + render(plain_module, args, run_state, event_bus, default_log_level) except BaseException as e: if isinstance(e, KeyboardInterrupt): error_message = "Keyboard interrupt" diff --git a/plain_file.py b/plain_file.py index c116d33..4e12684 100644 --- a/plain_file.py +++ b/plain_file.py @@ -203,6 +203,26 @@ def _is_acceptance_test_heading(token) -> tuple[bool, str | None]: return False, None +def _find_acceptance_test_heading(token): + """ + Recursively search a token tree for an `acceptance tests` heading. + + Returns the heading token if one is found anywhere in the subtree, otherwise None. + Used to detect acceptance tests nested under sections other than functional specs. + """ + is_acceptance_test_heading, _ = _is_acceptance_test_heading(token) + if is_acceptance_test_heading: + return token + + if getattr(token, "children", None): + for child in token.children: + found = _find_acceptance_test_heading(child) + if found is not None: + return found + + return None + + def _process_single_acceptance_test_requirement(functional_requirement: mistletoe.block_token.ListItem): """ Process a single functionality to extract acceptance tests. @@ -473,6 +493,11 @@ def parse_plain_source( # noqa: C901 specification_heading = token.children[0].children[0].children[0].content + if specification_heading == plain_spec.ACCEPTANCE_TEST_HEADING: + raise PlainSyntaxError( + f"Plain syntax error: Syntax error at line {token.line_number}: {plain_spec.ACCEPTANCE_TEST_HEADING} heading should be nested under specific functional spec." + ) + if specification_heading not in plain_spec.ALLOWED_SPECIFICATION_HEADINGS: raise PlainSyntaxError( f"Plain syntax error: Syntax error at line {token.line_number}: Invalid specification heading (`{specification_heading}`). Allowed headings: {', '.join(plain_spec.ALLOWED_SPECIFICATION_HEADINGS)}" @@ -511,6 +536,22 @@ def parse_plain_source( # noqa: C901 f"Plain syntax error: Syntax error at line {token.line_number}: Invalid source structure (`{token_text}`)" ) + # Acceptance tests are only allowed nested under functional specs. Reject them when + # they are nested under any other section (e.g. definitions, implementation reqs, test reqs). + for non_functional_heading in ( + plain_spec.DEFINITIONS, + plain_spec.NON_FUNCTIONAL_REQUIREMENTS, + plain_spec.TEST_REQUIREMENTS, + ): + section = plain_source.get(non_functional_heading) + if section is None: + continue + nested_acceptance_test_heading = _find_acceptance_test_heading(section) + if nested_acceptance_test_heading is not None: + raise PlainSyntaxError( + f"Plain syntax error: Syntax error at line {nested_acceptance_test_heading.line_number}: {plain_spec.ACCEPTANCE_TEST_HEADING} heading should be nested under specific functional spec." + ) + if plain_source[plain_spec.DEFINITIONS] is not None: with PlainRenderer() as renderer: for token in plain_source[plain_spec.DEFINITIONS].children: diff --git a/plain_spec.py b/plain_spec.py index dab505d..003519e 100644 --- a/plain_spec.py +++ b/plain_spec.py @@ -25,6 +25,15 @@ ALLOWED_IMPORT_SPECIFICATION_HEADINGS = [DEFINITIONS, NON_FUNCTIONAL_REQUIREMENTS, TEST_REQUIREMENTS] +def has_acceptance_tests(plain_source_tree) -> bool: + """Return True if any functional requirement in the plain source defines acceptance tests.""" + functional_requirements = plain_source_tree.get(FUNCTIONAL_REQUIREMENTS) or [] + for functional_requirement in functional_requirements: + if functional_requirement.get(ACCEPTANCE_TESTS): + return True + return False + + def collect_specification_linked_resources(specification, specification_heading, linked_resources_list): linked_resources = [] if "linked_resources" in specification: diff --git a/tests/data/acceptance_tests_warning/main_requiring_acceptance_tests.plain b/tests/data/acceptance_tests_warning/main_requiring_acceptance_tests.plain new file mode 100644 index 0000000..7f04d3f --- /dev/null +++ b/tests/data/acceptance_tests_warning/main_requiring_acceptance_tests.plain @@ -0,0 +1,16 @@ +--- +requires: + - required_with_acceptance_tests +--- + +***definitions*** + +- :MainApp: is a console application. + +***implementation reqs*** + +- The :MainApp: should print hello. + +***functional specs*** + +- Display "hello, world" diff --git a/tests/data/acceptance_tests_warning/required_with_acceptance_tests.plain b/tests/data/acceptance_tests_warning/required_with_acceptance_tests.plain new file mode 100644 index 0000000..b6e42fe --- /dev/null +++ b/tests/data/acceptance_tests_warning/required_with_acceptance_tests.plain @@ -0,0 +1,15 @@ +***definitions*** + +- :App: is a console application. + +***implementation reqs*** + +- The :App: should be simple. + +***functional specs*** + +- Implement the entry point for :App:. + + ***acceptance tests*** + + - The entry point should run without errors. diff --git a/tests/test_plain2code.py b/tests/test_plain2code.py new file mode 100644 index 0000000..1a51db4 --- /dev/null +++ b/tests/test_plain2code.py @@ -0,0 +1,94 @@ +import tempfile +from argparse import Namespace +from types import SimpleNamespace +from unittest.mock import patch + +import plain2code +import plain_spec +from plain_modules import PlainModule + + +def _make_module(module_name, has_acceptance_tests, required_modules=None): + """Build a minimal stand-in for a PlainModule. + + The functional requirements only need an `acceptance_tests` key when the module + is expected to contain acceptance tests, because that is all + `plain_spec.has_acceptance_tests` inspects. + """ + functional_requirement = {"markdown": f"- {module_name} functionality."} + if has_acceptance_tests: + functional_requirement[plain_spec.ACCEPTANCE_TESTS] = [{"markdown": "- Test it."}] + + return SimpleNamespace( + module_name=module_name, + plain_source={plain_spec.FUNCTIONAL_REQUIREMENTS: [functional_requirement]}, + all_required_modules=required_modules or [], + ) + + +def test_warns_when_acceptance_tests_present_and_no_conformance_script(): + plain_module = _make_module("top", has_acceptance_tests=True) + args = Namespace(conformance_tests_script=None) + + with patch("plain2code.console") as mock_console: + plain2code.warn_if_acceptance_tests_without_conformance_script(plain_module, args) + + mock_console.warning.assert_called_once() + warning_message = mock_console.warning.call_args.args[0] + assert "top" in warning_message + assert "conformance tests script" in warning_message + + +def test_no_warning_when_conformance_script_configured(): + plain_module = _make_module("top", has_acceptance_tests=True) + args = Namespace(conformance_tests_script="run_conformance_tests.sh") + + with patch("plain2code.console") as mock_console: + plain2code.warn_if_acceptance_tests_without_conformance_script(plain_module, args) + + mock_console.warning.assert_not_called() + + +def test_no_warning_when_no_acceptance_tests(): + plain_module = _make_module("top", has_acceptance_tests=False) + args = Namespace(conformance_tests_script=None) + + with patch("plain2code.console") as mock_console: + plain2code.warn_if_acceptance_tests_without_conformance_script(plain_module, args) + + mock_console.warning.assert_not_called() + + +def test_warns_when_acceptance_tests_only_in_required_module(): + required_module = _make_module("dependency", has_acceptance_tests=True) + plain_module = _make_module("top", has_acceptance_tests=False, required_modules=[required_module]) + args = Namespace(conformance_tests_script=None) + + with patch("plain2code.console") as mock_console: + plain2code.warn_if_acceptance_tests_without_conformance_script(plain_module, args) + + mock_console.warning.assert_called_once() + warning_message = mock_console.warning.call_args.args[0] + assert "dependency" in warning_message + + +def test_warning_covers_required_modules_for_real_plain_module(get_test_data_path): + """Integration test: a main module without acceptance tests that requires a + module with acceptance tests should still trigger the warning, naming the + required module. This mirrors the dry-run path which builds a real PlainModule.""" + fixtures_dir = get_test_data_path("data/acceptance_tests_warning") + with tempfile.TemporaryDirectory() as build, tempfile.TemporaryDirectory() as conformance: + plain_module = PlainModule( + "main_requiring_acceptance_tests.plain", + build, + conformance, + [fixtures_dir], + ) + + args = Namespace(conformance_tests_script=None) + with patch("plain2code.console") as mock_console: + plain2code.warn_if_acceptance_tests_without_conformance_script(plain_module, args) + + mock_console.warning.assert_called_once() + warning_message = mock_console.warning.call_args.args[0] + assert "required_with_acceptance_tests" in warning_message diff --git a/tests/test_plainfileparser.py b/tests/test_plainfileparser.py index 09f8b5f..c42e20d 100644 --- a/tests/test_plainfileparser.py +++ b/tests/test_plainfileparser.py @@ -376,6 +376,114 @@ def test_acceptance_tests_block_include_with_trailing_newline_keeps_structure_an plain_file.plain_file_parser("block_level_include.plain", [get_test_data_path("data/templates")]) +def test_acceptance_tests_top_level_rejected(): + plain_source = """ +***acceptance tests*** + +- Test something. +""" + with pytest.raises( + PlainSyntaxError, + match=re.escape( + "Syntax error at line 1: acceptance tests heading should be nested under specific functional spec." + ), + ): + plain_file.parse_plain_source(plain_source, {}, [], [], []) + + +def test_acceptance_tests_top_level_after_other_headings_rejected(): + plain_source = """***definitions*** + +- :concept: is a concept. + +***acceptance tests*** + +- Test something. +""" + with pytest.raises( + PlainSyntaxError, + match=re.escape( + "Syntax error at line 5: acceptance tests heading should be nested under specific functional spec." + ), + ): + plain_file.parse_plain_source(plain_source, {}, [], [], []) + + +def test_acceptance_tests_nested_under_definitions_rejected(): + plain_source = """***definitions*** + +- :concept: is a concept. + + ***acceptance tests*** + + - Test the :concept:. + +***functional specs*** + +- Display "hello, world" +""" + with pytest.raises( + PlainSyntaxError, + match=re.escape( + "Syntax error at line 5: acceptance tests heading should be nested under specific functional spec." + ), + ): + plain_file.parse_plain_source(plain_source, {}, [], [], []) + + +def test_acceptance_tests_nested_under_implementation_reqs_rejected(): + plain_source = """***implementation reqs*** + +- First implementation requirement. + + ***acceptance tests*** + + - Test something. +""" + with pytest.raises( + PlainSyntaxError, + match=re.escape( + "Syntax error at line 5: acceptance tests heading should be nested under specific functional spec." + ), + ): + plain_file.parse_plain_source(plain_source, {}, [], [], []) + + +def test_acceptance_tests_nested_under_test_reqs_rejected(): + plain_source = """***test reqs*** + +- First test requirement. + + ***acceptance tests*** + + - Test something. +""" + with pytest.raises( + PlainSyntaxError, + match=re.escape( + "Syntax error at line 5: acceptance tests heading should be nested under specific functional spec." + ), + ): + plain_file.parse_plain_source(plain_source, {}, [], [], []) + + +def test_acceptance_tests_nested_under_functional_spec_allowed(): + plain_source = """***definitions*** + +- :concept: is a concept. + +***functional specs*** + +- Display "hello, world" + + ***acceptance tests*** + + - Test the :concept:. +""" + result = plain_file.parse_plain_source(plain_source, {}, [], [], []) + assert plain_spec.FUNCTIONAL_REQUIREMENTS in result.plain_source + + def test_concept_validation_definitions(get_test_data_path): plain_file.plain_file_parser( "concept_validation_definition.plain", diff --git a/tests/test_plainspec.py b/tests/test_plainspec.py index 83ee4bd..d773870 100644 --- a/tests/test_plainspec.py +++ b/tests/test_plainspec.py @@ -9,6 +9,42 @@ def test_get_linked_resources_invalid_input(): plain_spec.collect_linked_resources([], [], None, True) +def test_has_acceptance_tests_true(): + plain_source = { + plain_spec.FUNCTIONAL_REQUIREMENTS: [ + {"markdown": "- First functionality."}, + { + "markdown": "- Second functionality.", + plain_spec.ACCEPTANCE_TESTS: [{"markdown": "- Test the second functionality."}], + }, + ] + } + assert plain_spec.has_acceptance_tests(plain_source) is True + + +def test_has_acceptance_tests_false_when_no_acceptance_tests(): + plain_source = { + plain_spec.FUNCTIONAL_REQUIREMENTS: [ + {"markdown": "- First functionality."}, + {"markdown": "- Second functionality."}, + ] + } + assert plain_spec.has_acceptance_tests(plain_source) is False + + +def test_has_acceptance_tests_false_when_empty_acceptance_tests(): + plain_source = { + plain_spec.FUNCTIONAL_REQUIREMENTS: [ + {"markdown": "- First functionality.", plain_spec.ACCEPTANCE_TESTS: []}, + ] + } + assert plain_spec.has_acceptance_tests(plain_source) is False + + +def test_has_acceptance_tests_false_when_no_functional_requirements(): + assert plain_spec.has_acceptance_tests({}) is False + + def test_get_frids_simple(get_test_data_path): _, plain_source, _ = plain_file.plain_file_parser("simple.plain", [get_test_data_path("data/")])