diff --git a/tested/dsl/ast_translator.py b/tested/dsl/ast_translator.py index 60e8dbbb..bd334493 100644 --- a/tested/dsl/ast_translator.py +++ b/tested/dsl/ast_translator.py @@ -27,6 +27,9 @@ """ import ast +import collections +import io +import tokenize from decimal import Decimal from typing import Literal, cast, overload @@ -333,6 +336,25 @@ def _translate_to_ast(node: ast.Interactive, is_return: bool) -> Statement: return _convert_statement(statement_or_expression) +def extract_comment(code: str) -> str: + """ + Extract the comment from the code. + + :param code: The code to extract the comment from. + :return: The comment if it exists, otherwise an empty string. + """ + comment = "" + tokens = tokenize.generate_tokens(io.StringIO(code).readline) + # The "maxlen" is 3 because, the tokenizer will always generate a NEWLINE + # and ENDMARKER token at the end. So a comment comes just before those 2 tokens. + candidates = collections.deque(tokens, maxlen=3) + if len(candidates): + candidate = candidates.popleft() + if candidate.type == tokenize.COMMENT: + comment = candidate.string.lstrip("#").strip() + return comment + + @overload def parse_string(code: str, is_return: Literal[True]) -> Value: ... diff --git a/tested/dsl/translate_parser.py b/tested/dsl/translate_parser.py index 9a56cca0..0aa48574 100644 --- a/tested/dsl/translate_parser.py +++ b/tested/dsl/translate_parser.py @@ -32,7 +32,7 @@ resolve_to_basic, ) from tested.dodona import ExtendedMessage -from tested.dsl.ast_translator import InvalidDslError, parse_string +from tested.dsl.ast_translator import InvalidDslError, extract_comment, parse_string from tested.parsing import get_converter, suite_to_json from tested.serialisation import ( BooleanType, @@ -567,6 +567,7 @@ def _convert_testcase(testcase: YamlDict, context: DslContext) -> Testcase: if "statement" in testcase and "return" in testcase: testcase["expression"] = testcase.pop("statement") + line_comment = "" _validate_testcase_combinations(testcase) if (expr_stmt := testcase.get("statement", testcase.get("expression"))) is not None: if isinstance(expr_stmt, dict) or context.language != "tested": @@ -583,6 +584,8 @@ def _convert_testcase(testcase: YamlDict, context: DslContext) -> Testcase: the_input = LanguageLiterals(literals=the_dict, type=the_type) else: assert isinstance(expr_stmt, str) + if testcase.get("description") is None: + line_comment = extract_comment(expr_stmt) the_input = parse_string(expr_stmt) return_channel = IgnoredChannel.IGNORED if "statement" in testcase else None else: @@ -653,6 +656,7 @@ def _convert_testcase(testcase: YamlDict, context: DslContext) -> Testcase: input=the_input, output=output, link_files=context.files, + line_comment=line_comment, ) diff --git a/tested/languages/bash/config.py b/tested/languages/bash/config.py index 787ea82e..22a73ef1 100644 --- a/tested/languages/bash/config.py +++ b/tested/languages/bash/config.py @@ -38,6 +38,9 @@ def datatype_support(self) -> dict[AllTypes, TypeSupport]: def file_extension(self) -> str: return "sh" + def comment(self, text: str) -> str: + return f"# {text}" + def initial_dependencies(self) -> list[str]: return [] diff --git a/tested/languages/c/config.py b/tested/languages/c/config.py index cb7c8e6a..18f4499b 100644 --- a/tested/languages/c/config.py +++ b/tested/languages/c/config.py @@ -38,6 +38,9 @@ def needs_selector(self): def file_extension(self) -> str: return "c" + def comment(self, text: str) -> str: + return f"// {text}" + def naming_conventions(self) -> dict[Conventionable, NamingConventions]: return {"global_identifier": "macro_case"} diff --git a/tested/languages/csharp/config.py b/tested/languages/csharp/config.py index 1f97335c..67d7342a 100644 --- a/tested/languages/csharp/config.py +++ b/tested/languages/csharp/config.py @@ -41,6 +41,9 @@ def needs_selector(self): def file_extension(self) -> str: return "cs" + def comment(self, text: str) -> str: + return f"// {text}" + def naming_conventions(self) -> dict[Conventionable, NamingConventions]: return { "namespace": "pascal_case", diff --git a/tested/languages/generation.py b/tested/languages/generation.py index 1f1c074d..7c7d883b 100644 --- a/tested/languages/generation.py +++ b/tested/languages/generation.py @@ -146,11 +146,17 @@ def get_readable_input( format_ = bundle.config.programming_language text = generate_statement(bundle, case.input) text = bundle.language.cleanup_description(text) + + if case.line_comment: + text = f"{text} {bundle.language.comment(case.line_comment)}" else: assert isinstance(case.input, LanguageLiterals) text = case.input.get_for(bundle.config.programming_language) format_ = bundle.config.programming_language + if case.line_comment: + text = f"{text} {bundle.language.comment(case.line_comment)}" + # If there are no files, return now. This means we don't need to do ugly stuff. if not case.link_files: return ExtendedMessage(description=text, format=format_), set() diff --git a/tested/languages/haskell/config.py b/tested/languages/haskell/config.py index 7b45168d..5565526f 100644 --- a/tested/languages/haskell/config.py +++ b/tested/languages/haskell/config.py @@ -37,6 +37,9 @@ def needs_selector(self): def file_extension(self) -> str: return "hs" + def comment(self, text: str) -> str: + return f"-- {text}" + def naming_conventions(self) -> dict[Conventionable, NamingConventions]: return { "namespace": "pascal_case", diff --git a/tested/languages/java/config.py b/tested/languages/java/config.py index 72c08538..4e4c6dbe 100644 --- a/tested/languages/java/config.py +++ b/tested/languages/java/config.py @@ -42,6 +42,9 @@ def needs_selector(self): def file_extension(self) -> str: return "java" + def comment(self, text: str) -> str: + return f"// {text}" + def naming_conventions(self) -> dict[Conventionable, NamingConventions]: return { "namespace": "pascal_case", diff --git a/tested/languages/javascript/config.py b/tested/languages/javascript/config.py index c9eb9a3b..90b32541 100644 --- a/tested/languages/javascript/config.py +++ b/tested/languages/javascript/config.py @@ -43,6 +43,9 @@ def needs_selector(self): def file_extension(self) -> str: return "js" + def comment(self, text: str) -> str: + return f"// {text}" + def naming_conventions(self) -> dict[Conventionable, NamingConventions]: return { "namespace": "camel_case", diff --git a/tested/languages/kotlin/config.py b/tested/languages/kotlin/config.py index 4610fd3e..2a702797 100644 --- a/tested/languages/kotlin/config.py +++ b/tested/languages/kotlin/config.py @@ -50,6 +50,9 @@ def needs_selector(self): def file_extension(self) -> str: return "kt" + def comment(self, text: str) -> str: + return f"// {text}" + def naming_conventions(self) -> dict[Conventionable, NamingConventions]: return { "namespace": "pascal_case", diff --git a/tested/languages/language.py b/tested/languages/language.py index a6432f82..29c32014 100644 --- a/tested/languages/language.py +++ b/tested/languages/language.py @@ -191,6 +191,16 @@ def file_extension(self) -> str: """ raise NotImplementedError + @abstractmethod + def comment(self, text: str) -> str: + """ + Generate a comment for the given text. + + :param text: The text to comment. + :return: The comment. + """ + raise NotImplementedError + def is_source_file(self, file: Path) -> bool: """ Check if the given file could be a source file for this programming language. diff --git a/tested/languages/python/config.py b/tested/languages/python/config.py index cb495054..61665f01 100644 --- a/tested/languages/python/config.py +++ b/tested/languages/python/config.py @@ -51,6 +51,9 @@ def supports_debug_information(self) -> bool: def file_extension(self) -> str: return "py" + def comment(self, text: str) -> str: + return f"# {text}" + def naming_conventions(self) -> dict[Conventionable, NamingConventions]: return {"class": "pascal_case", "global_identifier": "macro_case"} diff --git a/tested/languages/typescript/config.py b/tested/languages/typescript/config.py index 18ebca24..5c7c61b1 100644 --- a/tested/languages/typescript/config.py +++ b/tested/languages/typescript/config.py @@ -46,6 +46,9 @@ def needs_selector(self) -> bool: def file_extension(self) -> str: return "ts" + def comment(self, text: str) -> str: + return f"// {text}" + def naming_conventions(self) -> dict[Conventionable, NamingConventions]: return { "namespace": "camel_case", diff --git a/tested/testsuite.py b/tested/testsuite.py index a617fcb9..253044dd 100644 --- a/tested/testsuite.py +++ b/tested/testsuite.py @@ -557,6 +557,7 @@ class Testcase(WithFeatures, WithFunctions): description: Message | None = None output: Output = field(factory=Output) link_files: list[FileUrl] = field(factory=list) + line_comment: str = "" def get_used_features(self) -> FeatureSet: return combine_features( diff --git a/tests/exercises/global/evaluation/comment_description_plan.yaml b/tests/exercises/global/evaluation/comment_description_plan.yaml new file mode 100644 index 00000000..244ebf45 --- /dev/null +++ b/tests/exercises/global/evaluation/comment_description_plan.yaml @@ -0,0 +1,7 @@ +- tab: "Global variable" + testcases: + - expression: "GLOBAL_VAR # The name of the global variable" + return: "GLOBAL" + description: + description: "Hallo # This is a greeting" + format: "code" diff --git a/tests/exercises/global/evaluation/plan.yaml b/tests/exercises/global/evaluation/plan.yaml index 5fd954bc..6f49cf6c 100644 --- a/tests/exercises/global/evaluation/plan.yaml +++ b/tests/exercises/global/evaluation/plan.yaml @@ -1,6 +1,6 @@ - tab: "Global variable" testcases: - - expression: "GLOBAL_VAR" + - expression: "GLOBAL_VAR # The name of the global variable" return: "GLOBAL" description: description: "Hallo" diff --git a/tests/exercises/global/evaluation/plan_no_description.yaml b/tests/exercises/global/evaluation/plan_no_description.yaml new file mode 100644 index 00000000..5ed14985 --- /dev/null +++ b/tests/exercises/global/evaluation/plan_no_description.yaml @@ -0,0 +1,4 @@ +- tab: "Global variable" + testcases: + - expression: "GLOBAL_VAR # The name of the global variable" + return: "GLOBAL" diff --git a/tests/test_functionality.py b/tests/test_functionality.py index 29b0beb5..7372053e 100644 --- a/tests/test_functionality.py +++ b/tests/test_functionality.py @@ -59,6 +59,73 @@ def test_global_variable_yaml( assert updates.find_status_enum() == ["correct"] +@pytest.mark.parametrize( + "language, comment_start", + [ + ("bash", "#"), + ("python", "#"), + ("kotlin", "//"), + ("csharp", "//"), + ("java", "//"), + ("c", "//"), + ("javascript", "//"), + ("haskell", "--"), + ], +) +def test_global_comment( + language: str, comment_start: str, tmp_path: Path, pytestconfig: pytest.Config +): + conf = configuration( + pytestconfig, + "global", + language, + tmp_path, + "plan_no_description.yaml", + "correct", + ) + result = execute_config(conf) + updates = assert_valid_output(result, pytestconfig) + description = updates.find_next("start-testcase") + + assert "description" in description and "description" in description["description"] + assert description["description"]["description"].endswith( + f"{comment_start} The name of the global variable" + ) + + +@pytest.mark.parametrize("language", ALL_LANGUAGES) +def test_global_no_comment(language: str, tmp_path: Path, pytestconfig: pytest.Config): + conf = configuration( + pytestconfig, "global", language, tmp_path, "plan.yaml", "correct" + ) + result = execute_config(conf) + updates = assert_valid_output(result, pytestconfig) + description = updates.find_next("start-testcase") + + assert "description" in description and "description" in description["description"] + assert description["description"]["description"] == "Hallo" + + +@pytest.mark.parametrize("language", ALL_LANGUAGES) +def test_global_comment_description( + language: str, tmp_path: Path, pytestconfig: pytest.Config +): + conf = configuration( + pytestconfig, + "global", + language, + tmp_path, + "comment_description_plan.yaml", + "correct", + ) + result = execute_config(conf) + updates = assert_valid_output(result, pytestconfig) + description = updates.find_next("start-testcase") + + assert "description" in description and "description" in description["description"] + assert description["description"]["description"] == "Hallo # This is a greeting" + + @pytest.mark.parametrize("lang", EXCEPTION_LANGUAGES) def test_generic_exception_wrong( lang: str, tmp_path: Path, pytestconfig: pytest.Config