diff --git a/bin/generate_tests b/bin/generate_tests new file mode 100755 index 00000000..56edc7ca --- /dev/null +++ b/bin/generate_tests @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 + +"""Test generator v1.""" + +import argparse +import datetime +import json +import os +import pathlib +import re +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")) + 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) + # JSON formatting, default to compact form (`jq -c`). + env.filters["tojson"] = filter_tojson + # String escaping, Tcl style: + # first express as a JSON string (this exposes all whitespace characters, and adds quotes) + # then protect Tcl special characters + env.filters["tclstring"] = lambda x: re.sub(r'([\[\$])', r'\\\1', filter_tojson(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() + exercise_config = json.loads((exercise / ".meta/config.json").read_text()) + + header = textwrap.dedent(f"""\ + #!/usr/bin/env tclsh + # generated on {timestamp} + package require tcltest + namespace import ::tcltest::* + source testHelpers.tcl + + # Uncomment next line to view test durations. + #configure -verbose {{body error usec}} + + ############################################################ + source "{exercise_config["files"]["solution"][0]}" + """ + ).strip() + data = { + "cases": list(enumerate(cases)), + "header": header, + "footer": "\ncleanupTests", + "slug": exercise.name, + } + + # 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/bob/.meta/template.j2 b/exercises/practice/bob/.meta/template.j2 new file mode 100644 index 00000000..0f0b5f4e --- /dev/null +++ b/exercises/practice/bob/.meta/template.j2 @@ -0,0 +1,13 @@ +{{ header }} + +{% for idx, case in cases -%} +{% if idx > 0 -%} +skip {{slug}}-{{idx + 1}} +{% endif -%} +test {{slug}}-{{idx + 1}} "{{ case["description"] }}" -body { + heyBob {{ case["input"]["heyBob"] | tclstring }} +} -returnCodes ok -result "{{ case["expected"] }}" + +{% endfor -%} + +{{footer}} diff --git a/exercises/practice/bob/.meta/tests.toml b/exercises/practice/bob/.meta/tests.toml index 5299e289..c6a46496 100644 --- a/exercises/practice/bob/.meta/tests.toml +++ b/exercises/practice/bob/.meta/tests.toml @@ -9,17 +9,20 @@ # As user-added comments (using the # character) will be removed when this file # is regenerated, comments can be added via a `comment` key. -[e162fead-606f-437a-a166-d051915cea8e] -description = "stating something" +[8a2e771d-d6f1-4e3f-b6c6-b41495556e37] +description = "asking a question" [73a966dc-8017-47d6-bb32-cf07d1a5fcd9] description = "shouting" -[d6c98afd-df35-4806-b55e-2c457c3ab748] -description = "shouting gibberish" +[a5193c61-4a92-4f68-93e2-f554eb385ec6] +description = "forceful question" -[8a2e771d-d6f1-4e3f-b6c6-b41495556e37] -description = "asking a question" +[bc39f7c6-f543-41be-9a43-fd1c2f753fc0] +description = "silence" + +[e162fead-606f-437a-a166-d051915cea8e] +description = "stating something" [81080c62-4e4d-4066-b30a-48d8d76920d9] description = "asking a numeric question" @@ -27,23 +30,34 @@ description = "asking a numeric question" [2a02716d-685b-4e2e-a804-2adaf281c01e] description = "asking gibberish" -[c02f9179-ab16-4aa7-a8dc-940145c385f7] -description = "talking forcefully" +[bb0011c5-cd52-4a5b-8bfb-a87b6283b0e2] +description = "question with no letters" -[153c0e25-9bb5-4ec5-966e-598463658bcd] -description = "using acronyms in regular speech" +[9bfc677d-ea3a-45f2-be44-35bc8fa3753e] +description = "non-letters with question" -[a5193c61-4a92-4f68-93e2-f554eb385ec6] -description = "forceful question" +[8608c508-f7de-4b17-985b-811878b3cf45] +description = "prattling on" -[a20e0c54-2224-4dde-8b10-bd2cdd4f61bc] -description = "shouting numbers" +[05b304d6-f83b-46e7-81e0-4cd3ca647900] +description = "ending with whitespace" -[f7bc4b92-bdff-421e-a238-ae97f230ccac] -description = "no letters" +[66953780-165b-4e7e-8ce3-4bcb80b6385a] +description = "multiple line question" +include = false -[bb0011c5-cd52-4a5b-8bfb-a87b6283b0e2] -description = "question with no letters" +[2c7278ac-f955-4eb4-bf8f-e33eb4116a15] +description = "multiple line question" +reimplements = "66953780-165b-4e7e-8ce3-4bcb80b6385a" + +[d6c98afd-df35-4806-b55e-2c457c3ab748] +description = "shouting gibberish" + +[3c954328-86fb-4c71-8961-e18d6a5e2517] +description = "shouting a statement containing a question mark" + +[a20e0c54-2224-4dde-8b10-bd2cdd4f61bc] +description = "shouting numbers" [496143c8-1c31-4c01-8a08-88427af85c66] description = "shouting with special characters" @@ -51,40 +65,29 @@ description = "shouting with special characters" [e6793c1c-43bd-4b8d-bc11-499aea73925f] description = "shouting with no exclamation mark" -[aa8097cc-c548-4951-8856-14a404dd236a] -description = "statement containing question mark" - -[9bfc677d-ea3a-45f2-be44-35bc8fa3753e] -description = "non-letters with question" - -[8608c508-f7de-4b17-985b-811878b3cf45] -description = "prattling on" - -[bc39f7c6-f543-41be-9a43-fd1c2f753fc0] -description = "silence" - [d6c47565-372b-4b09-b1dd-c40552b8378b] description = "prolonged silence" [4428f28d-4100-4d85-a902-e5a78cb0ecd3] description = "alternate silence" -[66953780-165b-4e7e-8ce3-4bcb80b6385a] -description = "multiple line question" -include = false +[72bd5ad3-9b2f-4931-a988-dce1f5771de2] +description = "other whitespace" -[5371ef75-d9ea-4103-bcfa-2da973ddec1b] -description = "starting with whitespace" +[c02f9179-ab16-4aa7-a8dc-940145c385f7] +description = "talking forcefully" -[05b304d6-f83b-46e7-81e0-4cd3ca647900] -description = "ending with whitespace" +[153c0e25-9bb5-4ec5-966e-598463658bcd] +description = "using acronyms in regular speech" -[72bd5ad3-9b2f-4931-a988-dce1f5771de2] -description = "other whitespace" +[f7bc4b92-bdff-421e-a238-ae97f230ccac] +description = "no letters" + +[aa8097cc-c548-4951-8856-14a404dd236a] +description = "statement containing question mark" + +[5371ef75-d9ea-4103-bcfa-2da973ddec1b] +description = "starting with whitespace" [12983553-8601-46a8-92fa-fcaa3bc4a2a0] description = "non-question ending with whitespace" - -[2c7278ac-f955-4eb4-bf8f-e33eb4116a15] -description = "multiple line question" -reimplements = "66953780-165b-4e7e-8ce3-4bcb80b6385a" diff --git a/exercises/practice/bob/bob.test b/exercises/practice/bob/bob.test index c4f1c9f0..adb5855a 100644 --- a/exercises/practice/bob/bob.test +++ b/exercises/practice/bob/bob.test @@ -1,133 +1,143 @@ #!/usr/bin/env tclsh +# generated on 2026-07-15T19:37:55+00:00 package require tcltest namespace import ::tcltest::* source testHelpers.tcl +# Uncomment next line to view test durations. +#configure -verbose {body error usec} + ############################################################ source "bob.tcl" -test bob-1 "stating something" -body { - heyBob "Tom-ay-to, tom-aaaah-to." -} -returnCodes ok -result "Whatever." +test bob-1 "asking a question" -body { + heyBob "Does this cryogenic chamber make me look fat?" +} -returnCodes ok -result "Sure." skip bob-2 -test bob-2 "shouting" -body { +test bob-2 "shouting" -body { heyBob "WATCH OUT!" } -returnCodes ok -result "Whoa, chill out!" skip bob-3 -test bob-3 "shouting gibberish" -body { - heyBob "FCECDFCAAB" -} -returnCodes ok -result "Whoa, chill out!" +test bob-3 "forceful question" -body { + heyBob "WHAT'S GOING ON?" +} -returnCodes ok -result "Calm down, I know what I'm doing!" skip bob-4 -test bob-4 "asking a question" -body { - heyBob "Does this cryogenic chamber make me look fat?" -} -returnCodes ok -result "Sure." +test bob-4 "silence" -body { + heyBob "" +} -returnCodes ok -result "Fine. Be that way!" skip bob-5 -test bob-5 "asking a numeric question" -body { - heyBob "You are, what, like 15?" -} -returnCodes ok -result "Sure." +test bob-5 "stating something" -body { + heyBob "Tom-ay-to, tom-aaaah-to." +} -returnCodes ok -result "Whatever." skip bob-6 -test bob-6 "asking gibberish" -body { - heyBob "fffbbcbeab?" +test bob-6 "asking a numeric question" -body { + heyBob "You are, what, like 15?" } -returnCodes ok -result "Sure." skip bob-7 -test bob-7 "talking forcefully" -body { - heyBob "Hi there!" -} -returnCodes ok -result "Whatever." +test bob-7 "asking gibberish" -body { + heyBob "fffbbcbeab?" +} -returnCodes ok -result "Sure." skip bob-8 -test bob-8 "using acronyms in regular speech" -body { - heyBob "It's OK if you don't want to go work for NASA." -} -returnCodes ok -result "Whatever." +test bob-8 "question with no letters" -body { + heyBob "4?" +} -returnCodes ok -result "Sure." skip bob-9 -test bob-9 "forceful question" -body { - heyBob "WHAT'S GOING ON?" -} -returnCodes ok -result "Calm down, I know what I'm doing!" +test bob-9 "non-letters with question" -body { + heyBob ":) ?" +} -returnCodes ok -result "Sure." skip bob-10 -test bob-10 "shouting numbers" -body { - heyBob "1, 2, 3 GO!" -} -returnCodes ok -result "Whoa, chill out!" +test bob-10 "prattling on" -body { + heyBob "Wait! Hang on. Are you going to be OK?" +} -returnCodes ok -result "Sure." skip bob-11 -test bob-11 "no letters" -body { - heyBob "1, 2, 3" -} -returnCodes ok -result "Whatever." +test bob-11 "ending with whitespace" -body { + heyBob "Okay if like my spacebar quite a bit? " +} -returnCodes ok -result "Sure." skip bob-12 -test bob-12 "question with no letters" -body { - heyBob "4?" +test bob-12 "multiple line question" -body { + heyBob "\nDoes this cryogenic chamber make\n me look fat?" } -returnCodes ok -result "Sure." skip bob-13 -test bob-13 "shouting with special characters" -body { - heyBob "ZOMG THE %^*@#\$(*^ ZOMBIES ARE COMING!!11!!1!" +test bob-13 "shouting gibberish" -body { + heyBob "FCECDFCAAB" } -returnCodes ok -result "Whoa, chill out!" skip bob-14 -test bob-14 "shouting with no exclamation mark" -body { - heyBob "I HATE THE DENTIST" +test bob-14 "shouting a statement containing a question mark" -body { + heyBob "DO LIONS EAT PEOPLE? AHHHHH." } -returnCodes ok -result "Whoa, chill out!" skip bob-15 -test bob-15 "statement containing question mark" -body { - heyBob "Ending with ? means a question." -} -returnCodes ok -result "Whatever." +test bob-15 "shouting numbers" -body { + heyBob "1, 2, 3 GO!" +} -returnCodes ok -result "Whoa, chill out!" skip bob-16 -test bob-16 "non-letters with question" -body { - heyBob ":) ?" -} -returnCodes ok -result "Sure." +test bob-16 "shouting with special characters" -body { + heyBob "ZOMG THE %^*@#\$(*^ ZOMBIES ARE COMING!!11!!1!" +} -returnCodes ok -result "Whoa, chill out!" skip bob-17 -test bob-17 "prattling on" -body { - heyBob "Wait! Hang on. Are you going to be OK?" -} -returnCodes ok -result "Sure." +test bob-17 "shouting with no exclamation mark" -body { + heyBob "I HATE THE DENTIST" +} -returnCodes ok -result "Whoa, chill out!" skip bob-18 -test bob-18 "silence" -body { - heyBob "" +test bob-18 "prolonged silence" -body { + heyBob " " } -returnCodes ok -result "Fine. Be that way!" skip bob-19 -test bob-19 "prolonged silence" -body { - heyBob " " +test bob-19 "alternate silence" -body { + heyBob "\t\t\t\t\t\t\t\t\t\t" } -returnCodes ok -result "Fine. Be that way!" skip bob-20 -test bob-20 "alternate silence" -body { - heyBob "\t\t\t\t\t\t\t\t\t\t" +test bob-20 "other whitespace" -body { + heyBob "\n\r \t" } -returnCodes ok -result "Fine. Be that way!" skip bob-21 -test bob-21 "multiple line question" -body { - heyBob "\nDoes this cryogenic chamber make me look fat?" -} -returnCodes ok -result "Sure." +test bob-21 "talking forcefully" -body { + heyBob "Hi there!" +} -returnCodes ok -result "Whatever." skip bob-22 -test bob-22 "starting with whitespace" -body { - heyBob " hmmmmmmm..." +test bob-22 "using acronyms in regular speech" -body { + heyBob "It's OK if you don't want to go work for NASA." } -returnCodes ok -result "Whatever." skip bob-23 -test bob-23 "ending with whitespace" -body { - heyBob "Okay if like my spacebar quite a bit? " -} -returnCodes ok -result "Sure." +test bob-23 "no letters" -body { + heyBob "1, 2, 3" +} -returnCodes ok -result "Whatever." skip bob-24 -test bob-24 "other whitespace" -body { - heyBob "\n\r \t" -} -returnCodes ok -result "Fine. Be that way!" +test bob-24 "statement containing question mark" -body { + heyBob "Ending with ? means a question." +} -returnCodes ok -result "Whatever." skip bob-25 -test bob-25 "non-question ending with whitespace" -body { +test bob-25 "starting with whitespace" -body { + heyBob " hmmmmmmm..." +} -returnCodes ok -result "Whatever." + +skip bob-26 +test bob-26 "non-question ending with whitespace" -body { heyBob "This is a statement ending with whitespace " } -returnCodes ok -result "Whatever." + cleanupTests