From 2764a5e6943625145bce17c03f246987c2712074 Mon Sep 17 00:00:00 2001 From: Isaac Good Date: Mon, 5 Oct 2026 18:06:53 -0700 Subject: [PATCH] [rational-numbers] Add new practice exercise --- config.json | 8 + .../rational-numbers/.docs/instructions.md | 42 ++ .../rational-numbers/.meta/config.json | 21 + .../rational-numbers/.meta/example.gd | 50 ++ .../rational-numbers/.meta/template.j2 | 38 ++ .../rational-numbers/.meta/tests.toml | 139 ++++++ .../rational-numbers/lib/test_runner.gd | 467 ++++++++++++++++++ .../rational-numbers/rational_numbers.gd | 30 ++ .../rational-numbers/rational_numbers_test.gd | 335 +++++++++++++ exercises/practice/rational-numbers/run_tests | 6 + 10 files changed, 1136 insertions(+) create mode 100644 exercises/practice/rational-numbers/.docs/instructions.md create mode 100644 exercises/practice/rational-numbers/.meta/config.json create mode 100644 exercises/practice/rational-numbers/.meta/example.gd create mode 100644 exercises/practice/rational-numbers/.meta/template.j2 create mode 100644 exercises/practice/rational-numbers/.meta/tests.toml create mode 100644 exercises/practice/rational-numbers/lib/test_runner.gd create mode 100644 exercises/practice/rational-numbers/rational_numbers.gd create mode 100644 exercises/practice/rational-numbers/rational_numbers_test.gd create mode 100755 exercises/practice/rational-numbers/run_tests diff --git a/config.json b/config.json index 9f23868..4d1d223 100644 --- a/config.json +++ b/config.json @@ -373,6 +373,14 @@ "prerequisites": [], "difficulty": 5 }, + { + "slug": "rational-numbers", + "name": "Rational Numbers", + "uuid": "fdc9d068-bc60-423d-be4a-2e4999078cf1", + "practices": [], + "prerequisites": [], + "difficulty": 5 + }, { "slug": "series", "name": "Series", diff --git a/exercises/practice/rational-numbers/.docs/instructions.md b/exercises/practice/rational-numbers/.docs/instructions.md new file mode 100644 index 0000000..f64fc0f --- /dev/null +++ b/exercises/practice/rational-numbers/.docs/instructions.md @@ -0,0 +1,42 @@ +# Instructions + +A rational number is defined as the quotient of two integers `a` and `b`, called the numerator and denominator, respectively, where `b != 0`. + +~~~~exercism/note +Note that mathematically, the denominator can't be zero. +However in many implementations of rational numbers, you will find that the denominator is allowed to be zero with behaviour similar to positive or negative infinity in floating point numbers. +In those cases, the denominator and numerator generally still can't both be zero at once. +~~~~ + +The absolute value `|r|` of the rational number `r = a/b` is equal to `|a|/|b|`. + +The sum of two rational numbers `r₁ = a₁/b₁` and `r₂ = a₂/b₂` is `r₁ + r₂ = a₁/b₁ + a₂/b₂ = (a₁ * b₂ + a₂ * b₁) / (b₁ * b₂)`. + +The difference of two rational numbers `r₁ = a₁/b₁` and `r₂ = a₂/b₂` is `r₁ - r₂ = a₁/b₁ - a₂/b₂ = (a₁ * b₂ - a₂ * b₁) / (b₁ * b₂)`. + +The product (multiplication) of two rational numbers `r₁ = a₁/b₁` and `r₂ = a₂/b₂` is `r₁ * r₂ = (a₁ * a₂) / (b₁ * b₂)`. + +Dividing a rational number `r₁ = a₁/b₁` by another `r₂ = a₂/b₂` is `r₁ / r₂ = (a₁ * b₂) / (a₂ * b₁)` if `a₂` is not zero. + +Exponentiation of a rational number `r = a/b` to a non-negative integer power `n` is `r^n = (a^n)/(b^n)`. + +Exponentiation of a rational number `r = a/b` to a negative integer power `n` is `r^n = (b^m)/(a^m)`, where `m = |n|`. + +Exponentiation of a rational number `r = a/b` to a real (floating-point) number `x` is the quotient `(a^x)/(b^x)`, which is a real number. + +Exponentiation of a real number `x` to a rational number `r = a/b` is `x^(a/b) = root(x^a, b)`, where `root(p, q)` is the `q`th root of `p`. + +Implement the following operations: + +- addition, subtraction, multiplication and division of two rational numbers, +- absolute value, exponentiation of a given rational number to an integer power, exponentiation of a given rational number to a real (floating-point) power, exponentiation of a real number to a rational number. + +Your implementation of rational numbers should always be reduced to lowest terms. +For example, `4/4` should reduce to `1/1`, `30/60` should reduce to `1/2`, `12/8` should reduce to `3/2`, etc. +To reduce a rational number `r = a/b`, divide `a` and `b` by the greatest common divisor (gcd) of `a` and `b`. +So, for example, `gcd(12, 8) = 4`, so `r = 12/8` can be reduced to `(12/4)/(8/4) = 3/2`. +The reduced form of a rational number should be in "standard form" (the denominator should always be a positive integer). +If a denominator with a negative integer is present, multiply both numerator and denominator by `-1` to ensure standard form is reached. +For example, `3/-4` should be reduced to `-3/4` + +Assume that the programming language you are using does not have an implementation of rational numbers. diff --git a/exercises/practice/rational-numbers/.meta/config.json b/exercises/practice/rational-numbers/.meta/config.json new file mode 100644 index 0000000..568dba3 --- /dev/null +++ b/exercises/practice/rational-numbers/.meta/config.json @@ -0,0 +1,21 @@ +{ + "authors": [ + "IsaacG" + ], + "files": { + "solution": [ + "rational_numbers.gd" + ], + "test": [ + "rational_numbers_test.gd", + "run_tests", + "lib/test_runner.gd" + ], + "example": [ + ".meta/example.gd" + ] + }, + "blurb": "Implement rational numbers.", + "source": "Wikipedia", + "source_url": "https://en.wikipedia.org/wiki/Rational_number" +} diff --git a/exercises/practice/rational-numbers/.meta/example.gd b/exercises/practice/rational-numbers/.meta/example.gd new file mode 100644 index 0000000..bd6f66e --- /dev/null +++ b/exercises/practice/rational-numbers/.meta/example.gd @@ -0,0 +1,50 @@ +class Rational: + @export var numer: int = 0 + @export var denom: int = 0 + + func gcd(a, b): + """Greatest Common Denominator, Euclid's algorithm.""" + if b == 0: + return a + return self.gcd(b, a % b) + + func _init(numer, denom): + var div = self.gcd(numer, denom) + self.numer = numer / div + self.denom = denom / div + + func add(other): + # (a1 * b2 + a2 * b1) / (b1 * b2). + var numer = self.numer * other.denom + other.numer * self.denom + var denom = self.denom * other.denom + return Rational.new(numer, denom) + + func sub(other): + var numer = self.numer * other.denom - other.numer * self.denom + var denom = self.denom * other.denom + return Rational.new(numer, denom) + + func mul(other): + return Rational.new(self.numer * other.numer, self.denom * other.denom) + + func div(other): + return Rational.new(self.numer * other.denom, other.numer * self.denom) + + func abs(): + return Rational.new(abs(self.numer), abs(self.denom)) + + func exprational(power): + if power >= 0: + return Rational.new(self.numer ** power, self.denom ** power) + else: + power = abs(power) + return Rational.new(self.denom ** power, self.numer ** power) + + func expreal(base): + # n'th root of x = root(x, n) = power(x, 1/n) + return pow(base**self.numer, 1/float(self.denom)) + + func reduce(): + var div = self.gcd(self.numer, self.denom) + return Rational.new(numer / div, denom / div) + diff --git a/exercises/practice/rational-numbers/.meta/template.j2 b/exercises/practice/rational-numbers/.meta/template.j2 new file mode 100644 index 0000000..904e6c5 --- /dev/null +++ b/exercises/practice/rational-numbers/.meta/template.j2 @@ -0,0 +1,38 @@ +func equals(r1, r2) -> bool: + r1 = r1.reduce() + r2 = r2.reduce() + return r1.numer == r2.numer and r1.denom == r2.denom + +{% for case in cases %} +func test_{{ case.func_name }}(solution_script): +{%- for name, val in case.input.items() %} +{%- if name == "r1" %} + var rational_one = solution_script.Rational.new({{ val[0] }}, {{ val[1] }}) +{%- elif name == "r2" %} + var rational_two = solution_script.Rational.new({{ val[0] }}, {{ val[1] }}) +{%- elif name == "r" %} + var rational = solution_script.Rational.new({{ val[0] }}, {{ val[1] }}) +{%- elif name == "n" or name == "x" %} + var integer = {{ val }} +{%- endif %} +{%- endfor %} +{%- if case.property == "expreal" %} + var want = {{ case.expected }} +{%- else %} + var want = solution_script.Rational.new({{ case.expected[0] }}, {{ case.expected[1] }}) +{%- endif %} +{%- if case.property in ["add", "sub", "mul", "div" ] %} + var got = rational_one.{{ case.property }}(rational_two) +{%- elif case.property in ["abs", "reduce"] %} + var got = rational.{{ case.property }}() +{%- elif case.property in ["exprational", "expreal"] %} + var got = rational.{{ case.property }}(integer) +{%- endif %} +{%- if case.property == "expreal" %} + return [is_equal_approx(got, want), true] +{%- else %} + return [equals(got, want), true] +{%- endif %} + +{% endfor %} + diff --git a/exercises/practice/rational-numbers/.meta/tests.toml b/exercises/practice/rational-numbers/.meta/tests.toml new file mode 100644 index 0000000..ddea714 --- /dev/null +++ b/exercises/practice/rational-numbers/.meta/tests.toml @@ -0,0 +1,139 @@ +# This is an auto-generated file. +# +# Regenerating this file via `configlet sync` will: +# - Recreate every `description` key/value pair +# - Recreate every `reimplements` key/value pair, where they exist in problem-specifications +# - Remove any `include = true` key/value pair (an omitted `include` key implies inclusion) +# - Preserve any other key/value pair +# +# As user-added comments (using the # character) will be removed when this file +# is regenerated, comments can be added via a `comment` key. + +[0ba4d988-044c-4ed5-9215-4d0bb8d0ae9f] +description = "Arithmetic -> Addition -> Add two positive rational numbers" + +[88ebc342-a2ac-4812-a656-7b664f718b6a] +description = "Arithmetic -> Addition -> Add a positive rational number and a negative rational number" + +[92ed09c2-991e-4082-a602-13557080205c] +description = "Arithmetic -> Addition -> Add two negative rational numbers" + +[6e58999e-3350-45fb-a104-aac7f4a9dd11] +description = "Arithmetic -> Addition -> Add a rational number to its additive inverse" + +[47bba350-9db1-4ab9-b412-4a7e1f72a66e] +description = "Arithmetic -> Subtraction -> Subtract two positive rational numbers" + +[93926e2a-3e82-4aee-98a7-fc33fb328e87] +description = "Arithmetic -> Subtraction -> Subtract a positive rational number and a negative rational number" + +[a965ba45-9b26-442b-bdc7-7728e4b8d4cc] +description = "Arithmetic -> Subtraction -> Subtract two negative rational numbers" + +[0df0e003-f68e-4209-8c6e-6a4e76af5058] +description = "Arithmetic -> Subtraction -> Subtract a rational number from itself" + +[34fde77a-75f4-4204-8050-8d3a937958d3] +description = "Arithmetic -> Multiplication -> Multiply two positive rational numbers" + +[6d015cf0-0ea3-41f1-93de-0b8e38e88bae] +description = "Arithmetic -> Multiplication -> Multiply a negative rational number by a positive rational number" + +[d1bf1b55-954e-41b1-8c92-9fc6beeb76fa] +description = "Arithmetic -> Multiplication -> Multiply two negative rational numbers" + +[a9b8f529-9ec7-4c79-a517-19365d779040] +description = "Arithmetic -> Multiplication -> Multiply a rational number by its reciprocal" + +[d89d6429-22fa-4368-ab04-9e01a44d3b48] +description = "Arithmetic -> Multiplication -> Multiply a rational number by 1" + +[0d95c8b9-1482-4ed7-bac9-b8694fa90145] +description = "Arithmetic -> Multiplication -> Multiply a rational number by 0" + +[1de088f4-64be-4e6e-93fd-5997ae7c9798] +description = "Arithmetic -> Division -> Divide two positive rational numbers" + +[7d7983db-652a-4e66-981a-e921fb38d9a9] +description = "Arithmetic -> Division -> Divide a positive rational number by a negative rational number" + +[1b434d1b-5b38-4cee-aaf5-b9495c399e34] +description = "Arithmetic -> Division -> Divide two negative rational numbers" + +[d81c2ebf-3612-45a6-b4e0-f0d47812bd59] +description = "Arithmetic -> Division -> Divide a rational number by 1" + +[5fee0d8e-5955-4324-acbe-54cdca94ddaa] +description = "Absolute value -> Absolute value of a positive rational number" + +[3cb570b6-c36a-4963-a380-c0834321bcaa] +description = "Absolute value -> Absolute value of a positive rational number with negative numerator and denominator" + +[6a05f9a0-1f6b-470b-8ff7-41af81773f25] +description = "Absolute value -> Absolute value of a negative rational number" + +[5d0f2336-3694-464f-8df9-f5852fda99dd] +description = "Absolute value -> Absolute value of a negative rational number with negative denominator" + +[f8e1ed4b-9dca-47fb-a01e-5311457b3118] +description = "Absolute value -> Absolute value of zero" + +[4a8c939f-f958-473b-9f88-6ad0f83bb4c4] +description = "Absolute value -> Absolute value of a rational number is reduced to lowest terms" + +[ea2ad2af-3dab-41e7-bb9f-bd6819668a84] +description = "Exponentiation of a rational number -> Raise a positive rational number to a positive integer power" + +[8168edd2-0af3-45b1-b03f-72c01332e10a] +description = "Exponentiation of a rational number -> Raise a negative rational number to a positive integer power" + +[c291cfae-cfd8-44f5-aa6c-b175c148a492] +description = "Exponentiation of a rational number -> Raise a positive rational number to a negative integer power" + +[45cb3288-4ae4-4465-9ae5-c129de4fac8e] +description = "Exponentiation of a rational number -> Raise a negative rational number to an even negative integer power" + +[2d47f945-ffe1-4916-a399-c2e8c27d7f72] +description = "Exponentiation of a rational number -> Raise a negative rational number to an odd negative integer power" + +[e2f25b1d-e4de-4102-abc3-c2bb7c4591e4] +description = "Exponentiation of a rational number -> Raise zero to an integer power" + +[431cac50-ab8b-4d58-8e73-319d5404b762] +description = "Exponentiation of a rational number -> Raise one to an integer power" + +[7d164739-d68a-4a9c-b99f-dd77ce5d55e6] +description = "Exponentiation of a rational number -> Raise a positive rational number to the power of zero" + +[eb6bd5f5-f880-4bcd-8103-e736cb6e41d1] +description = "Exponentiation of a rational number -> Raise a negative rational number to the power of zero" + +[30b467dd-c158-46f5-9ffb-c106de2fd6fa] +description = "Exponentiation of a real number to a rational number -> Raise a real number to a positive rational number" + +[6e026bcc-be40-4b7b-ae22-eeaafc5a1789] +description = "Exponentiation of a real number to a rational number -> Raise a real number to a negative rational number" + +[9f866da7-e893-407f-8cd2-ee85d496eec5] +description = "Exponentiation of a real number to a rational number -> Raise a real number to a zero rational number" + +[0a63fbde-b59c-4c26-8237-1e0c73354d0a] +description = "Reduction to lowest terms -> Reduce a positive rational number to lowest terms" + +[5ed6f248-ad8d-4d4e-a545-9146c6727f33] +description = "Reduction to lowest terms -> Reduce places the minus sign on the numerator" + +[f87c2a4e-d29c-496e-a193-318c503e4402] +description = "Reduction to lowest terms -> Reduce a negative rational number to lowest terms" + +[3b92ffc0-5b70-4a43-8885-8acee79cdaaf] +description = "Reduction to lowest terms -> Reduce a rational number with a negative denominator to lowest terms" + +[c9dbd2e6-5ac0-4a41-84c1-48b645b4f663] +description = "Reduction to lowest terms -> Reduce zero to lowest terms" + +[297b45ad-2054-4874-84d4-0358dc1b8887] +description = "Reduction to lowest terms -> Reduce an integer to lowest terms" + +[a73a17fe-fe8c-4a1c-a63b-e7579e333d9e] +description = "Reduction to lowest terms -> Reduce one to lowest terms" diff --git a/exercises/practice/rational-numbers/lib/test_runner.gd b/exercises/practice/rational-numbers/lib/test_runner.gd new file mode 100644 index 0000000..9274583 --- /dev/null +++ b/exercises/practice/rational-numbers/lib/test_runner.gd @@ -0,0 +1,467 @@ +class_name TestRunner +extends SceneTree + +class ErrorReader: + """ErrorReader reads from a STDERR file (cmd 2>/file). + + It filters out the less useful Godot output and handles retrieving lines iteratively. + """ + + const EXPECTED_GODOT_ERRORS = [ + "ERROR: Could not create directory: /root/.local\n at: make_dir_recursive (core/io/dir_access.cpp:180)\n", + "ERROR: Error attempting to create data dir: /root/.local/share/godot/app_userdata/[unnamed project].\n at: ensure_user_data_dir (core/os/os.cpp:344)\n", + "ERROR: Could not create editor data directory: /root/.local/share/godot\n at: EditorPaths (editor/editor_paths.cpp:183)\n", + "ERROR: Could not create directory: /root/.config\n at: make_dir_recursive (core/io/dir_access.cpp:180)\n", + "ERROR: Could not create editor config directory: /root/.config/godot\n at: EditorPaths (editor/editor_paths.cpp:198)\n", + "ERROR: Could not create directory: /root/.cache\n at: make_dir_recursive (core/io/dir_access.cpp:180)\n", + "ERROR: Could not create editor cache directory: /root/.cache/godot\n at: EditorPaths (editor/editor_paths.cpp:219)\n", + "ERROR: Can't save resource to empty path. Provide non-empty path or a Resource with non-empty resource_path.\n at: save (core/io/resource_saver.cpp:105)\n", + "ERROR: Error saving editor settings to \n at: save (editor/editor_settings.cpp:1013)\n", + ] + var already_processed_bytes: int = 0 + var filename: String = "" + var color_sequence = RegEx.create_from_string("\\e\\[\\d[\\d;]*m") + + func _init(filename: String) -> void: + self.already_processed_bytes = 0 + self.filename = filename + if not self.filename.is_empty() and ResourceLoader.exists(self.filename): + self.get_error_message() + + func check() -> Error: + if not FileAccess.file_exists(self.filename): + push_error("STDERR must be redirected to '%s' when using '--json'" % self.filename) + return ERR_FILE_NOT_FOUND + if OS.get_stderr_type() != OS.STD_HANDLE_FILE: + push_error("STDERR must be redirected to '%s' when using '--json'" % self.filename) + return ERR_FILE_NOT_FOUND + return OK + + func get_error_message() -> String: + """ + Checks the contents of the STDERR file, where error output from the + currently running test suite should be stored. If there is any relevant output + there, this method will return it. If the file is empty, an empty string is returned. + + This method will remove previously read output from the returned message. It should + be called after executing every test, to ensure that the message contains only output + relevant for the given test. + + NOTE: running a Godot instance without full access to certain folders in the + home directory (`~/.config`, `~/.local`, `~/.cache`) will result in additional + errors. This happens when GDScript test runner is executed in Docker. However, + these errors do not stop the execution of the program, so they can be ignored. + This method filters them out, leaving only the errors caused by the actual tests. + """ + if self.filename.is_empty(): + return "" + + var error_output = FileAccess.get_file_as_string(self.filename) + error_output = error_output.erase(0, self.already_processed_bytes) + self.already_processed_bytes += len(error_output) + + # By default, Godot's error messages are printed with colors. To include the output in the + # `results.json` file, color markers need to be removed first. + error_output = self.color_sequence.sub(error_output, "", true) + + # Filter out the expected error messages + for expected_error in EXPECTED_GODOT_ERRORS: + error_output = error_output.replace(expected_error, "") + + return error_output + + +class ResultWriter: + + var json_file: String = "" + var stderr: ErrorReader = null + + func _init(json_file: String, stderr: ErrorReader) -> void: + self.json_file = json_file + self.stderr = stderr + + func delete_existing() -> Error: + """ + Removes the previous `results.json` file, to ensure that the current test suite will not use it. + """ + if self.json_file.is_empty(): + return OK + if FileAccess.file_exists(self.json_file): + DirAccess.remove_absolute(self.json_file) + var error_message: String = self.stderr.get_error_message() + if not error_message.is_empty(): + push_error(error_message) + return FAILED + return OK + + func write(results: Dictionary) -> Error: + """ + If output_dir_path is empty, outputs friendly-formatted test results to + stdout. + + Otherwise, writes JSON results to results.json in the given path. + """ + if self.json_file.is_empty(): + return print_friendly_results(results) + else: + return write_results_file(results) + + func print_friendly_results(results: Dictionary) -> Error: + """ + Prints rich results to stdout. + """ + var rich_results = [""] + if results.status == "pass": + rich_results.push_back("[color=yellow]Exercise passed! Nicely done![/color]") + else: + rich_results.push_back("Exercise has at least one failed test:") + for test in results.tests: + if test.status == "pass": + rich_results.push_back(" %s [color=yellow]passed ✔[/color]" % test.name) + else: + rich_results.push_back(" %s [color=pink]failed: %s[/color]" % [test.name, test.message]) + print_rich("\n".join(rich_results)) + return OK + + func write_results_file(results: Dictionary) -> Error: + """ + Saves a dictionary as a `results.json` file in the output directory. The file + is indented with 4 spaces, keys are not sorted. + + The `results` dictionary represents the full output of a single exercise's + test suite, according to the Test Runner Interface documentation: + + https://exercism.org/docs/building/tooling/test-runners/interface + + This method automatically adds `'version' = 2` to the results. + """ + # Due to keys not being sorted, `version` should be inserted first + var full_results = {"version": 2} + full_results.merge(results) + + var pretty_results = JSON.stringify(full_results, " ", false) + + var results_json = FileAccess.open(self.json_file, FileAccess.WRITE) + if results_json == null: + var err = FileAccess.get_open_error() + push_error("Failed to write the file: %s (%s)" % [self.json_file, error_string(err)]) + return err + + results_json.store_string(pretty_results + "\n") + return OK + + +class ArgParser: + """ArgParser reads the cmdline and parses inputs and options.""" + + var solution_script_path: String = "" + var test_suite_script_path: String = "" + var run_all: bool = false + var json: bool = false + var json_file: String = "" + + func usage(message: String) -> void: + print("Usage:") + print("test_runner [--all] [--json filename.json] ") + if not message.is_empty(): + print() + print(message) + + func parse_args() -> Error: + """ + Validates the number of args passed to the script and sets values of the following + global variables, based on the given args: + * `solution_script_path` + * `test_suite_script_path` + """ + var args = OS.get_cmdline_user_args() + var positional: Array = [] + + var idx := 0 + while idx < len(args): + var arg = args[idx] + if arg == "--all": + run_all = true + elif arg.begins_with("--json"): + if arg.contains("="): + var parts = arg.split("=", true, 1) + if parts[0] == "--json": + self.json = true + self.json_file = parts[1] + else: + usage("Invalid flag %s" % arg) + return ERR_INVALID_PARAMETER + elif idx + 1 < len(args): + self.json = true + self.json_file = args[idx + 1] + idx += 1 + else: + usage("Flag %s is missing a filename" % arg) + return ERR_INVALID_PARAMETER + elif arg.begins_with("--"): + usage("Invalid flag %s" % arg) + return ERR_INVALID_PARAMETER + else: + positional.append(arg) + idx += 1 + + if len(positional) != 2: + usage("Expecting 2 positional args but got %d" % len(positional)) + return ERR_INVALID_PARAMETER + + var slug = positional[0] + var solution_dir_path = positional[1] + + # Test folders use dashes, but test files use underscores + var gdscript_path = solution_dir_path.path_join(slug.replace("-", "_")) + + self.solution_script_path = gdscript_path + ".gd" + self.test_suite_script_path = gdscript_path + "_test.gd" + + return OK + + func files() -> Array: + return [self.solution_script_path, self.test_suite_script_path] + + +class Runner: + + var test_suite: Object = null + var solution: Object = null + var stderr: ErrorReader = null + var result_writer: ResultWriter = null + + func _init(test_suite: Object, solution: Object, stderr: ErrorReader, result_writer: ResultWriter) -> void: + self.test_suite = test_suite + self.solution = solution + self.stderr = stderr + self.result_writer = result_writer + + func extract_methods() -> Dictionary: + """Extract the source code of a method from a script.""" + var in_func: bool = false + var parts = [] + var methods: Dictionary = {} + var name: String = "" + var func_re: RegEx = RegEx.create_from_string('^func ([^(]+)\\(') + for line in self.test_suite.get_script().source_code.split("\n"): + if not in_func: + var matched = func_re.search(line) + if matched: + in_func = true + name = matched.get_strings()[1] + parts = [] + elif not line.begins_with("\t") and not line.begins_with(" "): + if in_func: + in_func = false + methods[name] = "\n".join(parts) + else: + parts.append(line) + + return methods + + func get_failed_test_message(actual, expected) -> String: + """ + Generates a message for a failed test. If given values are strings, + this method will wrap them in single quotes, to distinguish from + other types, like integers (e.g. '3' vs 3). + + This method should be used if the actual and expected values for a + given test are different, or if they have different types. + """ + var values = [] + + for value in [actual, expected]: + var formatted_value = str(value) + if typeof(value) == TYPE_STRING: + formatted_value = "'{0}'".format([formatted_value]) + values.append(formatted_value) + + return "Expected output was {1}, actual output was {0}.".format(values) + + func run_tests() -> Array: + """ + Runs the given test suite agains the given solution script. Returns an Array + representing the results of each test case, in the same order that they are defined + in the test suite. Each test result is a Dictionary containing 3 values: + + * `name`: the name of the test case + * `status`: 'pass', 'fail', or 'error' + * `message`: null if the test passed, otherwise it contains the details of the failure/error + + This method will look for methods starting with `test_` in the test script, and + then call them with the solution script as the only argument. The `test_` method's + name will be used as the `name` value for each test result. + + If any stderr output was generated during the execution of the `test_` method, + `status` will be set to 'error'. The test will also receive an 'error' status + if calling the `test_` method returned null (which indicates execution issues). + + Otherwise, the `test_` method should return an Array of 2 elements: the actual value, + and the expected value. The values will be compared and if they are the same, the test will + pass. Otherwise, it will fail, with a relevant message being set by this method. + """ + var test_results = [] + var test_codes = self.extract_methods() + + for method in self.test_suite.get_method_list(): + if not method["name"].begins_with("test_"): + continue + var test_method_name = method["name"] + print("-> " + test_method_name) + var test_method = self.test_suite.get(test_method_name) + + var output = test_method.call(self.solution) + var error_message = self.stderr.get_error_message() + + if output == null: + test_results.append({ + "name": test_method_name, + "status": "error", + "message": error_message, + }) + else: + var actual = output[0] + var expected = output[1] + + var passed = ( + typeof(actual) == typeof(expected) and + actual == expected and + error_message.is_empty() + ) + + var status = "error" + if error_message.is_empty(): + status = "pass" if passed else "fail" + error_message = null if passed else self.get_failed_test_message( + actual, expected + ) + + var test_code = test_codes[method["name"]] + # The code would be cleaner if the test suite actually checked the values. + # Instead, the test suite returns an array with two values which are compared (above). + # This `replace()` hides some of that implementation detail by communicating that the + # array elements are being compared for equality. + test_code = test_code.replace("return [", "assert_equal [") + test_code = test_code.replace("\t", " ") + test_code = test_code.dedent().strip_edges() + + test_results.append({ + "name": test_method_name, + "status": status, + "message": error_message, + "test_code": test_code, + }) + + return test_results + + func run(run_all: bool) -> void: + """ + Executes the current suite against the current solution. Stores the results in the + `results.json` file in the output dir. + """ + var test_results = run_tests() + var results = {} + + # Check if any tests were executed + if len(test_results) == 0: + results = {"status": "error", "message": "No tests were executed.", "tests": []} + else: + results = {"status": "pass", "tests": test_results} + + # If any of the tests failed, change the global status to `fail` + for test_result in test_results: + if test_result["status"] != "pass": + results["status"] = "fail" + break + + if not run_all and results["status"] != "pass": + var success_count: int = 0 + for test_result in test_results: + if test_result["status"] == "pass": + success_count += 1 + + var has_failure = false + var filtered_results = [] + for test_result in test_results: + if test_result["status"] == "pass": + filtered_results.append(test_result) + elif not has_failure: + has_failure = true + test_result["message"] = "Passes %d/%d tests. First failure: %s" % [ + success_count, len(test_results), test_result["message"], + ] + filtered_results.append(test_result) + results["tests"] = filtered_results + + self.result_writer.write(results) + + +func load_script(solution_script_path: String) -> Object: + """Load a script. This helper is needed to catch failures without aborting the error handling.""" + return load(solution_script_path).new() + + +func load_solution_script(solution_script_path: String, result_writer: ResultWriter, stderr: ErrorReader) -> Array: + """ + Loads the solution script and saves it in a global variable. In case of any + issues, this method will save the `results.json` file with `error` status. + """ + # Make sure the STDERR file is empty. + stderr.get_error_message() + var solution_script = load_script(solution_script_path) + + if solution_script == null: + var message: String = "The solution file could not be parsed." + # Extract the error message, up until the "Failed to load" line + # which is specific to the test runner, not the user's solution. + var error_message: String = stderr.get_error_message() + if not error_message.is_empty(): + var msg = [] + var stop = 'ERROR: Failed to load script "%s" with error "Parse error".' % solution_script_path + for line in error_message.split("\n"): + if line == stop: + break + msg.append(line) + message += "\n" + "\n".join(msg) + var results = {"status": "error", "message": message, "tests": []} + result_writer.write(results) + return [null, ERR_PARSE_ERROR] + + return [solution_script, OK] + + +func _init(): + # Calling `quit(1)` doesn't stop the program immediately, so a `return` is necessary. + # That's why errors are checked directly in `_init()`, instead of calling `quit(1)` + # in each method. + var args = ArgParser.new() + if args.parse_args() != OK: + quit(1) + return + for file in args.files(): + if not ResourceLoader.exists(file): + push_error("File %s not found." % file) + quit(1) + return + + var stderr = ErrorReader.new("/tmp/stderr") + if args.json and stderr.check() != OK: + quit(1) + return + + var result_writer = ResultWriter.new(args.json_file, stderr) + if result_writer.delete_existing() != OK: + quit(1) + return + + var r = load_solution_script(args.solution_script_path, result_writer, stderr) + if r[1] != OK: + quit(1) + return + var solution = r[0] + + var test_suite = load(args.test_suite_script_path).new() + + Runner.new(test_suite, solution, stderr, result_writer).run(args.run_all) + quit() diff --git a/exercises/practice/rational-numbers/rational_numbers.gd b/exercises/practice/rational-numbers/rational_numbers.gd new file mode 100644 index 0000000..0f519ad --- /dev/null +++ b/exercises/practice/rational-numbers/rational_numbers.gd @@ -0,0 +1,30 @@ +class Rational: + @export var numer: int + @export var denom: int + + func _init(numer, denom): + pass + + func add(other): + pass + + func sub(other): + pass + + func mul(other): + pass + + func div(other): + pass + + func abs(): + pass + + func exprational(power): + pass + + func expreal(base): + pass + + func reduce(): + pass diff --git a/exercises/practice/rational-numbers/rational_numbers_test.gd b/exercises/practice/rational-numbers/rational_numbers_test.gd new file mode 100644 index 0000000..821dc4f --- /dev/null +++ b/exercises/practice/rational-numbers/rational_numbers_test.gd @@ -0,0 +1,335 @@ +func equals(r1, r2) -> bool: + r1 = r1.reduce() + r2 = r2.reduce() + return r1.numer == r2.numer and r1.denom == r2.denom + + +func test_add_two_positive_rational_numbers(solution_script): + var rational_one = solution_script.Rational.new(1, 2) + var rational_two = solution_script.Rational.new(2, 3) + var want = solution_script.Rational.new(7, 6) + var got = rational_one.add(rational_two) + return [equals(got, want), true] + + +func test_add_a_positive_rational_number_and_a_negative_rational_number(solution_script): + var rational_one = solution_script.Rational.new(1, 2) + var rational_two = solution_script.Rational.new(-2, 3) + var want = solution_script.Rational.new(-1, 6) + var got = rational_one.add(rational_two) + return [equals(got, want), true] + + +func test_add_two_negative_rational_numbers(solution_script): + var rational_one = solution_script.Rational.new(-1, 2) + var rational_two = solution_script.Rational.new(-2, 3) + var want = solution_script.Rational.new(-7, 6) + var got = rational_one.add(rational_two) + return [equals(got, want), true] + + +func test_add_a_rational_number_to_its_additive_inverse(solution_script): + var rational_one = solution_script.Rational.new(1, 2) + var rational_two = solution_script.Rational.new(-1, 2) + var want = solution_script.Rational.new(0, 1) + var got = rational_one.add(rational_two) + return [equals(got, want), true] + + +func test_subtract_two_positive_rational_numbers(solution_script): + var rational_one = solution_script.Rational.new(1, 2) + var rational_two = solution_script.Rational.new(2, 3) + var want = solution_script.Rational.new(-1, 6) + var got = rational_one.sub(rational_two) + return [equals(got, want), true] + + +func test_subtract_a_positive_rational_number_and_a_negative_rational_number(solution_script): + var rational_one = solution_script.Rational.new(1, 2) + var rational_two = solution_script.Rational.new(-2, 3) + var want = solution_script.Rational.new(7, 6) + var got = rational_one.sub(rational_two) + return [equals(got, want), true] + + +func test_subtract_two_negative_rational_numbers(solution_script): + var rational_one = solution_script.Rational.new(-1, 2) + var rational_two = solution_script.Rational.new(-2, 3) + var want = solution_script.Rational.new(1, 6) + var got = rational_one.sub(rational_two) + return [equals(got, want), true] + + +func test_subtract_a_rational_number_from_itself(solution_script): + var rational_one = solution_script.Rational.new(1, 2) + var rational_two = solution_script.Rational.new(1, 2) + var want = solution_script.Rational.new(0, 1) + var got = rational_one.sub(rational_two) + return [equals(got, want), true] + + +func test_multiply_two_positive_rational_numbers(solution_script): + var rational_one = solution_script.Rational.new(1, 2) + var rational_two = solution_script.Rational.new(2, 3) + var want = solution_script.Rational.new(1, 3) + var got = rational_one.mul(rational_two) + return [equals(got, want), true] + + +func test_multiply_a_negative_rational_number_by_a_positive_rational_number(solution_script): + var rational_one = solution_script.Rational.new(-1, 2) + var rational_two = solution_script.Rational.new(2, 3) + var want = solution_script.Rational.new(-1, 3) + var got = rational_one.mul(rational_two) + return [equals(got, want), true] + + +func test_multiply_two_negative_rational_numbers(solution_script): + var rational_one = solution_script.Rational.new(-1, 2) + var rational_two = solution_script.Rational.new(-2, 3) + var want = solution_script.Rational.new(1, 3) + var got = rational_one.mul(rational_two) + return [equals(got, want), true] + + +func test_multiply_a_rational_number_by_its_reciprocal(solution_script): + var rational_one = solution_script.Rational.new(1, 2) + var rational_two = solution_script.Rational.new(2, 1) + var want = solution_script.Rational.new(1, 1) + var got = rational_one.mul(rational_two) + return [equals(got, want), true] + + +func test_multiply_a_rational_number_by_1(solution_script): + var rational_one = solution_script.Rational.new(1, 2) + var rational_two = solution_script.Rational.new(1, 1) + var want = solution_script.Rational.new(1, 2) + var got = rational_one.mul(rational_two) + return [equals(got, want), true] + + +func test_multiply_a_rational_number_by_0(solution_script): + var rational_one = solution_script.Rational.new(1, 2) + var rational_two = solution_script.Rational.new(0, 1) + var want = solution_script.Rational.new(0, 1) + var got = rational_one.mul(rational_two) + return [equals(got, want), true] + + +func test_divide_two_positive_rational_numbers(solution_script): + var rational_one = solution_script.Rational.new(1, 2) + var rational_two = solution_script.Rational.new(2, 3) + var want = solution_script.Rational.new(3, 4) + var got = rational_one.div(rational_two) + return [equals(got, want), true] + + +func test_divide_a_positive_rational_number_by_a_negative_rational_number(solution_script): + var rational_one = solution_script.Rational.new(1, 2) + var rational_two = solution_script.Rational.new(-2, 3) + var want = solution_script.Rational.new(-3, 4) + var got = rational_one.div(rational_two) + return [equals(got, want), true] + + +func test_divide_two_negative_rational_numbers(solution_script): + var rational_one = solution_script.Rational.new(-1, 2) + var rational_two = solution_script.Rational.new(-2, 3) + var want = solution_script.Rational.new(3, 4) + var got = rational_one.div(rational_two) + return [equals(got, want), true] + + +func test_divide_a_rational_number_by_1(solution_script): + var rational_one = solution_script.Rational.new(1, 2) + var rational_two = solution_script.Rational.new(1, 1) + var want = solution_script.Rational.new(1, 2) + var got = rational_one.div(rational_two) + return [equals(got, want), true] + + +func test_absolute_value_of_a_positive_rational_number(solution_script): + var rational = solution_script.Rational.new(1, 2) + var want = solution_script.Rational.new(1, 2) + var got = rational.abs() + return [equals(got, want), true] + + +func test_absolute_value_of_a_positive_rational_number_with_negative_numerator_and_denominator(solution_script): + var rational = solution_script.Rational.new(-1, -2) + var want = solution_script.Rational.new(1, 2) + var got = rational.abs() + return [equals(got, want), true] + + +func test_absolute_value_of_a_negative_rational_number(solution_script): + var rational = solution_script.Rational.new(-1, 2) + var want = solution_script.Rational.new(1, 2) + var got = rational.abs() + return [equals(got, want), true] + + +func test_absolute_value_of_a_negative_rational_number_with_negative_denominator(solution_script): + var rational = solution_script.Rational.new(1, -2) + var want = solution_script.Rational.new(1, 2) + var got = rational.abs() + return [equals(got, want), true] + + +func test_absolute_value_of_zero(solution_script): + var rational = solution_script.Rational.new(0, 1) + var want = solution_script.Rational.new(0, 1) + var got = rational.abs() + return [equals(got, want), true] + + +func test_absolute_value_of_a_rational_number_is_reduced_to_lowest_terms(solution_script): + var rational = solution_script.Rational.new(2, 4) + var want = solution_script.Rational.new(1, 2) + var got = rational.abs() + return [equals(got, want), true] + + +func test_raise_a_positive_rational_number_to_a_positive_integer_power(solution_script): + var rational = solution_script.Rational.new(1, 2) + var integer = 3 + var want = solution_script.Rational.new(1, 8) + var got = rational.exprational(integer) + return [equals(got, want), true] + + +func test_raise_a_negative_rational_number_to_a_positive_integer_power(solution_script): + var rational = solution_script.Rational.new(-1, 2) + var integer = 3 + var want = solution_script.Rational.new(-1, 8) + var got = rational.exprational(integer) + return [equals(got, want), true] + + +func test_raise_a_positive_rational_number_to_a_negative_integer_power(solution_script): + var rational = solution_script.Rational.new(3, 5) + var integer = -2 + var want = solution_script.Rational.new(25, 9) + var got = rational.exprational(integer) + return [equals(got, want), true] + + +func test_raise_a_negative_rational_number_to_an_even_negative_integer_power(solution_script): + var rational = solution_script.Rational.new(-3, 5) + var integer = -2 + var want = solution_script.Rational.new(25, 9) + var got = rational.exprational(integer) + return [equals(got, want), true] + + +func test_raise_a_negative_rational_number_to_an_odd_negative_integer_power(solution_script): + var rational = solution_script.Rational.new(-3, 5) + var integer = -3 + var want = solution_script.Rational.new(-125, 27) + var got = rational.exprational(integer) + return [equals(got, want), true] + + +func test_raise_zero_to_an_integer_power(solution_script): + var rational = solution_script.Rational.new(0, 1) + var integer = 5 + var want = solution_script.Rational.new(0, 1) + var got = rational.exprational(integer) + return [equals(got, want), true] + + +func test_raise_one_to_an_integer_power(solution_script): + var rational = solution_script.Rational.new(1, 1) + var integer = 4 + var want = solution_script.Rational.new(1, 1) + var got = rational.exprational(integer) + return [equals(got, want), true] + + +func test_raise_a_positive_rational_number_to_the_power_of_zero(solution_script): + var rational = solution_script.Rational.new(1, 2) + var integer = 0 + var want = solution_script.Rational.new(1, 1) + var got = rational.exprational(integer) + return [equals(got, want), true] + + +func test_raise_a_negative_rational_number_to_the_power_of_zero(solution_script): + var rational = solution_script.Rational.new(-1, 2) + var integer = 0 + var want = solution_script.Rational.new(1, 1) + var got = rational.exprational(integer) + return [equals(got, want), true] + + +func test_raise_a_real_number_to_a_positive_rational_number(solution_script): + var integer = 8 + var rational = solution_script.Rational.new(4, 3) + var want = 16.0 + var got = rational.expreal(integer) + return [is_equal_approx(got, want), true] + + +func test_raise_a_real_number_to_a_negative_rational_number(solution_script): + var integer = 9 + var rational = solution_script.Rational.new(-1, 2) + var want = 0.3333333333333333 + var got = rational.expreal(integer) + return [is_equal_approx(got, want), true] + + +func test_raise_a_real_number_to_a_zero_rational_number(solution_script): + var integer = 2 + var rational = solution_script.Rational.new(0, 1) + var want = 1.0 + var got = rational.expreal(integer) + return [is_equal_approx(got, want), true] + + +func test_reduce_a_positive_rational_number_to_lowest_terms(solution_script): + var rational = solution_script.Rational.new(2, 4) + var want = solution_script.Rational.new(1, 2) + var got = rational.reduce() + return [equals(got, want), true] + + +func test_reduce_places_the_minus_sign_on_the_numerator(solution_script): + var rational = solution_script.Rational.new(3, -4) + var want = solution_script.Rational.new(-3, 4) + var got = rational.reduce() + return [equals(got, want), true] + + +func test_reduce_a_negative_rational_number_to_lowest_terms(solution_script): + var rational = solution_script.Rational.new(-4, 6) + var want = solution_script.Rational.new(-2, 3) + var got = rational.reduce() + return [equals(got, want), true] + + +func test_reduce_a_rational_number_with_a_negative_denominator_to_lowest_terms(solution_script): + var rational = solution_script.Rational.new(3, -9) + var want = solution_script.Rational.new(-1, 3) + var got = rational.reduce() + return [equals(got, want), true] + + +func test_reduce_zero_to_lowest_terms(solution_script): + var rational = solution_script.Rational.new(0, 6) + var want = solution_script.Rational.new(0, 1) + var got = rational.reduce() + return [equals(got, want), true] + + +func test_reduce_an_integer_to_lowest_terms(solution_script): + var rational = solution_script.Rational.new(-14, 7) + var want = solution_script.Rational.new(-2, 1) + var got = rational.reduce() + return [equals(got, want), true] + + +func test_reduce_one_to_lowest_terms(solution_script): + var rational = solution_script.Rational.new(13, 13) + var want = solution_script.Rational.new(1, 1) + var got = rational.reduce() + return [equals(got, want), true] diff --git a/exercises/practice/rational-numbers/run_tests b/exercises/practice/rational-numbers/run_tests new file mode 100755 index 0000000..ed18a90 --- /dev/null +++ b/exercises/practice/rational-numbers/run_tests @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +slug=${PWD##*/} +[[ -f "${slug//-/_}.gd" ]] || die "Error! solution file is missing" +[[ -f "${slug//-/_}_test.gd" ]] || die "Error! test file is missing" +exec godot --headless --script ./lib/test_runner.gd -- "${slug}" "${PWD}"