Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions bin/generate_tests
Original file line number Diff line number Diff line change
@@ -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()
22 changes: 22 additions & 0 deletions exercises/practice/allergies/.meta/template.j2
Original file line number Diff line number Diff line change
@@ -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 %}
61 changes: 34 additions & 27 deletions exercises/practice/allergies/allergies_test.gd
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ func _sorted(array):
return array


# testing for eggs allergy

func test_eggs_not_allergic_to_anything(allergies):
allergies.score = 0
Expand All @@ -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]
Expand All @@ -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]
Expand All @@ -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]
Expand All @@ -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]
Expand All @@ -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]
Expand All @@ -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]
Expand All @@ -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]
Expand All @@ -221,59 +206,81 @@ 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'])
]


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'])
]
8 changes: 8 additions & 0 deletions exercises/practice/darts/.meta/template.j2
Original file line number Diff line number Diff line change
@@ -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 %}
2 changes: 1 addition & 1 deletion exercises/practice/darts/darts_test.gd
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading