From 4a8ff020e5f9348991c95cac832530118823b16e Mon Sep 17 00:00:00 2001 From: Isaac Good Date: Fri, 25 Sep 2026 00:22:32 -0700 Subject: [PATCH 1/3] Add the Python/JinJa test generator used by awk/bash/jq/fortran Longer term we likely want a GDScript based generator but to get things booted, reuse what we have. --- bin/generate_tests | 195 ++++++++++++++++++ .../practice/allergies/.meta/template.j2 | 22 ++ .../practice/allergies/allergies_test.gd | 62 +++--- 3 files changed, 252 insertions(+), 27 deletions(-) create mode 100755 bin/generate_tests create mode 100644 exercises/practice/allergies/.meta/template.j2 diff --git a/bin/generate_tests b/bin/generate_tests new file mode 100755 index 0000000..b3059fa --- /dev/null +++ b/bin/generate_tests @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 + +"""Test generator v1.""" + +import argparse +import datetime +import json +import os +import pathlib +import re +import shlex +import subprocess +import sys +import textwrap +import tomllib + +import jinja2 + + +def problem_spec_dir() -> pathlib.Path: + """Detect and return the problem specs.""" + cache_dir = os.getenv("XDG_CACHE_HOME", os.getenv("HOME") + "/.cache") + specs = pathlib.Path(cache_dir) / "exercism/configlet/problem-specifications" + if specs.exists(): + return specs + cur = pathlib.Path(os.getcwd()) + for i in cur.parents: + if i.name == "problem-specifications": + return i + raise LookupError("Could not find problem specs") + + +def flatten_cases(cases: list[dict]) -> list[tuple[list[str], dict]]: + """Recursive flatten test cases, returning individual cases with parent descriptions.""" + for case_or_group in cases: + if "cases" in case_or_group: + for groups, child_case in flatten_cases(case_or_group["cases"]): + yield ([case_or_group["description"]] + groups, child_case) + else: + yield ([], case_or_group) + + +def get_cases(specs: pathlib.Path, exercise: pathlib.Path) -> list[dict]: + """Return flattened, filtered cases with additional metadata attached.""" + canonical_path = specs / "exercises" / exercise.name / "canonical-data.json" + with open(canonical_path, "r", encoding="utf-8") as f: + canonical = json.load(f) + with open(exercise / ".meta" / "tests.toml", "rb") as f: + tests = tomllib.load(f) + + reimplemented = { + test["reimplements"] + for test in tests.values() + if test.get("include", True) and "reimplements" in test + } + cases = [] + for groups, case in flatten_cases(canonical["cases"]): + # Filter out test cases with include=false or not listed. + if case["uuid"] not in tests or case["uuid"] in reimplemented: + continue + if not tests[case["uuid"]].get("include", True): + continue + # Add metadata. + case["descriptions"] = groups + [case["description"]] + case["expect_error"] = isinstance(case["expected"], dict) and "error" in case["expected"] + if case["expect_error"]: + case["expect_error_msg"] = case["expected"]["error"] + cases.append(case) + return cases + + +def filter_tojson(data, separators=(',', ':'), indent=None) -> str: + """Filter `tojson` that JSON encodes a string with flexible settings.""" + return json.dumps(data, separators=separators, indent=indent) + + +def jinja_env(exercise: pathlib.Path) -> jinja2.Environment: + """Return a configured Jinja env with filters added.""" + env = jinja2.Environment(loader=jinja2.FileSystemLoader(exercise / ".meta")) + # Shell quoting + env.filters["quote"] = shlex.quote + # JSON formatting, default to compact form (`jq -c`). + env.filters["tojson"] = filter_tojson + # String escaping, ANSI-C style. + env.filters["repr"] = repr + # Return a dict with only specified keys kepts. + env.filters["camel_to_snake"] = lambda x: re.sub(r"([a-z])([A-Z])", (lambda m: f"{m.group(1)}_{m.group(2).lower()}"), x) + env.filters["format_list"] = lambda x: shlex.quote( + "[" + ",".join(f'"{i}"' if isinstance(i, str) else str(i) for i in x) + "]" + ) + return env + + +def bool_to_str(obj): + """Convert boolean values to strings.""" + if isinstance(obj, dict): + return {key: bool_to_str(val) for key, val in obj.items()} + if isinstance(obj, list): + return [bool_to_str(val) for val in obj] + if obj is True: + return "true" + if obj is False: + return "false" + return obj + + +def generate(specs: pathlib.Path, exercise: pathlib.Path) -> None: + """Generate and write test file for a given spec and exercise.""" + cases = get_cases(specs, exercise) + for case in cases: + case["expected"] = bool_to_str(case["expected"]) + + timestamp = datetime.datetime.now(tz=datetime.UTC).replace(microsecond=0).isoformat() + header = textwrap.dedent(f"""\ + #!/usr/bin/env bats + load bats-extra + + # generated on {timestamp} + """ + ).strip() + data = { + "cases": cases, + "header": header, + "solution": json.loads((exercise / ".meta/config.json").read_text())["files"]["solution"][0] + } + + # Render the template. + try: + template = jinja_env(exercise).get_template("template.j2") + out = template.render(data) + except jinja2.exceptions.TemplateAssertionError as e: + e.add_note(f"Error rendering template for {exercise.name}") + raise + + # Check for changes or the lack thereof. + test_file = exercise / json.loads((exercise / ".meta/config.json").read_text())["files"]["test"][0] + if test_file.exists(): + old_content = [i for i in test_file.read_text().strip().splitlines() if "generated on" not in i] + new_content = [i for i in out.splitlines() if "generated on" not in i] + if old_content == new_content: + return + + # Write the test file. + test_file.write_text(out + "\n") + + +def argparser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser() + parser.add_argument( + "--no-pull", + action="store_false", + dest="pull", + help="Do not run `git pull` on the problem specs repo", + ) + parser.add_argument( + "exercises", + nargs="*", + help="exercises to generate tests; if none supplied, generate all" + ) + return parser + + +def main(): + """Main entrypoint.""" + specs = problem_spec_dir() + args = argparser().parse_args() + if args.pull: + subprocess.check_call(["git", "pull"], cwd=specs) + exercises = args.exercises + # Generate all exercises with templates if none are specified as args. + if not exercises: + exercises = [ + i.parent.parent + for i in pathlib.Path("exercises/practice").glob("*/.meta/template.j2") + ] + else: + # Turn strings to paths and make them relative to the practice exercises. + out = [] + practice = pathlib.Path("exercises/practice") + for exercise in exercises: + path = pathlib.Path(exercise) + if not path.is_relative_to(practice): + path = practice / path + out.append(path) + exercises = out + + for exercise in exercises: + exercise_path = pathlib.Path(exercise) + if not exercise_path.exists(): + raise ValueError(f"Exercise {exercise_path} does not exist") + generate(specs, exercise_path) + + +if __name__ == "__main__": + main() diff --git a/exercises/practice/allergies/.meta/template.j2 b/exercises/practice/allergies/.meta/template.j2 new file mode 100644 index 0000000..c8bf140 --- /dev/null +++ b/exercises/practice/allergies/.meta/template.j2 @@ -0,0 +1,22 @@ +func _sorted(array): + # Array.sort() sorts an array in place and returns null. + # This helper method allows us to sort and compare in the same line. + array.sort() + return array + +{% for case in cases %} +{%- if case["property"] == "allergicTo" %} + +func test_{{ case["input"]["item"] }}_{{ case["description"] | replace(" ", "_") | replace(",", "") }}(allergies): + allergies.score = {{ case["input"]["score"] }} + return [allergies.allergic_to("{{ case["input"]["item"] }}"), {{ case["expected"] }}] +{%- else %} + +func test_list_when_{{ case["description"] | replace(" ", "_") }}(allergies): + allergies.score = {{ case["input"]["score"] }} + return [ + _sorted(allergies.lst), + _sorted({{ case["expected"] }}) + ] +{%- endif %} +{% endfor %} diff --git a/exercises/practice/allergies/allergies_test.gd b/exercises/practice/allergies/allergies_test.gd index c8378da..9fe3033 100644 --- a/exercises/practice/allergies/allergies_test.gd +++ b/exercises/practice/allergies/allergies_test.gd @@ -5,7 +5,6 @@ func _sorted(array): return array -# testing for eggs allergy func test_eggs_not_allergic_to_anything(allergies): allergies.score = 0 @@ -32,8 +31,6 @@ func test_eggs_allergic_to_everything(allergies): return [allergies.allergic_to("eggs"), true] -# testing for peanuts allergy - func test_peanuts_not_allergic_to_anything(allergies): allergies.score = 0 return [allergies.allergic_to("peanuts"), false] @@ -59,8 +56,6 @@ func test_peanuts_allergic_to_everything(allergies): return [allergies.allergic_to("peanuts"), true] -# testing for shellfish allergy - func test_shellfish_not_allergic_to_anything(allergies): allergies.score = 0 return [allergies.allergic_to("shellfish"), false] @@ -86,8 +81,6 @@ func test_shellfish_allergic_to_everything(allergies): return [allergies.allergic_to("shellfish"), true] -# testing for strawberries allergy - func test_strawberries_not_allergic_to_anything(allergies): allergies.score = 0 return [allergies.allergic_to("strawberries"), false] @@ -113,8 +106,6 @@ func test_strawberries_allergic_to_everything(allergies): return [allergies.allergic_to("strawberries"), true] -# testing for tomatoes allergy - func test_tomatoes_not_allergic_to_anything(allergies): allergies.score = 0 return [allergies.allergic_to("tomatoes"), false] @@ -140,8 +131,6 @@ func test_tomatoes_allergic_to_everything(allergies): return [allergies.allergic_to("tomatoes"), true] -# testing for chocolate allergy - func test_chocolate_not_allergic_to_anything(allergies): allergies.score = 0 return [allergies.allergic_to("chocolate"), false] @@ -167,8 +156,6 @@ func test_chocolate_allergic_to_everything(allergies): return [allergies.allergic_to("chocolate"), true] -# testing for pollen allergy - func test_pollen_not_allergic_to_anything(allergies): allergies.score = 0 return [allergies.allergic_to("pollen"), false] @@ -194,8 +181,6 @@ func test_pollen_allergic_to_everything(allergies): return [allergies.allergic_to("pollen"), true] -# testing for cats allergy - func test_cats_not_allergic_to_anything(allergies): allergies.score = 0 return [allergies.allergic_to("cats"), false] @@ -221,48 +206,67 @@ func test_cats_allergic_to_everything(allergies): return [allergies.allergic_to("cats"), true] -# list when: - func test_list_when_no_allergies(allergies): allergies.score = 0 - return [allergies.lst, []] + return [ + _sorted(allergies.lst), + _sorted([]) + ] func test_list_when_just_eggs(allergies): allergies.score = 1 - return [allergies.lst, ["eggs"]] + return [ + _sorted(allergies.lst), + _sorted(['eggs']) + ] func test_list_when_just_peanuts(allergies): allergies.score = 2 - return [allergies.lst, ["peanuts"]] + return [ + _sorted(allergies.lst), + _sorted(['peanuts']) + ] func test_list_when_just_strawberries(allergies): allergies.score = 8 - return [allergies.lst, ["strawberries"]] + return [ + _sorted(allergies.lst), + _sorted(['strawberries']) + ] func test_list_when_eggs_and_peanuts(allergies): allergies.score = 3 - return [_sorted(allergies.lst), ["eggs", "peanuts"]] + return [ + _sorted(allergies.lst), + _sorted(['eggs', 'peanuts']) + ] func test_list_when_more_than_eggs_but_not_peanuts(allergies): allergies.score = 5 - return [_sorted(allergies.lst), ["eggs", "shellfish"]] + return [ + _sorted(allergies.lst), + _sorted(['eggs', 'shellfish']) + ] func test_list_when_lots_of_stuff(allergies): allergies.score = 248 - return [_sorted(allergies.lst), ["cats", "chocolate", "pollen", "strawberries", "tomatoes"]] + return [ + _sorted(allergies.lst), + _sorted(['strawberries', 'tomatoes', 'chocolate', 'pollen', 'cats']) + ] func test_list_when_everything(allergies): allergies.score = 255 return [ _sorted(allergies.lst), - ["cats", "chocolate", "eggs", "peanuts", "pollen", "shellfish", "strawberries", "tomatoes"] + _sorted(['eggs', 'peanuts', 'shellfish', 'strawberries', 'tomatoes', 'chocolate', 'pollen', 'cats']) ] @@ -270,10 +274,14 @@ func test_list_when_no_allergen_score_parts(allergies): allergies.score = 509 return [ _sorted(allergies.lst), - ["cats", "chocolate", "eggs", "pollen", "shellfish", "strawberries", "tomatoes"] + _sorted(['eggs', 'shellfish', 'strawberries', 'tomatoes', 'chocolate', 'pollen', 'cats']) ] func test_list_when_no_allergen_score_parts_without_highest_valid_score(allergies): allergies.score = 257 - return [allergies.lst, ["eggs"]] + return [ + _sorted(allergies.lst), + _sorted(['eggs']) + ] + From b6299b3c791491e5d18710b9e990e3a9808caf82 Mon Sep 17 00:00:00 2001 From: Isaac Good Date: Fri, 25 Sep 2026 08:46:36 -0700 Subject: [PATCH 2/3] Remove the header. Add a `func_name` case entry. --- bin/generate_tests | 19 +++++-------------- .../practice/allergies/.meta/template.j2 | 4 ++-- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/bin/generate_tests b/bin/generate_tests index b3059fa..d770d53 100755 --- a/bin/generate_tests +++ b/bin/generate_tests @@ -3,15 +3,14 @@ """Test generator v1.""" import argparse -import datetime import json import os import pathlib import re import shlex +import string import subprocess import sys -import textwrap import tomllib import jinja2 @@ -107,20 +106,13 @@ def bool_to_str(obj): def generate(specs: pathlib.Path, exercise: pathlib.Path) -> None: """Generate and write test file for a given spec and exercise.""" cases = get_cases(specs, exercise) + translation = str.maketrans({i: "_" for i in string.punctuation + " "}) for case in cases: case["expected"] = bool_to_str(case["expected"]) + case["func_name"] = re.sub("_+", "_", case["description"].lower().translate(translation)) - timestamp = datetime.datetime.now(tz=datetime.UTC).replace(microsecond=0).isoformat() - header = textwrap.dedent(f"""\ - #!/usr/bin/env bats - load bats-extra - - # generated on {timestamp} - """ - ).strip() data = { "cases": cases, - "header": header, "solution": json.loads((exercise / ".meta/config.json").read_text())["files"]["solution"][0] } @@ -135,9 +127,8 @@ def generate(specs: pathlib.Path, exercise: pathlib.Path) -> None: # Check for changes or the lack thereof. test_file = exercise / json.loads((exercise / ".meta/config.json").read_text())["files"]["test"][0] if test_file.exists(): - old_content = [i for i in test_file.read_text().strip().splitlines() if "generated on" not in i] - new_content = [i for i in out.splitlines() if "generated on" not in i] - if old_content == new_content: + old_content = test_file.read_text() + if old_content.strip() == out.strip(): return # Write the test file. diff --git a/exercises/practice/allergies/.meta/template.j2 b/exercises/practice/allergies/.meta/template.j2 index c8bf140..95b5d26 100644 --- a/exercises/practice/allergies/.meta/template.j2 +++ b/exercises/practice/allergies/.meta/template.j2 @@ -7,12 +7,12 @@ func _sorted(array): {% for case in cases %} {%- if case["property"] == "allergicTo" %} -func test_{{ case["input"]["item"] }}_{{ case["description"] | replace(" ", "_") | replace(",", "") }}(allergies): +func test_{{ case["input"]["item"] }}_{{ case["func_name"] }}(allergies): allergies.score = {{ case["input"]["score"] }} return [allergies.allergic_to("{{ case["input"]["item"] }}"), {{ case["expected"] }}] {%- else %} -func test_list_when_{{ case["description"] | replace(" ", "_") }}(allergies): +func test_list_when_{{ case["func_name"] }}(allergies): allergies.score = {{ case["input"]["score"] }} return [ _sorted(allergies.lst), From 7c7b081cc4612b7752823cc586262c4885e6e104 Mon Sep 17 00:00:00 2001 From: Isaac Good Date: Fri, 25 Sep 2026 09:15:23 -0700 Subject: [PATCH 3/3] Add a template for `darts` --- bin/generate_tests | 4 ++-- exercises/practice/allergies/allergies_test.gd | 1 - exercises/practice/darts/.meta/template.j2 | 8 ++++++++ exercises/practice/darts/darts_test.gd | 2 +- 4 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 exercises/practice/darts/.meta/template.j2 diff --git a/bin/generate_tests b/bin/generate_tests index d770d53..59f96a5 100755 --- a/bin/generate_tests +++ b/bin/generate_tests @@ -119,7 +119,7 @@ def generate(specs: pathlib.Path, exercise: pathlib.Path) -> None: # Render the template. try: template = jinja_env(exercise).get_template("template.j2") - out = template.render(data) + out = template.render(data).strip() except jinja2.exceptions.TemplateAssertionError as e: e.add_note(f"Error rendering template for {exercise.name}") raise @@ -128,7 +128,7 @@ def generate(specs: pathlib.Path, exercise: pathlib.Path) -> None: test_file = exercise / json.loads((exercise / ".meta/config.json").read_text())["files"]["test"][0] if test_file.exists(): old_content = test_file.read_text() - if old_content.strip() == out.strip(): + if old_content.strip() == out: return # Write the test file. diff --git a/exercises/practice/allergies/allergies_test.gd b/exercises/practice/allergies/allergies_test.gd index 9fe3033..46cd5ab 100644 --- a/exercises/practice/allergies/allergies_test.gd +++ b/exercises/practice/allergies/allergies_test.gd @@ -284,4 +284,3 @@ func test_list_when_no_allergen_score_parts_without_highest_valid_score(allergie _sorted(allergies.lst), _sorted(['eggs']) ] - diff --git a/exercises/practice/darts/.meta/template.j2 b/exercises/practice/darts/.meta/template.j2 new file mode 100644 index 0000000..2ccce3d --- /dev/null +++ b/exercises/practice/darts/.meta/template.j2 @@ -0,0 +1,8 @@ +{% for case in cases %} +func test_{{ case["func_name"] }}(solution_script): + var x = {{ case["input"]["x"] }} + var y = {{ case["input"]["y"] }} + var expected = {{ case["expected"] }} + return [solution_script.score(x, y), expected] + +{% endfor %} diff --git a/exercises/practice/darts/darts_test.gd b/exercises/practice/darts/darts_test.gd index 737378d..8a15946 100644 --- a/exercises/practice/darts/darts_test.gd +++ b/exercises/practice/darts/darts_test.gd @@ -82,7 +82,7 @@ func test_just_outside_the_outer_circle(solution_script): return [solution_script.score(x, y), expected] -func test_asymmetric_position(solution_script): +func test_asymmetric_position_between_the_inner_and_middle_circles(solution_script): var x = 0.5 var y = -4 var expected = 5