diff --git a/.gitignore b/.gitignore index ba65ac7..e9cd78d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ .DS_Store *.log .#* -bin/* _build/* closet/track-configs.fasl closet/json/* @@ -9,5 +8,6 @@ closet/json/* *.html ci problem-specifications/* -!bin/fetch-configlet -!bin/verify-exercises +bin/configlet + +.idea/ diff --git a/bin/generate_tests.py b/bin/generate_tests.py new file mode 100755 index 0000000..dbbaf9e --- /dev/null +++ b/bin/generate_tests.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python + +"""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 filter_escape_whitespace_and_quote(data: str) -> str: + """Escape whitespace using CARRIAGE_RETURN, TAB and NEWLINE, then quote.""" + data = f'"{data}"' + for char, replacement in [("\n", "NEWLINE"), ("\t", "TAB"), ("\r", "CARRIAGE_RETURN")]: + data = data.replace(char, f'" // {replacement} // "') + while '// "" //' in data: + data = data.replace('// "" //', "//") + return data + + +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) + "]" + ) + # Replace whitespace with chars and concatenation. + env.filters["escape_quote_whitespace"] = filter_escape_whitespace_and_quote + + return env + + +def bool_to_str(obj): + """Recursively convert boolean values to strings in collections.""" + 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 "#t" + if obj is False: + return "#f" + 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 = f"""; These tests are auto-generated with test data from: +; https://github.com/exercism/problem-specifications/blob/main/exercises/{exercise.name}/canonical-data.json +; File last updated on {timestamp}""" + data = { + "cases": cases, + "header": header, + "solution": json.loads((exercise / ".meta/config.json").read_text())["files"]["solution"][0], + "name": 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/bin/update-new-exercise-files.py b/bin/update-new-exercise-files.py new file mode 100755 index 0000000..679a33b --- /dev/null +++ b/bin/update-new-exercise-files.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 + +""" + +See docs/TEST.md for usage of this script + +Use this script to create an initial /test.scm file +which can be used as a skeleton for your test. Typically, you will +have to replace 'response' in the generated file with the right +function call. + +Prerequisites +- Python 3.x (it may work with Python 2.x, but that is untested) +- Up-to-date local version of the https://github.com/exercism/problem-specifications.git repo + +Workflow for creating a new test +- pull latest changes from exercism/problem-specifications +- run this script for the example you want to create +- copy test-util.ss and Makefile for exercise directory +- implement working exercise +- fix potential problematic tests (see eg. exercise/bob "Test 20" and "Test 24") +- open a pull request with your changes + +For bob example: + +$ python3 bin/update-new-exercise-files.py --exercise_path exercises/practice/bob +$ cp config/CMakeLists.txt exercises/practice/bob/. +$ cd exercises/practice/bob +""" + +import argparse +import json +import os +import pathlib +import textwrap + + +class Exercise: + """Exercise holds exercise data.""" + + def __init__(self, exercise_path): + exercise_path = pathlib.Path(exercise_path) + if not exercise_path.exists(): + raise ValueError(f"{exercise_path} does not exist") + if not exercise_path.is_dir(): + raise ValueError(f"{exercise_path} is not a directory") + + slug = exercise_path.name + self.config = json.loads((exercise_path / ".meta" / "config.json").read_text()) + + self.slug = slug + self.name = slug.replace("-", "_") + self.path = exercise_path + + def config_file(self, filetype): + if len(self.config["files"][filetype]) != 1: + raise ValueError(f"Expected exactly one {filetype} file; got {self.config["files"][filetype]!r}") + return self.path / self.config["files"][filetype][0] + + def write_file(self, filepath, content): + if filepath.exists(): + print(f"{filepath} already exists; will not replace.") + else: + filepath.write_text(content.strip() + "\n") + print(f"Wrote to {filepath}") + + def write_stub_file(self, content): + self.write_file(self.config_file("solution"), content) + + def write_example_file(self, content): + self.write_file(self.config_file("example"), content) + + def write_test_template(self, content): + filepath = self.path / ".meta/template.j2" + self.write_file(filepath, content) + + +def stub_implementation(exercise_name: str) -> str: + return textwrap.dedent("""\ + module {exercise_name} + implicit none + contains + + logical function dummy() + dummy=.true. + end function + + end module + """) + + +def template_stub() -> str: + return textwrap.dedent("""\ + {{ header }} + + (load "test-util.ss") + + (define test-cases + `( + {%- for case in cases %} + {%- if not case["expect_error"] %} + (test-success "{{ loop.index }}. {{ case["description"] }}" = {{ case["property"] }} '({{ case["input"]["..."] }}) {{ case["expected"] }}) + {%- else %} + (test-error "{{ case["description"] }}" {{ case["property"] }} '({{ case["input"]["..."] }})) + {%- endif %} + {%- endfor %} + )) + + (run-with-cli "{{ name }}.scm" (list test-cases)) + """) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Create exercise") + parser.add_argument("-e", "--exercise_path", help="The path of the exercise") + + args = parser.parse_args() + exercise = Exercise(args.exercise_path) + + exercise.write_stub_file(stub_implementation(exercise.name)) + exercise.write_example_file(stub_implementation(exercise.name)) + exercise.write_test_template(template_stub()) + + print(f"Use './bin/generate_tests {exercise.slug}' to generate the test file from the Jinja template ({exercise.path / ".meta/template.j2"}).") diff --git a/exercises/practice/armstrong-numbers/.meta/template.j2 b/exercises/practice/armstrong-numbers/.meta/template.j2 new file mode 100644 index 0000000..ad931a9 --- /dev/null +++ b/exercises/practice/armstrong-numbers/.meta/template.j2 @@ -0,0 +1,17 @@ +{{ header }} + +(load "test-util.ss") + +(define test-cases + `( +{%- for case in cases %} +{%- if not case["expect_error"] %} + (test-success "{{ loop.index }}. {{ case["description"] }}" + equal? armstrong-number? '({{ case["input"]["number"] }}) {{ case["expected"] }}) +{%- else %} + (test-error "{{ case["description"] }}" armstrong-number? '({{ case["input"]["number"] }})) +{%- endif %} +{% endfor %} + )) + +(run-with-cli "{{ name }}.scm" (list test-cases)) diff --git a/exercises/practice/armstrong-numbers/.meta/tests.toml b/exercises/practice/armstrong-numbers/.meta/tests.toml index 09094a5..b3f09e4 100644 --- a/exercises/practice/armstrong-numbers/.meta/tests.toml +++ b/exercises/practice/armstrong-numbers/.meta/tests.toml @@ -13,25 +13,31 @@ description = "Zero is an Armstrong number" [579e8f03-9659-4b85-a1a2-d64350f6b17a] -description = "Single digit numbers are Armstrong numbers" +description = "Single-digit numbers are Armstrong numbers" [2d6db9dc-5bf8-4976-a90b-b2c2b9feba60] -description = "There are no 2 digit Armstrong numbers" +description = "There are no two-digit Armstrong numbers" [509c087f-e327-4113-a7d2-26a4e9d18283] -description = "Three digit number that is an Armstrong number" +description = "Three-digit number that is an Armstrong number" [7154547d-c2ce-468d-b214-4cb953b870cf] -description = "Three digit number that is not an Armstrong number" +description = "Three-digit number that is not an Armstrong number" [6bac5b7b-42e9-4ecb-a8b0-4832229aa103] -description = "Four digit number that is an Armstrong number" +description = "Four-digit number that is an Armstrong number" [eed4b331-af80-45b5-a80b-19c9ea444b2e] -description = "Four digit number that is not an Armstrong number" +description = "Four-digit number that is not an Armstrong number" [f971ced7-8d68-4758-aea1-d4194900b864] -description = "Seven digit number that is an Armstrong number" +description = "Seven-digit number that is an Armstrong number" [7ee45d52-5d35-4fbd-b6f1-5c8cd8a67f18] -description = "Seven digit number that is not an Armstrong number" +description = "Seven-digit number that is not an Armstrong number" + +[5ee2fdf8-334e-4a46-bb8d-e5c19c02c148] +description = "Armstrong number containing seven zeroes" + +[12ffbf10-307a-434e-b4ad-c925680e1dd4] +description = "The largest and last Armstrong number" diff --git a/exercises/practice/armstrong-numbers/test.scm b/exercises/practice/armstrong-numbers/test.scm index 277a0d2..96a56fe 100644 --- a/exercises/practice/armstrong-numbers/test.scm +++ b/exercises/practice/armstrong-numbers/test.scm @@ -1,60 +1,44 @@ +; These tests are auto-generated with test data from: +; https://github.com/exercism/problem-specifications/blob/main/exercises/armstrong-numbers/canonical-data.json +; File last updated on 2026-10-03T22:05:14+00:00 + (load "test-util.ss") (define test-cases - `((test-success "Zero is an Armstrong number" equal? - armstrong-number? '(0) #t) - (test-success "Single digit numbers are Armstrong numbers" - equal? armstrong-number? '(5) #t) - (test-success "There are no 2 digit Armstrong numbers" - equal? armstrong-number? '(10) #f) - (test-success - "Three digit number that is an Armstrong number" equal? - armstrong-number? '(153) #t) - (test-success - "Three digit number that is not an Armstrong number" equal? - armstrong-number? '(100) #f) - (test-success - "Four digit number that is an Armstrong number" equal? - armstrong-number? '(9474) #t) - (test-success - "Four digit number that is not an Armstrong number" equal? - armstrong-number? '(9475) #f) - (test-success - "Seven digit number that is an Armstrong number" equal? - armstrong-number? '(9926315) #t) - (test-success - "Seven digit number that is not an Armstrong number" equal? - armstrong-number? '(9926314) #f) - (test-success "The 25th Armstrong number" equal? - armstrong-number? '(24678050) #t) - (test-success - "Eight digit number that is not an Armstrong number" equal? - armstrong-number? '(30852815) #f) - (test-success "The 28th Armstrong number" equal? - armstrong-number? '(146511208) #t) - (test-success - "Nine digit number that is not an Armstrong number" equal? - armstrong-number? '(927427554) #f) - (test-success "The 32nd Armstrong number" equal? - armstrong-number? '(4679307774) #t) - (test-success - "Ten digit number that is not an Armstrong number" equal? - armstrong-number? '(8320172640) #f) - (test-success "The 34th Armstrong number" equal? - armstrong-number? '(32164049651) #t) - (test-success - "Eleven digit number that is not an Armstrong number" equal? - armstrong-number? '(13930642218) #f) - (test-success "The 66th Armstrong number" equal? - armstrong-number? '(4422095118095899619457938) #t) - (test-success "The 77th Armstrong number" equal? - armstrong-number? '(1927890457142960697580636236639) #t) - (test-success "The 88th Armstrong number" equal? - armstrong-number? '(115132219018763992565095597973971522401) - #t) - (test-success "Thirty-nine digit number that is not an Armstrong number" - equal? armstrong-number? - '(7744959048678381442547644364350528967165) #f))) + `( + (test-success "1. Zero is an Armstrong number" + equal? armstrong-number? '(0) #t) -(run-with-cli "armstrong-numbers.scm" (list test-cases)) + (test-success "2. Single-digit numbers are Armstrong numbers" + equal? armstrong-number? '(5) #t) + + (test-success "3. There are no two-digit Armstrong numbers" + equal? armstrong-number? '(10) #f) + + (test-success "4. Three-digit number that is an Armstrong number" + equal? armstrong-number? '(153) #t) + + (test-success "5. Three-digit number that is not an Armstrong number" + equal? armstrong-number? '(100) #f) + + (test-success "6. Four-digit number that is an Armstrong number" + equal? armstrong-number? '(9474) #t) + + (test-success "7. Four-digit number that is not an Armstrong number" + equal? armstrong-number? '(9475) #f) + (test-success "8. Seven-digit number that is an Armstrong number" + equal? armstrong-number? '(9926315) #t) + + (test-success "9. Seven-digit number that is not an Armstrong number" + equal? armstrong-number? '(9926314) #f) + + (test-success "10. Armstrong number containing seven zeroes" + equal? armstrong-number? '(186709961001538790100634132976990) #t) + + (test-success "11. The largest and last Armstrong number" + equal? armstrong-number? '(115132219018763992565095597973971522401) #t) + + )) + +(run-with-cli "armstrong-numbers.scm" (list test-cases)) diff --git a/exercises/practice/collatz-conjecture/.meta/template.j2 b/exercises/practice/collatz-conjecture/.meta/template.j2 new file mode 100644 index 0000000..24cbcf5 --- /dev/null +++ b/exercises/practice/collatz-conjecture/.meta/template.j2 @@ -0,0 +1,16 @@ +{{ header }} + +(load "test-util.ss") + +(define test-cases + `( +{%- for case in cases %} +{%- if not case["expect_error"] %} + (test-success "{{ loop.index }}. {{ case["description"] }}" = collatz '({{ case["input"]["number"] }}) {{ case["expected"] }}) +{%- else %} + (test-error "{{ loop.index }}. {{ case["description"] }}" collatz '({{ case["input"]["number"] }})) +{%- endif %} +{%- endfor %} + )) + +(run-with-cli "collatz-conjecture.scm" (list test-cases)) diff --git a/exercises/practice/collatz-conjecture/.meta/tests.toml b/exercises/practice/collatz-conjecture/.meta/tests.toml index 819b8d9..05e1299 100644 --- a/exercises/practice/collatz-conjecture/.meta/tests.toml +++ b/exercises/practice/collatz-conjecture/.meta/tests.toml @@ -23,6 +23,19 @@ description = "large number of even and odd steps" [7d4750e6-def9-4b86-aec7-9f7eb44f95a3] description = "zero is an error" +include = false + +[2187673d-77d6-4543-975e-66df6c50e2da] +description = "zero is an error" +reimplements = "7d4750e6-def9-4b86-aec7-9f7eb44f95a3" +include = false +comment = "Excluding to protect older solutions." [c6c795bf-a288-45e9-86a1-841359ad426d] description = "negative value is an error" +include = false + +[ec11f479-56bc-47fd-a434-bcd7a31a7a2e] +description = "negative value is an error" +reimplements = "c6c795bf-a288-45e9-86a1-841359ad426d" +include = false diff --git a/exercises/practice/collatz-conjecture/test.scm b/exercises/practice/collatz-conjecture/test.scm index db09df9..5019265 100644 --- a/exercises/practice/collatz-conjecture/test.scm +++ b/exercises/practice/collatz-conjecture/test.scm @@ -1,11 +1,15 @@ +; These tests are auto-generated with test data from: +; https://github.com/exercism/problem-specifications/blob/main/exercises/collatz-conjecture/canonical-data.json +; File last updated on 2026-10-03T19:24:44+00:00 + (load "test-util.ss") (define test-cases - `((test-success "zero steps for one" = collatz '(1) 0) - (test-success "divide if even" = collatz '(16) 4) - (test-success "even and odd steps" = collatz '(12) 9) - (test-success "large number of even and odd steps" = collatz - '(1000000) 152))) + `( + (test-success "1. zero steps for one" = collatz '(1) 0) + (test-success "2. divide if even" = collatz '(16) 4) + (test-success "3. even and odd steps" = collatz '(12) 9) + (test-success "4. large number of even and odd steps" = collatz '(1000000) 152) + )) (run-with-cli "collatz-conjecture.scm" (list test-cases)) -