diff --git a/bin/generate_tests b/bin/generate_tests new file mode 100755 index 0000000..59f96a5 --- /dev/null +++ b/bin/generate_tests @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 + +"""Test generator v1.""" + +import argparse +import json +import os +import pathlib +import re +import shlex +import string +import subprocess +import sys +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) + 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)) + + data = { + "cases": cases, + "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).strip() + 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 = test_file.read_text() + if old_content.strip() == out: + 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..95b5d26 --- /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["func_name"] }}(allergies): + allergies.score = {{ case["input"]["score"] }} + return [allergies.allergic_to("{{ case["input"]["item"] }}"), {{ case["expected"] }}] +{%- else %} + +func test_list_when_{{ case["func_name"] }}(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..46cd5ab 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,13 @@ 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']) + ] 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